【js基础巩固计划】深入理解作用域与作用域链

努力让学习成为一种习惯,自信来源于充分的准备

如果你觉得该文章对你有帮助,欢迎大家点赞关注分享

前言

作用域作用域链一直是js中非常重要同时容易让人产生困惑的一个点。更有些小伙伴可能会把作用域的规则与this规则给搞混。这一篇文章来帮助大家彻底清除这方面的困惑

这是js基础巩固系列文章的第二篇,旨在帮助自己巩固js相关知识,同时也希望能给大家带来些新的认识,如有疑问出入,欢迎评论区一起讨论交流

作用域

let foo = '1'
{
  let bar = '2'
}
function func() {
  let u = 3
}

console.log(bar) // ReferenceError
console.log(u) // ReferenceError

上面的代码只要是有编程基础的人都可以很直观的感受到:这段代码会报错。因为它符合一个常识:外部的代码块不能访问内部的变量,而这个看似自然而生的规则制定者就是作用域

作用域是规定在代码执行时根据标识符名称查找变量、函数的一套规则,它决定了变量、函数的可访问范围(权限)

词法作用域

作用域有两种主要的工作模型:词法作用域动态作用域,其中词法作用域被大多数主流编程语言采用

词法作用域:用于定义在词法阶段的作用域

V8引擎在执行js脚本代码之前会对整体脚本代码先进行编译,在编译的过程中会对代码进行词法分析语法分析后生成AST(抽象语法树),同时会生成全局作用域

词法分析

词法分析又称为分词,其作用是将一行行的源码拆解成一个个token。所谓token,指的是语法上不可能再分的、最小的单个字符或字符串

var name = 'lxy'

这段代码经过javascript-ast站点分词后:

【js基础巩固计划】深入理解作用域与作用域链

token有许多不同的类型,以上图为例子:可以看出一个简单的赋值语句var name = 'lxy',被分成关键字token, 标识符token, 赋值运算token, 字符串token

了解完了分词,我们需要明白一个点:js引擎在执行脚本的时候,需要对整个脚本代码先进行编译编译的过程中会对代码进行词法、语法分析词法分析是编译过程进行的,所以在词法分析过程中产生的词法作用域也是编译时候产生的,此时代码还没执行。说了这么多到底想表达啥呢?

js中函数和变量的作用域在一开始就决定好了,取决于他们声明的位置,与他们执行时候的位置无关

我们来通过一段代码来体验一下

let b = 'outer'
function bar() {
  console.log(b) // outer
}
function foo() {
  let b = 'inner'
  bar() 
}
foo()

因为js是基于词法作用域,所以上述代码输出为outer

我们可以通过下面两种方式深入探究下:

// a.js
var $aa = 1
let b = 'outer'
{
  let c = 'block'
}
function bar() {
  console.log(b) // outer
}
function foo() {
  let b = 'inner'
  console.dir(bar)
}
foo()

【js基础巩固计划】深入理解作用域与作用域链

通过上面的控制台打印我们可以得出以下结论:

  1. bar函数并没有执行,只是编译了。但其作用域已经确定了
  2. 全局声明的letconst的变量会被单独放在Script作用域中。这也是我们无法通过 window.X访问的原因

另外一种是直接基于V8指令生成对应scope: d8 --print-scopes ./a.js

具体操作流程可以参考 使用 jsvu 快速调试 v8

Inner function scope:
function bar () { // (0x14e04cfd0) (62, 94)
  // NormalFunction
  // 2 heap slots
}
Inner function scope:
function foo () { // (0x14e04d1c0) (107, 150)
  // NormalFunction
  // 2 heap slots
  // local vars:
  LET b;  // (0x14e04f030) never assigned
}
Global scope:
global { // (0x14e04ca30) (0, 157)
  // will be compiled
  // NormalFunction
  // 2 stack slots
  // 4 heap slots
  // temporary vars:
  TEMPORARY .result;  // (0x14e04d470) local[0]
  // local vars:
  VAR bar;  // (0x14e04d190)
  VAR foo;  // (0x14e04d380)
  VAR $aa;  // (0x14e04cc50)
  LET b;  // (0x14e04cd10) context[3]

  function foo () { // (0x14e04d1c0) (107, 150)
    // lazily parsed
    // NormalFunction
    // 2 heap slots
  }

  function bar () { // (0x14e04cfd0) (62, 94)
    // lazily parsed
    // NormalFunction
    // 2 heap slots
  }

  block { // (0x14e04cda0) (28, 49)
    // local vars:
    LET c;  // (0x14e04cf38) local[1], never assigned, hole initialization elided
  }
}
Global scope:
function foo () { // (0x14e04cc20) (107, 150)
  // will be compiled
  // NormalFunction
  // 1 stack slots
  // local vars:
  LET b;  // (0x14e04ce70) local[0], never assigned, hole initialization elided
}

