# 从执行上下文的角度理解作用域、提升、闭包

请欣赏灵魂n问

# 作用域和执行上下文有什么区别呢?

  1. 作用域:作用域就是一个独立的区域,它可以让变量不会向外暴露出去。作用域最大的用处就是隔离变量。内层作用域可以访问外层作用域。作用域只是一个“地盘”,其中没有变量,要通过作用域对应的执行上下文环境来获取变量的。作用域在编译阶段就已经确定了,所以作用域是静态观念的,而执行上下文环境是动态的。
  2. 执行上下文:一个作用域可能包含多个执行上下文,上下文是JS代码的运行环境。作用域变量的查找需要依赖执行上下文的变量对象。
  3. 执行上下文在运行时确定,随时可能改变;作用域在定义时就确定,并且不会改变。

# 词法作用域和词法环境有什么区别呢?

  1. 词法作用域是作用域的工作模型,它表示JS是静态作用域,在函数定义时根据位置确定的。相反的是动态作用域,类似this一样是动态的。
  2. 词法环境是上下文的一部分,指代码块中标识符和变量值、函数值(内存地址)的关联关系。词环境内部包含环境记录器和对外部环境的引用。环境记录器是存储变量和函数声明的实际位置,对外部环境的引用意味着可以访问父级词法环境。

# ES3执行上下文的Scope和函数内部属性[[scope]]有什么区别呢?

我们阅读过资料后发现,ES3创建执行上下文中也存在一个Scope,这与作用域划分时函数内部属性[[scope]]有什么关联呢? 参考下文的抽象数据结构:

const wrapper = () => {
  const inner = () => {
    // ...
  }
}

// 当函数创建时:
wrapper.[[scope]] = [
  gobalContext.VO
]

inner.[[scope]] = [
  wrapperContext.AO
  gobalContext.VO
]

后续执行 wrapper 函数创建执行上下文时(预编译阶段), 进入函数上下文并创建 VO/AO, 就会将活动对象添加到作用域顶端

wrapperContext = {
  AO: {}, // 活动对象: wrapper 函数的参数以及变量
  Scope: [AO, wrapper.[[scope]]] // !!! 这才是作用域链
}

关于 [[scope]] 和执行上下文中 Scope 的理解: 当你定义(书写)一个函数时, JS 引擎可以根据你书写的位置: 函数嵌套的位置, 生成一个[[scope]] 属性, 这个属性是属于函数的(即使函数不调用), 所以说基于词法作用域(静态的) 而 Scope 属性是在函数执行时, 生成执行上下文(VO/Scope/this), 这个是时候的 Scope 和 [[scope]] 不是同一个东西。Scope 是在 [[scope]] 的基础上新增了当前的 AO 对象来构成 所以函数定义时的[[scope]]是函数的属性, 函数执行时的 Scope 是执行上下文的属性。

Scope = [AO].concat([[Scope]]);

但这是ES3的处理,ES5标准以后使用VE/AE替换了VO/AO。

而ES5标准以后没有了Scope属性,增加了outer属性,应该也是类似的原理。可能是VO/AO变成了VE的词法环境和变量环境,所以设计需要变更,但outer指向上层执行上下文的词法环境,来获取执行过程中使用的变量,也可以理解为作用域链查找。

# [[Scopes]]和[[scope]]有什么区别?

我们在浏览器中观察到函数内部属性[[Scopes]],发现它和ES3的[[scope]]非常像,所以我们理解为不同版本就行。ps网上文章写啥的都有,不在这里计较了。

# 那么[[Scopes]]和执行上下文的outer有什么关系呢?他们都是作用域链的实现吗?

[[Scopes]]和执行上下文的outer都是作用域链的实现,outer是ES规范的内容,[[Scopes]]是引擎的具体实现,效果一致但过程可能不一致。

参考这里的解释 (opens new window):

[[Scopes]]是收集调用栈列表。理论上说,这是规范中 contexts 的一种具体实现,与规范描述效果一致,但并不一定有规范内描述的所有内容。

比如具体 context 中用到 TaggedField + offset 偏移量来获取对应字段内容,而没有用到规范所描述的结构来实现。

看效果和ES3比较像。 补充图:

# 作用域链也是预解析阶段生成的吗?

不完全。在确认作用域时,作用域链的上游其实就确定了,只确认了一部分。只不过是在执行上下文创建/执行时,再将变量对象填入前面,这才是完整的作用域链。

