You Don't Know JavaScript I —— Scope & Closures

题记

这本书前后通读了两遍,是我看过的所有前端书籍📚中(虽然没全读完过几本,基本都是翻几页感觉内容没吸引到我就撇一边了。。。),内容质量最赞,最值得反复阅读体会的一本书,而且每次重读,会因为时间阅历不同都会有不同的体会和领悟,强烈推荐!!!

之后一定会不止两遍的重复阅读,而且还要读英文原著,先立Flag。

为什么会读了两遍?读第一次,哇,这内容讲的好深入,发现自己之前对JS的理解和应用都是浮于表面。后来因为找实习写论文一些事情,没有全看完。等到我再拾起来看之前看过的内容时,发现自己对看过的内容根本没吸收多少,所以就又重读了一遍。而且读第二遍的时候做了读书笔记,因为是图书馆借的书,没敢在书上乱画,所以写了markdown记录自己读时觉得值得重复研读的知识点。

第一部分 作用域和闭包

第1章 作用域是什么

1.1 编译原理

JS是一门编译语言,JS引擎进行编译过程。

编译三步骤:

  • 词法分析:比如var a = 2;会被分解成var, a, blank,=, 2, ;这些词法单元。
  • 语法分析:将词法单元转换成抽象语法树AST。
  • 代码生成:将AST转换为可执行代码,上例则将AST转换成一组机器指令,用来创建变量a(包括分配内存),并将一个值存储在a中。

JS引擎的编译过程除以上三个步骤外,还有对运行性能的优化等。

1.2 理解作用域

var a = 2;的编译器处理过程:

  • 遇到var a,编译器询问作用域是否已经有一个该名称的变量存在于同一个作用域中。如果是,编译器会忽略该声明,继续编译;否则会要求作用域在当前作用域中声明一个新的变量,并命名为a。
  • 接下来编译器会为引擎生成运行时所需的代码,这些代码用来处理a = 2这个赋值操作。引擎运行时会首先询问作用域,是否存在叫做a的变量,如果有则使用这个变量,如果没有引擎会继续查找该变量。

编译时的LHS(赋值操作的目标是谁)RHS(谁是赋值操作的源头)查询:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function foo(a){
console.log(a);
}
foo(2);
// 上述代码处理过程
引擎:我需要为foo进行RHS引用
作用域:编译器刚声明了foo函数
引擎:我要执行foo,我需要对a进行LHS引用
作用域:编译器把a声明为foo的一个形式参数了
引擎:我要把2赋给a
引擎:我需要为console进行RHS引用
作用域:console是个内置对象,给你
引擎:我看console中是不是有log方法,找到了是个函数
引擎:帮我找一下对a的RHS引用
作用域:变量a在这里
引擎:我需要a的值

1.3 作用域嵌套

在当前作用域中无法找到某个变量时,引擎会在外层嵌套的作用域中继续查找,直到找到该变量或直到最外层作用域(全局作用域)。

1.4 异常

区分LHS和RHS:

  1. 如果RHS查询在所有嵌套的作用域中遍寻不到所需的变量,引擎就会抛出ReferenceError异常。

  2. 当引擎执行LHS查询时,如果在顶层全局作用域中也无法找到目标变量,全局作用域中就会创建一个具有该名称的变量,并将其返还给引擎。前提是程序运行在非严格模式下。严格模式下LHS查询失败时,并不会创建并返回一个全局变量,引擎会抛出ReferenceError异常。

1
2
3
4
5
function foo(a){
console.log(a+b); // ReferenceError: b is not defined -> RHS
b = a; // LHS
}
foo(2);

ReferenceError同作用域判别失败相关,而TypeError则代表作用域判别成功但是对结果的操作是非法或不合理的。

1.5 小结

作用域是一套规则,用于确定在何处以及如何查找变量(标识符)。如果查找的目的是对变量进行赋值,那么就会使用LHS查询;如果目的是获取变量的值,就会使用RHS查询。赋值操作会导致LHS查询。=操作符或调用函数时传入参数的操作都会导致关联作用域的赋值操作。

JS引擎首先会在代码执行前对其进行编译,在这个过程中,像var a = 2这样的声明会被分解成两个独立的步骤:

  1. 首先,var a在其作用域中声明新变量。这会在最开始的阶段,也就是代码执行前进行。

  2. 接下来,a = 2会查询(LHS查询)变量a并对其进行赋值。

LHS和RHS查询都会在当前执行作用域中开始,如果有需要(也就是说它们没有找到所需的标识符),就会向上级作用域继续查找目标标识符,这样每次上升一级作用域,最后抵达全局作用域,无论找到或没找到都将停止。

