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

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

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

前言

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

这是js基础巩固系列文章的第二篇,旨在帮助自己巩固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,指的是语法上不可能再分的、最小的单个字符或字符串

js 复制代码
var name = 'lxy'

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

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

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

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

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

js 复制代码
let b = 'outer'
function bar() {
  console.log(b) // outer
}
function foo() {
  let b = 'inner'
  bar() 
}
foo()

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

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

js 复制代码
// 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()

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

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

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

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

php 复制代码
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中变量、函数的作用域(全局、函数、块级)在编译阶段就已经确认了,取决于它声明的位置

作用域类型

全局作用域

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

js 复制代码
// 全局对象上的属性和方法
// window.document
// window.location
// window.localStorage
// window.setTimeout

let a = 1
function func() {}

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

csharp 复制代码
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
  }
}

在浏览器上打印

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

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

函数作用域

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

scss 复制代码
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]]里面的细节

js 复制代码
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()

控制台的输出如下:

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

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

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

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

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

js 复制代码
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()

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

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

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

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

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

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

lua 复制代码
innerFunc[[scopes]] = [Closure(func), ...func[[scopes]]]

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

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

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

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

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

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

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

结语

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

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

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

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

参考文章

JavaScript深入之作用域链

相关推荐
桂月二二41 分钟前
探索前端开发中的 Web Vitals —— 提升用户体验的关键技术
前端·ux
hunter2062062 小时前
ubuntu向一个pc主机通过web发送数据,pc端通过工具直接查看收到的数据
linux·前端·ubuntu
qzhqbb2 小时前
web服务器 网站部署的架构
服务器·前端·架构
刻刻帝的海角2 小时前
CSS 颜色
前端·css
浪浪山小白兔3 小时前
HTML5 新表单属性详解
前端·html·html5
lee5764 小时前
npm run dev 时直接打开Chrome浏览器
前端·chrome·npm
2401_897579654 小时前
AI赋能Flutter开发:ScriptEcho助你高效构建跨端应用
前端·人工智能·flutter
limit for me4 小时前
react上增加错误边界 当存在错误时 不会显示白屏
前端·react.js·前端框架
浏览器爱好者4 小时前
如何构建一个简单的React应用?
前端·react.js·前端框架
qq_392794484 小时前
前端缓存策略:强缓存与协商缓存深度剖析
前端·缓存