Skip to content

JavaScript 高级教程

函数中this指向

函数在调用时, Javascript会默认为this绑定一个值

js
// 定义一个函数
function foo() {
  console.log(this)
}

// 1. 直接调用
foo() // Window

// 2. 绑定对象调用
const obj = { name: 'ziu', aaa: foo }
obj.aaa() // obj

// 3. 通过call/apply调用
foo.call('Ziu') // String {'Ziu'}

this的绑定:

  • 和定义的位置没有关系
  • 和调用方式/调用位置有关系
  • 是在运行时被绑定的

this始终指向最后调用它的对象

js
function foo() {
  console.log(this)
}
foo() // Window

const obj = {
  name: 'ziu',
  bar: function () {
    console.log(this)
  }
}
obj.bar() // obj

const baz = obj.bar
baz() // Window

如何改变this的指向

new 实例化一个函数

new一个对象时发生了什么:

  1. 创建一个空对象
  2. 这个空对象会被执行prototype连接
  3. 将this指向这个空对象
  4. 执行函数体中的代码
  5. 没有显式返回这个对象时 会默认返回这个对象

函数可以作为一个构造函数, 作为一个类, 可以通过new关键字将其实例化

js
function foo() {
  console.log(this)
  this.name = 'Ziu'
}
foo() // 直接调用的话 this为Window

new foo() // 通过new关键字调用 则this指向空对象

使用 call apply bind

在 JavaScript 中, 函数是对象。

JavaScript 函数有它的属性和方法。call() 和 apply() 是预定义的函数方法。

两个方法可用于调用函数,两个方法的第一个参数必须是对象本身


要将foo函数中的this指向obj,可以通过赋值的方式:

js
obj.foo = foo // 绑定
obj.foo() // 调用

但是也可以通过对函数调用call / apply实现

js
var obj = {
  name: 'Ziu'
}

function foo() {
  console.log(this)
}

foo.call(obj) // 将foo执行时的this显式绑定到了obj上 并调用foo
foo.call(123) // foo的this被绑定到了 Number { 123 } 上
foo.call("ziu") // 绑定到了 String { "ziu" } 上

包装类对象

当我们直接使用类似:

js
"ZiuChen".length // String.length

的语句时,JS会为字符串 ZiuChen 包装一个对象,随后在这个对象上调用 .length 属性

call和apply的区别

  • 相同点:第一个参数都是相同的,要求传入一个对象
    • 在函数调用时,会将this绑定到这个传入的对象上
  • 不同点:后面的参数
    • apply 传入的是一个数组
    • call 传入的是参数列表
js
function foo(name, age, height) {
  console.log(this)
}

foo('Ziu', 18, 1.88)

foo.apply('targetThis', ['Ziu', 18, 1.88])

foo.call('targetThis', 'Ziu', 18, 1.88)

当我们需要给一个带参数的函数通过call/apply的方式绑定this时,就需要使用到call/apply的第二个入参了。

参数列表

当传入函数的参数有多个时,可以通过...args将参数合并到一个数组中去

js
function foo(...args) {
  console.log(args)
}

foo("Ziu", 18, 1.88) // ["Ziu", 18, 1.88]

bind绑定

如果我们希望:在每次调用foo时,都能将obj绑定到foothis上,那么就需要用到bind

js
// 将obj绑定到foo上
const fun = foo.bind(obj)
// 在后续每次调用foo时, foo内的this都将指向obj
fun() // obj
fun() // obj

bind()方法将创建一个新的函数,当被调用时,将其this关键字

箭头函数

箭头函数是ES6新增的编写函数的方式,更简洁。

  • 箭头函数不会绑定thisarguments属性

    • arguments
  • 箭头函数不能作为构造函数来使用(不能与new同用,会报错)

箭头函数中的this

在箭头函数中是没有this的:

js
const foo = () => {
  console.log(this)
}
foo() // window
console.log(this) // window

之所以找到了Window对象,是因为在调用foo()时,函数内部作用域并没有找到this,转而向上层作用域找this

因此找到了顶层的全局this,也即Window对象

箭头函数中this的查找规则

检查以下代码:

js
const obj = {
  name: "obj",
  foo: function () {
    const bar = () => {
      console.log(this)
    }
    return bar
  }
}
const fn = obj.foo()
fn() // obj

代码执行完毕,控制台输出this值为obj对象,这是为什么?

箭头函数中没有this,故会向上层作用域寻找thisbar的上层作用域为函数foo,而函数foothis由其调用决定

调用foo函数的为obj对象,故内部箭头函数中的this指向的是obj

检查以下代码:

js
const obj = {
  name: "obj",
  foo: () => {
    const bar = () => {
      console.log(this)
    }
    return bar
  }
}
const fn = obj.foo()
fn() // Window

和上面的代码不同之处在于:foo也是由箭头函数定义的,bar向上找不到foothis,故而继续向上,找到了全局this,也即Window对象

严格模式

  • 在严格模式下,全局的this不是Window对象,而是undefined
  • 在 JavaScript 严格模式(strict mode)下, 在调用函数时第一个参数会成为 this 的值, 即使该参数不是一个对象。
  • 在 JavaScript 非严格模式(non-strict mode)下, 如果第一个参数的值是 null 或 undefined, 它将使用全局对象替代。

this面试题

js
var name = 'window'

var person = {
  name: 'person',
  sayName: function () {
    console.log(this.name)
  }
}

function sayName() {
  var sss = person.sayName

  sss() // 默认绑定: window
  person.sayName();  // 隐式绑定: person
  (person.sayName)() // 隐式绑定: person, 本质与上一行代码相同
  ;(person.sayName = person.sayName)() // 间接调用: window
}

sayName()
js
var name = 'window'

var person1 = {
  name: 'person1',
  foo1: function () {
    console.log(this.name)
  },
  foo2: () => console.log(this.name),
  foo3: function () {
    return function () {
      console.log(this.name)
    }
  },
  foo4: function () {
    return () => console.log(this.name)
  }
}

var person2 = {
  name: 'person2'
}

person1.foo1() // 隐式绑定: person1
person1.foo1.call(person2) // 显式绑定: person2

person1.foo2() // 上层作用域: window
person1.foo2.call(person2) // 上层作用域: window

person1.foo3()() // 默认绑定: window
person1.foo3.call(person2)() // 默认绑定: window
person1.foo3().call(person2) // 显式绑定: person2

person1.foo4()() // 隐式绑定: person1
person1.foo4.call(person2)() // 显式绑定: person2
person1.foo4().call(person2) // 隐式绑定: person1

原型与继承

JavaScript中,任何一个对象都有一个特殊的内置属性[[prototype]],称之为原型

  • 可以通过.__proto__Object.getPrototypeOf(obj)获取到这个原型对象
js
const obj = {
  name: 'Ziu',
  age: 18
}

console.log(obj) // obj
console.log(obj.__proto__) // 由浏览器添加 非标准
console.log(Object.getPrototypeOf(obj)) // 标准的获取原型的方法

原型有什么作用?

当我们通过[[getter]]执行 obj.name 时:

  • 首先在自身上找name属性,如果找到了则直接返回
  • 如果没找到,则沿着原型链向上查找,检查其原型是否存在该属性

手动为obj的原型添加message属性后,在obj上获取name属性,会沿着原型链找到原型上的message属性

js
const proto = Object.getPrototypeOf(obj)
proto.message = 'Hello, Prototype.'
console.log(obj.message) // Hello, Prototype.

函数的显式原型

之前我们说对象的原型都是隐式的,不能直接通过属性直接获取(.__proto__的方式是非标准)

函数是存在一个名为prototype的显式原型的,可以通过这个属性获取到函数的原型,向原型添加额外的属性

每次在通过new操作符创建对象时,将对象的隐式原型指向这个显式原型

js
function Student(name, age) {
  this.name = name
  this.age = age
}

// 向构造函数的显式原型添加公共方法
Student.prototype.running = function () {
  console.log(`${this.name} is running`)
}

const s1 = new Student('ziu', 18)
const s2 = new Student('kobe', 19)
const s3 = new Student('brant', 20)

// 任何一个由该构造函数创建的对象实例的原型都指向函数的显式原型
// 可以调用原型上的公共方法
s1.running() // ziu is running
s2.running() // kobe is running

// 对象实例的原型上包含构造函数与我们手动添加上去的公共方法
console.log(Object.getPrototypeOf(s1)) // {running: ƒ, constructor: ƒ}

添加在对象原型上的方法,在被多个实例调用时只会开辟一块内存空间

如果将公共方法放到构造函数中,那么每创建一个实例,都会为这个方法开辟一块新的内存空间

构造函数的显式原型中的属性constructor,这个属性即指向构造函数,因此存在关系Student --proto-> {constructor: Student}

Object的原型

当我们定义了一个对象,它的原型即为Object,而Object的原型则为Null

js
const obj = {
  name: 'Ziu'
}

const p1 = Object.getPrototypeOf(obj) // Object
const p2 = Object.getPrototypeOf(p1) // null

console.log(p1)
console.log(p2)

以上一节的例子举例:

js
// s1是构造函数Student的实例
const p1 = Object.getPrototypeOf(s1)
const p2 = Object.getPrototypeOf(p1)
const p3 = Object.getPrototypeOf(p2)

console.log(Student.prototype) // {running: ƒ, constructor: ƒ}
console.log(p1) // {running: ƒ, constructor: ƒ}
console.log(p2) // Object
console.log(p3) // null

原型链实现继承

创建两个构造函数PersonStudent,各自有自身的方法,我们希望实现Student继承Person的方法

js
function Person(name, age) {
  this.name = name
  this.age = age
}

Person.prototype.running = function () {
  console.log(`${this.name} is running.`)
}

function Student(name, age, id, score) {
  this.name = name
  this.age = age
  this.id = id
  this.score = score
}

Student.prototype.studying = function () {
  console.log(`${this.name} is studying.`)
}

const s1 = new Student('Ziu', 18, 2, 60)
s1.running() // ERROR: s1.running is not a function

显然现在StudentPerson是没有任何关联的,要让二者联系起来有以下几种方法:

方法一(错误)

js
Student.prototype = Person.prototype

可以让Student构造函数的显式原型指向Person的显式原型,这时可以顺利在s1实例上调用.running()方法,但是存在问题:

  • 在子类Student添加的studying方法会被添加到父类Person的显式原型上,这并不合理
    • 父类和子类共享一个原型对象,子类的方法都被添加到了父类的原型对象上
    • 此后如果有从Person继承的子类,那这个子类也将可以调用studying方法,这是因为所有子类的方法都被放到了父类Person的显式原型上

方法二(有效但不合理)

创建一个父类的实例对象(new Person())用这个实例对象作为子类的原型对象

js
const p = new Person()
Student.prototype = p

创建一个p对象,其隐式原型__proto__指向Person的显式原型对象上

通过这个方法,Student添加的studying方法将被放到p对象上,此后由Student派生的类,其隐式原型也将被指定到p对象上,而不会污染原始的原型

但是当我们会发现,两个构造函数的内容是有重复的:

js
function Person(name, age) {
  this.name = name
  this.age = age
}
function Student(name, age, id, score) {
  this.name = name
  this.age = age
  this.id = id
  this.score = score
}

针对nameage的定义完全可以被复用

方法三(最终)

借用构造函数方法

js
function Student(name, age, id, score) {
  Person.call(this, name, age) // 通过Person.call 绑定this到当前的s实例上
  this.id = id
  this.score = score
}

Student.prototype = Person.prototype

const s = new Student('ziu', 18, 2, 60)
s.running()
  • 借用继承的方法很简单:在子类构造函数内部调用父类型构造函数
    • 因为函数可以在任意时刻被调用
    • 通过apply() call()方法也可以在创建新的对象上执行构造函数

创建原型对象的方法

js
function Person(name, age, height) {}
function Student() {}

要实现Student对Person的继承:

之前我们通过new关键字,为Student创建一个新的原型对象,也介绍了这种方法存在的弊端

js
const p = new Person()
Student.prototype = p.prototype

可以使用另一种更优秀的方案:

js
var obj = {}
// obj.__proto__ = Person.prototype // 非规范用法 兼容性不保证
Object.setPrototypeOf(obj, Person.prototype) // 挂载原型对象
Student.prototype = obj

社区中也有一种方案:

不需要再new Person(),而是用另外一个构造函数,此后需要挂载原型时都通过new F()来实现

  • 更具备通用性,此处做Person的继承,后续也可以做Animal的继承
  • 如果浏览器不支持Object.setPrototypeOf()则此方法更优
js
function F() {}
F.prototype = Person.prototype
Student.prototype = new F()

最终方案:

从之前的代码可知,要达到的目的有两个:1. 创建一个新对象 让原型对象不要被污染 2. 将新对象的原型挂载到目标原型上

js
var obj = Object.create(Person.prototype)
Student.prototype = obj

Object.create()传入的参数是原型对象,可以在创建新对象的同时将此对象的原型指向目标原型对象上

