譯:手把手教你如何寫自定義babel代碼轉換

原文鏈接:https://lihautan.com/step-by-step-guide-for-writing-a-babel-transformation/

譯:手把手教你如何寫自定義babel代碼轉換

今天,我將分享如何一步步寫一個自定義babel轉換工具。你可以利用這項技術來自動化代碼的修改,重構以及生成。

什麼是babel?

Babel 是一個Javascript 編譯器,它主要被用於將ECMA Script 2015以上的代碼轉換成目前或者更老版本瀏覽器或者環境可以兼容的版本。Babel的代碼變換啓用了插件系統,這個插件系統可以讓任何人基於babel寫自己的代碼轉換插件。

在你開始書寫babel轉換插件之前,你還需要了解什麼是抽象語法樹(AST)。

什麼是抽象語法樹(AST)?

我不太確信自己能比以下文章解釋得更好:
articles out there on the web:
* Leveling Up One’s Parsing Game With ASTs by Vaidehi Joshi * (強烈推薦! 👍)
* Wikipedia 的 Abstract syntax tree
* What is an Abstract Syntax Tree by Chidume Nnamdi
總的來說,AST是描述代碼的樹。在Javascript中,Javascript AST遵循了estree標準

AST代表了你的代碼,你的代碼結構和意義。因此它可以讓babel這類編譯器理解代碼,並對它做一些有意義的變換。

現在,你理解了什麼是AST。讓我們一起開始用AST來寫一個更改你帶嘛的自定義babel變換工具吧!

如何利用babal轉換代碼

以下展示了一個用babel做代碼轉換的通用樣本:

import { parse } from '@babel/parser';
import traverse from '@babel/traverse';
import generate from '@babel/generator';

const code = 'const n = 1';

// 將源代碼轉換爲AST
const ast = parse(code);

// 轉換AST
traverse(ast, {
  enter(path) {
    // in this example change all the variable `n` to `x`
    if (path.isIdentifier({ name: 'n' })) {
      path.node.name = 'x';
    }
  },
});

// 生成代碼 <- ast
const output = generate(ast, code);
console.log(output.code); // 'const x = 1;'

這段代碼需要安裝@babe/core才能運行。@babel/parser@babel/traverse@babel/generator都是@babel/core的依賴項,因此只需安裝@babel/core就可以了。

總的來說就是將你的代碼轉成AST,再轉換AST,然後從轉換過後的AST生成代碼。

源代碼 -> AST -> 轉換過的AST -> 轉換過的代碼

然而我們也可以用另一個babel提供的接口一步完成以上過程:

import babel from '@babel/core';

const code = 'const n = 1';

const output = babel.transformSync(code, {
  plugins: [
    // 你的第一個插件 😎😎
    function myCustomPlugin() {
      return {
        visitor: {
          Identifier(path) {
            // 在這個例子裏我們將所有變量 `n` 變爲 `x`
            if (path.isIdentifier({ name: 'n' })) {
              path.node.name = 'x';
            }
          },
        },
      };
    },
  ],
});

console.log(output.code); // 'const x = 1;'

現在,你完成了第一個將所有n的變量轉換成x的babel插件,酷不酷?

將myCustomPlugin抽取出來放到一個新文件中,並export。然後將這個文件打包發佈位一個npm包,你就可以驕傲地說你發佈了一個babel插件!🎉🎉

到這裏,你也許會想:“對,我剛寫了一個babel插件,但我完全不造它怎麼回事……“。不要擔心,我們一起來看看你是如何爲自己寫babel轉換插件的。以下是詳細步驟:

1. 想好你要把什麼轉換爲什麼

在示例裏,我想創建一個babel插件對同事惡作劇,這個插件會:

  • 反轉所有變量和方法名稱
  • 把字符串分割爲多個字符
function greet(name) {
  return 'Hello ' + name;
}

console.log(greet('tanhauhau')); // Hello tanhauhau

變成

function greet(name) {
  return 'Hello ' + name;
}

console.log(greet('tanhauhau')); // Hello tanhauhau

這裏我們保留了console.log,這樣即使代碼不太可讀,但它仍舊可以正常工作。(我可不想破壞線上代碼!)

