# 作用域和闭包(Scope & Closures)
# 作用域是什么
作用域是一套规则,用于确定在何处以及如何查找变量(标识符)。
# 编译原理
JS 是一门动态、解释执行语言。任何 JS 代码在执行前都要进行编译,编译完成后立即执行。
传统编译语言,编译过程
- 分词/词法分析 (Tokenizing/Lexing) :将字符串分解有意义的代码块(也叫词法单元 token),比如 "var a = 2;",通常会被分解为 var、a、 =、2、;。
- 解析/语法分析 (Parsing) :将词法单元数组,转换成 AST(Abstract Syntax Tree),即由元素嵌套组成的代表了程序语法结构的树。
- 代码生成:将 AST 转换为可以执行的代码,即一组机器指令。
分词 (tokenizing) 和词法分析 (lexing) 的区别在于,词法单元的识别是通过有状态的还是无状态的方式来进行的。如果是有状态的解析规则,则这个过程称为词法分析。
JS 引擎实际要复杂很多,例如,在语法分析和代码生成阶段有特定步骤对运行性能进行优化,包括对冗余元素进行优化等。
# 理解作用域
如果要理解 JS 工作原理,要学会像引擎一样思考
- 引擎:负责整个 JS 程序的编译和执行过程
- 编译器:引擎的好朋友之一,负责语法分析、代码生成等
- 作用域:引擎的另一位好朋友,负责收集并维护由所有声明的变量(标识符)组成的一系列查询,确定当前执行代码对这些变量的访问权限
var a = 2,变量的赋值会执行两个操作:
- var a 编译器会判断 a 是否已经在当前作用域中声明过,如果没有则在当前作用域中声明该变量,否则忽略该声明,继续编译
- 编译器将 var a = 2 编译成运行时的代码,引擎运行时,会在作用域中查找 a 变量,如果找到了就赋值
引擎执行代码时,会让作用域来协助查找变量 a,引擎查找分两种,可以简单的理解为赋值语句的左侧或右侧
- LHS (Left-Hand-Side) 左侧
- RHS (Right-Hand-Side) 右侧,准确理解为 retrieve his source value(取到它的源值)
核心:如果查找目的是对变量进行赋值,会使用 LHS。如果目的是获取变量的值,会使用 RHS。
例子
console.log(a) // 查找 console 变量、查找 a 变量都是 RHS 查询
function foo(a) {
console.log(a)
}
foo(2)
// 执行时,先查找 foo 使用 RHS 查询
// 继续执行,将 2 赋值给形参 a,相当于 a = 2,会进行 LHS 查询
// 执行执行 console,做 3 次 RHS 查询 console, console.log, a
# 作用域嵌套与异常
当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。
LHS 和 RHS 都会先从当前执行作用域中开始,如果需要(当前找不到),就会向上级作用域继续查找,直到找到或者最后抵达全局作用域(顶层)就会停止。
function foo(a) {
console.log(a + b) // RHS 查找 b,函数作用域找不到,再到全局作用域找,找到为 2
}
var b = 2;
foo(2) // 4
不成功的 RHS 会抛出 ReferenceError,比如 foo(a),找不到 a 或 foo,都会报这个错
foo(a) // Uncaught ReferenceError: foo is not defined
不成功的 LHS 会自动隐式的创建一个全局变量(非严格模式下),如果是严格模式,不会创建全局变量,会报 ReferenceError
function foo(a) {
b = a // 这里 LHS 找不到 b 会创建一个 window.b
console.log(a + b)
}
foo(2)
如果 RHS 查询到了一个变量,对该变量执行不合理的操作,比如非函数类型,执行函数调用会出现 TypeError
let p = 1
p() // Uncaught TypeError: p is not a function
# 词法作用域
在编译的词法分析阶段,基本可以知道全部标识符(变量)是在哪以及如何声明的,从而预测在执行中如何对他进行查找。
词法作用域意味着 - 作用域是由书写代码时函数声明的位置来决定的。
下面的例子中,根据嵌套,有 3 层作用域:最外层全局作用域(foo) => foo 函数内作用域(a,b,bar) => bar 函数内作用域(c)
function foo(a) {
var b = a + 2;
function bar(c) {
console.log(a, b, c)
}
bar(b * 3)
}
foo(2)
# 欺骗词法 eval 和 with
JS 有两种方法,可以 "欺骗" 词法作用域, 注意:欺骗词法作用域会导致性能下降
eval("var a = 2") 可以根据字符串参数动态执行 js,在运行时,假设里面有声明的变量,会修改默认的词法作用域
function foo(str, a) {
// "use strict" // 如果是严格模式,则 eval 不会影响词法作用域
eval(str)
console.log(a, b)
}
var b = 2;
foo('var b = 3', 1) // 1 3
按照正常的词法作用域,b 应该是 2,但 eval 中执行了 b 的声明语句,导致词法作用域被修改
with 的用处,减少重复引用
var obj = {
a: 1,
b: 2
}
with(obj) {
a = 2 // 加了 with 后,等价于 obj.a = 2
b = 3 // 加了 with 后,等价于 obj.b = 3
}
with 对词法作用域的修改
function foo (obj) {
with(obj) {
a = 2
}
}
var o1 = {
a: 3
}
var o2 = {
b: 3
}
foo(o1)
console.log(o1.a) // 2
foo(o2)
console.log(o2.a) // undefined
console.log(a) // 2
foo(o1) 时,o1.a 被重写为 2.foo(o2) 时由于 o2.a 没有被定义,相当于 "a = 2" LHS,会在全局作用域上声明 a。
以上两种机制的副作用是:引擎无法在编译时对作用域查找进行优化,因为在运行时,根据执行的内容,默认词法作用域可能会被修改。
# 函数作用域与块级作用域
# 函数作用域
函数作用域中,外层函数无法访问内部嵌套函数作用域中的变量,可以用于隐藏内部实现
匿名函数的缺点:
- 在栈追踪中不会显示有意义的函数名,不利于调试
- 如果是在触发事件中,无法解绑该事件函数
- 影响可读性
setTimeout(() => {
console.log('1s 后执行')
}, 1000)
// 建议改为
setTimeout(function timeoutHandler() {
console.log('1s 后执行')
})
立即执行函数表达式(IIFE): (function a() {})() 或者 (function a() {}())
var a = 2;
(function foo() {
var a = 3;
console.log(a) // 3
})();
console.log(a); // 2
或者
var a = 2;
(function foo() {
var a = 3;
console.log(a) // 3
}());
console.log(a); // 2
(function
开头的声明,和 function
开头的声明有很重要的区别,(function
会被当做函数表达式而不是一个标准的函数声明来处理。
UMD 项目中广泛使用 IIFE
(function IIFE(def) {
def(window)
})(function def(global) {
var a = 3;
console.log(a) // 3
console.log(global.a) // 2
})
# 块级作用域
函数不是唯一的作用域单元,代码块也是,一般由 {}
包裹
ES3 中 try {} catch(e) {}, catch 分句中,具有块作用域。
ES6 新增的 let/const,在声明变量时,会隐式的将对应的变量劫持在所在的块作用域内。且在块作用域中,不会进行提升
{
console.log(bar) // Uncaught ReferenceError: Cannot access 'bar' before initialization
let bar = 6;
}
console.log(bar)
ES6 之前 JS 没有块级作用域,if (){} 中的代码会泄露到全局。ES6 需要借助 let, const 来达到块级作用域效果
var foo = true
if (foo) {
var a = 2
const b = 3
a = 3
// b = 4 // Uncaught TypeError: Assignment to constant variable.
}
console.log(a) // 3
console.log(b) // Uncaught ReferenceError: b is not defined
# 提升(hoisting)
JS 是弱语言类型,容错性很高,变量在没定义前,就可以使用,这里涉及变量提升的概念
// 片段 1
a = 2;
var a;
console.log(a) // 为什么是 2,而不是 undefined
// 片段 2
console.log(a) // 为什么是 undefined,而不是 2
var a = 2
我们习惯将 var a = 2
看做一条声明,但 JS 引擎不这么认为,它将 var a 和 a = 2 当做两个单独的声明。第一个声明是编译阶段的任务,第二个声明是执行阶段的任务。
这意味着,无论作用域中的声明出现在什么地方,JS 引擎会在编译阶段将所有的声明(变量、函数)移动到各自作用域的顶端,这个过程叫做提升(hoisting)。
// 片段 1 实际执行代码
var a
a = 2
console.log(a) // 2
// 片段 2 实际执行代码
var a
console.log(a) // undefined
a = 2
函数声明
foo()
function foo() {
console.log(a)
var a = 2
}
// 编译、执行时
function foo() {
var a
console.log(a)
a = 2
}
foo()
需要注意两点
- 声明本身会被提升,但函数表达式的赋值操作不会提升
- 注意避免重复声明,比如不同的 var 声明 和 函数声明混合时,会引起危险问题
foo(); // Uncaught TypeError: foo is not a function
bar(); // ReferenceError
var foo = function bar() {
console.log('1')
}
// 等价于
var foo
foo() // 对 undefined 执行 RHS 时类型错误,报 TypeError
bar() // 找不到该变量
foo = function() {
var bar = ...self...
console.log('1')
}
# 函数优先
函数声明和变量声明都会被提升,如果函数和变量名重复,函数会被优先提升
foo() // 1
var foo;
function foo() {
console.log('1')
}
foo = function() {
console.log('2')
}
如果有多个重复的函数声明,后面的会覆盖前面的
foo() // 3
function foo() {
console.log('1')
}
foo = function() {
console.log('2')
}
function foo() {
console.log('3')
}
函数声明的提升,不会根据 if else 逻辑来走,编译时可能就会提升,这里的行为并不可靠,JS 未来版本可能会发生改变,因此需要避免在 块内部声明函数
foo()
// Safari 是 'b'
// Chrome/Firefox 是 Uncaught TypeError: foo is not a function
var a = true
if (a) {
function foo() { console.log('a') }
} else {
function foo() { console.log('b') }
}
# 作用域闭包(closures)
closures [ˈkləʊʒə(r)],个人认为这一章讲的相对复杂,JS 高级程序设计 函数 - 闭包 笔记 (opens new window) 里面讲的要好理解一点。
# 词法作用域与动态作用域
和大部分语言一样,JS 是词法作用域,另外还有一中动态作用域。
- 词法作用域,函数/标识符声明位置决定其作用域,编译初期,词法分析时静态确定
- 动态作用域,只关心他从何处调用,作用域基于调用栈,和 JS 中的 this 机制有点像
function foo() {
console.log(a)
}
function bar() {
var a = 3
foo()
}
var a = 2
bar() // 2
如果是词法作用域,foo() 执行时, a 通过 RHS 找到了全局作用域的 a,输出 2
如果是动态作用域,作用域基于调用栈,bar() 会打印 3
总结,主要区别:
- 词法作用域是在写代码或声明的时候确定的,动态作用域是在运行确定的。
- 词法作用域关注函数在何处声明,动态作用域关注函数在何处调用
# this 词法
this 绑定丢失的问题
var obj = {
id: 'awesome',
cool: function coolFn() {
console.log(this.id)
}
}
var id = 'not awesome'
obj.cool() // this.id 为 "awesome"
setTimeout(obj.cool, 100) // this.id 为 "not awesome"
该函数是谁在调用,this 就指向谁 obj.cool() 调用 this 就是 obj。obj.cool 赋值到 setTimeout,执行时 this 就是 window
一般可以使用 self 来解决 this 丢失的问题
ES6 新增的箭头函数,会继承父级作用域的 this,也就是 this 固定指向父级作用域