深不可测的JavaScript
JS的基础
js的8种基本数据类型:
***Undefined***,***Null***,***Boolean***,***Number***,***String***,***Object***,***BigInt***,***Symbol***
其中 Symbol 和 BigInt 是 ES6 新增的数据类型,可能会被单独问:
- ***Symbol*** 代表独一无二的值,最大的用法是用来定义对象的唯一属性名。
- ***BigInt*** 可以表示任意大小的整数。
数据类型的判断:
(1) typeof:能判断所有值类型,函数。不可对 null、对象、数组进行精确判断,因为都返回 object 。
1
2
3
4
5
6
7
8
9
10
11
12
console.log(typeof undefined); // undefined
console.log(typeof 2); // number
console.log(typeof true); // boolean
console.log(typeof "str"); // string
console.log(typeof Symbol("foo")); // symbol
console.log(typeof 2172141653n); // bigint
console.log(typeof function () {}); // function
// 不能判别
console.log(typeof []); // object
console.log(typeof {}); // object
console.log(typeof null); // object
(2) instanceof:能判断对象类型,不能判断基本数据类型,其内部运行机制是判断在其原型链中能否找到该类型的原型。
1
2
3
4
5
6
7
class People {}
class Student extends People {}
const vortesnail = new Student();
console.log(vortesnail instanceof People); // true
console.log(vortesnail instanceof Student); // true
其实现就是顺着原型链去找,如果能找到对应的 Xxxxx.prototype 即为 true 。比如这里的 vortesnail 作为实例,顺着原型链能找到 Student.prototype 及 People.prototype ,所以都为 true 。
(3) **Object.prototype.toString.call()**:所有原始数据类型都是能判断的,还有 Error 对象,Date 对象等。
1
2
3
4
5
6
7
8
9Object.prototype.toString.call(2); // "[object Number]"
Object.prototype.toString.call(""); // "[object String]"
Object.prototype.toString.call(true); // "[object Boolean]"
Object.prototype.toString.call(undefined); // "[object Undefined]"
Object.prototype.toString.call(null); // "[object Null]"
Object.prototype.toString.call(Math); // "[object Math]"
Object.prototype.toString.call({}); // "[object Object]"
Object.prototype.toString.call([]); // "[object Array]"
Object.prototype.toString.call(function () {}); // "[object Function]"
如何判断变量是否为数组?
1
2
3
4
Array.isArray(arr); // true
arr.__proto__ === Array.prototype; // true
arr instanceof Array; // true
Object.prototype.toString.call(arr); // "[object Array]"
原型和原型链
关于原型和原型链最开始也是懵懵懂懂,直接去看下面这两篇文章吧,相信你会对原型和原型链有更深的理解。
+ JavaScript 深入之从原型到原型链
+ 轻松理解JS 原型原型链作用域和作用域链
- 作用域:规定了如何查找变量,也就是确定当前执行代码对变量的访问权限。换句话说,作用域决定了代码区块中变量和其他资源的可见性。(全局作用域、函数作用域、块级作用域)
- 作用域链:从当前作用域开始一层层往上找某个变量,如果找到全局作用域还没找到,就放弃寻找 。这种层级关系就是作用域链。(由多个执行上下文的变量对象构成的链表就叫做作用域链,学习下面的内容之后再考虑这句话)
js 采用的是静态作用域,所以函数的作用域在函数定义时就确定了。
推荐阅读下面两篇文章:JavaScript深入之词法作用域和动态作用域,深入理解JavaScript作用域和作用域链执行上下文
古人云书读百遍,其义自见,所以直接gun去看掘金大佬的关于执行上下文的几篇文章吧:
+ JavaScript深入之执行上下文栈
+ JavaScript深入之变量对象
+ JavaScript深入之作用域链
+ JavaScript深入之执行上下文
总结一下:当 JavaScript 代码执行一段可执行代码时,会创建对应的执行上下文。对于每个执行上下文,都有三个重要属性:
* 变量对象(Variable object,VO);
* 作用域链(Scope chain);
* this。(关于 this 指向问题,也是一个重要的问题可以看一下JS 中 this 指向问题这篇文章,你就有更深理解啦。闭包
对闭包的定义:
MDN中文的定义:在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。可以在一个内层函数中访问到其外层函数的作用域。
也可以这样说:闭包是指那些能够访问自由变量的函数。 自由变量是指在函数中使用的,但既不是函数参数也不是函数的局部变量的变量。 闭包 = 函数 + 函数能够访问的自由变量。
闭包的实质:
在经过前文“执行上下文”的学习,再来阅读这篇文章:JavaScript 深入之闭包
总结:
在某个内部函数的执行上下文创建时,会将父级函数的活动对象加到内部函数的 [[scope]] 中,形成作用域链,所以即使父级函数的执行上下文销毁(即执行上下文栈弹出父级函数的执行上下文),但是因为其活动对象还是实际存储在内存中可被内部函数访问到的,从而实现了闭包。
闭包的应用:
(1) 函数作为参数被传递:
1
2
3
4
5
6
7
8
9
10
11function print(fn) {
const a = 200;
fn();
}
const a = 100;
function fn() {
console.log(a);
}
print(fn); // 100
(2) 函数作为返回值被返回:
1
2
3
4
5
6
7
8
9
10
11function create() {
const a = 100;
return function () {
console.log(a);
};
}
const fn = create();
const a = 200;
fn(); // 100
(3) 自由变量的查找,是在函数定义的地方,向上级作用域查找。不是在执行的地方。
应用实例:比如缓存工具,隐藏数据,只提供 API 。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16function createCache() {
const data = {}; // 闭包中被隐藏的数据,不被外界访问
return {
set: function (key, val) {
data[key] = val;
},
get: function (key) {
return data[key];
},
};
}
const c = createCache();
c.set("a", 100);
console.log(c.get("a")); // 100
call:
call() 方法在使用一个指定的 this 值和若干个指定的参数值的前提下调用某个函数或方法。
先直接看下面这个例子吧:
1
2
3
4
5
6
7
8
9var obj = {
value: "vortesnail",
};
function fn() {
console.log(this.value);
}
fn.call(obj); // vortesnail
通过 call 方法我们做到了以下两点:
- call 改变了 this 的指向,指向到 obj 。
- fn 函数执行了。
如何来写call?
先考虑改造 obj 。
1
2
3
4
5
6
7
8var obj = {
value: "vortesnail",
fn: function () {
console.log(this.value);
},
};
obj.fn(); // vortesnail
这时候 this 就指向了 obj ,但是这样做我们手动给 obj 增加了一个 fn 属性,这是不可行的,于是再使用对象属性的删除方法(delete):
1
2
3obj.fn = fn;
obj.fn();
delete obj.fn;
最后我们综合一下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26Function.prototype.myCall = function (context) {
// 判断调用对象
if (typeof this !== "function") {
throw new Error("Type error");
}
// 首先获取参数
let args = [...arguments].slice(1);
let result = null;
// 判断 context 是否传入,如果没有传就设置为 window
context = context || window;
// 将被调用的方法设置为 context 的属性
// this 即为我们要调用的方法
context.fn = this;
// 执行要被调用的方法
result = context.fn(...args);
// 删除手动增加的属性方法
delete context.fn;
// 将执行结果返回
return result;
};
apply:
apply与call没有任何区别,除了传参方式:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21Function.prototype.myApply = function (context) {
if (typeof this !== "function") {
throw new Error("Type error");
}
let result = null;
context = context || window;
// 与上面代码相比,我们使用 Symbol 来保证属性唯一
// 也就是保证不会重写用户自己原来定义在 context 中的同名属性
const fnSymbol = Symbol();
context[fnSymbol] = this;
// 执行要被调用的方法
if (arguments[1]) {
result = context[fnSymbol](...arguments[1]);
} else {
result = context[fnSymbol]();
}
delete context[fnSymbol];
return result;
};
bind:
bind() 方法创建一个新的函数,在 bind() 被调用时,这个新函数的 this 被指定为 bind() 的第一个参数,而其余参数将作为新函数的参数,供调用时使用。
相关文章:解析 bind 原理,并手写 bind 实现
bind 方法与 call / apply 最大的不同就是前者返回一个绑定上下文的函数,而后两者是直接执行了函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Function.prototype.myBind = function (context) {
// 判断调用对象是否为函数
if (typeof this !== "function") {
throw new Error("Type error");
}
// 获取参数
const args = [...arguments].slice(1),
const fn = this;
return function Fn() {
return fn.apply(
this instanceof Fn ? this : context,
// 当前的这个 arguments 是指 Fn 的参数
args.concat(...arguments)
);
};
};
new 关键字?
我们需要知道执行new关键字的时候发生了什么:
- 首先创一个新的空对象。
- 根据原型链,设置空对象的 proto 为构造函数的 prototype 。
- 构造函数的 this 指向这个对象,执行构造函数的代码(为这个新对象添加属性)。
- 判断函数的返回值类型,如果是引用类型,就返回这个引用类型的对象。1
2
3
4
5
6function myNew(context) {
const obj = new Object();
obj.__proto__ = context.prototype;
const res = context.apply(obj, [...arguments].slice(1));
return typeof res === "object" ? res : obj;
}Promise & async/await & eventloop
eventloop它的执行顺序:
- 一开始整个脚本作为一个宏任务执行
- 执行过程中同步代码直接执行,宏任务进入宏任务队列,微任务进入微任务队列
- 当前宏任务执行完出队,检查微任务列表,有则依次执行,直到全部执行完
- 执行浏览器UI线程的渲染工作
- 检查是否有Web Worker任务,有则执行
- 执行完本轮的宏任务,回到2,依此循环,直到宏任务和微任务队列都为空
宏任务包括:script 、setTimeout、setInterval 、setImmediate 、I/O 、UI rendering。
微任务包括:MutationObserver、Promise.then()或catch()、Promise为基础开发的其它技术,比如fetch API、V8的垃圾回收过程、Node独有的process.nextTick
注:在所有任务开始的时候,由于宏任务中包括了script,所以浏览器会先执行一个宏任务,在这个过程中你看到的延迟任务(例如setTimeout)将被放到下一轮宏任务中来执行。
关于async/await:
async 函数: 一个语法糖 是异步操作更简单,返回值是一个 promise 对象;
- return 的值是 promise resolved 时候的 value
- Throw 的值是 Promise rejected 时候的 reason async 函数的返回值是一个 promise
1
2
3
4
5
6
7
8
9
10
11
12
13
14async function test() {
return true
}
const p = test()
console.log(p) // 打印出一个promise,状态是resolved,value是true
// Promise {<fulfilled>: true}
// [[Prototype]]: Promise
// [[PromiseState]]: "fulfilled"
// [[PromiseResult]]: true
p.then((data) => {
console.log(data) // true
})1
2
3
4
5
6
7
8async function test() {
throw new Error('error')
}
const p = test()
console.log(p) // 打印出一个promise,状态是rejected,value是error
p.then((data) => {
console.log(data) //打印出的promise的reason 是error
})
await 函数
- 只能出现在 async 函数内部或最外层
- 等待一个 promise 对象的值
- await 的 promise 的状态为 rejected,后续执行中断
await 为等待 promise 的状态是 resolved 的情况
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17async function async1() {
console.log('async1 start')
await async2() // await为等待promise的状态,然后把值拿到
console.log('async1 end')
}
async function async2() {
return Promsie.resolve().then(_ => {
console.log('async2 promise')
})
}
async1()
/*
打印结果
async1 start
async2 promise
async1 end
*/
await 为等待 promise 的状态是 rejected 的情况
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27async function f() {
await Promise.reject('error')
//后续代码不会执行
console.log(1)
await 100
}
// 解决方案1
async function f() {
await Promise.reject('error').catch(err => {
// 异常处理
})
console.log(1)
await 100
}
// 解决方案2
async function f() {
try {
await Promise.reject('error')
} catch (e) {
// 异常处理
} finally {
}
console.log(1)
await 100
}
关于Promise:
实现一个 Promise.all:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Promise.all = function (promises) {
return new Promise((resolve, reject) => {
// 参数可以不是数组,但必须具有 Iterator 接口
if (typeof promises[Symbol.iterator] !== "function") {
reject("Type error");
}
if (promises.length === 0) {
resolve([]);
} else {
const res = [];
let count = 0;
const len = promises.length;
for (let i = 0; i < len; i++) {
//考虑到 promises[i] 可能是 thenable 对象也可能是普通值
Promise.resolve(promises[i])
.then((data) => {
res[i] = data;
if (++count === len) {
resolve(res);
}
})
.catch((err) => {
reject(err);
});
}
}
});
};
直接上阮一峰的这篇文章吧:ECMAScript 6 入门-Promise 对象
手写深拷贝
何为深/浅拷贝?
- 浅:创建一个新对象,这个对象有着原始对象属性值的一份精确拷贝。如果属性是基本类型,拷贝的就是基本类型的值,如果属性是引用类型,拷贝的就是内存地址 ,所以如果其中一个对象改变了这个地址,就会影响到另一个对象。
- 将一个对象从内存中完整的拷贝一份出来,从堆内存中开辟一个新的区域存放新对象,且修改新对象不会影响原对象
开撕吧^_^^_^^_^^_^
先来一个基础版本的浅拷贝吧:
1
2
3
4
5
6
7function clone(target) {
let cloneTarget = {};
for (const key in target) {
cloneTarget[key] = target[key];
}
return cloneTarget;
};
如果是深拷贝的话,考虑到我们要拷贝的对象是不知道有多少层深度的,我们可以用递归来解决问题,稍微改写上面的代码:
- 如果是原始类型,无需继续拷贝,直接返回
- 如果是引用类型,创建一个新的对象,遍历需要克隆的对象,将需要克隆对象的属性执行深拷贝后依次添加到新对象上。
1
2
3
4
5
6
7
8
9
10
11function clone(target) {
if (typeof target === 'object') {
let cloneTarget = {};
for (const key in target) {
cloneTarget[key] = clone(target[key]);
}
return cloneTarget;
} else {
return target;
}
};
兼容数组之后的深拷贝:
1
2
3
4
5
6
7
8
9
10
11module.exports = function clone(target) {
if (typeof target === 'object') {
let cloneTarget = Array.isArray(target) ? [] : {};
for (const key in target) {
cloneTarget[key] = clone(target[key]);
}
return cloneTarget;
} else {
return target;
}
};
解决循环引用问题,我们可以额外开辟一个存储空间,来存储当前对象和拷贝对象的对应关系,当需要拷贝当前对象时,先去存储空间中找,有没有拷贝过这个对象,如果有的话直接返回,如果没有的话继续拷贝,这样就巧妙化解的循环引用的问题。
这个存储空间,需要可以存储key-value形式的数据,且key可以是一个引用类型,我们可以选择Map这种数据结构:
- 检查map中有无克隆过的对象, 有 - 直接返回,没有 - 将当前对象作为key,克隆对象作为value进行存储
- 继续克隆
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function clone(target, map = new Map()) {
if (typeof target === 'object') {
let cloneTarget = Array.isArray(target) ? [] : {};
if (map.get(target)) {
return map.get(target);
}
map.set(target, cloneTarget);
for (const key in target) {
cloneTarget[key] = clone(target[key], map);
}
return cloneTarget;
} else {
return target;
}
};
深拷贝还有诸多优化可以看一下关于手写深拷贝的大佬文章:如何写出一个惊艳面试官的深拷贝?
数组去重
- 利用set关键字(ES6)
1
2
3function unique(arr) {
return [...new Set(arr)];
} - 利用filter方法(ES5)
1
2
3
4
5function unique(arr) {
return arr.filter((item, index, array) => {
return array.indexOf(item) === index;
});
}
数组扁平化
1
2
3
4
5
6
7
8
9
function flat(arr, depth = 1) {
if (depth > 0) {
// 以下代码还可以简化,不过为了可读性,还是....
return arr.reduce((pre, cur) => {
return pre.concat(Array.isArray(cur) ? flat(cur, depth - 1) : cur);
}, []);
}
return arr.slice();
}
手写 reduce
先不考虑第二个参数初始值:
1
2
3
4
5
6
7
8Array.prototype.reduce = function (cb) {
const arr = this; //this就是调用reduce方法的数组
let total = arr[0]; // 默认为数组的第一项
for (let i = 1; i < arr.length; i++) {
total = cb(total, arr[i], i, arr);
}
return total;
};
考虑上初始值:
1
2
3
4
5
6
7
8
9Array.prototype.reduce = function (cb, initialValue) {
const arr = this;
let total = initialValue || arr[0];
// 有初始值的话从0遍历,否则从1遍历
for (let i = initialValue ? 0 : 1; i < arr.length; i++) {
total = cb(total, arr[i], i, arr);
}
return total;
};
防抖和节流
防抖就是指的是某个函数在某段时间内,无论触发了多少次回调,都只执行最后一次;(回城,被打断了就要重新来)
1
2
3
4
5
6
7
8
9
10
11
12
13function debounce(fn, delay = 200) {
let timer = 0
return function() {
// 如果这个函数已经被触发了
if(timer){
clearTimeout(timer)
}
timer = setTimeout(() => {
fn.apply(this, arguments); // 透传 this和参数
timer = 0
},delay)
}
}
节流就是是某个函数在一定时间间隔内(例如 3 秒)只执行一次,在这 3 秒内 无视后来产生的函数调用请求,也不会延长时间间隔。(技能CD,CD没有冷却好,就一直用不了技能)
1
2
3
4
5
6
7
8
9
10
11
12
13// 节流函数
function throttle(fn, delay = 200) {
let timer = 0
return function () {
if(timer){
return
}
timer = setTimeout(() =>{
fn.apply(this, arguments); // 透传 this和参数
timer = 0
},delay)
}
}
应用场景:滚动加载,下拉刷新,提交按钮点击,轮播图切换