我们经常提到的 JavaScript,其实指的是 ECMAScript。ECMAScript 是形成 JavaScript 语言基础的脚本语言。而我们常说的 ES6/ES7,其实是一些 ECMAScript 新特性,主要是用来提升开发效率的语法糖。对于 Javascript,我们需要了解以下一些知识。

# 1. 单线程的 Javascript

作为浏览器脚本语言,JavaScript 的主要用途是与用户互动,以及操作 DOM。如果 Javascript 是多线程,当页面更新内容的时候、用户又触发了交互,这时候线程间的同步问题会变得很复杂,为了避免复杂性,Javascript 被设计为单线程。

那么这样一个单线程的 Javascript,要如何高效地进行页面的交互和渲染处理呢?Javascript 只有一个线程,意味着任务需要一个接一个地进行,如果这时候我们有一个任务是等待用户输入,那在用户进行操作前,所有其他任务都会等待,页面处于假死状态,体验糟糕。因此,异步任务出现了。

在浏览器中,任务可以分为同步任务和异步任务两种。同步任务在主线程上排队执行,只有前一个任务执行完毕,才能执行后一个任务。异步任务进入"任务队列"的任务,当该任务完成后,"任务队列"通知主线程,该任务才会进入主线程排队执行。主线程会在空闲时获取任务队列中的队列执行,这个模型也被称为 Event Loop。

异步任务机制会导致一些前端容易踩的坑,常见的有setTimeoutsetInterval的时间精确性。该类方法设置一个定时器,当定时器计时完成时需要执行回调函数,此时才把回调函数放入事件队列中。如果当回调函数放入队列时,假设队列中还有大量的事件没执行,此时就会造成任务执行时间不精确。

一般来说,可以使用系统时钟来补偿计时器不准确性。在每次回调任务结束的时候,根据最初的系统时间和该任务的执行时间进行差值比较,来修正后续的定时器时间。

# 2. 原型和继承

Javascript 的原型和继承围绕着一点展开:几乎所有的对象都是Object的实例,Object位于原型链的顶端。

原型对象

当谈到继承时,JavaScript 只有一种结构:对象。几乎所有 JavaScript 中的对象都是Object的实例,包括函数、数组、对象等。Javascript 中的对象之所以用途广泛,是因为它的值既可以是原始类型(numverstringbooleannullundefinedbigintsymbol),还可以是对象和函数。其中,函数也是一种特殊的对象,它同样拥有属性和值,除此之外还有name(函数名)和code(函数代码)两个隐藏属性,因此可被调用。

在一个对象中,属性的值同样可以为另外一个对象,因此我们可以通过这样的方式来实现继承:使用一个代表原型的属性,属性的值为被继承的对象,此时可以通过层层查找来得到原型链上的对象和属性。在 Javascript 中,该属性便是__proto__,被继承的对象即原型对象prototype

创建一个对象包括了工厂模式、构造函数模式、原型模式等,可以使用以下方法:

  • 使用语法结构,即定义一个数组、函数、对象等
  • 使用构造器: new XXX()
  • 使用Object.create
  • 使用class关键字

其中,最常见的便是使用构造函数来创建对象:

(1) 默认情况下,所有原型对象(prototype)自动获得一个constructor属性,指向与之关联的构造函数。

(2) 当我们创建对象时,Javascript 就会创建该构造函数的实例。

(3) 创建的实例通过将__proto__指向构造函数的原型对象(prototype),来继承该原型对象的所有属性和方法。

构造函数、原型和实例的关系:

  • 每个原型对象(prototype)都拥有constructor属性,指向该原型对象的构造函数
  • 使用构造函数可以创建对象,创建的对象称为实例对象
  • 实例对象通过将__proto__属性指向原型对象(prototype),实现了该原型对象的继承
  • 实例与构造函数原型之间有直接的关系,但与构造函数之间没有

关于__proto__prototype,很多时候我们容易搞混:

  • 每个对象都有__proto__属性来标识自己所继承的原型对象,但只有函数才有prototype属性
  • 通过prototype__proto__,JavaScript 可以在两个对象之间创建一个关联,使得一个对象可以通过委托访问另一个对象的属性和函数

原型链

原型链是 Javascript 中主要的继承方式,可以通过原型继承多个引用类型的属性和方法。

我们知道,一个对象可通过__proto__访问原型对象上的属性和方法,而该原型同样也可通过__proto__访问它的原型对象,这样我们就在实例和原型之间构造了一条原型链。JavaScript 中的所有对象都来自Object,因此默认情况下,任何函数的原型属性__proto__都是window.Object.prototypeprototype原型对象同样会具有一个自己的原型,层层向上直到一个对象的原型为null

