不一样的JavaScript

568 阅读18分钟

导读:本文以JavaScript计算机编程语言为载体,从执行过程去解析它的运行原理,从编译的角度去解析它的结构,最后以AST和产生式作为切入点进行案例分析,目的是为了让读者从更底层去了解计算机编程语言。

关于作者:

雷庭,任职于北京优锘科技,前端架构师,从事前端开发及架构工作17年,擅长可视化领域的前端开发,前端沟通交流可加作者微信ltlt820706

从一段简洁的JavaScript代码开始:

// 变量申明
const name = 'Zhangsan'
const age = 18

// 函数申明
function getPersonInfo(name, age) {
  return `姓名:${name},年龄:${age}`
}

console.log(getPersonInfo(name, age))

上述代码调用getPersonInfo函数,入参后返回计算后的值,并打印到控制台上,这我相信大家都知道;但是这段代码执行的过程中到底是否按照书写行的顺序执行的?其在编译器内部是什么样子的?本文带着这些疑问,一步一步带你找到答案。

语法及其作用

以JavaScript为例其基础语法包含:变量申明、语句、标识符、操作符、数据类型、作用域链、函数、类等。 语法的作用:

  • 指导程序员按照语法规则编写程序逻辑
  • 编译器基于语法约束对程序进行编译执行

1. JavaScript的运行过程

JavaScript是属于解释型语言,符合编译器标准的代码无需经过编译即可在其解释器(V8是一种)中执行,但其执行的过程中依然会先编译再执行。

1.1 Callstack

JavaScript使用Callstack(调用栈)方式管理和执行代码,可以理解为执行引擎将JavaScript代码编译N个不同的block,执行到当前block时将该block压入Callstack中,按照调用关系最先进入的block会被压在栈底,直到相关的最后一个block执行完毕后,按照LIFO模式推出Callstack,如下图:

执行栈

1.2 Excution Context

Excution Context是执行上下文(下文简称EC),为执行栈中的每个block提供执行环境,换句话说每个block对应一个EC。 EC分为三种:Global EC、Function EC、Eval EC

  • Global EC:全局默认的执行上下文,JavaScript代码一旦被载入会先创建一个Global EC,整个执行环境中只会有一个执行Global EC;
  • Function EC:函数执行上线文,当函数的block执行入栈时,JavaScript执行引擎会创建一个Function EC,执行环境中会存在多个Function EC;
  • Eval EC:当eval的block执行入栈时,JavaScript执行引擎会创建一个Eval EC,执行环境中会存在多个Eval EC;

不管哪种EC,在创建时会包含3个必要的功能模块:VO(Variable object)、Scopechain、this指针

  • VO:变量对象,用于保存申明的变量,可以理解为一个对象实例,key是变量名,value是变量对应的值,当程序入栈后开始执行时VO状态变为AO(Action object)活动对象;

入栈后EC形成会先将block中的代码进行变量提升,提升后的变量进入VO,因此这里有一个关键点,JavaScript代码的执行顺序并不是我们书写代码的顺序,所有变量申明的部分会被先提升(包含var、let、const及函数申明),如下图:

变量提升

  • Scopechain:作用域链,由执行栈中的最下层block一直向上将VO串联在一起形成的关系链,用于确定每个EC执行的访问关系(通常我们所说的变量作用域);

作用域链

  • this指针:用于确定当前执行环节的this指向;

2. JavaScript在编译器内部如何执行

本文以Chrome V8 JavaScript执行引擎为例,引擎分为4个部分:Parser、Ignition、Turbofan、Orinoco [该章节参考]

2.1 chrome V8模块关系一张图

2.2 Parser:

负责将JavaScript源代码转换为Abstract Syntax Tree(AST抽象语法书)

如何转换源代码到AST需要2步:

  • 词法分析->scanner词法分析器进行词法分析,转换成一个个有意义的token,例如:var a = 'This is a string';词法分析后的结果:

词法分析

scanner官网

  • 语法分析->parser语法分析器,将词法分析的tokens转换成V8的AST,V8的AST

2.3 Ignition:

解释器,将上一步Parser的结果AST解析成字节码,JavaScript代码就是在此开始执行

2.4 Turbofan:

Turbofan是根据字节码和热点函数反馈类型生成优化后的机器码,Turbofan很多优化过程,基本和编译原理的后端优化差不多,采用的sea-of-node

