如何理解js执行上下文

参考

  1. JS夯实之执行上下文与词法环境
  2. 面试官:说说执行上下文吧
  3. javascript图解之JavaScript引擎
  4. 分享一篇可视化的JS引擎执行流程
  5. 深入了解JavaScript执行过程(JS系列之一)
  6. 前端开发JavaScript原理:引擎基础
js解析器(引擎)执行过程 编译阶段
JS引擎有多种,比如v8(chrome)、JSCore(safari)等。
以基于Node.js和Chrome浏览器使用的 V8引擎 为例:
  • HTML解析器遇到脚本标记
  • 从网络或缓存请求此脚本
  • 响应是作为字节流的脚本
  • 字节流解码器在下载字节流时对其解码
  • 字节流解码器从已经解码的字节流创建词法单元token 【词法分析阶段】
    • token可以理解为语法上不可能再分的,最小的单个字符或字符串
    • 例如,0066解码为f,0075解码为u,006e解码为n,0063解码为c,0074解码为t,0069解码为i,006f解码为o,006e解码为n,后跟空格。这是关键字function,为其创建了一个token
    • var a = 2 ,这段程序会被分解成:“var、a、=、2、;” 五个 token
  • token列表被发送给解析器(parser)和预解析器(preparser)
    • 预解析器处理稍后可能使用的代码,而解析器处理立即需要的代码
    • 如某个函数只在用户单击某个按钮后才被调用,则不需要立即编译该代码来加载网站。如果用户最终点击按钮并需要这段代码,它就会被发送到解析器。
  • 解析器从字节流解码器中接收到token,创建节点,并形成一个抽象语法树(AST)。【语法分析阶段】
    • AST用树状结构表达源代码的语法结构
  • 解释器(interpreter)遍历AST,生成字节码,最后删除AST
    • 字节码 是介于 AST 和 机器码 之间的一种代码,需要将其转换为 机器码 后才能执行
  • 字节码发送给优化编译器(optimizing compiler),生成高度优化的本地机器码【JIT即时编译技术,只有一些js引擎有;优化编译器也叫JIT编译器】
    • 将AST转换为可执行代码的过程称为代码生成,因为计算机只能识别机器指令
总结:
  • 词法分析:形成词法单元token 【字节流解码器】
  • 语法分析:将token列表转换成抽象语法树AST【解析器】
  • 代码生成:将AST转换成字节码【解释器】、机器能够识别的机器码【优化编译器】
上面就是编译阶段,下面就是执行阶段了
执行阶段
在执行之前,还需要做一个准备工作,即生成执行上下文(创建阶段)。
执行上下文概念 执行上下文(execution context 简称 EC)即代码运行的环境,每当JS运行时,都是在执行上下文中运行的。主要记录了代码执行过程中的状态信息,如:
  • 变量对象的定义
  • 作用域链的扩展
  • 提供调用者的对象引用等信息
代码结束时,执行上下文也会销毁。
执行上下文栈 & Event Loop执行机制
  • 执行栈(call stack)是用来存放不同执行上下文的栈结构(LIFO后进先出),因为脚本运行时可能会调用很多函数而产生很多函数执行上下文,统一交由执行栈管理。
  • 执行栈的容量是有限的,如果积压到一定程度继续积压,会报“栈溢出”(stack overflow)错误。栈溢出错误常发生在递归(调用自身)中。
  • Event Loop事件循环是JS的执行机制。因为js引擎线程是单线程的,为了合理地安排同步任务和异步任务的执行,定义了事件循环机制来协调。Event Loop会先执行完处于执行栈中的任务后,然后从事件队列读取事件,添加到执行栈中执行,如此循环。
执行上下文分类 根据不同的运行场景,执行上下文分为:
  • 全局执行上下文
    • js代码开始的默认运行环境,在整个js脚本的生命周期中一直存在于执行堆栈的最底部不会被栈弹出销毁
    • 全局上下文会生成一个全局对象(浏览器中是window,node中是global)
    • 将this指向全局对象
  • 函数执行上下文
    • 函数调用时创建
    • 不管函数调用几次,每次调用都会生成新的上下文
  • eval执行上下文
    • eval函数执行时,会有属于它自己的执行上下文
