前端面经

前端知识点

参考的面经地址

JavaScript

原始值和引用值类型及区别

参考文章
你真的掌握变量和类型了吗
题解
前置知识(内存空间):
JS中任何变量都需要内存开辟一个空间存储.
内存空间: 栈内存 & 堆内存.
栈内存:

  1. 存储空间大小固定 (意味着存储的值不可改变)
  2. 空间较小
  3. 可直接操作保存的变量,运行效率高
  4. 由系统自动分配存储空间

堆内存:

  1. 存储大小不固定,可动态调整
  2. 空间较大, 运行效率低
  3. 无法直接操作内部存储, 通过引用地址访问
  4. 通过代码分配内存空间

从内存理解原始值与引用值的区别:
原始值(Null, Undefined, Boolean, Number, String, Symbol):
存储于栈内存, 值本身不可被改变. (变量定义时,栈已经完成内存空间分配,且栈内存空间大小固定)
str += 6 等操作不违背原始值 “不可变性” 的特点. 该操作实际是在栈中新开辟了内存空间存储新值, 并将原变量str指向该内存空间, 原数据内存空间若不存在引用则后续会被JS垃圾回收机制释放.

引用值(Object, Array, Function, Date,…):
值本身存储于堆内存, 在栈内存中存储了一个指向该值的定长地址. 引用值可被改变, 例如数组等,存在许多可改变其自身的方法(pop; push; …)

从操作角度对比原始值与引用值的区别:

  1. 复制:
    原始值复制, 栈内存中开辟新的空间存储拷贝的变量值, 由于开辟了新的空间, 因此拷贝值与被拷贝值虽然值相同, 但指向的内存空间互不相同, 两者的任何操作都是独立的, 互不影响.
    引用值复制, 复制的是其栈内存中的地址, 栈内存新开辟一个空间存储拷贝的地址. 拷贝值与被拷贝值的地址均指向堆内存中同一对象, 因此两者操作的实际上是同一对象, 互相影响.

  2. 比较:
    两个原始值之间比较, 直接比较它们值是否相等, 若相等则返回 true;
    两个引用值之间比较, 比较它们存储在栈内存中的地址, 由于两者地址不同, 即使它们在堆内存中的存储对象具有相同属性值, 比较值仍返回 false;

  3. 值传递: JS所有函数的参数都是按值传递!
    原始值与引用值作为函数参数传递, 本质上传递的是变量拷贝的副本, 在函数内操作的是拷贝值而非变量背身. 两者区别在于:
    原始值与其复制的局部变量, 两者内存空间不同, 修改局部变量不影响外部原值;
    引用值复制的是指向堆内存的地址, 函数内修改局部变量会对外部变量造成影响;

判断数据类型的方式 typeof; instanceof; Object.prototype.toString.call(); constructor

参考文章
你真的掌握变量和类型了吗
题解
typeof:
使用场景: 可准确判断变量的原始类型, 例如 typeof 123 === 'number'; 还可以判断函数类型, 例如 typeof function(){} === 'function'
不适用于判断引用类型, 例如 typeof [] === 'object'; typeof {} === 'object'; typeof new Date() === 'object'

instanceof:
使用场景: 判断引用类型对象的具体类型, 例如 [] instanceof Array === true
本质: 通过原型链判断, 上例中主要判断 Array.prototype(原型) 是否在 [] 的原型链上, 若是则返回 true
缺点:

  1. 由于 instanceof 是通过原型链判断的, 而 Obejct.property 是所有原型链的终点, 因此 [] instanceof Object === true 等总是成立, 导致在检测数据类型时不会很准确. (另一种解释是被检测目标可能更改过原型指向,导致检测不准确)
  2. instanceof 不能检测基本数据类型

Object.prototype.toString.call():
基本可以解决所有内置对象类型的判断问题.
所有引用类型均有 toString() 方法, toString() 方法默认被所有 Object 对象继承, 其包括了 Array; Date 等常见引用类型, 也包括了 String, Number 等特殊引用的包装类型. toString() 方法若继承后没有被覆盖(重写), 则返回 "[object type]", 其中 type 就是被检测对象的类型
但是,该场景需要在toString方法未被重写的条件下实现, 而大多引用类型, 例如 Array; Date 等, 都对toString方法进行了重写. 因此, 我们在用toString进行类型判断时, 需直接调用 Object.property 原型的 toString 方法, 并用 call 改变 this 指向被检测目标.
缺点: 无法用于自定义的构造函数

constructor:
xxx.constructor === Animal
利用了对象的constructor指向构造函数的原理; 但constructor属性会被随意修改, 且容易混淆指向, 不推荐.

类数组与数组的区别与转换

参考文章
类数组与数组
JS 原生面经从初级到高级 – 3.8节
JavaScript 类数组对象与 arguments
题解
类数组对象定义: 拥有 length 属性且可通过索引属性访问元素的对象

1
2
3
4
5
const arrLike = {
0: 'aaa',
1: 'bbb',
length: 2,
}

类数组对象与数组的相同点: 访问/赋值/获取长度等操作与数组一致
区别: 类数组对象不能直接使用数组方法. 因此类数组对象比数组局限性大, 通常需要将类数组转化为数组.
区分类数组与数组的方法:

  1. instanceof
  2. constructor
  3. toString()
  4. ES提供的 isArray()

类数组到数组的转化:
call/apply实现: 改变Array原型slice方法的this指向, 将arguments复制为新的数组; (splice方法也可以,但splice方法是在原类数组基础上做的改变,不是创建新数组)
Array.from: 可根据类数组或可迭代对象创建出新数组
… Spread语法: 将类数组扩展为字符串后,再重新定义为数组

  1. Array.prototype.slice.call(arguments)
  2. Array.prototype.slice.apply(arguments)
  3. Array.from(arguments)
  4. [...arguments]

除了转化外, 类数组对象还可以通过方法借用调用数组方法, 例如 Array.prototype.push.call(arguments, 'xxx')

数组的常见API

参考文章
js 数组详细操作方法及解析合集
题解
数组原型提供了许多方法(API), 可大致分为三类: 改变原数组; 不改变原数组; 数组遍历
API的具体参数及作用不在本文详述, 具体可通过参考文章了解.
改变原数组的API(9):

  1. array.splice(index, num, item1,...)
  2. array.sort(func(a,b){...})
  3. array.pop()
  4. array.push(item1,...)
  5. array.shift()
  6. array.unshift(item1,...)
  7. array.reverse()
  8. array.copyWithin(target, start, end)
  9. array.fill(num, start, end)

不改变原数组的API(8):

  1. array.slice(begin, end)
  2. array.join(str)
  3. array.toLocalString()
  4. array.toString()
  5. array.concat(array1,...)
  6. array.indexOf(searchElement, fromIndex)
  7. array.lastIndexOf(searchElement, fromIndex)
  8. array.includes(searchElement, fromIndex)

遍历数组(12):
遵循原则: 尽量不要在遍历的时候,修改后面要遍历的值; 尽量不要在遍历的时候修改数组的长度(删除/添加)

  1. array.forEach(function(currentValue, index, arr), thisValue)
  2. array.every(function(currentValue, index, arr), thisValue)
  3. array.some(function(currentValue, index, arr), thisValue)
  4. array.filter(function(currentValue, index, arr), thisValue)
  5. array.map(function(currentValue, index, arr), thisValue)
  6. array.reduce(function(total, currentValue, currentIndex, arr), initialValue)
  7. array.reduceRight(function(total, currentValue, currentIndex, arr), initialValue)
  8. array.find(function(currentValue, index, arr), thisArg)
  9. array.findIndex(function(currentValue, index, arr), thisArg)
  10. array.keys()
  11. array.values()
  12. array.entries()

bind、call、apply的区别

参考文章
细说 call、apply 以及 bind 的区别和用法
装饰器模式和转发,call/apply
函数绑定
题解
call / apply 区别:
主要体现在参数: call 第二参数接收任何可迭代对象; apply 第二参数接收数组或类数组对象

bind / (call; apply) 区别:
bind 方法返回新的函数, 该函数 this 指向提供的第一参数;
bind 方法不会立即执行, 需要手动调用; call/apply 方法立即执行

bind / call / apply 共同点:
作用对象必须是一个函数, 即 Function.apply() 等;
目的是改变函数执行时的上下文;

new的原理

参考文章
重学 JS 系列:聊聊 new 操作符
如何实现一个 new
Object.create
题解

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
29
30
// 1.
function Test(name) {
this.name = name;
}
const test = new Test('Siri');
console.log(test.name) // Siri

// 2.
Test.prototype.sayName = function() {
console.log(this.name)
}
test.sayName() // Siri

// 3.
fuction Test2(name) {
this.name = name;
return 1;
}
function Test3(name) {
this.name = name;
return {
age: 18,
}
}
const test2 = new Test2('Siri');
console.log(test2.name) // Siri

const test3 = new Test3('Siri');
console.log(test3) // {age: 18}
console.log(test3.name) // undefined

new 作用:

  1. new 创建的实例可以访问构造函数中的属性
  2. new 创建的实例可以访问构造函数原型中的属性, new 将实例和构造函数通过原型链连接
  3. 构造函数返回原始值, 返回值不会生效; 构造函数返回对象, 该对象有效, 返回对象会导致 new 操作符不起作用(new 操作符本质上返回了该构造函数的 this 对象)

new 操作符的实现:

  1. new 操作符需要返回一个 this 对象(构造函数的 this)
  2. 为了可以访问构造函数原型, 该 this 对象需要连接构造函数构成一条原型链
  3. 返回原值时要忽略, 返回对象则正常返回
1
2
3
4
5
function _new(Fn, ...args) {
let _this = Object.create(Fn.prototype); // 通过 Object.create , 基于 Fn.prototype 原型创建空对象
let obj = Fn.apply(_this, args); // 基于 _this 空对象立即执行 Fn , 在 _this 上挂载 args 参数. Fn 可能有返回值, 创建一个 obj 变量接收
return obj instanceof Object ? obj : _this // 若返回值为对象, 则返回该对象, 否则返回 _this 对象, 这样就忽略了原始值
}

如何正确判断this?

参考文章
嗨,你真的懂this吗?
题解
建议看完下述题解后, 参考上方文章的例子, 自己尝试分析一遍 this 指向.

this 绑定优先级: new 绑定 > 显示绑定 > 隐式绑定 > 默认绑定
判断顺序如下:

  1. 函数是否通过 new 绑定, 若是, 则 this 绑定创建的实例对象;
  2. 函数是否通过 call, apply, bind 绑定, 若是, 则 this 绑定第一参数指定的对象;
  3. 函数是否在某个上下文对象中调用(隐式绑定), 若是, this 绑定该上下文对象, 一般是obj.foo(), 若对象链式调用, this 指向最近的调用上下文对象;
  4. 如果以上都不是, 那么使用默认绑定; 如果在严格模式下, 则绑定到undefined, 否则绑定到全局对象;
  5. null / undefined 作为 this 的绑定对象传入 call, apply, bind 会被忽略(无效), 此时应用默认绑定规则;
  6. 如果是箭头函数, 箭头函数的 this 继承的是外层代码块的this

箭头函数 this 是动态的, 当箭头函数被使用时(JS代码执行时) this 指向才能确定, 其指向上一层代码块的 this; 个人理解: 普通函数 this 未被调用时默认指向全局, 在调用时确定指向, 其指向调用它的对象, 而箭头函数为被调用时不存在 this, 调用时 this 指向其定义位置(注意是定义区域而不是调用区域, 与普通函数差别体现在这)的上层块级作用域;
PS: 箭头函数不能用 call/apply/bind; yield; arguments 等

闭包及其作用

参考文章
动画:什么是闭包?
为了前端的深度-闭包概念与应用
闭包详解一
深入理解闭包之前置知识→作用域与词法作用域
题解
前置知识(作用于 & 词法作用域):
作用域: 一套用于如何查找变量以及如何确定变量位置的规则. 简单理解作用域就是查找变量的地方; 作用域又分为静态作用域(词法作用域) & 动态作用域;
作用域链: 查找变量时所遵循从下到上逐层查找的规则.
词法作用域: 作用域的一种工作模式, 是静态的作用域. 其作用域位置由书写代码时函数声明的位置来决定的.

闭包概念(网上有很多种说法, 但都大同小异):

  • 一个内部函数保持着其对外部函数词法作用域的访问权限;
  • 一个函数对其周围状态(词法环境)的引用, 与该函数捆绑在一起的组合称为闭包;
  • 保护一个可重用的局部变量的词法结构;
  • 当函数可以记住并访问所在的词法作用域时, 就产生了闭包, 即使函数是在当前词法作用域之外执行; (<<你不知道的JavaScript>>)

个人理解:
函数在书写代码时, 就已经确定了其周围的词法环境, 当函数被调用时, 会创建一个执行上下文环境, 在这里将完成一系列的变量初始化与赋值等. 当函数执行完成后, 该执行上下文环境会被销毁, 被回收机制回收, 此时若环境内的变量没有被引用, 那么会随着上下文环境一同被回收; 但是! 若在该函数执行完成后, 其内部(只有内部的情况,外部是无法访问内部变量的)仍有对该变量的引用(常见的就是内部嵌套函数, 函数回调等), 那么该变量就不会被回收.
综上所述, 若一个内部函数持有对其外部函数词法作用域的访问权限, 那么闭包就形成了; 广义上讲任何函数都是闭包, 最外层函数引用全局作用域的变量, 其也保持了全局作用域的访问权限, 因此也是闭包; 一般我们提及的闭包都指的是有效(能给代码产生效益)的闭包.

