前端面试官:“你说说作用域”

985 阅读12分钟

引言

这个问题,我自己去面试的时候,遇到的几率挺大的,而每次回答,都是回答个大概,然后面试官也没有深究。但是根据我自己学习到的知识,隐约知道这里面水很深,但从没有做过一次总结归纳。因此,这篇文章,就来刨根问底,并总结出一份应对这个问题的“完美”答案。

作用域相关定义(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 中的作用域中到底有哪些规则呢?

  1. 只有作用域链中存在的变量、函数,才能被引用、访问。
  2. 子(内部)作用域可以引用、访问父(外部)作用域,但反之不行。
  3. 访问变量、函数、对象时,访问规则是就近原则,从内到外,直到全局作用域(一层一层的作用域连起来就是作用域链了)。

以上就是作用域的全部规则了。当然,上面的几条是站在比较高层得出的总结。

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

babel-lab.jpg

代码中:我们分析查看 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"
    }
  ]
}

debug.jpg

我们可以看到,代码实际执行到函数内部的时候,作用域中,保留的变量,并不是像上面分析的那样全部都保留,而是只留下了访问的变量以及全局的变量。而且你可能还注意到,globalFunc 作用域前面还有一个你很熟悉的字眼 Closure-闭包。

闭包是返回函数的时候扫描函数内的标识符引用,把用到的本作用域的变量打成 Closure 包,放到 [[ Scopes ]] 里

我们把断点换一个地方,看看 innerFunction 本身的属性

scope.jpg

相信你知道闭包最常见的特点,所以我这里总结一遍作用域以及闭包的关系:我们知道 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变量,会用另一个栈结构存放,再将栈结构推进词法环境中。子的块级作用域执行完,就会将该栈结构弹出。而当我们查找一个变量时,我们的查找顺序,是从词法环境的栈顶开始,找到栈底,然后才是变量环境,之后才是外部作用域。

词法环境是一个递归的栈结构: 你嵌套的块级作用域越深,递归栈结构就越多。

block.jpg

总结

文章先从作用域定义入手,确定了作用域是一个抽象概念,而执行上下文是实际运行时的一个内部对象,Js引擎借助它实现了作用域规定的一系列规则。

之后,我们具体了解了 JavaScript 作用域的分类,以及在编程语言层面上的分类:词法作用域, 还提了一嘴,用 this 来弥补 作为非动态作用域的不足。

接着,我们借助工具,来实际查看和分析作用域,发现了理论与实际的出入,引出了闭包。然后总结了闭包是啥以及怎么参与到作用域中的。

最后,我们提出了一个问题,JS 引擎是怎么实现同时支持块级作用域和 var 变量提升。然后给了实际的答案。

当然还有一些作用域的边际情况没说:比如 new Functioneval,前者打包闭包时,不管在哪儿,都只打包了全局作用域,后后者则相反,为了防止漏掉,将上层作用域全打包了。

结尾

我们引言部分,提出了一个巨大的目标,要总结出一份“完美”答案,但感觉有点难,作用域会涉及到很多其他概念:函数表达式、函数声明、调用栈、闭包等等,这些点与作用域拉拉扯扯,很晚完全分开来讲解。要讲解一个概念,得先假设另一个概念是完全理解的。因此,文章最终还是无法给出一份完美答案,只能说,基础概念提一下,然后面试官想针对哪一点深入了解,再进行详细的解说。

参考文章


本文首发在个人博客,欢迎交流。