开发封装

根据上述最终方案的原理,在开发中可以对继承方案做如下封装:

js
function inherit(subType, superType) {
  // 创建一个新对象并将新对象的原型指向父类构造函数的原型
  subType.prototype = Object.create(superType.prototype)

  // 为子类的原型添加constructor属性
  Object.defineProperty(subType.prototype, 'constructor', {
    enumerable: false, // 不可被枚举
    configurable: true, // 值可被修改
    writable: true, // 值可被覆写
    value: subType // 初始值: 父类构造函数
  })
}

inherit(Student, Person)

另外,如果担心Object.create()的兼容性,也可以将创建对象的代码替换为:

js
function createObject(o) {
  function F() {}
  F.prototype = o
  return new F()
}
...
  subType.prototype = createObject(superType.prototype)
...

也可以实现:创建对象并修改新对象的原型指向

对象方法补充

  • hasOwnProperty
    • 对象上是否有某一个属于自己的属性(不是在原型上的属性)
js
const info = {
  name: 'Ziu',
  age: 18
}
const obj = createObject(info) // 创建一个新对象并将新对象的原型指向info
console.log(obj.name) // Ziu: 对象自身上并没有该属性 会沿着原型链找到info对象
console.log(obj.hasOwnProperty('name')) // false: 因为对象身上并没有该属性
  • in 或 for-in
    • 判断某个属性是否存在于某个对象或对象的原型上

以上文中例子举例,用in操作符可以沿着原型链获取属性:

for-in遍历的不只是自己身上的属性,也包括原型上的属性,因为对象都是继承自Object,而Object中的属性其属性描述符enumerable默认为false,故自己创建的对象在遍历时不会遍历到Object对象上的属性

js
console.log('name' in obj) // true: 对象上没有 则沿着原型链找到原型上的属性
for (const key in obj) {
  console.log(key) // name age
}
  • instanceof
    • 用于判断构造函数(Person Student类)的prototype,是否出现在某个实例对象的原型链上
    • 用于判断对象与构造函数之间的关系
    • 该运算符右侧必须为一个对象:s instanceof null将报错
js
function Person() {}
function Student() {}

inherit(Student, Person) // 将Student的隐式原型指向Person的隐式原型(继承)

const s = new Student()
console.log(s instanceof Student) // true
console.log(s instanceof Person) // true
console.log(s instanceof Object) // true
console.log(s instanceof Array) // false

instanceof会沿着原型链查找:s -> Student -> Person -> Object

  • isPrototypeOf
    • 用于检测某个对象是否出现在某个实例对象的原型链上
    • 可以用于判断对象之间的继承关系
js
const s = new Student()
console.log(Student.prototype.isPrototypeOf(s)) // true

解读原型继承关系图

  • f1是Foo的实例对象
  • obj是Object的实例对象
  • Function/Object/Foo都是Function的实例对象
  • 原型对象默认创建时,其隐式原型都是指向Object的显示原型(Object指向null)

JavaScript Object Layout

在解读之前首先明确以下几点:

  • 对象都有隐式原型,可以通过Object.getPrototypeof()__proto__(非标准)获取到
  • 函数也是对象,有隐式原型,也有显式原型,显式原型可以通过.prototype获取到
  • Object.getPrototypeOf()本应传入的是一个对象,当我们为其传入一个构造函数时,是将此构造函数视为对象,此时通过Object.getPrototypeOf(Foo)获取到的是函数对象的原型,即Function.prototype

由上至下解读,首先解读function Foo()这一层:

通过构造函数new Foo()创建了两个实例对象f1 f2f1 f2是由function Foo创建出来的,故其隐式原型指向Foo.prototype

Foo既是一个函数,也是一个对象(由new Function()创建出来的)。

  • 作为函数,它拥有显式原型prototype,指向Foo.prototype这个原型对象
  • 作为对象,它拥有隐式原型__proto__,指向Function.prototype这个原型对象

Foo.prototype作为一个原型对象,拥有他的构造函数constructor指向Foo,也拥有它的隐式原型__proto__指向Object.prototype(本质上Foo.prototype也是由new Object()创建出来的)

随后,开始解读function Object()这第二层:

通过构造函数new Object()创建出来两个实例对象o1 o2,它们是由function Object创建出来的,故其隐式原型指向Object.prototype

同样的,function Object作为构造函数对象,它既拥有作为函数的显式原型对象,也拥有作为对象的隐式原型对象

  • 作为函数,它拥有显式原型,指向Object.prototype
  • 作为对象,它是由new Function()创建的,其隐式原型指向Function.prototype

需要注意的是,原型对象Object.prototype的隐式原型指向null,它的构造函数constructor指向function Object

继续解读function Function()第三层:

JavaScript中的函数对象都是通过function Function()这个构造函数创建出来的,所以所有函数的隐式原型__proto__都指向Function.prototype

  • function Function()作为函数,其显式原型指向Function.prototype
  • 而当其作为对象,是由new Function()创建出来的,故其隐式原型也指向Function.prototype

构造函数的类方法

需要区分一下类方法与实例方法:

  • 实例方法:在实例上调用,会沿着原型链向上查找
  • 类方法:在类上(构造函数上)直接调用

在下面的例子中,我们向Person.prototype上添加了方法running,当我们在实例对象p上调用running时,会沿着p p.__proto__(Person.prototype)查找,在Person.prototype上找到running方法后进行调用

然而我们在Person类上直接调用running()则不行,这是因为Person只是Person.prototypeconstructor属性,是它的构造函数,与原型链没有关系,Person上并没有running()方法,Person.prototype上才有

js
function Person(name) {
  this.name = name
}
Person.prototype.running = function () {
  console.log(`${this.name} is running.`)
}
const p = new Person('Ziu')
p.running() // Ziu is running
Person.running() // undefined

要实现类方法,只需要直接在构造函数上添加新属性即可,因为构造函数本身也是一个对象:构造函数对象

js
const names = ['abc', 'cba', 'nba', 'mba']
Person.randomPerson = function () {
  const randName = names[Math.floor(Math.random() * names.length)]
  return new Person(randName)
}

const p2 = Person.randomPerson()
console.log(p2)

上例中,我们手动向Person构造函数对象中添加了类方法randomPerson,可以直接在Person上进行调用

ES6继承

  • class方式定义类
  • extends实现继承
  • Babel的ES6转ES5
  • 面向对象多态理解
  • ES6对象的增强

ES6提供了class定义类的语法糖,其本质的特性是与ES6之前实现继承的方式是一样的

js
class Person {
  constructor(name) {
    this.name = name
  }
  // 实例方法
  running() {
    console.log(`${this.name} is running.`)
  }
  // 静态方法(类方法)
  static randomPerson() {
    console.log('static func: random person')
  }
}

const p = new Person('Ziu') // 根据类创建实例

console.log(Person.prototype === p.__proto__) // true
console.log(Person.prototype.constructor) // class Person ...
console.log(typeof Person) // function

当我们通过new关键字操作类时,会调用这个constructor函数,并执行以下操作:

  1. 在内存中创建一个新的对象(空对象)
  2. 这个对象内部的[[prototype]]属性(隐式原型__proto__)会被赋值为该类的prototype属性
  3. 构造函数内部的this,会指向创建出来的新对象
  4. 执行构造函数内的代码
  5. 如果构造函数没有返回非空对象,则返回创建出来的新对象

与function的异同

区别在于:定义构造函数与定义实例方法的部分聚合到了一起(高内聚、低耦合)相同的功能如果要用function来实现:

js
function Person(name) {
  this.name = name
}
Person.prototype.running = function () {
  console.log(`${this.name} is running.`)
}
const p = new Person('Ziu')
console.log(Person.prototype === p.__proto__) // true
console.log(Person.prototype.constructor) // function Person ...
console.log(typeof Person) // function

运行代码可知,几个console.log输出都是相同的,这证明二者的本质是相同的,Class语法只是一种语法糖

二者之间是存在区别的:

  • 构造函数function Person可以通过()作为普通函数调用
  • class Person则不可以直接调用:Class constructor Person cannot be invoked without 'new'

定义访问器方法

为对象属性定义访问器(Object.defineProperty()):

js
const obj = {}
let value

Object.defineProperty(obj, 'name', {
  get: function () {
    console.log('name getted')
    return value
  },
  set: function (val) {
    console.log('name setted ' + val)
    value = val
    return value
  }
})

obj.name = 'Ziu' // name setted Ziu
const tmp = obj.name // name getted

直接在对象中定义访问器:

js
const obj = {
  _name: 'Ziu',
  get name() {
    return this._name
  },
  set name(val) {
    this._name = val
    return true
  }
}

在ES6的class关键字中定义访问器:

js
class Person {
  constructor(name) {
    this._name = name
  }
  get name() {
    return this._name
  }
  set name(val) {
    this._name = val
    return true
  }
}

访问器的应用场景

当我们需要频繁的对某些属性组合进行调用时,可以将这些属性组合,在外部可以通过访问器获得计算好的值

例如下述代码中实现了一个简单的Rectangle类,可以通过访问器直接获取其positionsize属性

js
class Rectangle {
  constructor(x, y, width, height) {
    this.x = x
    this.y = y
    this.width = width
    this.height = height
  }

  get position() {
    return { x: this.x, y: this.y }
  }

  get size() {
    return this.width * this.height
  }
}

const rect = new Rectangle(10, 15, 20, 50)
console.log(rect.position, rect.size)

类的静态方法

在ES6之前我们将这种方法称为类方法,在其之后我们将其称为静态方法

静态方法通常用于定义直接使用类来执行的方法,不需要有类的实例,使用static关键字来定义:

js
class Person {
  constructor(name) {
    this.name = name
  }
  // 实例方法
  running() {
    console.log(`${this.name} is running.`)
  }
  // 静态方法(类方法)
  static randomPerson() {
    console.log('static func: random person')
  }
}

本质上:

js
Person.randomPerson = function() {
  console.log('static func: random person')
}

extends实现继承

Person类的基础上实现Student类继承自Person

在子类的构造函数内需要通过super()并传入父类(超类)构造函数需要的参数,这样就可以调用父类的构造函数

js
class Person {
  constructor(name) {
    this.name = name
  }
  running() {
    console.log(`${this.name} is running.`)
  }
  sitting() {
    console.log(`${this.name} is sitting.`)
  }
}

class Student extends Person {
  constructor(name, score) {
    super(name) // 在子类的constructor中通过super()调用父类的构造函数
    this.score = score
  }
  studying() {
    super.sitting() // 在子类中通过super.method()调用父类的方法
    console.log(`${this.name} is studying.`)
  }
}

const s = new Student('Ziu', 60)
s.running() // 可以调用父类方法
s.studying() // 也可以调用自己的方法
  • 在子类中,执行super.method(...)可以调用一个父类方法
  • 在子类的constructor中,执行super(...)来调用父类constructor

在子(派生)类的构造函数中使用this或者返回默认对象之前,必须先通过super调用父类的构造函数

js
...
constructor(name, score) {
  this.score = score
  super(name) // 这是不对的,应该在使用this之前先调用super
}
...

继承自默认类

可以通过继承,为内置类做修改或扩展,方便我们使用:

在下例中,我们对内置类Array做了扩展,添加了访问器属性lastItem可以获取数组内最后一个元素

js
class SelfArray extends Array {
  get lastItem() {
    return this[this.length - 1]
  }
}

const arr = new SelfArray(5).fill(false)
console.log(arr)
console.log(arr.lastItem)

传统方式也可以实现,通过操作原型:

Array.prototype定义属性lastItem,并为其添加getter函数,可以通过lastItem获取最后一个元素

需要注意的是,这种方法会对所有Array对象产生影响,所有的数组都可以调用这个方法

js
Object.defineProperty(Array.prototype, 'lastItem', {
  get: function () {
    return this[this.length - 1]
  }
})

const arr = new Array(5).fill(false)
console.log(arr.lastItem)

类的混入mixin

JavaScript只支持单继承,不支持多继承。要让一个类继承自多个父类,调用来自不同父类的方法,可以使用mixin

下例中我们希望Bird能够同时继承自AnimalFlyer类,直接使用extends会报错

js
function mixinAnimal(BaseClass) {
  // 1. 创建一个新的类 继承自baseClass
  // 2. 对这个新的类进行扩展 添加running方法
  return class extends BaseClass {
    running() {
      console.log('running')
    }
  }
}
function mixinFlyer(BaseClass) {
  return class extends BaseClass {
    flying() {
      console.log('flying')
    }
  }
}

class Bird {
  eating() {
    console.log('eating')
  }
}
class newBird extends mixinAnimal(mixinFlyer(Bird)) {}

const b = new newBird()
b.eating() // eating
b.running() // running
b.flying() // flying

通过调用mixin方法,创建一个新的类 继承自baseClass,对这个新的类进行扩展 添加我们希望新类能够继承的方法

除了这种方式,也可以通过复制对象的方法实现继承Object.assign(Bird.prototype, {})

