【轉向JavaScript系列】AST in Modern JavaScript

weixin_33860722發表於2017-12-20

What is AST

什麼是AST?AST是Abstract Syntax Tree(抽象語法樹)的縮寫。
傳說中的程式設計師三大浪漫是編譯原理、圖形學、作業系統,不把AST玩轉,顯得逼格不夠,而本文目標就是為你揭示AST在現代化JavaScript專案中的應用。

var a = 42
function addA(d){
  return a + d;
}

按照語法規則書寫的程式碼,是用來讓開發者可閱讀、可理解的。對編譯器等工具來講,它可以理解的就是抽象語法樹了,在網站javascript-ast裡,可以直觀看到由原始碼生成的圖形化語法樹

2898168-0fc9ecd80bf1d1f7.png

生成抽象語法樹需要經過兩個階段:

  • 分詞(tokenize)
  • 語義分析(parse)

其中,分詞是將原始碼source code分割成語法單元,語義分析是在分詞結果之上分析這些語法單元之間的關係。

以var a = 42這句程式碼為例,簡單理解,可以得到下面分詞結果

[
    {type:'identifier',value:'var'},
    {type:'whitespace',value:' '},    
    {type:'identifier',value:'a'},
    {type:'whitespace',value:' '},
    {type:'operator',value:'='},
    {type:'whitespace',value:' '},
    {type:'num',value:'42'},
    {type:'sep',value:';'}
]

實際使用babylon6解析這一程式碼時,分詞結果為


2898168-0f154592039848bf.png

生成的抽象語法樹為

{
    "type":"Program",
    "body":[
        {
            "type":"VariableDeclaration",
            "kind":"var",
            "declarations":{
                "type":"VariableDeclarator",
                "id":{
                    "type":"Identifier",
                    "value":"a"
                },
                "init":{
                    "type":"Literal",
                    "value":42
                }
            }
        }
    ]
}

社群中有各種AST parser實現

AST in ESLint

ESLint是一個用來檢查和報告JavaScript編寫規範的外掛化工具,通過配置規則來規範程式碼,以no-cond-assign規則為例,啟用這一規則時,程式碼中不允許在條件語句中賦值,這一規則可以避免在條件語句中,錯誤的將判斷寫成賦值

//check ths user's job title
if(user.jobTitle = "manager"){
  user.jobTitle is now incorrect
}

ESLint的檢查基於AST,除了這些內建規則外,ESLint為我們提供了API,使得我們可以利用原始碼生成的AST,開發自定義外掛和自定義規則。

module.exports = {
    rules: {
        "var-length": {
            create: function (context) {
                //規則實現
            }
        }
    }
};

自定義規則外掛的結構如上,在create方法中,我們可以定義我們關注的語法單元型別並且實現相關的規則邏輯,ESLint會在遍歷語法樹時,進入對應的單元型別時,執行我們的檢查邏輯。

比如我們要實現一條規則,要求賦值語句中,變數名長度大於兩位

module.exports = {
    rules: {
        "var-length": {
            create: function (context) {
                return {
                    VariableDeclarator: node => {
                        if (node.id.name.length < 2) {
                            context.report(node, 'Variable names should be longer than 1 character');
                        }
                    }
                };
            }
        }
    }
};

為這一外掛編寫package.json

{
    "name": "eslint-plugin-my-eslist-plugin",
    "version": "0.0.1",
    "main": "index.js",
    "devDependencies": {
        "eslint": "~2.6.0"
    },
    "engines": {
        "node": ">=0.10.0"
    }
}

在專案中使用時,通過npm安裝依賴後,在配置中啟用外掛和對應規則

"plugins": [
    "my-eslint-plugin"
]

"rules": {
    "my-eslint-plugin/var-length": "warn"
}

通過這些配置,便可以使用上述自定義外掛。