闭包应用
优点: 保存 & 保护

  1. 私有变量
  2. 回调与计时器
  3. 绑定函数上下文
  4. 偏应用函数
  5. 函数重载: 缓存记忆 / 函数包装
  6. 即时函数: 独立作用域 / 简介代码 / 循环 / 类库包装 / 通过参数限制作用域内名称

缺点: 闭包信息始终会保存在内存里
小疑问: 函数仅对外部函数词法环境内某个变量持有引用, 其他变量仍会存在吗? 个人认为会存在, 因为变量和其环境是绑定关系(同生共死); 但是好像这些变量也无法再被利用了, 回收机制可以清除吗?

原型和原型链

参考文章
图解原型和原型链
2020面试收获 - js原型及原型链
题解
仔细阅读上面两篇文章, 就能大致理解”原型/构造函数/实例/原型链/类”的概念了.
总结以下:

  1. 原型: 是个对象, 主要作用是共享方法, 通过原型共享方法, 可以避免重复开辟内存空间的问题.
  2. 构造函数与原型的关系: 构造函数.prototype == 原型, 原型.constructor == 构造函数
  3. 实例与原型的关系: 实例.__proto__ == 原型
  4. 原型链: 原型与原型层层链接的过程, 原型1.__proto__ == 原型2; 作用: 使对象可以使用构造函数原型对象的属性和方法, 查找对象属性或方法时, 顺着原型链从下至上查找. 实例 -> 原型 -> 原型 -> … -> null
  5. 继承: 属性继承通过 call 改变 this 指向实现; 方法继承通过子类原型指向父类实例实现(将子类原型添加到原型链中)

除上述总结外, 还有 new 的操作实现, ES6 类的实现等

prototype与__proto__的关系与区别

参考文章
详解原型链中的prototype和 proto
proto 和 prototype 到底有什么区别
从__proto__和prototype来深入理解JS对象和原型链
隔壁小孩也能看懂的 7 种 JavaScript 继承实现
题解
前置知识:
Object & Function
JS 中引用类型有很多: Object, Function, Array, Date …;
其中 Object, Function 是能被 typeof 识别的, 其余的本质上都是 Object 的衍生对象;
Function 在 JS 中被单独视为一类, 是因为它在 JS 中是所谓的一等公民, JS 中没有类的概念, 其是通过函数来模拟类的, 因此, prototype 是用来区分 Function 和 Object 的关键: 函数创建时, JS 会为函数自动添加 prototype 属性, 其值为一个带有 constructor 属性的对象;
[[Prototype]]
每个对象都有一个内部属性[[Prototype]], 其用于存放该对象的原型. 通过 __proto__ , Object.getPrototypeOf/Object.setPrototypeOf访问, 通过 Function.prototype 设置;

  1. __proto__ 存在于所有对象上, prototype 只存在于函数上;
  2. 每个对象都对应一个原型对象, 并从原型对象继承属性和方法, 该对应关系由 __proto__ 实现(访问对象内部属性[[Prototype]]);
  3. prototype 用于存储共享的属性和方法, 其作用主要体现在 new 创建对象时, 为 __proto__ 构建一个对应的原型对象(设置实例对象的内部属性[[Prototype]]);
  4. __proto__ 不是 ECMAScript 语法规范的标准, 是浏览器厂商实现的一种访问和修改对象内部属性 [[Prototype]] 的访问器属性(getter/setter), 现常用 ECMAScript 定义的 Object.getPrototypeOfObject.setPrototypeOf 代替;
  5. prototype 是 ECMAScript 语法规范的标准;

多读多看, 一时间可能难以理解

继承的实现方式及比较

参考文章
隔壁小孩也能看懂的 7 种 JavaScript 继承实现
重学 JS 系列:聊聊继承
题解
前置知识:
JS 中没有类的概念, 继承是通过原型链来实现的, ES6 的 class 关键字, 实际上类似于原型链模拟类的一个语法糖; JS 设计之初所有数据类型都是对象, 为了模拟类, JS 期望用 new 操作符从一个原型对象中生成一个实例对象, 实现对象间的联系; 在类中 new 会调用该类的构造函数, JS 做了简化, new 直接调用构造函数而不是类, 通过构造函数就达到了生成实例对象的目的; 但该方法有一个缺点就是无法共享属性和方法, 每个实例对象都有自己的 this; 为解决这一问题, JS 设计者为构造函数设置了 prototype 属性, 里面存储所有需要共享的属性和方法, 当实例对象被创建时, 将自动引用 prototype 的属性和方法(new 操作符内部的实现: 实例.__proto__ = Function.prototype), 因此实例对象属性和方法可以分为本地(构造函数内定义的)和引用(构造函数原型对象定义的);
JS 的七大继承方式

  1. 原型链继承: 子类原型对象指向父类实例;
    缺点: 子类无法向父类传参; 引用类型的属性被所有实例共享
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    <!-- 父类 -->
    function Parent() {
    this.name = 'Siri';
    }
    <!-- 在父类原型链上添加方法 -->
    Parent.prototype.callName = function () {
    console.log(this.name)
    }
    <!-- 子类 -->
    function Child() {}
    <!-- 原型链继承 -->
    Child.prototype = new Parent();

    const child = new Child();
    child.callName(); // Siri
  2. 构造函数继承: 调用父类构造函数, 并用 call 改变构造函数的 this 指向
    优点: 子类可向父类传参; 各实例间属性不再被共享(每个实例都有父类的副本实现, 且都改变了 this 指向自身)
    缺点: 由于各子类间互不相同, 导致方法不再被共享, 每个实例都创建了各自的属性和方法, 占用内存;
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    <!-- 父类 -->
    function Parent(name) {
    this.name = [name];
    <!-- 方法添加在构造函数内 -->
    this.callName = function() {
    console.log(this.name);
    }
    }
    <!-- 子类 -->
    function Child() {
    <!-- 构造函数继承 -->
    Parent.call(this, 'Siri');
    }

    const child1 = new Child();
    const child2 = new Child();
    child1.name.push('John');

    console.log(child1.name); // ['Siri','John']
    console.log(child2.name); // ['Siri']
  3. 组合继承: 属性通过构造函数继承, 共享方法通过原型链继承
    优点: 各实例间属性不共享, 但能共享原型链上定义的方法
    缺点: 调用了两次父类构造函数, 实例对象和其对应的原型对象属性重复, 在查找属性时, 会优先选择实例对象的属性, 不会到原型对象上找属性, 从而浪费了存储空间; (寄生组合式继承解决了这点, 在此之前先了解何为原型式继承和寄生式继承)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    <!-- 父类 -->
    function Parent(name) {
    this.name = name;
    this.hobbies = ['sing','dance','rap'];
    }
    Parent.prototype.sayHi = function () {
    console.log('Hi' this.name);
    }
    <!-- 子类 -->
    function Child(age) {
    <!-- 构造函数继承 -->
    Parent.call(this, 'Siri');
    this.age = age;
    }
    <!-- 原型链继承 -->
    <!-- 第一次父类构造函数调用 -->
    Child.prototype = new Parent();

    <!-- 第二次父类构造函数调用 -->
    const child = new Child(18);
  4. 原型式继承: 将传入对象作为原型, 创建实例对象
    ESMAScript5 中用 Object.create(prototype) 规范了原型式继承
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    function createObj(obj) {
    function F() {};
    F.prototype = obj;
    return new F();
    }
    const person = {
    name: 'Siri',
    age: 18,
    }
    const person1 = createObj(person);
  5. 寄生式继承: 创建一个函数封装继承过程, 并可扩展额外功能, 返回继承后的对象;
    与构造函数继承类似, 返回的对象间不共享属性方法;
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    function createObj(obj) {
    let tmp = Object.create(obj);
    tmp.callName = function () {
    console.log('hi');
    }
    return tmp;
    }
    const person = {
    name: 'Siri',
    age: 18,
    }
    const person1 = createObj(person);
  6. 寄生组合式继承: 利用空函数作为媒介, 避免了多次调用父类构造函数;
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    function Parent(name) {
    this.name = name
    this.hobbies = ['sing','dance','rap'];
    }
    function Child(age) {
    <!-- 构造函数继承 -->
    Parent.call(this, 'Siri');
    this.age = age;
    }
    <!-- 寄生式继承 -->
    <!-- 用临时的空函数充当子类和父类原型链继承的媒介 -->
    <!-- 与原型链继承相比, tmp 临时函数在 new 调用后就销毁, 因此 Child 既指向了父类原型对象, 又避免多次调用父类构造函数(因为仅用到了父类原型对象, 父类内部属性没有被创建), 创建不必要的属性 -->
    const tmp = function() {};
    tmp.prototype = Parent.prototype;
    Child.prototype = new tmp();

    const child = new Child();
  7. ES6 extends 继承: 在后续 ES6 知识点中详细讲解;

浅拷贝与深拷贝

参考文章
浅拷贝与深拷贝
如何写出一个惊艳面试官的深拷贝?
题解
以下概念都针对引用类型:
赋值: 将对象赋值给新变量, 复制的是对象在栈中的地址, 新变量与对象指向同一堆内存数据, 两者相互联动;
浅拷贝: 在堆内存中开辟新的内存存储拷贝的对象, 拷贝前后对象在堆中内存空间不同, 其基本数据类型互不影响, 但其引用类型共享同一块内存, 相互影响;
深拷贝: 在堆内存中开辟新的内存存储拷贝的对象, 拷贝对象内若存在子对象, 则进行递归拷贝, 拷贝前后对象互不影响;
浅拷贝实现

  1. Object.assign(): 将任意多个源对象的可枚举属性拷贝给目标对象, 并将目标对象返回;
    1
    2
    3
    4
    <!-- 源对象 -->
    let obj1 = { person: {name: "kobe", age: 41},sports:'basketball' };
    <!-- 浅拷贝, 返回对象由 obj2 接收 -->
    let obj2 = Object.assign({}, obj1)
  2. 函数库 lodash 的 _.clone() 方法
    1
    2
    3
    4
    5
    6
    <!-- 引入 lodash 库 -->
    const _ = require('lodash');
    <!-- 源对象 -->
    let obj1 = { person: {name: "kobe", age: 41},sports:'basketball' };
    <!-- 浅拷贝 -->
    let obj2 = _.clone(obj1)
  3. 展开运算符 ...: 将对象展开, 再创建一个空对象包裹, 重新组合成对象
    1
    2
    3
    4
    <!-- 源对象 -->
    let obj1 = { person: {name: "kobe", age: 41},sports:'basketball' };
    <!-- 浅拷贝 -->
    let obj2 = {...obj1}
  4. Array.prototype.concat(): 数组的浅拷贝
    1
    2
    let arr1 = [1, 3, {username: 'kobe'}];
    let arr2 = arr1.concat();
  5. Array.prototype.slice(): 数组的浅拷贝
    1
    2
    let arr1 = [1, 3, {username: 'kobe'}];
    let arr2 = arr1.slice();

深拷贝实现

  1. JSON.parse(JSON.stringify()): 利用JSON.stringify将对象转成JSON字符串, 再用JSON.parse把字符串解析成对象, 产生新对象, 而且对象会开辟新的内存空间,实现深拷贝;
    缺点: 不能处理对象内的函数和正则等, 会变成 null;
    1
    2
    3
    4
    <!-- 源对象 -->
    let obj1 = { person: {name: "kobe", age: 41},sports:'basketball' };
    <!-- 深拷贝 -->
    let obj2 = JSON.parse(JSON.stringify(obj1));
  2. 函数库 lodash 的 _.cloneDeep() 方法
    1
    2
    3
    4
    <!-- 源对象 -->
    let obj1 = { person: {name: "kobe", age: 41},sports:'basketball' };
    <!-- 深拷贝 -->
    let obj2 = _.cloneDeep(obj1);

手撕浅拷贝 / 深拷贝

  1. 浅拷贝:
    1
    2
    3
    4
    5
    6
    7
    function clone(obj) {
    let copy = {};
    for (let [key,value] of Object.entries(obj)) {
    copy[key] = value;
    }
    return copy;
    }
  2. 简易版深拷贝:
    未解决”循环引用”问题
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    function deepClone(target) {
    // 递归终止条件
    if (typeof target === 'object') {
    // 这部分其实就是浅拷贝
    // 判断是否为数组
    let obj = Array.isArray(target) ? [] : {};
    for (let [key, value] of Object.entries(target)) {
    // 此处需要继续判断 value 是不是 object, 因此递归写在这里, obj[key] 收集递归值
    obj[key] = deepClone(value);
    }
    // 返回递归值
    return obj;
    } else {
    return target;
    }
    }
  3. 完整版深拷贝:
    简易版深拷贝没有考虑对象内调用自身的情况, 会出现”循环引用”, 结果就是死循环导致栈内存溢出
    “循环引用” 解决方案:
    额外开辟一个存储空间,存储当前对象和拷贝对象的对应关系, Map 是比较好的选择;
    当需要拷贝当前对象时,先去存储空间中找,有没有拷贝过这个对象,如果有的话直接返回,如果没有的话继续拷贝;
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    function deepClone(target, map = new Map()) {
    if (typeof target === 'object') {
    let obj = Array.isArray(target) ? [] : {};
    for (let [key, value] of Object.entries(target)) {
    // value 是被拷贝的对象, 检查它是否存在(被拷贝过), 若存在则直接返回该值, 不需要拷贝;
    if (map.get(value)) {
    return value;
    }
    // 若不存在, 则将其添加至索引表, 并对其深拷贝;
    // 此处 obj 刚传入时是空对象, 但由于是引用, 所以后续改变 obj 也会更新表内的值;
    map.set(value, obj);
    // 注意传入 map
    obj[key] = deepClone(value, map);
    }
    return obj;
    } else {
    return target;
    }
    }