执行上下文生命周期 创建阶段
  • 初始化变量对象
    • 如果是函数执行上下文,会以参数列表(arguments)初始化变量对象,以及将函数内部的变量声明、函数声明添加到变量对象。
    • 在这一阶段,会进行变量和函数的初始化声明,变量定义为undefined,而函数直接定义。即变量提升,变量和函数都会提升,但函数会更靠前。
      //变量提升中,函数更靠前,所以会被同名的变量覆盖 var a = 2 function a(){ } console.log(a); //2

  • 构建作用域链
  • 确定this值
执行阶段
【如何理解js执行上下文】JS代码开始逐句执行,顺着作用域链访问变量、为之前声明的变量赋值、碰到函数调用则创建新的函数执行上下文压入栈中,并把控制权交出。
销毁阶段
一般情况下,函数执行完成后,会将当前上下文弹出执行栈进行销毁,将控制权交回给上一层的执行上下文。但是闭包的情况有所不同。
当包裹闭包函数的父函数执行完毕后,父函数本身执行环境的作用域链被销毁,但是由于闭包函数有对父函数变量的引用,导致父函数的变量对象一直存在于内存,无法被销毁,除非闭包的引用被删除。
过多使用闭包导致不再用到的内存,没有及时释放,有可能发生“内存泄露”。
ES3执行上下文的内容 在ES3的定义中,执行上下文包括:
  • 变量对象 (variable object 简称 VO)
  • 活动对象 (activation object 简称 AO)
  • 作用域链(scope chain)
  • 调用者信息 (this)
变量对象
在全局执行上下文中,变量对象即全局对象,以浏览器来说即window。通过var定义的全局变量或函数都会成为window的属性和方法,但是let和const的顶级声明不会。
在函数执行上下文中,变量对象会包含函数的参数列表(arguments),及函数内部声明的变量、函数(注意是函数声明会加入变量对象,函数表达式不会)。但是不能被直接访问到,需等函数调用时,VO变为AO。
//函数声明 function a = {}//函数表达式 //b是变量声明,会加入变量对象 //foo是函数表达式,会被忽略 let b = function foo(){}

活动对象
函数调用时,函数上下文的变量对象转变为活动对象。其实变量对象和活动对象是一个东西,只不过处于不同的状态和阶段而已。
作用域链
作用域规定如何查找变量,即当前执行代码对变量的访问权限。
创建执行上下文时,会为变量对象创建作用域链:当查找变量时,如果不存在于当前上下文的变量对象,则会往上一级上下文中的变量对象中查找,一直到全局上下文。这样由多级上下文的变量对象构成的链表,称为作用域链。
函数在创建时已经确定作用域:创建一个scope的内部属性保存所有父变量对象;当函数执行时,创建执行上下文,则会复制scope来构建作用域链,然后将VO转变为AO并添加到作用域链前端。
调用者信息
this是执行上下文创建时会创建的一个属性,this绑定的对象取决于函数调用的条件。
  • 直接使用不带任何修饰的函数引用进行调用,则绑定到window【默认规则】
  • 调用位置有上下文对象,则this绑定到那个上下文对象【隐式绑定】
  • 使用call、apply、bind显式绑定 【显式绑定】
  • 使用new操作符将this绑定到新对象 【new绑定】
  • 箭头函数的this不适用上面的规则,而是根据外层作用域绑定:会等于外层函数的this或全局对象
数据结构模拟
如果用代码将执行上下文表达出来:
executionContext:{ [variable object | activation object]:{ arguments, variables: [...], funcions: [...] }, scope chain: variable object + all parents scopes thisValue: context object }