2. 瞭解你的AST目標

打開看看babel AST explorer,點擊不同部分的代碼,再看看右側AST中在什麼位置,和怎樣呈現了這段代碼:
選擇左側的代碼就可以看到右側對應AST高亮顯示
假如這是你第一次看到AST,多玩一會,感受它大概的樣子,並瞭解AST上跟你的代碼相對應的節點的名字。

現在我們明白目標是什麼了:

  • 變量和方法名字的標識
  • 字符串的StringLiteral

3. 瞭解轉換過後的AST長啥樣

再看下babel AST explorer,但這次我們看你想要最終生成的代碼。可以看到原來的部分現在變成了有嵌套的

思考並嘗試下如何將之前的AST轉換成現在的AST吧。

比如說,你可以看到’H’ + ‘e’ + ‘l’ + ‘l’ + ‘o’ + ‘ ‘ + name是由BinaryExpression嵌套了StringLiteral的形式出現。

4. 寫代碼

現在我們再來看看代碼:

function myCustomPlugin() {
  return {
    visitor: {
      Identifier(path) {
        // ...
      },
    },
  };
}

這裏的轉換使用了訪問者模式

在遍歷階段,babel會先進行深度優先遍歷來訪問AST的每一個節點。你可以爲訪問指定一個回調函數,然後每當訪問某個節點的時候,babel會調用這個函數,並給函數傳入當前訪問的節點。

在visitor對象裏,你可以爲回調指定特定名字的節點:

function myCustomPlugin() {
  return {
    visitor: {
      Identifier(path) {
        console.log('identifier');
      },
      StringLiteral(path) {
        console.log('string literal');
      },
    },
  };
}

運行它,你會看到”string literal”和”identifier“在babel每次遇到它們的時候被調用:

identifier
identifier
string literal
identifier
identifier
identifier
identifier
string literal

在我們繼續之前,我們可以看到Identifer(path){}的參數叫path而不是nodepathnode有什麼區別呢?

在babel裏,path是基於node的一層抽象,它提供了node之間的聯繫,即父級節點,並提供了領域(scope)上下文(context)等信息。此外,path還提供了replaceWithinsertBefore之類用於更新AST節點的函數。

你可以在 Jamie Kylebabel handbook 中獲得關於path的更多詳細信息。

好了,讓我們繼續寫我們的babel插件。

轉換變量名

我們可以從AST explorer中看到,Identifier的名字被存儲在name中。因此,我們需要做的便是反轉這個name

Identifier(path) {
  path.node.name = path.node.name
    .split('')
    .reverse()
    .join('');
}

運行它你就能看到:

Identifier(path) {
  path.node.name = path.node.name
    .split('')
    .reverse()
    .join('');
}

我們就快完成了,除了一點,我們不小心把console.log也給反轉了。怎樣可以避免它呢?

我們再看看AST:

console.logMemberExpression的一部分,擁有一個叫"console"object,和叫"log"property

那讓我們在當前節點是MemberExpression中的Identifier時跳過反轉這步:

Identifier(path) {
  if (
    !(
      path.parentPath.isMemberExpression() &&
      path.parentPath
        .get('object')
        .isIdentifier({ name: 'console' }) &&
      path.parentPath.get('property').isIdentifier({ name: 'log' })
    )
  ) {
   path.node.name = path.node.name
     .split('')
     .reverse()
     .join('');
 }
}

現在就對啦!

function teerg(eman) {
  return 'Hello ' + name;
}

console.log(teerg('tanhauhau')); // Hello tanhauhau

那爲什麼我們需要看Identifier的父級節點是不是console.logMemberExpression呢?爲啥我們不直接比較Identifier.name === ‘console’ || Identifier.name === ‘log‘呢?

你當然可以這麼做,不過它就不會反轉叫consolelog的變量名了:

const log = 1;

那我怎麼知道isMemberExpressionisIdentifier的呢?其實所有在@babel/types中聲明的節點類型都擁有一個對應的isXxxx驗證函數。如:anyTypeAnnotation函數會有一個isAnyTypeAnnotation驗證函數。如果你想知道驗證函數的完整列表,你可以查看源代碼