上述add函数接受x,y参数,由于JavaScript是弱类型语言,只有运行时才能判定参数类型,因此Ignition在转换成字节码时会执行%OptimizeFunctionOnNextCall(add)主动调用Turbofan对add函数进行优化。 程序的执行时在CPU指令集上,编译型的语言会在执行前被编译成可执行文件,所以必须强类型,而JavaScript则会在执行时动态编译和判定类型。

2.5 Orinoco:

garbage collector,垃圾回收模块,负责将程序不再需要的内存空间回收;

3. JavaScript编译浅理解

上面的章节讲到的都是符合ES规范要求的JavaScript代码段执行,但是对于现代前端项目来说技术要复杂的多,众多技术的融合项目,了解编译已经是一门必修课。

目前我们接触到的大部分项目都是由Vue,sass,模板,react等众多前端技术混合而成,而且几乎每个项目都会用到webpack进行打包,那么这个打包的过程就是执行的标准编译流程。

3.1 编译项及编译目的

  • 为了程序更容易编写,大部分程序员采用ES6+的标准进行编码,目标浏览器对ES支持的程度不同,编译是为了差异化运行;
  • 项目中用到模板技术,模板本身浏览器无法直接识别,因此需要编译成标准的html或者JavaScript代码;
  • 由于项目中使用sass,styus等预编译样式技术,因此需要利用编译程序将他们编译成标准的css;
  • 由于项目中使用vue,react等框架技术,框架都有自己的语法糖,代码书写特定语法,因此需要将这些内容编译成ES支持的标准语法代码;
  • 由于项目设计多端运行,需要将vue代码转换成微信小程序代码,这些也需要进行编译;

3.2 编译过程

如果需要观测编译过程中产生的AST、Token等,推荐工具:

在线工具:EsprimaASTExploer VSCode工具:Babel AST Exploer

JavaScript、模板、预编译样式等编译都遵从同样的编译过程,如下:

3.2.1 源代码

这里的源代码是多模块,多文件联合的包括框架特有语法糖、预编译样式、附件源文件(图片等)、标准JavaScript代码等众多复合元素,扩展名包含但不限于:.js,.vue,.scss,.styus,.png,.jpg等,编译器会根据链接关系(import或者require)逐级将源文件作为编译输入

3.2.2 词法分析

词法分析也叫代码扫描scanner,将输入源代码按照分词规则进行分词,分词后的内容包装进一个个Token中(和上个章节提到的token类似),同时它会移除空白符,注释等内容,最后代码被分割进一个个tokens列表(类似Token数组),如下图:

附注:上图是在VSCode中利用babylon工具对代码:var a = 123进行分词的结果 分词规则:分词程序分析源代码的时候,它会一个一个字母去读取源代码,直到遇到空白符、操作符或者其他特殊符号会认为是一个词的结束,所以形象的称之为扫描scanner。

3.2.3 语法分析

语法分析也叫解析器,解析器会删除一些没有必要的token(例如不完整的括号),因此AST不是100%匹配源代码的,AST的生成是根据文法和节点类型定义构造出来的。

  • 文法:描述了程序设计语言的构造规则(语法的具体表达),用于指导整个语法分析的过程,它由4个部分构成
    • 一组终结符号(Token)
    • 一组非终结符号
    • 一组产生式
    • 一个开始符号

下图是JavaScript的函数产生式

  • 推导:语法分析器将Token逐个读入,不断替换产生式体的非终结符号,直至全部将非终结符号替换为终结符号,这个过程被称为推导,推导又分为左推导和右推导两种
    • 左推导:优先替换产生式左侧的非终结符号;
    • 右推导:有限替换产生式右侧的非终结符;

语法分析器按照工作方式来划分,自顶向下分析法和自底向上分析法

  • 自顶向下分析法使用左推导方式构建AST,常用分析算法工具:递归向下分析器、LL语法分析器;
  • 而自底向上分析法使用右推导方式构建AST,常用分析算法工具:LR语法分析器,SLR语法分析器,LALR语法分析器;

例子:foo.bar.baz.qux如果采用左推导方式得出的结果:foo.(bar.(baz.qux)),但正确的结果是:((foo.bar).baz).qux,所以这必须采用右推导的方式进行语法分析。

3.2.4 语法转换

语法转换又称语义分析,这个阶段通常会检查程序上下文是否和语言所定义的语义一致,比如类型检查,作用域检查;另一个则是生成中间代码,比如三地址代码:用地址和指令来线性描述代码,。在JavaScript的语义分析阶段通常会进行ES语法转换,将AST中对应的节点转换成目标ES程序的节点对象并加以替换,这一步通常会使用Babel来进行,因为Babel具备成熟的生态链,其插件能够满足大部分需求,如果插件库中的插件解决不了,还可以自己根据规则编写Babel插件(如何写Babel插件后续会出专文)。 例如:下图将匿名函数转换成箭头函数,就在这一步进行完成的。

