目录
- 概述
- 作用域
- 编译过程
- 词法作用域
- 全局作用域
- 函数作用域
- 闭包
- 循环和闭包
- 闭包的用途
- 性能
- 总结
概述
作用域和闭包一直是各大小厂面试的重点,学习了一段时间 JS 了,是时候对这部分知识有个交代了。
本文暂不涉及ES6的块级作用域。
本文是对《你不知道的JavaScript》的大量梳理。
作用域
作用域是一套用来存储变量的规则,用于确定在何处以及如何查找变量(标志符)。
作用域也通常被理解为变量存在的范围、当前的执行上下文等。在 ES5 的规范中,JavaScript 只有两种作用域:
- 全局作用域:变量在整个程序中一直存在,所有地方都可以读取
- 函数作用域:变量只在函数内部存在
当一个函数中嵌套另一个函数,它的作用域也会嵌套,一层层的作用域嵌套,就形成了作用域链。
如果一个变量或者其他表达式不在当前的作用域,那么 JS 机制会继续沿着一层一层作用域链往上查找,直到全局作用域(global 或浏览器中的 window)。如果找不到将不可被使用。
而在源代码执行前,会先经历编译过程。
编译过程
与传统的编译语言不同,JavaScript 不是提前编译的,大部分情况下编译发生在代码执行前的几微秒甚至更短的时间内。因此 JavaScript 引擎用了各种办法(比如 JIT,可以延迟甚至重编译)来保证性能最佳。
整个编译过程分为以下几步:
(1)词法分析
这个过程会将由字符组成的字符串分解成有意义的代码块,这些代码块被称为词法单元(token)。
比如 var a = 2
,这段程序会分解成这些词法单元:var
、a
、=
、2
。之间的空格是否当做词法单元取决于是否有意义。
(2)语法分析
这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树。这个树被称为“抽象语法树”(Abstract Syntax Tree,AST)。
这棵树定义了代码的结构,通过操纵这棵树,我们可以精准的定位到声明语句、赋值语句、运算语句等等,实现对代码的分析、优化、变更等操作。
举个例子:
var global1 = 1
上面这段代码的 AST 如下(Parser: acorn-6.1.1):
{
"type": "VariableDeclaration",
"declarations": [
{
"type": "VariableDeclarator",
"id": {
"type": "Identifier",
"name": "global1"
},
"init": {
"type": "Literal",
"value": 1,
"raw": "1"
}
}
],
"kind": "var"
}
举个更复杂的例子(详见——完整语法树):
var global1 = 1
function fn1(param1){
var local1 = 'local1'
var local2 = 'local2'
function fn2(param2){
var local2 = 'inner local2'
console.log(local1)
console.log(local2)
}
function fn3(){
var local2 = 'fn3 local2'
fn2(local2)
}
fn3() // 'local1'
// 'inner local2'
}
fn1()
如果只分析变量声明,AST 可以简化为如下的图:
整个分析过程是在静态阶段完成的,因此 fn3
中的 fn2
在语法分析阶段就已经确定了它的声明位置,并且在 fn1
调用的时候,明确了 fn2
的作用域是 fn1
的函数结构体内,fn3
的函数作用域并不会对其造成影响,因此打印的 local2
的值是 'inner local2'
而不是 'fn3 local2'
。
AST 常见的几种用途:
- 代码语法的检查、代码风格的检查、代码的格式化、代码的高亮、代码错误提示、代码自动补全等等
- 如JSLint、JSHint对代码错误或风格的检查,发现一些潜在的错误
IDE的错误提示、格式化、高亮、自动补全等等
- 如JSLint、JSHint对代码错误或风格的检查,发现一些潜在的错误
- 代码混淆压缩
- UglifyJS2等
- 优化变更代码,改变代码结构使达到想要的结构
- 代码打包工具webpack、rollup等等
CommonJS、AMD、CMD、UMD等代码规范之间的转化
CoffeeScript、TypeScript、JSX等转化为原生Javascript
- 代码打包工具webpack、rollup等等
(3)代码生成
将 AST 转换为可执行代码的过程。
代码生成就是上一个步骤得到的 AST 转化为机器指令,然后在内存中存储它们。
词法作用域
就是定义在词法阶段的作用域。变量的作用域是在定义时而非执行时决定,也就是说词法作用域取决于源码,通过静态分析就能确定,因此词法作用域也叫做静态作用域(
with
和eval
可以欺骗词法作用域)。
以 var a = 2
为例:
- 编译器遇到
var a
会询问作用域中是否有该名称的变量。如果是,忽略并继续编译;如果不是,在当前作用域声明变量,命名为a
。 - 引擎执行代码
a = 2
,会查询a
(LHS查询)并对其进行赋值。
查询分两种:
- LHS(Left Hand Side):查找目的是为变量赋值
- RHS(Right Hand Side):查找目的是获取变量的值
LHS 和 RHS 查询都会在当前执行作用域开始,如果没有找到所需标志符,就会向上级作用域继续查询,一级一级直到全局作用域。到了全局作用域,如果 RHS 查询失败抛出 ReferenceError,如果 LHS 查询失败会隐式创建一个全局变量(非严格模式)。
看个例子:
function foo(a) {
var b = a
return a + b
}
var c = foo(2)
- 引擎执行
var c = foo(2)
,会在作用域里查找(RHS)是否有foo
函数 - 找到后,将实参
2
赋值给形参a
(LHS,隐式变量分配) -
var b = a
,首先要先找到变量a
(RHS) - 将
a
的值赋值给b
(LHS) -
return a + b
,分别查找a
和b
的值(两次 RHS),然后返回 - 将
foo(2)
的结果赋值给c
(LHS)
全局作用域
以浏览器环境为例:
- 最外层函数和在最外层函数外面定义的变量拥有全局作用域
- 所有未定义直接赋值的变量自动声明为拥有全局作用域
- 所有 window 对象的属性拥有全局作用域
缺点:会污染全局命名空间。
解决方案:
- 立即执行函数(Immediately Invoked Function Expression, IIFE),因此很多库的源码都在使用
- 模块化 (ES6、commonjs 等等)
函数作用域
函数作用域指属于这个函数的全部变量都可以在整个函数范围内使用及复用。
function foo() {
let name = 'Shawn'
function sayName() {
console.log(`Hello, ${name}`)
}
sayName()
}
foo() // 'Hello, Shawn'
console.log(name) // 外部无法访问到内部变量
sayName() // 外部无法访问到内部函数
闭包
当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时就产生了闭包。
举个最简单的闭包(函数 + 函数内部能访问的变量):
var local = "变量"
function foo () {
console.log(local)
}
但这样 local
就暴露在了全局作用域中,其他函数也能访问。并且,这里只体现了可以访问,并没有“记住”。所以在闭包的基础上还需添加一些代码,使得变量 local
成为 foo
的局部变量,且 foo
能被外部访问到。一些实现方式:
(1)用立即执行函数封装,并将所需函数添加为 window 的全局变量
!function(){
var local = "变量"
window.foo = function (){
console.log(local)
}
}()
foo()
(2)匿名函数表达式,将所需函数当做参数返回
var a = function(){
var local = "变量"
function foo(){
console.log(local)
}
return foo
}
var myFoo = a()
myFoo() // 这就是闭包的效果
上面(2)中的例子里,因为需要访问局部变量 local
,设计了函数 foo
,根据嵌套函数“内部函数可以访问外部函数的参数和变量”的特点,foo
可以访问到局部变量 local
,然后在外部函数中,将 foo
作为参数返回。这样,当匿名函数被赋值给 a
,然后将 a
的执行结果赋值给 myFoo
,就等价于 myFoo = foo
,执行 myFoo
,就达到了记住并访问 foo
所在词法作用域的目的。
并且,在 a()
执行之后,由于闭包的存在,其内部作用域并不会被GC销毁,因为 foo()
在持续使用该内部作用域。
无论以何种方式将内部函数传递到所在词法作用域之外,它都会保持对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。
循环和闭包
来看个经典的例子:
for (var i = 1; i <=5; i++) {
setTimeout(function timer(){
console.log(i)
}, i * 1000)
}
初看这段代码时,我对这段代码的预期是:分别输出数字 1~5,每秒一次,一次一个。
然而实际运行结果是:输出五次 6,每秒一次。
Why?
首先,循环结束时 i
的值是 6,然后,延迟函数的回调会在循环结束时才执行。即使设置 setTimeout(..., 0)
,结果依然不变。
Why?
因为 setTimeout
是异步执行的,1000 毫秒后向任务队列里添加一个任务,只有主线程上的任务全部执行完毕才会执行任务队列里的任务,所以当主线程 for 循环执行完之后 i
的值为 6,而用这个时候再去任务队列中执行任务,因此 i
全部为 6。
又因为在 for 循环中使用 var
声明的 i
是在全局作用域中,那么全程都只有一个 i
,尽管循环中的 5 个函数都在各自的迭代中分别定义,然而它们共享这一个 i
的引用,因此 timer
函数中打印出来的 i
自然是都是 6。
那么,我们需要给循环中的每个迭代过程都设定一个闭包作用域。
试一下立即执行函数(IIFE)来解决。
第一次尝试:
for (var i = 1; i <=5; i++) {
!function () {
setTimeout(function timer(){
console.log(i)
}, i * 1000)
}()
}
然而这样并不能成功,因为匿名函数的作用域是空的,它并没有什么实质内容为我们所用。全程依然只有一个 i
。
第二次尝试:
for (var i = 1; i <=5; i++) {
!function () {
var j = i
setTimeout(function timer(){
console.log(j)
}, j * 1000)
}()
}
It worked! 但是代码看起来不太优雅。
第三次尝试:
for (var i = 1; i <=5; i++) {
!function (j) {
setTimeout(function timer(){
console.log(j)
}, j * 1000)
}(i)
}
这样,在迭代内使用 IIFE 会为每个迭代都生成一个新的作用域,使得延迟函数的回调可以将新的作用域封闭在每个迭代内部,每个迭代中都会含有一个具有正确值的变量供我们访问。
那么,ES6 之后这个问题是怎么解决的呢?首先想到let
,可以用来劫持块作用域,并且在块作用域内声明一个变量。
第四次尝试:
for (var i = 1; i <=5; i++) {
let j = i // 闭包的块作用域
setTimeout(function timer(){
console.log(j)
}, j * 1000)
}
那么,这是不是究极答案呢?先看代码:
第五次尝试:
for (let i = 1; i <=5; i++) {
setTimeout(function timer(){
console.log(i)
}, i * 1000)
}
最后这种写法,是现在的通用写法。
它是一个语法糖,并且其内部原理就是第四次尝试的写法代码。这里 i
的作用域只在 for(...)
的圆括号内,只不过每次迭代,JS 会自动重新声明一个 i
在 {...}
内,随后的每个迭代的 i
都会使用上一个迭代结束时的值来初始化。
闭包的用途
(1)存储、隐藏变量
闭包一大用途是读取函数内部的变量,并让这些变量始终保持在内存中,即闭包可以使得它诞生环境一直存在。并且由于是函数内部的变量,局部变量外部无法访问,也达到了隐藏的目的。
function createCounter(initial) {
var x = initial || 0
return {
inc: function () {
x += 1
return x
}
}
}
var c1 = createCounter()
c1.inc() // 1
c1.inc() // 2
c1.inc() // 3
var c2 = createCounter(1024)
c2.inc() // 1025
c2.inc() // 1026
c2.inc() // 1027
上例中,x
是函数 createCounter
的内部变量。通过闭包,x
的状态被保留了,每一次调用都是在上一次调用的基础上进行计算。inc
存在依赖于 createCounter
,因此也始终在内存中,不会在调用结束后,被垃圾回收机制回收。这就使得变量 x
达到了储存且隐藏的目的。
所以,闭包可以看作是函数内部作用域的一个接口。
(2)封装私有变量
由于 JavaScript 中的属性没有 public、private 这类的修饰符来控制访问,并且所有属性都需要在函数中定义,我们需要一些手段来达到变量私有化的目的。
var Foo = function () {
var _name = 'Frank'
this.getName = function () {
return _name
}
this.setName = function (str) {
_name = str
}
}
var foo1 = new Foo()
foo1.setName('Shawn')
var foo2 = new Foo()
foo2.setName('Givenchy')
foo1._name // undefined,外部无法直接访问局部变量,相当于“私有化”
foo1.getName() // 'Shawn'
foo2.getName() // 'Givenchy'
上例中,函数 Foo
的内部变量 _name
,通过闭包 setName
和 getName
,变成了返回对象 foo1
和 foo2
的私有变量,并且它们之间互不影响,互相独立。
更普遍地,本质上无论何时何地,如果将(访问它们各自词法作用域的)函数当作第一级的值类型并到处传递,就能看到闭包在这些函数中的应用。如在定时器、事件监听器、AJAX请求、跨窗口通信、Web Worders或者任何其他的异步(或同步)任务中,只要使用了回调函数,实际上就是在使用闭包。
性能
如果不是某些特定任务需要使用闭包,在其它函数中创建函数是不明智的,因为闭包在处理速度和内存消耗方面对脚本性能具有负面影响。
例如,在创建新的对象或者类时,方法通常应该关联于对象的原型,而不是定义到对象的构造器中。原因是这将导致每次构造器被调用时,方法都会被重新赋值一次(也就是,每个对象的创建)。
例如上例的封装私有变量的闭包改成在原型上定义更好:
var Foo = function () {
var _name = 'Frank'
Foo.prototype.getName = function () {
return _name
}
Foo.prototype.setName = function (str) {
_name = str
}
}
继承的原型可以为所有对象共享,不必在每一次创建对象时定义方法。
总结
Q:什么是作用域?
A:作用域是用于确定在何处以及如何查找变量的一套规则。Q:什么是作用域链?
A:当一个函数嵌套在另一个函数中时,就发生了作用域嵌套。如果在当前作用域下找不到某个变量,JS 引擎就会往外层嵌套的作用域继续查找,直到找到该变量或抵达全局作用域。如果在全局作用域中还没找到就会报错。这种逐级向上查找的模式就是作用域链。Q:什么是闭包?
A:当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时就产生了闭包。
后面等刷了一些题之后会挑出一些经典的题目总结一下,以备面试和加深之用。
参考: