在這篇文章里我想要通過一些小例子來介紹使用jscodeshift來進行自動化重構的技術。具體來說,我想要介紹在一個組件庫的開發和維護過程中,如何使用jscodeshift來自動修改公開的API接口,從而盡可能小的產生對組件用戶的影響。
如果你們團隊開發的組件被其消費者(組織內部或者外部)使用了,而這些代碼又不在你的控制之內,那么這里討論的技術和模式可能對你很有幫助。而如果你的日常工作更多的是使用組件庫來開發應用程序,我希望這里的知識和技巧仍然對你有所啟發,畢竟在軟件系統中,我們往往都既是某些庫的消費者,又同時是另外一些庫的生產者。
從一個簡單場景出發
設想這樣一個場景,你發布了一個酷炫的組件庫(fancylib),其中有一個按鈕(Button)組件。這個Button的一個屬性是當點擊后處于加載中(loading)狀態時現實一個表示加載中的小圖標。
(圖片來源:https://xd.adobe.com/ideas/process/ui-design/designing-interactive-buttons-states/)
在代碼實現中,這個加載中狀態被定義為了名為isInLoadingStatus公開prop。用戶可以通過設置其值來控制Button的狀態:
import Button from '@fancylib/button';
const app = () => (Click me
)
一個實習生在某一天code review的時候提出了一個問題:在組件庫中的其他地方,所有的boolean狀態都是用一個單詞來表示的,比如checked, disabled等。如果按照這個慣例,這里應該把isInLoadingStatus簡化為loading。好主意!
import Button from '@fancylib/button';
const app = () => (Click me
)
假如所有用到Button的地方都在你的控制之內,字符串替換大約是一個快速且80%有效的方案。不過稍微分析一下,你就會發現簡單的Shift+F6會遇到很多問題。
復雜情況
比如用戶對其做了二次包裝以適配更符合自己用戶的使用習慣,這使得簡單的全局字符串替換變成了不可能::
import Button as FancyButton from '@fancylib/button';
const MyEvenFancierButton = (props: FancyButtonProps) => (
const theme = {
backgroundColor: "orangered",
color: "white"
};
Click me
);
除了這些問題之外,由于這是一個非常受歡迎的組件庫,Button在很多(包括內部和外部的)產品中都有使用,你沒有辦法訪問所有的用戶代碼,更沒有辦法讓所有人都用手工的查找替換來做更新,你需要另尋出路。
你需要一個工具 -- 一個可以讀懂代碼意圖的工具 -- 來幫助你做修改,而且整個過程最好可以自動化,比如通過執行一個腳本來完成。
使用jscodeshift
jscodeshift就是這樣一個工具(工具集)。簡單來說,jscodeshift的工作方式就是將源代碼分析成一棵樹(抽象語法樹),然后提供API來修改這棵樹,最后再把樹生成為代碼。
?
也就是說,她可以讀懂你的代碼,并提供指令(API)來根據你的意愿修改相應的代碼。
實現
接下來,我們可以通過實現一個可以完成上述場景的自動重構的腳本來對jscodeshift的使用做一個簡單介紹。簡單來說,jscodeshift的工作流程是:首先你需要定義一個轉換腳本(transform),這個腳本需要符合一定的規范以便jscodeshift調用;然后jscodeshift的命令行工具會啟動runner,并將轉換腳本應用到某個文件或者某個文件夾中的所有文件中:jscodeshift -t myTransform src
定義一個transform
也就是說,我們所有的邏輯都會定義在轉換腳本中。transform腳本需要導出一個固定格式的函數:
import { Transform } from "jscodeshift";
const transform: Transform = (file, api, options) => {
//...
};
export default transform;
file為解析后的文件對象,api是jscodeshift的API對象,可以通過它來查找,修改文件對象,options是一個可選的,用來傳遞其他參數(比如格式化最終輸出格式等)的對象。在函數體中,我們可以使用jscodeshift提供的API來操縱抽象語法樹(Abstract Syntax Tree)來實現對代碼的修改。這個過程和通過DOM API來操作瀏覽器中的頁面元素非常類似:按照屬性查找元素,對查找結果進行增刪改等操作,只不過這里的操作對象是語法樹(比如變量定義,函數體,條件語句等等)。
在詳細討論如何使用jscodeshift的API來修改代碼之前,我們來略微看一下抽象語法樹的概念。這將是我們腳本需要操作的主要對象。
抽象語法樹AST
抽象語法樹,是編譯器將源碼解析(parse)之后形成的一課樹形結構。簡單來說,我們的代碼被解析成為Token,Token再根據語法規則形成子樹,子樹最終根據文法歸并成一顆樹。我們可以通過AST Explorer工具來實時查看代碼對應的語法樹。
舉個例子,我們的代碼片段:
import Button from '@fancylib/button';
const app = () => (Click me
)
經過解析(jscodeshift默認使用babel來解析,你可以選擇其他的解析器)之后,會形成右側的一顆樹,比如isInLoadingStatus被識別成JSXIdentifier類型,而變量app定義則被識別為VariableDeclarator等。所有符合語法的元素都會被抽取成Token,并體現為樹上的一個節點。
?
有了這些基本概念之后,我們就可以開始編寫一個簡單的transform了。這里我們可以通過AST Explorer提供的在線IDE中的Transform功能來實時調試(此處選擇jscodeshift作為轉換器)。
然后我們定義這樣一個轉換函數:
// Press ctrl+space for code completion
export default function transformer(file, api) {
const j = api.jscodeshift;
return j(file.source)
.find(j.JSXIdentifier)
.forEach(path => {
if(path.node.name === "isInLoadingStatus") {
j(path).replaceWith(
j.identifier('loading')
)
}
})
.toSource();
}
比如上述代碼中,我們查找所有的j.JSXIdentifier,并迭代每一個找到的節點,如果它的值是isInLoadingStatus的話,就將其替換為loading。可以觀察到右下側的調試器窗口中的轉換結果:
?
測試驅動開發
當然了,作為一個嚴肅的程序員,我們不應該通過一個在線IDE來進行開發。幸運的是jscodeshift可以和jest完美配合,同時我發現編寫自動化腳本是一個非常適合測試驅動開發的場景:
- 輸入輸出都非常明確
- 各種不同的邊界場景很容易想象/編寫成用例
- 每一個步驟都可以劃分的比較小
jscodeshift提供了一個小工具defineInlineTest,通過它你可以很方便的定義測試用例:
import { defineInlineTest } from 'jscodeshift/dist/testUtils';
import transformer from './transformer';
describe('transformer', () => {
defineInlineTest(
{ default: transformer, parser: 'tsx' },
{},
`
import Button from '@fancylib/button';
export default () => (Click me
);
`,
`
import Button from '@fancylib/button';
export default () => (Click me
);
`,
'change isInLoadingStatus to loading'
);
});
當然,如果你不習慣字符串模板的話,它同時還提供了基于文件形式的測試定義,這樣你可以將測試的輸入(轉化前)和輸出(轉化后)外置到文件中,并在其中構建較為復雜的使用場景。
比如我們希望這個transform不要誤傷我們代碼中使用的其他Button,比如我們使用了另外一個組件庫,而巧合的是那個庫中Button也有一個isInLoadingStatus。
那么對應的測試用例會是:
defineInlineTest(
{ default: transformer, parser: 'tsx' },
{},
`
import Button from '@facebook/button';
export default () => (Click me
);
`,
`
import Button from '@facebook/button';
export default () => (Click me
);
`,
'should not change isInLoadingStatus to loading from other package'
);
對應的我們需要在代碼中加入相應的邏輯:
// Press ctrl+space for code completion
export default function transformer(file, api) {
const j = api.jscodeshift;
const root = j(file.source);
const specifiers = root
.find(j.ImportDeclaration)
.filter((path) => path.node.source.value === "@fancylib/button")
.find(j.ImportDefaultSpecifier);
if (specifiers.length === 0) {
return;
}
//...
}
即,我們先查找所有的import語句,如果沒有找到從@fancylib/button導入的Button就跳過后續的操作。你應該已經注意到了,我們這里又很多的諸如j.ImportDeclaration和j.ImportDefaultSpecifier之類的Token定義,你可以從AST Explorer的樹結構中找到類似的名稱,然后用jscodeshift的API來查找并訪問改節點。
這個過程或多或少有點像我們通過DOM的API來選擇HTML節點一樣:
document.querySelectorAll('a')
.filter(anchor => anchor.classList.includes('button'))
.forEach(anchor => anchor.style["text-decoration"] = "underline")
如果你覺得這里要素太多,這是很正常的。嘗試著多寫幾個就會發現規律。
如果把所有的實現細節都列舉在一篇文章中,我覺得文章會非常枯燥(可能寫成一個系列教程等),因此這里我不再貼代碼,相關的源碼可以在這里找到。
可能的陷阱
使用腳本來自動化重構的想法當然非常有誘惑了,特別是對于疲于為已經公布的API打補丁的人們來說,簡直太過于美好。不過公平起見,我還是得略微說一些它的一些drawbacks。
首先,jscodeshift 的API略顯晦澀,有一定的學習成本。開發過程中可能會有很多調試的工作。其次,它并不定覆蓋100%的使用場景,比如對于復雜的spreading操作,需要調試和分析的工作量不容小覷,也就是說你仍然需要人工校對一些edge cases。最后,需要一些腳本來支持組件的消費團隊使用,比如自動化補丁工具等,如果有多個transform,如何一次patch等問題。
小結
在這篇文章中,我們從一個簡化了的實際例子出發,描述了為何jscodeshift在某些場景下可以提供的幫助,比如降低大型修改可能帶來的影響(而如果影響不可避免,那么如何使其變得不那么痛苦)。隨后我們描述了jscodeshift中的一些基本概念和基本的工作方式,并結合之前討論的例子實現了部分的自動化重構。
?
評論
查看更多