有時我們不想要釋出新的外掛,而僅想編寫本地自定義規則,這時我們可以通過自定義規則來實現。自定義規則與外掛結構大致相同,如下是一個自定義規則,禁止在程式碼中使用console的方法呼叫。

const disallowedMethods = ["log", "info", "warn", "error", "dir"];
module.exports = {
    meta: {
        docs: {
            description: "Disallow use of console",
            category: "Best Practices",
            recommended: true
        }
    },
    create(context) {
        return {
            Identifier(node) {
                const isConsoleCall = looksLike(node, {
                    name: "console",
                    parent: {
                        type: "MemberExpression",
                        property: {
                            name: val => disallowedMethods.includes(val)
                        }
                    }
                });
                // find the identifier with name 'console'
                if (!isConsoleCall) {
                    return;
                }

                context.report({
                    node,
                    message: "Using console is not allowed"
                });
            }
        };
    }
};

AST in Babel

Babel是為使用下一代JavaScript語法特性來開發而存在的編譯工具,最初這個專案名為6to5,意為將ES6語法轉換為ES5。發展到現在,Babel已經形成了一個強大的生態。

2898168-c9fad3735c891b22.png

業界大佬的評價:Babel is the new jQuery

2898168-f3b754bf968ade04.png

Babel的工作過程經過三個階段,parse、transform、generate,具體來說,如下圖所示,在parse階段,使用babylon庫將原始碼轉換為AST,在transform階段,利用各種外掛進行程式碼轉換,如圖中的JSX transform將React JSX轉換為plain object,在generator階段,再利用程式碼生成工具,將AST轉換成程式碼。


2898168-ea0c37094d7f3e59.png

Babel為我們提供了API讓我們可以對程式碼進行AST轉換並且進行各種操作

import * as babylon from "babylon";
import traverse from "babel-traverse";
import generate from "babel-generator";

const code = `function square(n) {
    return n * n;
}`

const ast = babylon.parse(code);
traverse(ast,{
    enter(path){
        if(path.node.type === 'Identifier' && path.node.name === 'n'){
            path.node.name = 'x'
        }
    }
})
generate(ast,{},code)

直接使用這些API的場景倒不多,專案中經常用到的,是各種Babel外掛,比如 babel-plugin-transform-remove-console外掛,可以去除程式碼中所有對console的方法呼叫,主要程式碼如下

