用JS解釋JS!詳解AST及其應用

圖片

一  AST 是什麼?css

1  AST:Abstract Syntax Tree - 抽象語法樹html

當咱們查看目前主流的項目中的 devDependencies,會發現各類各樣的模塊工具。概括一下有:JavaScript轉譯、css預處理器、elint、pretiier 等等。這些模塊咱們不會在生產環境用到,但它們在咱們的開發過程當中充當着重要的角色,而全部的上述工具,都創建在 AST 的基礎上。前端

圖片

2  AST 工做流程node

圖片

  • parse:把代碼解析爲AST。
  • transform:對AST中的各個節點作相關操做,如新增、刪除、替換、追加。業務開發 95%的代碼都在這裏。
  • generator:把AST轉換爲代碼。

3  AST 樹預覽react

圖片

AST 輔助開發工具:https://astexplorer.net/git

二  從一個簡單需求上手github

代碼壓縮的僞需求:將 square 函數參數與引用進行簡化,變量由 num 轉換爲 n:算法

圖片

解法1:使用 replace 暴力轉換express

const sourceText = `function square(num) {
  return num * num;
}`;
sourceText.replace(/num/g, 'n');

以上操做至關的暴力,很容易引發bug,不能投入使用。如若存在字符串 "num",也將被轉換:編程

// 轉換前
function square(num) {
  return num * num;
}
console.log('param 2 result num is ' + square(2));

// 轉換後
function square(n) {
  return n * n;
}
console.log('param 2 result n is ' + square(2));

解法2:使用 babel 進行 AST 操做

module.exports = () => {
  return {
    visitor: {
      // 定義 visitor, 遍歷 Identifier
      Identifier(path) {
        if (path.node.name === 'num') {
          path.node.name = 'n'; // 轉換變量名
        }
      }
    }
  }
};

經過定義 Identifier visitor,對 Identifier(變量) 進行遍歷,若是 Identifier 名稱爲 "num",進行轉換。以上代碼解決了 num 爲字符串時也進行轉換的問題,但還存在潛在問題,如代碼爲以下狀況時,將引起錯誤:

// 轉換前
function square(num) {
  return num * num;
}
console.log('global num is ' + window.num);

// 轉換後
function square(n) {
  return n * n;
}
console.log('global num is ' + window.n); // 出錯了

因爲 window.num 也會被上述的 visitor 迭代器匹配到而進行轉換,轉換後出代碼爲 window.n,進而引起錯誤。分析需求「將 square 函數參數與引用進行簡化,變量由 num 轉換爲 n」,提煉出的3個關鍵詞爲 「square 函數、參數、引用」,對此進一步優化代碼。

解法2升級:找到引用關係

module.exports = () => {
  return {
    visitor: {
      Identifier(path,) {
        // 三個前置判斷
        if (path.node.name !== 'num') { // 變量須要爲 num
          return;
        }
        if (path.parent.type !== 'FunctionDeclaration') { // 父級須要爲函數
          return;
        }
        if (path.parent.id.name !== 'square') { // 函數名須要爲 square
          return;
        }
        const referencePaths = path.scope.bindings['num'].referencePaths; // 找到對應的引用
        referencePaths.forEach(path => path.node.name = 'n'); // 修改引用值
        path.node.name = 'n'; // 修改自身的值
      },
    }
  }
};

上述的代碼,可描述流程爲:

圖片

轉換結果:

// 轉換前
function square(num) {
  return num * num;
}
console.log('global num is ' + window.num);

// 轉換後
function square(n) {
  return n * n;
}
console.log('global num is ' + window.num);

在面向業務的AST操做中,要抽象出「人」的判斷,作出合理的轉換。

三  Babel in AST

圖片

1  API 總覽

// 三劍客
const parser = require('@babel/parser').parse;
const traverse = require('@babel/traverse').default;
const generate = require('@babel/generator').default;

// 配套包
const types = require('@babel/types');

// 模板包
const template = require('@babel/template').default;

2  @babel/parser

經過 babel/parser 將源代碼轉爲 AST,簡單形象。

const ast = parser(rawSource, {
  sourceType: 'module',
  plugins: [
    "jsx",
  ],
});

3  @babel/traverse

AST 開發的核心,95% 以上的代碼量都是經過 @babel/traverse 在寫 visitor。

const ast = parse(`function square(num) {
  return num * num;
}`);

traverse(ast, { // 進行 ast 轉換
    Identifier(path) { // 遍歷變量的visitor
      // ...
    },
    // 其餘的visitor遍歷器
  } 
)

visitor 的第一個參數是 path,path 不直接等於 node(節點),path 的屬性和重要方法組成以下:

圖片

4  @babel/generator

經過 @babel/generator 將操做過的 AST 生成對應源代碼,簡單形象。

const output = generate(ast, { /* options */ });

5  @babel/types

@babel/types 用於建立 ast 節點,判斷 ast 節點,在實際的開發中會常常用到。

// is開頭的用於判斷節點
types.isObjectProperty(node);
types.isObjectMethod(node);