在AST基础上进行语法转换的详细案例见下一章:AST

3.2.5 编译输出

经过词法分析、语法分析、语法转换后,源代码资源已经被转换成可用的AST和系列附件产物,在编译输出阶段会将前面所得到的中间产物进行整合输出成最终可用的系列文件,编译输出的动作包含:

  • AST转换成源代码字符串流,最终形成文件存储
  • 多文件连接合并
  • 附件资源存入指定文件夹

4. AST

AST是什么?

AST抽象语法树简写abstract syntax tree,上个章节V8在执行JavaScript代码时会先将源代码编译成AST在转成字节码执行,事实上,无论哪种语言,在编译时都会将源代码编译成AST作为中间产物,AST是计算机编译原理中很早的概念,不属于V8特有,更不属于JavaScript特有。

这里我们讲的AST都是和JavaScript相关的,后文的都属于狭义AST

4.1 AST应用场景

这里要讲的是前端工程编译过程中的AST概念,常见场景有:

  • 对代码中无业务作用的代码进行删除,如:console调用的删除
  • 对源代码的ES版本进行升降级,如:es5->es6,es6->es5,箭头函数转普通函数等
  • 对代码进行压缩,压缩利用对上下文调用的查找实现
  • 对框架模板进行编译,需要将模板先转换成AST后再进行进一步语法转换
  • 对scss,styus等预编译样式文件进行编译
  • ESLint,TSLint等协助开发的工具也是利用AST实现

4.2 AST编译过程

如上图,语法分析阶段形成AST,AST不是从源代码的基础上转换而来,而是从词法分析后形成的tokens转换而来,AST构成的依据是JavaScript文法产生式(关于产生式在后续章节讲),AST是一组树型结构化数据,其目的方便后续使用节点查询算法对AST进行语法分析。 AST的结构如下图:

AST是一组树型结构化数据,它遵从ESTree标准,所以树上的每个节点都有对应的type、属性用于描述该节点(Node),ESTree是一个业界统一的标准(这也是现在前端代码能够发展这么迅速的原因之一),下一章ESTree就是ES的标准AST对象模型。

4.3 ESTree

ESTree是业界统一遵从的标准,它定义了JavaScript中所有涉及到的语法的表达形式,对语法元素描述进行统一标准的定义,并且ES在不断的升级过程中ESTree也会伴随着进行升级。 [ESTree规范官网] 这里看一个例子:

这里定义一个z变量并且赋值10,在AST里描述可以简化成4步

  1. 生成一个type为VariableDeclaration的Node对象,用于标识这是一个变量申明的语句
  2. 将kind属性赋值为const,用于标识这个变量申明使用的const关键字
  3. 使用type为Identifier类型的节点来确定这个变量的名称,文中为name: 'z'
  4. 因为赋值的是Number类型10,所以使用type为NumeriLiteral类型的节点对z进行赋值

类似VariableDeclaration的类型还有很多,每种类型有自己的对象属性,ESTree就是由多种数据类型构成的数据结构体。

4.3.1 ESTree规范

ESTree的描述中

  • :> 表示的是子类型关系,结果是产生了新类型;
  • extend 表示没有产生新类型,只是对原来类型的扩展,也就是添加了新属性,  或者对原来属性值进行修改/扩展

ESTree规范请点击这里

4.4 AST常用工具

在线工具:EsprimaASTExploer VSCode扩展工具:Babel AST Exploer

打包编译工具:babel,babylon(现在已经合并到babel中)

  • 需要在代码编译过程中看到中间结果(token,AST),可以使用babylon

4.5 AST应用案例

4.5.1 案例一

写在前面,说明既重要又不重要!案例二的代码(在下面代码块里),那么复杂,那么多函数调用,第一次接触的人可能会问,这代码这么复杂,怎么才会写?从哪入手写?

莫着急,在代码结束后,会分析几个常见问题,争取让您也能写起来。

在前端架构过程中,我们为了达到业务最大程度复用,有时候会对代码进行不同运行平台的转换,这个案例是将一段Vue代码编译成微信小程序代码。

