精讀《用 Babel 創造自定義 JS 語法》

1 引言

在寫此次精讀以前,我想談談前端精讀能夠爲讀者帶來哪些價值,以及如何評判這些價值。javascript

前端精讀已經寫到第 123 篇了,你們已經沒必要擔憂它忽然中止更新,由於我已養成每週寫一篇文章的習慣,而讀者也養成了每週看一篇的習慣。因此我想說的實際上是一種更有生命力的自媒體運做方式,按期更新。一個按期更新的專欄比一個不不按期更新的專欄更有活力,也更受讀者喜好,由於讀者能看到文章之間的聯繫,跟隨做者一塊兒成長。我的學習也是如此,養成按期學習的習慣,比在培訓班突擊幾個月更有用,學會在生活中規律的學習,甚至好過讀幾年名牌大學。前端

前端精讀想帶給讀者的不只是一篇篇具體的內容和知識,知識是無窮無盡的,幾萬篇文章也說不完,但前端精讀一直沿用了「引言-概述-精讀-總結」這套學習模式,不管是前端任何領域的問題,仍是對人生和世界的思考均可以套用,但願能爲讀者提供一套學習思惟框架,讓你能學習到如何找到好的文章,以及如何解讀它。vue

至今已經選擇了許多源碼解讀的題材,與培訓思惟的源碼解讀不一樣,我但願你不要帶着面試的目的學習源碼,由於這樣會讓你只侷限在 react、vue 這種熱門的框架上。前端精讀選取的框架類型之因此普遍,是但願你能靜下心來,吸收不一樣框架風格與做者的優點,培養一種優雅編碼的氣質。java

進入正題,此次選擇的文章 《用 Babel 創造自定義 JS 語法》 也是培養編碼氣質的一類文章,雖然對你實際工做用處不大,但這篇文章能夠培養幾個程序員求之不得的能力:深刻理解 Babel、深刻理解框架拓展機制。理解一個複雜系統或培養框架思惟不是一朝一夕的,但持續閱讀這種文章可讓你愈來愈接近掌握它。node

之因此選擇 Babel,是由於 Babel 處理的一直是語法樹相關的底層邏輯,編譯原理是程序世界的基座之一,擁有很大的學習價值。因此咱們的目的並非像文章標題說的 - 創造一個自定義 JS 語法,由於你創造的語法只會讓 JS 複雜體系更加混亂,但可讓你理解 Babel 解析標準 JS 語法的原理,以及看待新語法提案時,擁有從實現層面思考的能力。react

最後,沒必要多說,能重溫 Babel 經典的插件機制,你能夠發現 Babel 的插件拓展機制和 Antrl4 很像,在設計業務模塊拓展方案時也能夠做爲參考。git

2 概述

咱們要利用 Babel 實現 function @@ 的新語法,用 @@ 裝飾的函數會自動柯里化:程序員

// '@@' makes the function `foo` curried
function @@ foo(a, b, c) {
  return a + b + c;
}
console.log(foo(1, 2)(3)); // 6
複製代碼

能夠看到,function @@ foo 描述的函數 foo 支持 foo(1, 2)(3) 這種柯里化調用。github

實現方式分爲兩步:面試

  1. Fork babel 源碼。
  2. 建立一個 babel 轉換器插件。

不要畏懼這些步驟,「若是你讀完了這篇文章,你將成爲同事眼中的 Babel 大神」 - 原文。

首先 Fork babel 源碼到本地,執行下面的命令能夠初始化並編譯 babel:

$ make bootstrap
$ make build
複製代碼

babel 使用 Makefile 執行編譯命令,而且採用 monorepo 管理,咱們此次要關心的是 package/babel-parser 這個模塊。

詞法

首先要了解詞法知識,更詳細的能夠閱讀原文或精讀以前的一篇系列文章:精讀《詞法分析》