ES5执行上下文的内容 ES5对执行上下文的概念做了调整,去掉了变量对象、活动对象,用词法环境组件(LexicalEnvironment component)和变量环境(VariableEnvironment component)组件代替。
所以 ES5 的执行上下文概念上表示大概如下:
ExecutionContext = { ThisBinding = , LexicalEnvironment = { ... }, VariableEnvironment = { ... }, }

词法环境
1.定义 词法环境的定义: 词法环境是一种规范类型,基于 ECMAScript 代码的词法嵌套结构来定义标识符和具体变量和函数的关联。一个词法环境由环境记录器和一个可能的引用外部词法环境的空值组成。
简单来说,词法环境是标识符—变量的映射结构:
  • 标识符是指变量/函数的名字
  • 而变量是对实际对象的引用 或 基础类型数据的值
比如:
let a = 10; let b = 20; function foo(){}//词法环境类似于 lexicalEnvironment = { a: 10, b: 20, foo: }

2.组成
  • 环境记录(Environment Record):存放变量和函数声明的地方
    • 又可分为声明类环境记录(declare ER)、对象类环境记录(object ER)
    • 声明类环境记录:储存函数声明和变量声明(let、const、class、import、函数声明)
      • 声明类环境记录中有一个特别的,即函数环境记录,因为会额外增加一些内部属性。
    • 对象类环境记录:主要用于with和global的词法环境。全局上下文的ER,是声明类+对象类,对象类存放全局对象函数、函数声明、async、generator、var关键词变量,声明类存放let、const、class等。
  • 外层引用(Outer):提供了访问父词法环境的引用,如果在全局上下文中则为null。根据这个属性来构建作用域链
3.词法环境伪代码
let a = 10; function foo(){ let b = 20 console.log(a, b) } foo()// 它们的词法环境伪码如下: GlobalEnvironment: { EnvironmentRecord: { type: 'object', //declare 和 object的混合, //由于标准中将object类型的ER视作基准ER,因此这里我们仍将全局ER的类型视作object a: , foo: }, outer: }FunctionEnvironment: { EnvironmentRecord: { type: 'declarative', arguments: {length: 0},//有arguments对象,记录函数的入参信息 b: , }, outer: }

变量环境
变量环境本质上是词法环境,只是只用来储存用 var 声明的变量。
ES5执行上下文的声明周期 依然是创建、执行、销毁三阶段,只是创建阶段里所做的事有所不同。
创建阶段:
  • 创建词法环境
  • 创建变量环境
  • 绑定this
在创建词法环境和变量环境时,JS引擎找出变量和函数声明,变量最初会设置为 undefined(var 情况下),或者未初始化uninitialized(let 和 const 情况下)。这也是为什么在声明前访问var变量为undefined【声明提升】,在声明前访问let/const变量/常量会报错。
ES5执行上下文的流程总结
  • 1 全局执行上下文的创建阶段
    • 1.1 创建词法环境
      • 1.1.1 创建对象类环境记录【存放变量和函数声明,不包括var,初始值为uninitialized】
      • 1.1.2 创建外层引用,为null
    • 1.2 创建变量环境
      • 1.2.1 创建对象类环境记录【存放var声明,初始值为undefined】
      • 1.2.2 创建外层引用,为nulll
    • 1.3 确定this为全局对象
  • 2 函数被执行时,函数执行上下文的创建阶段
    • 2.1 创建词法环境
      • 2.1.1 创建声明类环境记录【存放变量和函数声明和arguments,不包括var,初始值uninitialized】
      • 2.1.2 创建外层引用,为父词法环境或全局对象
    • 1.2 创建变量环境
      • 2.1.1 创建声明类环境记录【存放var声明,初始值为undefined】
      • 2.1.2 创建外层引用,为父词法环境或全局对象
    • 1.3 确定this值
  • 3 执行上下文的执行阶段
    • JS代码开始逐句执行,顺着作用域链访问变量、为之前声明的变量赋值
总结:其实ES5的执行上下文和ES3的本质差别不大,只是为了服务let和const而变更了概念。
更多前端学习笔记请戳这里~

    推荐阅读