JavaScript 底层原理
一、原型和原型链
prototype
如下所示,我们用构造函数 Person()
new 了一个实例对象 person。
1 | function Person() { |
每个 JS 对象(null 除外)在创建时会与一个叫做原型的对象关联,每个对象都会从原型继承属性。prototype 是函数独有的属性,它指向该构造函数所创建的实例对象的原型。
1 | function Person() { |
用Person.prototype
表示实例原型,则构造函数和实例原型的关系如下:

__proto__
每个 JS 对象(null 除外)都有一个叫做 __proto__
的属性,这个属性会指向该对象的原型。
1 | function Person() { |
于是关系图更新如下:

constructor
每个实例原型都有一个 constructor 属性指向关联的构造函数。
1 | function Person() { |
关系图更新如下:

综上我们已经得出:
1 | function Person() { |
原型的原型
当读取实例的属性时,如果实例没有该属性,那么就会去查找实例原型中的属性,如果还查不到,就去找实例原型的原型,一直找到最顶层为止。
1 | function Person() { |
在上例中,给实例对象 person 添加了 name 属性,当打印 person.name 时,结果自然为 Daisy。但是当删除了 person 的 name 属性时,再次读取 person.name,从 person 对象中找不到 name 属性就会从 person 的原型也就是 person.__proto__
,即Person.prototype
中查找,结果为 Kevin。但如果还没有找到,则会去查找原型的原型。
因为实例原型本身就是一个对象,既然是对象,就可以用最原始的方式创建它:
1 | var obj = new Object(); |
原型对象就是通过 Object 构造函数生成的,根据实例的 __proto__
指向构造函数的 prototype,关系图更新如下:

原型链
Object.prototype
的原型则指向 null:
1 | console.log(Object.prototype.__proto__ === null) // true |
null 表示没有对象,即该处不应有值。 Object.prototype.__proto__
的值为 null,也就说明 Object.prototype
没有原型。最终的关系图更新如下,图中的蓝线即为原型链。

补充
① 当获取 person.constructor
时,其实 person 中并没有 constructor 属性。当不能读取到 constructor 属性时,会从 person 的原型也就是 Person.prototype
中读取到该属性,所以 person.constructor === Person.prototype.constructor
。
1 | function Person() { |
② 绝大多数浏览器都支持用 __proto__
访问原型,但这个属性其实来自于 Object.prototype
。与其说它是一个属性,不如说是一个 getter/setter,当使用 obj.__proto__
时,可以理解成返回了 Object.getPrototypeOf(obj)
。
③ 前面讲到每个对象都会从原型继承属性,实际上继承是个具有迷惑性的说法。继承意味着复制操作,然而 JS 默认并不会复制对象的属性,相反 JS 只是在两个对象间创建一个关联,这样一个对象就可以通过委托访问另一个对象的属性和函数。
④ Objects created using Object.create()
空对象也有原型,但 Object.create(null)
创造的对象没有。
1 | let literalObject = {}; |
二、作用域
作用域是指一个变量和函数的作用范围,它分为词法作用域和动态作用域。JS 采用词法作用域(lexical scoping,也称静态作用域)。
- 静态作用域:函数的作用域在函数定义时就确定。
- 动态作用域:函数的作用域在函数调用时才确定。
1 | var value = 1; |
JS 采用静态作用域,所以上例 foo 函数在执行时,先从 foo 函数内部查找是否有局部变量 value,如果没有,就根据定义的位置查找上一层的代码 var value = 1;
,所以结果打印 1。
注意,上一层的代码范围是包含在 foo 函数后面才声明的变量。
1 | foo(2); // 4 |
假设 JS 采用动态作用域,则执行 foo 函数,依然是从 foo 函数内部查找是否有局部变量 value。如果没有,就从调用该函数的作用域,即 bar 函数内查找 value 变量,所以结果打印 2。
注:有时候会想通过在外部引入的自定义 js 方法里面来修改当前页面的一些属性值,然而由于 js 采用静态作用域,外部函数方法中的 this 并不能获取到当前页面的属性。所以不能这么做。
题目:两个函数的打印结果
1 | var scope = 'global scope'; |
1 | var scope = 'global scope'; |
两段代码都会打印:local scope
,因为 JS 采用词法作用域,函数的作用域基于其创建的位置。
三、执行上下文
① 执行上下文栈
JS 的可执行代码(executable code)分为三种:全局代码、函数代码、eval 代码。每当执行一个函数,就会创建一个执行上下文(execution contexts)。每个执行上下文包含三个重要属性:变量对象(Variable object,VO)、作用域链(Scope chain)、this。为了方便管理这些执行上下文,JS 引擎创建了执行上下文栈(execution context stack,ECStask)。
在此假设执行上下文栈是一个数组,以便模拟其行为。
1 | ECStack = []; |
JS 解释执行代码时最先遇到全局代码,所以初始化时先向 ECStack 压入全局执行上下文 globalContext
。只有当整个程序结束时 ECStack
才会被清空,即 ECStack
栈底只能放 globalContext
。当执行一个函数时,就会创建一个 EC,并将其压入 ECStack。当函数执行完毕时,就将其从栈中弹出。
1 | ECStack = [ |
当 JS 遇到如下代码:
1 | function fun3() { |
其执行上下文栈经历的过程如下:
1 | // 执行 fun1(),将其 EC 入栈 |
题目:两个函数在处理执行上下文的区别
1 | var scope = 'global scope'; |
1 | var scope = 'global scope'; |
② 变量对象
变量对象是与执行上下文相关的数据作用域,存储了在上下文中定义的变量和函数声明。全局上下文和函数上下文的变量对象稍有不同,这里分开讨论。
全局上下文中的全局对象
由 var
声明的全局变量会作为全局对象的成员被脚本创建。全局对象就是全局上下文中的变量对象,即作用域链的头。在 web 浏览器中,window
对象就是浏览器的全局对象,任何全局变量或全局函数都能作为 window
的属性来访问。
Ⅰ. 全局对象可以通过 this 引用。
1 | console.log(this); |
Ⅱ. 全局对象是 Object 构造函数的一个实例。
1 | console.log(this instanceof Object); // true |
Ⅲ. 全局对象预定义了许多函数和属性。
1 | console.log(Math.random()); |
Ⅳ. 全局对象作为全局变量的宿主。
1 | var a = 1; |
Ⅴ. 浏览器的全局对象有 window 属性指向自身。
1 | var a = 1; |
函数上下文中的活动对象
在函数上下文中,用活动对象(activation object, AO)来表示变量对象。两者本质相同,区别在于变量对象是规范上或引擎实现上的,不能在 JS 环境中访问。当进入一个 EC 时,EC 的变量对象会被激活为 AO,这时其上的各种属性(如形参、变量声明)才能被访问。
所有的 JS 代码片段在执行之前都会被编译,只是这个编译的过程非常短暂(可能只有几微秒不到),紧接着这段代码就会被执行。
函数上下文的变量对象在初始化时只包含 Arguments 对象。在进入 EC 进行编译时,js 引擎会搜集形参以及变量和函数的声明,并提前将其赋给变量对象。在代码执行阶段,会按照执行顺序依次修改变量对象的属性值。
Ⅰ. 进入 EC
当刚进入 EC,还未执行代码时,变量对象的属性包括:
① 函数形参(如果是函数上下文):由名称和对应值组成。如果没有实参,对应值设为 undefined
② 函数声明:由名称和对应值(函数对象,function-object)组成。如果变量对象已存在同名的属性,则将其完全替换
③ var 变量声明:由名称和对应值(undefined)组成。变量声明不会干扰到已声明的同名形参或函数
1 | function foo(a) { |
在进入执行上下文后,上例的 AO 是:
1 | AO = { |
Ⅱ. 代码执行
在代码执行阶段会顺序执行代码,根据代码修改变量对象的值。当代码执行完后,上例的 AO 变为:
1 | AO = { |
块作用域与暂时性死区
与 var 不同,let 和 const 不存在变量提升。这是因为早期的声明提升机制可能会带来误操作:那些忘记被声明的变量无法在开发阶段被明显地察觉出来,而是以 undefined 这样危险的形式藏匿在你的代码里。为了减少运行时错误,防止带来不可预知的问题,ES6 特意将声明前不可用这一点做了强约束。
块作用域是伴随 ES6 而生的一个概念。我们把被一对花括号括起来的代码称为一个代码块,被这个代码块圈起来的变量集,就是块作用域。
当用 let 和 const 声明变量时,变量会被绑定到块作用域上,而 var 是不感知块作用域的。
1 | { |
如果区块中存在 let 和 const 命令,这个区块对这些命令声明的变量,从一开始就形成了封闭作用域。当在声明前去使用这类变量时,就会报错,哪怕作用域链中有父级 EC 存在同名的变量。这个区块中位于变量声明前的区域就叫暂时性死区。
1 | var name = 'Terry'; |
let、const 和 var 的区别
- var 定义的变量,不存在块作用域,可以跨块访问,但不能跨函数访问;let 定义的变量,只能在块作用域里访问,不能跨块访问,也不能跨函数访问;const 用来定义常量,使用时必须初始化,只能在块作用域里访问,不能修改
- var 可以先使用后声明,因为存在变量提升;let 必须先声明后使用,并且会出现暂时性死区
- var 允许在相同作用域内重复声明同一个变量
- 在全局上下文中,基于 let 声明的全局变量和全局对象 GO(window)没有任何关系。var 声明的变量会和 GO 有映射关系
- let 和 const 会把当前所在的大括号 (除函数之外) 作为一个全新的块级上下文。当遇到循环事件绑定等类似需求,无需再构建闭包来存储,只要基于 let 的块作用特征即可解决
练习题
题目一
1 | function foo() { |
foo()
:报错是因为函数中的 a
没有通过 var 关键字声明,也就没有产生变量提升,因而没有被存放在 AO 中。当执行到 console,此时 AO 的值如下。没有 a 的值,然后就到全局去找,全局也没有,所以报错。
1 | AO = { |
fin()
:函数中的 a 通过变量提升,相当于
1 | function fin() { |
bar()
:当执行到 console 时,bar 函数的活动对象已经添加了 a 属性,所以打印 1。
题目二
1 | console.log(foo); |
在进入 EC 时,首先处理函数声明,其次处理变量声明。如果变量跟函数重名,则变量声明不会干扰函数声明,相当于:
1 | function foo() { |
题目三
1 | function foo(){ |
先函数提升,再变量提升,相当于
1 | function foo() { |
题目四
1 | var name = 'Rose'; |
③ 作用域链
当查找变量时,会先从当前 EC 的变量对象中查找,如果没有找到,就会从父级 EC 的变量对象中查找,一直找到全局上下文的变量对象(即全局对象)。这样由多个 EC 的变量对象构成的链表就叫作用域链。
下面以函数的创建和激活两个阶段来说明作用域链是如何创建和变化的。
函数创建阶段
JS 采用静态作用域,函数的作用域在函数定义时便已确定。这是因为当函数被创建时,函数有一个内部属性 [[scope]]
会保存所有父级 EC 的变量对象。注意,它不是完整的作用域链。
1 | function foo() { |
上述函数创建时,各自的 [[scope]]
为:
1 | foo.[[scope]] = [ |
函数激活阶段
当进入函数上下文后,会将新创建的 AO 添加到作用域链的头部。即该 EC 最终完整的作用域链为:
1 | Scope = [AO].concat([[Scope]]); |
题目:分析 checkscope()
的执行过程
1 | var scope = 'global scope'; |
Ⅰ. checkscope
函数被创建,在其 [[scope]]
属性中添加父级 EC 的变量对象;
1 | checkscope.[[scope]] = [ |
Ⅱ. 创建 checkscope
函数的 EC,并将其压入 ECStack;
1 | ECStack = [ |
Ⅲ. 执行函数前,复制函数的 [[scope]]
属性到 EC 中,创建作用域链;
1 | checkscopeContext = { |
活动对象由 arguments 初始化,随后加入形参、函数声明、变量声明;
1 | checkscopeContext = { |
最后将该 EC 的 AO 压入 checkscope
作用域链头部;
1 | checkscopeContext = { |
Ⅳ. 开始执行函数,修改 AO 的属性值;
1 | checkscopeContext = { |
Ⅴ. 返回 scope2 的值后函数执行完毕,将该 EC 从 ECStack 中弹出。
1 | ECStack = [ |
④ this 指向
1 | var value = 1; |
简述:foo.bar()
的复杂表达式为 foo.bar,不涉及计算,所以属于 reference。其 basevalue 为 foo,属于对象,则 this 指向该对象。
Ⅰ. Reference
ECMAScript 规范定义的类型分为语言类型和规范类型。
- 语言类型是开发者能使用 ECMAScript 语言直接操作其值的,分为 Undefined、Null、Boolean、String、Number 和 Object。
- 规范类型可以使用算法来描述 ECMAScript 语言结构和语言类型。规范类型包括:Reference、List、Completion、Property Descriptor、Property Identifier、Lexical Environment 和 Environment Record。
由上可知,ECMAScript 规范中定义了一种不存在实际 js 代码中的类型,它的作用是为了更好地描述语言底层行为逻辑。而其中的 Reference 类型便与 this 的指向密切相关,它用来解释诸如 delete、typeof
和赋值等操作行为。
Reference 由如下三个部分构成:
- base value:属性所在的对象,该值的类型只能是 Undefined、Object、Boolean、String、Number 或
EnvironmentRecord
。 - reference name:属性名。
- strict reference
1 | // 例一 |
1 | // 例二 |
GetValue()
该方法返回 Reference 对象真正的值,而非 Reference 自身。当函数调用表达式中包含位运算符(如&)、逻辑运算符(如&&)、条件运算符(a?b:c)、赋值运算符(如=)、等值运算符(如==)、比较运算符(如<)、移位运算符(如<<)、逗号运算符、加减乘除运算符,也就是涉及计算时,都会使用到该方法。
1 | var foo = 1; |
创建 Reference 有两种途径:标识符解析和属性访问。比如 foo 和 foo.bar 能创建 Reference,而字面量和函数表达式以及包含上述运算符的表达式却不会。具体可参考下图:

Ⅱ. 复杂表达式
原始表达式(PrimaryExpression)是表达式的最小单位,它不再包含其他表达式。原始表达式分为字面量、关键字和变量,具体包括 this 关键字、标识符引用、字面量引用、数组初始化、对象初始化和分组表达式。
而复杂表达式(MemberExpression)由原始表达式和操作符组成,包括属性访问表达式、对象创建表达式和函数表达式。
FunctionExpression
函数定义MemberExpression[Expression]
属性访问MemberExpression.IdentifierName
属性访问new MemberExpression(Arguments)
对象创建
1 | function foo() { |
可以认为 MemberExpression 是函数调用时()
左边的部分。
Ⅲ. 判定方法
ECMAScript 规范说明了当函数被调用时,如何确定 this 的取值。将 ref 作为 MemberExpression 的计算结果,则有:
如果 ref 是 Reference 类型,并且 base value 值是一个对象,那么 this 值为 base value;
如果 ref 是 Reference 类型,并且 base value 值是
EnvironmentRecord
,那么 this 值为 undefined;如果 ref 不是 Reference 类型,那么 this 值为 undefined。
练习题
1 | var value = 1; |
例 1:foo.bar()
ME 计算结果为 foo.bar,属于 Reference 类型,值为:
1 | var BarReference = { |
base value 为 foo,是一个对象,所以 this 指向 foo { value: 2, bar: [Function: bar] }
。打印 2。
例 2:(foo.bar)()
()
并没有对 ME 进行计算,所以结果跟例 1 一样。
例 3:(foo.bar = foo.bar)()
存在赋值运算符,即运算过程中会使用GetValue()
,所以返回值不是 Reference 类型,则 this 指向 undefined,undefined 自然不存在 value 属性,则打印 undefined。非严格模式下,当 this 值为 undefined 时,其值会被隐式转换为全局对象,打印 1。
例 4:(false || foo.bar)()
存在逻辑运算符,即运算过程中会使用GetValue()
,所以返回值不是 Reference 类型,则 this 指向 undefined,打印 undefined。非严格模式下打印 1。
例 5:(foo.bar, foo.bar)()
存在逗号运算符,即运算过程中会使用GetValue()
,所以返回值不是 Reference 类型,则 this 指向 undefined,打印 undefined。非严格模式下打印 1。
例 6:fin()
ME 计算结果为 fin,属于 Reference 类型,值为:
1 | var fooReference = { |
base value 为 EnvironmentRecord
,所以 this 指向 undefined,打印 undefined。非严格模式打印 1。
例 7:
1 | var name = 'a'; |
例 8:
1 | function create() { |
例 9:setTimeout()
1 | var num = 10 |
例 10:箭头函数
1 | var a = 'aaa' |
例 11:
1 | 在 JavaScript 中,下面选项关于 this 描述正确的是(A) |
在浏览器的 JavaScript 中,在全局范围内,this 指向全局对象 (通常是 window 对象)。在非严格模式下,如果没有明确指定,this 会被默认绑定到全局对象上。在严格模式下,this 将是 undefined。
四、闭包
MDN 将闭包定义为能够访问自由变量的函数。而自由变量是指在函数中使用的,但不是函数参数或函数局部变量的变量。即闭包 = 函数 + 函数能够访问的自由变量。
1 | var a = 1; |
上例中,foo 函数可以访问变量 a,但 a 既非 foo 函数的局部变量,也不是函数参数,所以 a 是自由变量,foo 函数和其访问的 a 便构成了闭包。理论上所有的 JS 函数都是闭包,因为它们都在创建时保存了父级 EC 的变量对象。而从实际开发角度看,以下函数才算闭包:
- 即使创建闭包的上下文已经销毁,它仍然存在(比如内部函数从父函数中返回)
- 在代码中引用了自由变量
1 | var scope = 'global scope'; |
上例中 EC 的简要变化过程如下:
- 进入全局代码,创建全局执行上下文,并将其压入 ECStack;
- 全局执行上下文初始化;
- 执行
checkscope()
,创建该函数的 EC,并将其压入 ECStack; - checkscope 的 EC 初始化,创建变量对象、作用域链、this 等;
- checkscope 函数执行完毕,将其 EC 从 ECStack 中弹出;
- 执行
f()
,创建 f 函数的 EC,并将其压入 ECStack; - f 的 EC 初始化,创建变量对象、作用域链、this 等;
- f 函数执行完毕,将其 EC 从 ECStack 中弹出。
当执行 f 函数时,checkscope 的 EC 已被销毁(从 ECStack 中弹出),但仍能读取到 checkscope 作用域下的 scope 属性。这是因为 f 函数的 EC 保存了作用域链:
1 | fContext = { |
即使 checkscopeContext 被销毁,但 JS 仍会把 f 函数引用的 checkscopeContext.AO 保留在内存中,f 函数也就能通过其作用域链找到 checkscopeContext.AO 的值。闭包正因此机制才得以实现。
应用
闭包最⼤的作⽤就是隐藏变量,闭包的⼀⼤特性就是内部函数总是可以访问其所在的外部函数中声明的参数和变量,即使在其外部函数被销毁之后。基于此特性,JavaScript 可以实现私有变量、特权变量、储存变量等。就以实现私有变量举例:浅谈 class 私有变量
1 | function Person(){ |
函数体内的 name 只有 getName 和 setName 两个函数可以访问,外部⽆法访问,相对于将变量私有化。
题目 1
1 | var data = []; |
当执行 data[0] 函数前,全局上下文的 VO 为:
1 | globalContext = { |
当执行 data[0] 函数时,data[0] 函数的作用域链为:
1 | data[0]Context = { |
data[0]Context 的 AO 没有 i 值,所以从 globalContext.VO 中查找到 i,打印 3。data[1] 和 data[2] 同理。
1 | // 闭包版本 |
当执行 data[0] 函数前,全局上下文的 VO 为:
1 | globalContext = { |
当执行 data[0] 函数时,data[0] 函数的作用域链发生改变:
1 | data[0]Context = { |
匿名函数的 EC 的 AO 添加了函数参数:
1 | 匿名函数Context = { |
data[0]Context 的 AO 没有 i 值,于是沿着作用域链从匿名函数 Context.AO 中查找到 i,打印 0。data[1] 和 data[2] 同理,分别打印 1 和 2。
题目 2
1 | (function() { |
五、参数按值传递
ECMAScript 中所有函数的参数都是按值传递:函数的形参是被调用时所传实参的副本,修改形参的值并不会影响实参。下例中,当传递 value 到函数 foo 中,相当于拷贝了一份 value,假设拷贝的这份叫 _value,则函数中修改的都是 _value 的值,不会影响到 value 值。
1 | var value = 1; |
但当值是一个复杂的数据结构时,拷贝会产生性能问题。所以还有一种传递方式叫按引用传递:传递对象的引用,函数内部对参数的任何改变都会影响该对象的值,因为两者引用的是同一个对象。
1 | var obj = { |
前面说 ECMAScript 中所有函数的参数都是按值传递,而上例看起来却像是按引用传递。其实这是按共享传递:当传递的是对象时,传递对象的引用的副本。
1 | var obj = { |
所以修改 o.value 时,可以通过引用找到原值。但直接修改 o,不会影响到原值。**参数如果是基本类型则按值传递,如果是引用类型则按共享传递’**但因为拷贝副本本身也是一种值拷贝,所以也可认为是按值传递。
六、类数组对象
类数组对象包含 length 属性和若干索引属性。在客户端 JS 中,一些 DOM 方法(如document.getElementsByTagName()
)返回的就是类数组对象。
1 | // 数组对象 |
从读写、获取长度、遍历三方面来看,数组与类数组并无二致。
1 | // 读写 |
但类数组对象不能使用数组的方法。
1 | arrayLike.push('4'); //error: arrayLike.push is not a function |
可以使用 Array.prototype.func.call()
让类数组间接调用数组方法:
1 | var arrayLike = {0: 'name', 1: 'age', 2: 'sex', length: 3 } |
数组的一些方法还能让类数组转成数组。
1 | var arrayLike = {0: 'name', 1: 'age', 2: 'sex', length: 3 } |
其实 Arguments 对象也是一种类数组对象。它只定义在函数体中,包括了函数的参数和其他属性。在函数体中,arguments
指代该函数的 Arguments 对象。
1 | function foo(name, age, sex) { |
上例打印结果如下:

length 属性
Arguments 对象的 length 属性,表示实参的长度。
1 | function foo(b, c, d){ |
callee 属性
Arguments 对象的 callee 属性,通过它可以调用函数自身。讲个闭包经典面试题使用 callee 的解决方法:
1 | var data = []; |
arguments 和对应参数的绑定
1 | function foo(name, age, sex, hobbit) { |
在非严格模式下,传入的实参和 arguments 的值会共享,当没有传入时,实参与 arguments 值不会共享。在严格模式下,实参和 arguments 不会共享。
传递参数
将参数从一个函数传递到另一个函数
1 | // 使用 apply 将 foo 的参数传递给 bar |
使用 ES6 的 … 运算符,可以轻松将其转成数组。
1 | function func(...arguments) { |