第一部分 作用域和闭包
第一章 作用域是什么
编译原理
尽管通常将 JavaScript 归类为 “动态” 或 “解释执行” 语言,但事实上它是一门 编译语言,但与传统的编译语言不同,它不是提前编译的,编译结果也不能在分布式系统中进行移植。
在传统编译语言流程中,程序中的一段源代码在执行之前会经历三个步骤,统称为 “编译”。
分词/词法分析(Tokenizing/Lexing)
这个过程会将由字符组成的字符串分解成(对编程语言来说)有意义的代码块 , 这些代码块被称为词法单元(token)。例如 var a = 2
,被分解成这些词法单元:var、a、=、2、;
空格是否会被当做词法单元,取决于空格在这门语言中是否具有意义。
解析/语法分析(Parsing)
这个过程将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树,这个树被称为 “ 抽象语法树 “(Abstract Syntax Tree,AST)。
代码生成
简单来说有某种方法可以将 var = 2
的 AST 转化为一组机器指令,用来创建一个叫做 a 的变量(包括分配内存等),并将一个值储存在 a 中。
与其他语言不同,JavaScript 的编译过程不是发生在构建之前的。大部分的编译发生在代码执行前的几微秒的时间里,比起编译过程只有三步的语言的编译器,JavaScript 引擎要复杂得多。
理解作用域
演员表
引擎:从头到尾负责整个 JavaScript 程序的编译及执行过程
编译器:引擎的好朋友之一,负责语言分析及代码生成等脏活累活
作用域:引擎的另一位好朋友,负责收集并维护由所有声明的标识符(变量)组成的一系列查询
变量赋值
对于 var a = 2
,引擎认为这里有两个完全不同的声明,一个由编译器在编译时处理,另一个则由引擎在运行时处理。
变量赋值操作会执行两个动作,首先编译器会在当前作用域中声明一个变量(如果之前没有声明过),然后在运行时引擎会在作用域中查找该变量,如果能够找到就会对它赋值。
首先,
var a
在其作用域中声明新变量。这会在最开始的阶段,也就是代码执行前进行。接下来,
a = 2
会查询(LHS 查询)变量 a 并对其进行赋值。
引擎查找
在 var a = 2
,这个例子中,引擎会为变量 a 进行 LHS 查询,另一个查找的类型叫做 RHS。
简单理解
如果查找的目标是对变量进行赋值,那么就会使用 LHS 查询;如果目的是获取变量的值,就会使用 RHS 查询; 赋值操作符会导致 LHS 查询,= 操作符 或 调用函数时传入参数的操作都会导致关联作用域的赋值操作。
深入理解
RHS查询 与简单查找某个变量的值别无二致, 而 LHS查询 则是试图找到变量的容器本身,从而可以对其赋值。在概念上最好将其理解为 “ 赋值操作的目标是谁(LHS)” 以及 “ 谁是赋值操作的源头(RHS)”。
1 | console.log(a); // 这里对 a 的引用是一个 RHS引用,因为这里 a 并没有赋予任何值。 |
- 实例一理解 LHS RHS
1 | function foo(a) { |
- 实例二理解 LHS RHS
1 | function foo(a) { |
异常
ReferenceError
不成功的 RHS、LHS(严格)查询
1 | function foo(a) { |
上面这段代码在对 b 进行 RHS 查询时无法找到,引擎会抛出 ReferenceError : Uncaught ReferenceError: b is not defined。
在非严格模式下,对 a 的 LHS 查询并没有抛出错误,而会在全局创建一个 a 的变量,在严格模式下,LHS 查询没有结果也会抛出 ReferenceError。
TypeError
成功的 RHS 查询,但是是非法的操作
1 | var b = null; |
如果 RHS 查询到了,但是对其进行不合理的操作,比如对非函数类型的值进行函数调用,那么引擎会抛出 TypeError:Uncaught TypeError: b is not a function
小结
ReferenceError 同作用域判别失败相关
TypeError 代表作用域判别成功了,但对结果的操作是非法的或不合理的。
不成功的 RHS 引用会导致 ReferenceError 异常
不成功的 LHS 引用
- 非严格模式下会创建全局变量
- 严格模式下导致 ReferenceError 异常
第二章 词法作用域
简单的说,词法作用域就是定义在词法阶段的作用域
词法阶段
window a
通过这种技术可以访问那些被同名变量所遮蔽的全局变量,但是非全局变量如果被遮蔽了无论如何都无法被访问到。
词法作用域只会查找一级标识符,如果代码中引用了 foo.bar.baz ,词法作用域只会试图查找 foo 标识符,找到这个变量后,对象属性访问规则会接管对 bar 和 baz 属性的访问。
欺骗词法
不要使用 eval 和 with
第三章 函数作用域和块作用域
函数作用域的含义是指,属于这个函数的全部变量都可以在整个函数的范围内使用及复用
函数作用域
从所写的代码中挑选出一个任意的片段,然后用函数声明对它进行包装,然后再用立即执行函数(IIFE)的方式再进行一层包装。
立即执行函数表达式
1 | var a = 2; |
立即执行函数传参
1 | // 传递 window 对象 |
IIFE 传函数当做参数
1 | var a = 2; |
块作用域
能用 let
和 const
就不用 var
第四章 提升
无论作用域中的声明出现在什么地方,都将在代码本身被执行前首先进行处理,可以将这个过程形象地想象成所有的声明(变量和函数),都会被 “移动” 到各自作用域的最顶端,这个过程被称为提升。
声明本身会被提升,而包括函数表达式的赋值在内的赋值操作并不会提升。
第五章 作用域闭包
定义
当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。
1 | function foo() { |
对于上面的代码,简单来说,bar() 依然持有对该作用域的引用,而这个引用就叫作闭包。
1 | function foo() { |
1 | var fn; |
无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。
本质理解
下面这段代码中 timer 保有对变量 message 的引用,wait(…) 执行 1000 毫秒后,它的内部作用域并不会消失,timer 函数依然保有 wait(..) 作用域的闭包。
在引擎内部,内置的工具函数 setTimeout(..) 持有对一个参数的引用,这个参数也许叫 fn 或者 func,或者其它名字。引擎会调用这个函数,在这个例子中就是内部的 timer 函数。
1 | function wait(message) { |
本质上无论何时何地,如果将函数当做第一级的值类型并到处传递,就可以看到闭包在这些函数中的应用。在定时器、事件监听器、Ajax 请求、跨窗口通信、Web Workers 或者任何其他的异步(或者同步)任务中,只要用了回调函数,实际上就是在使用闭包!
循环和闭包
普通循环
1 | for(var i = 1; i <= 5; i ++) { |
上面这段代码会一秒打印一个6,总共打印5个6
原因就是延时函数的回调会在循环结束时才执行,当定时器运行时即使每个迭代中执行的是 setTimeout(.., 0)
,即 0ms 后执行 setTimeout,所有的回调函数依然是在循环结束后才会被执行,因此会每次输出一个 6 出来。
IIFE + 闭包
1 | for(var i = 1; i <= 5; i ++) { |
块作用域 + 闭包
1 | for(let i = 1; i <= 5; i ++) { |
模块
模块模式
模块模式需要两个必要条件:
- 必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块实例)。
- 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态。
1 | function CoolModule() { |
单例模式
当只需要一个实例时,可以对模块模式进行简单的改进来实现单例模式:
1 | var foo = (function CoolModule() { |
补充
可以传参,另外可以命名将要作为公共API返回的对象,比如下面的 change()
函数
1 | var foo = (function CoolModule(id) { |