React中的高阶组件

在React的高阶组件实现中有connect方法,即是使用到了类似mixin的方法实现多继承

Babel是如何转化ES6的

Babel可以对更高级的代码进行转义,转义后的代码支持在更低级的浏览器上运行。开发者可以使用高级语法进行开发而不需要担心在低级浏览器上的兼容问题。

例如Babel会将ES6的类(Class)写法转义为ES5的prototype继承式写法

class是如何转化的

假设有以下ES6代码,我们解读通过Babel转义后的ES5代码:

js
class Person {
  constructor(name) {
    this.name = name
  }
  running() {
    console.log(`${this.name} is running.`)
  }
  static randomPerson() {
    return new this('xxx')
  }
}

const p = new Person('Ziu')
js
'use strict'

function _typeof(obj) {
  '@babel/helpers - typeof'
  return (
    (_typeof =
      'function' == typeof Symbol && 'symbol' == typeof Symbol.iterator
        ? function (obj) {
            return typeof obj
          }
        : function (obj) {
            return obj &&
              'function' == typeof Symbol &&
              obj.constructor === Symbol &&
              obj !== Symbol.prototype
              ? 'symbol'
              : typeof obj
          }),
    _typeof(obj)
  )
}
function _classCallCheck(instance, Constructor) {
  if (!(instance instanceof Constructor)) {
    throw new TypeError('Cannot call a class as a function')
  }
}
function _defineProperties(target, props) {
  for (var i = 0; i < props.length; i++) {
    var descriptor = props[i]
    descriptor.enumerable = descriptor.enumerable || false
    descriptor.configurable = true
    if ('value' in descriptor) descriptor.writable = true
    Object.defineProperty(target, _toPropertyKey(descriptor.key), descriptor)
  }
}
function _createClass(Constructor, protoProps, staticProps) {
  if (protoProps) _defineProperties(Constructor.prototype, protoProps)
  if (staticProps) _defineProperties(Constructor, staticProps)
  Object.defineProperty(Constructor, 'prototype', { writable: false })
  return Constructor
}
function _toPropertyKey(arg) {
  var key = _toPrimitive(arg, 'string')
  return _typeof(key) === 'symbol' ? key : String(key)
}
function _toPrimitive(input, hint) {
  if (_typeof(input) !== 'object' || input === null) return input
  var prim = input[Symbol.toPrimitive]
  if (prim !== undefined) {
    var res = prim.call(input, hint || 'default')
    if (_typeof(res) !== 'object') return res
    throw new TypeError('@@toPrimitive must return a primitive value.')
  }
  return (hint === 'string' ? String : Number)(input)
}
var Person = /*#__PURE__*/ (function () {
  function Person(name) {
    _classCallCheck(this, Person)
    this.name = name
  }
  _createClass(
    Person,
    [
      {
        key: 'running',
        value: function running() {
          console.log(''.concat(this.name, ' is running.'))
        }
      }
    ],
    [
      {
        key: 'randomPerson',
        value: function randomPerson() {
          return new this('xxx')
        }
      }
    ]
  )
  return Person
})()
var p = new Person('Ziu')

_createClass传入一个构造函数,在_createClass内部:

  • 检查是否传入了原型方法(实例方法)

    • 如果传入了原型方法,通过调用_defineProperties将这些方法挂载到Constructor.prototype
    • 在此例中,传入_defineProperties的第一个参数为原型对象,第二个参数为原型方法,通过for循环挂载方法,并分别配置每个方法的属性描述符:enumerable: false,配置其configurable: true,如果配置了'value'属性,则writable: true
  • 检查是否传入了静态方法(类方法)

    • 如果传入了静态方法,通过调用_defineProperties将这些方法挂载到Constructor
    • 在此例中,具体执行过程是相同的,只不过第一个入参变成了构造函数(类),将方法直接挂载到类上
  • Constructor类添加显式原型prototype属性,其值不可被覆写

  • 返回该构造函数Person

  • 直接通过var p = new Person()创建实例对象

    • 在使用new关键字调用函数时,在函数内部调用了_classCallCheck
      • 检查调用方式,不允许通过调用函数的方法调用类,而只能通过new关键字
      • 使用new关键字时会默认创建一个Person实例,此时函数内部的this自然指向这个实例
      • 入参为this与该构造函数,如果this不是该构造函数的实例,则抛出错误
  • 代码中_toPropertyKey _toPrimitive _typeof都是方法类

  • 其中涉及到纯函数(pure function)的概念:纯函数是没有任何副作用的函数,可以直接被删除而不用考虑其影响,在Tree-Shaking时有较大作用(将代码从依赖树上删除)

extends是如何转化的

js
class Person {
  constructor(name) {
    this.name = name
  }
  running() {
    console.log(`${this.name} is running.`)
  }
  static randomPerson() {
    return new this('xxx')
  }
}

class Student extends Person {
  constructor(name, score) {
    super(name)
    this.score = score
  }
  studying() {
    console.log(`${this.name} is studying.`)
  }
  static randomStudent() {
    return new this('xxx', 66)
  }
}

const s = new Student('Kobe', 66)
js
'use strict'

function _inherits(subClass, superClass) {
  if (typeof superClass !== 'function' && superClass !== null) {
    throw new TypeError('Super expression must either be null or a function')
  }
  subClass.prototype = Object.create(superClass && superClass.prototype, {
    constructor: { value: subClass, writable: true, configurable: true }
  })
  Object.defineProperty(subClass, 'prototype', { writable: false })
  if (superClass) _setPrototypeOf(subClass, superClass)
}
function _setPrototypeOf(o, p) {
  _setPrototypeOf = Object.setPrototypeOf
    ? Object.setPrototypeOf.bind()
    : function _setPrototypeOf(o, p) {
        o.__proto__ = p
        return o
      }
  return _setPrototypeOf(o, p)
}
function _createSuper(Derived) {
  var hasNativeReflectConstruct = _isNativeReflectConstruct()
  return function _createSuperInternal() {
    var Super = _getPrototypeOf(Derived),
      result
    if (hasNativeReflectConstruct) {
      var NewTarget = _getPrototypeOf(this).constructor
      result = Reflect.construct(Super, arguments, NewTarget)
    } else {
      result = Super.apply(this, arguments)
    }
    return _possibleConstructorReturn(this, result)
  }
}
function _possibleConstructorReturn(self, call) {
  if (call && (_typeof(call) === 'object' || typeof call === 'function')) {
    return call
  } else if (call !== void 0) {
    throw new TypeError('Derived constructors may only return object or undefined')
  }
  return _assertThisInitialized(self)
}
function _assertThisInitialized(self) {
  if (self === void 0) {
    throw new ReferenceError("this hasn't been initialised - super() hasn't been called")
  }
  return self
}
function _isNativeReflectConstruct() {
  if (typeof Reflect === 'undefined' || !Reflect.construct) return false
  if (Reflect.construct.sham) return false
  if (typeof Proxy === 'function') return true
  try {
    Boolean.prototype.valueOf.call(Reflect.construct(Boolean, [], function () {}))
    return true
  } catch (e) {
    return false
  }
}
function _getPrototypeOf(o) {
  _getPrototypeOf = Object.setPrototypeOf
    ? Object.getPrototypeOf.bind()
    : function _getPrototypeOf(o) {
        return o.__proto__ || Object.getPrototypeOf(o)
      }
  return _getPrototypeOf(o)
}
function _typeof(obj) {
  '@babel/helpers - typeof'
  return (
    (_typeof =
      'function' == typeof Symbol && 'symbol' == typeof Symbol.iterator
        ? function (obj) {
            return typeof obj
          }
        : function (obj) {
            return obj &&
              'function' == typeof Symbol &&
              obj.constructor === Symbol &&
              obj !== Symbol.prototype
              ? 'symbol'
              : typeof obj
          }),
    _typeof(obj)
  )
}
function _classCallCheck(instance, Constructor) {
  if (!(instance instanceof Constructor)) {
    throw new TypeError('Cannot call a class as a function')
  }
}
function _defineProperties(target, props) {
  for (var i = 0; i < props.length; i++) {
    var descriptor = props[i]
    descriptor.enumerable = descriptor.enumerable || false
    descriptor.configurable = true
    if ('value' in descriptor) descriptor.writable = true
    Object.defineProperty(target, _toPropertyKey(descriptor.key), descriptor)
  }
}
function _createClass(Constructor, protoProps, staticProps) {
  if (protoProps) _defineProperties(Constructor.prototype, protoProps)
  if (staticProps) _defineProperties(Constructor, staticProps)
  Object.defineProperty(Constructor, 'prototype', { writable: false })
  return Constructor
}
function _toPropertyKey(arg) {
  var key = _toPrimitive(arg, 'string')
  return _typeof(key) === 'symbol' ? key : String(key)
}
function _toPrimitive(input, hint) {
  if (_typeof(input) !== 'object' || input === null) return input
  var prim = input[Symbol.toPrimitive]
  if (prim !== undefined) {
    var res = prim.call(input, hint || 'default')
    if (_typeof(res) !== 'object') return res
    throw new TypeError('@@toPrimitive must return a primitive value.')
  }
  return (hint === 'string' ? String : Number)(input)
}
var Person = /*#__PURE__*/ (function () {
  function Person(name) {
    _classCallCheck(this, Person)
    this.name = name
  }
  _createClass(
    Person,
    [
      {
        key: 'running',
        value: function running() {
          console.log(''.concat(this.name, ' is running.'))
        }
      }
    ],
    [
      {
        key: 'randomPerson',
        value: function randomPerson() {
          return new this('xxx')
        }
      }
    ]
  )
  return Person
})()
var Student = /*#__PURE__*/ (function (_Person) {
  _inherits(Student, _Person)
  var _super = _createSuper(Student)
  function Student(name, score) {
    var _this
    _classCallCheck(this, Student)
    _this = _super.call(this, name)
    _this.score = score
    return _this
  }
  _createClass(
    Student,
    [
      {
        key: 'studying',
        value: function studying() {
          console.log(''.concat(this.name, ' is studying.'))
        }
      }
    ],
    [
      {
        key: 'randomStudent',
        value: function randomStudent() {
          return new this('xxx', 66)
        }
      }
    ]
  )
  return Student
})(Person)
var s = new Student('Kobe', 66)

创建Student类的代码仍然是一个纯函数:

  • 立即执行函数传入了一个实参Person,随后实参作为内部函数的形参读取到函数内部以供使用
  • 可以消除副作用,避免直接调用外部变量形成闭包,让立即执行函数在得到参数的同时成为纯函数

Student内部通过调用_inherits,传入StudentPerson,将二者之间做了关联:

  • 创建一个Person实例对象,并将Student的原型对象指向此对象
  • 向此对象上添加constructor属性,指向构造函数Student
  • 经过_setPrototypeOfStudent__proto__指向了Person,这样带来的好处:让Student函数对象可以继承Person的类方法

实现继承后,通过_createClass创建类,相关内容不再重复叙述(挂载实例方法、类方法、prototype属性)

至此完成了Student类的创建,随后我们通过var s = new Student(...)创建一个Student实例

  • 通过new关键字调用Student时,将执行其内部的function Student
  • 首先执行_classCallCheck检查调用方式是否合法
  • 在内部通过借用构造函数继承_this = _super.call(this, name)
    • 此处的_super本质上就是父类(可以理解为_this = Person.call(this, name))下面来解释一下_super的创建过程
    • 通过_createSuper方法创建_super
      • 检查是否支持Reflect,返回的新的函数_createSuperInternal实际上就是_super
      • 当外部通过_this = _super.call(this, name)调用时,实际上调用的就是这个函数
      • 首先获取子类的原型,通过_getPrototypeof,该函数内部进行了一些兼容性判断,检查是否支持各种获取原型的方法
      • 最终获取到Student的隐式原型Person并赋值给_Super
        • 如果支持Reflect,通过Reflect.construct实现借用构造函数继承,调用父类的构造方法
        • 如果不支持Reflect,通过Super.apply()实现···
    • 绕了一圈,本质上就是调用了_Person.call(this, name),并且将创建的对象返回出来赋值给result

自己实现的ES5对比

在之前的内容中我们自己实现了相关的类定义/继承功能

js
function Person(name) {
  this.name = name // 定义内部属性
}
// 定义实例方法
Person.prototype.running = function() {
  console.log(`${this.name} is running.`)
}
// 定义类方法(静态方法)
Person.randomPerson = function() {
  return new this('xxx')
}

自己实现的继承:

js
function inherit(SubClass, SuperClass) {
  // 创建一个新对象并将新对象的原型指向父类构造函数的原型
  subType.prototype = Object.create(superType.prototype)

  // 为子类的原型添加constructor属性
  Object.defineProperty(subType.prototype, 'constructor', {
    enumerable: false, // 不可被枚举
    configurable: true, // 值可被修改
    writable: true, // 值可被覆写
    value: subType // 初始值: 父类构造函数
  })
}

inherit(Student, Person)

