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