本文深入解析JavaScript中的作用域与作用域链机制,涵盖全局、模块、函数及块级四种作用域类型。详细阐述词法环境的内部实现原理,包括环境记录与外部引用关系。基于变量解析性能规律,提供减少作用域链查找次数的具体优化策略,帮助开发者编写更高效、安全的代码。
前言
作用域与作用于链是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):对外部词法环境的引用,以此关联外部词法环境。
代码执行的过程中,每一个局部变量和局部函数的声明,都会作为一个属性字段被添加到环境记录中,后续对变量和函数的读取则通过对应标识符在环境记录中进行查找。
根据上面的概念,我们可以通过下面的对象结构理解词法环境:
lexicalEnvironment = {
environmentRecord: {
<identifier>: <value>,
<identifier>: <value>,
},
outer: <Reference to the parent lexical environment>,
}再来通过下面的代码例子来理解词法环境:
/*
当前模块运行时,模块的词法环境被创建,
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引用继续搜索外部词法环境,以此类推,直到全局词法环境。 - 如果在任何地方都找不到这个变量,那么在严格模式下就会报错。
根据上面的概念,我们来看看下面的例子:
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的许多地方,值得我们去好好了解。
到此这篇关于JavaScript作用域与作用域链优化方式的文章就介绍到这了,更多相关JS 作用域链优化内容请搜索本站以前的文章或继续浏览下面的相关文章希望大家以后多多支持本站!