// 建立 null 節點
const nullNode = types.nullLiteral();
// 建立 square 變量節點
const squareNode = types.identifier('square');

6  @babel/template

@bable/types 能夠建立 ast 節點,但過於繁瑣,經過 @babel/template 則能夠快速建立整段的 ast 節點。下面對比了得到 import React from 'react'  ast 節點的兩種方式:

// @babel/types
// 建立節點須要查找對應的 API,傳參須要匹配方法
const types = require('@babel/types');
const ast = types.importDeclaration(
  [ types.importDefaultSpecifier(types.identifier('React')) ], 
  types.stringLiteral('react')
);

// path.replaceWith(ast) // 節點替換
// 使用 @babel/template
// 建立節點輸入源代碼便可,清晰易懂
const template = require('@babel/template').default;
const ast = template.ast(`import React from 'react'`);

// path.replaceWith(ast) // 節點替換

7  定義通用的 babel plugin

定義通用的 babel plugin,將有利於被 Webpack 集成,示例以下:

// 定義插件
const { declare } = require('@babel/helper-plugin-utils');

module.exports = declare((api, options) => {
  return {
    name: 'your-plugin', // 定義插件名
    visitor: { // 編寫業務 visitor
      Identifier(path,) {
        // ...
      },
    }
  }
});
// 配置 babel.config.js
module.exports = {
    presets: [
        require('@babel/preset-env'), // 可配合通用的 present
    ],
    plugins: [
        require('your-plugin'),
        // require('./your-plugin') 也能夠爲相對目錄
    ]
};

在 babel plugin 開發中,能夠說就是在寫 ast transform callback,不須要直接接觸「@babel/parser、@babel/traverse、@babel/generator」等模塊,這在 babel 內部調用了。

在須要用到 @babel/types 能力時,建議直接使用 @babel/core,從源碼[1]能夠看出,@babel/core 直接透出了上述 babel 模塊。

const core = require('@babel/core');
const types = core.types; // const types = require('@babel/types');

四  ESLint in AST

在掌握了 AST 核心原理後,自定義 ESlint 規則也變的容易了,直接上代碼:

// eslint-plugin-my-eslint-plugin
module.exports.rules = { 
  "var-length": context => ({ // 定義 var-length 規則,對變量長度進行檢測
    VariableDeclarator: (node) => { 
      if (node.id.name.length <= 1){ 
        context.report(node, '變量名長度須要大於1');
      }
    }
  })
};
// .eslintrc.js
module.exports = {
  root: true,
  parserOptions: { ecmaVersion: 6 },
  plugins: [
   "my-eslint-plugin"
  ],
  rules: {
    "my-eslint-plugin/var-length": "warn" 
  }
};

體驗效果

IDE 正確提示:

圖片

執行 eslint 命令的 warning:

圖片

查閱更多 ESLint API 可查看官方文檔[2]。

五  得到你所須要的 JSX 解釋權

第一次接觸到 JSX 語法大可能是在學習 React 的時候,React 將 JSX 的能力發揚光大[3]。但 JSX 不等於 React,也不是由 React 創造的。

// 使用 react 編寫的源碼
const name = 'John';
const element = <div>Hello, {name}</div>;
// 經過 @babel/preset-react 轉換後的代碼
const name = 'John';
const element = React.createElement("div", null, "Hello, ", name);

JSX 做爲標籤語法既不是字符串也不是 HTML,是一個 JavaScript 的語法擴展,能夠很好地描述 UI 應該呈現出它應有交互的本質形式。JSX 會令人聯想到模版語言,它也具備 JavaScript 的所有功能。下面咱們本身寫一個 babel plugin,來得到所須要對 JSX 的解釋權。

1  JSX Babel Plugin

咱們知道,HTML是描述 Web 頁面的語言,axml 或 vxml 是描述小程序頁面的語言,不一樣的容器二者並不兼容。但相同點是,他們都基於 JavaScript 技術棧,那麼是否能夠經過定義一套 JSX 規範來生成出同樣的頁面表現?

2  目標

export default (
  <view>
    hello <text style={{ fontWeight: 'bold' }}>world</text>
  </view>
);
<!-- 輸出 Web HTML -->
<div>
  hello <span style="font-weight: bold;">world</span>
</div>
<!--輸出小程序 axml -->
<view>
  hello <text style="font-weight: bold;">world</text>
</view>

目前的疑惑在於:AST 僅可用做 JavaScript 的轉換,那 HTML 和 axml 等文本標記語言改怎麼轉換呢?不妨轉換一種思路:將上述的 JSX 代碼轉化爲 JS 的代碼,在 Web 端和小程序端提供組件消費便可。這是 AST 開發的一個設計思想,AST 工具僅作代碼的編譯,具體的消費由下層操做,@babel/preset-react 與 react 就是這個模式。

// jsx 源碼
module.exports = function () {
  return (
    <view
      visible
      onTap={e => console.log('clicked')}
    >ABC<button>login</button></view>
  );
};