不成功的RHS引用会导致抛出ReferenceError异常。不成功的LHS引用会导致自动隐式地创建一个全局变量(非严格模式下),该变量使用LHS引用的目标作为标识符,或者抛出ReferenceError异常(严格模式下)。

第2章 词法作用域

作用域有两种工作模型:词法作用域、动态作用域。

2.1 词法阶段

词法作用域是在写代码或者定义时确定的,而动态作用域是在运行时确定的。词法作用域关注函数在何处声明,而动态作用域关注函数从何处调用(this也是)。

1
2
3
4
5
6
7
8
9
10
11
function foo(){
console.log(a);
}
function bar(){
var a = 3;
foo();
}
var a = 2;
bar();
// 词法作用域 foo输出 2
// 动态作用域 foo输出 3

遮蔽效应:多层嵌套的作用域中可以定义同名的标识符,内部的标识符会遮蔽外部的标识符。非全局变量被遮蔽无法访问到,全局变量被遮蔽可以通过对全局对象属性的引用window.a来进行访问。

词法作用域的查找只查找一级标识符,比如obj.name,会先查找obj标识符,找到这个变量后,再按照对象访问规则对属性进行访问。

2.2 欺骗词法

词法作用域完全由写代码时函数做声明的位置来定义,可以采用下面两种机制来实现修改词法作用域。欺骗词法作用域会导致性能下降。

2.2.1 eval

eval(..)接收一个字符串为参数,并执行其中的JS代码。

1
2
3
4
5
6
function foo(str, a){
eval(str); // cheat!
console.log(a, b);
}
var b = 2;
foo("var b = 3;", 1); // 1, 3

eval在函数foo的作用域内声明了一个新的变量b,对已存在的foo(..)词法作用域进行了修改,遮蔽了外部作用域中的同名变量。

严格模式下,eval在运行时有自己的词法作用域,也就无法修改所在的作用域。

1
2
3
4
5
6
function foo(str){
"use strict";
eval(str);
console.log(a); // ReferenceError: a is not defined
}
foo("var a = 2;");

JS中还有一些方法和eval(..)有相似的功能:

  1. setTimeout、setInterval的第一个参数也可以是字符串,字符串可以被解释为动态生成的代码,已过时不提倡使用。

  2. new Function()最后一个参数也可以接受代码字符串,并将其转化为动态生成的函数

    1
    2
    var add = new Function('a', 'b', 'return a + b;');
    add(2, 6);

面试题:当没有JSON对象时,怎么将一个JSON字符串str = "{"a":1,"b":"kad"}"转换成JS对象?

1
2
3
1. eval('(' + str + ')');
2. var fn = new Function("return " +str);
fn();

2.2.2 with

with用于重复引用同一个对象的多个属性时的快捷方式,不需要重复引用对象本身。

1
2
3
4
5
6
7
8
9
10
11
12
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); // undefind
console.log(a); // 2 -> 不好,a泄漏到全局作用域中了

在with块内部,对变量a进行的是LHS引用,所以当传递o2给with时,with所声明的作用域是o2,o2的作用域、foo的作用域和全局作用域中都没有找到标识符a,则自动创建了全局变量a(因为是非严格模式)。

2.2.3 性能

eval和with会在运行是修改或创建新的作用域,以此来欺骗书写定义时的词法作用域。

JS引擎会在编译阶段进行性能优化,其中有些优化依赖于能够根据代码的词法进行静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到标识符。但引擎如果在代码中发现了eval或with,无法在词法分析阶段明确知道eval会接收到什么代码,这些代码会如何对作用域进行修改,也无法知道传递给with用来创建新词法作用域的对象内容。这样所有的优化可能都是无意义的,也就完全不做任何优化。如果代码中大量使用eval或with,运行起来一定会非常慢。

2.3 小结

词法作用域意味着作用域是由书写代码时函数声明的位置来决定的。编译的词法分析阶段基本能够知道全部标识符在哪里以及是如何声明的,从而能够预测在执行过程中如何对它们进行查找。

JS中有两个机制可以欺骗词法作用域:eval和with。前者可以对一段包含一个或多个声明的代码字符串进行演算,并借此来修改已经存在的词法作用域(在运行时)。后者本质上是通过将一个对象的引用当作作用域来处理,将对象的属性当作作用域中的标识符来处理,从而创建了一个新的词法作用域(同样是在运行时)。

这两个机制的副作用是引擎无法在编译时对作用域查找进行优化,因为引擎智能谨慎地认为这样的优化是无效的。使用这其中任何一个机制都将导致代码运行变慢。不要使用。

第3章 函数作用域和块作用域