基于上面结果我们可以再次确认:js中变量、函数的作用域(全局、函数、块级)在编译阶段就已经确认了,取决于它声明的位置

作用域类型

全局作用域

声明在全局的变量、函数。可以在全部范围内访问

// 全局对象上的属性和方法
// window.document
// window.location
// window.localStorage
// window.setTimeout

let a = 1
function func() {}

我们把上面代码使用v8指令生成下对应的 scope

Inner function scope:
function func () { // (0x11d04cee0) (23, 28)
  // NormalFunction
  // 2 heap slots
}
Global scope:
global { // (0x11d04cc30) (0, 29)
  // will be compiled
  // NormalFunction
  // 1 stack slots
  // 4 heap slots
  // temporary vars:
  TEMPORARY .result;  // (0x11d04d130) local[0]
  // local vars:
  LET a;  // (0x11d04ce50) context[3]
  VAR func;  // (0x11d04d0a0)

  function func () { // (0x11d04cee0) (23, 28)
    // lazily parsed
    // NormalFunction
    // 2 heap slots
  }
}

在浏览器上打印

【js基础巩固计划】深入理解作用域与作用域链

可能有小伙伴会有疑问🤔:浏览器的打印全局作用域有 window 对象,而 v8解析出来的全局作用域没有

答案是:全局对象是由宿主环境提供的(目前我们熟知的有 node、浏览器)。V8在解析代码生成作用域前需要先准备好一系列环境(全局执行上下文、全局变量、事件循环系统等),这些均由宿主环境提供。
v8引擎只负责执行js。它运行在浏览器渲染主线程

函数作用域

函数的作用域由声明位置决定。函数块可以产生作用域。函数内声明的变量只能被子级作用域访问,无发被外层作用域访问

function func() {
    var a = 1
}
function ac() {
    console.log(a)
}
ac() // ReferenceError: a is not defined
console.log(a) // ReferenceError: a is not defined

块级作用域

为什么需要有块级作用域、怎么实现块级作用域的。有关块级作用域的详细解读可以参考我之前的文章:
【js基础巩固计划】你真的理解变量提升吗

作用域链

在这之前大家可以花一首歌的时间想想下面几个问题:

  1. 什么是作用域链?
  2. 函数的内部属性[[scopes]]是什么?
  3. 上面两者的关系或者说区别的是什么?

[[scopes]]

通过前面的例子我们可以知道[[scopes]]属性在编译阶段就已经确定好了,这里我们可以通过一个例子来看看[[scopes]]里面的细节

var g1 = 1
let g2 = 2
let g3 = 3

function func() {
  const func1 = 1
  const func2 = 2
  function innerFunc() {
    const innerFunc1 = 1
    console.log('g1 :>> ', g1);
    console.log('g2 :>> ', g2);
    console.log('func1 :>> ', func1);
    console.log('innerFunc1 :>> ', innerFunc1);
  }
  console.dir(innerFunc)
}

func()

控制台的输出如下:

【js基础巩固计划】深入理解作用域与作用域链

通过观察,我们可以发现[[scopes]]中存放着多个对象,包括以下几种

  1. Closure对象:闭包-只会包含外层作用域被当前内部作用域使用的变量(比如上例中fun2),这个后续会单独用一篇文章讲解
  2. Script对象:全局作用域下通过letconst声明的变量都会存放到这个对象
  3. Global对象:全局对象,包含了全局作用域中声明的各种变量,以及宿主环境提供的变量,浏览器环境下即是 window

这里心细的小伙伴也许会发现一个关键点:innerFunc函数本身内部声明的变量并不在里面。这个很好理解,因为[[scopes]]是在编译阶段产生的,此时innerFunc函数并没有执行,可见[[scopes]]并不是完整的作用域链

接下来我们来看看完整的作用域链(接下来执行上下文均以ES5规范为标准说明),是怎么产生的

依然是之前的代码,我们打个断点

var g1 = 1
let g2 = 2
let g3 = 3

function func() {
  const func1 = 1
  const func2 = 2
  function innerFunc() {
    debugger
    const innerFunc1 = 1
    console.log('g1 :>> ', g1);
    console.log('g2 :>> ', g2);
    console.log('func1 :>> ', func1);
  }
  innerFunc()
}

func()

【js基础巩固计划】深入理解作用域与作用域链

