ホームページ > 記事 > ウェブフロントエンド > 高度なフロントエンドの基礎 (4): スコープ チェーンとクロージャの詳細図
私が初めて JavaScript を学んだとき、クロージャの学習でかなり回り道をしました。今回は、基礎知識の整理に立ち返り、クロージャを分かりやすく説明することも非常に大きな課題です。
クロージャはどのくらい重要ですか?フロントエンドを初めて使用する場合、実際の開発においてクロージャがどの程度普及しているかを直観的に伝えることはできませんが、フロントエンドの面接ではクロージャについて質問する必要があることは言えます。面接官は、クロージャについての理解を利用して面接官の基本レベルを判断することがよくありますが、保守的に見積もっても、フロントエンド面接官の 10 人中少なくとも 5 人はクロージャで死亡します。
しかし、なぜクロージャは非常に重要であるにもかかわらず、これほど多くの人がまだそれを理解していないのでしょうか?みんな学ぶ気がないからでしょうか?実際にはそうではありませんが、検索で見つけたクロージャを説明する中国の記事のほとんどは、クロージャについて明確に説明していませんでした。それは表面的であるか、不可解であるか、あるいは単なるナンセンスであるかのいずれかです。私自身も含めて、かつてクロージャについてまとめた記事を書いたことがあるのですが、振り返ってみると、それは見るに耐えませんでした。
そのため、この記事の目的は、読者がクロージャを漠然と理解するのではなく、読んだ後に完全に理解できるように、クロージャを明確かつ明確に説明することです。
1. スコープとスコープチェーン
スコープチェーンを詳しく説明する前に、JavaScript における以下の重要な概念を大まかに理解していることを前提としています。これらの概念は非常に役立ちます。
1. 基本的なデータ型と参照データ型
3. ガベージコレクションのメカニズム
5. まだ理解していない場合は、このシリーズの最初の 3 つの記事を読むことができます。この記事の最後に目次のリンクがあります。クロージャを説明するために、皆さんのために基礎知識を用意しました。ハハ、なんて大きなショーなんだ。
スコープ
1. JavaScript では、この一連のルールを使用して、エンジンが現在のスコープおよびネストされたサブスコープの名前を変数検索に使用する方法を管理します。 ここでの識別子は変数名または関数名を指します
2. JavaScriptにはグローバルスコープと関数スコープしかありません(evalは日常の開発ではほとんど使用されないため、ここでは説明しません)。
3. スコープと実行コンテキストは、まったく異なる概念です。混同している人も多いとは思いますが、しっかり区別してください。 JavaScript コードの実行プロセス全体は、コードのコンパイル段階とコードの実行段階の 2 つの段階に分かれています。コンパイル フェーズはコンパイラによって完了し、コードが実行可能コードに変換されます。スコープ ルールはこの段階で決定されます。実行フェーズはエンジンによって完了します。主なタスクは、実行可能コードを実行することです。このフェーズでは実行コンテキストが作成されます。
スコープチェーン以下に示すように、前の記事で分析した実行コンテキストのライフサイクルを確認してみましょう。
スコープチェーンは実行コンテキストの作成フェーズ中に生成されることがわかりました。これはおかしい。上でスコープがコンパイル段階でのルールを決定すると述べましたが、なぜ実行段階でスコープチェーンが決定されるのでしょうか? この質問がある理由は、誰もがスコープとスコープチェーンについて誤解しているからです。上で述べたように、スコープは一連のルールです。では、スコープ チェーンとは何でしょうか?これは、この一連のルールの具体的な実装です。これがスコープとスコープチェーンの関係であり、誰もがそれを理解する必要があると思います。関数が呼び出されてアクティブ化されると、実行コンテキスト生成プロセス中に、対応する実行コンテキストの作成が開始され、変数オブジェクト、スコープ チェーン、および this の値がそれぞれ決定されることがわかっています。前回の記事では変数オブジェクトについて詳しく説明しましたが、今回はスコープチェーンについて詳しく説明します。
スコープチェーンは、現在の環境と上位環境の一連の変数オブジェクトで構成されており、アクセス権限を満たす変数や関数への現在の実行環境の規則的なアクセスが保証されます。誰もがスコープチェーンを理解できるように、最初に例と対応する図を使って説明しましょう。
var a = 20; function test() { var b = a + 10; function innerTest() { var c = 10; return b + c; } return innerTest(); } test();
上記の例では、global、関数test、関数innerTestの実行コンテキストが連続して作成されます。変数オブジェクトをそれぞれ VO(global)、VO(test) として設定します。
VO(インナーテスト)。 innerTest のスコープ チェーンにはこれら 3 つの変数オブジェクトも含まれるため、innerTest の実行コンテキストは次のように表現できます。 是的,你没有看错,我们可以直接用一个数组来表示作用域链,数组的第一项scopeChain[0]为作用域链的最前端,而数组的最后一项,为作用域链的最末端,所有的最末端都为全局变量对象。 很多人会误解为当前作用域与上层作用域为包含关系,但其实并不是。以最前端为起点,最末端为终点的单方向通道我认为是更加贴切的形容。如图。 注意,因为变量对象在执行上下文进入执行阶段时,就变成了活动对象,这一点在上一篇文章中已经讲过,因此图中使用了AO来表示。Active Object 是的,作用域链是由一系列变量对象组成,我们可以在这个单向通道中,查询变量对象中的标识符,这样就可以访问到上一层作用域中的变量了。
二、闭包 对于那些有一点 JavaScript 使用经验但从未真正理解闭包概念的人来说,理解闭包可以看作是某种意义上的重生,突破闭包的瓶颈可以使你功力大增。 先直截了当的抛出闭包的定义:当一个函数可以记住并访问所在的作用域(全局作用域除外),并在定义该函数的作用域之外执行时,该函数就可以称之为一个闭包。 简单来说,假设函数A在函数B的内部进行定义了,并在函数B的作用域之外执行(不管是上层作用域,下层作用域,还有其他作用域),那么A就是一个闭包。记住这个定义,你在其他地方很难看到了。 在基础进阶(一)中,我总结了JavaScript的垃圾回收机制。JavaScript拥有自动的垃圾回收机制,关于垃圾回收机制,有一个重要的行为,那就是,当一个值,在内存中失去引用时,垃圾回收机制会根据特殊的算法找到它,并将其回收,释放内存。 而我们知道,函数的执行上下文,在执行完毕之后,生命周期结束,那么该函数的执行上下文就会失去引用。其占用的内存空间很快就会被垃圾回收器释放。可是闭包的存在,会阻止这一过程。 先来一个简单的例子。 在上面的例子中,foo()执行完毕之后,按照常理,其执行环境生命周期会结束,所占内存被垃圾收集器释放。但是通过fn = innerFoo,函数innerFoo的引用被保留了下来,复制给了全局变量fn。这个行为,导致了foo的变量对象,也被保留了下来。于是,函数fn在函数bar内部执行时,依然可以访问这个被保留下来的变量对象。所以此刻仍然能够访问到变量a的值。 这样,我们就可以称fn为闭包。 下图展示了闭包fn的作用域链。 所以,通过闭包,我们可以在其他的执行上下文中,访问到函数的内部变量。比如在上面的例子中,我们在函数bar的执行环境中访问到了函数foo的a变量。个人认为,从应用层面,这是闭包最重要的特性。利用这个特性,我们可以实现很多有意思的东西。 通过闭包,我们可以访问到函数的内部变量。这是闭包的一种特性,但是由于在其他很多地方,被用来当成闭包的定义,这其实是不准确的。 不过读者老爷们需要注意的是,虽然例子中的闭包被保存在了全局变量中,但是闭包的作用域链并不会发生任何改变。在闭包中,能访问到的变量,仍然是作用域链上能够查询到的变量。 对上面的例子稍作修改,如果我们在函数bar中声明一个变量c,并在闭包fn中试图访问该变量,运行结果会抛出错误。 上面的例子,可以很直观的感受到闭包的存在,但是还有一种情况的闭包,则更加隐蔽难以感受。我们来看一个例子。 这个例子中,函数bar在函数test的作用域中定义,然后被作为参数传入了函数foo中并在foo的作用域中被执行。根据定义,我们很容易知道函数bar就是一个闭包。因为其隐蔽性,很多人并没有意识到这就是一个闭包。这种情况,就是我们常常说的回调函数。在实际开发中,我们遇到的大多数回调函数都是闭包。 很多时候,回调函数都是匿名函数,但是要注意的是,在其他一些语言中,闭包与匿名函数是有区别的,但是JavaScript在实现匿名函数的时候允许形成闭包,当匿名函数作为参数传入函数中时,匿名函数的引用会保存在改函数变量对象的arguments对象中。因此在JavaScript中,我们可以不用那么严格的区别闭包与匿名函数。 闭包的应用场景 接下来,我们来总结下,闭包的常用场景。 延迟函数setTimeout 我们知道setTimeout的第一个参数是一个函数,第二个参数则是延迟的时间。在下面例子中, 执行上面的代码,变量timer的值,会立即输出出来,表示setTimeout这个函数本身已经执行完毕了。但是一秒钟之后,fn才会被执行。这是为什么? 按道理来说,既然fn被作为参数传入了setTimeout中,那么fn将会被保存在setTimeout变量对象中,setTimeout执行完毕之后,它的变量对象也就不存在了。可是事实上并不是这样。至少在这一秒钟的事件里,它仍然是存在的。这正是因为闭包。 很显然,这是在函数的内部实现中,setTimeout通过特殊的方式,保留了fn的引用,让setTimeout的变量对象,并没有在其执行完毕后被垃圾收集器回收。因此setTimeout执行结束后一秒,我们任然能够执行fn函数。 回调函数 在上面的例子中,我们已经解释过了回调函数。所以就不再多说。 柯里化 在函数式编程中,利用闭包能够实现很多炫酷的功能,柯里化算是其中一种。关于柯里化,我会在以后详解函数式编程的时候仔细总结。 模块 在我看来,模块是闭包最强大的一个应用场景。如果你是初学者,对于模块的了解可以暂时不用放在心上,因为理解模块需要更多的基础知识。但是如果你已经有了很多JavaScript的使用经验,在彻底了解了闭包之后,不妨借助本文介绍的作用域链与闭包的思路,重新理一理关于模块的知识。这对于我们理解各种各样的设计模式具有莫大的帮助。 在上面的例子中,我使用函数自执行的方式,创建了一个模块。方法add被作为一个闭包,对外暴露了一个公共方法。而变量a,b被作为私有变量。在面向对象的开发中,我们常常需要考虑是将变量作为私有变量,还是放在构造函数中的this中,因此理解闭包,以及原型链是一个非常重要的事情。模块十分重要,因此我会在以后的文章专门介绍,这里就暂时不多说啦。 为了验证自己有没有搞懂作用域链与闭包,这里留下一个经典的思考题,常常也会在面试中被问到。 利用闭包,修改下面的代码,让循环输出的结果依次为1, 2, 3, 4, 5 关于作用域链的与闭包我就总结完了,虽然我自认为我是说得非常清晰了,但是我知道理解闭包并不是一件简单的事情,所以如果你有什么问题,可以在评论中问我,留言必回。你也可以带着从别的地方没有看懂的例子在评论中留言。大家一起学习进步。innerTestEC = {
VO: {...}, // 变量对象
scopeChain: [VO(innerTest), VO(test), VO(global)], // 作用域链
this: {}
}
var fn = null;
function foo() {
var a = 2;
function innnerFoo() {
console.log(a);
}
fn = innnerFoo; // 将 innnerFoo的引用,赋值给全局变量中的fn
}
function bar() {
fn(); // 此处的保留的innerFoo的引用
}
foo();
bar(); // 2
var fn = null;
function foo() {
var a = 2;
function innnerFoo() {
console.log(c); // 在这里,试图访问函数bar中的c变量,会抛出错误
console.log(a);
}
fn = innnerFoo; // 将 innnerFoo的引用,赋值给全局变量中的fn
}
function bar() {
var c = 100;
fn(); // 此处的保留的innerFoo的引用
}
foo();
bar();
function test() {
function bar (str) {
console.log(str);
}
function foo (fn, string) {
fn(string);
}
foo(bar, 'this is closure');
}
test();
function fn() {
console.log('this is test.')
}
var timer = setTimeout(fn, 1000);
console.log(timer);
(function () {
var a = 10;
var b = 20;
function add(num1, num2) {
var num1 = !!num1 ? num1 : a;
var num2 = !!num2 ? num2 : b;
return num1 + num2;
}
window.add = add;
})();
for (var i=1; i<=5; i++) {
setTimeout( function timer() {
console.log(i);
}, i*1000 );
}