纸上得来终觉浅,绝知此事要躬行
前言🎀
作用域与闭包频繁出现在文章、面试和代码中,理解它们对于学习JavaScript和工作编码都会有不小的帮助,《You Dont Konw JS》中作者甚至将理解它比喻为Neo第一次见到矩阵。
你可能见过不少类似结构的代码:
javascript
function sayWord(word) {
return () => console.log(word);
}
const sayHello = sayWord("hello");
sayHello(); // "hello"
console.log(word); // ReferenceError
这段代码存在一个有趣的地方:通过sayHello
函数我们可以在word
不生效的地方访问到它,你可能认为这是理所当然的事,但这其中却涉及到大量JavaScript的底层原理。
在本文中,我们将了解实现此行为的作用域和闭包,以及它们的延伸知识,希望本文能对你有所帮助。
如果觉得有收获还望大家点赞、收藏 🌹
作用域 & 词法作用域
javascript
var a = 10;
console.log(a); // 10
一段很简单代码,但程序是在哪读取、怎么去读取a
并知道它的值为10
的呢?
javascript
{
let num = 10;
console.log(num); // 10
}
console.log(num); // ReferenceError
而这段代码,为什么外部的console.log
无效而内部的有效呢?
这都与作用域脱不开关系,而 什么是作用域 和 为什么需要作用域? 我们带着这样的问题进入本章
作用域
1. 概念 - 存储、访问变量的规则
程序为了能够存储、访问和修改变量中的值,需要一套规则来存储变量,并且方便之后找到这些变量。
作用域就是这套规则,它收集并维护所有变量,限制了变量的有效范围,规定了程序如何根据识符名称查找变量。
而规则是怎样设定的?程序如何使用作用域?围绕着这两个问题进入下文
2. 查找规则
因为编译器的存在,任何JavaScript代码执行前都要经过编译(具体看预编译部分)。
程序运行的过程中,引擎 (负责程序编译、执行) 编译器 (负责语法解析、代码生成) 作用域(维护所有标识符、控制访问权限)都会参与运行。
而以下代码会被拆分为两个动作var a
、a = 10
,分别在 编译器编译时处理 和 引擎运行时处理。
javascript
var a = 10;
- 编译时
var a
,编辑器会在当前作用域中声明一个变量命名为a(如果之前并未声明) - 运行时
a = 10
,引擎会在作用域中查找该变量,如果能够找到就会对它赋值,否则抛出异常
引擎根据查找目的决定执行怎样的查找(RHS / LHS),从而影响最终的查找结果。
- RHS查询:查找目的是获取变量的值,不成功的RHS引用会抛出ReferenceError异常
- LHS查询:查找目的是获取变量容器本身,从而对其进行赋值,不成功的LHS引用,严格模式下会抛出ReferenceError异常,非严格模式会隐式创建一个全局变量
比如console.log(a);
,会触发RHS查询,因为这里需要查找并取得a的值,这样才能将值传递给console.log(..)
,与a变量的容器没有关系
比如var b = a;
, b = ..
会触发LHS查询, = a;
会触发RHS查询
LHS和RHS并不一定意味着就是"=赋值操作符的左侧或右侧",最好将其理解为:1. LHS:赋值操作的目标是谁 、 2. RHS:谁是赋值操作的源头
3. 作用域嵌套
查询会从当前执行作用域开始,如果有需要(它们没有找到所需的标识符),就会向上级作用域 (作用域是可以嵌套的) 继续查找目标标识符,这样每次上升一级作用域,最后抵达全局作用域(顶层),之后查找过程停止。
js
{
const foo = "foo";
console.log(foo); // "foo"
{
const bar = "bar";
console.log(foo); // "foo"
{
console.log(foo, bar); // "foo bar"
}
}
}
4. 分类 - 作用域的生效范围
作用域又分为 全局作用域、函数作用域、块级作用域 三种类型,它们各自有不同的生效范围。
4.1 全局作用域
全局作用域里的变量可以在任何地方访问和使用,以下几种情况拥有全局作用域:
- 声明在最外层的变量和函数
- 非严格模式下在函数内赋值却未定义的变量
- window对象下的所有属性和函数
javascript
var num1 = 10;
function test1() {
num2 = 20;
console.log(num1);
}
window.num3 = 30;
test1(); // 10
console.log(num2); // 20
console.log(num3); // 30
4.2 函数作用域
相比于容易产生变量冲突的全局作用域,函数作用域仅在函数内部生效,函数外部无法使用。
在函数内部声明的变量拥有函数作用域。
javascript
function test() {
var num = 10;
console.log(num); // 10
}
console.log(num); // ReferenceError
4.3 块级作用域
与函数作用域类似,块级作用域量只在当前块中有效,在块外无法访问。
以下情况会形成块级作用域:
- let、const声明的变量
- {}括住的代码块,包含if() {}, for() {}, while() {}, switch() {}
javascript
{
let num = 10;
}
console.log(num); // ReferenceError
if (condition) {
let num2 = 20;
console.log(num2); // 20
} else {
console.log(num2); // ReferenceError
}
词法作用域
1. 概念 - 作用域的工作模型
作用域并不是JavaScript独有的,很多编程语言都有这个概念。
作用域共有两种工作模型:动态作用域 和 静态作用域,JavaScript采用的是静态作用域 (又称词法作用域)。
它的一大特征是:作用域只由书写代码时函数声明的位置决定,当你把函数编写到某个位置时作用域就已经确定了,与它如何调用、在何处调用无关。
javascript
function fn() {
console.log(num);
}
function show() {
var num = 20;
fn();
}
var num = 10;
show(); // output:?
上述代码经历以下几个执行过程:
- show函数被调用执行,执行过程中调用fn函数
- fn函数执行
console.log(num)
,查找当前的函数作用域内是否存在变量num - 未查找到num,根据
fn函数
定义的位置 ,向上一层作用域发起查找,找到num = 10
与之相对的动态作用域,在调用时才确定,JavaScript中
this
的指向就类似动态作用域,它不关心函数和作用域如何声明以及何处声明,只关心它们从何处调用
2. 原理 - 定义在词法分析阶段的作用域
简单地说,JavaScript在编译器编译代码时,就已经将代码中的变量和函数绑定到相应的作用域中。
因此作用域在函数定义时就确定了,这种机制可以保证函数在任何时候都可以访问其定义时所在的作用域中的变量和函数。
这是词法作用域的核心机制,也是实现闭包的关键。这也是个人喜欢把闭包理解为:词法作用域 + JS中函数也是对象 的必然结果
案例
javascript
function fn() {
console.log(num);
}
var num = 20;
function show(f1) {
var num = 10;
(function() {
var num = 30;
f1();
})();
}
show(fn); // output:?
章节总结
- 作用域是一套规则,负责收集变量并维护它们的访问
- 代码先进行编译创建作用域,引擎在作用域的协助下,根据目的进行 LHS / RHS 查询
- 作用域的类型分为 全局作用域、函数作用域、块级作用域,它们各自有不同的生效范围
- 作用域之间可以嵌套,查询会从当前作用域开始,根据需要向上级作用域继续查找目标,直到全局作用域
- 作用域的工作模型是词法作用域,它在词法分析阶段确认,内容只与代码编写的位置有关,与怎么调用、在哪调用无关
JavaScript为什么引入了编译器?又对代码执行有什么影响?我们进入下文探究
预编译 & 执行上下文
JavaScript代码确实可以不经处理直接在浏览器中运行,但为了提升代码性能和安全性,多数浏览器引擎都引入了编译器(例如V8引擎采用了解释器+编译器)。
下方代码打印undefined
而非ReferenceError
或1
,也证明了程序的流程并非直接解释执行。
javascript
console.log(a); // undefined
var a = 1;
所以 代码执行的过程究竟是怎样的?经历了哪些操作?
这里我们引入两个概念:预编译 & 执行上下文
预编译
1. 概念 - 为代码执行做准备工作
任何JavaScript代码片段并不会直接解释执行
而是先编译
,大部分编译发生在代码片段执行前的几微秒(甚至更短) 而不是整体程序执行前。这也是它被视作即时编译型语言的原因。
JavaScript的编译整体可分为三个步骤:
- 语法分析,检查语法错误
- 预编译代码
- 解释执行,逐行解释并执行
其中预编译的目的是在执行前对代码进行解析和预处理,为代码执行做准备工作。
2. 原理 - 创建执行上下文并初始化变量对象
为了更快地查找变量和函数,提高代码的执行效率,JavaScript引擎在预编译时会生成执行上下文 并初始化其中的变量对象。
预编译分为两种:函数预编译 和 全局(函数)预编译
函数预编译:发生在函数执行前,具体过程分四步:
- 创建
AO
对象(Activation Object,活动对象) 记录函数中的变量和函数- 寻找形参和变量声明,将形参名和变量名作为
AO
对象的属性名,值为undefined
- 形参和实参相统一,实际参数赋值给形式参数
- 在函数体里找函数声明,将函数名作为
AO
对象的属性名,值为该函数体
全局预编译 :发生在页面加载完成时,JS会先创建对象 GO
(Global Object,全局对象),步骤与函数预编译类似,但是全局函数没有参数,也就少了一步(形参和实参相统一)。
javascript
console.log(b)
a = 2;
var a;
var b = 3;
console.log(a);
console.log(fn);
function fn(num) { ++num };
// 1. 生成GO
// GO : {
// 2. 寻找形参和变量声明,将形参名和变量名作为`GO`对象的属性名,值为`undefined`
// a: undefined
// b: undefined
//
// 3. 在函数体里找函数声明,将函数名作为`GO`对象的属性名,值为该函数体
// fn: function(num) { ++num }
//
// 函数执行
// console.log(b) // undefined
// a: undefined -> 2
// b: undefined -> 3
// console.log(a) // 2
// console.log(fn) // ƒ fn(num) { ++num }
// }
注意:函数声明 ≠ 函数表达式 ,即
var fn1 = function() {}
只会被当做变量声明处理,提前打印console.log(fn1);
输出undefined
执行上下文
上文说到预编译会产生执行上下文来为代码执行做准备
,那么执行上下文是什么呢?它由什么组成?
1. 概念 - 代码执行的环境
- 例如阅读一篇文章的某一部分内容需要结合上下文来理解一样
- 遇到可执行代码的时候也要提供一个环境来让代码、函数正常运行,执行上下文就是代码执行的环境
一个执行上下文的生命周期可分为 创建阶段 和 代码执行阶段 两个阶段。
创建阶段: 这个阶段就是前文中的函数预编译,执行上下文会分别进行以下操作
- 创建 变量对象,存储当前作用域的变量和函数
- 建立 作用域链,记录外部环境
- 确定 this 的指向,确定调用者信息
代码执行阶段: 创建完成之后,就会开始执行代码,并依次完成以下步骤
- 变量赋值
- 函数引用
- 执行其他代码
2. 作用 - 存储当前环境的容器
执行上下文类似一个容器内部存储了必要的数据,供运行时读取。
我们可以将上下文理解为包含以下属性的一个 object
:
- 变量对象
Variable Object,VO
/ 活动对象Activation Object,AO
- 作用域链
Scope Chain
- 调用者信息
this
注:VO、AO 是同一个对象,只是处于执行上下文的不同生命周期。在执行阶段,VO转化为AO。
JS引擎会在程序开始时创建执行上下文栈(Execution Context Stack)简称ECS
,它是执行代码的调用栈,用来维护代码运行时遇到的执行上下文。
在函数执行时生成执行上下文入栈(每个上下文独一无二,多次调用同一个函数产生多个上下文),函数执行完成后销毁上下文出栈。
案例
javascript
function a(a) {
var a = a + 1;
console.log(a);
}
var a;
a(2);
console.log(a);
javascript
console.log(sum);
function sum(a, b) {
console.log(a);
var a = 10;
console.log(a);
var b = 5;
console.log(fn);
var fn = function() {}; // 函数表达式
console.log(b);
}
// 调用函数
sum(2, 2);
章节总结
- JavaScript引擎为了提升代码执行效率引入了编译器
- 代码执行前先进行编译,流程如下:(语法检查 -> 预编译 -> 解释执行)
- 预编译分为 函数预编译 和 全局预编译 ,会创建执行上下文初始化变量对象为代码执行提供准备
- 执行上下文由 变量对象
Variable Object
、作用域链Scope Chain
、调用者信息this
共同组成 - 由于预编译的原因,变量、函数声明无论处在作用域中的任何地方,都将在执行前被预处理,起到声明提升的效果
- 函数声明会整体提升,变量只有声明提升,赋值并不会提升
不同ES版本的上下文存在差异,导致关于执行上下文说法各有不同。而我们只要记住核心------执行上下文是为了给代码提供执行环境创建的,内部存储了必要的数据,供运行时读取
变量对象起到了提升效果,而作用域链又是什么?对代码执行又有什么影响?
作用域链
创建执行上下文的同时也会创建作用域链,它是由当前上下文和所有父级上下文的变量对象共同形成的链表。
根据查找规则,JavaScript 执行查找变量会从作用域链顶端(始终是当前函数自身的AO)开始依次查找对应变量, 如果找不到就会沿着作用域链一层层向外查找(即从父级上下文的变量对象到全局上下文的变量对象)。
如果说执行上下文像是 内部和外部环境的集合(在ES后续的发展中得到了验证),其中变量对象体现了函数自身的内部环境,而作用域链更多体现了函数与外部环境(outer)的关联
[[Scopes]] & 闭包
假设存在一个嵌套函数:
- 父函数执行完成、返回内部的子函数、同时执行上下文出栈销毁
- 而返回的子函数执行时、父函数的上下文已经销毁、变量对象也已不存在
又该如何构建作用域链呢?
这涉及到闭包的机制,而关于闭包要一个对象属性开始~
[[Scopes]]
关于[[Scopes]]
可能很多人跟我之前一样理解存在误区,认为其中存储的是自身和所有父级上下文的变量对象,且由于引用关系变量对象不会被销毁。(为此删掉了写好的几千字😭)
很明显为了使用部分变量而保存整个变量对象是非常消耗性能的,在V8中对这个情况作出了优化。
详细内容推荐下这篇文章 《一文颠覆大众对闭包的认知》
概念
为了让函数无论在何处调用都能正常获取到定义时的环境 ,函数对象存在一个内部属性 [[Scopes]]
(无法自由访问、仅供JS引擎访问的属性)
[[Scopes]]
属性在函数定义时创建,呈栈结构,它记录了函数对外部环境的引用 (注:函数就算永不调用,[[Scopes]]属性也已存在,并且它不是完整的作用域链)
编译器会分析出父函数中被引用到的变量,把它们打包到称作Closure
的对象里并推入栈中,每一层被使用到的父函数会生成一个Closure
当函数作为返回值在外部使用时,仍能通过[[Scopes]]
恢复tree shaking后的作用域链 = AO + [[Scopes]]
js
function outer() {
const num1 = 10;
const num2 = 20;
return function inner() {
if (false) console.log(num1) // 只要是存在的变量,就算实际上不使用也会被记录
}
}
console.dir(outer());
// ƒ inner()
// aruments: null
// caller: null
// ...
// [[Scopes]]: [Closure (outer) {num1: 10}, Global]
console.dir()在控制台打印出该对象的所有属性和属性值
案例
javascript
function a () {
var a = 1;
function b () {
console.log(a);
}
console.dir(b);
return b;
}
a();
闭包
最后我们结合前文再谈谈闭包,你可能听过 "JavaScript中所有函数皆闭包",这句话虽然有些夸张,但并不是无的放矢。
概念
闭包 (closure)是一个函数以及其捆绑的 周边环境状态(lexical environment ,词法环境 )的引用的组合。换而言之,闭包让开发者可以从内部函数访问外部函数的作用域。在 JavaScript 中,闭包会随着函数的创建而被同时创建。 --- ---MDN
我们可以把闭包理解为一种现象:当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。
从这个角度上来说,所有Javascript函数都是闭包(除全局函数),因为函数[[Scopes]]
属性的存在,所有函数都能记住并访问window
全局作用域。
闭包因为其特性,十分适合私有属性、单例模式、高阶函数
等场景,同时也带来了内存泄漏、内存消耗
的问题,对于不用的闭包函数可以通过置空回收(= null)
原理
对于普通函数,执行完成后执行上下文销毁 作用域链也跟着销毁,再次调用会产生新的执行上下文 重新创建作用域链。
但对于返回了内部函数的嵌套函数:只要接收返回值的容器还存在,通过[[Scopes]]
我们可以一直访问到函数定义时的环境。
js
function a() {
let number = 10;
function fn() {
number++;
console.log(number);
}
return fn;
}
let b = a();
b(); // 11
b(); // 12
b(); // 13
案例
一道经典问题:
js
function createFunc() {
var result = [];
for (var i = 0; i < 10; i++) {
result[i] = function () {
return i;
};
}
return result;
}
createFunc().map(fn => fn()) // ?
// and how to log [0,1,2,3,...,9] ?
总结
函数像一个仓库/背包,其中存储了函数定义时作用域中的所有变量,将当它作为返回值在外部使用时,仓库/背包里的东西就被携带到外部环境了,这时就形成了闭包。
- 作用域收集变量并维护它们的访问
- 预编译生成执行上下文提供代码的执行环境
- 为了让函数能正确获取到定义时的环境,函数定义时生成
[[Scopes]]
它们环环相扣 相辅相成,最后形成了闭包这一个现象/概念。
文章到这就结束了,知识之间多是有关联的,学习闭包应该从多方面去考究。我们要理解为什么要产生闭包、是什么产生了闭包。
题外话
查漏补缺,如果有存在争议的内容还望指点!
即使本文篇幅较长,但单篇文章很难总结的面面俱到,更深的细节还需自己钻研,例如:
- 作用域可以通过
with()
、eval()
动态改变,但不建议使用它们 let
、const
会形成暂时性死区(TDZ)
在声明之前使用这些变量会报错- 这种在运行时根据实际需要 动态地生成代码、改善代码性能 的编译被称为
即时编译(JIT)
- 执行上下文在不同ES版本中的差异:
- ES3中的
scope chain、variable object、this value
- 在ES5中变为了更为贴切的
词法环境 lexical environment、变量环境 variable environment、this value
- 而在ES2018中又发生了变化
code evaluation、Function、ScriptOrModule...
- ES3中的
- ...
有基础后建议阅读 《你不知道的JavaScript》 系列原著,希望你阅读过后也能有书中描述的感觉:
结语🎉
不要光看不实践哦,后续会持续更新前端相关的知识 😉
好久没写文了,立个flag以后一月至少一更,尽量坚持🙉
脚踏实地不水文,真的不关注一下吗~
写作不易,如果觉得有收获还望大家点赞、收藏 🌹
才疏学浅,如有问题或建议欢迎大家指教。
参考文章
# 你不知道的JavaScript
# JavaScript 的静态作用域链与"动态"闭包链
# bilibili 6_作用域、作用域链、预编译、闭包基础
# Variable scope, closure
# Javascript Execution Context and Hoisting
# ⚡️⛓JavaScript Visualized: Scope (Chain)