防抖和节流

参考文章
函数防抖和节流
JavaScript专题之跟着underscore学防抖
JavaScript专题之跟着 underscore 学节流
死磕 36 个 JS 手写题
题解
应用场景: 进行窗口 resize, scroll, 输入框内容校验等操作时, 频繁触发绑定事件函数, 导致页面频繁重渲染, 加重浏览器负担
防抖与节流的类似作用: 减少触发的频率
防抖
触发事件后 n 秒后才执行函数, 如果在 n 秒计时期间又触发了事件, 则重新计算函数执行时间;

  1. 简易版防抖:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    // 外层封装, 形成闭包;
    function debounce (func, wait) {
    let timer = null;
    // 返回真正用于绑定触发事件的函数;
    return function() {
    // 存储 this 指向: this 指向触发函数的事件对象;
    const context = this;
    const args = [...arguments]
    // 函数触发时, 先清除计时器, 停止之前正在计时的函数;
    clearTimeout(timer);
    // 重新设置计时器;
    timer = setTimeout(()=>{
    // 此处 apply 重新设置 this 是必要的, 尽管箭头函数 this 指向 function() {...} 的 this;
    // function() {...} 触发时 this 就是事件对象;
    // 但是执行的 func this 指向不能明确, 所以要 apply 强制指向事件对象;
    func.apply(context, args);
    }, wait);
    }
    }
  2. 完整版防抖(支持立即执行/支持函数返回值/支持取消功能)
    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
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    function debounce(func, wait, immediate = false) {
    let timer = null, result = null;
    let mainFunc = function () {
    const context = this;
    const args = [...arguments];

    if (timer) { clearTimeout(timer) };
    if (immediate) {
    // 立即执行: 和非立即执行(简易版)相反即可;
    // 当 timer 为 null 时, 可立即执行; 同时创建计时器, 计时完成后再将 timer 置为 null;
    let callNow = !timer;
    timer = setTimeout(() => {
    timer = null;
    }, wait);
    if (callNow) {
    // 立即执行可以返回函数值, 非立即执行不可以;
    // 因为 setTimeout() 中还未完成 result 赋值, result 就被返回了, 值为 undefined;
    result = func.apply(context, args);
    }
    } else {
    // 同简易版防抖
    timer = setTimeout(() => {
    func.apply(context, args);
    }, wait)
    }
    return result;
    }

    // 取消防抖功能
    mainFunc.cancel = function() {
    // 清除现有计时器, 并将计时器初始化为null;
    clearTimeout(timer);
    timer = null;
    // console.log(timer);
    }

    return mainFunc;
    }