相比之下不难发现,Babel转义用ES5实现相关功能的核心思想是一致的,只不过Babel转义后的代码添加了更多的边界条件的判定。

浏览器运行原理

网页解析过程

输入域名 => DNS解析为IP => 目标服务器返回index.html

DNS:Domain Name System

HTML解析过程

  • 浏览器开始解析index.html文件,当遇到<link>则向服务器请求下载.css文件

  • 遇到<script>标签则向服务器请求下载.js文件

浏览器解析HTML过程浏览器是和如何工作的

How browsers work

生成CSS规则

在解析的过程中,如果遇到<link>元素,那么会由浏览器负责下载对应的CSS文件

  • 注意:下载CSS文件不会影响到DOM解析
  • 有单独一个线程对CSS文件进行下载与解析

浏览器下载完CSS文件后,就会对CSS文件进行解析,解析出对应的规则树:

  • 我们可以称之为CSSOM(CSS Object Model,CSS对象模型)

构建Render Tree

有了DOM Tree和CSSOM Tree之后,就可以将二者结合,构建Render Tree了

此时,如果有某些元素的CSS属性display: none;那么这个元素就不会出现在Render Tree中

  • 下载和解析CSS文件时,不会阻塞DOM Tree的构建过程
  • 但会阻塞Render Tree的构建过程:因为需要对应的CSSOM Tree

布局和绘制(Layout & Paint)

第四步是在渲染树(Render Tree)上运行布局(Layout),以计算每个节点的几何体

  • 渲染树会表示显示哪些节点以及其他的样式,但是不表示每个节点的尺寸、位置等信息
  • 布局是确定呈现树中所有节点的宽度、高度和位置信息

第五步是将每个节点绘制(Paint)到屏幕上

  • 在绘制阶段,浏览器布局阶段计算的每个frame转为屏幕上实际的像素点
  • 包括将元素的可见部分进行绘制,比如文本、颜色、边框、阴影、替换元素

回流和重绘(Reflow & )

回流也可称为重排

理解回流(Reflow):

  • 第一次确定节点的大小和位置,称之为布局(layout)
  • 之后对节点的大小、位置修改重新计算,称之为回流

什么情况下会引起回流?

  • DOM 结构发生改变(添加新的节点或者移除节点)
  • 改变了布局(修改了width height padding font-size等值)
  • 窗口resize(修改了窗口的尺寸等)
  • 调用getComputedStyle方法获取尺寸、位置信息

理解重绘(Repaint):

  • 第一次渲染内容称之为绘制(paint)
  • 之后的重新渲染称之为重绘

什么情况下会引起重绘?

  • 修改背景色、文字颜色、边框颜色、样式等

回流一定会引起重绘,所以回流是一件很消耗性能的事情

  • 开发中要尽量避免发生回流
  • 修改样式尽量一次性修改完毕
    • 例如通过cssText一次性设置样式,或通过修改class的方式修改样式
  • 尽量避免频繁的操作DOM
    • 可以在一个DocumentFragment或者父元素中,将要操作的DOM操作完成,再一次性插入到DOM树中
  • 尽量避免通过getComputedStyle获取元素尺寸、位置等信息
  • 对某些元素使用position的absolute或fixed属性
    • 并不是不会引起回流,而是开销相对较小,不会对其他元素产生影响

特殊解析: composite合成

在绘制的过程中,可以将布局后的元素绘制到多个合成图层中

  • 这是浏览器的一种优化手段
  • 将不同流生成的不同Layer进行合并
标准流 => LayouTree => RenderLayer
`position:fixed;` => RenderLayer

默认情况,标准流中的内容都是被绘制在同一个图层(Layer)中的

而一些特殊的属性,浏览器会创建一个新的合成层(CompositingLayer),并且新的图层可以利用GPU来加速绘制

  • 每个合成层都是单独渲染的
  • 单独渲染可以避免所有的动画都在同一层中渲染导致性能问题
  • 在各自的层中渲染完成后,只需要将渲染结果更新回合成层即可

当元素具有哪些属性时,浏览器会为其创建新的合成层呢?

  • 3D Transforms
  • video canvas iframe
  • opacity 动画转换时
  • position: fixed
  • will-change: 一个实验性的属性,提前告诉浏览器此元素可能发生哪些变化
  • animation 或 transition设置了opacity、transform
案例1:同一层渲染
css
.box1 {
  width: 100px;
  height: 100px;
  background-color: red;
}
.box2 {
  width: 100px;
  height: 100px;
  background-color: blue;
}
html
<body>
  <div class="box1"></div>
  <div class="box2"></div>
</body>

在开发者工具的图层工具中可以看到,两个元素.box1.box2都是在一个层(Document)下渲染的:

image-20221122103111654

案例2:分层渲染

当我们为.box2添加上position: fixed;属性,这时.box2将在由浏览器创建出来的合成层,分层单独渲染

css
.box2 {
  width: 100px;
  height: 100px;
  background-color: blue;
  position: fixed;
}

image-20221122103256116

案例3:transform 3D

为元素添加上transform属性时,浏览器也会为对应元素创建一个合成层,需要注意的是:只有3D的变化浏览器才会创建

如果是translateXtranslateY则不会

css
.box2 {
  width: 100px;
  height: 100px;
  background-color: blue;
  /* position: fixed; */
  transform: translateZ(10px);
}

image-20221122103715428

案例4:transition+transform

当我们为元素添加上动画时,动画的中间执行过程的渲染会在新的图层上进行,但是中间动画渲染完成后,结果会回到原始图层上

css
.box2 {
  width: 100px;
  height: 100px;
  background-color: blue;
  transition: transform 0.5s ease;
}
.box2:hover {
  transform: translateY(10px);
}
  • 这也是使用transform执行动画性能更高的原因,因为浏览器会为动画的执行过程单独创建一个合成层
  • 如果是通过修改top left等定位属性实现的动画,是在原始的图层上渲染完成的。“牵一发则动全身”,动画过程中将导致整个渲染树回流与重绘,极大的影响性能
案例5:transition+opacity

transform类似,使用transition过渡的opacity动画,浏览器也会为其创建一个合成层

css
.box2 {
  width: 100px;
  height: 100px;
  background-color: blue;
  opacity: 1;
  transition: opacity 0.5s ease;
}
.box2:hover {
  opacity: 0.2;
}
总结

分层确实可以提高性能,但是它是以内存管理为代价的,因此不应当作为Web性能优化策略的一部分过度使用

浏览器对script元素的处理

之前我们说到,在解析到link标签时,浏览器会异步下载其中的css文件,并在DOM树构建完成后,将其与CSS Tree合成为RenderTree

但是当浏览器解析到script标签时,整个解析过程将被阻塞,当前script标签后面的DOM树将停止解析,直到当前script代码被下载、解析、执行完毕,才会继续解析HTML,构建DOM树

为什么要这样做呢?

  • 这是因为Javascript的作用之一就是操作DOM,并且可以修改DOM
  • 如果我们等到DOM树构建完成并且渲染出来了,再去执行Javascript,会造成回流和重绘,严重影响页面性能
  • 所以当浏览器构建DOM树遇到script标签时,会优先下载和执行Javascript代码,而后再继续构建DOM树

这也会带来新的问题,比如在现代的页面开发中:

  • 脚本往往比HTML更“重”,浏览器也需要花更多的时间去处理脚本
  • 会造成页面的解析阻塞,在脚本下载、解析、执行完成之前,用户在界面上什么也看不到

为了解决这个问题,浏览器的script标签为我们提供了两个属性(attribute):deferasync

defer属性

defer 即推迟,为script标签添加这个属性,相当于告诉浏览器:不要等待此脚本下载,而是继续解析HTML,构建DOM Tree

  • 脚本将由浏览器进行下载,但是不会阻塞DOM Tree的构建过程
  • 如果脚本提前下载好了,那么它会等待DOM Tree构建完成,在DOMContentLoaded事件触发之前先执行defer中的代码
html
<script>
  console.log('script enter')
  window.addEventListener('DOMContentLoaded', () => {
    console.log('DOMContentLoaded enter')
  })
</script>
<script src="./defer.js" defer></script>
js
// defer.js
console.log('defer script enter')

上述代码在控制台的输出为:

script enter
defer script enter
DOMContentLoaded enter
  • 多个带defer的脚本也是按照自上至下的顺序执行的
  • 从某种角度来说,defer可以提高页面的性能,并且推荐放到head元素中
  • 注意:defer仅适用于外部脚本,对于script标签内编写的默认JS代码会被忽略掉

async属性

async属性也可以做到:让脚本异步加载而不阻塞DOM树的构建,它与defer的区别:

  • async标记的脚本是完全独立
  • async脚本不能保证执行顺序,因为它是独立下载、独立运行,不会等待其他脚本
  • 使用async标记的脚本不会保证它将在DOMContentLoaded之前或之后被执行

要使用async属性标记的script操作DOM,必须在其中使用DOMContentLoaded监听器的回调函数,在该事件触发(DOM树构建完毕)后,执行相应的回调函数

JavaScript 运行原理

JS代码的执行

JavaScript代码下载好后,是如何一步步被执行的?

浏览器内核是由两部分组成的,以Webkit为例:

  • WebCore:负责HTML解析、布局、渲染等相关的工作
  • JavaScriptCore:解析、执行JavaScript代码

JavaScript V8引擎

image-20221125090752249

JS源代码经过解析,生成抽象语法树(词法分析器、语法分析器),经过ignition转为字节码(二进制、跨平台),即可由CPU执行

在ignition的过程中,会由TurboFan收集类型信息(检查哪些函数是被重复执行的),对优化生成的机器码,得到性能更高的机器指令

如果生成的优化的机器码不符合函数实际执行,则会进行Deoptimization(反优化),降级回普通的字节码

  • Parse模块会将JavaScript代码转化为AST(抽象语法树),因为解释器并不直接认识JavaScript代码
  • Ignition是一个解释器,会将AST转换成ByteCode(字节码)
    • 同时会收集TurboFan优化所需要的信息(比如函数参数的类型信息,有了类型才能进行真实的计算)
    • 如果函数只调用一次,Ignition会直接解释执行ByteCode
    • 官方文档:https://v8.dev/blog/ignition-interpreter
  • TurboFan是一个编译器,可以将字节码编译为CPU可以直接执行的机器码
    • 如果一个函数被多次调用,那么就会被标记为热点函数,经过TurboFan转化成优化的机器码,提高代码的执行性能
    • 但是,机器码实际上也会被还原为ByteCode,这是因为如果后续执行函数的过程中,类型发生了变化(比如sum函数原来执行的是number类型,后来执行变成了string类型),之前优化的机器码并不能正确的处理运算,就会逆向的转换成字节码
    • 官方文档:https://v8.dev/blog/turbofan-jit

image-20221125094148365

Blink 获取到源码 => 转为Stream => Scanner扫描器

  • 词法分析(lexical analysis,简称lexer)
    • 将字符序列转换成token序列的过程,例如var name = 'Ziu',词法分析器会将每个词转为token
    • token是记号化(tokenization)的缩写
    • 词法分析器,也叫扫描器(scanner)
  • 语法分析(syntactic analysis,也叫parsing)
    • 语法分析器也可以称之为parser 解析器

JavaScript代码执行过程

  • 初始化全局对象
    • js引擎会在执行代码之前,在堆内存中创建一个全局对象:Global Object (GO)
    • 该对象可以在所有的作用域(scope)中被访问
    • 里面会包含Date、Array、String、Number、setTimeout、setInterval等内置对象
    • 其中还包含一个window属性指向自己
  • 执行上下文
    • JS引擎内部有一个执行上下文栈(Execution Context Stack 简称ECS),它是用于执行代码的调用栈
    • 那么现在它要执行谁呢?执行的是全局的代码块:
      • 全局的代码块为了执行会构建一个Global Execution Context(GEC)
      • GEC会被放入到ECS中执行
    • GEC被放入到ECS中里面包含两部分内容:
      • 第一部分:在代码执行之前,在parser转化为AST的过程中,会将全局定义的变量、函数等加入到GlobalObject中,但是并不会赋值
        • 这个过程也被称为变量的作用域提升(hoisting)
      • 第二部分:在代码的执行过程中,对变量进行赋值,或者执行其他的函数
        • 当代码被翻译为可执行代码,将进入到一个可执行的上下文中,活跃的函数执行上下文在逻辑上是一个栈,
js
console.log('script start')

function fun1() {
  console.log('fun1')
}

function fun2() {
  console.log('fun2 start')
  fun1()
  console.log('fun2 end')
}

fun2()

console.log('script end')

创建全局执行上下文 => 函数执行到fun2() => 创建fun2执行上下文 => 函数执行到fun1() => 创建fun1执行上下文 => fun1执行完毕出栈 => 继续执行fun2后续代码 => fun2执行完毕出栈 => 继续执行全局上下文后续代码 => 结束

sh
'script start'
'fun2 start'
'fun1'
'fun2 end'
'script end'

