前端知库

执行上下文、作用域链和变量环境是什么关系?

每调用一次函数,引擎就创建一个执行上下文压进调用栈,里面记录这段代码用哪套环境记录查变量。环境记录通过外层引用连成作用域链,外层是函数定义的位置决定的。var 声明放在变量环境里,let、const 放在词法环境里。

参考答案

简答

每调用一个函数,引擎都会为这次调用建一份“运行记录”,记下这段代码执行到哪、变量去哪里找,这份记录叫执行上下文。执行上下文按调用顺序压进一个栈,也就是调用栈,正在执行的永远是栈顶那个,函数返回时它就出栈。

变量放在执行上下文引用的环境记录里。每个环境记录都有一个指向外层环境记录的引用,找不到的变量就顺着这个引用往外找,一直找到全局,这条链就是作用域链。外层是谁,在函数定义时就定下了,跟在哪里调用无关。

规范里执行上下文有两个相关组件:词法环境用来解析代码里的标识符,let、const、class 声明的绑定在这里;变量环境专门放 var 声明的绑定。

要注意,这些是规范描述行为用的模型,引擎内部怎么实现不一定一一对应。面试里能讲清“调用栈管执行顺序,作用域链管变量查找,查找路线由定义位置决定”就够了。

调用栈:谁在执行

MDN 的执行模型把执行上下文也叫栈帧,是最小的执行单位。代码开始运行时先有一个全局的执行上下文;调用函数时,引擎为它创建新的执行上下文压到栈顶,ECMAScript 把栈顶这个叫“正在运行的执行上下文”。函数返回,它出栈,控制权回到下面那个。

所以报错时看到的调用栈,就是当时栈里从上到下的这些执行上下文。递归太深会报栈溢出,也是因为栈里压了太多执行上下文。

为什么变量按定义位置查找

下面 inner 在 caller 里被调用,但它定义在 outer 里。看它最后读到哪个 name。

JavaScript
var name = '全局';
function outer() {
  var name = 'outer';
  function inner() {
    return name;
  }
  return inner;
}
function caller() {
  var name = 'caller';
  return outer()();
}
console.log(caller()); // outer
console.log(typeof hoisted); // undefined
var hoisted = 1;
try {
  console.log(later);
} catch (error) {
  console.log(error.name); // ReferenceError
}
let later = 2;

为什么不是 caller?inner 自己的环境记录里没有 name,就沿外层引用去找。这个外层引用指向的是 inner 创建时所在的环境,也就是 outer 那次调用的环境,所以拿到 'outer'。调用它的 caller 虽然在调用栈里,却不在 inner 的作用域链上。调用栈和作用域链是两条不同的链,这一点最容易混。

这条“按定义位置决定”的规则就是词法作用域,闭包能读到外层变量也是靠它,见《什么是闭包?它有哪些实际应用?》。

变量环境和词法环境有什么不同

例子后半段,typeof hoisted 得到 undefined 而不报错,later 却抛出 ReferenceError。原因是代码执行前,引擎会先把声明登记到环境记录里。var hoisted 登记在变量环境,同时初始化为 undefined,所以提前读不报错。let later 登记在词法环境,但在执行到声明那一行之前一直没有初始化,这段时间读它就报错,也就是暂时性死区。细节见《var、let、const 和暂时性死区有什么关系?》。

ECMAScript 对这两个组件的定义是:词法环境用于解析这段代码里的标识符引用,变量环境保存 VariableStatement(也就是 var 语句)创建的绑定。块级作用域出现后,进入 {} 块时会为 let、const 新建一个环境记录,外层引用指回原来的词法环境;var 不受块影响,仍然放在函数的变量环境里。

this 和执行上下文

函数环境记录除了放变量,还提供 this 绑定,不过规范特别说明,箭头函数的函数环境记录不提供 this。所以箭头函数里的 this 和普通变量一样,要顺着外层去找,这就是它“没有自己的 this”的来源。普通函数的 this 由调用方式决定,见《普通函数和箭头函数的 this 有什么区别?》。

常见追问

执行上下文和作用域是一回事吗?

不是。作用域讲的是“变量在哪段代码里能访问”,写代码时就定了。执行上下文是运行时才有的,每次调用都新建一个。同一个函数调用两次,作用域规则一样,执行上下文和环境记录却是两份,所以每次调用的局部变量互不影响。

Generator 暂停时执行上下文去哪了?

MDN 的执行模型里提到,调用 Generator 函数会创建一个执行上下文但先挂起,生成器对象把它保存起来。每次调用 next(),这个执行上下文重新压栈运行,遇到 yield 再挂起出栈,所以局部变量能一直保留。