我们经常提到的 JavaScript,其实指的是 ECMAScript。ECMAScript 是形成 JavaScript 语言基础的脚本语言。而我们常说的 ES6/ES7,其实是一些 ECMAScript 新特性,主要是用来提升开发效率的语法糖。对于 Javascript,我们需要了解以下一些知识。
# 1. 单线程的 Javascript
作为浏览器脚本语言,JavaScript 的主要用途是与用户互动,以及操作 DOM。如果 Javascript 是多线程,当页面更新内容的时候、用户又触发了交互,这时候线程间的同步问题会变得很复杂,为了避免复杂性,Javascript 被设计为单线程。
那么这样一个单线程的 Javascript,要如何高效地进行页面的交互和渲染处理呢?Javascript 只有一个线程,意味着任务需要一个接一个地进行,如果这时候我们有一个任务是等待用户输入,那在用户进行操作前,所有其他任务都会等待,页面处于假死状态,体验糟糕。因此,异步任务出现了。
在浏览器中,任务可以分为同步任务和异步任务两种。同步任务在主线程上排队执行,只有前一个任务执行完毕,才能执行后一个任务。异步任务进入"任务队列"的任务,当该任务完成后,"任务队列"通知主线程,该任务才会进入主线程排队执行。主线程会在空闲时获取任务队列中的队列执行,这个模型也被称为 Event Loop。
异步任务机制会导致一些前端容易踩的坑,常见的有setTimeout
、setInterval
的时间精确性。该类方法设置一个定时器,当定时器计时完成时需要执行回调函数,此时才把回调函数放入事件队列中。如果当回调函数放入队列时,假设队列中还有大量的事件没执行,此时就会造成任务执行时间不精确。
一般来说,可以使用系统时钟来补偿计时器不准确性。在每次回调任务结束的时候,根据最初的系统时间和该任务的执行时间进行差值比较,来修正后续的定时器时间。
# 2. 原型和继承
Javascript 的原型和继承围绕着一点展开:几乎所有的对象都是Object
的实例,Object
位于原型链的顶端。
原型对象
当谈到继承时,JavaScript 只有一种结构:对象。几乎所有 JavaScript 中的对象都是Object
的实例,包括函数、数组、对象等。Javascript 中的对象之所以用途广泛,是因为它的值既可以是原始类型(numver
、string
、boolean
、null
、undefined
、bigint
和symbol
),还可以是对象和函数。其中,函数也是一种特殊的对象,它同样拥有属性和值,除此之外还有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.prototype
。prototype
原型对象同样会具有一个自己的原型,层层向上直到一个对象的原型为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 中引入了let
和const
关键字,从而使 Javascript 也拥有了块级作用域。
当代码进入执行阶段后,我们在编译阶段创建的变量对象(VO)中变量属性会进行赋值,变量对象会转为活动对象(Active Object,简称 AO),此时活动对象才可被访问。这便是 VO -> AO 的过程,本质上变量对象和活动对象为一个对象,但在编译阶段该对象值仍为undefined
,且处于不可访问的状态。
this
Javascript 中this
指针代表的是执行当前代码的对象的所有者,可简单理解为this
永远指向最后调用它的那个对象。
根据 JavaScript 中函数的调用方式不同,this
分为以下情况:
- 在全局执行环境中(在任何函数体外部),
this
指向全局对象(在浏览器中为window
) - 在函数内部,
this
的值取决于函数被调用的方式- 函数作为对象的方法被调用,
this
指向调用这个方法的对象 - 函数用作构造函数时(使用
new
关键字),它的this
被绑定到正在构造的新对象 - 在类的构造函数中,
this
是一个常规对象,类中所有非静态的方法都会被添加到this
的原型中
- 函数作为对象的方法被调用,
- 在箭头函数中,
this
执行它被创建时的环境 - 使用
apply
、call
、bind
等方式调用:根据 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 全部代码、
setTimeout
、setInterval
、setImmediate
(Node.js)、requestAnimationFrame
(浏览器)、I/O 操作、UI 渲染(浏览器) - 微任务:包括
process.nextTick
(Node.js)、Promise
、MutationObserver
在浏览器中,异步任务队列的执行过程如下:
(1) 宏任务队列一次只从队列中取一个任务执行,执行完后就去执行微任务队列中的任务。
(2) 微任务队列中所有的任务都会被依次取出来执行,直到微任务队列为空。
(3) 在执行完所有的微任务之后,执行下一个宏任务之前,浏览器会执行 UI 渲染操作、更新界面。
我们能看到,在浏览器中每个宏任务执行完成后,会执行微任务队列中的任务。而在 Node.js 中,事件循环分为 6 个阶段,微任务会在事件循环的各个阶段之间执行。也就是说,每当一个阶段执行完毕,就会去执行微任务队列的任务。