引言
这个问题,我自己去面试的时候,遇到的几率挺大的,而每次回答,都是回答个大概,然后面试官也没有深究。但是根据我自己学习到的知识,隐约知道这里面水很深,但从没有做过一次总结归纳。因此,这篇文章,就来刨根问底,并总结出一份应对这个问题的“完美”答案。
作用域相关定义(what)
这一小节:着重引用概念以及表达结论。
Scope The current context of execution. The context in which values and expressions are "visible" or can be referenced. If a variable or other expression is not "in the current scope," then it is unavailable for use. Scopes can also be layered in a hierarchy, so that child scopes have access to parent scopes, but not vice versa. ---来自 MDN
上面说,“作用域”就是当前的“执行上下文”, 其实这不准确,这两者是不同的概念:前者是一个抽象概念,指一个范围、区域,里面有一系列的规则,后者是实际执行代码时创建的一个“对象”。 JS引擎通过该对象实现了作用域描述的规则。
那么 Javascript 中的作用域中到底有哪些规则呢?
- 只有作用域链中存在的变量、函数,才能被引用、访问。
- 子(内部)作用域可以引用、访问父(外部)作用域,但反之不行。
- 访问变量、函数、对象时,访问规则是就近原则,从内到外,直到全局作用域(一层一层的作用域连起来就是作用域链了)。
以上就是作用域的全部规则了。当然,上面的几条是站在比较高层得出的总结。
JavaScript 作用域分类
从大的方面:全局作用域和局部作用域。
这在ES5及之前,局部作用域就是函数作用域,但ES6之后,又引入了块级作用域,所以现在的局部作用域就可细分:函数作用域、块级作用域。
全局作用域:顾名思义,就是一个全局的范围,里面设置的变量、函数、对象等等,在任何地方都可以访问到。比如 globalThis
就是全局对象,任何的代码块、函数都可以访问到。
你写的任何代码都是处于全局作用域中,当然可以访问其中的变量、函数、对象。
console.log(globalThis)
function globalFunc() {
console.log(globalThis)
}
globalFunc()
函数作用域:同样顾名思义,是以函数为划分范围的区域范围,里面的定义的变量、函数、对象等等,外部无法访问,只能函数内部访问。表现形式是函数定义
块级作用域同理。表现形式是花括号 + let、const 关键字 ,包括 for 、while 循环结构、trycatch 等等。
// 函数作用域
function foo() {
var local = 'GumplinGo'
console.log(local)
}
foo()
console.log(local) // Uncaught ReferenceError: local is not defined
// 块级作用域
{
const block = 'GumplinGo'
console.log(block)
}
console.log(block) // Uncaught ReferenceError: block is not defined
词法作用域(静态作用域)
这里不要与上面的 Javascript 作用域分类搞混了,这里不是多了一个分类,而是从更高的层面(编程语言)来归类 javaScript 的作用域类型:词法作用域(静态作用域)。
词法作用域(静态作用域):子(内部)作用域 的父(外部)作用域 的确定,是函数创建时、代码块位置 决定的。
动态作用域:函数、代码执行时才确定 子(内部)作用域 的父(外部)作用域。
而 JavaScript 的作用域类型就是词法作用域:具体特征如下:
var target = 'GumplinGo'
function defineOutside() {
console.log(target)
}
function executor() {
var target = 'local GumplinGo'
defineOutside()
}
executor() // 打印出来的是啥?全局的target,还是函数内部的target?
上面的代码打印出来的是全局 target, 而不是函数内部的 target。当 函数 defineOutside
创建(声明)时,会确定该函数的外部作用域是全局作用域,不管 defineOutside
最终在哪里调用,一旦需要去作用域链上面找变量,就会走创建时确定的路径。
作为对比,你可以把 defineOutside
函数声明放到 executor
内部试试, 这回 defineOutside
创建时的外部作用域就是 executor
的函数作用域了,所以打印出来的就是函数内部的 target 了
var target = 'GumplinGo'
function executor() {
var target = 'local GumplinGo'
function defineOutside() {
console.log(target)
}
defineOutside()
}
executor() // 打印出来的是啥?全局的target,还是函数内部的target?
那么问题来了,有时就是需要获取函数执行时的环境中的变量,怎么办?你都在创建时就绑定了,太不自由了!为了弥补这一点,JavaScript 引入了 this
,但不是本文重点,先按下不表。
实际查看作用域
我们可以通过 babel 的 解析和遍历 工具,来静态查看分析代码的作用域,也可以通过 vscode 的代码调试功能,查看某个执行阶段,某个函数当时的作用域。
下面是具体的演示:
找个文件夹,执行如下命令:
# 创建项目文件夹并初始化
mkdir scope-lab && cd scope-lab && npm init
# 安装 babel 的解析和遍历工具
npm install --save-dev @babel/parser @babel/traverse
在 scope-lab/
目录下面新建一个 index.js
文件,作为试验的主文件。
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const code = `
console.log(globalThis)
let globalVariable = 'globalVariable'
function globalFunc() {
console.log(globalThis)
const innerVariable = 'innerVariable'
function innerFunction() {
console.log(innerVariable)
}
innerFunction()
}
globalFunc()
`;
const ast = parser.parse(code);
traverse(ast, {
Function (path) {
if (path.get('id.name').node === 'innerFunction') {
console.log(path.scope.dump());
}
}
})
在当前项目下,在命令行里面执行 node ./index.js
代码中:我们分析查看 innerFunction
的作用域,有三个层级的作用域,图片中从上到下,代表从内到外的三层作用域。作用域中存有的变量、函数、对象如图所示。
注意以上的作用域分析,只是函数解析阶段的理论作用域,我们下面看看代码实际执行时的作用域。
具体操作我示例一下:
在 scope-lab/
目录下 再建一个 debug.js
文件,复制以下代码进去
console.log(globalThis)
let globalVariable = 'globalVariable'
function globalFunc() {
console.log(globalThis)
const innerVariable = 'innerVariable'
function innerFunction() {
console.log(innerVariable)
}
innerFunction()
}
globalFunc()
之后点击工具栏左侧的 debug 功能,配置一下 launch.json
, 之后打个断点,按下 F5
, 即可将代码暂停,此时可查看当前的作用域情况。
{
// 使用 IntelliSense 了解相关属性。
// 悬停以查看现有属性的描述。
// 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Launch Program",
"skipFiles": [
"<node_internals>/**"
],
"program": "${workspaceFolder}\\debug.js",
"console": "integratedTerminal"
}
]
}
我们可以看到,代码实际执行到函数内部的时候,作用域中,保留的变量,并不是像上面分析的那样全部都保留,而是只留下了访问的变量以及全局的变量。而且你可能还注意到,globalFunc
作用域前面还有一个你很熟悉的字眼 Closure
-闭包。
闭包是返回函数的时候扫描函数内的标识符引用,把用到的本作用域的变量打成 Closure 包,放到 [[ Scopes ]] 里
我们把断点换一个地方,看看 innerFunction
本身的属性
相信你知道闭包最常见的特点,所以我这里总结一遍作用域以及闭包的关系:我们知道 JavaScript 是一门解释型动态语言。在实际执行的时候,分为解析 + 执行 两个步骤。在解析阶段,我们会先创建执行上下文,用来存放初始化一些变量、函数、对象等等,之后才是执行表达式。在初始化函数的时候,虽然没有立即执行,但是会进行预解析,扫描函数内的标识符引用,主要目的就是看看是否引用了外部的变量,如果引用了,那么就会创建该变量所在作用域的一个闭包,放到当前函数的[[Scopes]]
属性里面,该属性的值是一个数组,也可以说是栈,添加或访问都是按照栈的规则来的。等到真正执行该函数的时候,那么就需要创建该函数的执行上下文(先对等作用域),这个时候,除了local 作用域,还会设置外部作用域,那么就通过函数解析时记录下来的 [[Scopes]]属性 设置外部作用域了。
为什么需要闭包?(why)
上一节,我们从理论结论进行分析总结,之后借助工具进行分析,引出了闭包,以及闭包怎么形成的。那么问题来了,为什么要用闭包这种方式来构建函数作用域?一般不是函数被作为返回值才会形成闭包么,你上面的也没返回函数并被引用,怎么也有闭包啊?
要说清为啥需要闭包,就需要了解函数是一等公民、执行上下文以及执行栈的规则。
如果某个编程语言的函数,可以和这个语言的数据类型做一样的事情,我们就把这个语言中的函数称为一等公民。
即函数既可以作为参数,也可以作为返回值(函数本身是一个对象,所以当然可以了)
很多文章讲解执行栈(调用栈)的过程,比如李兵老师的调用栈:为什么JavaScript代码会出现栈溢出?。如果你对此不熟悉,可以先看看。
这里假设你已经了解执行栈了,那么我们看看下面的代码:
function makeCounter() {
let count = 0
return function counter() {
console.log(count++)
}
}
const counter = makeCounter()
在调用完 makeCounter
后,将弹出其执行上下文,那么存放其中的变量也将销毁了,但是,我们还保留了counter
函数的引用,而counter
中又引用了makeCounter
作用域中的变量。那么怎么办?于是,闭包出现了,将外部变量的信息打包复制一下,放到函数的[[Scopes]]里面,然后你外面的尽管销毁,反正我已经备份了。
那么下一个问题,不是说作为函数返回值才用闭包么?其实不是,只是这种情况比较明显,常常作为典型来说明闭包,但是,其实,任何一个函数都有闭包,你可以理解为“背包”,起码背了 Global 全局作用域变量打包起来的这个闭包。
怎么同时实现块级作用域又兼容var的变量提升(how)?
我们知道,要实现块级作用域,需要用到const
let
关键字来进行声明,也一直鼓励这么做,避免造成 var
关键字声明的种种问题。但现在的浏览器,是即能实现块级作用,又能实现var的变量提升,JS 引擎是怎么实现的呢?
在李兵老师的文章中,解开了这一问题的答案:块级作用域:var缺陷以及为什么要引入let和const?
总结就是:
在执行上下文这个对象中,存在两个对象:变量环境和词法环境。变量环境用来存放var
声明的变量,或者函数声明的函数对象,而词法环境,则专门用来存放 let
,const
声明的变量,然而,词法环境这个对象还是一个栈结构,栈底用来存放当前作用域的 let
,const
声明的变量, 当出现了子的块级作用域,里面声明的 let
,const
变量,会用另一个栈结构存放,再将栈结构推进词法环境中。子的块级作用域执行完,就会将该栈结构弹出。而当我们查找一个变量时,我们的查找顺序,是从词法环境的栈顶开始,找到栈底,然后才是变量环境,之后才是外部作用域。
词法环境是一个递归的栈结构: 你嵌套的块级作用域越深,递归栈结构就越多。
总结
文章先从作用域定义入手,确定了作用域是一个抽象概念,而执行上下文是实际运行时的一个内部对象,Js引擎借助它实现了作用域规定的一系列规则。
之后,我们具体了解了 JavaScript 作用域的分类,以及在编程语言层面上的分类:词法作用域, 还提了一嘴,用 this
来弥补 作为非动态作用域的不足。
接着,我们借助工具,来实际查看和分析作用域,发现了理论与实际的出入,引出了闭包。然后总结了闭包是啥以及怎么参与到作用域中的。
最后,我们提出了一个问题,JS 引擎是怎么实现同时支持块级作用域和 var
变量提升。然后给了实际的答案。
当然还有一些作用域的边际情况没说:比如 new Function
, eval
,前者打包闭包时,不管在哪儿,都只打包了全局作用域,后后者则相反,为了防止漏掉,将上层作用域全打包了。
结尾
我们引言部分,提出了一个巨大的目标,要总结出一份“完美”答案,但感觉有点难,作用域会涉及到很多其他概念:函数表达式、函数声明、调用栈、闭包等等,这些点与作用域拉拉扯扯,很晚完全分开来讲解。要讲解一个概念,得先假设另一个概念是完全理解的。因此,文章最终还是无法给出一份完美答案,只能说,基础概念提一下,然后面试官想针对哪一点深入了解,再进行详细的解说。
参考文章
- 旧时的 "var"
- 变量作用域,闭包
- "new Function" 语法
- 浏览器工作原理与实践
- JavaScript 的静态作用域链与“动态”闭包链
- 深入理解JavaScript作用域和作用域链
- 面试官:说说作用域和闭包吧
本文首发在个人博客,欢迎交流。