抖音前端團(tuán)隊(duì)的自動(dòng)發(fā)包方案是如何做到的?
作者簡(jiǎn)介: zoomdong,目前在抖音前端技術(shù)團(tuán)隊(duì)參與 Monorepo 相關(guān)的工程化基建工作,熱愛(ài)編程,熱愛(ài)開(kāi)源,是?
pnpm、Ant Design等多個(gè)知名開(kāi)源項(xiàng)目的貢獻(xiàn)者。

Changesets 是一個(gè)用于 Monorepo 項(xiàng)目下版本以及 Changelog 文件管理的工具。目前一些比較火的 Monorepo 倉(cāng)庫(kù)都在使用該工具進(jìn)行項(xiàng)目的發(fā)包例如 pnpm、mobx 等。github 倉(cāng)庫(kù)為: https://github.com/atlassian/changesets,在 github 上有大約 2k 的 star。
目前筆者組自研的 Monorepo 發(fā)包方案基于該方案進(jìn)行二次開(kāi)發(fā),替代 lerna 成為工程化團(tuán)隊(duì)內(nèi)部統(tǒng)一的發(fā)包方案,并在其它團(tuán)隊(duì)也取得了不錯(cuò)的落地效果,其中包括之前關(guān)于 pnpm 的個(gè)人文章中提到的 Tiktok FE 團(tuán)隊(duì),另外也包括目前字節(jié)開(kāi)源出來(lái)的 modern.js 倉(cāng)庫(kù):

在這篇文章中,將會(huì)介紹 changesets 工具是如何來(lái)完成 Monorepo 倉(cāng)庫(kù)中項(xiàng)目包版本的管理、一些基本的命令使用以及原理、同時(shí)還會(huì)介紹一些缺陷以及目前可以優(yōu)化的一些點(diǎn)。
Lerna 發(fā)包方案缺陷
在之前的文章中,有介紹過(guò)基于 lerna 發(fā)包方案的源碼解析。同時(shí)筆者組自研的 Monorepo 工具中,早期版本中也是采用了 lerna 這一套的發(fā)包方案,但隨著在用戶中的推廣以及使用,這套方案隨之帶來(lái)了不少問(wèn)題:
ignoreChanges不能做到文件的完全忽略,存在優(yōu)先級(jí)問(wèn)題lerna version根據(jù) commit 以及 tag 更新出來(lái)的包版本不符合預(yù)期生成的 CHANGELOG 文件信息不完整 lifecycle scripts經(jīng)常命中一些用戶自定義的 script(例如publish等)CI 中自動(dòng)化發(fā)包場(chǎng)景需要很高的定制成本 lerna 本身不支持 workspace 協(xié)議,導(dǎo)致基于 pnpm 開(kāi)發(fā)的一些倉(cāng)庫(kù)無(wú)法使用
基于以上這些缺點(diǎn),包括 lerna 本身的使用成本以及冗余的代碼設(shè)計(jì),加上目前 lerna 本身停止維護(hù),因此在調(diào)研之后,我們將自研 Monorepo 工具中發(fā)包方案逐步替換為了 changesets。
Changesets 工作流介紹
在前面我們講過(guò)了 changesets 的作用,changesets 主要關(guān)心 monorepo 項(xiàng)目下子項(xiàng)目版本的更新、changelog 文件生成、包的發(fā)布。一個(gè) changeset 是個(gè)包含了在某個(gè)分支或者 commit 上改動(dòng)信息的 md 文件,它會(huì)包含這樣一些信息:
需要發(fā)布的包 包版本的更新層級(jí)(遵循 semver 規(guī)范) CHANGELOG 信息
Changesets 工作流會(huì)將開(kāi)發(fā)者分為兩類角色,一類是項(xiàng)目的維護(hù)者,還有一類為項(xiàng)目的開(kāi)發(fā)者,兩者的職責(zé)可以通過(guò)如下流程圖很簡(jiǎn)潔的表示出來(lái):