关于原型链,我们需要知道:

  • 当试图访问一个对象的属性时,会优先在该对象上搜寻。如果找不到,还会依次层层向上搜索该对象的原型对象、该对象的原型对象的原型对象等(套娃告警)
  • 根据定义,null没有原型,并作为这个原型链中的最后一个环节
  • __proto__的整个原型链被查看之后,浏览器才会认为该属性不存在,并给出属性值为undefined的结论
// 任何函数的原型属性 __proto__ 都是 Object.prototype
// Object.getPrototypeOf() 方法返回指定对象的原型
// 我们能看到,null 作为原型链中最后一个环节
Object.getPrototypeOf(Object.prototype) === null; // true

我们来看个具体的例子:

// 让我们假设我们有一个对象 o, 其有自己的属性 a 和 b:
var o = {a: 1, b: 2};
// o 的原型 o.__proto__有属性 b 和 c:
o.__proto__ = {b: 3, c: 4};
// 最后, o.__proto__.__proto__ 是 null.
// 这就是原型链的末尾,即 null,
// 根据定义,null 没有__proto__.
// 综上,整个原型链如下:
{a:1, b:2} ---> {b:3, c:4} ---> null

// 当我们在获取属性值的时候,就会触发原型链的查找:
console.log(o.a); // o.a => 1
console.log(o.b); // o.b => 2
console.log(o.c); // o.c => o.__proto__.c => 4
console.log(o.d); // o.c => o.__proto__.d => o.__proto__.__proto__ == null => undefined

属性的查找会带来性能问题:

  • 在原型链上查找属性比较耗时,对性能有副作用,这在性能要求苛刻的情况下很重要
  • 试图访问不存在的属性时,会遍历整个原型链

继承

前面我们提到,在 Javascript 中原型链是主要的继承方式。总体上,Javascript 中实现继承的方式包括:

  • 原型链继承:把构造函数的原型赋值为另一个类型的实例
  • 盗用构造函数(经典继承):在子类构造函数中调用父类构造函数
  • 组合继承:通过原型链继承共享的属性和方法,通过构造函数继承实例属性
  • 原型式继承:将传入的对象作为创建的对象的原型,本质上是对给定对象执行浅复制
  • 寄生式继承:基于一个对象创建一个新对象,增强新对象后返回新对象
  • 寄生组合式继承

其中,原型链继承方式中引用类型的属性被所有实例共享,无法做到实例私有;盗用构造函数方式可以实现实例属性私有,但要求类型只能通过构造函数来定义;组合继承融合原型链继承和构造函数的优点,是 JavaScript 中最常用的继承模式,它长这样:

function Parent(name) {
  // 私有属性,不共享
  this.name = name;
}
// 需要复用、共享的方法定义在父类原型上
Parent.prototype.speak = function () {
  console.log("hello");
};
function Child(name) {
  Parent.call(this, name);
}
// 将子类的 __proto__ 指向父类原型
Child.__proto__ = Parent.prototype;

# 3. 函数执行上下文

Javascript 运行过程主要分成三个阶段:

(1) 语法分析阶段:分析代码是否有语法错误(SyntaxError),如果有语法错误将会在控制台报错,并终止执行。

(2) 编译阶段。每进入一个不同的运行环境时都会:

  • 创建一个新的执行上下文(Execution Context)
  • 创建一个新的词法环境(Lexical Environment),即作用域

(3) 执行阶段。Javascript 在运行过程中会产生一个调用栈,调用栈遵循 LIFO(先进后出,后进先出)原则:

  • 将步骤 (2) 中创建的执行上下文压入执行栈,并成为正在运行的执行上下文
  • 执行代码
  • 在代码执行结束后,将其弹出执行栈

Javascript 运行环境

Javascript 运行环境包括全局环境、函数环境和eval

第一次载入 Javascript 代码时,会创建一个全局环境。当函数被调用时,则进入该函数的运行环境。不同的函数运行环境不一样,即使是同一个函数,在被多次调用时也会创建多个不同的函数环境。

不同的运行环境中,变量和函数可访问的其他数据范围不同,各种的行为也有所区别。每进入一个不同的运行环境时,Javascript 都会创建一个新的执行上下文,该过程包括:建立作用域链(Scope Chain)、创建变量对象(VO,Variable Object)以及确定 this 的指向。

创建变量对象

每个上下文都有一个关联的变量对象,这个上下文中定义的所有变量和函数都存在于这个对象上。在浏览器中,全局环境中的变量对象是window对象,所有的全局变量和函数都是作为window对象的属性和方法创建的。相对应的,在 Node 中则是global对象。

创建变量对象过程将会创建arguments对象(仅函数环境下),同时会检查当前上下文的函数声明和变量声明:

  • 对于变量声明,给变量分配内存,初始化为undefined(定义声明和赋值声明分开,执行阶段才执行赋值语句)
  • 对于函数声明,会在内存里创建函数对象,并且直接初始化为该函数对象