上面左侧的代码转换成右侧代码通过简单的正则匹配去完成是非常麻烦的事,这时候就必须利用AST抽象语法树在语法分析的基础上进行转换。 完成上面的转换需要4步:

  1. 将data函数转换成data属性,并且原有data函数的blockStatement作为箭头函数的函数主题
  2. 将methods属性中的add和minus提取出来放到methods同级,同时删除methods
  3. 将this.[data menber]this.data.[data menber],注意这里只转换data中的属性
  4. 在变更的this.data下面插入this.setData函数来触发数据变更

对应到代码中如下(代码借鉴网络作者肉欣某文,但原文有bug,本作者已经修正):

// vuecode.js文件内容
export default {
  data() {
    return {
      message: 'hello vue',
      count: 0
    }
  },
  methods: {
    add() {
      ++this.count
    },
    minus() {
      --this.count
    }
  }
}
// transform.js完成vuecode.js文件中代码转换的主题程序文件

const parser = require('@babel/parser')
const t = require('@babel/types')
const generate = require('@babel/generator').default
const traverse = require('@babel/traverse').default

const fs = require('fs')
const path = require('path')

const codeString = fs.readFileSync(path.join(__dirname, './vuecode.js')).toString()

if (codeString) {
  let ast = parser.parse(codeString, {
    sourceType: 'module',
    plugins: ['flow']
  })

  // 1.获取data函数的函数体保存,然后创建data属性并使用前面保存的函数体作为箭头函数的函数体
  traverse(ast, {
    ObjectMethod(path) {
      if (path.node.key.name === 'data') {
        // 获取第一级的 BlockStatement,也就是data函数体
        let blockStatement = null
        path.traverse({  //将traverse合并的写法
          BlockStatement(p) {
            blockStatement = p.node
          }
        })

        // 用blockStatement生成ArrowFunctionExpression
        const arrowFunctionExpression = t.arrowFunctionExpression([], blockStatement)
        // 生成CallExpression
        const callExpression = t.callExpression(arrowFunctionExpression, [])
        // 生成data property
        const dataProperty = t.objectProperty(t.identifier('data'), callExpression)
        // 插入到原data函数下方
        path.insertAfter(dataProperty)

        // 删除原data函数
        path.remove()
        // console.log(arrowFunctionExpression)
      }
    }
  })

  // 2.找到methods属性将内部内容提升到和methods同级,然后删除methods
  traverse(ast, {
    ObjectProperty(path) {
      if (path.node.key.name === 'methods') {
        // 遍历属性并插入到原methods之后
        path.node.value.properties.forEach(property => {
          path.insertAfter(property)
        })
        // 删除原methods
        path.remove()
      }
    }
  })

  // 获取`this.data`中的属性
  const datas = []
  traverse(ast, {
    ObjectProperty(path) {
      if (path.node.key.name === 'data') {
        path.traverse({
          ReturnStatement(path) {
            path.traverse({
              ObjectProperty(path) {
                datas.push(path.node.key.name)
                path.skip()
              }
            })
            path.skip()
          }
        })
      }
      path.skip()
    }
  })

  traverse(ast, {
    MemberExpression(path) {
      if (path.node.object.type === 'ThisExpression' && datas.includes(path.node.property.name)) {
        const propertyName = path.node.property.name

        // 3.在this[data member]替换成this.data.[data member]
        path.get('object').replaceWithSourceString('this.data')

        //一定要判断一下是不是赋值操作
        if (
          (t.isAssignmentExpression(path.parentPath) && path.parentPath.get('left') === path) ||
          t.isUpdateExpression(path.parentPath)
        ) {
          // findParent
          const expressionStatement = path.findParent((parent) =>
            parent.isExpressionStatement()
          )
          // 4.创建setData函数并插入到当前path父级的后面
          if (expressionStatement) {
            const finalExpStatement =
              t.expressionStatement(
                t.callExpression(
                  t.memberExpression(t.thisExpression(), t.identifier('setData')),
                  [t.objectExpression([t.objectProperty(
                    t.identifier(propertyName), t.identifier(`this.data.${propertyName}`)
                  )])]
                )
              )
            expressionStatement.insertAfter(finalExpStatement)
          }
        }
      }
    }
  })

  // 5.generate处理AST返回处理后的代码字符串
  const result = generate(ast, {}, '').code

  // 将转换后的代码写入同目录的goalTemplate.js文件中
  fs.writeFileSync(path.join(__dirname, './goalTemplate.js'), result)
}

上面代码那么复杂,改如何入手?

