本文来源于《你不知道的JavaScript》上卷,第一部分:作用域和闭包。有兴趣,有时间的同学,强烈建议直接去看书,书里的知识才是最全面最系统的,本文仅为个人书后总结。
一 作用域是什么?
变量的存储和查找的这一套规则被称为作用域
1.1 编译原理
js通常被认为是动态(解释执行)语言,但事实上它也是一门编译语言,但并不是提前编译的。 传统语言的编译分为三部分:
- 分词/词法分析(Tokenizing/Lexing): 将
var a = 2;分解成为、词法单元:var、 a、 =、 2 - 解析/语法分析(Parsing)::将词法单元流转化为抽象语法树(AST)
- 代码生成: 将 AST 转换为可执行代码的过程称被称为代码生成。
而对于js来讲,通常会在代码片段(通常以script标签为片段)执行前进行编译。
1.2 理解作用域
以 var a = 2 为例。该段代码执行需要有
- 引擎:负责js的编译和执行
- 编译器:负责语法分析及代码生成
- 作用域:负责收集并维护由所有变量的引用及使用规则。
执行过程
- 遇到
var a,编译器会查询作用域是否存在变量a,若存在,忽视该声明,若不存在,在该作用域声明一个变量a。 - 接下来编译器生成了用来执行
a = 2这个操作的代码,由引擎进行执行,引擎执行时,会在该作用域下进行查询,本例中,引擎会在本作用域下找到a,然后进行赋值。
1.3 作用域嵌套
上面我们所举的例子是在单作用域下进行的,引擎很顺利的在本作用域下找到了变量a,执行了赋值语句。那么如果该作用域下找不到变量a该怎么办呢? 比如:
function foo() {
a = 2;
}
foo()
这种情况下,我们没有在函数foo中声明a,那么编译器也不会去执行对a的声明,而是直接生成a=2的执行语句。那当引擎执行到 a = 2 时会发生什么?
1.3.1 LHS 和 RHS
在引擎执行的过程中会对变量进行查询,在a = 2中我们涉及到了LHS查询,因为a出现在赋值操作的左边。那么另外一种RHS的使用,很显然就是变量出现在赋值操作右边的时候。
- 相同点:
- 变量遮蔽:通常LHS和RHS都会从当前作用域开始层级一层一层往上找(最远到全局作用域),会在第一次查找到目标值之后返回,不会再继续往下查找,所以我们不会再获取到上层的同名变量。
- 变量遮蔽:通常LHS和RHS都会从当前作用域开始层级一层一层往上找(最远到全局作用域),会在第一次查找到目标值之后返回,不会再继续往下查找,所以我们不会再获取到上层的同名变量。
- 不同点:
LHS是查找变量的容器,也可以理解为指针,因为我们需要为它赋值。RHS查找则是简单的查找该变量中存储的值。- 当
RHS没有查找到时,会抛出ReferenceError异常;当LHS查找到全局作用域仍不成功时,会隐式的在全局作用域创建一个同名变量供LHS使用。(严格模式下则不会,直接报ReferenceError)
我们回过头来看刚才的例子
function foo() {
a = 2;
}
foo()
- 函数作用域总是包含在全局作用域中的,所以天然有一种嵌套的关系。
- 首先,引擎执行foo(),发现语句
a = 2对a进行LHS查找。 - LHS发现本层级作用域中没有a,接着去上一层(本例为全局作用域)中去找,同样的发现也没有。
- 由于目前已经到达了全局作用域,仍然没找到,那么LHS就会隐式的声明一个全局变量a作为目标值,去进行赋值操作。
我们最后来看一个例子,来看一下其中一共有哪些LHS/RHS查询。
function foo(a) {
var b = a;
return a + b;
}
var c = foo( 2 );
- 此段代码中,涉及到的LHS查询有 foo的形参
a = ...; b = ... ; c = ...;(形参的赋值也是一次LHS查询。) - 涉及到的RHS查询有:
... = a; ... + b; a + ...; ... = foo(2)
小结
- 作用域是变量存储和查找的一套规则。
- 当引擎开始执行语句时,会对变量进行LHS/RHS查找,若变量出现在赋值操作左边,执行LHS查找,否则执行RHS查找。LHS查找变量的容器(指针),RHS查找变量的值。
- 两种方式都是从本作用域依次向上查找,第一次查到即返回,否则到全局作用域以后终止。若查找失败,非严格模式下,LHS会声明一个全局同名变量并引用。否则同RHS一样报
ReferenceError
二 词法作用域
词法作用域就是词法阶段的作用域,是一种静态作用域,我由JavaScript书写的位置来决定的。
你可以将其理解为一个“对象”,你在函数(全局)内同层级所声明的变量都是它的属性。
以下面代码为例
function foo(a) {
var b = a * 2;
function bar(c) {
console.log( a, b, c );
}
bar( b * 3 );
}
foo( 2 ); // 2, 4, 12
以上代码存在三层的作用域嵌套,如下图所示。
- 1 代表全局作用域,全局作用域中只有foo函数。
- 2 代表foo函数中的作用域,包含了形参a, 变量b, 和函数bar
- 3 代表bar函数中的作用域,包含了形参c
我们在bar中输出了 a, b, c 相当于三个RHS查找。我们的词法作用域和代码执行时的作用域基本一致,书写位置确定了作用域的嵌套结构,加上我们之间所讲的作用域查找规则,我们就可以找到我们所需要的变量了。
结果也很简单:我们在bar中直接找到了c,在 foo作用域中找到了b和a
小结
- 词法作用域就是词法分析阶段的作用域,是一种静态作用域,意味着作用域是根据js书写的位置决定,与执行位置无关。它帮助我们确定了变量声明的位置及其声明方式,从而确定js在执行阶段如何对变量进行查找。
谨记词法作用域是由
书写位置决定的,是静态的作用域。与之相对应的另外一种是动态,由函数执行的位置决定。
三 作用域的特点
作用域实现了变量的私有化,规避了冲突,体现了软件设计中的最小暴露原则。作用域中的变量只能被内部作用域访问。外部作用域无法访问其内部(除非用闭包)。
3.1 函数作用域
通常我们使用函数来包装一段代码,就形成了一个作用域单元,该作用于单元内的变量和函数声明,都不可被上级所查找,实现了变量私有化。是软件设计中的最小暴露原则的体现。
- 规避冲突:
- 全局命名空间的冲突 :由于全局变量可以在任意作用域中被访问,所以容易被篡改替换,导致一些意想不到的结果,私有化变量可以解决这点。
- 模块管理:利用函数作用域的特点我们可以实现模块机制。
3.2 块级作用域
let 和 const 是es6中新增的声明变量的方式,跟原有的var的声明方式相比会有一些不同的区别
- var声明的全局变量会挂载在window上,let/const不会
- var具有变量提升,可以在未赋值前使用,值为undefined。let/const则无法使用(事实上let/const也被提升了,但是并没有被初始化赋值)
- 在块{..}中,var只会绑定到外部作用域中,let /const会绑定到块级作用域中。
其中最大的区别还是在于let/const可以声明一个块级作用域变量。类似于函数作用域,块级作用域内的变量同样无法被外部查找(let/const声明的变量)。最有名的实践应该就是for循环中嵌套异步函数。
小结
- 作用域内的命名空间无法被作用域外访问(除闭包)。
- 作用域单元除了函数包装,还有一种是块级也就是
{...}包装。 - 函数作用域中的变量,
let/const/var/function所声明的变量都是函数作用域内的私有化变量,无法被外界访问。 - 块级作用域中的变量,只有
let/const可以声明块级作用域变量,var声明不会被绑定在块内。(尽量不要在块中声明函数,会很怪异,有兴趣请看我另一篇文章)
四 闭包
在上面几节中,我们最后提到了闭包。看过了前面几节,再来理解闭包应该是比较容易的。
4.1 闭包的定义
- 上面我们了解了什么是词法作用域,作用域是由书写位置所决定的。我们通过词法作用域确定了变量的声明方式和位置。而闭包就是词法作用域的自然结果。
- 当函数可以记住并访问所在的词法作用域时, 就产生了闭包, 即使函数是在当前词法作用 域之外执行。
我们举个例子可能一下子就清楚了
function foo() {
var a = 1;
var b = 2;
function bar() {
a++;
b++;
console.log(a, b, a + b);
}
return bar;
}
let baz = foo();
baz() //2, 3, 5; 这就是闭包的效果。
正常来讲foo()执行过后,内部作用域的变量应该都被回收了,但是我们通过对bar的引用,讲foo的内部词法作用域进行了保存。这就形成了一个闭包。
bar所在的词法作用域中包含了a ,b,bar。foo函数通过return bar,在外部用baz对bar进行一个接收,使其可以在所在词法作用域外执行。bar依然可以正常使用其所在词法作用域的内的变量。即对其所在词法作用域进行了引用保存。
4.2 for循环和闭包
我们再看一下其他情况下的闭包使用
for (var i=1; i<=5; i++) {
setTimeout( function timer() {
console.log( i );
}, i * 1000 );
}
最终结果大家可能都知道,都为6。
- 原因是因为setTimeout所在的词法作用域是空的,所以
{..}中并没有变量可以用来保存。 - 但是他们都处在同一个全局作用域下,所以当
setTimeout中的函数执行时,查找不到当前所在词法作用域的变量,只能到全局查找 i,由于i是全局变量,此时早已被for循环更改为6,所以最后都输出的是6
那么如果输出我们的预期值呢?当前所在的词法作用域没有变量,那么声明一个对i进行保存不就好了。
for (var i=1; i<=5; i++) {
let j = i;
setTimeout( function timer() {
console.log( j );
}, j * 1000 );
}
如上我们对 i 的值进行了一个引用,并将其绑定在{...}块作用域中。
那么通常我们不会这么写,而是直接
for 循环头部的 let 声明还会有一个特殊的行为。 这个行为指出变量在循环过程中不止被声明一次, 每次迭代都会声明。 随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量。
for (let i=1; i<=5; i++) {
setTimeout( function timer() {
console.log( i );
}, i * 1000 );
}
4.3 节流和防抖
我们再来简单的实现一下节流和防抖函数
- 节流
function throttle(callback, duration){
let start = 0;
function fn(...args) {
let now = +new Date();
if (now - start > duration) {
start = now;
callback.apply(this, args)
}
}
return fn;
}
- 防抖
function debounce (func, wait = 50){
// 缓存一个定时器id
let timer = 0;
function fn(...args) {
if (timer) clearTimeout(timer)
timer = setTimeout(function timerFn() {
func.apply(this, args)
}, wait)
}
return fn;
}
小结
- 所以,闭包的概念很简单,就是对词法作用域的引用和保存。
- 常见的创建方法就是通过引用 作用域
内部返回的函数,来实现对作用域内部词法作用域的使用。 - 另外闭包的最大应用就是模块,我们可以使用闭包轻松实现模块机制。
五 提升
回看我们之前第一部分所讲的, js在执行之前会经过编译. 变量提升就发生在这个阶段.
5.1 变量提升
var a = 2
这条声明实际上会分为两部分var a; 和 a = 2;。 第一个定义声明是在编译阶段进行的。 第二个赋值声明会被留在原地等待执行阶段。
var a;
a = 2;
函数内部作用域, 也同样会有变量提升, 也就是说变量提升是以作用域为范围的.
5.2 函数提升
函数声明同样也会提升, 不同的是函数提升,是整个函数声明包括函数体的提升.
foo(); // 1
function foo() {
console.log( 1
}
函数调用写在函数声明之前,同样有效
5.2.1 函数表达式
函数表达式并不会提升函数体.
foo(); // TypeError
var foo = function() {
console.log( 1
}
以上代码会被编译为
var foo;
foo(); // TypeError
foo = function() {
console.log( 1
}
所以foo的值为undefined, 提前执行会发生类型错误
5.2.2 函数优先
函数声明和变量声明都会提升,但是函数声明会比变量声明的优先级更高.
foo(); // 1
var foo;
function foo() {
console.log( 1 );
}
foo = function() {
console.log( 2 );
};
以上代码会被编译为
function foo() {
console.log( 1 );
}
foo(); // 1
foo = function() {
console.log( 2 );
};
变量声明被忽略了, 因为像我们之前讲的, 编译器发现作用域中已经声明了该变量, 就会忽略var声明. 因为函数优先提升在了变量声明之前.
小结
- var a = 2 会被分解成 var a; 和 a = 2; 一个是编译任务,一个是执行任务. 所以无论声明写在哪里,都会被提升到各自作用域的顶部.
- 函数声明的提升比变量声明的优先级更高. 并且函数声明提升整个函数声明.
- 含有声明的赋值表达式并不会提升, 包括函数表达式声明.