这里我们可以明显的看到Local对象是在函数执行的过程中产生的,它包含了当前执行上下文中词法/变量环境里的变量以及this绑定,而且它是动态的。随着函数的执行,innerFunc1将会被赋值为1。
所以完整的作用域链在[[scopes]]内置属性的基础上增添了Local对象

接下来我们用伪代码的行为来看看整体流程是怎么样的

  1. func函数被创建,保存作用域链到内部属性[[scopes]]
func[[scopes]] = [
   // globalContext
   {
      LexicalEnvironment: {       // 词法环境
        EnvironmentRecord: {
          g2: 2,
          g3: 3
        }
      },
      VariableEnvironment: {
        EnvironmentRecord: {
          g1: 1
        }
      }
   }
]

注意这里func函数没有被执行,innerFunc函数还不会被创建(但是其实他的词法作用域已经确定了-因为此时AST已经生成了,只是其内部属性[[scopes]]在创建的时候才会被添加)

  1. func函数被执行,执行上下文被创建。入执行上下文栈
ECStack = [
    funcContext,
    globalContext
];
  1. func函数并不立刻执行,开始做准备工作,复制函数[[scopes]]属性创建作用域链存到执行上下文中
funcContext = {
  scope: func[[scopes]]
}
  1. 用 arguments 创建词法环境、变量环境,随后初始化,加入形参、函数声明、变量声明(这里细化应该是对应的环境记录器,执行上下文后续专门讲解,这里简单了解下)
funcContext = {
   LexicalEnvironment: {       // 词法环境
    EnvironmentRecord: {
      func1: <value unavailable>,
      func2: <value unavailable>,
      innerFunc: f innerFunc()
    }
  },
  VariableEnvironment: {
    EnvironmentRecord: {
      
    }
  }
  scope: func[[scopes]]
}
  1. 词法、变量环境压入作用域链头部
funcContext = {
   LexicalEnvironment: {       // 词法环境
    EnvironmentRecord: {
      func1: <value unavailable>,
      func2: <value unavailable>,
      innerFunc: f innerFunc()
    }
  },
  VariableEnvironment: {
    EnvironmentRecord: {
      
    }
  }
  scope: [{LexicalEnvironment,VariableEnvironment},...func[[scopes]]]
}

同时在这个过程中就已经创建了 innerFunc函数(变量提升),所以这里会同步保存其作用域链到内部属性[[scopes]],注意这里会产生闭包

【js基础巩固计划】深入理解作用域与作用域链

innerFunc[[scopes]] = [Closure(func), ...func[[scopes]]]

6.func函数执行,修改对应词法/环境的值,此时 innerFunc[[scopes]]里面的属性值也会变

【js基础巩固计划】深入理解作用域与作用域链

  1. innerFunc函数执行,创建执行上下文但不会立刻执行……(后续步骤与func函数类似,这里不过多重复了)

最后 innerFunc的执行作用域Scope = [{LexicalEnvironment,VariableEnvironment},...innerFunc[[scopes]]]

好了,我们再回头看之前的问题:

  1. 什么是作用域链?
  2. 函数的内部属性[[scopes]]是什么?
  3. 上面两者的关系或者说区别的是什么?

相信小伙伴对这几个问题已经比较清晰了,这里给出一个总结:

  1. 代码在执行的时候,遇到变量或者函数,会先从当前的执行上下文中查找(var声明的变量在变量环境中查找,let、const、function在词法环境中查找),然后往父级(词法层面的父级)执行上下文中查找,直到全局执行上下文,这个查找变量的链条便是作用域链
  2. [[scopes]]是一个对象数组,每一个对象里面都包含相对父级(词法层面)执行上下文中词法环境/变量环境声明的变量(闭包会比较特殊,只包含了内部使用了的变量)
  3. [[scopes]]是代码最初编译阶段函数的词法作用域,作为函数的一个内部属性。函数执行时会创建执行上下文,此时复制函数[[scope]]属性创建作用域链添加到执行上下文中。随后创建变量/词法环境时将其添加到作用域链的头部形成自己完整的作用域。作用域链执行上下文中的某个属性

结语

到这里,就是本篇文章的全部内容了

在这整个过程有一个一直让人头疼的点没有详细介绍,那就是闭包。限于篇幅,闭包后续将单独抽一篇文章讲解。

如果你觉得该文章对你有帮助,欢迎大家点赞关注分享

如果你有疑问或者出入,评论区告诉我,我们一起讨论。

参考文章

JavaScript深入之作用域链

原文链接:https://juejin.cn/post/7359086027581620260 作者:l_xy

(0)
上一篇 2024年4月19日 上午10:33
下一篇 2024年4月19日 上午10:38

相关推荐

发表回复

登录后才能评论