重回作用域和闭包

最近阅读了《你不知道的JavaScript上卷》第一章-作用域和闭包,想着怎么也得写个读书笔记,不然不就白看(会忘)了嘛哈哈哈

闭包在之前b站学过,当时也写了一篇笔记,这次在这本书中,似乎读出了当时在学的味道,不论是内容、还有顺序,都蛮有一种熟悉感,书和视频一样,都是从作用域(书中是词法作用域)入手,包括预解析,最后再聊到闭包,以及在章末引出下一章的知识点-好多人摸不着头脑的this。总的来说,两者一起结合看会让你收获颇丰,视频有着不一样的代码实践和轻松的bgm,以及一些解释性弹幕,可能会让你更快上手,书中则更为详细的讲解了作用域以及变量提升的知识点,从编译器、JavaScript引擎,到变量提升、LHS 和 RHS,专业名词多,但书中也不失幽默,无论是先看视频,还是先看书,都会让你对JavaScript这门语言,不敢说大开,也能’小‘开眼界。

笔记针对之前所学进行补充

编译原理

程序中的一段源代码在执行之前会经历三个步骤

  1. 分词/词法分析(Tokenizing/Lexing)

    这个过程会将由字符组成的字符串分解成(对编程语言来说)有意义的代码块,这些代码块被称为词法单元(token)

  2. 解析/语法分析(Parsing)

    这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法 结构的树。这个树被称为“抽象语法树”(Abstract Syntax Tree,AST)

  3. 代码生成

    AST 转换为可执行代码的过程称被称为代码生成。这个过程与语言、目标平台等息息相关。 抛开具体细节,简单来说就是有某种方法可以将 var a = 2; 的 AST 转化为一组机器指令,用来创建一个叫作 a 的变量(包括分配内存等),并将一个值储存在 a 中。

LHS和RHS

如果查找的目的是对变量进行赋值,那么就会使用 LHS 查询;如果目的是获取变量的值,就会使用 RHS 查询

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

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

词法作用域

JavaScript使用的是词法作用域。

定义:词法作用域就是定义在词法阶段的作用域。换句话说,词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域 不变(大部分情况下是这样的)

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

遮蔽效应:在多层的嵌套作用域中可以定义同名的标识符,这叫作“遮蔽效应”(内部的标识符“遮蔽”了外部的标识符)。

eval

定义:JavaScript 中的 eval(..) 函数可以接受一个字符串为参数,并将其中的内容视为好像在书 写时就存在于程序中这个位置的代码。

如果 eval(..) 中所执行的代码包含有一个或多个声明(无论是变量还是函数),就会对 eval(..) 所处的词法作用域进行修改,且是在运行期修改书写期的词法作用域,这会严重影响性能,下面介绍的with同理。

setTimeout(..) 和 setInterval(..) 的第一个参数可以是字符串,字符串的内容可以被解释为一段动态生成的 函数代码。这些功能已经过时且并不被提倡。不要使用它们。

with

尽管 with 块可以将一个对象处理为词法作用域,但是这个块内部正常的 var 声明并不会被限制在这个块的作用域中,而是被添加到 with 所处的函数作用域中。

不推荐使用 eval(..) 和 with 的原因是会被严格模式所影响(限制)。with 被完全禁止,而在保留核心功能的前提下,间接或非安全地使用 eval(..) 也被禁止了。

JavaScript 引擎会在编译阶段进行数项的性能优化。其中有些优化依赖于能够根据代码的 词法进行静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到标识符。

如果引擎在代码中发现了 eval(..) 或 with,它只能简单地假设关于标识符位置的判断都是无效的,因为无法在词法分析阶段明确知道 eval(..) 会接收到什么代码,这些代码会如何对作用域进行修改,也无法知道传递给 with 用来创建新词法作用域的对象的内容到底是什么。

总而言之:这两个机制的副作用是引擎无法在编译时对作用域查找进行优化。

函数作用域

声明在一个函数内部的变量或函数会在所处的作用域中“隐藏”起来,这是有意为之的良好软件的设计原则。

最小特权原则:也叫最小授权或最小暴露原则。这个原则是指在软件设计中,应该最小限度地暴露必 要内容,而将其他内容都“隐藏”起来,比如某个模块或对象的 API 设计

变量提升

引擎会在解释 JavaScript 代码之前首先对其进行编译。编译阶段中的一部分工作就是找到所有的 声明,并用合适的作用域将它们关联起来。

只有声明本身会被提升,而赋值或其他运行逻辑会留在原地。

函数声明会被提升,但是函数表达式却不会被提升。

image-20220428103103715

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

我们习惯将 var a = 2; 看作一个声明,而实际上 JavaScript 引擎并不这么认为。它将 var a 和 a = 2 当作两个单独的声明,第一个是编译阶段的任务,而第二个则是执行阶段的任务。无论作用域中的声明出现在什么地方,都将在代码本身被执行前首先进行处理。声明本身会被提升,而包括函数表达式的赋值在内的赋值操作并不会提升。

闭包

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

  1. 无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用 域的引用,无论在何处执行这个函数都会使用闭包。
  2. 无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用 域的引用,无论在何处执行这个函数都会使用闭包。
1
2
3
4
5
6
7
8
function foo() {
var a = 2;
function bar() {
console.log(a); // 2
}
bar();
}
foo();

bar() 对 a 的引用的方法是词法作用域的查找规则,而这些规则只是闭包的一部分。(但却是非常重要的一部分!

模块

模块模式需要具备两个必要条件

  1. 必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块实例)。
  2. 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并 且可以访问或者修改私有的状态。

一个具有函数属性的对象本身并不是真正的模块。从方便观察的角度看,一个从函数调用 所返回的,只有数据属性而没有闭包函数的对象并不是真正的模块。

动态作用域

我们知道:JavaScript 中的作用域就是词法作用域。

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

根据词法作用域,我们知道:词法作用域让 foo() 中的 a 通过 RHS 引用到了全局作用域中的 a,因此会输出 2。

但是动态作用域并不关心函数和作用域是如何声明以及在何处声明的,只关心它们从何处调 用。换句话说,作用域链是基于调用栈的,而不是代码中的作用域嵌套。因此,上面的foo() 在执行时将会输出 3。因为当 foo() 无法找到 a 的变量引用时,会顺着调用栈在调用 foo() 的地 方查找 a,而不是在嵌套的词法作用域链中向上查找。由于 foo() 是在 bar() 中调用的, 引擎会检查 bar() 的作用域,并在其中找到值为 3 的变量 a。

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

ES5如何创造块作用域

我们知道在ES6种如何创造块作用域:

1
2
3
4
5
{
let a = 2;
console.log(a); // 2
}
console.log(a); // ReferenceError

但是在ES5中,我们如何去创建块作用域呢,使用catch!

1
2
3
4
try{throw 2;}catch(a){
console.log( a ); // 2
}
console.log( a ); // ReferenceError