前言
作用域和作用域链是所有JavaScript开发人员每天都要接触和应用的内容。不管是面试中的作用域链的面试考察,还是日常代码研发中变量与作用域链的构建,它的身影几乎无处不在。它就像一顶优秀厨师的厨师帽,只要我们走进厨房,我们就要将它整理好,套在头上。没有它整洁干净的戴在头上,你就不是一名好的JavaScript工程师。
其实,作为一名前端工程师,我也曾经疑惑过:基本上所有的计算机语言都具有作用域的概念,但是为何JavaScript开发人员总是对作用域这个概念执着不已?直到,我多次在编写代码过程中遇到涉及到作用域的问题后,我才渐渐了解这个问题并去仔细研究。
而这篇文章,就是想要和大家聊聊有关JavaScript作用域以及作用域链的那些事情,以及针对它们的一些我们在代码中优化小技巧。
内容
对于几乎所有的编程语言来说,最基本的功能之一,就是储存变量当中的值并且能在之后对这个值进行访问和修改。这种能力的引入,是程序的状态存在的基础。但是,能力的引入需要我们解决几个问题,例如:变量存储在哪里?以何种形式存储?需要读取和修改变量的时候,以什么方式获取到这个变量?
很明显,为了解决这些问题,我们需要一套设计良好的规则来存储变量,并且之后可以方便的找到这些变量。与此同时,整套完整规则的设计就会衍生出额外规则概念。而作用域,就是这套规则下衍生出来的概念。
作用域
我们可以把作用域理解为上面讲到的这套规则下的限定范围。作用域的职责是,在这段限定范围中根据这套设计好的规则存储所声明的变量,并且提供修改该变量的支持。在变量的访问权限安全上,作用域还承担着保护当前作用域内的变量不被外部作用域访问的权限保护作用。
通过类比,我们可以把作用域想象成一个气泡。在这个气泡里所声明的变量成员被包含在其中。每个气泡都配备有一位有原则的管家,将所有的成员管理起来,并针对他们声明的位置和要求对它们提供保护。当气泡中代码语句想要访问和修改变量成员时,管家会结合变量成员的要求关联对应访问和修改操作。
随着ECMAScript标准的不断发展和完善,JavaScript目前存在着四种作用域类型:
- 全局作用域(Global Scope): JavaScript语言环境的最顶级作用域,在语言环境初始化时创建。
- 模块作用域(Module Scope): 由ECMAScript模块标准(ES Module)引入,在解析ECMAScript模块时创建。
- 函数作用域(Function Scope): 在函数声明
function() {}
或者() => {}
时创建。 - 块级作用域(Block Scope): 由ECMAScript2015的变量声明标识符
let
和const
引入,在使用这两者进行变量声明时,根据最近的一对花括号{}
创建。
/* 全局作用域 start,JavaScript语言环境初始化时就被创建 */
/* 模块作用域 start,作为ES Module解析和执行时被创建 */
let name = 'Wu';
{
/* 块级作用域 start,const进行变量声明在最近的花括号{}内创建 */
const prefix = Hardy;
name = prefix name;
/* 块级作用域 end */
}
export function sayMyName(myName) {
/* 函数作用域 start,函数声明时自动创建,初始化默认包含函数的形参变量 */
if (!myName) {
/* 块级作用域 start */
const noNameAnswer = 'Sorry!';
console.log(noNameAnswer);
return;
/* 块级作用域 end */
}
const wordPrifix = 'Hi! My Name is ';
const answer = wordPrifix myName '.';
console.log(answer);
/* 函数作用域 end */
}
/* 模块作用域 end */
/* 全局作用域 end */
作用域的嵌套
作用域在使用上具有嵌套特征。一个作用域能够在自身内部创建一个新作用域从而形成内部和外部作用域的嵌套关系。
全局作用域作为JavaScript的初始作用域,是所有其他作用域最外层的作用域。另外,每一个ES Module都具有模块自己的顶级作用域(top-level scope),模块中的顶级作用域变量和函数都包含在这个模块顶级作用域中,而模块作用域的外部作用域是全局作用域。而函数作用域和块级作用域则相对比较灵活,可以相互嵌套。
作用域的一些实现细节
在JavaScript中,每一个函数、代码块{...}
以及script
脚本被运行前,都会有一个相对应的称为词法环境(Lexical Environment) 的内部关联对象被创建。
词法环境由两部分组成:
- 环境记录(Environment Record):一个存储所有局部变量作为其属性(包括一些执行上下文信息,例如
this
的值)的对象。 - 外部词法环境引用(Outer):对外部词法环境的引用,以此关联外部词法环境。
代码执行的过程中,每一个局部变量和局部函数的声明,都会作为一个属性字段被添加到环境记录中,后续对变量和函数的读取则通过对应标识符在环境记录中进行查找。
根据上面的概念,我们可以通过下面的对象结构理解词法环境:
代码语言:text复制 lexicalEnvironment = {
environmentRecord: {
<identifier>: <value>,
<identifier>: <value>,
},
outer: <Reference to the parent lexical environment>,
}
再来通过下面的代码例子来理解词法环境:
代码语言:javascript复制/* 当前模块运行时,模块的词法环境被创建, moduleLexicalEnvironment = { environmentRecord: { name: <uninitialized>, sayName: <reference to function object>, }, outer: <globalLexicalEnvironment>, }*/
let name = 'Hardy';
/* 变量声明和赋值,修改环境记录的字段属性值, moduleLexicalEnvironment = { environmentRecord: { name: 'Hardy', sayName: <reference to function object>, }, outer: <globalLexicalEnvironment>, }*/
function sayName(myName) {
/* 执行函数时,函数的词法环境被创建, functionLexicalEnvironment = { environmentRecord = { myName: 'Hardy', }, outer: <moduleLexicalEnvironment>, } */
/* 通过读取环境记录的对应标识符字段属性值获取myName的变量值 */
console.log(myName);
}
sayName(); // Hardy
我们来分析下上面的代码例子。
根据声明提前的特性,变量name
和函数sayName
都会在模块的词法环境创建时被添加在环境记录中。但是,由于let
的暂时性死区特性,变量name
在自身声明和初始化赋值之前处于不可引用和未初始化状态。函数的声明则不同,除了声明提前外还会初始化函数的引用。这就是我们可以在函数执行声明语句前调用函数的原因。另外,函数的词法环境在被创建时,对应函数的参数会被初始化在环境记录中,并且会被赋值上调用函数时的所传值或者函数参数的默认值。
在outer
引用方面,模块词法环境moduleLexicalEnvironment
的outer
引用指向JavaScript最外部的全局词法环境globalLexicalEnvironment
,而函数词法环境functionLexicalEnvironment
的outer
引用指向外部的模块词法环境moduleLexicalEnvironment
。
我们可以看出,词法环境是JavaScript对作用域概念的内部技术实现。它是JavaScript引擎创建一个执行上下文时,创建用来存储变量和函数声明的环境。代码执行过程中,通过它访问到存储在其内部的变量和函数。在代码执行完毕后,执行上下文会从堆栈中被销毁回收,而词法环境也会根据情况的被销毁(如果词法环境被其他外部的词法环境所引用,则不会被销毁回收,例如闭包)。
作用域链
作用域可以嵌套,嵌套在内部的作用域可以访问外部的作用域所声明的变量和函数。通过上面词法环境的介绍,我们大概清楚,作用域的这种嵌套关系是通过词法环境的外部词法环境引用outer
来关联实现的。这种词法环境的外部引用的关联关系,构建了一条单向的词法环境的链条。这就是我们常说的作用域链。
本质上,作用域链是JavaScript引擎给所执行代码维护的一条词法环境链条。代码执行中对外部作用域的变量的引用,通过这一条链条进行变量的查找、读取、修改。
代码执行中对某个变量的访问大致如下:
- 当代码要访问一个变量时,首先会搜索当前内部词法环境。如果搜索成功,就返回对一个变量值或变量引用,结束搜索。如果搜索不到,则通过
outer
引用继续搜索外部词法环境,以此类推,直到全局词法环境。 - 如果在任何地方都找不到这个变量,那么在严格模式下就会报错。
根据上面的概念,我们来看看下面的例子:
代码语言:javascript复制let phrase = 'Hello';
function sayHello(name) {
/* 函数的作用域链, functionLexicalEnvironment{ name: 'Hardy' } ==outer==> moduleLexicalEnvironment{ phrase: 'Hello' } ==outer==> globalLexicalEnvironment 变量name从当前functionLexicalEnvironment中查找到并获取, 变量phrase沿作用域链查找,从moduleLexicalEnvironment中查找到并获取 */
console.log(`${phrase}, ${name}!`);
}
sayHello('Hardy'); // Hello, Hardy!
上面例子中,函数sayHello
在内部引用了name
和phrase
两个变量,函数被调用的执行时会创建functionLexicalEnvironment > moduleLexicalEnvironment > globalLexicalEnvironment
的作用域链。
其中,变量name
作为函数参数属于当前函数作用域的局部变量,变量可以直接从当前函数的词法环境functionLexicalEnvironment
中查找到并返回相关信息。而变量phrase
属于外部作用域中声明的变量,存储在外部的模块词法环境moduleLexicalEnvironment
中。函数sayHello
引用变量phrase
,会首先从在自身函数词法环境functionLexicalEnvironment
中进行查找,查找不到后,会沿外部词法环境引用outer
找到模块词法环境moduleLexicalEnvironment
,并从中继续进行变量的查找,查找到了并返回变量的相关信息。
值得注意的是console.log()
是全局内置对象console
上的方法,对该方法的调用需要引用console
。这个变量的引用会沿作用域链一直查找到全局词法环境globalLexicalEnvironment
中,从中查找到并返回相关变量信息。
变量标识符解析和引用的过程就是沿作用域链迭代查找变量是否在作用域链节点中并返回变量相关信息的过程。
相关优化
综合上面的标识符的解析过程和作用域以及作用域链的关系,我们可以了解到,变量标识符解析的性能是和变量标识符所处在作用域链中的位置是息息相关的。变量标识符所出的作用域节点越靠近整个作用域链的前端,则需要沿作用域链迭代查找的次数就越少,变量标识符解析的速度就会越快,性能就越好。
这种标识符解析性能的规律,让我们可以得出以下使用变量的优化点:
- 对于频繁引用的外部作用域的变量,可以根据情况在当前作用域内声明赋值为局部变量后使用。
- 减少作用域增强
with
语句的使用。
外部作用域变量标识符的多次引用,会造成执行过程中的标识符解析沿作用域链查找的频繁执行,这种查找在第一次解析引用时是必须的,但是后续解析引用却是重复的。将外部作用域变量通过在当前作用域内声明赋值为局部变量,可以优化后续查找的需要经过的作用域链节点个数,得到一定的性能提升。
with
语句可以在当前作用域链前端临时添加一个词法环境,从而在位置构建和使用新的作用域链。但是这方式问题也很显而易见:作用域链被加长了,除了被添加到前端的词法环境中的存储的变量外,其他变量的标识符解析性能都会变差。因此,我们应该减少with
语句的使用。
总结
随着JavaScript语言的发展,语言中的作用域的种类也变得丰富起来,不再局限于函数作用域作为最小变量声明范围来使用,而是可以基于更小范围的跨级作用域来管理我们的变量引用范围。变量的管理变得更加的灵活、安全。
作用域链是作用域链嵌套的结构产物,所有变量标识符的解析和引用会沿着作用域链进行查找。而词法环境,是JavaScript对于作用域的内部技术实现。深入了解词法环境后,也让我们更清楚代码在解析变量标识符时的内部执行过程。也根据这个过程,我们大概总结出了两点关于作用域和变量使用的性能优化点。
作用域的使用作为每一位JavaScript开发人员的必修课,了解得深入才能在使用它的时候不再迷茫。它就像空气,存在于JavaScript的许多地方,值得我们去好好了解。