Lerna 停止維護后,Monorepo發(fā)包新方案Changesets快了解一下!
作者簡介: zoomdong,目前在抖音前端技術(shù)團隊參與 Monorepo 相關(guān)的工程化基建工作,熱愛編程,熱愛開源,是?
pnpm、Ant Design等多個知名開源項目的貢獻者。

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

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

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

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

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

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

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

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

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

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

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

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

最后
如果你覺得這篇內(nèi)容對你挺有啟發(fā),我想邀請你幫我三個小忙:
點個「在看」,讓更多的人也能看到這篇內(nèi)容(喜歡不點在看,都是耍流氓 -_-)
歡迎加我微信「?sherlocked_93?」拉你進技術(shù)群,長期交流學習...
關(guān)注公眾號「前端下午茶」,持續(xù)為你推送精選好文,也可以加我為好友,隨時聊騷。