根據(jù)上圖, changesets 的工作流程是這樣:開(kāi)發(fā)者在 Monorepo 項(xiàng)目下進(jìn)行開(kāi)發(fā),開(kāi)發(fā)完成后,給對(duì)應(yīng)的子項(xiàng)目添加一個(gè) changesets 文件。項(xiàng)目的維護(hù)者后面會(huì)通過(guò) changesets 來(lái)消耗掉這些文件并自動(dòng)修改掉對(duì)應(yīng)包的版本以及生成 CHANGELOG 文件,最后將對(duì)應(yīng)的包發(fā)布出去。
以上就是一個(gè)簡(jiǎn)單的 changesets 工作流,當(dāng)然這些工作流會(huì)對(duì)應(yīng)到具體的 cli 命令以及 config 配置中去,下面我會(huì)基于此工作流介紹一些關(guān)于 changesets 最常用的幾個(gè)子命令以及使用原理。
子命令及工作原理
如果要使用 changesets,需要先安裝其 CLI 工具,通過(guò) pnpm install @changeset/cli 安裝就行。安裝之后,就可以按照下面的一些命令開(kāi)始使用了。
init
該命令為初始化命令,通過(guò)執(zhí)行 changeset init,可以在項(xiàng)目根目錄下生成一個(gè) .changeset 目錄,里面會(huì)生成一個(gè) changeset 的 config 文件,可以參考 pnpm 目前項(xiàng)目的根目錄:

該命令原理相對(duì)簡(jiǎn)單,執(zhí)行的時(shí)候通過(guò) fs 將對(duì)應(yīng)配置文件寫(xiě)到目錄下就行,關(guān)于 config 中的具體配置描述可以參考官方文檔。init 初始化出來(lái)的為默認(rèn)配置,一般不需要用戶去做過(guò)多的修改。
add
add 在 changesets 中算得上比較關(guān)鍵的命令之一了,它會(huì)根據(jù) monorepo 下的項(xiàng)目來(lái)生成一個(gè) changeset 文件,里面會(huì)包含前面提到的 changeset 文件信息(更新包名稱、版本層級(jí)、CHANGELOG 信息)。
還是以 pnpm 該項(xiàng)目作為例子,例如在 pnpm 倉(cāng)庫(kù)下執(zhí)行 changeset add 會(huì)出現(xiàn)一系列 Prompt 問(wèn)題:

會(huì)讓我們選擇本次 changeset 需要發(fā)布的包,這些包名都是 Monorepo 項(xiàng)目下的子包,changesets 內(nèi)部通過(guò) getPackages() 這一方法得到 Monorepo 項(xiàng)目下子項(xiàng)目信息,該方法的具體實(shí)驗(yàn)可以參考 changesets 下面一個(gè)叫做 @manypkg/get-packages 的包。方法本質(zhì)上是通過(guò)讀 Monorepo 下所有子項(xiàng)目的 package.json 然后構(gòu)建出一個(gè)依賴圖出來(lái),changesets 可以根據(jù)該結(jié)果得到需要進(jìn)行發(fā)包流程的項(xiàng)目,可以說(shuō)整個(gè) changesets 項(xiàng)目本身都會(huì)基于底層這個(gè)方法來(lái)進(jìn)行構(gòu)建,有點(diǎn)類似于一般 Monorepo 工具中的 graph 構(gòu)建。
這里同時(shí)會(huì)通過(guò)封裝的 git diff 命令檢查出本次 commit 修改了的包名稱,不過(guò)即使是沒(méi)有修改的包,用戶其實(shí)也是可以進(jìn)行選擇的,這里不同于其他 Monorepo 發(fā)包工具的區(qū)別在于更多的修改權(quán)限在用戶的手里。

之后選擇了想要發(fā)布的包之后,后面會(huì)選擇到想要更新包的版本層級(jí),例如這里我選擇了 patch 級(jí)別,按照 semver 的規(guī)范,這里選擇的包為 @pnpm-private,在填完 summay 之后,后面會(huì)生成一個(gè)文件名稱隨機(jī)的 changeset 文件,如圖所示:

這里文件的名稱是通過(guò)一個(gè)叫做 human-id 的庫(kù)生成的,具體可以在 npm 上查看,但實(shí)際上這里用戶也是可以自行修改文件名稱的,這里并沒(méi)有太大的關(guān)系,也可以修改文件里面的 CHANGELOG 的信息。
這個(gè)文件本質(zhì)上是做個(gè)信息的預(yù)存儲(chǔ),在該文件被消耗之前,用于是可以自定義修改的。隨著不同開(kāi)發(fā)者的迭代積累,changeset 文件是可以在一個(gè)周期之內(nèi)進(jìn)行累積的。例如 pnpm 現(xiàn)在下面就積累了一些 changeset 文件:

如果有信息相同,只是 CHANGELOG 描述不同的 changeset 文件,在消耗這些文件的時(shí)候是會(huì)被合并處理的,即對(duì)應(yīng)包的 version 并不會(huì)被升級(jí)多次。
version
version 這個(gè)命令這里可以當(dāng)作 bump version 來(lái)理解,這里本質(zhì)上做的工作是消耗 changeset 文件并且修改對(duì)應(yīng)包版本以及依賴該包的包版本,同時(shí)會(huì)根據(jù)之前 changeset 文件里面的信息來(lái)生成對(duì)應(yīng)的 CHANGELOG 信息。version 的源碼流程具體為:

這一步的核心步驟主要在依賴于 changesets 本身項(xiàng)目下的兩個(gè)庫(kù),分別為 @changesets/assemble-release-plan 和 @changesets/apply-release-plan ,其中 assembleReleasePlan 主要是通過(guò)讀生成的 changesets 文件然后分析出需要更新的包版本以及其依賴關(guān)系,然后將讀出來(lái)的待更新結(jié)果給到 applyReleasePlan 中去,在 applyReleasePlan 中則會(huì)根據(jù)相應(yīng)的信息修改掉包版本、消耗掉 changeset 文件、同時(shí)更新掉 CHANGELOG 文件(如果沒(méi)有就新生成一個(gè))。
例如現(xiàn)在在 pnpm 倉(cāng)庫(kù)的根目錄下執(zhí)行一次 changeset version,那么就會(huì)根據(jù)上面的流程得到這樣的結(jié)果:

對(duì)應(yīng)的 changeset 文件被消耗,然后對(duì)應(yīng)子項(xiàng)目的 CHANGELOG 以及版本發(fā)生變更,當(dāng)然改完后不滿意用戶還可以手動(dòng)對(duì) changelog 進(jìn)行修改。自動(dòng)修改的 changelog 信息如下:

其它命令
changesets 還提供了一些其他的命令,這里我就不再一一對(duì)其介紹,這些命令其實(shí)相對(duì)比較好理解并且實(shí)現(xiàn)上沒(méi)有特別讓人難以理解的地方。例如用戶如果要發(fā)一個(gè) prelease 的包版本(例如 beta、alpha 版本),那么就可以使用 changeset pre 命令,然后再結(jié)合 version 命令去進(jìn)行版本的 bump。
如果用戶想查看當(dāng)前的 changesets 文件消耗狀態(tài),那么可以使用 changeset status 命令。
發(fā)包的 changeset publish 本質(zhì)上就是對(duì) npm publish 做了一次封裝,同時(shí)會(huì)檢查對(duì)應(yīng)的 registry 上有沒(méi)有對(duì)應(yīng)包的版本,如果已經(jīng)存在了,就不會(huì)再發(fā)包了,如果不存在會(huì)對(duì)對(duì)應(yīng)的包版本執(zhí)行一次 npm publish。
changesets 目前缺陷
筆者在前面其實(shí)有提到過(guò)目前團(tuán)隊(duì)開(kāi)發(fā) Monorepo 工具時(shí),并沒(méi)有直接接入 changesets 這套方案,而是通過(guò)直接 fork 該倉(cāng)庫(kù)進(jìn)行修改,主要在于這套方案目前在一些使用場(chǎng)景下確實(shí)存在許多問(wèn)題。
changeset 文件名隨機(jī)
在前面有提到 add 這一命令生成出來(lái)的 changeset 文件名稱是隨機(jī)的(通過(guò) human-id 這個(gè)庫(kù)生成),那么在一個(gè)快速迭代的 Monorepo 開(kāi)發(fā)場(chǎng)景下。例如筆者組 Monorepo 項(xiàng)目,每周會(huì)大概產(chǎn)生 20+ 的 changeset 文件,而這些文件名稱又是隨機(jī)的,非常不便于用戶去進(jìn)行管理和辨別。
因此筆者在 fork 該項(xiàng)目之后,通過(guò)修改了 @changesets/write 這一部分代碼,使得生成的 changeset 文件能夠按照分支名+用戶名+id 的形式顯示出來(lái),便于不同的開(kāi)發(fā)者對(duì)自己的 changeset 文件進(jìn)行篩選。
命令均不支持項(xiàng)目篩選
例如 add 命令無(wú)法指定特定的包,而只能通過(guò)前面 getPackages() 方法得到所有的子項(xiàng)目名來(lái)進(jìn)行選擇,如果一個(gè)項(xiàng)目下存在好幾十個(gè)子項(xiàng)目的話,找具體的項(xiàng)目就是一件很費(fèi)成本的事情。
不過(guò) add 命令至少會(huì)通過(guò) git diff 來(lái)篩出修改的子包名稱,這樣在一定程度上減少了用戶去找項(xiàng)目的成本,但是 version 命令因?yàn)闆](méi)有提供對(duì)應(yīng)的篩選功能,導(dǎo)致在一些場(chǎng)景下,用戶只想消耗特定的 changeset 文件去更新特定包是無(wú)法完成的。
因此筆者在 fork 該項(xiàng)目之后,通過(guò)其與 pnpm 的 filter 機(jī)制(參考文檔: https://pnpm.io/filtering)結(jié)合,使得整個(gè)工作流能夠被用戶進(jìn)行自定義篩選。
Prelease 包發(fā)布過(guò)程繁瑣
使用 changesets 如果想發(fā)一些測(cè)試版本的包,需要反復(fù)執(zhí)行 changeset pre enter 、changeset pre exit 以及 changeset version 等命令,整個(gè)流程上是很繁瑣的。實(shí)際上在自行維護(hù)的過(guò)程中,這些瑣碎的流程可以集合到一個(gè)命令中來(lái)完成的,并不用消費(fèi)如此大的成本。
項(xiàng)目缺少維護(hù)
這一點(diǎn)其實(shí)也算是支撐筆者自己 fork 源碼重新搞一套的一個(gè)重要理由,目前該項(xiàng)目處于長(zhǎng)期沒(méi)有 PR 合并的一個(gè)狀態(tài),近半年來(lái)合并的 pr 都是一些簡(jiǎn)單的文檔修改而沒(méi)有實(shí)質(zhì)性的功能進(jìn)展:

同時(shí) changesets 本身的文檔還是比較欠缺的,例如一些常見(jiàn)的 FAQ 文檔目前還是處于 TODO 的狀態(tài)。
不過(guò)好消息是最近作者已經(jīng)開(kāi)始活躍起來(lái),并回復(fù)了大量的 issue ,期待能在不久之后重新將整個(gè)項(xiàng)目運(yùn)作起來(lái)。
總結(jié)
目前的 changesets 方案整體而言在 Monorepo 項(xiàng)目下還是挺適用的,而且整體架構(gòu)上而言并沒(méi)有特別大的技術(shù)難點(diǎn),主要難點(diǎn)在于 version bump 這一部分。
筆者認(rèn)為該方案最大的優(yōu)點(diǎn)在于提供了很大的自主權(quán)在用戶手中,在復(fù)雜的業(yè)務(wù)場(chǎng)景下能夠做出一些合適的調(diào)整,例如用戶可以自行修改 changeset 文件、changelog 文件、甚至是 bump version 后不滿意的版本。
相比較于 lerna 提供的比較理想化的方案而言,changeset 本身是一套泛用性很強(qiáng)的方案,而且比較適合當(dāng)下 Monorepo 工作流場(chǎng)景下的一些運(yùn)作方式,雖然本身還存在著不少的缺點(diǎn)。
期待作為目前不少 Monorepo 項(xiàng)目正在使用的發(fā)包方案,未來(lái) changesets 能越來(lái)越流行~

