[JS] You Don't Know JavaScript [Scope & Closures]

前言

我们在The Scope Chain不断地提到全域作用域,可能会疑问为什么最外层的作用域是全域作用域?而为什么对于JS来说会是最重要的?仅避免使用全域範围就足够了吗?

对于JS来说,全域是一个很複杂的环节,他非常的实用但细节也非常多,本章节将会对这些问题做一些解释。


Why Global Scope?

对于我们在开发一个专案的时候,我们并不会把所有的程式都写在同一个文件中,那么JS是通过什么方式让不同文件中的程式在运行的时候上下组合再一起的??

对于浏览器来说主要有三种方式:

如果使用的是ES Modules,那么这些文件会被JS单独加载,然后使用import将需要的module引入到需要的环境中,单独的module通过这个方法互相引入与合作且不需要共享作用域。如果在建构的时期将程式捆绑在一起,则通常会将所有文件连接在一起然后再交给JS引擎和浏览器,然后他们都只处理连接在一起的这个大文件,虽然所有的程式都在一个文件中,但每个部分都需要有能够让其他部分呼叫的名字与功能;在某些设置中,文件中内容都包装在一个封闭的作用域中(wrapper function..),每个功能都可以透过共享的作用域来让其他功能呼叫。
(function wrappingOuterScope(){    var moduleOne = (function one(){        // ..    })();    var moduleTwo = (function two(){        // ..        function callModuleOne() {            moduleOne.someMethod();        }        // ..    })();})();

以上面的例子来说,由于moduleOnemoduleTwo都存在于wrappingOuterScope作用域中,所以可以所以可以互相访问,而wrappingOuterScope只是一个function而不是一个真正的全域作用域,但是他储存了所有的功能,所以他可以称为是全域作用域的替身。
3. 如果都不是上面的两种方式,那么全域作用域就是联繫不同功能的唯一方法。

var moduleOne = (function one(){    // ..})();var moduleTwo = (function two(){    // ..    function callModuleOne() {        moduleOne.someMethod();    }    // ..})();

由于没有共同的function scope,所以将moduleOnemoduleTwo宣告在全域中,而事实上JS是分别对两个文件进行加载

// module1.jsvar moduleOne = (function one(){    // ..})();

// module2.jsvar moduleTwo = (function two(){    // ..    function callModuleOne() {        moduleOne.someMethod();    }    // ..})();

这两个档案在浏览器中分别被加载,每个文件中的顶部变量宣告都会成为全域变量,而全域作用域是这两个文件沟通的唯一桥樑,所以对JS引擎来说这他们都是独立的程式。

全域作用域还包括:

JS exposes its built-ins:primitives: undefined,null,Infinity,NaN...natives: Date(),Object(),String()...global functions: eval(),parseInt()...namesoaces: Math.Atinucs.JSONfriends of JS: Intl, WebAssemblyThe environment hosting the JS engine exposes its own built-ins:consoleDOM(window,document...)timer(setTimeout(...)...)web platform APIs(history,navigatot...)

Where Exactly in this Gloval Scope

虽然说全域作用域就是文件的最外层,也就是说他不存在于任function或block中,但他的定义也不是这么间单的,不同的JS环境对全域作用域的定义与处理方式都不一样,如果没有分辨的能力可能会有不能预期的错误出现。

Browser "Window"

var studentName = "Kyle";function hello() {    console.log(`Hello, ${ studentName }!`);}hello(); //Hello, Kyle!

我们可以使用<script>标籤加入到浏览器的环境中,他会动态的建立一个DOM元素,上面的例子中我们将stundntNamehello(...)都宣告在全域作用中,这意味着我们可以在全域的物件(在浏览器中全域的物件是window)中找到与他们同名的属性。

var studentName = "Kyle";function hello() {    console.log(`Hello, ${ window.studentName }!`);}window.hello(); // Hello, kyle!

Globals Shadowing Globals

我们有在The Scope Chain介绍到什么是shadowing,内部作用域的宣告可以覆盖外部作用域的宣告,让其无法访问到外部作用域的同名变量。

而对于全域作用域来说,全域物件的属性会被在全域宣告的变量shadowing。

window.something = 42; // property of object in globallet something = "Kyle"; // declarate blobal variableconsole.log(something); // Kyle -> shadowingconsole.log(window.something); //42

上面的程式码中,对于全域的物件宣告一个someting的属性,但是也使用let宣告一个同名的变量,这样的结果是something的词法宣告会shadowing全域物件的属性。

DOM Globals

对于DOM来说有一个特别的现象,当你处于浏览器的环境下,若你有一个DOM元素它具有id Attribute,那么他就会自动创建一个引用他的全域变量。

<ul id="my-todo-list">   <li id="first">Write a book</li>   ..</ul>

对于上面的html会转换为

first; // <li id="first">..</li>window["my-todo-list"]; // <ul id="my-todo-list">..</ul>

如果id的值对于lexcal来说是合法的那么这个id的值便会被建立,若不是则会在全域物件中建立(window[...]),虽然这种将所有id的值都建立为全域是旧版浏览器的行为,但是为了迎合一些旧版的网站只能暂时将他们保留,作为开发者能做的就是尽量不要去使用这些被自动创建出来的全域变量。

what's in a(window) Name?

var name = 42;console.log(name, typeof name); // "42", string

window.name他是在浏览器中定义的全域,他是全域物件中的一个属性(property),我们使用var宣告了name,但是他却没有shadowing全域物件的porperty,这代表着当全域物件中已经有这个名字的property,那么使用var宣告一个一样名字的全域变数时var的宣告会被忽略,这与我们上面看得的使用let宣告的结果并不一样。

但是奇怪的是我们将这个全域的numbername console出来后发现他虽然也是42但是他的资料结构变为string,这是因为对于window物件来说,要取得这个name属性他所使用的是getting/setting方法,这个方法要求他的值必须是字串。

Developer Tools Console/REPL

对于开发者工具来说,虽然他们也确实的再处理JS程式,但是为了给使用者良好的使用体验所以会额外做一些处理,在某些情况下开发者工具并不会处理JS程序的所有步骤,这个可能会造成程式与开发者工具之间的差异,举例来说,
开发者工具在对于一些JS的错误相对放宽,所以当输入一个程式到开发者工具中,有可能不会报错。

其中的差异有以下几种:

全域作用域行为Hoisting在全域作用域中使用块状作用域宣告关键字(let/const)

所以虽然使用console/REPL在确实有在全域作用域中处理程序,但这并不是很準确,虽然这些开发者工具会在一定程度上模仿全域作用域的行为,但是他仅仅是模仿并不是严格遵守,开发者工具优先考量开发者使用的便利性,所以行为会与真正的JS规範有一定的差距。

ES Modules(ESM)

在ES6中引入了对模块的概念(ES Modules),他能够修改文件中的全域作用域是他最为明显的影响之一。

var studentName = "Kyle";function hello() {    console.log(`Hello, ${ studentName }!`);}hello();  // Hello, Kyle!export hello;

若是使用ES Modules来加载那么他的运行方式和一般程式运行的方式一样,但是从应用程式的角度来说却是不一样的,儘管在模块的顶层宣告,但是在全域作用域中stydentNamehello(...)任然不是全域变数,他们是属于模块作用域中(模块中的全域)的。

所以说他并不是被加入到全域物件中,但是这并不代表他不能够在全域中被访问到,只是不能通过在module中的全域来建立真正意义上的全域变数。

module中的全域作用域是真正全域作用域的下一级,这代表全域作用域中的宣告可以提供给module使用。

ESM的出现就是为了让开发者减少对全域作用域的依赖,在全域作用域中可以import需要的module,这样可以减少对于全域或全域物件的用法。

Node

Node会处理他加载的每一个.js文件包括启动Node的主要module,而这样的效果是Node程式的顶层并不是全域作用域。

var studentName = "Kyle";function hello() {    console.log(`Hello, ${ studentName }!`);}hello(); // Hello, Kyle!

在处理之前,Node会将程式码包装近一个function中,所以varfunction会在一个function中被宣告而不是视为在全域作用中宣告,就像下面的程式码。

function Module(module,require,__dirname,...) {    var studentName = "Kyle";    function hello() {        console.log(`Hello, ${ studentName }!`);    }    hello(); // Hello, Kyle!    module.exports.hello = hello; //export variables}

Node实际上会运行你调用的module,所以上面的程式码中可以了解到为什么studentNamehello(...)不是全域宣告而是module範围的宣告。

Node定义了一些如require(...)的全域变量,但他们实际上并不是真正定义在全域作用域中,他们只是被注入到每一个module中,所以要在Node中定义实际的全域变量需要将他加入到一个Node自动提供的globals属性中,当然他也不是真正的globals,他只是实际全域作用域的一个reference,类似于浏览器中的window


Global This

在ES2020中,JS终于定义了对全域物件标準化的引用flobalThis,所以理论上可以使用globalThis代替上面介绍的所有方法。

参考文献:
You Don't Know JavaScript 2nd


关于作者: 网站小编

码农网专注IT技术教程资源分享平台,学习资源下载网站,58码农网包含计算机技术、网站程序源码下载、编程技术论坛、互联网资源下载等产品服务,提供原创、优质、完整内容的专业码农交流分享平台。

热门文章