Proxy与Reflect

  • 监听对象的操作
  • Proxy类基本使用
  • Proxy常见捕获器
  • Reflect介绍和作用
  • Reflect基本使用
  • Reflect的receiver

监听对象方法

Proxy监听对象

可以使用Proxy对象将原对象包裹,此后的操作都对proxy进行,每次getset被触发时都会自动执行相应代码

  • 可以为handler传入不同的捕获器
  • 需要注意的是 getset 都需要有返回值
    • get 默认行为是返回属性值
    • set 返回true表示设置成功 也可以做其他if-else抛出错误表示执行失败
js
const obj = {
  name: 'ziu',
  age: 18,
}
const proxy = new Proxy(obj, {
  // 需要注意的是 get 与 set 都需要有返回值
  get(target, key) {
    console.log('get', key)
    return target[key] // 默认行为是返回属性值
  },
  set(target, key, value) {
    console.log('set', key, value)
    target[key] = value
    return true // 代表执行成功
  }
})
js
const tmp = proxy.age // getter被触发
proxy.name = 'Ziu' // setter被触发

defineProperty监听对象

除此之外,可以通过Object.defineProperty为对象中某个属性设置gettersetter函数,可以达到类似的效果

需要注意的是,此处要用for-in遍历对象的所有属性,并逐个为其设置gettersetter

js
for (const key of Object.keys(obj)) {
  let value = obj[key]
  Object.defineProperty(obj, key, {
    get() {
      console.log('get', value)
      return value
    },
    set(newVal) {
      console.log('set', key, newVal)
      value = newVal
      return true
    }
  })
}

Proxy与defineProperty的区别

defineProperty 和 Proxy区别

  1. 监听数据的角度

    1. defineproperty只能监听某个属性而不能监听整个对象。
    2. proxy不用设置具体属性,直接监听整个对象。
    3. defineproperty监听需要知道是哪个对象的哪个属性,而proxy只需要知道哪个对象就可以了。也就是会省去for in循环提高了效率。
  2. 监听对原对象的影响

    1. 因为defineproperty是通过在原对象身上新增或修改属性增加描述符的方式实现的监听效果,一定会修改原数据。
    2. proxy只是原对象的代理,proxy会返回一个代理对象不会在原对象上进行改动,对原数据无污染。
  3. 实现对数组的监听

    1. 因为数组 length 的特殊性 (length 的描述符configurable 和 enumerable 为 false,并且妄图修改 configurable 为 True 的话 js 会直接报错:VM305:1 Uncaught TypeError: Cannot redefine property: length)
    2. defineproperty无法监听数组长度变化, Vue只能通过重写数组方法的方式变现达成监听的效果,光重写数组方法还是不能解决修改数组下标时监听的问题,只能再使用自定义的$set的方式
    3. proxy因为自身特性,是创建新的代理对象而不是在原数据身上监听属性,对代理对象进行操作时,所有的操作都会被捕捉,包括数组的方法和length操作,再不需要重写数组方法和自定义set函数了。(代码示例在下方)

    4. 监听的范围

    1. defineproperty只能监听到valueget set 变化。
    2. proxy可以监听除 [[getOwnPropertyNames]] 以外所有JS的对象操作。监听的范围更大更全面。

Proxy

JS
const proxy = new Proxy(target, handler)

即使不传入handler,默认也会进行基本的代理操作,将对代理对象的操作透传给原始对象

js
const obj = {
  name: 'ziu',
  age: 18
}
const proxy = new Proxy(obj, {})
proxy.height = 1.88 // 添加新属性
proxy.name = 'Ziu' // 修改原属性

console.log(obj) // { name: 'Ziu', age: 18, height: 1.88 }

捕获器 (trap)

handler 对象的方法

常用的捕获器有setget函数

js
const proxy = new Proxy(obj, {
  set: function (target, key, newVal) {
    console.log(`监听: ${key} 设置 ${newVal}`)
    target[key] = newVal
  },
  get: function (target, key) {
    console.log(`监听: ${key} 获取`)
    return target[key]
  }
})
  • set函数有四个参数
    • target 目标对象(侦听的对象)
    • property 即将被设置的属性key
    • value 新属性值
    • receiver 调用的代理对象
  • get函数有三个参数
    • target 目标对象(侦听的对象)
    • property 被获取的属性key
    • receiver 调用的代理对象

另外介绍两个捕获器:hasdeleteProperty

js
const proxy = new Proxy(obj, {
  ...
  has: function (target, key) {
    console.log(`监听: ${key} 判断`)
    return key in target
  },
  deleteProperty: function (target, key) {
    console.log(`监听: ${key} 删除 `)
    return true
  }
})

delete proxy.name // 监听: name 删除
console.log('age' in proxy) // 监听: age 判断

this指向问题

Proxy对象可以对我们的目标对象进行访问,但没有做任何拦截时,也不能保证与目标对象的行为一致,因为目标对象内部的this会自动改变为Proxy代理对象。

js
const obj = {
  name: 'ziu',
  foo() {
    return this === proxy
  }
}

const proxy = new Proxy(obj, {})

console.log(obj.foo()) // false
console.log(proxy.foo()) // true

使用Proxy监听嵌套对象

TODO

Reflect

Reflect是ES6新增的一个API,它本身是一个对象

  • 提供了很多操作JavaScript对象的方法,有点像Object中操作对象的方法
  • 比如Reflect.getPrototypeOf(target)类似于Object.getPrototypeOf()
  • 比如Reflect.defineProperty(targetm propertyKey, attributes)类似于Object.defineProperty()

如果我们又Object对象可以完成这些操作,为什么还需要Reflect呢?

  • Object作为一个构造函数,这些操作放到它身上并不合适
  • 包含一些类似于 in delete的操作符
  • 在ES6新增了Reflect,让这些操作都集中到了Reflect对象上
  • 在使用Proxy时,可以做到不操作原对象

与Object操作的区别

删除对象上的某个属性

js
const obj = {
  name: 'ziu',
  age: 18
}
// 当且仅当该属性的 configurable 键值为 true 时,该属性的描述符才能够被改变
// 同时该属性也能从对应的对象上被删除。 默认为 false。
Object.defineProperty(obj, 'name', {
  configurable: false
})

// 1. 旧方法 检查`delete obj.name`是否执行成功
// 结果: 需要额外编写检查代码且存在问题(严格模式下删除configurable为false的属性将报错)
delete obj.name
if (obj.name) {
  console.log(false)
} else {
  console.log(true)
}

// 2. Reflect
// 结果: 根据是否删除成功返回结果
if (Reflect.deleteProperty(obj, 'name')) {
  console.log(true)
} else {
  console.log(false)
}

Reflect常见方法

其中的方法与Proxy的方法是一一对应的,一共13个。其中的一些方法是Object对象中没有的:

  • has 判断一个对象是否存在某个属性,和 in 运算符功能完全相同
  • get 获取对象身上某个属性的值,类似于target[key]
  • set 将值分配给属性的函数,返回一个Boolean,如果更新成功则返回true
  • deleteProperty 作为函数的 delete 操作符,相当于执行 delete target[key]
  • ···

代理对象的目的:不再直接操作原始对象,一切读写操作由代理完成。我们先前在编写Proxy的代理代码时,仍然有操作原对象的行为:

js
const proxy = new Proxy(obj, {
  set: function (target, key, newVal) {
    console.log(`监听: ${key} 设置 ${newVal}`)
    target[key] = newVal // 直接操作原对象
  },
})

这时我们可以让Reflect登场,代替我们对原对象进行操作,之前的代码可以修改:

js
const proxy = new Proxy(obj, {
  set: function (target, key, newVal) {
    console.log(`监听: ${key} 设置 ${newVal}`)
    Reflect.set(target, key, newVal)
  },
  get: function (target, key) {
    console.log(`监听: ${key} 获取`)
    return Reflect.get(target, key)
  },
  has: function (target, key) {
    console.log(`监听: ${key} 判断`)
    return Reflect.has(target, key)
  }
})

使用Reflect替代之前的对象操作有以下好处:

  • 代理对象的目的:不再直接操作原对象
  • Reflect.set方法有返回Boolean值,可以判断本次操作是否成功
  • receiver就是外层的Proxy对象

针对好处三,做出如下解释。以下述代码为例,set name(){}函数中的this指向的是obj

js
const obj = {
  _name: 'ziu',
  set name(newVal) {
    console.log(`set name ${newVal}`)
    console.log(this)
    this._name = newVal
  },
  get name() {
    console.log(`get name`)
    console.log(this)
    return this._name
  }
}

console.log(obj.name)
obj.name = 'Ziu'
js
const proxy = new Proxy(obj, {
  set: function (target, key, newVal, receiver) {
    console.log(`监听: ${key} 设置 ${newVal}`)
    Reflect.set(target, key, newVal, receiver)
  },
  get: function (target, key, receiver) {
    console.log(`监听: ${key} 获取`)
    return Reflect.get(target, key, receiver)
  }
})

我们使用Proxy代理,并且使用Reflect操作对象时,输出的this仍然为obj,需要注意的是,此处的this指向是默认指向原始对象obj,而如果业务需要改变this指向,此时可以为Reflect.set()的最后一个参数传入receiver

Reflect.construct方法

以下两段代码的实现结果是一样的:

js
function Person(name, age) {
  this.name = name
  this.age = age
}

function Student(name, age) {
  Person.call(this, name, age) // 借用
}

const stu = new Student('ziu', 18)
console.log(stu)
js
function Person(name, age) {
  this.name = name
  this.age = age
}

function Student(name, age) {
  // Person.call(this, name, age) // 借用
}

const stu = new Reflect.construct(Person, ['ziu', 18], Student)
console.log(stu)

理解Proxy与Reflect中的receiver参数

Proxy和Reflect中的receiver到底是个什么东西

ES6的Proxy中,为什么推荐使用Reflect.get而不是target[key]?

Promise详解

  • 异步代码的困境
  • 认识Promise
  • Promise状态变化
  • Promise实例方法
  • Promise的类方法

异步代码

ES5之前,都是通过回调函数完成异步逻辑代码的,如果存在多次异步操作,会导致回调函数不断嵌套,回调地狱

js
function fetchData(callback) {
  setTimeout(() => {
    const res = ['data1', 'data2', 'data3']
    callback(res)
  }, 2000)
}
fetchData((res) => {
  const div = document.createElement('div')
  div.innerHTML = res
  document.body.appendChild(div)
})

认识Promise

用Promise改造上述代码:

js
function fetchData() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const res = ['data1', 'data2', 'data3']
      resolve(res)
    }, 2000)
  })
}
const data = fetchData()
data.then((res) => {
  const div = document.createElement('div')
  div.innerHTML = res
  document.body.appendChild(div)
})
  • 通过new创建Promise对象时,需要传入一个回调函数,称之为executor
    • 这个回调函数会被立即执行,并且传入另外两个回调函数 resolve reject
    • 当我们调用resolve时,会执行Promise对象的then方法传入的回调函数
    • 当我们调用reject时,会执行Promise对象的catch方法传入的回调函数

.catch() 其实只是没有给处理已兑现状态的回调函数预留参数位置的 .then() 而已。

Promise状态变化

  • Promise有三种状态 只修改一次 后续状态将被锁定
    • pending 初始状态 既没有被兑现,也没有被拒绝
      • 执行executor中代码时,处于该状态
    • fulfilled 操作已完成
      • 执行resolve时,Promise已经被兑现
    • rejected 操作失败
      • 执行reject时,Promise已经被拒绝

resolve值

resolve函数传入普通值,Promise的状态会被设置为兑现,而为resolve传入一个Promise对象时,当前Promise对象的状态将由传入的Promise对象决定。如下述代码中,'Outer Promise Result'会被作为最终结果被兑现

js
const p = new Promise((res, rej) => {
  res('Outer Promise Result')
})
function fetchData() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const res = ['data1', 'data2', 'data3']
      resolve(p)
    }, 2000)
  })
}

resolve还可以传入thenable对象:该对象中的then方法会被回调

js
function fetchData() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const res = ['data1', 'data2', 'data3']
      resolve({
        name: 'Ziu',
        then: (resolve, reject) => {
          resolve('thenable Object resolve')
        }
      })
    }, 2000)
  })
}

综上:

  • 如果resolve传入一个普通的值或对象,那么这个值会作为then回调的参数
  • 如果resolve中传入的是另一个Promise,那么这个新Promise会决定原Promise的状态
  • 如果resolve中传入的是一个对象,并且这个对象中实现了then方法,那么会执行该then方法,并且根据then方法的结果来决定Promise 的状态

.then方法

实际上,then方法可以传入两个回调函数,可以同时传入成功处理的回调函数和失败处理的回调函数。

js
const data = fetchData()
data
  .then(
  (res) => {
    const div = document.createElement('div')
    div.innerHTML = res
    document.body.appendChild(div)
  },
  (err) => {
    console.log(err)
  }
)

在前文中我们描述.catch为:

