Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

JavaScript 作用域链简述 #268

Open
toFrankie opened this issue Feb 26, 2023 · 0 comments
Open

JavaScript 作用域链简述 #268

toFrankie opened this issue Feb 26, 2023 · 0 comments
Labels
2021 2021 年撰写 JS 与 JavaScript、ECMAScript 相关的文章

Comments

@toFrankie
Copy link
Owner

toFrankie commented Feb 26, 2023

配图源自 Freepik

继上一篇文章 JavaScript 脚本编译与执行过程简述,再来介绍一下 JavaScript 中的作用域链(Scope Chain)。

函数的 [[scope]] 也是与闭包直接相关。并推荐专题:

作用域链的形成

作用域链(以下简称 Scope)与执行上下文相关。

全局上下文:
   Scope 就是 GlobalContext.VO,即 window 对象。

函数上下文:
  函数被调用时,函数上下文的 Scope 被创建,包括 AO 和这个函数内部的 [[scope]] 属性。

因此,我们可以大致当前上下文的作用域链:Scope = AO + function.[[scope]]

函数内部 [[scope]] 属性的形成

当函数被创建的时候,属性 [[scope]] 会保存所有的父级变量对象。

举个例子:

function foo() {
  // some statements...
}

上述例子,函数 foo 处于全局上下文。而全局上下文中所声明的函数,它们的 [[scope]]GlobalContext.VO,即 window 对象。因此 foo.[[scope]] = [ GlobalContext.VO ]

再看:

function foo() {
  function bar() {
    // some statements...
  }
}

同样地,函数 foo[[scope]] 属性为 GlobalContext.VO。然后调用 foo 函数,进入 foo 函数上下文并进行初始化,包括以下过程:

  1. 以当前函数 foo.[[scope]] 为基础初始化函数上下文的 Scope
  2. 初始化上下文的 AO 对象,包括 Arguments、形参、函数声明、变量声明。
    该过程若有函数声明,对应函数的 [[scope]] 也将会被确定,其值就是 Scope
  3. AO 初始化完成后,将 AO 插入上下文的 Scope 中。

因此有两个结论:

  • 当前上下文的作用域链 Scope = AO + foo.[[scope]]
  • 当前上下文内定义的函数 bar,其 [[scope]] 属性值为上下文的 Scope,即 AO + foo.[[scope]]

注意,即便是函数表达式,它在代码执行的时候,才会确定其 [[scope]],由于执行过程中 AO 也会跟着更新,且它们是引用关系,因此总能确保,当前作用域内的函数(函数声明或函数表达式)的 [[scope]] 总是 AO + 各父级上下文的 AO/VO

但是使用 Function 构造器来创建一个新的函数,该函数的 [[scope]] 只有 GlobalContext.VO。下面的示例中,执行 bar 函数会去作用域链上查找 a 变量,可它的作用域链只含全局对象,导致找不到 a 变量而抛出 ReferenceError

function foo() {
  var a = 1
  var bar = new Function('console.log(a)')
  bar() // ReferenceError: a is not defined
}

foo()

因此,尽量不要使用构造函数的方式来创建函数。

影响作用域链的一些例子

一般情况下,一个作用域链 Scope 包括父级变量对象、函数上下文的活动对象 AO,并从当前上下文逐级往上查询。

提醒一下:当我们从对象上查询某个属性,首先从对象自身属性上查找,当找不到的时候,才会往原型上查找......直至原型链的顶端 Object.prototype 再查询不到就返回 undefined

其实作用域链的原理跟原型链很类似,当前如果这个变量在自己的作用域中没有,那么它会往父级查找,直至最顶层(全局对象),再查找不到就会抛出 ReferenceError

前面讲过,当前上下文(作用域)内声明的变量或函数,是以属性的形式,放到一个变量对象(Variable Object)上的。但由于 VO 是无法通过代码访问的,因此在函数调用的时候 VO 被激活形成一个活动对象(Activation Object),它是可以被访问到的(可以简单的理解为 AOVO 浅拷贝的一个引用)。

但是,AO 是没有原型的。假设我们在当前作用域下查找一个变量 a,相当于从 AO 上查找 a 属性。假设 AO 本身没有该属性,自然会往 AO 原型上查找,但很遗憾 AO 没有原型,即当前作用域下查找不到该变量(或称为属性)。然后往作用域链的上一级 AO 中查找......查找规律同理......直到全局作用域(其 VO 就是 window 对象)下的 window 对象查找。由于 window 对象是有原型的,如果自身找不到 a 属性,就会往 window 的原型上查找,查到就返回,查不到就抛出 ReferenceError