要解析語法,首先要進行詞法分析。任何語法輸入都是一個字符串,好比 function @@ foo(a, b, c),詞法分析就是要將這個長度爲 24 的字符拆分爲一個個有語義的單詞片斷:function @@ foo ( a ..

因爲 @@ 是咱們創造的語法,因此咱們第一個任務就是讓 babel 詞法分析能夠識別它。

下面是 package/babel-parser 的文件結構:

- src/
  - tokenizer/
  - parser/
  - plugins/
    - jsx/
    - typescript/
    - flow/
    - ...
- test/
複製代碼

能夠看到,分爲詞法分析 tokenizer,語法分析 parser,以及支持一些特殊語法的插件,以及測試用例 test

推薦使用 Test-driven development (TDD) - 測試驅動開發的方式,就是先寫測試用例,再根據測試用例開發。這種開發方式在後端或者 babel 這種底層框架很常見,由於 TDD 方式開發的邏輯能保證測試用例 100% 覆蓋,同時先看測試用例也是個很好的切面編程思惟。

// packages/babel-parser/test/curry-function.js

import { parse } from '../lib';

function getParser(code) {
  return () => parse(code, { sourceType: 'module' });
}

describe('curry function syntax', function() {
  it('should parse', function() {
    expect(getParser(`function @@ foo() {}`)()).toMatchSnapshot();
  });
});
複製代碼

能夠利用 jest 直接測試這段代碼:

BABEL_ENV=test node_modules/.bin/jest -u packages/babel-parser/test/c
複製代碼

結果會出現以下報錯:

SyntaxError: Unexpected token (1:9)

at Parser.raise (packages/babel-parser/src/parser/location.js:39:63)
at Parser.raise [as unexpected] (packages/babel-parser/src/parser/util.js:133:16)
at Parser.unexpected [as parseIdentifierName] (packages/babel-parser/src/parser/expression.js:2090:18)
at Parser.parseIdentifierName [as parseIdentifier] (packages/babel-parser/src/parser/expression.js:2052:23)
at Parser.parseIdentifier (packages/babel-pars
複製代碼

第 9 個字符就是 @,說明程序如今還不支持函數前面的 @ 解析。咱們還能夠在錯誤堆棧中找到報錯位置,並把當前 Token 與下一個 Token 打印出來:

// packages/babel-parser/src/parser/expression.js

parseIdentifierName(pos: number, liberal?: boolean): string {
  if (this.match(tt.name)) {
    // ...
  } else {
    console.log(this.state.type); // current token
    console.log(this.lookahead().type); // next token
    throw this.unexpected();
  }
}
複製代碼

this.state.type 表明當前 Token,this.lookahead().type 表示下一個 Token。lookahead 是詞法分析的專有詞,表示向後查看。打印以後,咱們會發現輸出了兩個 @ Token:

TokenType {
  label: '@',
  // ...
}
複製代碼

下一步,咱們須要讓 babel 詞法分析識別 @@ 這個 Token。首先須要註冊這個 Token:

// packages/babel-parser/src/tokenizer/types.js

export const types: { [name: string]: TokenType } = {
  // ...
  at: new TokenType('@'),
  atat: new TokenType('@@'),
};
複製代碼

註冊了以後,咱們要在遍歷 Token 時增長判斷 「若是當前字符是 @ 且下一個字符也是 @,則總體構成了 @@ Token 而且光標向後移動兩格」:

// packages/babel-parser/src/tokenizer/index.js

getTokenFromCode(code: number): void {
  switch (code) {
    // ...
    case charCodes.atSign:
      // if the next character is a `@`
      if (this.input.charCodeAt(this.state.pos + 1) === charCodes.atSign) {
        // create `tt.atat` instead
        this.finishOp(tt.atat, 2);
      } else {
        this.finishOp(tt.at, 1);
      }
      return;
    // ...
  }
}
複製代碼

再次運行測試文件,輸出變成了:

// current token
TokenType {
  label: '@@',
  // ...
}

// next token
TokenType {
  label: 'name',
  // ...
}
複製代碼

到這一步,已經能正確解析 @@ Token 了。

語法

詞法已經能夠將 @@ 解析爲 atat Token,下一步咱們就要利用這個 Token,讓生成的 AST 結構中包含柯里化函數的信息,並利用 babel 插件在解析時實現柯里化功能。

首先咱們能夠在 Babel AST explorer 看到 AST 解析的結構,咱們拿 generator 函數測試,由於這個函數結構與柯里化函數相似:

能夠看到,babel 經過 generator async 屬性來標識函數是否爲 generator 或者 async 函數。同理,增長一個 curry 屬性就能夠實現第一步了:

要實現如上效果,只需在詞法分析 parser/statement 文件的 parseFunction 處新增 atat 解析便可:

// packages/babel-parser/src/parser/statement.js

export default class StatementParser extends ExpressionParser {
  // ...
  parseFunction<T: N.NormalFunction>(
    node: T,
    statement?: number = FUNC_NO_FLAGS,
    isAsync?: boolean = false
  ): T {
    // ...
    node.generator = this.eat(tt.star);
    node.curry = this.eat(tt.atat);
  }
}
複製代碼

eat 是吃掉的意思,實際上能夠理解爲吞掉這個 Token,這樣作有兩個效果:1. 爲函數添加了 curry 屬性 2. 吞掉了 @@ 標識,保證全部 Token 都被識別是 AST 解析正確的必要條件。

關於遞歸降低語法分析的更多知識,能夠參考 精讀《手寫 SQL 編譯器 - 語法分析》,或者閱讀原文。

咱們再次執行測試函數,發現測試經過了,一切都在預料中。

babel 插件

如今咱們獲得了標記了 curry 的 AST,那麼最後須要一個 babel 解析插件,實現柯里化。

首先咱們經過修改 babel 源碼的方式實現的效果,是能夠轉化爲自定義 babel parser 插件的:

// babel-plugin-transformation-curry-function.js

import customParser from './custom-parser';

export default function ourBabelPlugin() {
  return {
    parserOverride(code, opts) {
      return customParser.parse(code, opts);
    },
  };
}
複製代碼

這樣就能夠實現修改 babel 源碼同樣的效果,這也是作框架經常使用的插件機制。

其次咱們要理解如何實現柯里化。柯里化能夠經過柯里函數包裝後實現:

function currying(fn) {
  const numParamsRequired = fn.length;
  function curryFactory(params) {
    return function (...args) {
      const newParams = params.concat(args);
      if (newParams.length >= numParamsRequired) {
        return fn(...newParams);
      }
      return curryFactory(newParams);
    }
  }
  return curryFactory([]);
}

// from
function @@ foo(a, b, c) {
  return a + b + c;
}

// to
const foo = currying(function foo(a, b, c) {
  return a + b + c;
})
複製代碼

柯里化函數經過構造參數數量相關的遞歸,當參數傳入不足時返回一個新函數,並持久化以前傳入的參數,最後當參數齊全後一次性調用函數。

咱們須要作的是,將 @@ foo 解析爲 currying() 函數包裹後的新函數。

下面就是咱們熟悉的 babel 插件部分了:

// babel-plugin-transformation-curry-function.js

export default function ourBabelPlugin() {
  return {
    // ...
    visitor: {
      FunctionDeclaration(path) {
        if (path.get('curry').node) {
          // const foo = curry(function () { ... });
          path.node.curry = false;
          path.replaceWith(
            t.variableDeclaration('const', [
              t.variableDeclarator(
                t.identifier(path.get('id.name').node),
                t.callExpression(t.identifier('currying'), [
                  t.toExpression(path.node),
                ])
              ),
            ])
          );
        }
      },
    },
  };
}
複製代碼

FunctionDeclaration 就是 AST 的 visit 鉤子,這個鉤子在執行到函數時被觸發,咱們經過 path.get('curry') 拿到 柯里化函數,並利用 replaceWith 將這個函數構造爲一個被 currying 函數包裹的新函數。

剩下最後一個問題:currying 函數源碼放在哪裏。

第一種方式,建立相似 babel-plugin-transformation-curry-function 這樣的插件,在 babel 解析時將 currying 函數註冊到全局,這是全局思惟的方案。

第二種是模塊化解決方案,建立一個自定義的 @babel/helpers,註冊一個 currying 標識:

// packages/babel-helpers/src/helpers.js
helpers.currying = helper("7.6.0")` export default function currying(fn) { const numParamsRequired = fn.length; function curryFactory(params) { return function (...args) { const newParams = params.concat(args); if (newParams.length >= numParamsRequired) { return fn(...newParams); } return curryFactory(newParams); } } return curryFactory([]); } `;
複製代碼

在 visit 函數使用 addHelper 方式拿到 currying

path.replaceWith(
  t.variableDeclaration('const', [
    t.variableDeclarator(
      t.identifier(path.get('id.name').node),
      t.callExpression(this.addHelper("currying"), [
        t.toExpression(path.node),
      ])
    ),
  ])
);
複製代碼

這樣在 babel 轉換後,就會自動 import helper,並引用 helper 中導出的 currying

最後原文末尾留下了一些延伸閱讀內容,感興趣的同窗能夠 點擊到原文

3 精讀

讀完這篇文章,相信你不只對 babel 插件有了更深入的認識,並且還掌握瞭如何爲 js 添加新語法這種黑魔法。

我來幫你從 babel 這篇文章總結一些編程模型和知識點,藉助 babel 創造自定義語法的實例,加深對它們的理解。

TDD

Test-driven development 即測試驅動的開發模式。

從文章的例子能夠看出,創造一個新語法,能夠先在測試用例先寫上這個語法,經過執行測試命令經過報錯堆棧一步步解決問題。這種方式開發可讓測試覆蓋率更高,目的更專一,更容易保障代碼質量。

聯想編程

聯想編程不屬於任何編程模型,但從簡介的思路來看,做者把 「爲 babel 建立一個新 js 語法」 看做一種探案式探索過程,經過錯誤堆棧和代碼閱讀,一步一步經過合理聯想實現最終目的。

在 AST 那一節,還藉助了 Babel AST explorer 工具查看 AST 結構,經過聯想到 generator 函數找到相似的 AST 結構,並找到拓展 AST 的突破口。

隨着解決問題的不一樣,聯想方式也不一樣,若是可以觸類旁通,對不一樣場景都能合理的聯想,纔算是具有了技術專家的軟素質。

詞法、語法分析

詞法、語法分析屬於編譯原理的知識,理解詞法拆分、遞歸降低,能夠幫助你技術走的更深。

不管是 Babel 插件的使用、仍是 Babel 增長自定義 JS 語法,都要具有基本編譯原理知識。編譯原理知識還能幫助你開發在線編輯器,作智能語法提示等等。

插件機制

以下是 babel 自定義 parser 的插件拓展方式:

export default function ourBabelPlugin() {
  return {
    parserOverride(code, opts) {
      return customParser.parse(code, opts);
    },
  };
}
複製代碼

這只是插件拓展的一種,有申明式,也有命令式;有用 JS 書寫的,也有用 JSON 書寫的。babel 選擇了經過對象方式拓展,是比較適合對 AST 結構統一處理的。

作框架首先要肯定接口規範,好比 parser,先按照接口規範實現一套官方解析,對接時按照接口進行對接,就能夠天然而然被用戶自定義插件替代了。

能夠參考的文章: 精讀《插件化思惟》

柯里化

柯里化是面試常常考察的一個知識點,咱們能學到的有兩點:理解遞歸、理解如何將函數變成柯里化。

這裏再拓展一下,咱們還能夠想到 JS 尾遞歸優化。如何快速寫一個支持尾遞歸的函數?

const fn = tailCallOptimize(() => {
  if ( /* xxx */ ) {
    fn()
  }
})
複製代碼

經過封裝 tailCallOptimize 函數,能夠很方便的構造一個支持尾遞歸的函數,這個函數能夠這麼寫:

export function tailCallOptimize<T>(f: T): T {
  let value: any;
  let active = false;
  const accumulated: any[] = [];
  return function accumulator(this: any) {
    accumulated.push(arguments);
    if (!active) {
      active = true;
      while (accumulated.length) {
        value = (f as any).apply(this, accumulated.shift());
      }
      active = false;
      return value;
    }
  };
}
複製代碼

感興趣的讀者能夠在評論裏解釋一下這個函數的原理。

AST visit

遍歷 AST 樹常採用的方案是作一個遍歷器 visitor,因此在遍歷過程當中進行拓展常採用 babel 這種方式:

return {
  // ...
  visitor: {
    FunctionDeclaration(path) {
      if (path.get('curry').node) {
        // const foo = curry(function () { ... });
        path.node.curry = false;
        path.replaceWith(
          t.variableDeclaration('const', [
            t.variableDeclarator(
              t.identifier(path.get('id.name').node),
              t.callExpression(t.identifier('currying'), [
                t.toExpression(path.node),
              ])
            ),
          ])
        );
      }
    },
  },
};
複製代碼

visitor 下每個 key 名都是遍歷過程當中的拓展點,好比上面的例子,咱們能夠對函數定義位置進行拓展和改寫。

內置函數註冊

babel 提供了兩種內置函數註冊方式,一種相似 polyfill,在全局註冊 window 級的變量,另外一種是模塊化的方式。

除此以外,能夠學習的是 babel 經過 this.addHelper("currying") 這種插件拓展方式,在編譯後會自動從 helper 引入對應的模塊,前提是 @babel/helper 須要註冊 currying 這個 helper。

babel 將編譯過程隱藏了起來,經過一些高度封裝的函數調用,以較爲語義化方式書寫插件,這樣寫出來的代碼也容易理解。

4 總結

《用 Babel 創造自定義 JS 語法》這篇文章雖說的是 babel 相關知識,但能夠從中提取到許多通用知識,這就是如今還去理解 babel 的緣由。

從某個功能點爲切面,走一遍框架的完整流程是一種高效的進階學習方式,若是你也有看到相似這樣的文章,歡迎推薦出來。

討論地址是:精讀《用 Babel 創造自定義 JS 語法》 · Issue #210 · dt-fe/weekly

若是你想參與討論,請 點擊這裏,每週都有新的主題,週末或週一發佈。前端精讀 - 幫你篩選靠譜的內容。

關注 前端精讀微信公衆號

版權聲明:自由轉載-非商用-非衍生-保持署名(創意共享 3.0 許可證

相關文章
相關標籤/搜索