// 目標:轉後爲更通用的 JavaScript 代碼
module.exports = function () {
  return {
    "type": "view",
    "visible": true,
    "children": [
      "ABC",
      {
        "type": "button",
        "children": [
          "login1"
        ]
      }
    ]
  };
};

明確了目標後,咱們要作的事爲:

1. 將 jsx 標籤轉爲 Object,標籤名爲 type 屬性,如 <view /> 轉化爲 { type: 'view' }

2. 標籤上的屬性平移到 Object 的屬性上,如 <view onTap={e => {}} /> 轉換爲 { type: 'view', onTap: e => {} }

3. 將 jsx 內的子元素,移植到 children 屬性上,children 屬性爲數組,如 { type: 'view', style, children: [...] }

4. 面對子元素,重複前面3步的工做。

下面是實現的示例代碼:

const { declare } = require('@babel/helper-plugin-utils');
const jsx = require('@babel/plugin-syntax-jsx').default;
const core = require('@babel/core');
const t = core.types;

/*
  遍歷 JSX 標籤,約定 node 爲 JSXElement,如
  node = <view onTap={e => console.log('clicked')} visible>ABC<button>login</button></view>
*/
const handleJSXElement = (node) => {
  const tag = node.openingElement;
  const type = tag.name.name; // 得到表情名爲 View
  const propertyes = []; // 儲存對象的屬性
  propertyes.push( // 得到屬性 type = 'ABC'
    t.objectProperty(
      t.identifier('type'),
      t.stringLiteral(type)
    )
  );
  const attributes = tag.attributes || []; // 標籤上的屬性
  attributes.forEach(jsxAttr => { // 遍歷標籤上的屬性
    switch (jsxAttr.type) {
      case 'JSXAttribute': { // 處理 JSX 屬性
        const key = t.identifier(jsxAttr.name.name); // 獲得屬性 onTap、visible
        const convertAttributeValue = (node) => {
          if (t.isJSXExpressionContainer(node)) { // 屬性的值爲表達式(如函數)
            return node.expression; // 返回表達式
          }
          // 空值轉化爲 true, 如將 <view visible /> 轉化爲 { type: 'view', visible: true }
          if (node === null) {
            return t.booleanLiteral(true);
          }
          return node;
        }
        const value = convertAttributeValue(jsxAttr.value);
        propertyes.push( // 得到 { type: 'view', onTap: e => console.log('clicked'), visible: true }
          t.objectProperty(key, value)
        );
        break;
      }
    }
  });
  const children = node.children.map((e) => {
    switch(e.type) {
      case 'JSXElement': {
        return handleJSXElement(e); // 若是子元素有 JSX,便利 handleJSXElement 自身
      }
      case 'JSXText': {
        return t.stringLiteral(e.value); // 將字符串轉化爲字符
      }
    }
    return e;
  });
  propertyes.push( // 將 JSX 內的子元素轉化爲對象的 children 屬性
    t.objectProperty(t.identifier('children'), t.arrayExpression(children))
  );
  const objectNode = t.objectExpression(propertyes); // 轉化爲 Object Node
  /* 最終轉化爲
  {
    "type": "view",
    "visible": true,
    "children": [
      "ABC",
      {
        "type": "button",
        "children": [
          "login"
        ]
      }
    ]
  }
  */
  return objectNode;
}

module.exports = declare((api, options) => {
  return {
    inherits: jsx, // 繼承 Babel 提供的 jsx 解析基礎
    visitor: {
      JSXElement(path) { // 遍歷 JSX 標籤,如:<view />
        // 將 JSX 標籤轉化爲 Object
        path.replaceWith(handleJSXElement(path.node));
      },
    }
  }
});

六  總結

咱們介紹了什麼是 AST、AST 的工做模式,也體驗了利用 AST 所達成的驚豔能力。如今來想一想 AST 更多的業務場景是什麼?當用戶:

  • 須要基於你的基礎設施進行二次編程開發的時候
  • 有可視化編程操做的時候
  • 有代碼規範定製的時候

AST 將是你強有力的武器。

注:本文演示的代碼片斷與測試方法在 https://github.com/chvin/learn\_ast,有興趣的讀者可前往學習體驗。

招聘

筆者任職於阿里雲-人工智能實驗室-應用研發部。我部門目前已累積了近 20w 的開發者和企業用戶,爲數億的設備提供移動服務。目前團隊急招大前端(前端、iOS、Android等)、Java開發、數據算法等各方向的工程師。方向是移動 Devops 平臺、移動中間件、Serverless、低代碼平臺、小程序雲、雲渲染應用平臺、新零售/教育產業數字化轉型等,有意詳聊:changwen.tcw@alibaba-inc.com

參考資料

[1]https://github.com/babel/babe...

[2]https://cn.eslint.org/docs/de...

[3]https://reactjs.bootcss.com/d...


**
技術公開課**

《React 入門與實戰》

React是一個用於構建用戶界面的JavaScript庫。本課程共54課時,帶你全面深刻學習React的基礎知識,並經過案例掌握相關應用。

點擊「閱讀原文」開始學習吧~

相關文章
相關標籤/搜索