.catch() 其实只是没有给处理已兑现状态的回调函数预留参数位置的 .then() 而已。

那么.catch的实现本质上就是为成功回调传入null的.then

js
const data = fetchData()
data
  .catch((err) => {
  console.log(err)
})
  .then(null, (err) => {
  console.log(err)
})

Promise的.then方法可以被多次调用,下例中,控制台只会输出一次'execute'而会输出三次'success',证明三次.then都被调用了。同理,.catch方法也可以被多次调用

js
const p = new Promise((res, rej) => {
  console.log('execute')
  res(['data1', 'data2', 'data3'])
})

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

.then的返回值

Promise本身支持链式调用,.then方法实际上是返回了一个新的Promise,链式调用中的.then是在等待这个新的Promise兑现之后执行

js
const p = new Promise((res, rej) => {
  res('aaa')
})

p.then((res) => {
  console.log(res)
  return 'bbb' // return new Promise((res) => res('bbb'))
}).then((res) => {
  console.log(res)
})

.catch的返回值

可以通过throw抛出一个错误实现在链式调用中抛出异常,让.catch对异常进行处理

js
const p = new Promise((res, rej) => {
  res('aaa')
})

p.then((res) => {
  console.log(res)
  return 'bbb' // return new Promise((res) => res('bbb'))
})
  .then((res) => {
  console.log(res)
  throw new Error('Error Info') // return new Promise((_, rej) => rej('Error Info'))
})
  .catch((err) => {
  console.log(err)
})

Promise类方法

Promise.resolve()

类方法与实例方法,下例中两个方式是等效的,其中Promise.resolve()即为类方法。如果你现在已经有了一个数据内容,希望通过Promise包装来使用,这时就可以通过Promise.resolve()方法

js
Promise.resolve(['data1', 'data2', 'data3'])
// 等价于
new Promise((res) => res(['data1', 'data2', 'data3']))

Promise.reject()

resolve方法类似,下述两种写法的效果是相同的

js
Promise.reject(['data1', 'data2', 'data3'])
// 等价于
new Promise((_, rej) => rej(['data1', 'data2', 'data3']))

Promise.all()

**Promise.all(iterable)** 方法返回一个 Promise 实例,此实例在 iterable 参数内所有的 promise 都“完成(resolved)”或参数中不包含 promise 时回调完成(resolve);如果参数中 promise 有一个失败(rejected),此实例回调失败(reject),失败原因的是第一个失败 promise 的结果。

  • 将多个Promise包裹在一起形成一个新的Promise
  • 新的Promise状态由包裹的所有Promise共同决定
    • 当所有的Promise状态都变成fulfilled状态时,新的Promise状态为fulfilled,并且会将所有的Promise返回值组成一个数组
    • 当有一个Promise状态为reject时,新的Promise状态为reject,并且会将第一个reject的返回值作为参数
js
const p1 = new Promise((res) => res(['data1', 'data2', 'data3']))
const p2 = new Promise((res) => res('Hello, Promise.'))
const p3 = new Promise((_, rej) => rej('Failed'))

const promises = [p1, p2, p3]

Promise.all(promises)
  .then((res) => {
  console.log(res)
})
  .catch((err) => {
  console.log(err)
})

Promise.allSettled() (ES11)

.all()方法有一个缺陷:当某一个Promise被reject时,新的Promise会立刻变成reject状态,此时对于其他处于resolved和pending状态的Promise就获取不到结果了

  • 会在所有的Promise都有结果(settled) 无论是fulfilled 还是 rejected时,才会有最终的状态
  • 这个Promise的结果一定是fulfilled的
  • 返回的结果是一个数组,每个元素包含Promise的状态与结果
js
Promise.allSettled(promises)
  .then((res) => {
  console.log(res)
})
  .catch((err) => {
  console.log(err)
})

Promise.race()

Promise.race(iterable) 方法返回一个 promise,一旦迭代器中的某个promise解决或拒绝,返回的 promise就会解决或拒绝。

js
Promise.race(promises)
  .then((res) => {
  console.log(res)
})
  .catch((err) => {
  console.log(err)
})

Promise.any() (ES12)

  • any方法是ES12新增的方法,与race类似
    • any方法会等到一个fulfilled状态,才会决定新的Promise状态
    • 如果所有Promise都是reject的,那么也会等到所有Promise都变成rejected状态
    • (race方法一旦fulfilled或rejected会直接修改新的Promise状态)

迭代器与生成器

  • 迭代器 可迭代对象
  • 原生的迭代器对象
  • 自定义类的迭代器
  • 生成器的理解和作用
  • 自定义生成器方案
  • 异步处理方案解析

什么是迭代器

迭代器是帮助我们对某个数据结构进行遍历的对象,不同语言对迭代器都有不同的实现

在JavaScript中,迭代器是一个具体的对象,这个对象需要符合迭代器协议(literator protocol)

  • 迭代器协议产生了一系列值(无论是有限个还是无限个)的标准方式
  • 在JavaScript中这个标准就是一个特定的next方法

这个next()方法有如下要求:

  • 一个没有参数或者一个参数的函数,返回一个对象,此对象拥有两个属性:
    • done 布尔值 代表当前迭代是否完成
      • 如果迭代器可以产生序列中下一个值,则done = false(等价于没有指定done这个属性)
      • 如果迭代器已将序列迭代完毕,则done = true,这种情况下value是可选的,如果它依然存在,即为迭代结束之后的默认返回值
    • value
      • 迭代器返回的任何JavaScript值,其done值为true时可以省略

案例:

js

异步处理

假设有如下场景:请求3的数据依赖请求2,请求2的数据依赖请求1,这样会存在嵌套的问题

js
// 第一次请求
reqData('ziu').then((res) => {
  // 第二次请求
  reqData('Ziu').then((res) => {
    // 第三次请求
    reqData('Ziuc').then((res) => {
      console.log(res)
    })
  })
})

可以通过Promise链式调用,避免深层嵌套回调,但是代码并不足够优秀

js
reqData('ziu')
  .then((res) => {
  return reqData(res + 'Ziu')
})
  .then((res) => {
  return reqData(res + 'Ziuc')
})
  .then((res) => {
  console.log(res)
})

生成器写法

js
function* getData() {
  const res1 = yield reqData('ziu')
  const res2 = yield reqData(res1 + 'Ziu')
  const res3 = yield reqData(res2 + 'Ziuc')
  console.log(res3)
}

const generator = getData()
generator.next().value.then((res) => {
  generator.next(res).value.then((res) => {
    generator.next(res).value.then((res) => {
      generator.next(res)
    })
  })
})

通过递归优化生成器代码

js
function execGenFn(genFn) {
  const generator = genFn()
  function exec(res) {
    const result = generator.next(res)
    if (result.done) return
    else result.value.then((res) => exec(res))
  }
  exec()
}

execGenFn(getData)

此时引入asyncawait可以提高代码的阅读性,本质上就是生成器函数和yeild的语法糖

js
async function getData() {
  const res1 = await reqData('ziu')
  const res2 = await reqData(res1 + 'Ziu')
  const res3 = await reqData(res2 + 'Ziuc')
  console.log(res3)
}
getData()

let与const

  • letvar都用于声明变量
  • const声明的变量不允许被修改,如果是引用类型变量,则可以修改引用内的属性,不可修改变量本身
  • var的区别:
    • 不允许重复声明、不存在变量提升
    • 不会被添加到window上,不能通过window.xxx访问

作用域提升

var定义的变量会自动进行变量提升

js
console.log(typeof name) // undefined
var name = 'Ziu'

在声明name前就可以访问到这个变量,且其值为undefined

但是用let const定义的变量在初始化前访问会抛出错误

js
console.log(address) // 报错
let address = 'China'
  • 变量会被创建在包含他们的词法环境被实例化时,但是是不可以访问它们的,直到词法绑定被求值
  • 在执行上下文的词法环境创建出来的时候,变量事实上已经被创建了,只是这个变量是不能被访问的

暂时性死区

在作用域内,从作用域开始到变量被定义之前的区域被称为暂时性死区,这部分里是不能访问变量的。

js
function foo() {
  console.log(name, age) // 报错
  console.log('Hello, World!')
  let name = 'Ziu'
  let age = 18
}

foo()
  • 从作用域的顶部一直到变量声明完成之前,这个变量处在暂时性死区(TDZ, Temporal dead zone)
  • 暂时性死区和定义的位置没有关系,和代码的执行顺序有关系
  • 暂时性死区形成之后,在该区域内该标识符都不能被访问
js
// 代码报错:
console.log(name)
let name = 'Hello, World!'
js
// 代码正确执行
function foo() {
  console.log(name)
}
let name = 'Hello, World!'
foo() // Hello, World!

在上例中,先执行let name = 'Hello, World!',随后再执行foo()输出name变量,这样就不会报错。

所以得出结论:暂时性死区与代码的执行顺序有关


下述代码能正常执行:调用foo()时,现在内部作用域查找message,没找到则到外部作用域找到message并输出

js
let message = 'Hello, World!'
function foo() {
  console.log(message)
}
foo()

但是稍作修改,下面的代码执行将报错:这是因为函数foo()内部形成了暂时性死区,函数内定义了message,所以优先访问内部的message变量,在输出语句访问它时正处于暂时性死区中,所以会抛出错误

js
let message = 'Hello, World!'
function foo() {
  console.log(message)
  let message = 'Ziu'
}
foo()

变量保存位置

既然通过let const声明的变量不会被保存在window上,那他们保存在哪里呢?我们从ECMA文档入手

A Global Environment Record is logically a single record but it is specified as a composite encapsulating an Object Environment Record and a Declarative Environment Record.

全局环境记录包括:1 对象环境记录 2 声明环境记录

Table 20: Additional Fields of Global Environment Records

Field NameValueMeaning
[[ObjectRecord]]an Object Environment RecordBinding object is the global object. It contains global built-in bindings as well as FunctionDeclaration, GeneratorDeclaration, AsyncFunctionDeclaration, AsyncGeneratorDeclaration, and VariableDeclaration bindings in global code for the associated realm.
[[GlobalThisValue]]an ObjectThe value returned by this in global scope. Hosts may provide any ECMAScript Object value.
[[DeclarativeRecord]]a Declarative Environment RecordContains bindings for all declarations in global code for the associated realm code except for FunctionDeclaration, GeneratorDeclaration, AsyncFunctionDeclaration, AsyncGeneratorDeclaration, and VariableDeclaration bindings.
[[VarNames]]a List of StringsThe string names bound by FunctionDeclaration, GeneratorDeclaration, AsyncFunctionDeclaration, AsyncGeneratorDeclaration, and VariableDeclaration declarations in global code for the associated realm.

由上表可知,对象环境记录也就是global object,在浏览器中也就是window对象,它包含了全局内置绑定:函数声明、生成器声明、异步函数声明、异步生成器声明、变量声明(特指通过var声明的变量)

而在声明环境记录中,它包含了除了上述内容的声明,比如const let声明的变量

块级作用域

用花括号括起来的代码块是一个块级作用域,在ES5之前,只有全局作用域与函数作用域

js
{
  var bar = 'Ziu'
}
console.log(bar) // Ziu

在代码块内声明的变量,在外部仍然可以访问到。

但是通过let const function class声明的变量具有块级作用域限制:

js
{
  let foo = 'foo'
  function bar() {
    console.log('bar')
  }
  class Person {}
}

console.log(foo) // 报错
bar() // bar 函数能够被调用
var p = new Person() // 报错

但是,函数虽然有块级作用域限制,但是仍然是可以在外界被访问的

  • 这是因为引擎会对函数的声明进行特殊处理,允许其像var那样提升

  • 但是与var不同的是,var声明的变量可以在其之前被访问,其值为undefined,但是函数不能在代码块之前访问

js
bar() // 报错
{
  function bar() {
    console.log('bar')
  }
}

开发中的应用

使用var定义的变量会被自动提升,在某些情况下会导致非预期行为:

使用var的非预期行为

我们希望为每个按钮添加监听事件,点击后在控制台输出按钮编号,然而通过var定义循环变量i时,其所在作用域是全局的。代码执行完毕后,当多个回调函数访问位于全局的i时,它的值已经变成了3,所以每次点击控制台都会输出btn 4 is clicked

html
<button>按钮1</button>
<button>按钮2</button>
<button>按钮3</button>
<button>按钮4</button>
<script>
  const btns = document.querySelectorAll('button')
  for (var i = 1; i < btns.length; i++) {
    const btn = btns[i]
    btn.onclick = function () {
      console.log(`btn ${i + 1} is clicked`) // btn 4 is clicked
    }
  }
</script>

早期解决方案

当然,在没有let const的早期也有解决方法:为每个DOM引用添加额外属性index,保存当前次遍历时的i

js
const btns = document.querySelectorAll('button')
for (var i = 0; i < btns.length; i++) {
  const btn = btns[i]
  btn.index = i
  btn.onclick = function () {
    console.log(`btn ${this.index + 1} is clicked`) // btn 0,1,2,3 is clicked
  }
}

