在解释闭包(Closure)之前,要先知道作用域及范围链是什么,才能更好的解释闭包。在ES6 以前,作用域只有global 全域以及function 里的作用域,定义变数则都是使用var来宣告。在ES6 时出现了const与let,同时也增加了新的作用域block,var渐渐的被取代不再被使用,下面先来了解一下作用域是什么。
对于作用域我自己的解释是:
把作用域当成一个只进不出国家,外面的人可以顺利地进来,但是只要是在国家内出生的人民,一辈子都无法出去...
转换成实际上的解释就是,在作用域范围内生成的变数,无法被外面的区域做使用,但外面的区域变数,是可以被里面的作用域做使用的。
而作用域的范围可以分为三种:
1. 全域(global scope)
2. 函式作用域(function scope)
3. 区块作用域(block scope)
顾名思义,就是一个最外层的区域,没有比全域再更外层的了,而在全域宣告的变数或是函式,就称全域变数,而全域变数可以在任何地方被使用。
范例:
const global = 'global variable'
function str(){
console.log(global);
}
str(); //output: 'global variable'
特别注意的是如果没有宣告变数而直接赋值,不管在哪个作用域,则该变数都会变成全域变数,这样就很容易出问题,所以尽量一定要宣告变数。
范例:
//Ex1: function
function test() {
//函式內未宣告直接賦值
a = 3;
}
test();
console.log(a); //output: 3
//Ex2: block
for (let i = 0; i < 5; i++) {
//區塊內未宣告直接賦值
b = 5;
}
console.log(b); //output: 5
一样顾名思义,可以马上理解就是在函式(Function)内的区域,也就是执行区域{}内就是函式的作用域,对应函式作用域的宣告就是var。
这时候就有一个问题了,刚刚提到如果没有宣告变数,则该变数会变全域变数,那在Function 的参数区域()里面的变数也不用宣告,那里面的参数会变全域变数吗?
这边就要提一下函式的生成流程,不过因为有点复杂,所以只先大概提一下以供解释上面的问题。
可以先想像在函式生成时,会产生该函式的执行环境(Execution Context),以下简称EC,EC 里面储存了跟函式有关的资讯,顺带一提除了函式,全域执行时也有一个全域执行环境(global EC),每个EC 生成时都会有相对应的变数物件(Variable Object),以下简称VO ,宣告的变数及函式都会被储存在VO 里面。
而函式的VO 另外称作执行物件(Activation Object),以下简称AO ,AO 跟VO 一样,差别在于AO 除了储存{}内的变数还储存了()内的参数,因为AO 是对应函式的EC 而存在,所以不用宣告参数也会在函式的作用域里面。
范例:
function age(number) {
var str = 'My age is:';
console.log(str,number);
}
age(5); //output: My age is: 5
console.log(number); //ReferenceError: number is not defined
console.log(str); //ReferenceError: str is not defined
在ES6 新增的作用域,那区块(block)是指什么呢?区块指的是{}大括号范围内的区域都称之区块,像是if 及switch 判断式、while 及for 回圈,包括function 的大括号范围都是区块。不过不是指var宣告变数变成区块变数,他还是只能在function 里才有作用域的效果(可怜的var)。
而对应的区块变数宣告就是新增的const跟let,在大括号内宣告(当然包含function)都会有作用域的效果。
那在回圈的小括号()里宣告的变数呢?
范例:
//Ex1:
for (var j = 0; j < 5; j++) {
console.log(j); //output: 0 1 2 3 4
}
console.log(j); // output: 5
//Ex2:
for (let i = 0; i < 5; i++) {
console.log(i); //output: 0 1 2 3 4
}
console.log(i); // ReferenceError: i is not defined
可以看到有两个for 回圈,分别在小括号里面用var及let分别做宣告,可以发现在小括号里宣告var可以在global 抓到变数,而用let做宣告,无法在global 抓到变数。所以可以证实在回圈的小括号()内宣告变数,是一个区块变数。
所以说不管是在回圈的()里面还是在{}里面宣告变数(使用区块宣告),都会有作用域的效果,都只能在{}内才能被使用,到了外层则无法抓到该变数。
至于常看到的解释,只有在{}宣告区块变数,就是区块作用域,我想会这么说是因为比较好解释?毕竟变数只能在{}里调用,()也只是宣告变数而已。所以说在{}内就是区块作用域其实没错,只是要记得在回圈的()内宣告区块变数也会在{}有区块作用域的效果喔!
以上这只是我的猜测,如果有人知道为什么会这样解释可以在底下留言!
了解了作用域后var、const、let就很容易理解拉!
var就是在函式内的宣告,会产生function scope。
let、const则是在区块内宣告,会产生block scope。
var大家都很熟了,那let跟const同样都是block scope,有什么差别呢?
可以宣告变数不赋值,这点跟var一样。
可以更改变数的内容。
相同变数在同一层无法重新宣告。
范例:
let a;
console.log(a); // output: undefined
a = 2
console.log(a); // output: 2
let a = 3; // SyntaxError: Identifier 'a' has already been declared
不可以宣告变数不赋值。
不可以更改变数的值。
相同变数在同一层无法重新宣告。
范例:
const a; //SyntaxError: Missing initializer in const declaration
const b = 2;
console.log(b); //output: 2
b = 3;
console.log(b); //TypeError: Assignment to constant variable.
const c = 2;
const c = 3; //SyntaxError: Identifier 'c' has already been declared
来做一个表格整理:
特性 | var | let | const |
---|---|---|---|
作用域 | function | block | block |
宣告变数是否需要赋值 | ❌ | ❌ | ✅ |
宣告后是否可以更改内容 | ✅ | ✅ | ❌ |
宣告后是否可以重新宣告 | ✅ | ❌ | ❌ |
是否有hoisting 效果 | ✅ | ❌ | ❌ |
在进入到Closure(闭包)之前,还需要了解的一个概念,就是范围链(Scope Chain),先来看一道经典题目:
var number = 1;
function b() {
console.log(number);
}
function a() {
var number = 100;
b();
}
a();
宣告一个全域变数number = 1,建立funA 里面重新宣告number = 100,并包了一个funB ,则funB 里面console.log(number)会跑出什么答案呢?
答案是:1
有没有答对呢~如果没答对是正常的,我一开始也没答对XD,大部分的人一开始一定都会觉得,欸~~ funB 不是在funA 里面呼叫,那funB 抓外面的变数number不就是100 吗?
而这个就要牵扯到前面提到的执行环境EC 了,当函式建立执行环境时,同时也建立了外部环境参考(Reference to Outer Environment),也就是谁才是function外的环境,而JavaScript 的外部环境参考,是依照静态作用域(Static Scope)又称词汇作用域(lexical Scope)为准则。
什么是静态作用域呢?简单讲就是物理上的程式码范围,虽然funB 是在funA 里面被呼叫,但是funB 在建立执行环境时(也就是funB 被建立的位置),外层的环境就是global,所以 才会是抓到globalnumber的number = 1。
而既然有静态作用域,当然也有动态作用域(Dynamic Scope)啦,在某些语言的情况下刚刚那个问题,答案就会是100 没错喔,是不是很酷!
动态的意思就是,外部参考环境是根据被呼叫的当下,所在的环境就是外部执行环境,恰好跟静态作用域相反。
那看回原本的那个范例,funB 要console.log(number),可是funB 里面没有number那怎么办呢?这时候他就会往外找看有没有人定义number是什么?直到找到最外层的global。
而这个往外找的机制就称作Scope Chain。
终于可以进入到闭包了,有了上面的观念,就可以比较好解释闭包了。
先来看一个范例:
function count() {
var number = 5;
function addTen() {
console.log(number + 10);
}
addTen();
}
count(); //output: 15
这是一个普通的例子,呼叫一个count的function ,里面定义一个变数number的值为5 ,然后建立一个addTen的function,印出number + 10,然后执行addTen。
执行后就会印出15 。
这时候问题来了,那如果是在countfunction 里面回传addTen的function 呢?
function count() {
var number = 5;
function addTen() {
console.log(number + 10);
}
return addTen;
}
var answer = count();
answer(); //output: 15
跟刚刚不同的是,这次不执行addTen,而是回传它,并且把它传入一个新的变数answer,然后执行answer,神奇的事情发生了,按照之前讲的外部参考环境的规则,应该会跑出ReferenceError: number is not defined才对。
var answer = count();
// count() 回傳 addTen,而 addTen = function(){console.log(number)}
//等同於:
var answer = function(){
console.log(number)
}
answer();
怎么还是跑出15 呢?就好像变数被存在function 里面一样,没错!这就是闭包常见的解释,明明已经执行完count了,但里面的变数却能被answer存取到!而造成这样的原因有两个。
第一个原因就要提到刚刚讲的范围链(Scope Chain)了,已经知道Scope Chain就是当抓不到变数时,就会往外层找,直到global 层,而前面也提到funciton 的执行环境EC 会产生对应的执行物件AO。但其实EC 还会产生对应的Scope Chain,里面装了AO 以及function 的[[Scope]]属性,可以简化成以下的式子:
scope chain = activation object + [[Scope]]
AO 我们已经知道是function 里的参数以及变数,那[[Scope]]应该可以大致猜到是什么了吧?没错!!就是往外找的所有AO + VO 的物件。
所以看回刚刚的例子:
function count() {
var number = 5;
function addTen() {
console.log(number + 10);
}
return addTen;
}
当建立了addTen的function 时,也建立了一个[[Scope]]的属性,里面装了外层的所有AO + VO,就把{ number: 5 }装进了[[Scope]]里面,所以只要addTen这个function 存在,永远可以透过它去抓到{ number: 5 }。
那除此之外,第二个原因当然就是因为在function 里面回传一个function,如果只是单纯的在count里面放一个addTen,怎么在count的外面抓到addTen的[[Scope]]呢?
所以说闭包(Closure)并不是一个像作用域阿,范围链等有明确定义的东西,它比较像一个现象,因为Scope Chain以及回传function所产生的效果。
那闭包有什么好处呢?我们可以把一些不想被更动的变数藏在function 里面,这样就无法因为外面的程式码而改变数值,但是需要的时候,还是可以得到藏在function 里的变数。
在实际写程式时,并不会特别的想说要怎么把Closure应用在程式码,它比较像一个JavaSctipt自动产生的一个现象,我们不能透过Closure 去更改函式里的变数,但是是可以得到函式里的变数值,除非特殊情况才会特别使用它。
所以下次在看到Closure 时,就要马上想到是因为function有[[Scope]]的属性且回传function,所造成的现象!
48 天前