几个关键的名词

  • @babel/parser
    • 用于将源代码字符串转化成符合ESTree标准的AST
    • 如果源代码不规范,parser的过程中会报错
  • @babel/types
    • 官方定义是:手动创建AST,检查AST节点类型的工具集,[官网解释]
    • 比如创建一个变量定义可以使用t.variableDeclaration(kind,[declarator]),具体参数(下图)可以到ESTree查询

  • @babel/generator
    • 用于将AST生成代码
  • @babel/traverse
    • 用于遍历和更新AST的每一个节点,调用其traverse函数即可,调用方式:traverse(ast,{类型(path)})
    • 可多次调用traverse对AST进行不同的业务处理

几个关键的函数调用

transfrom.js(将vue代码转小程序代码)文件中处理第二步:将methods中的属性全部移到外面并且删除methods属性是怎么做到的?

  // 2.找到methods属性将内部内容提升到和methods同级,然后删除methods
  traverse(ast, {
    ObjectProperty(path) {
      if (path.node.key.name === 'methods') {
        // 遍历属性并插入到原methods之后
        path.node.value.properties.forEach(property => {
          path.insertAfter(property)
        })
        // 删除原methods
        path.remove()
      }
    }
  })

问题1:我怎么知道遍历的是ObjectProperty? 答案:借助ASTExploer工具,将源代码输入在左侧,右侧AST中直接找到这段代码对应到AST中是什么,如下图:

也就是说,我们只要借助工具就知道这段代码的type是什么,这里就可以直接在travers里调用ObjectProperty,并且函数内判断path.node.key.name==='methods' **问题2:**将methods中的add和minus移动到methods同级,我怎么知道是遍历properties就可以拿到add和minus函数? 答案:也是借助ASTExploer工具即可看得到你要移动的主体在哪,如下图:

小结一下:当你不知道该从哪开始的时候,先使用工具观察观察需要被编译的代码AST结构是什么样的,会越看越明白,作者也是这么看过来的。

4.5.2 案例二

有了案例一的详细讲解,案例二只描述编译过程,原理参照案例一

将箭头函数表达式编译成ES5函数申明,如下图:

完成这样的编译需要6步

  1. 将箭头函数格式化成AST
  2. 从箭头函数AST中提出变量 add和箭头函数的形参保存备用
  3. 判断箭头函数主体=>后是否有{}
    • 如果有花括号则直接作为新生成es5函数申明的主体;
    • 如果没有花括号则利用types生成新的BlockStatement,并将老箭头函数的主题封装进ReturnStatument
  4. 利用前三步的变量名、形参、函数主体,生成新的es5函数声明
  5. 将新函数声明写进AST
  6. 利用generator生成新代码,并写入文件

转换的主体代码如下:

const parser = require('@babel/parser')
const t = require('@babel/types')
const generate = require('@babel/generator').default
const traverse = require('@babel/traverse').default

const fs = require('fs')
const path = require('path')

// 读取需要转换的函数代码
const codeString = fs.readFileSync(path.join(__dirname, './sourceCode.js')).toString()

if (codeString) {
  let ast = parser.parse(codeString, {
    sourceType: 'module',
    plugins: ['flow']
  })

  let newFnAST = null

  traverse(ast, {
    ArrowFunctionExpression(path) {
      // 用于存放箭头函数申明的保存变量主体对象ast
      const id = path.parent.id

      // 用于保存箭头函数参数ast
      const params = path.node.params

      // 用于保存新生成的es5函数主题
      let block = null

      // 1.1 如果箭头函数主体是花括号,则直接使用箭头函数的body作为es5函数申明的body
      if (path.node.body.type === 'BlockStatement') {
        block = path.node.body
      }

      // 1.2 如果箭头函数的主体是二进制表达式,则利用表达式构建花括号主体
      if (path.node.body.type === 'BinaryExpression') {
        block = t.blockStatement([
          t.returnStatement(path.node.body)
        ])
      }

      // 2.利用箭头函数的参数,id,第1步中生成的新函数主体生成新函数ast
      if (path.parent.type === 'VariableDeclarator') {
        // 构造普通函数申明
        newFnAST = t.functionDeclaration(id, params, block, false, false)
      } else {
        // 构建函数表达式
        newFnAST = t.functionExpression(id, params, block, false, false)
      }
    }
  })

  traverse(ast, {
    Program(path) {

      // 3.将新函数ast push进boday
      path.node.body.push(newFnAST)
    }
  })

  // 4.generate处理AST返回处理后的代码字符串
  const result = generate(ast, {}, '').code

  // 将转换后的代码写入同目录的goalTemplate.js文件中
  fs.writeFileSync(path.join(__dirname, './goalTemplate.js'), result)
}