也可以使用立即执行函数,每次添加监听回调函数时都创建一个新的作用域,并且将当前的i值作为参数传入,这样每次回调函数在被调用时访问到的都是各自的词法环境(作用域),传入函数的m值是固定的

这里用到了闭包,立即执行函数会拥有自己的AO,而每次遍历时立即执行函数的AO都通过闭包保存了下来,这样挂载到AO上的变量就是各自独立的了

js
const btns = document.querySelectorAll('button')
for (var i = 0; i < btns.length; i++) {
  const btn = btns[i]
  ;(function (m) {
    btn.onclick = function () {
      console.log(`btn ${m + 1} is clicked`) // btn 0,1,2,3 is clicked
    }
  })(i)
}

使用let解决

如果使用let来定义循环用的临时变量i,那么每次遍历时都是一个新的作用域,变量i不会泄露到全局,这样每个回调函数在被调用时访问到的i都来自各自的词法环境,都来自作用域

js
const btns = document.querySelectorAll('button')
for (let i = 0; i < btns.length; i++) {
  const btn = btns[i]
  btn.onclick = function () {
    console.log(`btn ${i + 1} is clicked`) // btn 0,1,2,3 is clicked
  }
}

await async 事件循环

  • async await
  • 浏览器进程 线程
  • 宏任务、微任务队列
  • Promise面试题解析
  • throw try catch
  • 浏览器存储Storage

异步函数 async

js
// 普通函数
function fun() {}
const fun = function () {}
// 箭头函数
const fun = () => {}
// 生成器函数
function* fun() {}
js
// 异步函数写法
async function fun() {}
const fun = async function () {}
const fun = async () => {}

// 默认包裹Promise
async function getData() {
  return '123'
}
const res = getData()
console.log(res) // Promise {<fulfilled>: '123'}

res.then((r) => {
  console.log(r) // '123'
})

异步函数的返回值默认会被包裹一层Promise。

如果代码中抛出了错误,不会像普通函数一样报错,而是会在内部调用reject(err)将错误抛给.catch方法(如果有的话),否则会直接抛出

js
async function getData() {
  throw new Error('Error Info')
  return '123'
}

const res = getData()
.then((r) => {
  console.log(r)
})
.catch((err) => {
  console.log(err)
})

await关键字

  • await关键字只能在异步函数中使用
  • 通常await后面跟上一个表达式,该表达式会返回一个Promise
  • await会等到Promise的状态变为fulfilled状态,之后继续执行异步函数

会阻塞代码执行,与yeild类似

js
async function reqData(param) {
  return param
}

async function getData() {
  const data1 = await reqData('ziu')
  const data2 = await reqData('Ziu')
  const data3 = await reqData('Ziuc')

  console.log(data1, data2, data3)
}

getData()

如果代码在await过程中出现异常,也可以通过.catch捕获到异常

js
async function reqData(param) {
  throw new Error('Error Info')
  return param
}

async function getData() {
  const data = await reqData('ziu')
  console.log(data)
}

getData().catch((err) => {
  console.log(err) // 捕获到异步函数内抛出的异常
})

进程与线程

  • 进程:计算机已经运行的程序,是操作系统管理程序的一种方式
    • 启动一个应用程序时就会默认启动一个或多个进程
  • 线程:操作系统能够运行运算调度的最小单元,通常情况下它被包含在进程中
    • 每一个进程中,都会启动至少一个线程用来执行程序中的代码,这个线程被称之为主线程
    • 可以认为进程是线程的容器

操作系统可以同时让多个进程同时工作

  • CPU的运算速度很快,可以快速地在多个进程之间迅速地切换
  • 当进程中的线程获取到时间片时,就可以快速执行我们编写的代码
  • 用户是感受不到这种快速的切换的

JavaScript线程

JavaScript是单线程(可以开启Worker)的,但是JavaScript线程应该有自己的容器进程:浏览器或Node

  • 浏览器是一个应用程序,是多进程的,每打开一个Tab页面就会开启一个新的进程:防止一个页面卡死导致浏览器崩溃
  • 每个进程都有很多的线程,其中就包括执行JavaScript代码的线程
  • JavaScript的代码执行是在一个单独的线程执行的
    • 这意味着JavaScript的代码在同一个时刻只能做一件事
    • 如果某些任务非常消耗时间,当前线程就会被阻塞
    • timer函数、onClick、ajax callback

setTimeout

当函数执行到setTimeout时,会由浏览器单独的另一个线程负责计时,将这个定时器任务放到Event Table,计时完成后Event Table会将定时器的回调函数放入Event Queue,当主线程的代码执行完毕后,会去Event Queue中取出队头的任务放入主线程中执行

这样的过程会不断重复,这也就是常说的Event Loop 事件循环

js
setTimeout(() => {
  console.log('timeout')
}, 500)

setInterval

setIntervalsetTimeout类似,只不过setInterval会每隔将注册的回调函数放入Event Queue,如果前面的事件处理得太久也会出现延迟执行的问题,如果队列前面的事件阻塞的时间太长,那么后续队列中就连续被放入了多个setInterval的回调函数任务,这样就失去了定期执行的初衷。

js
setInterval(() => {
  console.log('interval')
}, 500)

onClick

DOM监听时注册的回调函数也会作为异步任务进入事件循环中,每次点击都会将回调函数任务加入到Event Queue中。

js
const btn = document.querySelector('button')
btn.addEventListener('click', () => {
  console.log('btn Clicked')
})

微任务与宏任务

  • 宏任务队列macrotask queue

    • ajax、setTimeout、setInterval、DOM监听、UI Rendering等
  • 微任务microtask queue

    • Promise的.then回调、Mutation Observer API、queueMicrotask()等
  • 微任务和宏任务皆为异步任务,它们都属于一个队列,主要区别在于他们的执行顺序,Event Loop的走向和取值。

  • 主线程的代码先执行

  • 在执行任何一个宏任务之前,都会先查看微任务队列中是否有任务需要执行

    • 宏任务执行之前,保证微任务队列是空的
    • 如果不为空,那么优先执行微任务队列中的任务(回调)
js
console.log('script start')

setTimeout(() => {
  console.log('timeout1')
})

new Promise((res) => {
  console.log('promise')
  res()
}).then((res) => {
  console.log('then')
})

setTimeout(() => {
  console.log('timeout2')
})

console.log('script end')
sh
'script start'
'promise'
'script end'
'then'
'timeout1'
'timeout2'

Promise内的代码会直接在主线程中执行,而.then内的回调则会作为微任务进入微任务队列

事件循环 面试题

面试题1

js
console.log('script start')

setTimeout(() => {
  console.log('setTimeout1')
  new Promise((res) => {
    res()
  }).then(() => {
    new Promise((res) => {
      res()
    }).then(() => {
      console.log('then4')
    })
    console.log('then2')
  })
})

new Promise((res) => {
  console.log('promise1')
  res()
}).then(() => {
  console.log('then1')
})

setTimeout(() => {
  console.log('setTimeout2')
})

console.log('2')

queueMicrotask(() => {
  console.log('queueMicrotask1')
})

new Promise((res) => {
  res()
}).then(() => {
  console.log('then3')
})

console.log('script end')
sh
'script start'
'promise1'
'2'
'script end'
'then1'
'queueMicrotask1'
'then3'
'setTimeout1'
'then2'
'then4'
'setTimeout2'
  • 主线程 script start promise1 2 script end
  • 宏任务 setTimeout1 setTimeout2
  • 微任务 then1 queueMicrotask1 then3

先开始执行微任务队列,微任务队列执行完毕清空后,开始setTimeout1宏任务

  • setTimeout1 宏任务开始,then4进入微任务队列,then2直接执行
  • setTimeout1 宏任务结束,微任务队列非空,执行then4,清空微任务队列
  • setTimeout2 宏任务开始,执行完毕

await的使用

js
console.log('script start')

function reqData(param) {
  return new Promise((res) => {
    setTimeout(() => {
      console.log('setTimeout')
      res(param)
    }, 2000)
  })
}

function getData() {
  console.log('getData start')

  reqData('Ziu').then((res) => {
    console.log('res', res)
  })

  console.log('getData end')
}

getData()

console.log('script end')
sh
'script start'
'getData start'
'getData end'
'script end'
'setTimeout'
'res Ziu'
  • 主线程 script start getData start getData end script end

主线程运行过程中,执行到getData start后的Promise时,其中代码在主线程中进行

遇到setTimeout,放入Event Table由浏览器开始计时,计时2s结束后,将回调函数放入宏任务队列中执行setTimeout

setTimeout执行完毕,res(param)执行后,返回的Promise成功resolved,将.then回调函数放入微任务队列并开始执行

微任务.then执行完毕输出res 代码执行结束

改用await实现微任务

js
console.log('script start')

function reqData(param) {
  return new Promise((res) => {
    setTimeout(() => {
      console.log('setTimeout')
      res(param)
    }, 2000)
  })
}

async function getData() {
  console.log('getData start')

  const res = await reqData('Ziu')

  console.log('res', res)
  console.log('getData end')
}

getData()

console.log('script end')

改写上述面试题2,将res部分代码改为使用await,这里的代码输出为:

sh
'script start'
'getData start'
'script end'
'setTimeout'
'res Ziu'
'getData end'

await后续的代码,实质上就是一个微任务,这些代码不在主线程中被执行,而是被放入微任务队列:

js
/* 使用await */
const res = await reqData('Ziu')
console.log('res', res)
console.log('getData end')
/* 不使用await */
reqData('Ziu').then((res) => {
  console.log('res', res)
})
console.log('getData end')

这样就会出现:先script end,后setTimeout, res Ziu, getData end的情况

面试题2

js
console.log('script start')

async function async1() {
  console.log('async1 start')
  await async2()
  console.log('async1 end')
}

async function async2() {
  console.log('async2')
}

setTimeout(() => {
  console.log('setTimeout')
})

async1()

new Promise((res) => {
  console.log('promise1')
  res()
}).then(() => {
  console.log('promise2')
})

console.log('script end')
sh
'script start'
'async1 start'
'async2'
'promise1'
'script end'
'async1 end'
'promise2'
'setTimeout'

await前的代码是跟随之前代码的执行顺序执行的,而其后的代码会被放入微任务队列中延迟执行

Node事件循环

TODO

防抖与节流

  • 认识防抖与节流
  • underscore使用
  • 防抖函数实现优化
  • 节流函数实现优化
  • 深拷贝函数的实现
  • 事件总线工具实现

防抖函数

降低高频操作的发生频次,如输入框防抖

  • 当事件触发时,相应的函数不会立即触发,而是会等待一定的时间
  • 当事件密集触发时,函数的触发会被频繁的推迟
  • 只有等待了一段时间也没有事件触发,才会真正的执行响应函数

防抖的应用场景

  • 输入框中频繁输入内容
  • 频繁的点击按钮
  • 监听浏览器的滚动事件
  • 用户缩放浏览器的resize事件

基本实现

  • 入参:函数、防抖时间
  • 返回值:一个新的函数
js
function debounce(callback, timeout) {
  let timer = null
  const _debounce = function () {
    if (timer) clearTimeout(timer) // 高频调用 清理上次调用的timer
    // 创建本次调用的timer
    timer = setTimeout(() => {
      callback()
      timer = null // 执行回调 清理此次调用的timer
    }, timeout)
  }
  return _debounce
}

const input = document.querySelector('input')
input.oninput = debounce(function() {
  console.log(this.value)
}, 500)

优化 绑定this 传入参数

此时并没有绑定this,无法通过this.value获取到当前输入框内的值,现在要将返回的函数_debounce绑定给input

以DOM事件绑定的函数为例,事件触发回调的入参会有参数event可供使用

js
function debounce(callback, timeout) {
  let timer = null
  const _debounce = function (...args) {
    if (timer) clearTimeout(timer) // 高频调用 清理上次调用的timer
    // 创建本次调用的timer
    timer = setTimeout(() => {
      callback.apply(this, args)
      timer = null // 执行回调 清理此次调用的timer
    }, timeout)
  }
  return _debounce
}

const input = document.querySelector('input')
input.oninput = debounce(function (event) {
  console.log(this.value, event)
}, 500)

需要注意的是:在箭头函数中没有this,会直接去上层作用域找this,直到找到function定义的函数。

优化 取消功能

如果用户输入过程中执行了页面跳转,旧的防抖函数需要被取消

_debounce绑定一个取消的函数cancel(),执行后如果有timer,则清理此timer

js
function debounce(callback, timeout) {
  let timer = null
  const _debounce = function (...args) {
    if (timer) clearTimeout(timer) // 高频调用 清理上次调用的timer
    // 创建本次调用的timer
    timer = setTimeout(() => {
      callback.apply(this, args)
      timer = null // 执行回调 清理此次调用的timer
    }, timeout)
  }
  _debounce.cancel = function () {
    if (timer) clearTimeout(timer)
    timer = null
  }
  return _debounce
}