节流
连续触发事件但是在 n 秒中只执行一次函数, 节流会稀释函数的执行频率;
与防抖区别在于, 防抖的计时器会刷新, 只执行最后一次

  1. 简易版节流:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    // 时间戳节流法: 立即执行第一次
    function throttle(func, wait) {
    // 初始化时间
    let time = 0;
    return function() {
    const context = this;
    const args = [...arguments];

    // 获得当前时间戳
    let now = +new Date();
    // 时间差 > 阈值, 则触发函数; 由于第一次的时间差必定大于阈值, 所以第一次立即执行
    if(now - time > wait) {
    // console.log(now);
    func.apply(context, args);
    // 更新时间戳
    time = now;
    }
    }
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    // 定时器节流法: 停止后执行最后一次
    function throttle2(func, wait) {
    let timer = null;

    return function () {
    const context = this;
    const args = [...arguments];

    if (!timer) {
    timer = setTimeout(()=>{
    timer = null;
    func.apply(context, args);
    }, wait);
    }
    }
    }
  2. 完整版节流:
    逻辑有点绕, 可先实现时间戳和计时器, 再整理它们的逻辑, 在时间戳计时器执行时屏蔽计时器(清除), 在计时器执行时屏蔽时间戳(重置时间戳);
    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
    29
    30
    31
    32
    33
    34
    function throttle(func, wait) {
    // 计时器和时间戳初始化
    let timer = null, pre = 0;

    const mainFunc = function () {
    let context = this;
    let args = [...arguments];

    let now = +new Date();
    // 此处逻辑可能有点绕, 可以先把时间戳和计时器的实现都先写出来, 再调整逻辑
    if(now - pre > wait) {
    // 执行时间戳, 则屏蔽计时器方法, 即清除计时器并置为 null
    if (timer) {
    clearTimeout(timer);
    timer = null;
    }
    // 正常的时间戳方法
    func.apply(context, args);
    pre = now;
    } else if (!timer) {
    // 若时间戳不执行了, 此时 timer 为 null, 触发计时器方法执行
    // 注意: 计时器计时时间是剩余时间而不是设置的阈值
    timer = setTimeout(()=>{
    // 重置时间戳
    pre = +new Date()

    timer = null;
    func.apply(context, args);
    }, wait-(now - pre))
    }
    }

    return mainFunc
    }
  3. 进阶版节流:
    可控制是否第一次立即执行, 以及是否在末尾执行回调;
    方法: 设置个 options 作为第三个参数,然后根据传的值判断到底哪种效果,我们约定:
    leading:false 表示禁用第一次执行
    trailing: false 表示禁用停止触发的回调
    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
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    function throttle(func, wait, options={}) {
    let timer = null, pre = 0;

    // 不能同时设置禁用第一次执行和末尾回调
    if ((options.leading === false) && (options.trailing === false)) {
    throw new Error('options 参数不能同时为 true')
    }

    let mainFunc = function() {
    const context = this;
    const args = [...arguments];

    let now = +new Date();
    // 通过控制 pre 来控制是否第一次执行
    // 当 pre == 0 且禁用第一次执行时, 让 pre == now, 就不执行第一次的时间戳方法了
    // pre == 0 用于区分第一次时间戳方法和后续时间戳方法, 若只有 options.leading === false, 时间戳就被永久禁用了
    if (!pre && options.leading === false) pre = now;
    if (now - pre > wait) {
    // 重置计时器方法
    if (timer) {
    clearTimeout(timer);
    timer = null;
    }
    func.apply(context, args);
    pre = now;
    } else if (!timer && options.trailing !== false) {
    // options.trailing !== false 时才开放计时器方法
    timer = setTimeout(()=>{
    // 重置时间戳方法
    // 当禁用第一次执行时, 时间戳要重置回 0
    pre = options.leading === false ? 0 : +new Date();

    // timer 类似于开关, null 时表示可以执行下一次回调了
    timer = null;
    func.apply(context, args)
    }, wait-(now-pre))
    }
    }

    // 取消节流功能: 参数初始化
    mainFunc.cancel = function() {
    pre = 0;
    clearTimeout(timer);
    timer = null;
    }

    return mainFunc;
    }

作用域和作用域链、执行期上下文

参考文章
深入理解JavaScript作用域和作用域链
深入理解JavaScript执行上下文和执行栈
前端基础进阶(四):详细图解作用域链与闭包
理解 JavaScript 中的执行上下文和执行栈
题解
作用域(块级作用域 / 全局作用域 / 函数作用域)
作用域概念: 程序源代码中定义变量的区域; 它是一套规则, 规定了如何在当前作用域与自己作用域中查找变量;
作用域作用: 隔离变量;
词法作用域: 静态作用域, 在书写代码时根据变量和块作用域定义的位置决定;
全局作用域
全局作用域有且只有一个, 定义在全局作用域中的变量可在代码任何地方被访问;
全局作用域弊端: 变量命名冲突, 易污染全局命名空间;

  • 最外层函数
  • 最外层函数外定义的变量
  • 所有未定义直接赋值的变量
  • 所有 window 对象的属性

函数作用域
函数定义时确定的变量区域, 其内部定义的变量只能在该函数内被调用;

块级作用域
在任何代码块内部(一对花括号包裹)都会创建一个块级作用域;
ES6 块级作用域特点:

  • 变量声明不会提升
  • 禁止重复声明

执行上下文
前置知识:
JS 代码运行分为两个阶段: 编译阶段 & 执行阶段
编译阶段由编译器完成, 将代码翻译成可执行代码, 在该阶段完成了”词法分析; 语法分析; 作用域规则确定”;
执行阶段由引擎完成, 主要完成可执行代码的执行操作, 该阶段完成了”执行上下文创建; 执行函数代码; 垃圾回收”;
概念: JS 代码执行时所在的环境; JS 所有代码都在执行上下文中运行;
生命周期:

  1. 创建: 创建变量对象(初始化函数参数, arguments, 函数声明提升, 变量声明提升) -> 创建作用域链 -> 确定 this 指向 / 全局执行上下文是: 创建全局对象(浏览器中为 window 对象) -> this 指向该全局对象
  2. 执行: 变量赋值, 执行代码
  3. 回收: 一段代码执行完毕后, 从执行上下文出栈, 等待回收执行上下文;

类型:
全局上下文和函数执行上下文的区别: 作用区域不同; 函数执行上下文额外有 this, argument 和函数参数;

  1. 全局执行上下文(有且只有一个): 任何不在调用函数中的代码都位于全局执行上下文中;
  2. 函数执行上下文: 在函数被调用时创建, 每个调用函数对应一个执行上下文;
  3. Eval函数执行上下文: 不常用, 不做讨论;
  • 变量提升细节
    变量提升发生在执行上下文创建过程中: 先把代码中即将执行的变量/函数声明都拿出来; 变量先暂时赋值为undefined, 函数则先声明好可使用, 然后变量提升(当函数和变量同名且都会被提升, 函数声明优先级比较高, 因此变量声明会被函数声明所覆盖, 但是可以重新赋值);
  • 确定 this 指向细节
    this 在执行上下文创建过程中才能确定, 函数定义的时候是不能确认的;
    确定 this 的方法: new 绑定 > apply/bind 绑定 > 隐式绑定 > 默认绑定; 箭头函数 this 指向其定义时的上层块级作用域;
  • 执行上下文栈细节
    多个函数被调用时会产生多个函数执行上下文, JS 执行在单线程上, 所有代码需要排队进行, 因此需要一个管理函数执行上下文的数据结构;
    JS 引擎采用栈结构管理执行上下文, 执行上下文栈是一个存储函数调用的栈结构, 遵循先进后出的原则;
    栈执行上下文压入顺序: 全局执行上下文 -> 函数执行上下文1 -> 函数执行上下文2 -> …
    栈执行上下文弹出顺序: … -> 函数执行上下文2(出栈,等待回收) -> 函数执行上下文1(出栈,等待回收) -> 全局执行上下文(在浏览器关闭时出栈)
    即 JS 执行引擎总是访问栈顶的执行上下文;

作用域链
作用域链之所以放在最后讲, 是因为作用域链是在执行上下文中被创建的, 其发生在创建变量对象之后, 包含变量对象并用于解析变量, 变量解析遵循从最内层嵌套代码不断向上层环境查找(也有文章说是父级作用域)的规则, 直到找到该变量, 若未找到则报错;
抽象概念: 作用域链是由当前环境与上层环境的一系列变量对象组成, 它保证了当前执行环境对符合访问权限的变量和函数的有序访问;
上层环境和父级作用域的讨论: 个人认为上层环境和父级作用域都可以, 只是理解的角度不同, 如下例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- 全局变量 -->
let a = 10;

function print() {
console.log(a);
}

function border1() {
let a = 20;
print();
}

border1(); // 10

若以上层环境理解: 由于 print 函数直接执行, this 默认指向全局作用域, 因此 a == 10;
若以父级作用域理解: print 函数的父级作用域是全局作用域(作用域是在函数定义时就已经确定了的), 因此 a == 10;
父级作用域可能更容易判断: 因为其在书写代码时就确定了, 不用再通过 this 去判断执行环境; 以上例为例, print() 定义时已经确定了父级作用域是全局作用域, 因此调用时向上查找的全局作用域而不是 border1

执行上下文与作用域
区别: 执行上下文在运行时确定, 随时可能改变; 作用于在定义时就确定, 并且不会改变;
关系: 作用域可能包含多个上下文环境(多个函数调用), 也可能没有上下文环境(函数从未被调用, 或者函数执行完毕, 上下文环境被销毁), 也可能同时存在多个上下文(闭包); 同一个作用域下,不同的调用会产生不同的执行上下文环境,继而产生不同的变量的值。

DOM常见的操作方式

参考文章
JavaScript操作DOM常用的API
题解
前置知识:
DOM(文档对象模型): HTML 和 XML 文档的编程接口, 提供了对文档的结构化表述;
DOM 可以说是 HTML 文件的另一种展示, 我们可以通过编辑器以代码的形式展示, 也可以通过浏览器以页面的形式展示; DOM 将文档解析为一个由节点和对象(包含属性和方法)组成的结构集合;
DOM 是一个编程接口, 它连接了 web 页面和脚本语言, 让开发人员可以通过操作 DOM 来改变或控制 web 页面;
Node:
已知 DOM 是节点和对象组成的结构集合, DOM 的节点和对象从 Node 中继承;
每个节点类型对应一个节点类型常量,
常用的 Node 的节点类型常量(节点常量 / 值 / 描述):

  1. Node.ElEMENT_NODE / 1 / 元素节点(如<p>,<div>等)
  2. Node.TEXT_NODE / 3 / 文字
  3. Node.DOCUMENT_NODE / 9 / Document 节点
  4. Node.DOCUMENT_FRAGMENT_NODE / 11 / DocumentFragment 节点

Node 类型通过 Node 的 nodeType 属性判断, 值为一个整数(即上述节点类型常量的值);

1
2
3
if (X.nodeType === 9) {
console.log('X 是一个 Document 节点');
}

常用的 Node 节点类型: element, text, comment, document, document_fragment

  • Element
    提供了对元素标签名, 子节点和特性的访问; <div>, <span>, <a> 等标签都属于 element;
  • Text
    文本节点, 包含纯文本内容, 不能包含html代码, 但可以包含转义后的html代码;
  • Comment
    HTML文档的注释;
  • Document
    文档, 在浏览器中, document对象是HTMLDocument的一个实例, 表示整个页面, 它同时也是window对象的一个属性;
  • DocumentFragment
    所有节点中唯一一个没有对应标记的类型; 一种轻量级的文档, 当作一个临时的仓库用来保存可能会添加到文档中的节点;

DOM 的 API

  1. 节点创建型API: 创建节点
  • createElement: 传入指定的一个标签名来创建一个元素; let element = document.createElement(tagName); (注意: 通过createElement创建的元素并不属于HTML文档, 它只是创建出来, 并未添加到HTML文档中, 要调用 appendChildinsertBefore 等方法将其添加到HTML文档树中)
  • createTextNode: 创建一个文本节点; let text = document.createTextNode(data); (注意: 接收文本节点中的文本作为参数, 创建后的文本节点只是独立的一个节点, 同样需要 appendChild 将其添加到HTML文档树中)
  • cloneNode: 返回调用该方法的节点的一个副本;
  • createDocumentFragment: 创建 DOM 节点(文档片段); let fragment = document.createDocumentFragment();; 通常用例是”创建文档片段 -> 附加元素 -> 将文档片段插入 DOM 树”; 在实际 DOM 树中, 文档片段会被其附加的子元素代替; 文档片段存在于内存中, 不在 DOM 树中, 因此附加子元素时不会引起页面回流(对元素位置和几何上的计算), 能优化性能;
  • 总结: 创建型 API 所创建的节点是孤立的, 通常需要手动添加至 DOM 树; cloneNode 复制节点时要注意子节点和事件绑定的问题; createDocumentFragment 可以解决添加大量节点的性能问题;
  1. 页面修改型API
  • appendChild: 向目标节点添加子元素至末尾; parent.appendChild(child); (注意: 不会同时存在两个相同节点在页面上, 若存在, 执行 appendChild 相当于把这个节点移动到末尾, 如果child绑定了事件, 被移动时, 它依然绑定着该事件)
  • insertBefore: 添加一个节点到一个参照节点之前; parentNode.insertBefore(newNode, refNode); parentNode 表示新节点添加的父节点, newNode 表示要添加的节点, refNode 表示参照节点; (注意: refNode 参数必传, refNode 为 undefined 或 null, 则节点会被添加至子元素末尾)
  • removeChild: 删除指定的子节点并返回删除的节点; let deleteChild = parent.removeChild(node);
  • replaceChild: 使用一个节点替换另一个节点; parent.replaceChild(newChild, oldChild); (注意: newChild 若是页面上的节点, 则 replaceChild 执行的是将该节点转移到 oldChild 位置)
  • 总结: 新增或替换的节点若存在于页面上, 则其原来位置的节点将被移除, 同一个节点不能同时存在于页面的多个位置; 节点绑定的事件不会因为页面修改操作消失, 会一直保留;
  1. 节点查询型API
  • document.getElementById: 根据元素id返回元素, 返回值是Element类型, 如果不存在该元素, 则返回null; let element = document.getElementById(id); (注意: HTML 中若存在多个相同 id 的元素, 则返回第一个元素)
  • document.getElementsByTagName: 返回一个包括所有给定标签名称的元素的HTML集合; let elements = document.getElementByTagName(name); (注意: 搜索范围是整个 HTML 文档, 返回的 HTML 集合是动态的, 它可以自动更新并保持与 DOM 树的同步; 若不存在指定标签, 接口返回空的 HTML Collection 而不是 null)
  • document.getElementsByName: 通过指定 name 属性获取元素, 返回一个 NodeList 对象; let elements = document.getElementsByName(name); (注意: NodeList 是会随时变化的)
  • document.getElementsByClassName: 根据元素的class返回一个即时的HTMLCollection; let elements = document.getElementsByClassName(names); elements 为 HTML 集合, 包含了匹配的所有元素, names 为字符串, 包含所要匹配的类名列表, 类名通过空格分隔; (注意: getElementsByClassName 可在任何元素上调用, 不仅仅是 document)
  • document.querySelector: 通过 css 选择器来查找元素, 返回第一个匹配的元素(深度优先搜索), 若没有则返回 null; let element = document.querySelector(selectors);
  • document.querySelectorAll: 通过 css 选择器来查找元素, 返回所有匹配的元素; let elementList = document.querySelectorAll(selectors); elementList 是一个静态的 NodeList 类型的对象, selectors 是一个由逗号连接的包含一个或多个CSS选择器的字符串; (注意: 返回的 NodeList 是静态非即时的, 结果不会随着文档树变化而变化)
  1. 节点关系型API
  • parentNode: 返回元素父节点;
  • parentElement: 返回元素的父元素节点, 与 parentNode 不同在于, 父节点必须是 Element, 若不是则返回 null;
  • childNodes: 即时 NodeList, 表示元素的子节点列表;
  • children: 即时 HTML Collection, 返回 Element 子节点; 注意 HTML Collection 和 NodeList 的区别;
  • firstChild: 返回 DOM 树节点的第一个子节点, 若无子节点则返回 null;
  • lastChild: 返回当前节点的最后一个子节点, 若无子节点返回 null;
  • hasChildNodes: 返回布尔值, 表明当前节点是否包含子节点;
  • previousSibling: 返回当前节点的前一个兄弟节点,没有则返回null;
  • previousElementSibling: 返回当前元素在其父元素的子元素节点中的前一个元素节点;
  • nextSibling: 返回其父节点的childNodes列表中紧跟在其后面的节点;
  • nextElementSibling: 返回当前元素在其父元素的子元素节点中的后一个元素节点;
  1. 元素属性API
  • setAttribute: 设置指定元素的一个属性值, 若属性存在则更新其值, 否则添加新的属性; element.setAttribute(name, value); name 为属性值, value 为属性值;
  • getAttribute: 返回元素内的一个指定属性值, 若不存在返回 null 或 “”; let attr = element.setAttribute(attrName);
  • removeAttribute: 移除指定元素的一个属性; element.removeAttribute(attrName);

Array.sort()方法与实现机制

参考文章
js 数组详细操作方法及解析合集
六种排序算法的JavaScript实现以及总结
深入了解javascript的sort方法
题解
Array.sort()
作用: 对数组元素进行排序, 并返回排序后的数组;
参数: (可选) 规定排序顺序的比较函数; 若没有传比较函数, 默认按字母升序, 非字符元素调用 toString() 将元素转为字符串的 Unicode 再比较;
比较函数的两个默认参数 (a, b):

  • 若比较函数返回值<0,那么a将排到b的前面;
  • 若比较函数返回值=0,那么a 和 b 相对位置不变;
  • 若比较函数返回值>0,那么b 排在a 将的前面;

Array.sort() 实现机制:
Array.sort()只需传入比较的规则即可, 但实际内部依靠排序算法支撑;
不同浏览器采用的排序算法不相同, Firefox 采用快速排序, Chrome 采用归并排序;

排序算法(升序)

  1. 冒泡排序
    时间复杂度: O(n^2)
    10000条数据耗时: 360.530ms
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    function bubbleSort(arr) {
    const lens = arr.length;
    for (let i = 0; i < lens; i++) {
    for (let j = 0; j < lens - i; j++) {
    if (arr[j] > arr[j + 1]) {
    [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
    }
    }
    }
    return arr;
    }
  2. 选择排序
    时间复杂度: O(n^2)
    10000条数据耗时: 78.949ms
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    function selectionSort(arr) {
    const lens = arr.length;
    for (let i = 0; i < lens; i++) {
    let min = i;
    for (let j = i; j < lens; j++) {
    if (arr[min] > arr[j]) {
    min = j;
    }
    }
    if (min !== i) {
    [arr[i], arr[min]] = [arr[min], arr[i]];
    }
    }
    return arr;
    }
  3. 插入排序
    时间复杂度: O(n^2)
    10000条数据耗时: 40.008ms
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    function insertionSort(arr) {
    const lens = arr.length;
    for (let i = 1; i < lens; i++) {
    let j = i;
    let tmp = arr[i];
    while (j > 0 && arr[j - 1] > tmp) {
    arr[j] = arr[j-1];
    j--;
    }
    arr[j] = tmp;
    }
    return arr;
    }
  4. 归并排序
    时间复杂度: O(nlogn)
    10000条数据耗时: 11.314ms
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    function mergeSort(arr) {
    let res = main(arr);
    return res

    function main(arr) {
    if (arr.length === 1) return arr;
    const mid = Math.floor(arr.length / 2);
    const left = arr.slice(0, mid);
    const right = arr.slice(mid);
    // 回收递归结果
    const mergeResult = merge(main(left), main(right));
    return mergeResult;
    }

    function merge(left, right) {
    let il = 0, ir = 0;
    let result = [];
    while (il < left.length && ir < right.length) {
    left[il] < right[ir] ? result.push(left[il++]) : result.push(right[ir++]);
    }
    return result.concat(left.slice(il)).concat(right.slice(ir));
    }
    }
  5. 快速排序
    时间复杂度: O(nlogn)
    10000条数据耗时: 7.193ms
    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
    29
    30
    31
    32
    33
    34
    function quickSort(arr) {
    let left = 0, right = arr.length - 1;
    main(arr, left, right);
    return arr;

    function main(arr, left, right) {
    if (arr.length === 1) return;
    const index = partition(arr, left, right);
    if (left < index - 1) {
    main(arr, left, index - 1);
    }
    if (index < right) {
    main(arr, index, right);
    }
    }

    function partition(arr, left, right) {
    const midPivot = arr[Math.floor((left + right) / 2)];
    while (left <= right) {
    while (arr[left] < midPivot) {
    left++;
    }
    while (arr[right] > midPivot) {
    right--;
    }
    if (left <= right) {
    [arr[left], arr[right]] = [arr[right], arr[left]];
    left++;
    right--;
    }
    }
    return left;
    }
    }
  6. 堆排序
    1
    后续更新...

Ajax 请求过程

参考文章
前端校招准备–Ajax原理及其实现
ajax常见面试题
Ajax原理一片就够了
W3school – AJAX
题解
概念: AJAX (Asynchronous Javascript And XML) 异步 JavaScript 和 XML, 是一种创建交互式网页应用的网页开发技术; 其可以与服务器进行少量数据交换, 使网页局部实现异步更新;
Ajax 异步请求过程:

  1. 创建 XMLHttpRequest 对象
  2. 初始化参数(规定请求类型, URL 以及是否异步处理请求)
  3. 发送请求
  4. 接收响应数据

自己实现 AJAX 请求封装:
前置知识:
Ajax的核心 – XMLHttpRequest 对象; 在Chrome, Firefox, Opera, Safari以及IE7+都内建了 XmlHttpRequest 对象, 但是 IE5 和 IE6 是使用 ActiveX 对象。
XMLHttpRequest 对象方法:

  1. abort(): 取消现有连接, 重置 XMLHttpRequest 对象状态值为 0;
  2. open(): 初始化 http 请求参数;
  3. send(): 发送 http 请求;
  4. setRequestHeader(): 设置请求头(一般用于’POST’请求);

XMLHttpRequest 对象属性:

  1. onreadystateChange: XMLHttpRequest 对象状态值发生改变时触发的回调函数;
  2. reposeText: 服务器返回数据的字符串格式;
  3. reposeXML: 服务器返回数据的 XML 格式(兼容 DOM 的文档数据对象);
  4. status: 从服务器返回的状态码;
  • 2xx: 成功处理请求
  • 3xx: 重定向
  • 4xx: 客户端错误
  • 5xx: 服务端错误
  1. statusText: 伴随状态码返回的信息;
  2. readyState: XMLHttpRequest 对象状态值;
  • 0: XmlHttpRequest 对象已创建或者已经被 abort() 方法重置
  • 1: 对象已经初始化,但是请求还未发送(调用了open()方法,send()方法还没有调用)
  • 2: 请求已经发送,没有接收到响应信息(sed()方法已经被调用)
  • 3: 已经接收到了所有的响应头,响应体开始接收但未完成
  • 4: 响应信息已经全部接收

http状态码 (status) 和对象状态值 (readyState) 的区别:
readyState 针对 XMLHttpRequest 对象, 标记了 XMLHttpRequest 对象当前处于哪个状态;
status 针对服务器, 标记了服务器当前处于哪个状态, 由服务器接收到请求后返回;
readyState 标记了 XMLHttpRequest 对象的整个请求过程, 只有当请求过程全部完成后(完整执行了一次请求), 服务器才会接收到请求并返回一个 status , 根据服务器返回的 status 调用不同的函数;

AJAX 请求封装的代码实现

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
29
30
31
32
33
34
35
36
37
38
function ajax(options) {
let xhr = null;
if(window.XMLHttpRequest) {
xhr = new XMLHttpRequest();
} else {
// 兼容 IE5, IE6
xhr = new ActiveXObject("Microsoft.XMLHTTP");
}

// 初始化配置项
options = options || {}; // 配置项是否为空
options.type = (options.type || 'GET').toUpperCase(); // 请求方式统一大写, 默认 GET 请求
options.dataType = options.dataType || 'json'; // 默认请求体 json 格式

if (options.type === 'GET') {
xhr.open(options.type, options.url, true);
xhr.send();
} else if (options.type === 'POST') {
// 创建并初始化请求
xhr.open(options.type, options.url, true);
// POST 请求需设置请求头格式
xhr.setRequestHeader('Content-Type', "application/x-www-form-urlencoded")
// 发送请求
xhr.send();
}

// 设置请求状态值改变对应触发的回调函数
xhr.onreadystateChange = function() {
if (xhr.readyState === 4) {
// 注: 只有当 readyState === 4, 即完整执行一次请求后, 服务端才返回 status
if (xhr.status >=200 && xhr.status < 300) {
options.success && options.success(xhr.responseText, xhr.responseXML);
} else {
options.fail && options.fail(xhr.status, xhr.statusText);
}
}
}
}

JS 垃圾回收机制

参考文章
简单了解JavaScript垃圾回收机制
JavaScript中的垃圾回收和内存泄漏
题解
前置知识:

  • 垃圾回收机制: JS 找出不再使用的变量, 然后释放其占用的内存; 工作在 JavaScript 引擎内部;
  • 堆: 动态存放对象的内存空间;
  • mutator: 应用程序本身, 例如变量;
  • allocator: 负责从堆中调取内存空间供 mutator 使用(动态分配);
  • 活动对象/非活动对象: mutator 所引用的对象; let a = {name: bar} 中 a 是 mutator , 其引用的 {name: bar} 是活动对象, a = null 后, {name: bar} 不再被任何 mutator 引用, 因此变为了非活动对象;
  • 内存泄漏: 对象不再被使用但是仍处于被引用状态, 无法被回收释放; 常见的内存泄漏: 意外的全局变量; 被遗忘的计时器或回调函数; 闭包; 没有清理的 DOM 元素引用;

引用计数法
所有对象记录引用自己的 mutator 的数量; 当引用其的 mutator 数量为 0 时, 视为垃圾并被回收机制回收;

1
2
3
4
5
let a = new Object(); // obj 计数 1 (a)
let b = a; // obj 计数 2 (a,b)
let a = null; // obj 计数 1 (b)
let b = null; // obj 计数 0
<!-- GC 回收 obj -->
  • 引用计数法优势: 即时回收垃圾(目标被引用数为 0 时, 立刻被回收); 每次垃圾回收占用时间短; 不用去遍历堆内所有活动对象和非活动对象;
  • 引用计数法劣势: 对象计数器占用空间; 无法解决循环引用无法回收的问题
1
2
3
4
5
6
7
8
9
10
11
<!-- 循环引用 -->
<!-- f()执行完毕后, 由于其内部对象发生循环引用, 两者引用次数始终为 1, 无法被 GC 回收, 造成内存泄露 -->
function f(){
var o = {};
var o2 = {};
o.a = o2; // o 引用 o2,o2的引用次数是1
o2.a = o; // o2 引用 o,o的引用此时是1

return "azerty";
}
f();

标记清除法

  1. 标记阶段: GC 从全局作用域的变量, 沿作用域逐层向里深度遍历, 遍历到堆中对象时(说明该对象被引用), 做上标记, 继续递归遍历直到最深层作用域的最后一个节点;
  2. 清除阶段: 遍历整个堆, 回收没有被打上标记的对象;
  • 标记清除法优势: 实现简单, 标记不占用许多内存(有标记/没有标记, 一位二进制位即可表示); 解决了循环引用(两对象循环引用是无法从全局对象出发获取的, 因此就无法被标记, 其内存会被 GC 回收);
  • 标记清除法缺点: 碎片化; 需要多次遍历, 无法即时回收;

由于标记清除法需要遍历, 而遍历往往需要占用一部分运行时间, 为了避免长时间执行 GC, 标记清除法的 GC 回收机制采用定时运行的方式, 在程序运行过程中, 每隔一段时间进行一次统一的 GC;

复制算法
将一个内存空间分为两部分,一部分是From空间,另一部分是To空间,将From空间里面的活动对象复制到To空间,然后释放掉整个From空间,然后此刻将From空间和To空间的身份互换,那么就完成了一次GC。

小疑问: 文章提到的 GC 都是在堆内存中操作, 回收的是堆的对象, 那栈内存中的原始值是如何被回收的? 例如let a = 1; a = 2 此时 1 在栈中没有被使用, 应该被释放(上述操作中, 栈重新开辟了内存存储 a = 2 而不是在原位置替换值, 因为原始值不可改变)

JS String、Array和Math方法

参考文章
js中的String字符串方法
W3school - JavaScript 字符串方法
W3school - JavaScript 数组方法
W3school - JavaScript 数组排序
W3school - JavaScript 数组迭代
W3school - JavaScript Math 对象

addEventListener 和 onclick() 的区别

参考文章
你真的理解事件冒泡和事件捕获吗?
onclick和addEventListener的区别
题解
前置知识:
事件冒泡与事件捕获
作用: 解决页面中的事件流(事件发生顺序)
事件冒泡: 事件从最内层元素触发, 并一直向上传播, 直到 document 对象; element -> body -> html -> document
事件捕获: 事件从最外层触发, 向内传播, 直到最具体的元素; document -> html -> body -> document
addEventListener:

  • 语法: element.addEventListener(event, function, useCapture)
  • event: 事件名, 不能以’on’开头;
  • function: 触发事件执行的回调函数;
  • useCapture: true - 冒泡阶段执行; false - 捕获阶段执行;

addEventListener 和 onclick() 的区别:

  1. onclick 事件不能重复绑定, 只能指向唯一对象(重复绑定事件只会使最后绑定的事件响应); addEventListener 可以给一个对象注册多个listener(重复绑定事件会依次从上到下响应)
    1
    2
    3
    4
    5
    6
    7
    8
    const btn = document.getElementById('btn');
    btn.onclick = function() {console.log(1)};
    btn.onclick = function() {console.log(2)};
    btn.addEventListener('click', function() {console.log(3)}, false);
    btn.addEventListener('click', function() {console.log(4)}, false);
    // 2
    // 3
    // 4
  2. onclick() 事件流通过事件冒泡处理, addEventListener 可自定义事件流处理方式;
  3. onclick() 是 DOM0 级处理事件, 作用于 HTML 对象, addEventListener 是 DOM2 级处理事件, 作用于任何对象;

new和Object.create的区别

参考文章
JavaScript中new操作符和Object.create()的原理
Object.create()、new Object()和{}的区别
详解Object.create(null)
题解
首先自己手撸一遍 new 和 Object.create 的简单实现, 很多关系就显而易见了:
new 实现

1
2
3
4
5
6
7
8
9
function _new (Fn, ...args) {
const _this = Object.create(Fn.prototype);
const res = Fn.apply(_this, [...args]);
if (typeof res === 'object') {
return res;
} else {
retrun _this;
}
}

Object.create 实现

1
2
3
4
5
6
7
8
Object.create = function(objPrototype, properties) {
function F() {};
F.prototype = objPrototype;
if (properties) {
Object.defineProperties(F, properties)
}
return new F();
}

可以看到, 在 new 的实现中, 我们用到了 Object.create 方法:
Object.create(arg, pro) 方法的作用是根据传递的参数 arg (传递一个原型)创建一个对象, 若不传入其余参数, 则创建的是一个空对象(不挂载任何属性和方法), 对象原型指向 arg 原型;
而 new 关键字创建的对象的原型指向构造函数的原型, 该创建的对象还挂载了构造函数的一系列属性;
注意: Object.create(null) 创建的对象不包含 Object 原型的任何属性和方法, 除非我们执行 Object.create(Object.prototype); new Object() 创建的对象指向 Object 原型, 继承了它的属性和方法; 当我们需要一个干净且可定制的对象时, 考虑采用 Object.create(null) 创建空对象, 该对象不继承 Object.prototype 的任何属性和方法;

DOM的location对象

参考文章
MDN - Location

浏览器从输入URL到页面渲染的整个流程

参考文章
(详解)从浏览器输入 URL 到页面展示过程发生了什么?
十五张图带你彻底搞懂从URL到页面展示发生的故事

跨域、同源策略及跨域实现方式和原理

参考文章
九种跨域方式实现原理(完整版)
跨域资源共享 CORS 详解
题解
后续更新…

浏览器的回流(Reflow)和重绘(Repaints)

参考文章
浏览器的回流与重绘 (Reflow & Repaint)
你真的了解回流和重绘吗
前端基本功(四):性能优化之你真的懂回流、重绘与合成层吗?
题解
后续更新…

JavaScript中的arguments

参考文章
JavaScript arguments 对象全面介绍
JavaScript深入之类数组对象与arguments
题解
arguments 对象概念: 一个类数组对象, 是传给 function 的参数组成的列表;
重点:

  1. 类数组对象: 与数组结构类似, 键名为索引, 并有 length 属性, 打印出来的结果和数组一样, 例如 ['A','a',0]; 读写方式, 获取长度, for…in… 遍历等同数组一样; 但不能调用数组 Array 的方法;
  2. 包含的是传递给函数的参数, 注意表达, 即默认值是不被包含在 arguments 内的;

arguments 对象转数组:
提问: 为什么通常将 arguments 转为数组?
因为数组的方法更多, 功能更强大, 我们通常获取 arguments 后第一步先将它转为数组对象;
提问: 为什么不直接将 arguments 设计为数组对象?
为了向前兼容, 在 ES6 中有 Rest 参数可以替代 arguments;
arguments 转数组的常用方法有:

  1. 方法借用: const arr = Array.prototype.slice.call(arguments)
  2. 方法借用: const arr = Array.prototype.splice.call(arguments, 0)
  3. 方法借用: const arr = Array.prototype.concat.apply([], arguments)
  4. ES6 Array.from(): const arr = Array.from(arguments)
  5. ES6 扩展运算符: const arr = [...arguments]
  6. ES6 Rest参数(接收剩余参数, 存储于数组内)替换arguments: function(...args) {console.log(args)}

函数的 arguments 不能泄露或向外传递(直接返回/间接返回/闭包泄露等都不行)
V8 引擎会跳过优化, 造成性能损失;

1
2
3
4
5
6
7
8
9
10
11
12
13
function getArgs() {
return arguments;
}
function getArgs() {
const args = [].slice.call(arguments);
return args;
}
function getArgs() {
const args = arguments;
return function() {
return args;
};
}

注意上述表达, 不能向外传递, arguments 是可以向内传递给嵌套函数的;

1
2
3
4
5
6
7
function sayHi() {
getName.apply(this, arguments) // apply 可将数组或类数组作为参数传递给目标;
}
function getName(name) {
console.log('Hi ' + name)
}
sayHi('Siri') // Hi Siri

修改 arguments 的值
在严格模式下, arguments 同参数没有联系, 修改一个值不会改变另一个值;

1
2
3
4
5
6
7
8
9
'use strict'
function changeValue(a) {
console.log(a, arguments[0]) // 1 , 1
a = 10;
console.log(a, arguments[0]) // 10 , 1
arguments[0] = 20;
console.log(a, arguments[0]) // 10 , 20
}
changeValue(1)

非严格模式下, arguments 与参数关联;

1
2
3
4
5
6
7
8
9
'use strict'
function changeValue(a) {
console.log(a, arguments[0]) // 1 , 1
a = 10;
console.log(a, arguments[0]) // 10 , 10
arguments[0] = 20;
console.log(a, arguments[0]) // 20 , 20
}
changeValue(1)

arguments 应用场景

  1. 参数不定长
  2. 函数柯里化 (后续介绍)
  3. 递归调用
  4. 函数重载: 同名不同参数的函数(方法)称为重载函数(方法), 同名函数(方法)根据传参类型不同执行对应的函数(方法)称为函数重载; JS 不对传入参数类型进行严格定义, 因此不具备函数重载功能, 若有同名函数, 后面的函数会将前面的函数覆盖; (后续有机会再补充函数重载的方法)
    1
    2
    3
    function add(arg1, arg2) { console.log('我是函数1') }
    function add(arg1, arg2, arg3) { console.log('我是函数2') }
    add(1, 2) // 我是函数2

宏任务与微任务 & EventLoop事件循环

参考文章
JS事件循环机制(event loop)之宏任务/微任务
微任务、宏任务与Event-Loop
JavaScript基础四:事件循环EventLoop
JavaScript中的Event Loop(事件循环)机制
题解

  1. JavaScript 是单线程语言, JS 任务需要遵循一定的顺序执行;
  2. 为了避免某个任务执行时间过长而阻塞后面任务的执行, JS 将任务分为了同步和异步任务;
  3. 同步任务和异步任务执行的场所不同, 具体可见同步/异步任务思维导图; 具体执行过程如下:
  • 同步任务进入主线程执行, 异步任务进入 Event Table 执行, 并在此阶段注册其内部的回调函数;
  • 注册的回调函数会被放入 Event Queue 中等待;
  • 主线程中同步任务执行完毕后(js 引擎通过 monitoring process 进程持续监测主线程执行栈是否为空), 此时执行栈为空, 会去 Event Queue 检查是否存在等待的回调函数, 若存在则读取 Event Queue 的函数到主线程中执行;
  1. 异步任务又被细分为宏任务和微任务, JS 在处理宏任务和微任务时又遵循特殊的执行顺序;
  2. 当 JS 遇到宏任务时, 先将其放入 Macro Event Queue 中, 然后将微任务放入 Micro Event Queue 中(注意宏任务队列和微任务队列不是一个队列); 在读取(向外拿)回调函数时, 先从微任务队列拿微任务的回调函数, 然后再从宏任务队列中拿宏任务的回调函数; (换句话说, 每一次宏任务执行前, 要清空上一次的微任务队列, 宏任务在微任务之后执行);

经过上述讲解, 我们可以看到, 分析 JS 的执行顺序, 应遵循的思路为: 同步 or 异步 -> 同步放入主线程/异步放入 Event Table -> Event Table 中判断宏任务 or 微任务 -> 注册回调放入各自队列 -> 清空微任务 -> 执行下一次宏任务(此处并不是清空, 因为可能本次宏任务会添加新的微任务) -> …;

在通过例子深入了解 JS 执行机制前, 我们需要记住几个常用的宏任务和微任务:
宏任务: 整体 script 代码(script 代码是异步代码, 其内部可能包含同步代码, 但整体上是异步宏任务, 许多文章对于 script 代码解释有出入, 以自己理解为准), setTimeout, setInterval, setImmediate;
微任务: 原生 Promise(有些实现的promise将then方法放到了宏任务中, 具体在手撕 Promise 时再讨论), process.nextTick, MutationObserver, Object.observe(已废弃);

例一

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
29
30
31
32
33
34
35
36
console.log('script start');

setTimeout(function() {
console.log('setTimeout');
}, 0);

Promise.resolve().then(function() {
console.log('promise1');
}).then(function() {
console.log('promise2');
});

console.log('script end');

/**请写出打印结果
* script start
* script end
* promise1
* promise2
* setTimeout
*/

/**过程解析
* 添加执行环境 - 任务入栈:
* 最开始 JS 整体代码作为异步输入
* console.log('script start') 同步代码, 放入主线程队列
* setTimeout 异步宏任务, 经 Event Table 注册回调后, 其回调放入宏任务队列
* Promise.resolve() 异步微任务, 经 Event Table 注册回调后, 回调放入微任务队列 (Promise 由于链式调用, 微任务队列 promise1 先入, promise2 后入)
* console.log('script end') 同步代码, 放入主线程队列
*
* 执行 - 任务出栈:
* 先执行主线程, 输出 script start, script end
* 主线程为空, 清除微任务队列, 微任务回调出队列, 进入主线程执行, 输出 promise1, promise2
* 微任务为空, 清除宏任务队列, 宏任务回调出队列, 进入主线程执行, 输出 setTimeout
* 主线程, 任务队列均为空, 执行完毕;
*/

例二

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
29
30
31
32
setTimeout(()=>{
console.log('setTimeout1');
}, 0);

let p = new Promise((resolve, reject)=>{
console.log('Promise1');
resolve();
})

p.then(()=>{
console.log('Promise2');
})


/**请写出打印结果
* Promise1
* Promise2
* setTimeout1
*/

/**过程解析
* 入队
* 最开始 JS 整体代码作为异步输入
* setTimeout 异步宏任务, 注册回调并添加至宏任务
* new Promise 是同步任务, 其内部 executor 函数在主线程自动执行, 因此将 executor 函数添加至主线程;
* Promise.then() 异步微任务, 注册回调并添加至微任务
*
* 出队
* 清空主线程, 输出 Promise1;
* 清空微任务队列, 输出 Promise2;
* 取宏任务队列回调, 输出 setTimeout1;
*/

例三

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
Promise.resolve().then(()=>{
console.log('Promise1')
setTimeout(()=>{
console.log('setTimeout2')
},0)
});

setTimeout(()=>{
console.log('setTimeout1')
Promise.resolve().then(()=>{
console.log('Promise2')
})
},0)

/**输出结果
* Promise1
* setTimeout1
* Promise2
* setTimeout2
*/

/**过程解析
* 入队
* 最开始 JS 整体代码作为异步输入
* Promise.resolve() 异步微任务, 注册回调并添加至微任务
* setTimeout 异步宏任务, 注册回调并添加至宏任务
*
* 出队
* 主线程没有任务, 清空微任务, 输出 Promise1, 此时碰到 setTimeout 宏任务;
*
* 入队
* setTimeout 注册回调并添加至宏任务队尾
*
* 出队
* 微任务为空, 此时获取宏任务队列最开始的回调并执行, 输出 setTimeout1;
*
* 入队
* 遇到 Promise.resolve 异步微任务, 注册其回调并添加至微任务;
*
* 出队
* 下一次宏任务队列回调执行前, 必须保证微任务队列是清空的, 因此此时清空微任务回调, 输出 Promise2
* (这就是宏任务不用清空这两个字的原因)
* 清空后, 获取宏任务回调并执行, 输出 setTimeout2
*/

经过上述三个例子, 你可能对 JS 执行机制有了大致的了解, 在判断执行顺序时, 我们需要先构建初步的执行上下文(入队), 同步添加至主线程, 异步则进一步判断宏任务还是微任务, 注册回调并添加至各自的等待队列; 执行上下文构建完成后, 开始执行(出队), 先执行主线程的同步代码, 清空主线程后再清空微任务队列, 微任务队列清空后, 再从宏任务队列获取回调(多次强调, 此处是获取而不是清空)并执行; 在执行宏任务回调时, 又可能会添加新的主线程和微队列任务(第二轮构建入队), 我们要在下一次执行宏任务回调前, 将其清空(第二轮执行出队), 依次不断循环

终极考核 - 做对出师

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
console.log('1');

setTimeout(function() {
console.log('2');
process.nextTick(function() {
console.log('3');
})
new Promise(function(resolve) {
console.log('4');
resolve();
}).then(function() {
console.log('5')
})
})
process.nextTick(function() {
console.log('6');
})
new Promise(function(resolve) {
console.log('7');
resolve();
}).then(function() {
console.log('8')
})

setTimeout(function() {
console.log('9');
process.nextTick(function() {
console.log('10');
})
new Promise(function(resolve) {
console.log('11');
resolve();
}).then(function() {
console.log('12')
})
})

/**输出结果
* 1
*
*/


/**过程解析
* 首轮构建执行上下文(入队)
* js 代码块作为异步代码入队
* console.log(1) 同步代码, 进入主线程
* setTimeout 异步宏任务, 注册其 function(){...} (不关心其嵌套) 至宏任务队列
* process.nextTick 异步微任务, 注册回调并添加至微任务队列
* new Promise 内部 executor 函数同步执行, 添加至主线程
* .then() 异步微任务, 添加至微任务队列
* setTimeout 异步宏任务, 注册其 function(){...} (不关心其嵌套) 至宏任务队列
*
* 首轮执行(出队)
* 清空主线程: 输出 1, 7
* 清空微任务队列: 输出 6, 8
* 获取宏任务第一个回调并执行: 输出 2, 此时遇到 process.nextTick, 开启第二轮入队(构建执行环境)
*
* 第二轮入队
* 注册 process.nextTick 回调并添加至微任务队列,
* 添加 new Promise 内同步代码至主线程,
* 注册 .then() 回调至微任务; 此时主线程和微任务队列都不为空, 开启第二轮出队
*
* 第二轮出队
* 清空主线程: 输出 4
* 清空微任务队列: 输出 3, 5
* 获取宏任务下一个回调: 输出 9, 此时遇到 process.nextTick, 开启第三轮入队
*
* 第三轮入队
* 注册 process.nextTick 回调并添加至微任务队列,
* 添加 new Promise 内同步代码至主线程,
* 注册 .then() 回调至微任务; 此时主线程和微任务队列都不为空, 开启第三轮出队
*
* 第三轮出队
* 清空主线程: 输出 11;
* 清空微任务队列: 输出 10, 12
*
* 主线程, 宏任务队列, 微任务队列都为空, 执行完毕
* 输出结果为: 1, 7, 6, 8, 2, 4, 3, 5, 9, 11, 10, 12
*/

回调函数内可能嵌套了多层, 但遵循上述步骤仍可以正确判断, 我们每次只需关注最外层嵌套函数即可, 在原有上下文基础上构建第二级的执行上下文, 清空主线程, 清空微队列, 再获取下一个宏任务回调执行并判断, 遇到嵌套后再循环…

Event Loop 事件循环: 上述讲了这么多同步异步, 宏任务微任务, 看似没有提及事件循环, 但我们一直是以它为准进行判断的; Event Loop 实际就是 JavaScript 异步执行机制的一种实现方式; 程序按照主线程-微任务-宏任务的顺序不断重复执行, 并始终维护各执行队列直至全部队列清空的操作就是 Event Loop;

BOM 属性对象方法

参考文章
W3school - JavaScript Window - 浏览器对象模型

函数柯里化及其通用封装

参考文章
「前端进阶」彻底弄懂函数柯里化
JavaScript 函数式编程
题解
前置知识:

  • 柯里化: 一种将使用多个参数的一个函数转换成一系列使用一个参数的函数的技术;
  • JS 函数柯里化: 将一个多参函数转换成一系列使用一个或多个参数的函数;
  • 柯里化函数执行流程: 当接收的参数数量小于原函数的形参数量(不包含默认值)时,返回一个函数用于接收剩余的参数,直至接收的参数数量与形参数量一致,执行原函数;
  • 函数柯里化的作用: 降低函数的通用性, 提高函数的适用性, 使其拥有更高的自由度; 我们通常要求函数封装越通用越好, 这样可以适应各种应用场景, 实现函数复用, 但这也导致我们在某个场景内使用时需要重复输入参数, 柯里化可以很好的实现参数复用; 常用于表单校验等, 例子见「前端进阶」彻底弄懂函数柯里化

函数柯里化通用封装
封装思路:

  1. 创建一个数组用于存放已接收的参数
  2. 通过比较当前接收参数个数和目标函数的形参个数, 判断是直接执行目标函数还是继续返回函数接收参数
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
// 函数柯里化
/**
* curry
* @param {Function} fn // 接收被柯里化的目标函数名
* @param {Array} arr // 存储已传递的参数
* @returns // 若函数参数未传递满, 则返回函数接收剩余参数; 若传递完成则执行目标函数
*/

function curry(fn, arr = []) {
// fn.length <=> arguments.callee.length : 获取函数形参个数(不包含默认值)
let lens = fn.length;
// 返回一个函数接受剩余参数
return function func (...args) {
// 将剩余参数拼接到参数列表
arr = arr.concat(args)
if (arr.length < lens) {
// 注意此处若用 apply, 需要将多个参数用 [] 包裹, 因为 apply 参数只能接受数组或类数组对象
// call 接收多个参数, 并通过 Spread 语法统一到参数数组内, 即 fn.call(context, ...args)
// apply / call 立即执行, 因此此处返回的是 func()
return curry.call(this, fn, arr)
// return curry.apply(this, [fn, arr])
} else {
return fn.apply(this, arr)
}
}
}

函数柯里化的占位符实现
「前端进阶」彻底弄懂函数柯里化 还提及了函数柯里化占位符的实现, 后续有余力补充…

JS 的 map() 和 reduce()

参考文章
数组方法
一个合格的中级前端工程师需要掌握的 28 个 JavaScript 技巧
前端秋招面经
题解
Array.prototype.map()
作用: 对数组的每个元素都调用函数,并返回结果数组;
语法: let res = arr.map(function(item, index, array) {...})
map<U>(callbackfn: (value: T, index: number, array: T[]) => U, thisArg?: any): U[];
原生实现:
循环遍历实现 map()

1
2
3
4
5
6
7
8
9
10
11
12
Array.prototype.myMap = function (callbackfn, thisArg = this) {
// 结果数组
let res = [];
// 存储当前数组
const arr = this;
for (let i = 0; i < arr.length; i++) {
// 跳过稀疏值
if (!arr.hasOwnProperty(i)) continue;
res.push(callbackfn.call(thisArg, arr[i], i, arr))
}
return res;
}

reduce 实现 map()

1
2
3
4
5
6
Array.prototype.myMap = function (callbackfn, thisArg = this) {
return this.reduce((pre, item, idx, array) => {
// reduce 会将每次的返回结果存储在下一个 pre 中
return [...pre, callbackfn.call(thisArg, item, idx, array)]
}, [])
}

Array.prototype.reduce()
作用: 将函数应用于所有数组元素, 并将每次返回的结果应用于下一次调用;
语法: let value = arr.reduce(function(accumulator, item, index, array) {...}, [initial])
reduce(callbackfn: (previousValue: T, currentValue: T, currentIndex: number, array: T[]) => T, initialValue: T): T;
原生实现:
循环遍历实现 reduce()

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
Array.prototype.myReduce = function (callbackfn, initialValue) {
let res = undefined; // 初始值
let startIdx = undefined; // 初始值索引
const arr = this;
if (initialValue !== undefined) {
res = initialValue
} else {
// 若没有指定初始值, 取数组中第一个有效值
for (let i = 0; i < arr; i++) {
if (!arr.hasOwnProperty(i)) continue;
res = arr[i];
startIdx = i;
break;
}
}

// 若指定初始值, 则从0开始遍历; 反之从下一个数组值遍历(第一个作为初始值)
for (let i = ++startIdx || 0; i < arr.length; i++) {
// 寻找有效值
if (!arr.hasOwnProperty(i)) continue;
res = callbackfn(res, arr[i], i, arr);
}

return res;
}

“==”和“===”的区别

参考文章
JavaScript 中 == 和 === 的区别
从一道面试题说起—js隐式转换踩坑合集
题解
==操作符会先将两边的值进行强制类型转换再比较是否相等, 而===操作符不会进行类型转换;
==操作符只要求比较两个值是否相等, 而===操作符不仅要求值相等, 而且要求类型相同;
建议: 由于==!=带来的隐式类型转换规则非常繁琐, 以及为了避免混淆数据类型导致的 bug, 推荐使用===操作符和!==操作符;
JS 隐式转换
后续更新…

setTimeout用作倒计时为何会产生误差?

参考文章
前端进阶之setTimeout 倒计时为什么会出现误差?
JavaScript 倒计时踩坑集锦
题解
JS 是单线程的, 代码执行从上到下依次执行, 不同任务会被放入不同的等待队列中等待执行;
setTimeout 是异步宏任务, setTimeout 被执行时, 其内部回调函数会被注册并添加到宏任务队列(见宏任务/微任务), 这就意味着当回调被主线程从宏任务队列中拿出来时才会被执行, 但是! setTimeout 的计时过程在放入 Event Table 时就已经开始了, 这就是 setTimeout 计时器误差产生的原因;
此外, 如果timeout嵌套大于 5层, 而时间间隔小于4ms, 则时间间隔增加到4ms, 即多层 setTimeout 嵌套会自带误差;
处理定时器误差

1
2
3
4
5
6
7
8
9
10
11
12
13
function startCountdown(interval) {
let startTime = new Date().getTime();
return setTimeout(()=>{
let endTime = new Date().getTime();
<!-- 定时器误差 -->
const deviation = endTime - (startTime + interval);
count++;

console.log(`${count} 的延迟为 ${deviation} ms`);
<!-- 消除误差 -->
startCountdown(interval - deviation)
}, interval)
}

setInterval 的定时器误差
setInterval 到达计时器阈值时, 会将其回调注入到宏任务队列中, 并且不断重复该过程, 但是! 回调函数是在宏任务队列等待而不是立即执行, 这就导致了定时误差, 它总是会被队列前的任务给阻塞, 并不断累积误差;


ES6

近一万字的ES6语法知识点补充

let、const和var的区别 & 变量提升与暂时性死区

参考文章
var和let/const的区别
近一万字的ES6语法知识点补充
题解
let/const 与 var 的区别:

  1. 块级作用域{};
  2. 不存在变量提升;
  3. 暂时性死区;
  4. 不可重复声明;
  5. let/const 声明的全局变量不会挂载在顶层对象下;

let/const 声明变量时, 会创建一个块级作用域(一个花括号内是一个新的作用域), 块级作用域内声明的变量只能在其内部(包括子作用域)使用;
在同一作用域下, var 声明的变量可在声明前使用, 值为 undefined, let/const 在未声明时使用报错, 值得讨论的是: let/const 其实也存在变量提升的现象, 这是因为 JS 在运行阶段构建执行上下文创建变量时, 会执行函数提升和变量提升, 其声明变量会被创建并提升至作用域顶部, 与 var 不同在于, var 在执行上下文创建变量时就被初始化并赋值为 undefined, 执行时遇到赋值语句则将 undefined 替换, let/const 声明的变量同样会在该阶段被创建, 但不会初始化赋值 undefined, 变量值直到赋值语句执行时才被初始化, 若此时声明变量没有赋值, 则默认赋值 undefined (ES6 标准解释: 由let/const声明的变量,当它们包含的词法环境(Lexical Environment)被实例化时会被创建,但只有在变量的词法绑定(LexicalBinding)已经被求值运算后,才能够被访问);
let/const 声明变量从创建到初始化之间的代码片段称为”暂时性死区”;
let/const 不允许在相同作用域内重复声明同一变量;
ES6 规定 let/const 声明的变量不属于顶层全局变量(浏览器是windows, Node是Global)的属性, let/const 声明的变量在 Script 作用域下, 我们在使用 let/const 时就无需担心污染全局 window 对象;

const 特点:

  1. const 声明后必须初始化赋值;
  2. const 声明的简单类型变量值不可修改, 复杂类型(对象, 函数, 正则)等在栈内存中的指针地址不能更改, 指针所指向堆内存中的数据可以更改;

解构赋值

参考文章
ES6阮一峰: 变量的解构赋值
var和let/const的区别
近一万字的ES6语法知识点补充
题解
请移步阅读”阮一峰 ES6 入门”, 笔记后续补充…

Symbol

ES6篇 - Symbol
参考文章
ES6阮一峰: Symbol

Set 和 Map 数据结构

ES6篇 - Set & Map
参考文章
ES6阮一峰: Set 和 Map 数据结构

Generator

参考文章
ES6阮一峰: Generator 函数的语法
ES6阮一峰: Generator 函数的异步应用
题解
Generator 函数 (一种异步编程解决方案):

1
2
3
4
5
6
7
8
9
10
// 2
function* Gen() {
// 3
yield 'hello';
yield 'world';
return 'ending';
// throw new Error('Some Errors');
}

const gen = Gen(); // 1

Generator 函数基本组成:

  1. Generator 是普通函数(不是构造函数); 执行 Generator 函数, 返回一个遍历器对象/指针对象 (而不是直接运行函数体代码): const gen = Gen()
  2. Generator 函数特征: 1. function* 函数名() {...} 2. yield ...
  3. Genrator 可以理解为一个状态机, 封装多个内部状态(yield, return, ‘throw’);

yield & next

1

  1. 语法: yield 表达式; 迭代器对象.next()
  2. 特点: yield 是 Generator 的暂停标志; 配合迭代器对象的 next 方法可以手动控制函数分步执行;
  3. 运行逻辑: 每一次执行迭代器对象的 next 方法, 会基于当前暂停位置恢复函数执行, 指针指向下一个 yield 暂停标志, 并执行 yield 表达式, next 方法会返回一个 {value:xxx, done: xxx} 对象, 其中 value 为 yield 表达式的值;
  4. 注意事项:
  • yield 表达式惰性求值, 只有当指针对象(迭代器对象)调用 next 方法将指针指向该语句时才会运算求值;
  • yield 表达式只能用在 Generator 函数内;
  • yield 表达式用在另一个表达式内时需要用括号包裹;

yield 是 Generator 函数暂停执行的标志, 它将 Generator 函数体分隔为多个状态, 需要配合 next 方法使用;

后续补充…


手撕代码

GitHub - 手撕代码篇
各种源码实现,你想要的这里都有

Promise(A+规范)及其常用方法

参考文章
手写一款符合Promise/A+规范的Promise
题解
自己实现的Promise: Promise A+ – 无注释版
符合A+规范: 在文件路径下添加 defer 方法并运行 promises-aplus-tests [文件名.js]

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
// 创建 MyPromise 类;
class MyPromise {
// 实例化时接收 executor 函数;
constructor(executor) {
// 管理状态/值/错误信息;
this.state = 'pending';
this.value = undefined;
this.error = undefined;
// 队列: 存储回调;
// 解决 executor 内异步操作导致 then 执行时状态仍为 pending 的情况(保存 then 回调, 在状态改变清空队列);
// 解决多个 then 调用;
this.onFulfilledCallbacks = [];
this.onRejectedCallbacks = [];
// resolve() 改变状态/存储值;
this.resolve = val => {
// 只有当 pending 状态时执行, 即状态改变不可逆
if (this.state === 'pending') {
// 改变状态
this.state = 'fulfilled';
// 存储值
this.value = val;
// 清空成功回调队列
this.onFulfilledCallbacks.forEach(fn => fn());
}
}
// reject() 改变状态/存储错误信息;
this.reject = err => {
if (this.state === 'pending') {
this.state = 'rejected';
this.error = err;
// 清空拒绝回调队列
this.onRejectedCallbacks.forEach(fn => fn());
}
}
// 实例化时自动执行 executor (利用了 constructor 自动执行的特点);
executor(this.resolve, this.reject);
}
// then 方法: 接收两个回调函数;
then(onFulfilled, onRejected) {
// 确保传入的是函数类型, 若没有传入或传入不是函数类型则取默认值;
onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : val => val;
onRejected = typeof onRejected === 'function' ? onRejected : err => { throw err };
// 链式调用: 返回新的 MyPromise 实例;
let promise2 = new MyPromise((resolve, reject) => {
// fulfilled 状态执行;
if (this.state === 'fulfilled') {
// 利用 setTimeout 宏任务模拟原生 Promise then() 微任务, 确保 then 在同步代码清空后执行;
setTimeout(() => {
// try...catch... 确保错误抛出被正确捕获;
try {
// 接收回调返回值作为下一次 then() 回调函数的参数
let x = onFulfilled(this.value);
// 判断回调返回值
this._resolvePromise(promise2, x, resolve, reject);
} catch (err) {
reject(err);
}
}, 0);
}
// rejected 状态执行;
if (this.state === 'rejected') {
setTimeout(() => {
try {
let x = onRejected(this.error);
this._resolvePromise(promise2, x, resolve, reject);
} catch (err) {
reject(err);
}
}, 0);
}
// pending 状态执行;
if (this.state === 'pending') {
// 仍处于 pending 意味着异步还未完成, 将回调保存至回调队列;
this.onFulfilledCallbacks.push(() => {
setTimeout(() => {
try {
// 此处 this.value 在清空队列时才被赋值;
let x = onFulfilled(this.value);
this._resolvePromise(promise2, x, resolve, reject);
} catch (err) {
reject(err);
}
}, 0)
});
this.onRejectedCallbacks.push(() => {
setTimeout(() => {
try {
let x = onRejected(this.error);
this._resolvePromise(promise2, x, resolve, reject);
} catch (err) {
reject(err)
}
}, 0)
});
}
})
return promise2;
}
// 回调返回值判断;
_resolvePromise(promise2, x, resolve, reject) {
// 返回 MyPromise 实例本身会导致循环引用, 报错并终止;
if (promise2 === x) {
reject(new TypeError('Chaining cycle detected for promise'));
return;
}
// then 方法内可能存在多次调用 resolve/reject 的情况, 我们只期望执行最开始的 resolve/reject;
// 通过 called 变量维护上述操作;
let called = false;
// 返回值是 object 或 function 类型且不为空时, 进一步判断;
// 否则意味着返回值是原始类型, 直接传递给 promise2 then 的回调函数;
if (x !== null && (typeof x === 'object' || typeof x === 'function')) {
// try...catch.. 捕获运行 then 方法可能引发的错误;
try {
// 判断 x 是否为 MyPromise 实例: 看它的 then 是否是函数;
let then = x.then;
if (typeof then === 'function') {
// 是函数说明 x 是 MyPromise 实例, 通过 call 立即调用执行(因为下一个 then 要的是值而不是 MyPromise 实例);
// then.call(thisArg, successFunc, rejectFunc);
then.call(x, val => {
// 若 called 为 true 则说明执行过 resolve/reject, 不再执行;
if (called) return;
called = true;
this._resolvePromise(x, val, resolve, reject);
}, err => {
if (called) return;
called = true;
reject(err);
})
} else {
resolve(x)
}
} catch (err) {
if (called) return;
called = true;
reject(err);
}
} else {
resolve(x);
}
}
}

MyPromise.resolve = function (val) {
if (val instanceof MyPromise) return val;
// 改变状态为 fulfilled;
return new MyPromise((resolve, reject) => {
resolve(val);
})
}

MyPromise.reject = function (err) {
// 改变状态为 rejected;
return new MyPromise((resolve, reject) => {
reject(err);
})
}

// 等价于执行 then 的第二个回调函数;
MyPromise.catch = function (onRejected) {
return this.then(null, onRejected)
}

// 接收 promise 对象数组, 当全部 promise 对象 resolve 时执行 onFulfilled 方法;
// 当有一个 promise 对象 reject 时执行 onRejected 方法;
// 成功执行的结果按 promise 对象数组的顺序来排放;
MyPromise.all = function (promises) {
return new MyPromise((resolve, reject) => {
const lens = promises.length;
let arr = new Array(lens);
let count = 0;
// 依据索引顺序存放;
function processData(data, idx) {
arr[idx] = data;
count++;
// 若所有 promises 均成功执行回调, 返回结果数组;
// processData() 写在 MyPromise 内就不需要传递 resolve() 了, 会自动向上索引引用;
if (count === lens) resolve(arr);
}
promises.forEach((item, idx) => {
// 遍历 promises 数组, fulfilled 执行成功回调, 存放数据, rejected 执行拒绝回调, 直接退出遍历;
item.then(res => {
processData(res, idx);
}, err => reject(err))
})
})
}

// 接收 promise 对象数组, 返回最先完成的 promise 对象, 不关心它的状态(fulfilled, rejected);
MyPromise.race = function (promises) {
return new MyPromise((resolve, reject) => {
promises.forEach(item => {
// 遍历数组, 若某 promise 状态落定, 则改变状态并结束遍历;
item.then(res => resolve(res), err => reject(err))
})
})
}

// 无论 promise 状态如何, 都会执行 fn 回调
MyPromise.finally = function (fn) {
return this.then(
// resolve() 保证 fn() 必被执行
// 后续跟上 then() 用于传递 value 或 error;
val => MyPromise.resolve(fn()).then(() => val),
err => MyPromise.resolve(fn()).then(() => err)
)
}

// 实现一个promise的延迟对象 defer, 用于 Promise A+ 规范测试
MyPromise.defer = MyPromise.deferred = function () {
let dfd = {};
dfd.promise = new MyPromise((resolve, reject) => {
dfd.resolve = resolve;
dfd.reject = reject;
});
return dfd;
}

module.exports = MyPromise;

Iterator 迭代器实现 & Iterable 可迭代对象实现

参考文章
理解ES6的 Iterator 、Iterable 、 Generator
Iterable object(可迭代对象)
题解
Iterator 迭代器: 满足迭代器协议的对象;
迭代器协议: 对象内置 next() 方法, next() 方法是一个无参函数, 其返回一个对象, 对象拥有 done 和 value 两个属性;

  1. done(boolean):
  • done === true, 表示迭代器已经经过了被迭代序列(并非表示不可迭代);
  • done === false, 表示迭代器可以产生序列中的下一个值;
  1. value: 迭代器返回的任意值, done == true 时可省略, value 省略值为 undefined;

简单理解: 迭代器就是在目标上创建了一个 next() 模拟”指针”, 每次调用 next(), “指针”右移一位, 并返回当前”指针”所指的状态(done)和值(value); 你可以将迭代器理解为一个带由内置指针实现的对象, next() 实现指针操作, 每次执行 next(), 指针会返回当前所指的值和状态, 然后指针右移一位;
注意: done === true 并不代表迭代器无法使用了, 此时仍可以调用 next(), “指针”仍然右移, 但其返回值为自定义值或省略值(此处为 undefined)而非被迭代序列的内部值;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function createIterator(target) {
// 迭代器实现用到了函数闭包的特性;
let i = 0;
// 1. 迭代器: 一个对象(满足迭代器协议), 包含一个 next() 方法;
return {
// 2. next() 方法;
next() {
let done = (i >= target.length);
let value = done ? undefined : target[i++];
// 返回包含 done 和 value 属性的对象;
return { done, value }
}
}
}

Iterable 可迭代对象: 满足可迭代协议的对象;
可迭代协议: 对象包含 [Symbol.iterator] 属性(Symbol 在对象中通过 [] 调用), 其值是一个无参函数, 该函数返回一个迭代器;
ES6 内置可迭代对象:

  1. Array
  2. Map
  3. Set
  4. String
  5. TypedArray
  6. arguments
  7. NodeList

可迭代对象可被用于:

  1. for…of…
  2. 扩展运算符(…)
  3. yield*
  4. 解构赋值

注意:

  1. 迭代器对象不能用于 for…of, 解构赋值等操作, 需要包装成可迭代对象;
  2. for..of, 解构赋值等操作都会”消耗”迭代器(迭代一遍后, 可迭代对象内的迭代器 done === ture), 因此无法多次对同一可迭代对象执行迭代操作;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 可迭代对象实现: 包装一个迭代器, 并通过 [Symbol.iterator] 暴露;
function createIterable(target) {
let i = 0;
return {
// 比迭代器多实现了一个 [Symbol.iterator], 返回迭代器;
[Symbol.iterator]() {
return this;
},

next() {
let done = (i >= target.length);
let value = !done ? target[i++] : undefined;
return { done, value }
}
}
}

普通对象没有实现可迭代协议, 我们可以向 Object 原型内添加 [Symbol.iterator] 属性, 使其成为可迭代对象;

1
2
3
4
5
6
7
8
9
10
11
12
Object.prototype[Symbol.iterator] = function () {
// 将对象转为数组[[key,value],...]
let target = Object.entries(this);
let i = 0;
return {
next() {
let done = (i >= target.length)
let value = !done ? target[i++] : undefined;
return { done, value }
}
}
}

Thunk函数实现(结合Generator实现异步)

参考文章
理解thunk函数的作用及co的实现
Generator
题解
后续补充…

async实现原理(spawn函数)

参考文章
Async / Await / Generator 实现原理
题解
后续补充…

继承的几种实现与比较

参考文章
隔壁小孩也能看懂的 7 种 JavaScript 继承实现
重学 JS 系列:聊聊继承
题解
原型链继承

  • 所有实例共享父类属性和方法(共享同一内存空间);
  • 若内存空间内的值被修改, 所有继承的子类实例值都会发生改变;
  • 子类不能向父类传参;
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    let Parent = function() {
    this.name = 'Siri';
    this.hobby = [];
    this.sayHi = function() {
    console.log('Hi ' + this.name);
    }
    }
    let Child = function() {
    this.action = 'eat';
    }
    <!-- 子类原型连接父实例 -->
    <!-- 不能通过父子原型直接连接 -->
    Child.prototype = new Parent();

构造函数继承

  • 避免了引用类型的属性被所有实例共享;
  • 可以在child中向parent传参;
  • 子类每次实例化都创建一个 this, 挂载所有父类属性和方法, 导致一些共享方法重复创建;
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    let Parent = function(age) {
    this.name = 'Siri';
    this.hobby = [];
    this.age = age;
    this.sayHi = function() {
    console.log('Hi ' + this.name);
    }
    }
    let Child = function() {
    // call 执行父类构造函数, 将父类所有属性方法挂载到子类 this 上;
    Parent.call(this, 18);
    this.action = 'eat';
    }

组合式继承
原型链继承特点: 共享父类属性和方法; 构造函数继承特点: 各子类实例有独立的属性和方法;
我们期望各子类实例能共享父类的方法, 同时能维护自己的属性;
组合式继承: 在父类原型上定义方法, 在父类构造函数内定义属性, 子类通过原型链继承实现对函数的复用,通过构造函数继承保证每个实例都有它自己的属性;

  • 子类实例能维护自己的属性, 同时共享父类方法;
  • 组合式继承最大的缺点在于其调用两次父构造函数, 一次是设置子类实例的原型的时候, 一次是在创建子类型实例的时候; 这导致实例对象和原型对象上的属性值重复, 子类索引父类属性时,会优先在实例对象上找到属性, 不会继续通过原型链向原型对象查找,而这部分原型对象上的属性值就浪费了存储空间;
  • 组合式继承的缺点在后续寄生式组合继承中解决;
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    let Parent = function(age) {
    this.name = 'Siri';
    this.hobby = [];
    this.age = age;
    }
    Parent.prototype.sayHi = function() {
    console.log('Hi ' + this.name);
    }

    let Child = function() {
    // 属性通过构造函数继承;
    Parent.call(this, 18);
    this.action = 'eat';
    }
    // 共享方法通过原型链继承;
    Child.prototype = new Parent();

原型式继承

  • 创建空的构造函数, 连接原型, 返回构造函数的实例; 实例可以通过原型链访问到传入对象;
  • ES5 通过 Object.create() 规范化了原型式继承;
  • 原型式继承与原型链继承类似, 引用类型的属性值始终会共享相应的值;
    1
    2
    3
    4
    5
    function create(obj) {
    function F() {}
    F.prototype = obj;
    return new F();
    }

寄生组合式继承

  • 组合式继承中两次调用了父类构造函数, 导致子类实例对象中包含了父类属性, 其原型对象也挂载了父类属性, 而索引父类属性时在子类实例中找到属性则停止索引, 其原型对象的父类属性占用了空间导致内存浪费;
  • *寄生组合关键在于其用临时空构造函数代替了父类构造函数完成了原型链继承, 只改变空构造函数原型指向获取父类原型, 获取其原型上的共享方法, 而不挂载父类构造函数的属性, 从而避免了内存浪费;
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    let Parent = function() {
    this.name = 'Siri';
    this.hobby = [];
    this.age = age;
    }
    Parent.prototype.sayHi = function() {
    console.log('Hi ' + this.name);
    }

    let Child = function() {
    // 构造函数继承;
    Parent.call(this, 18);
    this.action = 'eat';
    }

    // 关键: 用临时空构造函数仅接收父类原型的共享方法, 而不挂载父类的属性;
    let F = function() {};
    F.prototype = Parent.prototype;
    // 原型链继承;
    Child.prototype = new F();

class的继承

参考文章
隔壁小孩也能看懂的 7 种 JavaScript 继承实现
重学 JS 系列:聊聊继承
题解

  • 子类必须在 constructor 方法中调用 super方法 (子类没有 this 对象, 其 this 继承于父类);
  • 只有调用 super 之后, 才可以使用 this 关键字, 这是因为子类实例的构建, 是基于对父类实例加工, 只有 super 方法才能返回父类实例;

与 ES5 构造函数继承的区别:
ES5 的继承实质是先创造子类的实例对象 this, 然后再将父类的方法添加到 this 上面(Parent.call(this));
ES6 的继承机制实质是先创造父类的实例对象 this (所以必须先调用 super() 方法), 然后再用子类的构造函数修改 this;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class parents {
constructor(){
this.grandmather = 'rose';
this.grandfather = 'jack';
}
}

class children extends parents{
constructor(mather,father){
//super 关键字,表示父类的构造函数,用来新建父类的 this 对象。
super();
this.mather = mather;
this.father = father;
}
}

防抖和节流

参考文章
函数防抖和节流
JavaScript专题之跟着underscore学防抖
JavaScript专题之跟着 underscore 学节流
死磕 36 个 JS 手写题
题解
非立即执行版防抖

1
2
3
4
5
6
7
8
9
10
function debounce(fn, wait ) {
let timer = null;
return function (...args) {
const ctx = this;
clearTimeout(timer)
timer = setTimeout(() => {
fn.call(ctx, args)
}, wait)
}
}

立即执行版防抖

1
2
3
4
5
6
7
8
9
10
11
12
13
function debounce(fn, wait) {
let timer = null;
return function (...args) {
const ctx = this;
clearTimeout(timer);
if (!timer) {
fn.call(ctx, args);
}
timer = setTimeout(() => {
timer = null
}, wait)
}
}

合并版防抖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function debounce(fn, wait, immediate) {
let timer = null;
return function (...args) {
const ctx = this;
timer && clearTimeout(timer)
if (immediate) {
let callNow = !timer;
timer = setTimeout(()=>{
timer = null;
}, wait)
if (callNow) fn.call(ctx, args);
} else {
timer = setTimeout(()=>{
fn.call(ctx, args)
}, wait)
}
}
}

防抖关键: 每次执行函数前, 清空上一次的计时器, 并在回调执行后设置新的计时器;

非立即执行版节流

1
2
3
4
5
6
7
8
9
10
11
12
function throttle(fn, wait) {
let timer = null;
return function (...args) {
const ctx = this;
if (!timer) {
timer = setTimeout(() => {
fn.call(ctx, args);
timer = null;
}, wait)
}
}
}

立即执行版节流

1
2
3
4
5
6
7
8
9
10
11
function throttle(fn, wait) {
let preTime = 0;
return function (...args) {
const ctx = this;
let curTime = new Date().getTime();
if (curTime - preTime > wait) {
fn.call(ctx, args);
preTime = new Date().getTime();
}
}
}

合并版节流

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function throttle(fn, wait) {
let timer = null, preTime = 0;
return function (...args) {
const ctx = this;
let curTime = new Date().getTime();
if (curTime - preTime > wait) {
if (timer) {
clearTimeout(timer);
timer = null;
}
fn.call(ctx, args);
preTime = new Date().getTime();
} else if (!timer) {
timer = setTimeout(()=>{
preTime = new Date().getTime();
fn.call(ctx, args);
timer = null;
},wait-(curTime-preTime))
}
}
}

可控的合并版节流

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
// options.leading === false: 禁用立即执行
// options.trailing === false: 禁用非立即执行
function throttle(fn, wait, options) {
let timer = null, preTime = 0;

return function (...args) {
const ctx = this;
let curTime = new Date().getTime();
if (!options.leading && preTime === 0) preTime = curTime;
if (curTime - preTime > wait) {
if (timer) {
clearTimeout(timer);
timer = null;
}
fn.call(ctx, args);
preTime = new Date().getTime();
} else if (!timer && options.trailing) {
timer = setTimeout(() => {
fn.call(ctx, args);
timer = null;
preTime = options.leading ? new Date().getTime() : 0;
}, wait - (curTime - preTime))
}
}
}

节流关键: 设置一个开关(关闭状态无法执行回调), 回调执行时, 关闭开关并计时, 计时结束打开开关, 与防抖区别在于它不需要在每次触发函数时重置开关;


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!