module.exports = function({ types: t }) {
  return {
    name: "transform-remove-console",
    visitor: {
      CallExpression(path, state) {
        const callee = path.get("callee");

        if (!callee.isMemberExpression()) return;

        if (isIncludedConsole(callee, state.opts.exclude)) {
          // console.log()
          if (path.parentPath.isExpressionStatement()) {
            path.remove();
          } else {
          //var a = console.log()
            path.replaceWith(createVoid0());
          }
        } else if (isIncludedConsoleBind(callee, state.opts.exclude)) {
          // console.log.bind()
          path.replaceWith(createNoop());
        }
      },
      MemberExpression: {
        exit(path, state) {
          if (
            isIncludedConsole(path, state.opts.exclude) &&
            !path.parentPath.isMemberExpression()
          ) {
          //console.log = func
            if (
              path.parentPath.isAssignmentExpression() &&
              path.parentKey === "left"
            ) {
              path.parentPath.get("right").replaceWith(createNoop());
            } else {
            //var a = console.log
              path.replaceWith(createNoop());
            }
          }
        }
      }
    }
  };

使用這一外掛,可以將程式中如下呼叫進行轉換

console.log()
var a = console.log()
console.log.bind()
var b = console.log
console.log = func

//output
var a = void 0
(function(){})
var b = function(){}
console.log = function(){}

上述Babel外掛的工作方式與前述的ESLint自定義外掛/規則類似,工具在遍歷原始碼生成的AST時,根據我們指定的節點型別進行對應的檢查。

在我們開發外掛時,是如何確定程式碼AST樹形結構呢?可以利用AST explorer方便的檢視原始碼生成的對應AST結構。

2898168-a29c1546ce69bc16.png

AST in Codemod

Codemod可以用來幫助你在一個大規模程式碼庫中,自動化修改你的程式碼。
jscodeshift是一個執行codemods的JavaScript工具,主要依賴於recast和ast-types兩個工具庫。recast作為JavaScript parser提供AST介面,ast-types提供型別定義。

利用jscodeshift介面,完成前面類似功能,將程式碼中對console的方法呼叫程式碼刪除

export default (fileInfo,api)=>{
    const j = api.jscodeshift;
    
    const root = j(fileInfo.source);
    
    const callExpressions = root.find(j.CallExpression,{
        callee:{
            type:'MemberExpression',
            object:{
                type:'Identifier',
                name:'console'
            }
        }
    });
    
    callExpressions.remove();
    
    return root.toSource();
}

如果想要程式碼看起來更加簡潔,也可以使用鏈式API呼叫

export default (fileInfo,api)=>{
    const j = api.jscodeshift;

    return j(fileInfo.source)
        .find(j.CallExpression,{
            callee:{
                type:'MemberExpression',
                object:{
                    type:'Identifier',
                    name:'console'
                }
            }
        })
        .remove()
        .toSource();
}

在瞭解了jscodeshift之後,頭腦中立即出現了一個疑問,就是我們為什麼需要jscodeshift呢?利用AST進行程式碼轉換,Babel不是已經完全搞定了嗎?

帶著這個問題進行一番搜尋,發現Babel團隊這處提交說明babel-core: add options for different parser/generator

前文提到,Babel處理流程中包括了parse、transform和generation三個步驟。在生成程式碼的階段,Babel不關心生成程式碼的格式,因為生成的編譯過的程式碼目標不是讓開發者閱讀的,而是生成到釋出目錄供執行的,這個過程一般還會對程式碼進行壓縮處理。

這一次過程在使用Babel命令時也有體現,我們一般使用的命令形式為

babel src -d dist

而在上述場景中,我們的目標是在程式碼庫中,對原始碼進行處理,這份經過處理的程式碼仍需是可讀的,我們仍要在這份程式碼上進行開發,這一過程如果用Babel命令來體現,實際是這樣的過程

babel src -d src

在這樣的過程中,我們會檢查轉換指令碼對原始碼到底做了哪些變更,來確認我們的轉換正確性。這就需要這一個差異結果是可讀的,而直接使用Babel完成上述轉換時,使用git diff輸出差異結果時,這份差異結果是混亂不可讀的。

基於這個需求,Babel團隊現在允許通過配置自定義parser和generator

{
    "plugins":[
        "./plugins.js"
    ],
    "parserOpts":{
        "parser":"recast"
    },
    "generatorOpts":{
        "generator":"recast"
    }
}

假設我們有如下程式碼,我們通過指令碼,將程式碼中import模式進行修改

import fs, {readFile} from 'fs'
import {resolve} from 'path'
import cp from 'child_process'

resolve(__dirname, './thing')

readFile('./thing.js', 'utf8', (err, string) => {
  console.log(string)
})

fs.readFile('./other-thing', 'utf8', (err, string) => {
  const resolve = string => string
  console.log(resolve())
})

cp.execSync('echo "hi"')

//轉換為
import fs from 'fs';
import _path from 'path';
import cp from 'child_process'

_path.resolve(__dirname, './thing')

fs.readFile('./thing.js', 'utf8', (err, string) => {
  console.log(string)
})

fs.readFile('./other-thing', 'utf8', (err, string) => {
  const resolve = string => string
  console.log(resolve())
})

cp.execSync('echo "hi"')

完成這一轉換的plugin.js為

module.exports = function(babel) {
  const { types: t } = babel
  // could just use https://www.npmjs.com/package/is-builtin-module
  const nodeModules = [
    'fs', 'path', 'child_process',
  ]

  return {
    name: 'node-esmodule', // not required
    visitor: {
      ImportDeclaration(path) {
        const specifiers = []
        let defaultSpecifier
        path.get('specifiers').forEach(specifier => {
          if (t.isImportSpecifier(specifier)) {
            specifiers.push(specifier)
          } else {
            defaultSpecifier = specifier
          }
        })
        const {node: {value: source}} = path.get('source')
        if (!specifiers.length || !nodeModules.includes(source)) {
          return
        }
        let memberObjectNameIdentifier
        if (defaultSpecifier) {
          memberObjectNameIdentifier = defaultSpecifier.node.local
        } else {
          memberObjectNameIdentifier = path.scope.generateUidIdentifier(source)
          path.node.specifiers.push(t.importDefaultSpecifier(memberObjectNameIdentifier))
        }
        specifiers.forEach(specifier => {
          const {node: {imported: {name}}} = specifier
          const {referencePaths} = specifier.scope.getBinding(name)
          referencePaths.forEach(refPath => {
            refPath.replaceWith(
              t.memberExpression(memberObjectNameIdentifier, t.identifier(name))
            )
          })
          specifier.remove()
        })
      }
    }
  }
}

刪除和加上parserOpts和generatorOpts設定允許兩次,使用git diff命令輸出結果,可以看出明顯的差異


2898168-5bc2b0f43fce3e4b.png
使用recast

2898168-a91893134b5109aa.png
不使用recast

AST in Webpack

Webpack是一個JavaScript生態的打包工具,其打出bundle結構是一個IIFE(立即執行函式)

(function(module){})([function(){},function(){}]);

Webpack在打包流程中也需要AST的支援,它藉助acorn庫解析原始碼,生成AST,提取模組依賴關係


2898168-7680d33cf134b836.png

在各類打包工具中,由Rollup提出,Webpack目前也提供支援的一個特性是treeshaking。treeshaking可以使得打包輸出結果中,去除沒有引用的模組,有效減少包的體積。

//math.js
export {doMath, sayMath}

const add = (a, b) => a + b
const subtract = (a, b) => a - b
const divide = (a, b) => a / b
const multiply = (a, b) => a * b

function doMath(a, b, operation) {
  switch (operation) {
    case 'add':
      return add(a, b)
    case 'subtract':
      return subtract(a, b)
    case 'divide':
      return divide(a, b)
    case 'multiply':
      return multiply(a, b)
    default:
      throw new Error(`Unsupported operation: ${operation}`)
  }
}

function sayMath() {
  return 'MATH!'
}

//main.js
import {doMath}
doMath(2, 3, 'multiply') // 6

上述程式碼中,math.js輸出doMath,sayMath方法,main.js中僅引用doMath方法,採用Webpack treeshaking特性,再加上uglify的支援,在輸出的bundle檔案中,可以去掉sayMath相關程式碼,輸出的math.js形如

export {doMath}

const add = (a, b) => a + b
const subtract = (a, b) => a - b
const divide = (a, b) => a / b
const multiply = (a, b) => a * b

function doMath(a, b, operation) {
  switch (operation) {
    case 'add':
      return add(a, b)
    case 'subtract':
      return subtract(a, b)
    case 'divide':
      return divide(a, b)
    case 'multiply':
      return multiply(a, b)
    default:
      throw new Error(`Unsupported operation: ${operation}`)
  }
}

進一步分析main.js中的呼叫,doMath(2, 3, 'multiply') 呼叫僅會執行doMath的一個分支,math.js中定義的一些help方法如add,subtract,divide實際是不需要的,理論上,math.js最優可以被減少為

export {doMath}

const multiply = (a, b) => a * b

function doMath(a, b) {
  return multiply(a, b)
}

基於AST,進行更為完善的程式碼覆蓋率分析,應當可以實現上述效果,這裡只是一個想法,沒有具體的實踐。參考Faster JavaScript with SliceJS

參考文章

相關文章