3.1 函数中的作用域

1
2
3
4
5
6
function foo(){
function bar(){
// ...
}
}
bar(); // error

bar是属于foo的作用域,从foo外部无法对其进行访问。

3.2 隐藏内部实现

最小授权或最小暴露原则 :应该最小限度地暴露必要内容,而将其他内容都隐藏起来,比如某个模块或对象的API设计。

变量冲突的一个典型例子存在与全局作用域中。当程序中加载了多个第三方库时,如果他们没有妥善地将内部私有的函数或变量隐藏起来,就会很容易引发冲突。

3.3 函数作用域

具名函数的名称本身就会污染所在作用域,而且必须显式地调用才能运行。如何能够不需要函数名,并且自动运行?

JS中提供了立即执行函数表达式来解决:

1
2
3
4
(function foo(){
var a = 3;
console.log(a); // 3
})();

区分函数声明和表达式:看function关键字出现在声明中的位置,如果是声明中的第一个词就是函数声明,否则就是函数表达式。(function foo(){..})作为函数表达式意味着foo只能在..所代表的位置中被访问,外部作用域则不行。foo变量名被隐藏在自身中以为这不会非必要的污染外部作用域。

3.3.1 匿名和具名

函数表达式可以是匿名的,而函数声明则不可以省略函数名。所以下方是匿名函数表达式。

1
2
3
setTimeout(function(){
// ...
}, 0);

如果没有函数名,当函数需要应用自身时只能使用arguments.callee引用(指向正在执行的函数的指针),比如递归。另一个函数需要引用自身的例子,是在事件触发后事件监听器需要解绑自身。

3.3.2 立即执行函数表达式

1
(function(){..})();

IIFE(Immediately Invoked Function Expression) :函数被包含在第一个括号内,成为了一个表达式,末尾再加上一个括号可以理解执行这个函数。

IIFE的几种写法:

  1. 1
    (function(){..}())
  2. 1
    2
    // 当作函数调用并传递参数进去
    (function(global){..})(window);
  3. 1
    2
    3
    4
    5
    6
    7
    8
    // 解决undefined标识符的默认值被错误覆盖导致的异常
    undefined = true; // warn
    (function(undefined){
    var a;
    if(a === undefined){
    console.log("undefined is safe here");
    }
    })();
  4. 1
    2
    3
    4
    5
    6
    7
    8
    9
    // 倒置代码的运行顺序,将需要运行的函数在IIFE执行之后当作参数传递进去
    var a = 2;
    (function(def){
    def(window);
    })(function(global){
    var a = 3;
    console.log(a); // 3
    console.log(global.a); // 2
    });
  5. 1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    // jQuery源码整体架构就是采用了IIFE的第4中写法,将需要运行的函数当作参数传递
    ;(function(global, factory) {
    factory(global);
    }(typeof window !== "undefined" ? window : this, function(window, noGlobal) {
    var jQuery = function( selector, context ) {
    return new jQuery.fn.init( selector, context );
    };
    jQuery.fn = jQuery.prototype = {};
    // 核心方法
    // 回调系统
    // 异步队列
    // 数据缓存
    // 队列操作
    // 选择器引
    // 属性操作
    // 节点遍历
    // 文档处理
    // 样式操作
    // 属性操作
    // 事件体系
    // AJAX交互
    // 动画引擎
    return jQuery;
    }));

3.4 块作用域

块作用域也应遵循最小授权原则。

1
2
3
4
// 比如变量i只在for循环内部使用,但却会污染到外部作用域中
for(var i = 0; i < 10; i++){
console.log(i);
}

JS中块作用域的相关功能:

3.4.1 with

with也是块作用域,用with从对象中创建出的作用域仅在with声明中而非外部作用域中有效。

3.4.2 try/catch

try/catch的catch分句会创建一个块作用域,其中声明的变量仅在catch内部有效。

1
2
3
4
5
6
try{
throw "TestError"; // 抛出一个异常
}catch(err){
console.log(err); // TestError
}
console.log(err); // ReferenceError: err is not defined

try/catch中的this、return、break、continue的含义不会发生变化,IIFE则会改变代码的含义。

3.4.3 let

let关键字声明的变量以大括号{..}为块作用域,而且使用let进行的声明不会在块作用域中进行提升。

1
2
3
4
5
6
{
console.log(a); // ReferenceError
var a = 2;
console.log(a); // 2
}
console.log(a); // ReferenceError
  1. 垃圾收集: 块作用域非常有用的原因和闭包及回收内存垃圾的回收机制有关。闭包覆盖的作用域中所有的结构都不会被引擎回收,块作用域可以让引擎清楚的知道没有必要再保留哪些结构。

  2. let循环: for循环头部的let将i绑定到了for循环的块中,而且是绑定到了循环的每一个迭代中,确保使用上一个循环迭代结束时的值重新进行赋值。

    1
    2
    3
    4
    for(let i = 0; i < 10; i++){
    console.log(i);
    }
    console.log(i); // ReferenceError