参考 (opens new window)

# 执行上下文创建的时机?是词法分析阶段吗?

说法一:全量解析对应执行上下文的创建过程

引擎在解析时,分为懒解析(预解析)和全量解析,对于不会立刻执行的函数是懒解析,只会定义出作用域。

全量解析:它会解析所有立即执行的代码。

  • 解析被使用的代码
  • 生成 AST
  • 构建具体的 scopes 信息,变量的引用,声明等
  • 抛出所有的语法错误

此时对应的,其实就是执行上下文的创建过程。需要区分的是,作用域与作用域链的信息是在预解析阶段就已经明确了。

参考 (opens new window)

说法二:

执行上下文是预解析阶段(v8具体哪个阶段呢?),作用域在词法分析阶段确定的。

执行上下文是在函数体代码执行之前创建,在调用函数时赋值,函数调用结束时就会自动释放。因为不同的调用可能有不同的参数,导致产生不同的上下文。因此不会和作用域是同一时机。

说法三:

预编译(函数执行前)※

  1. 创建AO对象(Active Object)
  2. 查找函数形参及函数内变量声明,形参名及变量名作为AO对象的属性,值为undefined
  3. 实参形参相统一,实参值赋给形参
  4. 查找函数声明,函数名作为AO对象的属性,值为函数引用

预编译(脚本代码块script执行前)

  1. 查找全局变量声明(包括隐式全局变量声明,省略var声明),变量名作全局对象的属性,值为undefined
  2. 查找函数声明,函数名作为全局对象的属性,值为函数引用

总结来看:全局上下文的创建和作用域的确定可能有重合时机,而造成一的理解,但函数上下文的创建和作用域确认并不在一个细分时机。 同时上下文包含:变量对象/作用域链/this,有些在分词阶段不能确认。同时,不纠结的话,统一回答预解析阶段,具体细分后面研究(TODO)

# 从执行上下文理解提升、TDZ、块作用域

参考译文 (opens new window)

块作用域:

块级作用域就是通过词法环境的栈结构来实现的。当在块作用域中看到由 let 和 const 声明的变量时,JavaScript 为我们创建了一个单独的区域。词法环境为这些变量维护着一个类似于栈的结构。所以同名的 let 变量不会相互冲突。

在查找变量时:

  • 词法环境:从栈顶从上到下->变量环境->outer
  • 变量环境:变量环境->outer

提升:

注意:你可能已经注意到了,在创建阶段 let 和 const 变量为 uninitialized,而 var变量为 undefined。

这是因为在创建阶段,代码被扫描为变量和函数声明,而函数声明被完整的存储在环境中,变量最初被设置为 undefined(var)或者保持 uninitiated(let 和 const)。

这就是为什么你可以在声明前访问 var 定义的变量(尽管是 undefined),但是在声明前访问 let 和 const 变量时会出现引用错误的原因。

这就是我们所说的变量“提升”。

TDZ:

  • 对于 var 变量来说,它的创建和初始化是被提升的,但赋值不是。
  • 对于 let 变量来说,它的创建是被提升的,但是初始化和赋值不是。
  • 对于函数来说,它的创建、初始化和赋值同时被提升。 人们将变量初始化之前的代码阶段称为暂时死区(temporal dead zone TDZ)。
  • 如果你试图在创建之前访问变量,你会看到错误“[variable name] is not defined.”
  • 如果你希望在初始化之前访问变量,你会看到错误“Cannot access [variable name] before initialization.”
  • 如果你在赋值之前使用一个变量,你会看到 undefined

# 从执行上下文理解闭包和this

this: 每个作用域都有自己的this,但this与任何作用域概念都无关。

参考这里的闭包、this (opens new window)

闭包: 外包裹函数wrapper执行完后,上下文所有内容被从执行栈移除。当我们在外部,调用内部的函数引用innner时,词法作用域规则发挥作用——内部函数可以访问外部函数中的变量。根据规则,引用的变量被保存到一个单独的区域。这是一个只能由内部的函数inner 访问的专属区域,也被称为闭包。

  • 只有被引用的变量被闭包包含,不是wrapper所有的变量。
  • 闭包在调试中可以在函数的[[Scopes]]观察,一般是[{闭包使用的变量}, {父级作用域内容(上下文的VO)}]
  • 闭包在JS引擎的内存中存储,具体点可能是堆内存