说那么多,还不如看个例子更清晰:

Object.prototype.a = 'proto'

function foo() {
  console.log(a)
}

foo() // "proto"

从例子可以看出 foo 函数上下文下并没有声明 a 变量,于是往上一级查找(即全局上下文),那么从 window 自身查找,是没有的。但是 window 是基于 Object 创建的(window instanceof Object 结果为 true),于是从 Object.prototype 上查找,并找到 a 属性,属性值为 "proto"

如何证明 AO 是没有原型的?

Object.prototype.a = 'proto'

function foo() {
  var a = 'inner'
  function bar() {
    console.log(a)
  }
  bar()
}

foo() // "inner"

过程就不在赘述了,假设 AO 是有原型的,那么 bar 函数上下文中查找 a 变量是,应该会取到 AO 对象原型上的 a 属性 "proto",但实际情况 a 取到的结果是 "inner"。因此可以证明:活动对象 AO 是没有原型的。

全局和 eval 上下文中的作用域链

全局上下文的作用域链仅包含全局对象。而 eval 上下文与当前的调用上下文(calling context)拥有同样的作用域链。

GlobalContext.Scope = [ window ]
 
EvalContext.Scope === CallingContext.Scope;

代码执行时对作用域链的影响

有些情况下也会包含其他对象,例如执行期间,动态加入作用域链中的,例如 with 语句或者 catch 语句。此时作用域链如下:

Scope = (withObject | catchObject)  +  (AO | VO)  +  [[Scope]]

withObject
  表示 with 语句产生的临时作用域对象。如 with({ name }) 中的 { name } 对象;
catchObject
  表示 catch 从句产生的异常对象。如 catch(e) 中的 e 对象。

举个例子:

var foo = { x: 1, y: 2 }

with (foo) {
  console.log(x) // 1
  console.log(y) // 2
}

它的作用域链变成了:Scope = foo + (AO | VO) + [[Scope]]。上面这个例子可能没有体现出来,我们修改一下:

var x = 1, y = 2
var foo = { x: 2 }

with (foo) {
  var x = 3, y = 4
  console.log(x) // 3
  console.log(y) // 4
}

console.log(x) // 1
console.log(y) // 4
console.log(foo) // { x: 3 }

我们来分析一下:

  1. 进入全局上下文的时候,会创建声明 xyfoo 变量。
  2. 执行到 with 语句,会将 foo 对象添加至作用域链顶端。
  3. with 内部的 xy 前面已被解析添加,因此它只是一个赋值语句,并不会重新赋值语句。
  4. 关键在于 with 内部,给 x、y 赋值,究竟是对应哪个变量。前面提到遇到 with 语句会往作用域链顶端插入该对象 foo(注意不会创建一个全新的作用域上下文,只是修改了作用域链而已)。
  5. 因此,当 console.log(x) 查找 x 变量时,从 foo 对象上查找 x 属性,并找到,因此 foo.x 被修改为 3
  6. 接着,查找 y 变量,而 foo 对象上没有(其原型也没有),因此往上一级作用域查找(即全局作用域),因此全局作用域下的 y 被修改为 4
  7. 因此 with 内部的 xy 分别打印出:34
  8. with 执行完,作用域链上的 foo 对象会被移除。即作用域链上只剩下 window 对象。
  9. 后面查找 xyfoo 变量都是从全局作用域下查找的,因此会分别打印出 14
  10. 最后我们也可以看到 foo 对象是更新变为:{ x: 3 }

结合前面的原型的内容,假设将 foo 对象的原型上添加 y 属性,那么 y = 4 被修改的是 foo.__proto__ 上的属性,而不是全局作用域下的 y 变量。(有兴趣的自行尝试一下)

The end.

@toFrankie toFrankie added the JS 与 JavaScript、ECMAScript 相关的文章 label Feb 26, 2023
@toFrankie toFrankie added the 2021 2021 年撰写 label Apr 26, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
2021 2021 年撰写 JS 与 JavaScript、ECMAScript 相关的文章
Projects
None yet
Development

No branches or pull requests

1 participant