代碼自動化重構利器——jscodeshift 初探
背景
開發(fā)維護規(guī)模較大的前端項目,難免時不時需要進行一些代碼重構工作。舉一個簡單的 ??:
有一個 npm 包 an-npm-package-containing-constants,用于維護項目埋點時使用的字符串常量,其代碼主要內容如下:
export const ConstantsForTrack = {
Track1: 'track_in_scene_1',
Track2: 'track_in_scene_2',
...
};
調用方以如下方式使用此包:
import { ConstantsForTrack } from 'an-npm-package-containing-constants';
track(ConstantsForTrack.Track1, ...otherParams);
隨著業(yè)務迭代,此包體積不斷增長,已經開始影響到項目的整體性能。所有業(yè)務的埋點標識字符串都集中于此包中,因此我們改變代碼的導出方式:
export const Track1 = 'track_in_scene_1';
export const Track2 = 'track_in_scene_2';
...
各業(yè)務通過按需引入,控制代碼體積:
import { Track1 } from 'an-npm-package-containing-constants/es/constants';
track(Track1, ...otherParams);
包內代碼的重構不算特別復雜,但是要對項目內各處引入該包以及埋點調用的方式進行修改,還是一件比較頭疼的事情。IDE 的全局替換功能顯然無法勝任這一工作;人工替換違背了程序員的 DRY[1] 原則,過程枯燥且有可能出錯;寫一個基于正則的替換腳本倒不是不可行,只是這樣的腳本一般可維護性和可復用性都不太好,并且可能存在一些 bad case(如可能會匹配到注釋或字符串字面量中的內容、需要考慮不同代碼風格的情況)。
此時筆者想起了之前聽說到的一個叫作 codemod 的概念,據說是一種對代碼進行批量修改操作的方法;并且 react[2]、vue[3] 還有 Ant Design[4] 都提供了自己的官方 codemod,以幫助用戶遷移到更新版本,避免其面對升級新版本后手動處理接口變更的痛苦。這次不妨嘗嘗鮮,也算是為今后這種類似的重構工作先踩踩坑。
相關概念簡介
Codemod[5]
Codemod is a tool/library to assist you with large-scale codebase refactors that can be partially automated but still require human oversight and occasional intervention.
Codemod 是一個誕生于 Facebook 內部的概念,可以理解為 "code modification" 的縮寫。如官方介紹所述,codemod 針對的場景是規(guī)模較大的代碼庫中的重構工作。當某個在代碼中被頻繁使用的接口發(fā)生了無法向前兼容的重大變化,codemod 提供了快速且可靠的、半自動的工具來對代碼庫中所有相關代碼進行重構,以幫助開發(fā)者對代碼進行快速迭代。
jscodeshift[6]
jscodeshift 是一個基于 codemod 理念的 JavaScript/TypeScript 重構工具,其原理是將 JS/TS 代碼解析為抽象語法樹(Abstract Syntax Tree,AST),并提供一系列用于訪問和修改 AST 的 API 以實現自動化的代碼重構。jscodeshift 將 babel parser、ast-types[7](用于快速創(chuàng)建新的 AST 節(jié)點)和 recast[8](維護生成代碼的代碼風格信息)三大工具整合在一起,提供了簡便快捷的操作接口;同時它還提供了多任務并行執(zhí)行的功能,使其對于海量代碼文件的重構操作可以并行運行,充分利用多核 CPU 算力,縮短重構任務執(zhí)行時間。
抽象語法樹
相信大家都在編譯原理的課程中了解過抽象語法樹的概念,這里先引用一段維基百科上的描述:
在計算機科學中,抽象語法樹(Abstract Syntax Tree,AST),或簡稱語法樹(Syntax tree),是源代碼語法結構的一種抽象表示。它以樹狀的形式表現編程語言的語法結構,樹上的每個節(jié)點都表示源代碼中的一種結構。之所以說語法是“抽象”的,是因為這里的語法并不會表示出真實語法中出現的每個細節(jié)。
我們再以一段簡單代碼為例:
if (1 + 1 == 3) {
alert('time to wake up!');
}
來更加具體地了解一下抽象語法樹是什么樣子:(由 http://nhiro.org/learn_language/AST-Visualization-on-browser.html 生成)

在 JavaScript 工程領域中,不僅僅只有 JavaScript 引擎解析代碼會涉及到 AST,在代碼轉譯(babel)、靜態(tài)分析(eslint)、打包構建(webpack、rollup...)中,都會將代碼解析為 AST 以作進一步的操作。
動手開干,寫一個 codemod
jscodeshift 雖然很好很強大,但是官方文檔提供的信息對于幫助我們快速寫出一個 codemod 來說卻相對有限。因此了解掌握 jscodeshift 最好的辦法就是 get hands dirty,參考一些已有的 codemod 腳本和網上的文章,自己寫一個 codemod。我們以解決背景一節(jié)提及的這個問題為例,寫一個 codemod 來完成 an-npm-package-containing-constants 引入和使用方式的修改。
在動手之前,先介紹一個超強的 AST 可視化工具—— AST Explorer[9]。如下圖所示,我們把想要修改的代碼粘貼在左側,即可即時在右側看到解析代碼獲得的語法樹并查看其中各個節(jié)點的屬性。通過這個工具,我們可以對 AST 有一個更加直觀的認識;在編寫 codemod 的過程中,我們也可以通過這個工具快速定位需要修改的節(jié)點。打開 "Transform" 開關,你還可以直接在瀏覽器里書寫 codemod 腳本,并即時看到 codemod 轉換后的效果。

通過對代碼及其 AST 的大致分析,我們可以把替換工作拆解為以下幾步:
遍歷文件,篩選出引入了 "an-npm-package-containing-constants" 的代碼文件; 查找篩選出的文件中所有對 ConstantsForTrack對象成員的訪問,并將ConstantsForTrack成員訪問表達式直接替換為常量名的表達式;收集代碼中使用的常量名,生成新的 import 語句并替換舊語句。
現在我們可以開始編寫 codemod 了!根據官方文檔[10],codemod 代碼需導出一個函數,jscodeshift 在運行此函數時會通過參數注入 API 和一些相關信息。由此我們可以寫出一個 codemod 的初步的框架:
module.exports = function (
fileInfo, // 當前處理文件的相關信息,包括文件路徑與內容
api, // jscodeshift 提供的接口
options // 通過 jscodeshift CLI 傳入的參數
) {
const { source, path } = fileInfo;
const { jscodeshift: j } = api;
const root = j(source); // 解析代碼獲得 AST
// 在這里編寫操作 AST 的代碼
return root.toSource({ quote: 'single' }); // 將 AST 轉換為代碼字符串后返回
}
需要注意的是,代碼最后的 toSource() 函數可以傳入一些代碼風格相關的配置。recast 在解析代碼時,會將代碼風格相關信息維護在語法樹中,在 toSource() 過程中再將代碼還原成原本的樣子。而在代碼轉換為語法樹后新插入的節(jié)點并沒有這些具體的代碼風格信息。具體如何配置這些代碼的風格可以參考這個文件[11]里的接口定義。
第一步:篩選需要修改的文件
由于我們只需關注引入了 an-npm-package-containing-constants 的代碼文件,所以我們先在 AST Explorer 里觀察解析 import { ConstantsForTrack } from 'an-npm-package-containing-constants'; 語句獲得的 AST 子樹,可以發(fā)現這棵子樹的根結點類型是 "ImportDeclaration",節(jié)點中 source 屬性的 value 字段就是我們所尋找的節(jié)點特征 "an-npm-package-containing-constants"。

接下來就是編寫代碼將這類節(jié)點篩選出來。鑒于 AST 的數據結構特征,一般的語法解析器 (parser) 以及 recast 提供的 API 都基于訪問者模式來對 AST 進行遍歷 (traversal)。jscodeshift 基于 Collection 的概念對 API 進行了進一步封裝[12],令使用者對 AST 節(jié)點的篩選及修改操作變得更加簡單。一些常用的 Collection API 我們會在接下來的實踐過程中用到,完整的 API 列表可以參閱以下源碼:
集合基本操作:https://github.com/facebook/jscodeshift/blob/master/src/Collection.js AST 節(jié)點訪問與修改:https://github.com/facebook/jscodeshift/blob/master/src/collections/Node.js
這里我們使用 find() 從語法樹中獲得目標節(jié)點的集合,若集合為空,則跳過此文件。代碼如下:
const trackConstantsImportDeclarations = root.find(j.ImportDeclaration, {
source: { value: 'an-npm-package-containing-constants' }
});
if (!trackConstantsImportDeclarations.length) {
// 返回 undefined 表示此文件無需修改
return;
}
第二步:收集代碼中所有相關常量的訪問并進行替換
我們再來分析一下 ConstantsForTrack.Track2:

可以看到這個表達式被解析成了一個 MemberExpression 類型的節(jié)點,該節(jié)點的 object 屬性是一個 name 為 "ConstantsForTrack" 的 Identifier 節(jié)點,property 是一個 name 為 "Track2" 的 Identifier 節(jié)點。
我們要把所有 ConstantForTrack.[constant name] 替換為 [constant name],只需使用 jscodeshift 提供的 replaceWith() 接口,把相應的 MemberExpression 節(jié)點替換為其 property 屬性中的 Identifier 節(jié)點即可。另外為了下一步將這些常量引入進來,我們需要把這些 Identifier 的 name 屬性收集起來。代碼如下:
let usedKeys = [];
const trackConstantsMemberExpressions = root.find(j.MemberExpression, {
object: { name: 'ConstantsForTrack' }
});
trackConstantsMemberExpressions.replaceWith((nodePath) => {
// replaceWith 在遍歷集合的回調函數中傳入的參數類型是 NodePath
// NodePath 除了節(jié)點自身的信息外還包含節(jié)點的上下文信息,因此需要先把節(jié)點從中取出來
const { node } = nodePath;
const keyId = node.property;
if (keyId.name) {
usedKeys.push(keyId.name);
return keyId;
}
});
if (!usedKeys.length) {
return;
}
第三步:替換 import 語句
這一步的目標是將 import { ConstantsForTrack } from 'an-npm-package-containing-constants'; 替換為 import { Track2 } from 'an-npm-package-containing-constants/es/constants';。我們先分析一下后者的語法樹:

可見 ImportDeclaration 的 specifiers 屬性記錄了 import 語句所引入的內容,以 ImportSpecifier 數組的形式表示。現在我們可以重新構造一個新的 ImportDeclaration 節(jié)點,代碼如下:
usedKeys = [...new Set(usedKeys)];
const keyIds = usedKeys.map((key) => j.importSpecifier(j.identifier(key)));
const trackConstantsEsImportDeclaration = j.importDeclaration(
keyIds,
j.literal("an-npm-package-containing-constants/es/constants")
);
// 替換原來的 import 語句
trackConstantsImportDeclarations.at(0).replaceWith(
() => trackConstantsEsImportDeclaration
);
jscodeshift 基于 ast-types 提供了各種節(jié)點的構建接口[13],接口形式如以上代碼所示,以駝峰命名法(小寫字母開頭)形式表示,與第一步中用于篩選的節(jié)點類型(帕斯卡命名法表示,大寫字母開頭)區(qū)分開來。不同節(jié)點構建方法的具體參數可以參閱源代碼[14],AST Explorer 中也提供了相關代碼提示。
至此我們的 codemod 腳本已經完成,可以嘗試執(zhí)行一下:(在運行之前,你需要保證已經通過 npm install -g jscodeshift 全局安裝上了 jscodeshift)

自動重構順利完成!這里介紹幾個常用的 CLI 參數,更加具體的信息可以參閱這里[15]:
-c, --cpus=N 最多開啟 N 個子進程并行運行 codemod 腳本
(默認為 max(CPU 總核心數 - 1, 1))
-d, --(no-)dry 測試運行,不對文件作實際修改(默認關閉)
--extensions=EXT 需要處理的文件擴展名(多個用“,”隔開,默認為 js)
--parser=babel|babylon|flow|ts|tsx
解析文件使用的 parser,默認為 babel
-t, --transform=FILE codemod 腳本的路徑或 URL,默認為 "./transform.js"
-v, --verbose=0|1|2 展示 codemod 執(zhí)行過程中的相關信息
總結
文章的最后,總結一下使用 jscodeshift 常用的 API 以及相關的參考文檔和源碼:
AST 的查找與篩選: find()、filter()Collection 訪問: get()、at()(兩者區(qū)別在于前者返回NodePath,后者返回Collection)節(jié)點的插入與修改: replaceWith()、insertBefore()、insertAfter()
Collection 常用 API: https://github.com/facebook/jscodeshift/blob/master/src/Collection.js https://github.com/facebook/jscodeshift/blob/master/src/collections/Node.js
AST 節(jié)點構建參數:https://github.com/benjamn/ast-types/tree/master/def
jscodeshift CLI 參數:https://github.com/facebook/jscodeshift#usage-cli
希望本文可以為大家的代碼重構工作提供一些啟發(fā),幫助大家從一些重復的代碼修改工作中解放出來。
來源:字節(jié)前端 Byte FE
