# 编译原理
尽管通常将 JavaScript 归类为“动态”或“解释执行”语言,但事实上它是一门编译语言。这个事实对你来说可能显而易见,也可能你闻所未闻,取决于你接触过多少编程语言,具有多少经验。但与传统的编译语言不同,它不是提前编译的,编译结果也不能在分布式系统中进行移植。
尽管如此,JavaScript 引擎进行编译的步骤和传统的编译语言非常相似,在某些环节可能比预想的要复杂。
在传统编译语言的流程中,程序中的一段源代码在执行之前会经历三个步骤,统称为“编译”。
分词/词法分析(Tokenizing/Lexing)
这个过程会将由字符组成的字符串分解成(对编程语言来说)有意义的代码块,这些代码块被称为词法单元(token)。例如,考虑程序 var a = 2;。这段程序通常会被分解成为下面这些词法单元:var、a、=、2 、;。空格是否会被当作词法单元,取决于空格在这门语言中是否具有意义。
分词(tokenizing)和词法分析(Lexing)之间的区别是非常微妙、晦涩的,主要差异在于词法单元的识别是通过有状态还是无状态的方式进行的。简单来说,如果词法单元生成器在判断 a 是一个独立的词法单元还是其他词法单元的一部分时,调用的是有状态的解析规则,那么这个过程就被称为词法分析。
解析/语法分析(Parsing)
这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树。这个树被称为“抽象语法树”(Abstract Syntax Tree,AST)。
var a = 2; 的抽象语法树中可能会有一个叫作 VariableDeclaration 的顶级节点,接下来是一个叫作 Identifier(它的值是 a)的子节点,以及一个叫作 AssignmentExpression的子节点。AssignmentExpression 节点有一个叫作 NumericLiteral(它的值是 2)的子节点。
代码生成将 AST 转换为可执行代码的过程称被称为代码生成。这个过程与语言、目标平台等息息相关。抛开具体细节,简单来说就是有某种方法可以将 var a = 2; 的 AST 转化为一组机器指令,用来创建一个叫作 a 的变量(包括分配内存等),并将一个值储存在 a 中。
比起那些编译过程只有三个步骤的语言的编译器,JavaScript 引擎要复杂得多。例如,在语法分析和代码生成阶段有特定的步骤来对运行性能进行优化,包括对冗余元素进行优化等。
比起那些编译过程只有三个步骤的语言的编译器,JavaScript 引擎要复杂得多。例如,在语法分析和代码生成阶段有特定的步骤来对运行性能进行优化,包括对冗余元素进行优化等。
因此在这里只进行宏观、简单的介绍,接下来你就会发现我们介绍的这些看起来有点高深的内容与所要讨论的事情有什么关联。
首先,JavaScript 引擎不会有大量的(像其他语言编译器那么多的)时间用来进行优化,因为与其他语言不同,JavaScript 的编译过程不是发生在构建之前的。
对于 JavaScript 来说,大部分情况下编译发生在代码执行前的几微秒(甚至更短!)的时间内。在我们所要讨论的作用域背后,JavaScript 引擎用尽了各种办法(比如 JIT,可以延迟编译甚至实施重编译)来保证性能最佳。
# 作用域
编程语言最基本的功能之一就是能储存变量的值,并能对这个值访问或修改。 正是这种储存和访问变量的值的能力将状态带给了程序。
若没有了状态这个概念,程序虽然也能够执行一些简单的任务,但会受到高度限制。将变量引入程序会引起几个问题:变量储存在哪?程序如何找到它们?
需要一套设计良好的规则来存储变量,方便找到这些变量。这套规则被称为作用域。在JavaScript中,作用域(Scope)指的是变量和函数可访问的区域或范围。它决定了代码块中声明的变量和函数在哪些地方是可见的,以及在哪些地方是不可见的。
作用域共有两种主要的工作模型:在各类编程语言中,作用域分为静态作用域和动态作用域。JavaScript 采用的是词法作用域(Lexical Scoping),也就是静态作用域。词法作用域中的变量,在编译过程中会产生一个确定的作用域。
- 词法作用域
- 动态作用域(如Bash脚本,函数的作用域是在函数调用的时候才决定)
大部分标准语言编译器的第一个工作阶段叫作词法化(也叫单词化)。词法化的过程会对源代码中的字符进行检查,如果是有状态的解析过程,还会赋予单词语义。编译过程
简单地说,词法作用域就是定义在词法阶段的作用域。换句话说,词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变(大部分情况下是这样的)。
存在一些欺骗词法作用域的方法,这些方法在词法分析器处理过后依然可以修改作用域,但是这种机制可能有点难以理解。事实上,让词法作用域根据词法关系保持书写时的自然关系不变,是一个非常好的最佳实践。
词法作用域中的变量,在编译过程中会产生一个确定的作用域,这个作用域即当前的执行上下文,在 ES5 后使用词法环境(Lexical Environment)替代作用域来描述该执行上下文。因此,词法环境可理解为常说的作用域,同样也指当前的执行上下文(注意,是当前的执行上下文)。
在 JavaScript 中,词法环境又分为词法环境(Lexical Environment)和变量环境(Variable Environment)两种,其中:
- 变量环境用来记录
var/function等变量声明; - 词法环境是用来记录
let/const/class等变量声明+变量环境声明(词法环境则是一个更广泛的概念,它包含了环境记录(Environment Record)和一个对外部词法环境的引用。)
动态作用域似乎暗示有很好的理由让作用域作为一个在运行时就被动态确定的形式,而不是在写代码时进行静态确定的形式,事实上也是这样的。我们通过示例代码来说明:
function foo() {
console.log( a ); // 2
}
function bar() {
var a = 3;
foo();
}
var a = 2;
bar();
词法作用域让 foo() 中的 a 通过 RHS 引用到了全局作用域中的 a,因此会输出 2。
而动态作用域并不关心函数和作用域是如何声明以及在何处声明的,只关心它们从何处调用。换句话说,作用域链是基于调用栈的,而不是代码中的作用域嵌套。
因此,如果 JavaScript 具有动态作用域,理论上,下面代码中的 foo() 在执行时将会输出 3。
function foo() {
console.log( a ); // 3(不是 2 !)
}
function bar() {
var a = 3;
foo();
}
var a = 2;
bar();
为什么会这样?因为当 foo() 无法找到 a 的变量引用时,会顺着调用栈在调用 foo() 的地方查找 a,而不是在嵌套的词法作用域链中向上查找。由于 foo() 是在 bar() 中调用的,引擎会检查 bar() 的作用域,并在其中找到值为 3 的变量 a。
需要明确的是,事实上 JavaScript 并不具有动态作用域。它只有词法作用域,简单明了。但是 this 机制某种程度上很像动态作用域。
主要区别:词法作用域是在写代码或者说定义时确定的,而动态作用域是在运行时确定的。(this 也是!)词法作用域关注函数在何处声明,而动态作用域关注函数从何处调用。最后,this 关注函数如何调用,这就表明了 this 机制和动态作用域之间的关系多么紧密
词法环境更新
在 JavaScript 中,确实存在两种类型的词法环境:词法环境(Lexical Environment)和变量环境(Variable Environment)。这两种环境在处理变量和函数声明时各有其职责。
词法环境(Lexical Environment)是 ECMAScript 2015(ES6)中引入的概念,用于定义变量和函数的声明性存储。词法环境主要由两部分组成:
- 环境记录(
Environment Record):它是一个存储变量和函数声明的实际位置的对象。当我们在代码中声明一个变量或函数时,它们就被添加到环境记录中。 - 外部词法环境引用(
Outer Lexical Environment Reference):它指向包围当前词法环境的外部词法环境。这个引用形成了一个词法环境的层级结构,也称作作用域链`。当在当前词法环境中找不到某个变量或函数声明时,JavaScript 会沿着作用域链查找外部词法环境。通过外部词法环境的引用,作用域可以层层拓展,建立起从里到外延伸的一条作用域链。当某个变量无法在自身词法环境记录中找到时,可以根据外部词法环境引用向外层进行寻找,直到最外层的词法环境中外部词法环境引用为null,这便是作用域链的变量查询。
变量环境(Variable Environment)是另一种类型的词法环境,主要用于记录通过 var 关键字声明的变量。在 ES6 之前,JavaScript 只有函数作用域和全局作用域,因此变量环境主要在这两种环境中使用。
- 在
全局执行上下文中,变量环境就是全局环境本身,它包含了所有全局变量和函数。 - 在
函数执行上下文中,变量环境是函数被声明时所在的环境,它记录了函数内部通过 var 声明的变量。 需要注意的是,在 ES6 中引入 let 和 const 关键字后,变量环境的概念变得不那么重要,因为 let 和 const 声明的变量具有块级作用域,而不是函数作用域。因此,现代 JavaScript 代码通常使用词法环境来处理所有类型的变量和函数声明。
总结一下,词法环境是包含环境记录和外部词法环境引用的抽象结构,用于定义变量和函数的声明性存储。而变量环境是词法环境的一种特殊类型,主要用于记录通过 var 关键字声明的变量。在现代 JavaScript 中,词法环境通常用于处理所有类型的变量和函数声明,而变量环境则较少使用。
通过使用两个词法环境(而不是一个)分别记录不同的变量声明内容,JavaScript 实现了支持块级作用域的同时,不影响原有的变量声明和函数声明。
外部词法环境引用又是怎样指向外层?
为了方便描述,先将 JavaScript 代码运行过程分为定义期和执行期,前面提到的编译阶段则属于定义期。
来看一个例子,我们定义了全局函数foo,并在该函数中定义了函数bar:
function foo() {
console.dir(bar);
var a = 1;
function bar() {
a = 2;
}
}
console.dir(foo);
foo();
前面我们说到,JavaScript 使用的是静态作用域,因此函数的作用域在定义期已经决定了。在上面的例子中,全局函数foo创建了一个foo的[[scope]]属性,包含了全局[[scope]]:
foo[[scope]] = [globalContext];
而当我们执行foo()时,也会分别进入foo函数的定义期和执行期。
在foo函数的定义期时,函数bar的[[scope]]将会包含全局[[scope]]和foo的[[scope]]:
bar[[scope]] = [fooContext, globalContext];
可以看到:
- foo的[[scope]]属性包含了全局[[scope]]
- bar的[[scope]]将会包含全局[[scope]]和foo的[[scope]]
也就是说,JavaScript 会通过外部词法环境引用来创建变量对象的一个作用域链,从而保证对执行环境有权访问的变量和函数的有序访问。除了创建作用域链之外,在这个过程中还会对创建的变量对象做一些处理。
编译阶段会进行变量对象(VO)的创建,该过程会进行函数声明和变量声明,这时候变量的值被初始化为 undefined。在代码进入执行阶段之后,JavaScript 会对变量进行赋值,此时变量对象会转为活动对象(Active Object,简称 AO),转换后的活动对象才可被访问,这就是 VO -> AO 的过程。
+ 编译器
+ 引擎
+ 作用域
- LHS
- RHS
- 遮蔽效应
- with
- eval
- 变量提升
-优先级
# 程序编译
程序中的一段源代码在执行之前会经历三个步骤,统称为“编译”。
- 分词/词法分析
- 解析/语法分析(这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树。这个树被称为 “抽象语法树” (Abstract Syntax Tree,AST)。)
- 代码生成(将 AST 转换为可执行代码的过程称被称为代码生成)
JS 引擎不会有大量时间进行优化,因为JS 的编译过程不是在构建之前的。JS大部分情况下编译发生在代码执行前的几微秒。
JS 代码片段在执行前都要进行编译(通常就在执行前)。JS 编译器首先会对 var a = 2; 这段程序进行编译,然后做好执行它的准备,通常马上就执行它。
- 引擎 从头到尾负责整个 JS 程序的编译及执行过程。
- 编译器 负责语法分析及代码生成等脏活累活。
- 作用域 负责收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限。
# LHS RHS
编译器在编译过程中生成了代码,引擎执行它时,会通过查找变量 a 来判断它是否已声明过。
当变量出现在赋值操作的左侧时进行 LHS 查询,出现在右侧时进行 RHS 查询(非左查询,因为不一定会有赋值操作)
# 作用域嵌套
当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。因此,在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量,或抵达最外层作用域(全局)为止。
① 包含着整个全局作用域,其中只有一个标识符:foo。
② 包含着 foo 所创建的作用域,其中有三个标识符:a、bar 和 b。
③ 包含着 bar 所创建的作用域,其中只有一个标识符:c。
# 异常
为什么区分 LHS 和 RHS 是一件重要的事情?
因为在变量还没有声明(在任何作用域中都无法找到该变量)的情况下,这两种查询的行为是不一样的。
function foo(a) {
console.log( a + b );
b = a;
}
foo( 2 );
第一次对 b 进行 RHS 查询时是无法找到该变量的。这是一个“未声明”的变量,因为在任何相关的作用域中都无法找到它。如果 RHS 查询在所有嵌套的作用域中遍寻不到所需的变量,引擎就会抛出 ReferenceError 异常。
当引擎执行 LHS 查询时,如果在顶层(全局作用域)中也无法找到目标变量,全局作用域中就会创建一个具有该名称的变量,并将其返还给引擎,前提是程序运行在 非“严格模式” 下。
ES5 中引入了“严格模式”。同正常模式相比,严格模式在行为上有很多不同。其一 禁止自动或隐式地创建全局变量 。因此,在严格模式中 LHS 查询失败时,并不会创建并返回一个全局变量,引擎会抛出同 RHS 查询失败时类似的 ReferenceError 异常。
如果 RHS 查询找到了一个变量,但是你尝试对这个变量的值进行不合理的操作,比如试图对一个非函数类型的值进行函数调用,或着引用 null 或 undefined 类型的值中的属性,那么引擎会抛出另外一种类型的异常,叫作 TypeError 。 ReferenceError 同作用域判别失败相关,而 TypeError 则代表作用域判别成功了,但是对结果的操作是非法或不合理的。
a=9;
//a值为9
"use strict"
b=9;
//Uncaught ReferenceError: b is not defined at <anonymous>:1:1
//Uncaught TypeError: b.push is not a function
var b=40;
b.push(1)
# 遮蔽效应
作用域查找会在找到第一个匹配的标识符时停止。在多层的嵌套作用域中可以定义同名的标识符,这叫作“遮蔽效应”(内部的标识符“遮蔽”了外部的标识符)。
全局变量会自动成为全局对象(比如浏览器中的 window 对象)的属性,因此可以不直接通过全局对象的词法名称,而是间接地通过对全局对象属性的引用来对其进行访问。
window.a
通过这种技术可以访问那些被同名变量所遮蔽的全局变量。但 非全局的变量如果被遮蔽了 ,无论如何都无法被访问到。
# 欺骗词法 eval with
# with
with 通常被当作重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象本身。
var obj = {
a: 1,
b: 2,
c: 3
};
// 单调乏味的重复 "obj"
obj.a = 2;
obj.b = 3;
obj.c = 4;
// 简单的快捷方式
with (obj) {
a = 3;
b = 4;
c = 5;
}
console.log(obj)//{a: 3, b: 4, c: 5}
function foo(obj) {
with (obj) {
a = 2;
}
}
var o1 = {
a: 3
};
var o2 = {
b: 3
};
foo( o1 );
console.log( o1.a ); // 2
try{
console.log( a );
}catch{
console.log('a不存在')
}//a不存在
foo( o2 );
console.log( o2.a ); // undefined
console.log( a ); // 2——不好,a 被泄漏到全局作用域上了!
创建了 o1 和 o2 两个对象。 foo(..) 函数接受一个 obj 参数,该参数是一个对象引用,并对这个对象引用执行了 with(obj) {..} 。在 with 块内部,我们写的代码看起来只是对变量 a 进行简单的词法引用,实际上就是一个LHS 引用,并将 2 赋值给它。
当我们将 o1 传递进去, a=2 赋值操作找到了 o1.a 并将 2 赋值给它,这在后面的 console.log(o1.a) 中可以体现。而当 o2 传递进去, o2 并没有 a 属性,因此不会创建这个属性,o2.a 保持 undefined 。
注意到一个奇怪的副作用 实际上 a = 2 赋值操作创建了一个全局的变量 a 。
尽管 with 块可以将一个对象处理为词法作用域,但是这个块内部正常的 var声明并不会被限制在这个块的作用域中,而是被添加到 with 所处的函数作用域中。
function foo(obj) {
with (obj) {
a = 2;
var c=10
}
console.log(c)//10
}
var o1 = {
a: 3
};
foo( o1 );
eval(..) 函数如果接受了含有一个或多个声明的代码,就会修改其所处的词法作用域,而with 声明实际上是根据传递给它的对象凭空创建了一个全新的词法作用域。
当传递 o1 给 with 时, with 所声明的作用域是 o1 ,而这个作用域中含有一个同 o1.a 属性相符的标识符。但当将 o2 作为作用域时,其中并没有 a 标识符,因此进行了正常的 LHS 标识符查找。o2 的作用域、 foo(..) 的作用域和全局作用域中都没有找到标识符 a ,因此当 a=2 执行时,自动创建了一个全局变量(因为是非严格模式)。
# 性能变差
引擎会在编译阶段进行数项的性能优化。其中有些优化依赖于能够根据代码的词法进行静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到标识符。
但如果引擎在代码中发现了 eval(..) 或 with ,它只能简单地假设关于标识符位置的判断都是无效的,因为无法在词法分析阶段明确知道 eval(..) 会接收到什么代码,这些代码会如何对作用域进行修改,也无法知道传递给 with 用来创建新词法作用域的对象的内容到底是什么。
最悲观的情况是如果出现了 eval(..) 或 with ,所有的优化可能都是无意义的。
# 函数作用域
把作用域比喻成气泡。冒泡的模式。
function foo(a) {
var b = 2;
// 一些代码
function bar() {
// ...
}
// 更多的代码
var c = 3;
}
在这个代码片段中,foo(..) 的作用域气泡中包含了标识符 a、b、c 和 bar。无论标识符声明出现在作用域中的何处,这个标识符所代表的变量或函数都将附属于所处作用域的气泡。
bar(..) 拥有自己的作用域气泡。全局作用域也有自己的作用域气泡,它只包含了一个标识符:foo。
由于标识符 a、b、c 和 bar 都附属于 foo(..) 的作用域气泡,因此无法从 foo(..) 的外部对它们进行访问。也就是说,这些标识符全都无法从全局作用域中进行访问,因此下面的代码会导致 ReferenceError 错误:
bar(); // 失败
console.log( a, b, c ); // 三个全都失败
但是,这些标识符(a、b、c、foo 和 bar)在 foo(..) 的内部都是可以被访问的,同样在bar(..) 内部也可以被访问(假设 bar(..) 内部没有同名的标识符声明)。
函数作用域的含义是指,**属于这个函数的全部变量都可以在整个函数的范围内使用及复用(事实上在嵌套的作用域中也可以使用)。**这种设计方案是非常有用的,能充分利用JavaScript 变量可以根据需要改变值类型的“动态”特性。
# 隐藏内部实现
对函数的传统认知就是先声明一个函数,然后再向里面添加代码。但反过来想也可以带来一些启示:从所写的代码中挑选出一个任意的片段,然后用函数声明对它进行包装,实际上就是把这些代码“隐藏”起来了。
实际的结果就是在这个代码片段的周围创建了一个作用域气泡,也就是说这段代码中的任何声明(变量或函数)都将绑定在这个新创建的包装函数的作用域中,而不是先前所在的作用域中。换句话说,可以把变量和函数包裹在一个函数的作用域中,然后用这个作用域来“隐藏”它们。
有很多原因促成了这种基于作用域的隐藏方法。它们大都是从最小特权原则中引申出来的,也叫最小授权或最小暴露原则。这个原则是指在软件设计中,应该最小限度地暴露必要内容,而将其他内容都“隐藏”起来,比如某个模块或对象的 API 设计。
这里,b 和 doSomethingElse(..) 都无法从外部被访问,而只能被 doSomething(..) 所控制。功能性和最终效果都没有受影响,但是设计上将具体内容私有化了,设计良好的软件都会依此进行实现。
不仅如此,还可以在一定程度上规避命名冲突的行为。
# 延伸规避命名冲突
- 全局的命名空间
变量冲突的一个典型例子存在于全局作用域中。当程序中加载了多个第三方库时,如果它们没有妥善地将内部私有的函数或变量隐藏起来,就会很容易引发冲突。
这些库通常会在全局作用域中声明一个名字足够独特的变量,通常是一个对象。这个对象被用作库的命名空间,所有需要暴露给外界的功能都会成为这个对象(命名空间)的属性,而不是将自己的标识符暴漏在顶级的词法作用域中。
var MyReallyCoolLibrary = {
awesome: "stuff",
doSomething: function() {
// ...
},
doAnotherThing: function() {
// ...
}
};
- 模块管理
另外一种避免冲突的办法和现代的模块机制很接近,就是从众多模块管理器中挑选一个来使用。使用这些工具,任何库都无需将标识符加入到全局作用域中,而是通过依赖管理器的机制将库的标识符显式地导入到另外一个特定的作用域中。
显而易见,这些工具并没有能够违反词法作用域规则的“神奇”功能。它们只是利用作用域的规则强制所有标识符都不能注入到共享作用域中,而是保持在私有、无冲突的作用域中,这样可以有效规避掉所有的意外冲突。
# 块级作用域
至少从 ES3 发布以来,JavaScript 中就有了块作用域,而with 和 catch 分句就是块作用域的两个小例子。
但随着 ES6 中引入了 let,代码终于有了创建完整、不受约束的块作用域的能力。块作用域在功能上和代码风格上都拥有很多激动人心的新特性。
console.warn(i,11) // undefined 11
for (var i=0; i<10; i++) {
console.log( i );
}
console.warn(i,22)// 10 22
以上var的写法会造成代码污染,所以不建议使用。var这种写法甚至让ES6之前的块级作用域无太大意义。幸好,ES6 改变了现状,引入了新的 let 关键字,提供了除 var 以外的另一种变量声明方式。
let 关键字可以将变量绑定到所在的任意作用域中(通常是 { .. } 内部)。换句话说,let为其声明的变量隐式地了所在的块作用域。
var foo = true;
if (foo) {
let bar = foo * 2;
console.log( bar );
}
console.log( bar ); // ReferenceError bar is not defined
在开发和修改代码的过程中,如果没有密切关注哪些块作用域中有绑定的变量,并且习惯性地移动这些块或者将其包含在其他的块中,就会导致代码变得混乱。
如果为了清晰,也可以主动显示声明块级作用域
var foo = true;
if (foo) {
{ // <-- 显式的块
let bar = foo * 2;
console.log( bar );
}
}
console.log( bar ); // ReferenceError
# 利用块级作用域内存及时销毁
function process(data) {
// 在这里做点有趣的事情
console.log(data)
}
var someReallyBigData = { a:1 };
process( someReallyBigData );
var btn = document.getElementById( "my_button" );
btn.addEventListener( "click", function click(evt) {
console.log("button clicked");
}, /*capturingPhase=*/false );
click 函数的点击回调并不需要 someReallyBigData 变量。理论上这意味着当 process(..) 执行后,在内存中占用大量空间的数据结构就可以被垃圾回收了。但是,由于 click 函数形成了一个覆盖整个作用域的闭包,JavaScript 引擎极有可能依然保存着这个结构(取决于具体实现)。
块作用域可以打消这种顾虑,可以让引擎清楚地知道没有必要继续保存 someReallyBigData 了:
function process(data) {
// 在这里做点有趣的事情
console.log(data);
}
// 在这个块中定义的内容可以销毁了!
{
let someReallyBigData = { a:1 };
process( someReallyBigData );
}
var btn = document.getElementById( "my_button" );
btn.addEventListener( "click", function click(evt){
console.log("button clicked");
}, /*capturingPhase=*/false );
总结
- 从 ES3 开始,try/catch 结构在 catch 分句中具有块作用域。
- 在 ES6 中引入了 let 关键字(var 关键字的表亲),用来在任意代码块中声明变量。if(..) { let a = 2; } 会声明一个劫持了 if 的 { .. } 块的变量,并且将变量添加到这个块中。
# 执行上下文
- 范围:一段script或者一个函数
- 全局: 变量定义 函数声明
- 函数: 变量定义 函数声明 this arguments
JavaScript 执行主要分为两个阶段:
- 代码预编译阶段
- 代码执行阶段
预编译阶段是前置阶段,这个时候由编译器将 JavaScript 代码编译成可执行的代码。 执行阶段主要任务是执行代码,执行上下文在这个阶段全部创建完成。
经过预编译过程,我们应该注意三点:
- 预编译阶段进行变量声明;
- 预编译阶段变量声明进行提升,但是值为 undefined;
- 预编译阶段所有非表达式的函数声明进行提升。
JavaScript 引擎创建了执行上下文栈(Execution context stack,ECS)来管理执行上下文
为了模拟执行上下文栈的行为,定义执行上下文栈是一个数组:
ECStack = [];
当 JavaScript 开始要解释执行代码的时候,最先遇到的就是全局代码,所以初始化的时候首先就会向执行上下文栈压入一个全局执行上下文,用 globalContext 表示,并且只有当整个应用程序结束的时候,ECStack 才会被清空:
ECStack = [
globalContext
];
现在 JavaScript 遇到下面的这段代码了:
function fun3() {
console.log('fun3')
}
function fun2() {
fun3();
}
function fun1() {
fun2();
}
fun1();
当执行一个函数的时候,就会创建一个执行上下文,并且压入执行上下文栈,当函数执行完毕的时候,就会将函数的执行上下文从栈中弹出。知道了这样的工作原理,让我们来看看如何处理上面这段代码:
// 伪代码
// fun1()
ECStack.push(<fun1> functionContext);
// fun1中竟然调用了fun2,还要创建fun2的执行上下文
ECStack.push(<fun2> functionContext);
// 擦,fun2还调用了fun3!
ECStack.push(<fun3> functionContext);
// fun3执行完毕
ECStack.pop();
// fun2执行完毕
ECStack.pop();
// fun1执行完毕
ECStack.pop();
// javascript接着执行下面的代码,但是ECStack底层永远有个globalContext
# 私有变量
严格来讲,JavaScript中没有私有成员的概念;所有对象属性都是公有的。不过,倒是有一个私有变量的概念。任何在函数中定义的变量,都可以认为是私有变量,因为不能在函数的外部访问这些变量,私有变量包括函数的参数、局部变量和在函数内部定义的其他函数。
function add(num1,num2){
var sum=num1+num2;
return sum;
}
在这个函数内部,有3个私有变量:num1、num2和sum。在函数内部可以访问这几个变量,但在函数外部则不能访问它们。如果在这个函数内部创建一个闭包,那么闭包通过自己的作用域链也可以访问这些变量。而利用这一点,就可以创建用于访问私有变量的公有方法。
特权方法(privileged method)是能够访问函数私有变量(及私有函数)的公有方法。在对象上有两种方式创建特权方法。特权方法就是作为函数对象的属性方法。
- 在构造函数中实现
function MyObject(){
//私有变量和私有函数
var privateVariable=10;
function privateFunction(){
return false;
}
//特权方法
this.publicMethod=function(){
privateVariable++;
return privateFunction();
};
这个模式在构造函数内部定义了所有私有变量和函数。然后,又继续创建了能够访问这些私有成员的特权方法。能够在构造函数中定义特权方法,是因为特权方法作为闭包有权访问在构造函数中定义的所有变量和函数。对这个例子而言,变量privateVariable和函数privateFunction()只能通过特权方法publicMethod()来访问。在创建MyObject的实例后,除了使用publicMethod()这一个途径外,没有任何办法可以直接访问privateVariable和privateFunction().
- 利用
私有和特权成员,可以隐藏那些不应该被直接修改的数据,例如:
function Person(name){
this.getName=function(){
return name;
};
this.setName=function(value){
name=value;
};
}
var person=new Person("yao");
alert(person.getName()); //"yao"
person.setName("xiyao"));
alert(person.getName()); //"xiyao"
以上代码的构造函数中定义了两个特权方法:getName()和setName()。这两个方法都可以在构造函数外部使用,而且都有权访问私有变量name。但在Person构造函数外部,没有任何办法访问name。由于这两个方法是在构造函数内部定义的,它们作为闭包能够通过作用域链访问name。私有变量name在Person的每一个实例中都不相同,因为每次调用构造函数都会重新创建这两个方法。不过,在构造函数中定义特权方法也有一个缺点,那就是你必须使用构造函数模式来达到这个目的。构造函数模式的缺点是针对每个实例都会创建同样一组新方法,而使用静态私有变量来实现特权方法就可以避免这个问题。
# 静态私有变量
特权方法也可以通过使用私有作用域定义私有变量和函数来实现。这个模式如下所示:
(function(){
let privateVariable = 10;
function privateFunction(){
return false;
}
MyObject = function(){}; // 定义一个私有函数
MyObject.prototype.publicMethod = function(){ // 在私有函数的原型上添加特权方法
privateVariable++;
return privateFunction();
};
})();
在这个模式中,匿名函数表达式中定义的是私有变量和私有函数,然后又定义了构造函数和公有方法。公有方法定义在构造函数的原型上,与典型的原型模式一样。注意,这个模式定义的构造函数没有使用函数声明,使用的是函数表达式。函数声明会创建内部函数,在这里并不是必需的。基于同样的原因(但操作相反),这里声明 MyObject 并没有使用任何关键字。因为不使用关键字声明的变量会创建在全局作用域中,所以 MyObject 变成了全局变量,可以在这个私有作用域外部被访问。注意在严格模式下给未声明的变量赋值会导致错误。
这个模式与前一个模式的主要区别就是,私有变量和私有函数是由实例共享的。因为特权方法定义在原型上,所以同样是由实例共享的。特权方法作为一个闭包,始终引用着包含它的作用域。来看下面的例子:
(function() {
let name = '';
Person = function(value) { // 构造函数,没有用关键字声明,会成为全局构造函数
name = value;
};
Person.prototype.getName = function() { // 特权方法 getName 写在构造函数的原型对象上
return name;
};
Person.prototype.setName = function(value) { // 特权方法 setName 写在构造函数的原型对象上
name = value;
};
})();
let person1 = new Person('Nicholas');
console.log(person1.getName()); // 'Nicholas'
person1.setName('Matt');
console.log(person1.getName()); // 'Matt'
let person2 = new Person('Michael');
console.log(person1.getName()); // 'Michael'
console.log(person2.getName()); // 'Michael'
这里的 Person 构造函数可以访问私有变量 name,跟 getName() 和 setName() 方法一样。使用这种模式,name 变成了静态变量,可供所有实例使用。这意味着在任何实例上调用setName() 修改这个变量都会影响其他实例。调用 setName() 或创建新的 Person 实例都要把name 变量设置为一个新值。而所有实例都会返回相同的值。
像这样创建静态私有变量可以利用原型更好地重用代码,只是每个实例没有了自己的私有变量。最终,到底是把私有变量放在实例中,还是作为静态私有变量,都需要根据自己的需求来确定。
注意:使用闭包和私有变量会导致作用域链变长,作用域链越长,则查找变量所需的时间也越多。
# 模块模式
前面的模式通过自定义类型创建了私有变量和特权方法。
模块模式:在一个单例对象上实现了相同的隔离和封装。
单例对象(singleton):就是只有一个实例的对象。JS 是通过对象字面量来创建单例对象的。
let singleton = {
name: value,
method(){
// 方法的代码
}
}
模块模式是在单例对象基础上加以扩展,使其通过作用域链来关联私有变量和特权方法。例如:
let singleton = function(){
let privateVariable = 10; // 私有变量
function privateFunction(){ // 私有函数
return false;
}
return { // 特权/公有方法和属性
publicProperty: true,
publicMethod(){
privateVariable++;
return privateFunction();
}
};
}();
上述的模块模式使用立即执行的匿名函数返回一个对象,在匿名函数内部,首先定义私有变量和私有函数。之后,创建一个要通过匿名函数返回的对象字面量。这个对象字面量中只包含可以公开访问的属性和方法。因为这个对象定义在匿名函数内部,所以它的所有公有方法都可以访问同一个作用域的私有变量和私有函数。本质上,对象字面量定义了单例对象的公共接口。如果单例对象需要进行某种初始化,并且需要访问私有变量时,那就可以采用这个模式:
let application = function() {
let components = new Array(); // 私有变量
// 初始化
components.push(new BaseComponent());
// 公共接口
return {
getComponentCount() {
return components.length;
},
registerComponent(component) {
if (typeof component == 'object') {
components.push(component);
}
};
}
}();
上面这个简单的例子创建了一个 application 对象,假设它用于管理组件。在创建这个对象之后,内部就创建一个私有的数组 components,然后将一个 BaseComponent 组件的新实例添加到数组中。(BaseComponent组件的代码并不重要,在这里用它只是为了说明模块模式的用法)。返回的对象字面量中定义的 getComponentCount() 和 registerComponent() 方法都是可以访问 components 私有数组的特权方法。前一个方法返回注册组件的数量,后一个方法负责注册新组件。
在模块模式中,单例对象作为一个模块,经过初始化可以包含某些私有的数据,而这些数据又可以通过其暴露的公共方法来访问。以这种方式创建的每个单例对象都是 Object 的实例,因为最终单例都由一个对象字面量来表示。不过这无关紧要,因为单例对象通常是可以全局访问的, 而不是作为参数传给函数的,所以可以避免使用 instanceof 操作符确定参数是不是对象类型的需求。
# 模块增强模式
另一个利用模块模式的做法是在返回对象之前先对其进行增强。这适合单例对象需要是某个特定类型的实例,但又必须给它添加额外属性或方法的场景。
let singleton = function(){
let privateVariable = 10; // 私有变量
function privateFunction(){ // 私有函数
return false;
}
let object = new CustomType(); // 创建 CustomType 类型的对象
object.publicProperty = true; // 在对象上添加共有属性
object.publicMethod = function(){ // 在对象上添加共有方法
privateVariable++;
return privateFunction();
};
return object;
}();
这样在返回对象时,返回的对象有自己的类型,并且也添加了需要的属性和方法用于访问私有变量和函数。
# 变量提升
只有声明本身会被提升,而赋值或其他运行逻辑会留在原地 。如果提升改变了代码执行的顺序,会造成非常严重的破坏。
foo();
function foo() {
console.log( a ); // undefined
var a = 2;
}
每个作用域都会进行提升操作。尽管前面大部分的代码片段已经简化了(因为它们只包含全局作用域),而 foo(..) 函数自身也会在内部对 var a 进行提升(显然并不是提升到了整个程序的最上方)。因此这段代码实际上会被理解为下面的形式:
function foo() {
var a;
console.log( a ); // undefined
a = 2;
}
foo();
函数声明会被提升,但是函数表达式却不会被提升。
foo(); // TypeError
bar(); // ReferenceError
var foo = function bar() {
// ...
};
这个代码片段经过提升后,实际上会被理解为以下形式:
var foo;
foo(); // TypeError
bar(); // ReferenceError
foo = function() {
var bar = ...self...
// ...
}
注意
函数声明和变量声明都会被提升。但是一个值得注意的细节(这个细节可以出现在有多个“重复”声明的代码中)是 函数会首先被提升,然后才是变量。
因为变量提升容易带来变量在预期外被覆盖掉的问题,同时还可能导致本应该被销毁的变量没有被销毁等情况
foo(); // 1
var foo;
function foo() {
console.log( 1 );
}
foo = function() {
console.log( 2 );
};
会输出 1 而不是 2 !这个代码片段会被引擎理解为如下形式:
function foo() {
console.log( 1 );
}
foo(); // 1
foo = function() {
console.log( 2 );
};
- 注意变量提升之后
// bar() // bar1
var bar = function () {
console.log('bar2');
};
function bar() {
console.log('bar1');
}
bar(); // bar2
尽管重复的 var 声明会被忽略掉,但出现在后面的函数声明还是可以覆盖前面的
foo(); // 3
function foo() {
console.log( 1 );
}
var foo = function() {
console.log( 2 );
};
function foo() {
console.log( 3 );
}
一个普通块内部的函数声明通常会被提升到所在作用域的顶部,这个过程不会像下面的代码暗示的那样可以被条件判断所控制:
foo(); // "b" [最新的谷歌浏览器是直接报错 Uncaught TypeError: foo is not a function]
var a = true;
if (a) {
function foo() { console.log("a"); }
}
else {
function foo() { console.log("b"); }
}
但是需要注意这个行为并不可靠,在 JavaScript 未来的版本中有可能发生改变,因此应该尽可能避免在块内部声明函数。
其实,如果不在变量前加上var等关键字,它会默认被定义到window下,可以删除,而var等定义的变量不可删除!
var a=10;
var b=10;
c=10
delete c;
console.log(c)
var a = 1;
Object.getOwnPropertyDescriptor(window,'a')
console.log(Object.getOwnPropertyDescriptor(window,'a'))
b=2;
console.log(Object.getOwnPropertyDescriptor(window,'b'))
// {value: 1, writable: true, enumerable: true, configurable: false}
// {value: 2, writable: true, enumerable: true, configurable: true}
- 这段会输出什么?
for(var i=0;i<10;i++){
console.log(i)
for(var i=0;i<2;i++){
console.log(i)
}
}
//死循环
函数声明和变量提升优先级 (opens new window)
# 作用域相关面试题
- 输出什么
function Foo() {
Foo.a = function() {
console.log(1)
}
this.a = function() {
console.log(2)
}
}
Foo.prototype.a = function() {
console.log(3)
}
Foo.a = function() {
console.log(4)
}
Foo.a();
let obj = new Foo();
obj.a();
Foo.a();
// 4
// 2
// 1
function Foo() {
Foo.a = function() {
console.log(1)
}
this.a = function() {
console.log(2)
}
}
// 以上只是 Foo 的构建方法,没有产生实例,此刻也没有执行
Foo.prototype.a = function() {
console.log(3)
}
// 现在在 Foo 上挂载了原型方法 a ,方法输出值为 3
Foo.a = function() {
console.log(4)
}
// 现在在 Foo 上挂载了直接方法 a ,输出值为 4
Foo.a();
// 立刻执行了 Foo 上的 a 方法,也就是刚刚定义的,所以
// # 输出 4
let obj = new Foo();
/* 这里调用了 Foo 的构建方法。Foo 的构建方法主要做了两件事:
1. 将全局的 Foo 上的直接方法 a 替换为一个输出 1 的方法。
2. 在新对象上挂载直接方法 a ,输出值为 2。
*/
obj.a();
// 因为有直接方法 a ,不需要去访问原型链,所以使用的是构建方法里所定义的 this.a,
// # 输出 2
Foo.a();
// 构建方法里已经替换了全局 Foo 上的 a 方法,所以
// # 输出 1
//故输出的是4 2 1
function changeObjProperty(o) {
o.siteUrl = "http://www.baidu.com"
o = new Object()
o.siteUrl = "http://www.google.com"
}
let webSite = new Object();
changeObjProperty(webSite);
console.log(webSite.siteUrl);//http://www.baidu.com
//传进函数的是原对象的地址(或者说引用),这个地址赋值给了形参(形参看做局部变量),
//形参变量此时指向原对象,后面o=new object的时候,形参变量保存的是新对象的地址,
//指向的是新的对象,所以第二次的o.siteUrl 也是给这个新对象属性的赋值,和旧对象无关。
//最后打印website.SiteUrl 的时候,访问的是旧对象,因为前面的改动都只涉及到形参变量,
//和website无关,website依然保存着旧对象的引用。
function Foo(){
getName =function(){
console.log(1)
}
return this
}
Foo.getName=function(){
console.log(2)
}
Foo.prototype.getName=function(){
console.log(3)
}
var getName =function(){
console.log(4)
}
function getName(){
console.log(5)
}
Foo.getName();
getName()
Foo().getName()
getName()
new Foo.getName()
new Foo().getName()
new new Foo().getName()
//2
//4
//1
//1
//2
//3
//3
function A(){
console.log(1)
}
function Fn(){
A=function(){
console.log(2)
}
return this
}
Fn.A=A
Fn.prototype ={
A:()=>{
console.log(3)
}
}
A()//1
Fn.A()//1
Fn().A()//2
new Fn.A()//1
new Fn().A()//3
new new Fn().A()//报错,箭头函数不能被new,他没有原型链,没有constructor
- 作用域和变量提升
虽然变量也会提升,但是如果在没赋值之前,都是undefined,而此时如果正好有同名的函数,会直接关联到函数;在赋值后操作的地方,则是有变量接管。
foo(10);
function foo(num) {
console.log(foo,1);
foo = num;
console.log(foo,2);
var foo;
}
console.log(foo,3);
foo = 1;
console.log(foo,4);
// undefined 1
// 10 2
// ƒ foo(num) {
// console.log(foo,1);
// foo = num;
// console.log(foo,2);
// var foo;
// } 3
// 1 4