这也是我们常说的变量提升和函数提升,其中函数声明提升优先于变量声明。变量提升容易带来在预期外被覆盖掉的问题,同时还可能导致本应该被销毁的变量没有被销毁等情况,因此 ES6 中引入了letconst关键字,从而使 Javascript 也拥有了块级作用域。

当代码进入执行阶段后,我们在编译阶段创建的变量对象(VO)中变量属性会进行赋值,变量对象会转为活动对象(Active Object,简称 AO),此时活动对象才可被访问。这便是 VO -> AO 的过程,本质上变量对象和活动对象为一个对象,但在编译阶段该对象值仍为undefined,且处于不可访问的状态。

this

Javascript 中this指针代表的是执行当前代码的对象的所有者,可简单理解为this永远指向最后调用它的那个对象。

根据 JavaScript 中函数的调用方式不同,this分为以下情况:

  • 在全局执行环境中(在任何函数体外部),this指向全局对象(在浏览器中为 window
  • 在函数内部,this的值取决于函数被调用的方式
    • 函数作为对象的方法被调用,this指向调用这个方法的对象
    • 函数用作构造函数时(使用new关键字),它的this被绑定到正在构造的新对象
    • 在类的构造函数中,this是一个常规对象,类中所有非静态的方法都会被添加到this的原型中
  • 在箭头函数中,this执行它被创建时的环境
  • 使用applycallbind等方式调用:根据 API 不同,可切换函数执行的上下文环境,即this绑定的对象

# 4. 作用域和闭包

我们常说的作用域即当前的执行上下文,在 ES5 后我们使用 Lexical Environment(词法环境)替代作用域来描述该执行上下文。词法环境由两个成员组成:环境记录(Environment Record)和和外部词法环境引用(Outer Lexical Environment,简称 Outer)。

每个词法环境的 Outer 记录了外层词法环境的引用,当在自身词法环境记录无法寻找到某个变量时,可以根据 Outer 向外层寻找。在最外层的全局词法环境中,Outer 为null

当代码在一个环境中执行时,会通过 Outer 创建变量对象的一个作用域链,来保证对执行环境有权访问的变量和函数的有序访问。每个 JavaScript 执行环境都有一个和它关联在一起的作用域链,这个作用域链是一个对象列表或对象链。在函数执行过程中,变量的解析是沿着作用域链一级一级搜索的过程:

  • 从第一个对象开始,逐级向后回溯,直到找到同名变量为止
  • 找到后不再继续遍历,找不到就报错
  • 当函数执行结束之后,执行期上下文将被销毁(作用域链和激活对象均被销毁)

作用域链使得我们在函数内部可以直接读取外部以及全局变量,闭包使得我们可以从外部读取局部变量。

由于在 Javascript 语言中,只有函数内部的子函数才能读取局部变量。我们看下面的例子:

function B() {
  var b = 2;
}
B();
alert(b); //undefined

在全局环境下无法访问函数 B 内的变量,这是因为全局函数的作用域链里,不含有函数 B 内的作用域。现在如果我们想要访问内部函数的变量,可以这样做:

function B() {
  var b = 2;
  function C() {
    alert(b); //2
  }
  return C;
}
var A = B();
A(); //2

此处,A 变成一个闭包了。闭包是一种特殊的对象。它由两部分构成:函数,以及创建该函数的环境。环境由闭包创建时在作用域中的任何局部变量组成,在本质上,闭包就是将函数内部和函数外部连接起来的一座桥梁。闭包的常见用途包括:

  • 用于从外部读取其他函数内部变量的函数
  • 可以使用闭包来模拟私有方法
  • 让这些变量的值始终保持在内存中(涉及垃圾回收机制,可能导致内存泄露问题)

# 5. 事件循环机制(Event Loop)

JavaScript 有一个基于事件循环的并发模型,称为事件循环(Event Loop)。前面我们提到异步任务的存在,Event Loop 的设计解决了异步任务的同步问题。

要理解 Javascript 的事件循环设计,需要先了解执行栈和任务队列。

执行栈

函数执行过程会产生一个调用栈,调用栈可理解为一个存储函数调用的栈结构,遵循先进后出的原则:

  • 每调用一个函数,该函数会被添加进调用栈,并开始执行
  • 如果正在调用栈中执行的 A 函数还调用了 B 函数,那么 B 函数也将会被添加进调用栈
  • 一旦 B 函数被调用,便会立即执行
  • 当前函数执行完毕后,解释器将其清出调用栈,继续执行当前执行环境下的剩余的代码

由此可见,该函数调用栈栈底永远是全局执行上下文,栈顶则永远是当前执行上下文。在不考虑全局执行上下文时,我们可以理解为刚开始的时候调用栈是空的,每当有函数被调用,相应的执行上下文都会被添加到调用栈中。执行完函数中相关代码后,该执行上下文又会自动被调用栈移除,最后调用栈又回到了空的状态(不考虑全局执行上下文)。

由于栈的容量是有限制的,因此当我们没有合理调用函数的时候,可能会导致爆栈异常。

任务队列

JavaScript 运行时会包含一个待处理的任务队列,每一个任务都关联着一个用以处理这个任务的回调函数。

任务队列则遵循先进先出的原则,处理过程如下:

  • 运行时会从最先进入队列的任务开始处理队列中的任务
  • 被处理的任务会被移出队列,并作为输入参数来调用与之关联的函数,此时会产生一个函数执行栈
  • 函数会一直处理到执行栈再次为空,然后事件循环将会处理队列中的下一个任务

在掌握了 Javascript 的单线程设计,以及同步任务、异步任务、执行栈和任务队列这些概念之后,我们来学习下 Event Loop 机制。

浏览器的 Event Loop

在浏览器里,每当一个被监听的事件发生时,事件监听器绑定的相关任务就会被添加进任务队列。通过事件产生的任务是异步任务,常见的事件任务包括:

  • 用户交互事件产生的事件任务,比如 DOM 操作
  • 计时器产生的事件任务,比如setTimeout
  • 异步请求产生的事件任务,比如 HTTP 请求

Javascript 的运行过程,可以借用一张经典的图来描述:

如图,主线程运行的时候,会产生堆(heap)和栈(stack),其中堆为内存、栈为函数调用栈。我们能看到,Event Loop 负责执行代码、收集和处理事件以及执行队列中的子任务,具体包括:

  • Javascript 有一个主线程和执行栈,所有的任务都会被放到调用栈等待主线程执行
  • 同步任务会被放在调用栈中,按照顺序等待主线程依次执行
  • 主线程之外存在一个任务队列,所有任务在主线程中以执行栈的方式运行
  • 同步任务都在主线程上执行,栈中代码在执行的时候会调用 Web API,此时会产生一些异步任务
  • 异步任务会在有了结果(比如被监听的事件发生时)后,将注册的回调函数放入任务队列中
  • 执行栈中任务执行完毕后,此时主线程处于空闲状态,会从任务队列中获取任务进行处理

上述过程会不断重复,这就是 JavaScript 的运行机制,称为事件循环机制(Event Loop)。

Node.js 中的 Event Loop

除了浏览器,Node.js 中同样存在 Event Loop。我们知道 Javascript 是单线程的,Event Loop 使 Node.js 可以通过将操作转移到系统内核中来执行非阻塞 I/O 操作。

Node.js 中的事件循环执行过程为:

(1) 当 Node.js 启动时将初始化事件循环,处理提供的输入脚本。

(2) 提供的输入脚本可以进行异步 API 调用,然后开始处理事件循环。

(3) 在事件循环的每次运行之间,Node.js 会检查它是否正在等待任何异步 I/O 或计时器,如果没有,则将其干净地关闭。

与浏览器不一样,Node.js 中事件循环分成不同的阶段:

  • timers:此阶段由setTimeout()和安排的回调setInterval()执行
  • pending callbacks:执行推迟到下一个循环迭代的 I/O 回调
  • idle/prepare:仅在 Node.js 内部使用
  • poll:检索新的 I/O 事件,执行与 I/O 相关的回调,节点将在此处阻塞
  • check:setImmediate()在这里调用回调
  • close callbacks:一些关闭回调,例如socket.on('close', ...)
   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤               |
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘

由于事件循环阶段划分不一致,Node.js 和浏览器在对宏任务和微任务的处理上也不一样。

宏任务和微任务

事件循环中的异步任务队列有两种:宏任务(MacroTask)和微任务(MicroTask)队列:

  • 宏任务:包括 script 全部代码、setTimeoutsetIntervalsetImmediate(Node.js)、requestAnimationFrame(浏览器)、I/O 操作、UI 渲染(浏览器)
  • 微任务:包括process.nextTick(Node.js)、PromiseMutationObserver

在浏览器中,异步任务队列的执行过程如下:

(1) 宏任务队列一次只从队列中取一个任务执行,执行完后就去执行微任务队列中的任务。

(2) 微任务队列中所有的任务都会被依次取出来执行,直到微任务队列为空。

(3) 在执行完所有的微任务之后,执行下一个宏任务之前,浏览器会执行 UI 渲染操作、更新界面。

我们能看到,在浏览器中每个宏任务执行完成后,会执行微任务队列中的任务。而在 Node.js 中,事件循环分为 6 个阶段,微任务会在事件循环的各个阶段之间执行。也就是说,每当一个阶段执行完毕,就会去执行微任务队列的任务。