发布时间:2021年7月9日
趁着在家休假的时间,重新翻开手头的《你不知道的JavaScript》这本书。相较于19年初读时,对一些概念有了更深的理解,对里面的一些内容多了些自己的思考。于是,准备把这些内容和自己思考整理成文章,记录下来,以供随时翻阅
首先纠正一个比较常见的认知错误。在很多文章中,把 JavasScript 归类为解释型语言或脚本语言。实际上 JavaScript 应该是编译语言。只是我们通常认为的编译语言如,C语言、C++语言、Java 等,都是在打包时完成编译并运行在不同环境中;而 JavaScript 属于运行时编译语言,即在浏览器中运行时由 JavaScript 引擎完成编译工作
具体的编译过程分为以下三步:
var a = 2;
会被拆解成 var
、a
、=
、 2
、 ;
这几个部分AST
我们在日常开发中已经听过不少应用场景了,比如 Webpack 中 loader、plugin 的开发,比如 babel 用 AST
实现我们代码的转换,我们常用的框架中也会用其自己的 运行时库(runtime
)来处理特定的逻辑理解了 JavaScript 在浏览器中转换和执行的过程后,我们就可以进一步的去了解作用域相关的内容了
首先我们先看一个代码,后续我们会结合这个代码进行理解:
var a = 2;
console.log(a); // 2
var a;
console.log(a); // a是多少?
在上面的代码中,我们首先定义了一个变量 a
并赋值为 2,此时打印 a
的值时输出结果肯定是 2。然后我们再次定义变量 a
,此次只定义不赋值,那么再次打印 a
的值,输出的应该是多少?
可能有些人会说此时 a
的值为 undefined
,因为再次定义 a
时并没有赋值。实际上,在浏览器中运行这块代码时,你会发现 a
的值还是 2。要知道为什么会是这个结果,就要理解 JavaScript 中与变量有关的两种查询方式,LHS查询与RHS查询
相信从 L 与 R 这两个字母中,就可以明白这两种查询是相对的,分别代表着左侧与右侧
左侧与右侧的概念其实就是对于一个赋值操作来说的。简单来说,当变量出现在赋值操作左侧时做 LHS查询,出现在右侧时做 RHS查询
从上面的代码中来看, var a = 2
这句代表着我们声明了一个名字叫 a
的变量,并赋值为 2
。那么按照我们前面说的,变量出现在赋值操作的左侧使用 LHS查询
LHS查询会沿着作用域不停的向上查找,找到时返回目标变量的内存地址,当查找到全局作用域(Window
)还没有找到时就会定义该变量,再返回其内存地址。而且如果只是初次定义变量且没有赋值操作,会默认赋值为undefined
所以我们就知道在第一次执行 var a = 2
这句时,LHS查询会查找到全局作用域并返回新建的变量 a
在内存中的地址,并在这个地址中保存一个 2 这个数字。所以此时打印 a
的值,可以看到 a
的值为 2
在第二次执行 var a;
这句代码时,也会执行一次 LHS查询。因为上面已经定义过了变量 a
,所以这次查询会直接返回 a
的内存地址,且由于我们在 var a
后没有进行赋值操作,所以不会对变量 a
有任何影响,所以再次打印 a
的值时,仍然为 2
LHS查询其实除最基础的 = 号赋值操作外,我们函数定义中使用到的入参其本质上也是 LHS查询。如
function addOne(a) { var b = a + 1; return b; }
上述代码中,我们对于入参
a
的使用也是一个 LHS查询,上述代码可以理解为function addOne() { var a = arguments[0]; var b = a + 1; return b; }
相对于 LHS查询,RHS查询的概念就比较容易理解。简单来说,所有跟取值相关的都可以归结于 RHS查询
比如函数的调用,看下面代码:
function foo(a) {
console.log(a);
}
foo(2);
在代码中,我们先是定义了一个名字叫 foo
的函数,在这个函数中我们打印入参 a
的值
在调用时,就会做 RHS查询,查找到 foo
的内存地址并返回,然后我们执行 foo
函数并传入数字 2
在 foo
函数执行时,先做 LHS查询,把 2 赋值给 a
这个变量,然后通过 RHS查询分别去查找 console
中的 log
方法,查找到后再去查找变量 a
的地址,并把这个地址传递给 log
方法
所以在上面这块代码中共出现了三次RHS查询,分别是 查找 foo
的地址、查找 log
方法的地址、查找变量 a
的 地址。出现了一次 LHS查询,即把 2
这个数值,分配给了 a
这个变量
具名函数声明并不属于LHS查询,如果使用 var foo = function() { … } 这种方式声明函数,是属于LHS查询的
在我们工作中可能遇到的 JavaScript 错误提示中,有两种是与 RHS查询相关联的。分别是 ReferenceError 以及 TypeError
当我们使用一个变量时,会沿着作用域链向上查找,如果知道全局作用域仍未查找到,那么就会抛出 ReferenceError 错误,表明 JavaScript 引擎未找到我们所需的变量地址
如果在查找到目标后,我们对变量进行了错误的引用,比如将一个数字变量当作函数去调用,那么就会抛出 TypeError 错误
作用域的模型目前主要分为 词法作用域 与 动态作用域 两种,大部分语言采用是第一种,在 JavaScript 中两种作用域均有体现
其实 JavaScript 中的动态作用域也是通过词法作用域来实现的,只是其机制与动态作用域十分类似,而且由于词法作用域与动态作用域的区别较大,我们先这么区分。
上面我们说了,JavaScript 引擎生成可执行代码过程分为 词法分析 => 语法分析 => 代码生成 三步,那么与词法作用域这个名字对应后,我们就知道,这个作用域是在词法阶段决定的。换句人话说,词法作用域是由我们把代码代码写在哪里决定的
还是从代码来看:
function foo(a) {
var b = a + 1;
function bar(c) {
console.log(c);
}
bar(b * 2);
}
foo(2);
在上面的代码中,我们把作用域由小到大可以拆成三块
bar
中的作用域:包含了一个变量 c
foo
的作用域:包含了变量 a
、b
以及 函数 bar
bar
在 LHS查询与RHS查询中,他们会沿着当前所处的作用域,依次向外查找,比如 foo
作用域中的 LHS查询,会沿着 foo => window
这个路径查找,bar
作用域会沿着 bar => foo => window
路径查找,当找到第一个匹配的标识符时会立刻停止查询。
利用这个特性,我们可以在多层作用域中使用同样的标识符也不会影响变量数值的正确性,这叫做 遮罩效应,但实际工作中不建议这样使用。
此章节内容过于过时,且部分观点与现在 JavaScript 发展趋势相悖,代码例子有一些远古时代的hack写法,因此不过多记录
es6前可以生成块级作用域的方法:
try-catch
IIFE (Immediately Invoked Function Express)立即执行函数表达式,本质上是闭包,代码如下
for(var i = 0;i < 10; i++) {
(function(j) {
console.log(j);
})(i)
}
或
for(var i = 0;i < 10; i++) {
(function(j) {
console.log(j);
}(i))
}
老生常谈,简单总结一下特点:
函数优先提升,同名函数后声明的会覆盖先声明的,如
foo(); // output: 3
function foo() {
console.log(1);
}
function foo() {
console.log(3);
}
变量声明会提前,赋值会原地等待,所以在赋值前打印变量会输出 undefined
闭包其实不只可以使用自己和其父级作用域中的变量,实际上它可以使用它所能访问到的所有作用域中的变量,直到最上层的全局作用域
我们都知道在 JavaScript 的世界中,除了基本类型外,所有的函数都是通过 object
对象来实现的。所以本质上函数也是一个保存在内存堆地址中的变量
通过对闭包实现形式的理解:执行一个函数并返回另外一个函数,返回的函数中引用了其作用域之外的变量。与上一段结合起来看,实际上返回的就是一个内存堆中地址,该地址引用了另外一个内存中的数据
于是我们可以这么理解闭包,我们传递到外面的其实是 对内存地址的引用,所以我们可以在调用作用域中使用到更深层作用域中的数据
所以给闭包下一个定义:
当函数在其自身原来的词法作用域外执行时,仍然可以访问其所原本所在的词法作用域及其上层作用域时,就产生了闭包
动态作用域与词法作用域的区别在于,词法作用域是在代码书写时就确定的,而动态作用域是在代码运行时确定的。即词法作用域关注在何处定义,动态作用域关注从何处调用
动态作用域具体内容与 this
机制息息相关,我会放在下一篇博文中讲解。