在寫此次精讀以前,我想談談前端精讀能夠爲讀者帶來哪些價值,以及如何評判這些價值。javascript
前端精讀已經寫到第 123 篇了,你們已經沒必要擔憂它忽然中止更新,由於我已養成每週寫一篇文章的習慣,而讀者也養成了每週看一篇的習慣。因此我想說的實際上是一種更有生命力的自媒體運做方式,按期更新。一個按期更新的專欄比一個不不按期更新的專欄更有活力,也更受讀者喜好,由於讀者能看到文章之間的聯繫,跟隨做者一塊兒成長。我的學習也是如此,養成按期學習的習慣,比在培訓班突擊幾個月更有用,學會在生活中規律的學習,甚至好過讀幾年名牌大學。前端
前端精讀想帶給讀者的不只是一篇篇具體的內容和知識,知識是無窮無盡的,幾萬篇文章也說不完,但前端精讀一直沿用了「引言-概述-精讀-總結」這套學習模式,不管是前端任何領域的問題,仍是對人生和世界的思考均可以套用,但願能爲讀者提供一套學習思惟框架,讓你能學習到如何找到好的文章,以及如何解讀它。vue
至今已經選擇了許多源碼解讀的題材,與培訓思惟的源碼解讀不一樣,我但願你不要帶着面試的目的學習源碼,由於這樣會讓你只侷限在 react、vue 這種熱門的框架上。前端精讀選取的框架類型之因此普遍,是但願你能靜下心來,吸收不一樣框架風格與做者的優點,培養一種優雅編碼的氣質。java
進入正題,此次選擇的文章 《用 Babel 創造自定義 JS 語法》 也是培養編碼氣質的一類文章,雖然對你實際工做用處不大,但這篇文章能夠培養幾個程序員求之不得的能力:深刻理解 Babel、深刻理解框架拓展機制。理解一個複雜系統或培養框架思惟不是一朝一夕的,但持續閱讀這種文章可讓你愈來愈接近掌握它。node
之因此選擇 Babel,是由於 Babel 處理的一直是語法樹相關的底層邏輯,編譯原理是程序世界的基座之一,擁有很大的學習價值。因此咱們的目的並非像文章標題說的 - 創造一個自定義 JS 語法,由於你創造的語法只會讓 JS 複雜體系更加混亂,但可讓你理解 Babel 解析標準 JS 語法的原理,以及看待新語法提案時,擁有從實現層面思考的能力。react
最後,沒必要多說,能重溫 Babel 經典的插件機制,你能夠發現 Babel 的插件拓展機制和 Antrl4 很像,在設計業務模塊拓展方案時也能夠做爲參考。git
咱們要利用 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
實現方式分爲兩步:面試
不要畏懼這些步驟,「若是你讀完了這篇文章,你將成爲同事眼中的 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 編譯器 - 語法分析》,或者閱讀原文。
咱們再次執行測試函數,發現測試經過了,一切都在預料中。
如今咱們獲得了標記了 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
。
最後原文末尾留下了一些延伸閱讀內容,感興趣的同窗能夠 點擊到原文。
讀完這篇文章,相信你不只對 babel 插件有了更深入的認識,並且還掌握瞭如何爲 js 添加新語法這種黑魔法。
我來幫你從 babel 這篇文章總結一些編程模型和知識點,藉助 babel 創造自定義語法的實例,加深對它們的理解。
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 樹常採用的方案是作一個遍歷器 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 將編譯過程隱藏了起來,經過一些高度封裝的函數調用,以較爲語義化方式書寫插件,這樣寫出來的代碼也容易理解。
《用 Babel 創造自定義 JS 語法》這篇文章雖說的是 babel 相關知識,但能夠從中提取到許多通用知識,這就是如今還去理解 babel 的緣由。
從某個功能點爲切面,走一遍框架的完整流程是一種高效的進階學習方式,若是你也有看到相似這樣的文章,歡迎推薦出來。
若是你想參與討論,請 點擊這裏,每週都有新的主題,週末或週一發佈。前端精讀 - 幫你篩選靠譜的內容。
關注 前端精讀微信公衆號
版權聲明:自由轉載-非商用-非衍生-保持署名(創意共享 3.0 許可證)