3.4.4 const

const可以用来创建块作用域变量,原文中说任何试图修改值的操作都会引起错误TypeError,个人感觉这里不严谨,const定义的引用类型的变量时可以修改的,只是不能修改变量类型。

3.5 小结

函数是JS中最常见的作用域单元,声明在一个函数内部的变量或函数会在所处的作用域中隐藏起来,这是良好软件的设计原则。但函数不是唯一的作用域单元。块作用域指的是变量和函数不仅可以属于所处的作用域,也可以属于某个代码块(通常指{..}内部)。

从ES3开始,try/catch结构在catch分句中具有块作用域。

在ES6中引入了let关键字,用来在任意代码块中声明变量。if(){let a = 2;}会声明了一个劫持了if的{..}块的变量,并且将变量添加到这个块中。

第4章 提升

4.1 先有鸡还是先有蛋

直觉上认为JS是从上到下一行一行执行的,考虑一下代码:

1
2
3
a = 2;
var a;
console.log(a); // 2

声明在前还是赋值在前?

4.2 编译器再度来袭

正确的思考思路是,包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理。

var a = 2;在JS看来是两部分var a;a = 2;,第一个定义声明是在编译阶段进行的,第一个赋值声明会被留在原地等待执行阶段。也就是先有声明后有赋值

1
2
3
4
5
foo(); // TypeError
bar(); // ReferenceError
var foo = function bar(){
// ...
}

函数表达式不会被提升,变量标识符foo被提升,所以调用foo()不会导致ReferenceError,但是没有赋值所以抛出TypeError。

具名函数表达式的作用:目前了解到的是函数表达式中的函数名只能用在函数内部,外部无法引用。

1
2
3
4
5
var foo = function bar(){
// ...
}
foo(); // 正常运行
bar(); // ReferenceError: bar is not defined

4.3 函数优先

1
2
3
4
5
6
7
8
foo(); // 1
var foo;
function foo(){
console.log(1);
}
foo = function(){
console.log(2);
}

尽管var foo出现在function foo()...的声明之前,但它是重复的声明因此被忽略了,函数声明会被提升到普通变量之前

4.4 小结

我们习惯将var a = 2;看作一个声明,而JS引擎将var aa = 2当作两个单独的声明,第一个是编译阶段的任务,第二个是执行阶段的任务。

这意味着无论作用域中的声明出现在什么地方,都将在代码本身被执行前首先进行处理,可以将这个过程形象地想象成所有的声明(变量和函数)都会被“移动”到各自作用域的最顶端,这个过程被称为提升。

声明本身被提升,而包括函数表达式的赋值在内的赋值操作并不会提升。

要注意避免重复声明,特别是当普通的声明和函数声明混合在一起的时候。

第5章 作用域闭包

5.1 启示

JS中闭包无处不在,你只需要能够识别并拥抱它。

5.2 实质问题

1
2
3
4
5
6
7
8
9
function foo(){
var a = 2;
function bar(){ // 函数bar的词法作用域能够访问foo()的作用域
console.log(a);
}
return bar;
}
// 实际是通过不同的标识符引用调用了内部的函数bar
baz(); // 2

在foo()执行后,通常会期待函数foo的整个内部作用域都被销毁,而闭包的神器之处就是阻止这件事的发生。变量baz也就是bar声明的位置,是拥有涵盖foo内部作用域的闭包,使得该作用域能够一直存活,以供bar在之后任何时间进行引用。

5.3 现在我懂了

1
2
3
4
5
6
function wait(msg){
setTimeout(function timer(){
console.log(msg);
}, 1000);
}
wait("hello, closure!");

内部函数timer涵盖了wait(..)的作用域,保有对变量msg的引用。wait在执行1000ms之后,内部作用域不会消失,timer函数依然保有对wait作用域的引用。这就是闭包。

在定时器、事件监听器、Ajax请求、跨窗口通信、Web Workers或者任何其他的异步或同步任务中,只要使用了回调函数,实际上就是在使用闭包。

函数在定义时的词法作用域以外的地方被调用,闭包使得函数可以继续访问定义使的词法作用域。

5.4 循环和闭包

1
2
3
4
5
6
7
for(var i = 1; i <= 5; i++){
setTimeout(function timer(){
console.log(i);
}, i * 1000);
}
// 以每秒一次的频率输出五次6
// 循环的终止条件是i=6,所有函数共享一个i的引用,