const input = document.querySelector('input')
const callback = debounce(function (event) {
  console.log(this.value, event)
}, 2000, true)
input.oninput = callback

const cancel = () => {
  console.log('debounce canceled')
  callback.cancel()
}

优化 立即执行功能

第一次触发时立即执行,后续时才防抖(默认为false

js
function debounce(callback, timeout, immediate = false) {
  let timer = null
  let isInvoke = false
  const _debounce = function (...args) {
    if (timer) clearTimeout(timer) // 高频调用 清理上次调用的timer

    // 需要立即执行 并且是第一次执行 立即执行回调
    if (immediate && !isInvoke) {
      callback.apply(this, args)
      isInvoke = true // 标记已经执行过
      return
    }
    // 创建本次调用的timer
    timer = setTimeout(() => {
      callback.apply(this, args)
      timer = null // 执行回调 清理此次调用的timer
      isInvoke = false // 恢复初始状态
    }, timeout)
  }
  _debounce.cancel = function () {
    if (timer) clearTimeout(timer)
    timer = null
    isInvoke = false
  }
  return _debounce
}

优化 获取返回值

在某些场景下,我们希望获取防抖函数中回调的返回值

js
const myDebounce = debounce(function (name, age, height) {
  const res = `${name} ${age} ${height}`
  console.log(res)
  return res
}, 1000)

// 三次调用只会执行最后一次
const res1 = myDebounce('Ziu', 18, 1.88)
const res2 = myDebounce('Ziu', 18, 1.88)
const res3 = myDebounce('Ziu', 18, 1.88)

console.log(res1, res2, res3) // undefined undefined undefined

可以将callback的返回值用Promise返回,后续有结果直接通过.then即可获取到返回值,同时通过try-catch包裹,抛出代码执行错误

js
function debounce(callback, timeout, immediate = false) {
  let timer = null
  let isInvoke = false
  const _debounce = function (...args) {
    return new Promise((resolve, reject) => {
      try {
        if (timer) clearTimeout(timer) // 高频调用 清理上次调用的timer
        // 需要立即执行 并且是第一次执行 立即执行回调
        if (immediate && !isInvoke) {
          const res = callback.apply(this, args)
          resolve(res)
          isInvoke = true // 标记已经执行过
          return
        }
        // 创建本次调用的timer
        timer = setTimeout(() => {
          const res = callback.apply(this, args)
          resolve(res)
          timer = null // 执行回调 清理此次调用的timer
          isInvoke = false // 恢复初始状态
        }, timeout)
      } catch (error) {
        reject(error)
      }
    })
  }
  return _debounce
}

const myDebounce = debounce(function (name, age, height) {
  const res = `${name} ${age} ${height}`
  return res
}, 1000)

myDebounce('Ziu', 18, 1.88).then((res) => {
  console.log(res) // Ziu 18 1.88
})

节流函数

基本实现

js
function throttle(callback, interval) {
  let startTime = 0 // 开始时间
  const _throttle = function (...args) {
    const nowTime = new Date().getTime() // 当前时间
    const waitTime = interval - (nowTime - startTime) // 需要等待的时间
    // 结束等待 调用函数
    if (waitTime <= 0) {
      callback.apply(this, args)
      startTime = nowTime // 重置开始时间 准备下一次调用
    }
  }
  return _throttle
}

const input = document.querySelector('input')
const callback = throttle(function (event) {
  console.log(this.value, event)
}, 2000)
input.oninput = callback

立即执行

一般情况下需要立即执行,也是默认状态。当需要取消第一次输入时的立即执行时,可以令startTimenowTime相等,这样_throttle第一次触发时,计算出来的waitTime值就为interval的值,也就是等待一个间隔的时间再执行。

需要注意的是触发条件为!immediate && startTime ===0同时满足,默认立即执行,只有同时满足startTime === 0即第一次触发函数时才会设置startTime = nowTime

js
function throttle(callback, interval, immediate = true) {
  let startTime = 0 // 开始时间
  const _throttle = function (...args) {
    const nowTime = new Date().getTime() // 当前时间

    if (!immediate && startTime === 0) {
      startTime = nowTime
    }

    const waitTime = interval - (nowTime - startTime) // 需要等待的时间
    // 结束等待 调用函数
    if (waitTime <= 0) {
      callback.apply(this, args)
      startTime = nowTime // 重置开始时间 准备下一次调用
    }
  }
  return _throttle
}

const input = document.querySelector('input')
const callback = throttle(
  function (event) {
    console.log(this.value, event)
  },
  2000,
  false
)
input.oninput = callback

深拷贝与浅拷贝

对象属于引用类型,如果单纯复制

浅拷贝

  • 直接引用赋值
    • 两个对象仍然共用一块内存
  • 浅拷贝
    • 解构创建一个新对象
    • Object.assign(target, source)
js
const obj1 = {
  name: 'ziu',
  age: 18,
  friend: {
    name: 'kobe'
  }
}

// 1. 引用赋值
// 修改obj2会影响原数据
const obj2 = obj1
obj2.name = 'Ziu'
console.log(obj1, obj2)

// 2. 浅拷贝
// 修改基本数据类型不会影响原对象
// 但是引用类型数据仍然是原对象的引用
const obj3 = { ...obj1 } // Object.assign({}, obj1)
obj3.name = 'Ziuc'
obj3.friend.name = 'james'
console.log(obj1, obj3)

深拷贝

  • JSON.stringify() JSON.parse()
    • 性能较差 对function Symbol无能为力(只能解析标准JSON格式 其他内容默认会被忽略)
    • 局限性较大
  • 实现深拷贝函数(第三方库)

基本实现

封装函数isObject判断传入变量是否为对象类型

js
function isObject(variable) {
  // null | object | array -> object
  // function -> function
  const type = typeof variable
  return variable !== null && (type === 'object' || type === 'function')
}

console.log(isObject(() => {})) // true
console.log(isObject(null)) // false
console.log(isObject({})) // true
console.log(isObject(undefined)) // false

通过递归实现基本的deepCopy函数,可以深拷贝嵌套对象

传入的对象如果包含数组,通过 Array.isArray() .toString() 等方法对数据类型进行具体的判断

js
function deepCopy(originValue) {
  // 递归的值非对象 直接返回其本身
  if (!isObject(originValue)) {
    return originValue
  }
  const target = Array.isArray(originValue) ? [] : {} // 判断原始值是数组还是对象
  for (const key in originValue) {
    // 递归调用deepCopy 实时创建新对象
    target[key] = deepCopy(originValue[key])
  }
  return target
}

支持Set

此时的代码,如果原始对象包含Set(),他也会被当做Object处理,但是需要注意的是:

  • Set()不支持 for-in 遍历
  • Set()只支持 for-of 遍历
js
// 处理Set
if (originValue instanceof Set) {
  const set = new Set()
  for (const item of originValue) {
    console.log(item)
    set.add(deepCopy(item))
  }
  return set
}

支持函数

一般情况下是不需要深拷贝函数的,对性能有较大的影响,当然,如果一定要支持深拷贝函数,只需要对函数做一次判断即可

直接返回其本身,这样两个函数都是同一片内存空间的引用,不必重新开辟新的内存空间

js
// 处理Function
if (typeof originValue === 'function') {
  return originValue
}

支持Symbol作为值

typeof Symbol() 返回的是 'symbol',同时需要考虑到如果Symbol()传入了description的情况

js
// 处理Symbol
if (typeof originValue === 'symbol') {
  return Symbol(originValue.description)
}

支持Symbol作为键

Symbol作为键在执行for-in时不会被遍历得到,以下是MDN对for-in的说明:

for...in 语句以任意顺序迭代一个对象的除Symbol以外的可枚举属性,包括继承的可枚举属性。

js
// 遍历对象中的Symbol键
const symbolKeys = Object.getOwnPropertySymbols(originValue)
for (const symbol of symbolKeys) {
  target[Symbol(symbol.description)] = deepCopy(originValue[symbol])
}

使用Object.getOwnPropertySymbols()方法获取所有键名类型为SymbolSymbo对象并遍历,逐个向新的深拷贝对象中添加对应description的新的Symbol键值对

支持循环引用

循环引用:在使用JSON.stringify对包含循环引用的对象进行转化时会报错

js
const obj = {
  name: 'Object'
}
obj.self = obj
console.log(obj === obj.self) // true
console.log(obj.self.self.self.self.self.self) // obj

如果使用我们之前编写的deepCopy拷贝一个包含循环引用的对象,会出现函数执行栈溢出的问题,这是因为target[key] = deepCopy(originValue[key])不会跳出而是会一直递归,最终导致栈溢出

可以为增加一个默认入参,每次调用deepCopy时为其传入一个Map,其中保存着拷贝出来的新对象的引用,在调用deepCopy时对此Map进行判断,如果其中已经包含了拷贝出来的新对象则直接取值并赋给当前的key,不再重新拷贝

每次执行完深拷贝需要对这个Map中保存的对象进行删除操作,如果通过map = null销毁此map,由于Map是强引用,里面保存的对象在内存中并不会被真正销毁。可以使用WeakMap替换,WeakMap中的引用是弱引用,如果引用的原始对象被销毁,能保证其引用的对象也被销毁

完整代码

js
function isObject(variable) {
  // null | object | array -> object
  // function -> function
  const type = typeof variable
  return variable !== null && (type === 'object' || type === 'function')
}

function deepCopy(originValue, map = new WeakMap()) {
  // 处理Symbol
  if (typeof originValue === 'symbol') {
    return Symbol(originValue.description)
  }

  // 递归的值非对象 直接返回其本身
  if (!isObject(originValue)) {
    return originValue
  }

  // 处理Set
  if (originValue instanceof Set) {
    const set = new Set()
    for (const item of originValue) {
      console.log(item)
      set.add(deepCopy(item))
    }
    return set
  }

  // 处理Function
  if (typeof originValue === 'function') {
    return originValue
  }

  // 如果存在引用循环 且上次已经设置过引用 则跳出递归
  if (map.get(originValue)) {
    return map.get(originValue)
  }

  // 如果是对象/数组类型 需要创建新的对象/数组
  const target = Array.isArray(originValue) ? [] : {} // 判断原始值是数组还是对象
  map.set(originValue, target) // 将新对象自身保存到WeakMap中

  // 遍历当前对象中所有普通的key
  for (const key in originValue) {
    // 递归调用deepCopy 实时创建新对象
    target[key] = deepCopy(originValue[key], map)
  }

  // 遍历对象中的Symbol键
  const symbolKeys = Object.getOwnPropertySymbols(originValue)
  for (const symbol of symbolKeys) {
    target[Symbol(symbol.description)] = deepCopy(originValue[symbol], map)
  }

  return target
}

const obj = {
  name: 'Ziu',
  age: 18,
  friend: {
    name: 'kobe',
    address: {
      name: '洛杉矶',
      detail: '斯坦普斯中心'
    }
  },
  array: [
    { name: 'JavaScript权威指南', price: 99 },
    { name: 'JavaScript高级程序设计', price: 66 }
  ],
  set: new Set(['abc', 'cba', 'nba']),
  func: function () {
    console.log('hello, deepCopy')
  },
  [Symbol('abc')]: 'Hello, Symbol Key.',
  [Symbol('cba')]: 'Hello, Symbol Key.'
}
obj.self = obj

const newObj = deepCopy(obj)
console.log(newObj)

事件总线

js
class ZiuEventBus {
  constructor() {
    this.eventMap = {}
  }

  on(eventName, eventFn) {
    // 获取保存事件回调的数组
    let eventFns = this.eventMap[eventName]
    if (!eventFns) {
      eventFns = []
      this.eventMap[eventName] = eventFns // 一个事件名可以同时注册多个回调函数
    }
    // 将当前回调存入数组
    eventFns.push(eventFn)
  }

  off(eventName, eventFn) {
    let eventFns = this.eventMap[eventName]
    if (!eventFns) return // 如果没有注册过事件回调 直接返回

    for (let i = 0; i < eventFns.length; i++) {
      const fn = eventFns[i]

      // 是同一个函数引用
      if (fn === eventFn) {
        eventFns.splice(i, 1)
        break
      }
    }
  }

  emit(eventName, ...args) {
    let eventFns = this.eventMap[eventName]
    if (!eventFns) return
    eventFns.forEach((fn) => {
      fn(...args)
    })
  }
}

const eventBus = new ZiuEventBus()

const callBack = (payload) => {
  console.log('navClick', payload)
}

// 支持为同一事件注册多个回调
eventBus.on('navClick', callBack)
eventBus.on('navClick', callBack)

const btn = document.querySelector('button')
btn.onclick = () => {
  // 手动触发事件回调
  eventBus.emit('navClick', {
    name: 'Ziu',
    age: 18
  })
}

// 支持移除回调
setTimeout(() => {
  eventBus.off('navClick', callBack) // 移除某个事件的某次回调
}, 5000)

Released under the MIT License.