你并不了解 JavaScript:作用域与闭包 - 第二版
第四章:全局作用域
第 3 章多次提到了「全局作用域」,但您可能仍想知道为什么程序的最外层作用域在现代 JS 中如此重要。现在,绝大多数工作都是在函数和模块内部完成的,而不是在全局作用域内。
仅仅断言「避免使用全局作用域」就够了吗?
JS 程序的全局作用域是一个内容丰富的话题,其实用性和细微差别远远超出你的想象。本章首先探讨了全局作用域在今天编写 JS 程序中(仍然)是如何有用和相关的,然后探讨了在不同的 JS 环境中访问全局作用域的位置和方式的差异。
充分理解全局作用域对于掌握使用词法作用域来构建程序至关重要。
为什么是全局作用域?
大多数应用程序都是由多个(有时是很多!)独立的 JS 文件组成的,读者对此可能并不陌生。那么,JS 引擎究竟是如何将所有这些独立文件拼接到一个运行时上下文中的呢?
关于浏览器执行应用程序,主要有三种方式。
首先,如果您直接使用 ES 模块(而不是将它们转换成其他模块包的格式),JS 环境会单独加载这些文件。然后,每个模块会 import
它需要访问的其他模块的引用。独立的模块文件只通过这些共享导入相互合作,而不需要任何共享的外层作用域。
其次,如果您在构建过程中使用了打包工具,那么所有文件通常都会在交付给浏览器和 JS 引擎之前串联在一起,然后只处理一个大文件。即使应用程序的所有部分都位于一个文件中,也需要建立某种机制,让每个部分都能注册一个名称,以便被其他部分引用,还需要建立某种设施,以便进行访问。
在某些编译设置中,文件的全部内容都被封装在一个单一的外层作用域中,如封装函数、通用模块(UMD — 见附录 A)等。每个片段都可以通过共享作用域中的局部变量注册自己,以便其他片段访问。例如
(function wrappingOuterScope() {
var moduleOne = (function one() {
// ..
})();
var moduleTwo = (function two() {
// ..
function callModuleOne() {
moduleOne.someMethod();
}
// ..
})();
})();
(function wrappingOuterScope() {
var moduleOne = (function one() {
// ..
})();
var moduleTwo = (function two() {
// ..
function callModuleOne() {
moduleOne.someMethod();
}
// ..
})();
})();
如上所示,wrappingOuterScope()
函数作用域内的 moduleOne
和 moduleTwo
局部变量的声明是为了让这些模块可以互相访问,以实现合作。
虽然 wrappingOuterScope()
的作用域是一个函数,而不是整个环境的全局作用域,但它确实充当了一种「全应用程序作用域」,一个可以存储所有顶级标识符的容器,尽管它不在真正的全局作用域中。在这方面,它有点像全局作用域的替身。
最后是第三种方式:无论应用程序使用的是打包工具,还是(非 ES 模块)文件只是在浏览器中单独加载(通过 <script>
标签或其他动态 JS 资源加载),如果没有包含所有这些部分的单一作用域,那么全局作用域就是它们相互合作的唯一方式:
这类文件通常看起来像这样:
var moduleOne = (function one() {
// ..
})();
var moduleTwo = (function two() {
// ..
function callModuleOne() {
moduleOne.someMethod();
}
// ..
})();
var moduleOne = (function one() {
// ..
})();
var moduleTwo = (function two() {
// ..
function callModuleOne() {
moduleOne.someMethod();
}
// ..
})();
在这里,由于周围没有函数作用域,这些 moduleOne
和 moduleTwo
声明被简单地放到了全局作用域中。这实际上等同于没有串联文件,而是单独加载文件:
module1.js:
var moduleOne = (function one() {
// ..
})();
var moduleOne = (function one() {
// ..
})();
module2.js:
var moduleTwo = (function two() {
// ..
function callModuleOne() {
moduleOne.someMethod();
}
// ..
})();
var moduleTwo = (function two() {
// ..
function callModuleOne() {
moduleOne.someMethod();
}
// ..
})();
如果在浏览器环境中将这些文件作为普通的独立 .js 文件单独加载,每个顶层变量声明最终都将成为全局变量,因为全局作用域是这两个独立文件之间唯一的共享资源。从 JS 引擎的角度来看,它们是独立的程序。
除了(有可能)说明运行时应用程序的代码所在位置,以及每个部分如何访问其他部分以进行合作外,全局作用域也是一个重要因素:
JS 提供的内置组件:
- 原始值:
undefined
,null
,Infinity
,NaN
- 内置:
Date()
,Object()
,String()
, 等等。 - 全局函数:
eval()
,parseInt()
, 等等。 - 命名空间:
Math
,Atomics
,JSON
- JS 的朋友们:
Intl
,WebAssembly
- 原始值:
托管 JS 引擎的环境提供的内置功能:
console
(以及它的函数)- DOM (
window
,document
, 等等) - timers (
setTimeout(..)
, 等等) - web 平台 API:
navigator
,history
, geolocation, WebRTC, 等等。
这些只是您的程序将与之交互的众多 globals 中的一部分。
注意: |
---|
Node 还在「全局」作用域内公开了几个元素,但从技术上讲,它们并不在 global 作用域内:require() , __dirname , module , URL 等。 |
大多数开发人员都认为,全局作用域不应该只是应用程序中每个变量的垃圾场。这样做会导致错误丛生。但同样不可否认的是,全局作用域几乎是每个 JS 应用程序的重要「粘合剂」。
全局作用域具体范围是什么?
全局作用域位于文件的最外层,即不在任何函数或其他代码块内,这一点似乎显而易见。但事实并非如此简单。
不同的 JS 环境会以不同的方式处理程序的作用域,尤其是全局作用域。JS 开发人员经常会在不知不觉中产生误解。
浏览器 "Window"
关于全作用域的处理,JS 最纯粹的运行环境是在浏览器的网页环境中运行独立的 .js 文件。我说的「纯粹」并不是指没有自动添加任何东西,而是指对代码的侵入或对其预期全局作用域行为的干扰最小。
假设有一个这样的 .js 文件:
var studentName = "Kyle";
function hello() {
console.log(`Hello, ${studentName}!`);
}
hello();
// Hello, Kyle!
var studentName = "Kyle";
function hello() {
console.log(`Hello, ${studentName}!`);
}
hello();
// Hello, Kyle!
这段代码可以在网页环境中使用内联 <script>
标签加载或者 <script src=..>
script 标签,甚至是动态创建的 <script>
DOM 元素。在这三种情况下,studentName
和 hello
标识符都是在全局作用域声明的。
这意味着,如果访问全局对象(通常是浏览器中的 window
),就会在那里找到这些相同名称的属性:
var studentName = "Kyle";
function hello() {
console.log(`Hello, ${window.studentName}!`);
}
window.hello();
// Hello, Kyle!
var studentName = "Kyle";
function hello() {
console.log(`Hello, ${window.studentName}!`);
}
window.hello();
// Hello, Kyle!
这正是阅读 JS 规范时所期望的默认行为:外层作用域是全局作用域,而 studentName
被合法地创建为全局变量。
这就是我所说的纯粹。但遗憾的是,这并不总是你所遇到的所有 JS 环境的真实情况,而这往往会让 JS 开发人员感到惊讶。
全局遮蔽全局
回想一下第 3 章中关于遮蔽(和解除全局遮蔽)的讨论,其中一个变量声明可以覆盖并阻止从外层作用域访问同名的声明。
全局变量和同名的全局属性之间的一个不同寻常的结果是,在全局作用域内,全局对象属性可以被全局变量遮蔽:
window.something = 42;
let something = "Kyle";
console.log(something);
// Kyle
console.log(window.something);
// 42
window.something = 42;
let something = "Kyle";
console.log(something);
// Kyle
console.log(window.something);
// 42
let
声明添加了一个 something
全局变量,但没有添加全局对象属性(参见第 3 章)。这样做的结果是 something
词法标识符会影响 something
全局对象属性。
在全局对象和全局作用域之间产生分歧肯定是个坏主意。你的代码读者很大概率会被绊倒。
避免全局声明出现这种问题的简单方法是:全局声明始终使用 var
。将 let
和 const
保留给块作用域(参见第 6 章「块作用域」)。
全局 DOM
我曾断言,浏览器托管的 JS 环境拥有我们将看到的最纯粹的全局作用域行为。然而,它并不完全纯粹。
在基于浏览器的 JS 应用程序中,您可能会在全局作用域内遇到一个令人惊讶的行为:带有 id
属性的 DOM 元素会自动创建一个引用它的全局变量。
请看以下代码:
<ul id="my-todo-list">
<li id="first">Write a book</li>
..
</ul>
<ul id="my-todo-list">
<li id="first">Write a book</li>
..
</ul>
但是这个页面的 JS 可能出现:
first;
// <li id="first">..</li>
window["my-todo-list"];
// <ul id="my-todo-list">..</ul>
first;
// <li id="first">..</li>
window["my-todo-list"];
// <ul id="my-todo-list">..</ul>
如果 id
值是一个有效的词法名称(如 first
),则会创建词法变量。否则,访问全局变量的唯一途径是通过全局对象 (window[..]
)。
将所有带有 id
的 DOM 元素自动注册为全局变量是一种古老的传统浏览器行为,但由于许多老网站仍然依赖于这种行为,因此这种行为必须保留。我的建议是永远不要使用这些全局变量,尽管它们总是会被静默创建。
(Window) 名称有什么含义?
基于浏览器的 JS 中的另一个全局作用域怪现象:
var name = 42;
console.log(name, typeof name);
// "42" string
var name = 42;
console.log(name, typeof name);
// "42" string
在浏览器上下文中,window.name
是一个预定义的「全局」;它是全局对象上的一个属性,因此看起来就像一个普通的全局变量(但它并不「普通」)。
我们在声明中使用了 var
,它不会对预定义的 name
全局属性产生遮蔽。这实际上意味着,var
声明会被忽略,因为已经有了一个全局作用域对象属性的名称。正如我们之前讨论过的,如果我们使用 let name
,我们就会使用一个单独的全局变量 name
来遮蔽 window.name
。
但真正令人惊讶的行为是,尽管我们将数字 42
赋值给了 name
(因此也赋值给了 window.name
),但当我们检索其值时,它却是一个字符串 "42"
!在这种情况下,出现这种奇怪的情况是因为 name
实际上是 window
对象上的一个预定义 getter/setter,它坚持其值必须是一个字符串值。哎呀!
除了 DOM 元素 ID 和 window.name
等极少数情况外,在浏览器页面中作为独立文件运行的 JS 具有我们会遇到的最纯粹的全局作用域行为。
Web Workers
Web Workers 是 browser-JS 行为之上的网络平台扩展,它允许 JS 文件在与运行主 JS 程序的线程完全独立的线程中运行(从操作系统角度看)。
由于这些 Web Worker 程序运行在单独的线程上,因此它们与主程序线程的通信受到限制,以避免/限制竞争条件和其他复杂情况。例如,Web Worker 代码不能访问 DOM。不过,Web Worker 可以使用某些 Web API,如 navigator
。
由于 Web Worker 被视为一个完全独立的程序,因此它不会与主 JS 程序共享全局作用域。不过,浏览器的 JS 引擎仍在运行代码,因此我们可以期待其全局作用域行为具有类似的纯净度。由于不存在 DOM 访问,因此全局作用域的 window
别名并不存在。
在 Web Worker 中,全局对象引用通常使用 self
:
var studentName = "Kyle";
let studentID = 42;
function hello() {
console.log(`Hello, ${self.studentName}!`);
}
self.hello();
// Hello, Kyle!
self.studentID;
// undefined
var studentName = "Kyle";
let studentID = 42;
function hello() {
console.log(`Hello, ${self.studentName}!`);
}
self.hello();
// Hello, Kyle!
self.studentID;
// undefined
与主 JS 程序一样,var
和 function
声明会在全局对象(又称 self
)上创建镜像属性,而其他声明(let
等)则不会。
因此,我们在这里看到的全局作用域行为对于运行的 JS 程序来说是纯粹的;由于没有 DOM 的干扰,也许会更加纯粹!
开发人员工具控制台/REPL
回顾入门中的第 1 章,开发者工具并不能创建一个完全贴合 JS 的环境。它们会处理 JS 代码,但也会偏向于对开发人员最友好的用户体验交互(又称开发人员体验或 DX)。
在某些情况下,在键入简短的 JS 代码段时偏向于使用 DX,而不是处理完整 JS 程序所需的常规严格步骤,会在程序和工具之间产生可观察到的代码行为差异。例如,在将代码输入开发工具时,适用于 JS 程序的某些错误条件可能会被放宽而不显示。
就我们在这里讨论的作用域言,这种可观察到的行为差异可能包括:
全局作用域的行为
提升 (见第 5 章)
在最外层作用域使用 Block-scoping 声明符时(
let
/const
, 见第 6 章)
虽然在使用控制台/REPL 时,在最外层作用域输入的语句似乎是在真正的全局作用域中处理的,但这并不十分准确。这类工具通常会在一定程度上模拟全局作用域的位置;这只是模拟,而不是严格遵守。这些工具环境优先考虑的是开发人员的便利性,这意味着有时(比如我们当前关于作用域的讨论),观察到的行为可能会偏离 JS 规范。
我们的结论是,开发者工具虽然经过优化,对各种开发者活动非常方便和有用,但不适合用来确定或验证实际 JS 程序上下文的明确和细微行为。
ES 模块 (ESM)
ES6 引入了对模块模式的一流支持(将在第 8 章中介绍)。使用 ESM 最明显的影响之一是它如何改变文件中可观察到的顶级作用域的行为。
回顾一下前面的代码片段(我们将使用 export
关键字将其调整为 ESM 格式):
var studentName = "Kyle";
function hello() {
console.log(`Hello, ${ studentName }!`);
}
hello();
// Hello, Kyle!
export hello;
var studentName = "Kyle";
function hello() {
console.log(`Hello, ${ studentName }!`);
}
hello();
// Hello, Kyle!
export hello;
如果该代码位于作为 ES 模块加载的文件中,其运行方式仍然完全相同。不过,从整个应用程序的角度来看,可观察到的效果将有所不同。
尽管 studentName
和 hello
被声明在(模块)文件的顶层,显而易见时在最外层的作用域内,但它们并不是全局变量。相反,它们是模块作用域内的变量,如果你愿意,也可以称为「模块全局变量」。
然而,在模块中,这些顶层声明并没有隐式的「模块作用域象」可以作为属性添加到模块中,而当声明出现在非模块 JS 文件的顶层时,就会出现这种情况。这并不是说全局变量不能存在或不能在此类程序中被访问。这只是说,全局变量不会通过在模块的顶层作用域中声明变量来创建。
模块的顶级作用域是从全局作用域降级而来的,就好像模块的全部内容都被封装在一个函数中一样。因此,存在于全局作用域中的所有变量(无论它们是否在全局对象上!)都可以在模块的作用域中作为词法标识符使用。
ESM 鼓励尽量减少对全局作用域的依赖,您可以导入当前模块运行所需的任何模块。因此,您较少看到使用全局作用域或其全局对象。
不过,如前所述,无论您意识到与否,仍有大量的 JS 和 Web 全局将继续从全局作用域访问!
Node
Node 的一个方面常常让 JS 开发人员措手不及,那就是 Node 将加载的每个 .js 文件(包括启动 Node 进程时使用的主文件)都视为模块(ES 模块或 CommonJS 模块,参见第 8 章)。这样做的实际效果是,Node 程序的顶层实际上并不是全局作用域,就像在浏览器中加载非模块文件时那样。
截至本文撰写之时,Node 最近新增了对 ES 模块的支持。此外,Node 从一开始就支持一种称为 "CommonJS" 的模块格式,它看起来像这样:
var studentName = "Kyle";
function hello() {
console.log(`Hello, ${studentName}!`);
}
hello();
// Hello, Kyle!
module.exports.hello = hello;
var studentName = "Kyle";
function hello() {
console.log(`Hello, ${studentName}!`);
}
hello();
// Hello, Kyle!
module.exports.hello = hello;
在处理之前,Node 会有效地将此类代码封装在一个函数中,这样 var
和 function
声明就包含在封装函数的作用域中,而不会被视为全局变量。
将前面的代码想象成 Node 看到的样子(示例,非实际):
function Module(module,require,__dirname,...) {
var studentName = "Kyle";
function hello() {
console.log(`Hello, ${ studentName }!`);
}
hello();
// Hello, Kyle!
module.exports.hello = hello;
}
function Module(module,require,__dirname,...) {
var studentName = "Kyle";
function hello() {
console.log(`Hello, ${ studentName }!`);
}
hello();
// Hello, Kyle!
module.exports.hello = hello;
}
然后,Node 会调用 Module(..)
函数来运行模块。在这里,您可以清楚地看到为什么 studentName
和 hello
标识符不是全局的,而是在模块作用域中声明的。
如前所述,Node 定义了许多类似 require()
的「全局」,但它们实际上并不是全局作用域中的标识符(也不是全局对象的属性)。它们被注入到每个模块的作用域中,本质上有点像 Module(..)
函数声明中列出的参数。
那么,如何在 Node 中定义实际的全局变量呢?唯一的方法就是在 Node 自动提供的另一个「全局」中添加属性,具有讽刺意味的是,这个「全局」被称为global
。global
是对真正全局作用域对象的引用,有点像在浏览器 JS 环境中使用 window
一样。
思考一下:
global.studentName = "Kyle";
function hello() {
console.log(`Hello, ${studentName}!`);
}
hello();
// Hello, Kyle!
module.exports.hello = hello;
global.studentName = "Kyle";
function hello() {
console.log(`Hello, ${studentName}!`);
}
hello();
// Hello, Kyle!
module.exports.hello = hello;
在这里,我们将 studentName
添加为 global
对象的一个属性,然后在 console.log(..)
语句中,我们就可以将 studentName
作为一个普通的全局变量来访问。
请记住,标识符 global
不是由 JS 定义的,而是由 Node 专门定义的。
Global This
回顾我们迄今为止所看到的 JS 环境,程序可能有,也可能没有:
- 在顶层作用域中使用
var
或function
声明或let
,const
和class
声明全局变量。 - 如果使用了
var
或function
声明,还可将全局变量声明添加为全局作用域对象的属性。 - 使用
window
、self
或global
引用全局作用域对象(用于添加或检索全局变量或者作为属性)。
我认为可以这样说,全局作用域的访问和行为比大多数开发人员想象的要复杂得多,前面的章节已经说明了这一点。但是,这种复杂性在试图确定对全局作用域对象的通用引用时表现得最为明显。
获取全局作用域对象引用的另一个「把戏」是:
const theGlobalScopeObject = new Function("return this")();
const theGlobalScopeObject = new Function("return this")();
注意: |
---|
可以使用 Function() 构造函数从存储在字符串值中的代码动态构造函数,类似于 eval(..) (请参阅第 1 章中的「取巧:修改运行时的作用域」)。如图所示,当使用普通的 () 函数调用时,这样的函数将自动以非严格模式运行(出于遗留原因);其 this 指向全局对象。有关确定 this 绑定的更多信息,请参阅本系列丛书第三册对象与类。 |
因此,我们有了 window
、self
、global
和这个丑陋的 new Function(..)
把戏。有很多不同的方法来获取这个全局对象。各有利弊。
要不再介绍一个!?!?
从 ES2020 开始,JS 终于定义了对全局作用域对象的标准化引用,称为 globalThis
。因此,根据您代码运行的 JS 引擎的最新情况,您可以使用 globalThis
代替任何其他方法。
我们甚至可以尝试定义一种跨环境的 polyfill,让之前不支持 globalThis
的 JS 环境中更安全,例如
const theGlobalScopeObject =
typeof globalThis != "undefined"
? globalThis
: typeof global != "undefined"
? global
: typeof window != "undefined"
? window
: typeof self != "undefined"
? self
: new Function("return this")();
const theGlobalScopeObject =
typeof globalThis != "undefined"
? globalThis
: typeof global != "undefined"
? global
: typeof window != "undefined"
? window
: typeof self != "undefined"
? self
: new Function("return this")();
呼!这当然不是最理想的,但如果你需要一个可靠的全局作用域参考,它还是有用的。
(在将该功能添加到 JS 时,globalThis
这个拟议名称曾引起相当大的争议。具体来说,我和其他许多人都认为名称中的 "this" 引用具有误导性,因为引用此对象的原因是访问全局作用域,而不是访问某种全局/默认的 this
绑定。我们曾考虑过许多其他名称,但由于种种原因都被排除了。不幸的是,最终选择的名称是不得已而为之。如果您打算在您的程序中与全局作用域对象交互,为了减少混淆,我强烈建议您选择一个更好的名称,比如这里使用的(长得可笑但准确的!)theGlobalScopeObject
(全局作用域对象)。)
掌握全局作用域
尽管将代码组织成模块的现代模式下不再强调在命名空间中存储标识符的重要性,但全局作用域在每一个 JS 程序中都存在并与之相关。
尽管如此,随着我们的代码越来越多地超出浏览器的限制,掌握全局作用域(和全局作用域对象!)在不同 JS 环境中的行为差异就显得尤为重要。
有了全局作用域的全貌,下一章将再次深入词法作用域的细节,研究如何以及何时使用变量。