轉換字符串

下一步是在StringLiteral外嵌套一個BinaryExpression

你可以使用@babel/types提供的一個工具函數來創建一個AST節點。@babel/types也可以從@babel/core中的babel.types獲取。

StringLiteral(path) {
  const newNode = path.node.value
    .split('')
    .map(c => babel.types.stringLiteral(c))
    .reduce((prev, curr) => {
      return babel.types.binaryExpression('+', prev, curr);
    });
  path.replaceWith(newNode);
}

我們把在path.node.value中的StringLiteral的內容分割開,然後把每個字符變成一個StringLiteral加上BinaryExpression。最後,我們把原來的StringLiteral替換成了新建的節點。

這就完成啦!除了……碰到如下棧溢出的問題😅:

RangeError: Maximum call stack size exceeded

爲什麼呢🤷?

那是因爲對於每遇到一個StringLiteral我們都創建更多StringLiteral,並且在每個StringLiteral中,我們都在“創建”更多StringLiteral。雖然我們是將StringLiteral替換爲另一個StringLiteral,但babel會因爲將它當作一個新的節點去訪問這個StringLiteral,因此產生了死循環和棧溢出。

那我們怎麼告訴babel一旦已經把StringLiteral替換爲節點,就不要再深入繼續訪問新建的節點了呢?

這裏我們可以用path.skip()來跳過對當前路徑子節點的訪問:

StringLiteral(path) {
  const newNode = path.node.value
    .split('')
    .map(c => babel.types.stringLiteral(c))
    .reduce((prev, curr) => {
      return babel.types.binaryExpression('+', prev, curr);
    });
  path.replaceWith(newNode);
  path.skip();
}

現在它總算工作不再棧溢出了!

小結

到這兒,我們就有了第一個babel轉換插件:

const babel = require('@babel/core');
const code = `
function greet(name) {
  return 'Hello ' + name;
}
console.log(greet('tanhauhau')); // Hello tanhauhau
`;
const output = babel.transformSync(code, {
  plugins: [
    function myCustomPlugin() {
      return {
        visitor: {
          StringLiteral(path) {
            const concat = path.node.value
              .split('')
              .map(c => babel.types.stringLiteral(c))
              .reduce((prev, curr) => {
                return babel.types.binaryExpression('+', prev, curr);
              });
            path.replaceWith(concat);
            path.skip();
          },
          Identifier(path) {
            if (
              !(
                path.parentPath.isMemberExpression() &&
                path.parentPath
                  .get('object')
                  .isIdentifier({ name: 'console' }) &&
                path.parentPath.get('property').isIdentifier({ name: 'log' })
              )
            ) {
              path.node.name = path.node.name
                .split('')
                .reverse()
                .join('');
            }
          },
        },
      };
    },
  ],
});
console.log(output.code);

總結一下我們做過的步驟:

  1. 想清楚你要把什麼轉換成什麼
  2. 瞭解AST上你的目標
  3. 瞭解轉換的AST長啥樣
  4. 寫代碼

更多資源

如果你感興趣,想學習更多的話,babel的Github倉庫永遠是可以讓你找到更多babel轉換代碼樣例的最好的地方。

進入https://github.com/babel/babel ,找到babel-plugin-transform-*或是babel-plugin-proposal-*文件夾,它們是所有babel提供的轉換插件,你可以從這裏找到babel如何轉換可爲空操作符 , 可選鏈 等等。

參考

* 	 [Babel docs](https://babeljs.io/docs/en/)  &  [Github repo](https://github.com/babel/babel) 
* 	 [Babel Handbook](https://github.com/jamiebuilds/babel-handbook)  by  [Jamie Kyle](https://jamie.build/) 
* 	 [Leveling Up One’s Parsing Game With ASTs](https://medium.com/basecs/leveling-up-ones-parsing-game-with-asts-d7a6fc2400ff)  by  [Vaidehi Joshi](https://twitter.com/vaidehijoshi) 

翻譯自朋友的博文:https://lihautan.com/step-by-step-guide-for-writing-a-babel-transformation/

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章