解决方法:把每次循环的i都封闭在一个作用域中,通过IIFE被每个迭代生成一个新的作用域

1
2
3
4
5
6
7
for(var i = 1; i <= 5; i++){
(function(i){
setTimeout(function timer(){
console.log(i);
}, i*1000);
})(i);
}

重返块作用域

每次迭代需要一个块作用域,可以使用第3章中介绍的。比如使用let声明,特殊之处在于,变量在循环过程中不止被声明一次,每次迭代都会声明,随后都会使用上一个迭代结束时的值来初始化这个变量。

5.5 模块

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function CoolModule(){
var something = "cool";
var another = [1,2,3];
function doSomething(){
console.log(something);
}
function doAnother(){
console.log(another.join('!'));
}
return {
doSomething: doSomething,
doAnother: doAnother
};
}
var foo = CoolModule();
foo.doSomething(); // cool
foo.doAnother(); // 1!2!3

必须通过调用CoolModule创建一个模块实例,如果不执行外部函数,内部作用域和闭包都无法被创建。

改为单例模式,替代调用CoolModule():

1
2
3
4
5
6
7
var foo = (function(){
// ...
return {
doSomething: doSomething,
doAnother: doAnother
};
})();

5.5.1 现代的模块机制

5.5.2 未来的模块机制

ES6中的模块

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//bar.js
function hello(){
return "bar";
}
export hello;
// foo.js
// 仅从bar模块导入hello
import hello from "bar"
function awesome(){
console.log(hello());
}
export awesome;
// baz.js
// 导入完整的foo和bar模块
module foo from "foo";
module bar from "bar";
console.log(bar.hello());
foo.awesome();

模块文件中的内功会被当作好像包含在作用域闭包中一样来处理。

5.6 小结

闭包就好像从JS中分离出来的一个充满神秘色彩的未开化世界,只有最勇敢的人才能够到那里。但实际上它只是一个标准,显然就是关于如何在函数作为值按需传递的词法环境中书写代码的。

当函数可以记住并访问所在的词法作用域,即时函数是在当前词法作用域之外执行,这时就产生了闭包。

如果没能认出闭包,也不了解它的工作原理,在使用它的过程中就很容易犯错,比如在循环中。但同时闭包也是一个非常强大的工具,可以用多种形式来实现模块等模式。

模块有两个主要特征:(1)为创建内部作用域而调用了一个包装函数;(2)包装函数的返回值必须至少包括一个对内部函数的引用,这样就会创建涵盖整个包装函数内部作用域的闭包。

文章目录
  1. 1. 题记
  • 第一部分 作用域和闭包
    1. 1. 第1章 作用域是什么
      1. 1.1. 1.1 编译原理
      2. 1.2. 1.2 理解作用域
      3. 1.3. 1.3 作用域嵌套
      4. 1.4. 1.4 异常
      5. 1.5. 1.5 小结
    2. 2. 第2章 词法作用域
      1. 2.1. 2.1 词法阶段
      2. 2.2. 2.2 欺骗词法
        1. 2.2.1. 2.2.1 eval
        2. 2.2.2. 2.2.2 with
        3. 2.2.3. 2.2.3 性能
      3. 2.3. 2.3 小结
    3. 3. 第3章 函数作用域和块作用域
      1. 3.1. 3.1 函数中的作用域
      2. 3.2. 3.2 隐藏内部实现
      3. 3.3. 3.3 函数作用域
        1. 3.3.1. 3.3.1 匿名和具名
        2. 3.3.2. 3.3.2 立即执行函数表达式
      4. 3.4. 3.4 块作用域
        1. 3.4.1. 3.4.1 with
        2. 3.4.2. 3.4.2 try/catch
        3. 3.4.3. 3.4.3 let
        4. 3.4.4. 3.4.4 const
      5. 3.5. 3.5 小结
    4. 4. 第4章 提升
      1. 4.1. 4.1 先有鸡还是先有蛋
      2. 4.2. 4.2 编译器再度来袭
      3. 4.3. 4.3 函数优先
      4. 4.4. 4.4 小结
    5. 5. 第5章 作用域闭包
      1. 5.1. 5.1 启示
      2. 5.2. 5.2 实质问题
      3. 5.3. 5.3 现在我懂了
      4. 5.4. 5.4 循环和闭包
      5. 5.5. 5.5 模块
        1. 5.5.1. 5.5.1 现代的模块机制
        2. 5.5.2. 5.5.2 未来的模块机制
      6. 5.6. 5.6 小结
  • |