クロージャとスコープ チェーンは JavaScript において比較的重要な概念です。ここ 2 日間でいくつかの情報を読み、関連する知識ポイントを以下にまとめました。
JavaScript は字句スコープを使用します。関数の実行が依存する変数のスコープは、関数の実行時ではなく、関数の定義時に決定されます。以下のコードを例に挙げます。一般的に (C 言語などのスタックベースの実装)、foo が呼び出された後、関数内のローカル変数のスコープは解放されますが、字句的に言えば、埋め込まれた匿名関数内のスコープは解放されます。 foo が参照する必要があるのは foo のローカル変数スコープであり、実際、コードの実行結果は f が呼び出された後、ローカル スコープが返されます。メイン関数 foo の呼び出しが終了した後も、関数オブジェクト f は foo 関数本体のスコープ変数への参照を保持します。これがいわゆるクロージャです。
var scope = 'global scope'; function foo() { var scope = 'local scope'; return function () { return scope; } } var f = foo(); f(); // 返回 "local scope"
では、クロージャはどのように機能するのでしょうか?クロージャを理解するには、まず変数のスコープとスコープ チェーンを理解する必要があります。もう 1 つの重要な概念は実行コンテキストです。
変数スコープ
JavaScript のグローバル変数にはグローバル スコープがあります。関数本体内で宣言された変数のスコープは関数本体全体であり、当然、関数本体内で定義された入れ子関数も含まれます。関数本体内のローカル変数の優先順位は、ローカル変数とグローバル変数が同じ名前である場合、グローバル変数は同様にローカル変数によってカバーされます。入れ子になった関数は、入れ子になった関数が配置されている関数内のローカル変数よりも上位になります。これは非常に明白なので、ほとんどの人が理解しています。
次に、皆さんには馴染みのないことについて話しましょう。
関数宣言の推進
関数宣言のプロモーションを 1 つの文で説明します。これは、関数本体内で宣言された変数が関数全体で有効であることを意味します。つまり、関数本体の下部で宣言された変数も上部に昇格されます。例:
var scope = 'global scope'; function foo() { console.log(scope); // 这里不会打印出 "global scope",而是 "undefined" var scope = 'local scope'; console.log(scope); // 很显然,打印出 "local scope" } foo();
最初の console.log(scope) は、ローカル変数の宣言がプロモートされていますが、まだ値が割り当てられていないため、グローバル スコープではなく undefined を出力します。
属性としての変数
JavaScript では、次のコード例の globalVal1、globalVal2、globalValue3 など、グローバル変数を定義する 3 つの方法があります。興味深い現象は、実際にはグローバル変数はグローバル オブジェクト window/global (ブラウザーでは window、node.js では global) の単なるプロパティであるということです。通常の意味での変数の定義との一貫性を高めるために、JavaScript は var で定義されたグローバル変数を、削除できないグローバル オブジェクト プロパティに設計します。 これは Object.getOwnPropertyDescriptor(this, 'globalVal1') を通じて取得でき、その構成可能なプロパティは false です。
var globalVal1 = 1; // 不可删除的全局变量 globalVal2 = 2; // 可删除的全局变量 this.globalValue3 = 3; // 同 globalValue2 delete globalVal1; // => false 变量没有被删除 delete globalVal2; // => true 变量被删除 delete this.globalValue3; //=> true 变量被删除
那么问题来了,函数体内定义的局部变量是不是也作为某个对象的属性呢?答案是肯定的。这个对象是跟函数调用相关的,在 ECMAScript 3中称为“call object”、ECMAScript 5中称为“declaravite environment record”的对象。这个特殊的对象对我们来说是一种不可见的内部实现。
作用域链
从上一节我们知道,函数局部变量可与看做是某个不可见的对象的属性。那么 JavaScript 的词法作用域的实现可以这样描述:每一段 JavaScript 代码(全局或函数)都有一个跟它关联的作用域链,它可以是数组或链表结构;作用域链中的每一个元素定义了一组作用域内的变量;当我们要查找变量 x 的值,那么从作用域链的第一个元素中找这个变量,如果没有找到者找链表中的下一个元素中查找,直到找到或抵达链尾。了解作用域链的概念对理解闭包至关重要。
执行上下文
每段 JavaScript 代码的执行都与执行上下文绑定,运行的代码通过执行上下文获可用的变量、函数、数据等信息。全局的执行上下文是唯一的,与全局代码绑定,每执行一个函数都会创建一个执行上下文与其绑定。JavaScript 通过栈的数据结构维护执行上下文,全局执行上下文位于栈底,当执行一个函数的时候,新创建的函数执行上下文将会压入栈中,执行上下文指针指向栈顶,运行的代码即可获得当前执行的函数绑定的执行上下文。如果函数体执行嵌套的函数,也会创建执行上下文并压入栈,指针指向栈顶,当嵌套函数运行结束后,与它绑定的执行上下文被推出栈,指针重新指向函数绑定的执行上下文。同样,函数执行结束,指针会指向全局执行上下文。
执行上下文可以描述成式一个包含变量对象(对应全局)/活动对象(对应函数)、作用域链和 this 的数据结构。当一个函数执行时,活动对象被创建并绑定到执行上下文。活动对象包括函数体内申明的变量、函数、arguments 等。作用域链在上一节以及提到,是按词法作用域构建的。需要注意的是 this 不属于活动对象,在函数执行的那一刻就以及确定。
执行上下文的创建是有特定的次序和阶段的,不同阶段有不同的状态,具体的细节可以看一下参考资料,在结尾部分会列出。
闭包
了解了作用域链和执行上下文,回过头看篇首的那段代码,基本上就可以解释闭包式如何工作了。函数调用的时候创建的执行上下文以及词法作用域链保持函数调用所需要的信息, f 函数调用之后才可以返回local scope。
需要注意的是,函数内定义的多个函数使用的是同一个作用域链,在使用 for 循环赋值匿名函数对象的场景比较容易引起错误,举例如下:
var arr = []; for (var i = 0; i < 10; i++) { arr[i] = { func: function() { return i; } }; } arr[0].func(); // 返回 10,而不是 0
arr[0].func()返回的是 10 而不是 0,跟感官上的语义有偏差。在 ECMAScript 6 引入 let 之前, 变量作用域范围是在整个函数体内而不是在代码区块之内,所以上面的例子中所有定义的 func 函数引用了同一个作用域链在 for 循环之后, i 的值已经变为 10 。
正确的做法是这样:
var arr = []; for (var i = 0; i < 10; i++) { arr[i] = { func: getFunc(i) }; } function getFunc(i) { return function() { return i; } } arr[0].func(); // 返回 0
以上内容给大家介绍了JavaScript作用域链、执行上下文与闭包的相关知识,希望对大家有所帮助。