学习链接
JavaScript深入之闭包(👍👍👍)
现代 JavaScript 教程:变量作用域,闭包(👍👍👍)
- 闭包的定义,并解释清楚为什么 JavaScript 中的所有函数都是闭包的
- 以及可能的关于
[[Environment]]
属性和词法环境原理的技术细节
闭包
闭包定义
闭包 closure 是 1964 年提出来的一个计算机编程的概念,按照当时论文《表达式的机器执行》的表述,它的定义包含控制和环境两个部分,在 JavaScript 中,以函数能够访问其定义时的环境中变量的方式得以实现。
当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这就产生了闭包。
在 JavaScript 中,所有函数都是天生闭包的(只有一个例外 new Function
,见 补充)。
也就是说:JavaScript 中的函数会自动通过隐藏的 [[Environment]]
属性记住创建它们的词法作用域,所以它们都可以访问外部变量。
本质
在本质上,闭包就是将函数内部和函数外部连接起来的一座桥梁,
也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。
每当创建一个函数,闭包就会在函数创建的同时被创建出来。
两个角度
或许从两个角度来看待闭包,就会少很多分歧和误解:
- 从理论角度:所有的函数都是闭包。
因为它们都在创建的时候就将上层上下文的数据保存起来了,都可以访问外部的变量。 - 从实践角度:以下函数才算是闭包:
- 即使创建它的上下文已经销毁,它仍然存在(比如,内部函数从父函数中返回)
- 在代码中引用了外部函数变量
细节概括
在 JavaScript 中,每个运行的函数,代码块 {...}
以及整个脚本,都有一个被称为 词法环境(Lexical Environment) 的内部(隐藏)的关联对象。
词法环境对象由两部分组成:
- 环境记录(Environment Record) —— 一个存储所有局部变量作为其属性(包括一些其他信息,例如
this
的值)的对象。 - 对 外部词法环境的引用,与外部代码相关联。
一个“变量”只是 环境记录 这个特殊的内部对象的一个属性。“获取或修改变量”意味着“获取或修改词法环境的一个属性”。
- 变量是特殊内部对象的属性,与当前正在执行的(代码)块/函数/脚本有关。
- 操作变量实际上是操作该对象的属性。
变量声明
当脚本开始运行,词法环境预先填充了所有声明的变量。
- 最初,它们处于“未初始化(Uninitialized)”状态。
- 这是一种特殊的内部状态,这意味着引擎知道变量,但是在用
let
声明前,不能引用它。(暂时性死区)
函数声明
与变量不同,函数声明的初始化会被立即完成。
当创建了一个词法环境时,函数声明会立即变为即用型函数(不像 let
那样直到声明处才可用)。
所有的函数在“诞生”时都会记住创建它们的词法环境。
所有函数都有名为 **[[Environment]]**
的隐藏属性,该属性保存了对创建该函数的词法环境的引用。
这就是函数记住它创建于何处的方式,与函数被在哪儿调用无关。[[Environment]]
引用在函数创建时被设置并永久保存。
函数调用
在一个函数运行时,在调用刚开始时,会自动创建一个新的词法环境以存储这个调用的局部变量和参数,并且其外部词法环境引用获取于 函数的 **[[Environment]]**
属性。
访问修改变量
当代码要访问一个变量时 —— 首先会搜索内部词法环境,然后搜索外部环境,然后搜索更外部的环境,以此类推,直到全局词法环境。
在变量所在的词法环境中更新变量,这也就意味着多次调用同一内部函数,使用的也是同一个外部函数的变量。
细节展开
变量
矩形表示环境记录(变量存储),箭头表示外部引用。
此时的词法环境只有一个,全局词法环境。全局词法环境没有外部引用,所以箭头指向了 null
。
- 当脚本开始运行,词法环境预先填充了所有声明的变量。
- 最初,它们处于“未初始化(Uninitialized)”状态。
- 这是一种特殊的内部状态,这意味着引擎知道变量,但是在用
let
声明前,不能引用它。(暂时性死区)
- 然后
let phrase
定义出现了。它尚未被赋值,值为undefined
。此时就可以使用变量了。 phrase
被赋予了一个值。phrase
的值被修改。
- 变量是特殊内部对象的属性,与当前正在执行的(代码)块/函数/脚本有关。
- 操作变量实际上是操作该对象的属性。
词法环境是一个规范对象
“词法环境”是一个规范对象(specification object):它仅仅是存在于 编程语言规范 中的“理论上”存在的,用于描述事物如何运作的对象。我们无法在代码中获取该对象并直接对其进行操作。
函数声明
一个函数其实也是一个值,就像变量一样。
不同之处在于函数声明的初始化会被立即完成。
当创建了一个词法环境(Lexical Environment)时,函数声明会立即变为即用型函数(不像 let
那样直到声明处才可用)。
这就是为什么我们可以在(函数声明)的定义之前调用函数声明。
正常来说,这种行为仅适用于函数声明,而不适用于我们将函数分配给变量的函数表达式,例如 let say = function(name)...
。
内部和外部的词法环境
在一个函数运行时,在调用刚开始时,会自动创建一个新的词法环境以存储这个调用的局部变量和参数。
在这个函数调用期间,总共有两个词法环境:内部一个(用于函数调用)和外部一个(全局):
- 内部词法环境与
say
的当前执行相对应。它具有一个单独的属性:name
,函数的参数。 - 外部词法环境是全局词法环境。它具有
phrase
变量和函数本身。
内部词法环境引用了 outer
。
当代码要访问一个变量时 —— 首先会搜索内部词法环境,然后搜索外部环境,然后搜索更外部的环境,以此类推,直到全局词法环境。
- 对于
name
变量,当say
中的alert
试图访问name
时,会立即在内部词法环境中找到它。 - 当它试图访问
phrase
时,然而内部没有phrase
,所以它顺着对外部词法环境的引用找到了它。
返回函数
尚未运行内部函数
在每次 makeCounter()
调用的开始,都会创建一个新的词法环境对象,以存储该 makeCounter
运行时的变量。
在执行 makeCounter()
的过程中创建了一个仅占一行的嵌套函数:return count++
。此时尚未运行它,仅创建了它。
所有的函数在“诞生”时都会记住创建它们的词法环境。
所有函数都有名为 **[[Environment]]**
的隐藏属性,该属性保存了对创建该函数的词法环境的引用。
因此,counter.[[Environment]]
有对 {count: 0}
词法环境的引用。这就是函数记住它创建于何处的方式,与函数被在哪儿调用无关。[[Environment]]
引用在函数创建时被设置并永久保存。
开始运行内部函数
当调用 **counter()**
时,会为该调用创建一个新的词法环境,并且其外部词法环境引用获取于 **counter.[[Environment]]**
。
当 counter()
中的代码查找 count
变量时,它首先搜索自己的词法环境(为空,因为那里没有局部变量),然后是外部 **makeCounter()**
的词法环境,并且在哪里找到就在哪里修改。
在变量所在的词法环境中更新变量。
执行后:
应用场景
任何闭包的使用场景都离不开这两点:
- 创建私有变量
- 延长变量的生命周期
一般函数的词法环境在函数返回后就被销毁,但是闭包会保存对创建时所在词法环境的引用,即便创建时所在的执行上下文被销毁,但创建时所在词法环境依然存在,以达到延长变量的生命周期的目的
- 可以读取函数内部的变量。
- 可以使变量的值长期保存在内存中,生命周期比较长。因此不能滥用闭包,否则会造成网页的性能问题。
使用闭包模拟私有方法:数据内部私有,暴露方法或函数供外面使用。
柯里化函数:把一个多参数函数转化成一个嵌套的一元函数,便于重用。
缺点
性能
如果不是某些特定任务需要使用闭包,在其它函数中创建函数是不明智的,因为闭包在处理速度和内存消耗方面对脚本性能具有负面影响。
内存泄漏
栈内存提供一个执行环境,即作用域,包括全局作用域和私有作用域。
- 全局作用域:只有当页面关闭的时候全局作用域才会销毁
- 私有的作用域:只有函数执行才会产生
一般情况下,函数执行会形成一个新的私有的作用域,当私有作用域中的代码执行完成后,我们当前作用域都会主动的进行释放和销毁。
但当遇到函数执行返回了一个引用数据类型的值,并且在函数的外面被一个其他的东西给接收了,这种情况下一般形成的私有作用域都不会销毁。
function fn(){
let num = 100;
return function(){
return num * 2;
}
}
const f = fn(); // fn执行形成的这个私有的作用域就不能再销毁了
也就是像上面这段代码,fn函数内部的私有作用域会被一直占用的,发生了内存泄漏。
所谓内存泄漏,指的是任何对象在我们不再拥有或需要它之后仍然存在。
- 闭包不能滥用,否则会导致内存泄露,影响网页的性能。
- 闭包使用完后,要立即释放资源,将引用变量指向
**null**
。
补充
现代 JavaScript 教程:”new Function” 语法
通常,闭包是指使用一个特殊的属性 [[Environment]]
来记录函数自身的创建时的环境的函数。它具体指向了函数创建时的词法环境。
但是如果我们使用 **new Function**
创建一个函数,那么该函数的 **[[Environment]]**
并不指向当前的词法环境,而是指向全局环境。
因此,此类函数无法访问外部(outer)变量,只能访问全局变量。
function getFunc() {
let value = "test";
let func = new Function('alert(value)');
return func;
}
getFunc()(); // error: value is not defined
可正常访问全局变量
let value = "test";
let func = new Function('alert(value)');
func(); // value
语法:
let func = new Function ([arg1, arg2, ...argN], functionBody);
由于历史原因,参数也可以按逗号分隔符的形式给出。
以下三种声明的含义相同:
new Function('a', 'b', 'return a + b'); // 基础语法
new Function('a,b', 'return a + b'); // 逗号分隔
new Function('a , b', 'return a + b'); // 逗号和空格分隔
用 new Function
创建的函数,它的 [[Environment]]
指向全局词法环境,而不是函数所在的外部词法环境。
因此,我们不能在 new Function
中直接使用外部变量。不过这样是好事,这有助于降低我们代码出错的可能。并且,从代码架构上讲,显式地使用参数传值是一种更好的方法,并且避免了与使用压缩程序而产生冲突的问题。