不一樣的 JavaScript

導讀:本文以 JavaScript 計算機編程語言爲載體,從執行過程去解析它的運行原理,從編譯的角度去解析它的結構,最後以 AST 和產生式作爲切入點進行案例分析,目的是爲了讓讀者從更底層去了解計算機編程語言。

從一段簡潔的 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

不管哪種 EC,在創建時會包含 3 個必要的功能模塊:VO(Variable object)、Scopechain、this 指針

入棧後 EC 形成會先將 block 中的代碼進行變量提升,提升後的變量進入 VO,因此這裏有一個關鍵點,JavaScript 代碼的執行順序並不是我們書寫代碼的順序,所有變量申明的部分會被先提升(包含 var、let、const 及函數申明),如下圖:

變量提升

作用域鏈

  1. JavaScript 在編譯器內部如何執行 ========================

本文以 Chrome V8 JavaScript 執行引擎爲例,引擎分爲 4 個部分:Parser、Ignition、Turbofan、Orinoco [該章節參考]

2.1 chrome V8 模塊關係一張圖

2.2 Parser:

負責將 JavaScript 源代碼轉換爲 Abstract Syntax Tree(AST 抽象語法書)

如何轉換源代碼到 AST 需要 2 步:

詞法分析

scanner 官網

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,垃圾回收模塊,負責將程序不再需要的內存空間回收;

  1. JavaScript 編譯淺理解 ===================

上面的章節講到的都是符合 ES 規範要求的 JavaScript 代碼段執行,但是對於現代前端項目來說技術要複雜的多,衆多技術的融合項目,瞭解編譯已經是一門必修課。

目前我們接觸到的大部分項目都是由 Vue,sass,模板,react 等衆多前端技術混合而成,而且幾乎每個項目都會用到 webpack 進行打包,那麼這個打包的過程就是執行的標準編譯流程。

3.1 編譯項及編譯目的

3.2 編譯過程

如果需要觀測編譯過程中產生的 AST、Token 等,推薦工具:

在線工具:Esprima,ASTExploerVSCode 工具: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 的生成是根據文法和節點類型定義構造出來的。

下圖是 JavaScript 的函數產生式

語法分析器按照工作方式來劃分,自頂向下分析法和自底向上分析法

例子: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 和系列附件產物,在編譯輸出階段會將前面所得到的中間產物進行整合輸出成最終可用的系列文件,編譯輸出的動作包含:

  1. AST ======

AST 是什麼?

AST 抽象語法樹簡寫 abstract syntax tree,上個章節 V8 在執行 JavaScript 代碼時會先將源代碼編譯成 AST 在轉成字節碼執行,事實上,無論哪種語言,在編譯時都會將源代碼編譯成 AST 作爲中間產物,AST 是計算機編譯原理中很早的概念,不屬於 V8 特有,更不屬於 JavaScript 特有。

這裏我們講的 AST 都是和 JavaScript 相關的,後文的都屬於狹義 AST

4.1 AST 應用場景

這裏要講的是前端工程編譯過程中的 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 的描述中

ESTree 規範請點擊這裏

4.4 AST 常用工具

在線工具:Esprima,ASTExploerVSCode 擴展工具:Babel AST Exploer

打包編譯工具:babel,babylon(現在已經合併到 babel 中)

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)
}

上面代碼那麼複雜,改如何入手?

幾個關鍵的名詞

幾個關鍵的函數調用

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. 判斷箭頭函數主體 => 後是否有 {}

  1. 利用前三步的變量名、形參、函數主體,生成新的 es5 函數聲明

  2. 將新函數聲明寫進 AST

  3. 利用 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)
}
  1. 產生式 ======

產生式後續獨立推送

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/MQXJCM_RW9gbke5NptBlqA