【實(shí)戰(zhàn)】1379- 基于 Yarn 的 Monorepo 實(shí)踐
幾年前工作中整過(guò)幾個(gè) SDK 倉(cāng)庫(kù),當(dāng)時(shí) SDK 庫(kù)邏輯還比較簡(jiǎn)單,工程設(shè)計(jì)也不復(fù)雜:
ESLint+Prettier+GitHook
Rollup 打包
npm 私有倉(cāng)庫(kù)搭建
隨即發(fā)包復(fù)用就解決了。
隨著時(shí)間的推移,SDK 庫(kù)為了兼容各個(gè)端、完善開(kāi)發(fā)體驗(yàn)實(shí)現(xiàn)各種配套的調(diào)試工具等等逐漸變得復(fù)雜,之前簡(jiǎn)單的工程能力要實(shí)現(xiàn)源碼插件化、分包發(fā)布、定制化構(gòu)建等等能力會(huì)比較痛苦:
簡(jiǎn)單目錄隔離劃分模塊
手動(dòng)多次更新目錄
package.json版本來(lái)發(fā)包多個(gè)端代碼復(fù)用一個(gè)
tsconfig.json,存在端限制(比如不能使用 DOM)的目錄并不能正確的校驗(yàn)。......
然后通過(guò)搜索你就會(huì)了解到了 Babel、React 等源碼都采用了 Monorepo 的方式管理,Babel 還用了 Lerna 工具來(lái)做發(fā)包工具等等業(yè)內(nèi)的實(shí)踐,但當(dāng)時(shí)借助 Lerna 搭建的一個(gè)倉(cāng)庫(kù)實(shí)踐體驗(yàn)沒(méi)有想象中的好......
最近我用 Yarn 包管理工具實(shí)踐了一次 Monorepo 的工程化搭建,此文意在將實(shí)踐過(guò)程分享出來(lái)并說(shuō)說(shuō)我對(duì) Monorepo 的一些看法,僅供參考。
搭建過(guò)程
本地新建一個(gè)倉(cāng)庫(kù)的過(guò)程略過(guò):
- packages/
- xxx/
- package.json
- README.md
- package.json
- .prettierrc
- .eslintrc.js
- .editorconfig
- commitlint.config.js
- README.md
可以看到源碼是 packages 目錄下存放的一個(gè)個(gè)模塊:
源碼使用統(tǒng)一的配置,如 eslint、prettier 配置等
不同模塊間有一個(gè)良好的目錄隔離
引入 Yarn
首選參照 yarn 官網(wǎng)在全局安裝:
npm i -g yarn
并在倉(cāng)庫(kù)根目錄中引入指定版本的 yarn:
yarn set version berry
此時(shí)你會(huì)發(fā)現(xiàn)倉(cāng)庫(kù)中出現(xiàn)了以下文件:
- .yarn/
- releases/
- yarn-berry.cjs # berry版本源碼
- .yarnrc.yml # yarn配置Yarn 配置
配置主要關(guān)心這些應(yīng)該就足夠用了:
httpProxy:'http://127.0.0.1:8899'
httpsProxy:'http://127.0.0.1:8899'
npmAuthIdent:'${USER:-root}:${TOKEN:-123456}'
npmPublishRegistry:'https://mirrors.cn/npm/'
npmRegistryServer:'https://mirrors.cn/npm/'
unsafeHttpWhitelist:
-mirrors.cn
yarnPath:.yarn/releases/yarn-berry.cjs可能因公司內(nèi)網(wǎng)限制,必須使用網(wǎng)絡(luò)代理
公司搭建了 npm 鏡像服務(wù),修改下包發(fā)包地址及相應(yīng)鑒權(quán)賬號(hào)密碼。
針對(duì)公司鏡像域名放開(kāi) https 限制
禁用 PnP 模式(后面聊)
然后在 package.json 中添加:
{
"workspaces": [
"packages/*"
],
}
配置 IDE
如果你開(kāi)啟了 PnP 模式(沒(méi)開(kāi)啟可以忽略這一步),那么還要參照文檔操作下,不然 IDE 語(yǔ)言功能可能運(yùn)行不正常:
yarn dlx @yarnpkg/pnpify --sdk vscode
引入插件
參照 yarn 文檔引入必要插件:
Typescript 插件是用于改進(jìn)使用體驗(yàn)的,它會(huì)在你安裝包 A 的同時(shí)去嘗試幫你安裝其類型 @types/A,這里不多介紹。
yarn plugin import typescript
Workspace-tools 是工作區(qū)插件,必備。
yarn plugin import workspace-tools
Version 插件是實(shí)現(xiàn)發(fā)布流的(本文所展示實(shí)踐未使用,不作過(guò)多介紹)。
yarn plugin import version
這時(shí)候你可能會(huì)發(fā)現(xiàn).yarnrc.yml 多了以下配置:
plugins:
-path:.yarn/plugins/@yarnpkg/plugin-typescript.cjs
spec:'@yarnpkg/plugin-typescript'
-path:.yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs
spec:'@yarnpkg/plugin-workspace-tools'
-path:.yarn/plugins/@yarnpkg/plugin-version.cjs
spec:'@yarnpkg/plugin-version'類型配置
接下來(lái)你要明確你的包(源碼)會(huì)在哪些環(huán)境去運(yùn)行,假設(shè)我們要在網(wǎng)頁(yè)上和服務(wù)器上運(yùn)行,那么類型配置如下:
//tsconfig.isomorph.json
{
"compilerOptions": {
"target":"es6"/*Specify ECMAScript target version:'ES3'(default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019'or'ESNEXT'.*/,
"module":"commonjs"/*Specify module code generation:'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or'ESNext'.*/,
"allowJs":true/*Allowjavascriptfilestobecompiled.*/,
"resolveJsonModule":true,
"skipLibCheck":true,
"declaration":true/*Generatescorresponding'.d.ts'file.*/,
"declarationMap":true/*Generatesasourcemapforeachcorresponding'.d.ts'file.*/,
"sourceMap":true/*Generatescorresponding'.map'file.*/,
"composite":true/*Enableprojectcompilation*/,
"tsBuildInfoFile":"./dist/tsconfig.tsbuildinfo"/*Specifyfiletostoreincrementalcompilationinformation*/,
"strict":true/*Enableallstricttype-checkingoptions.*/,
/*ModuleResolutionOptions*/
"moduleResolution":"node"/*Specify module resolution strategy:'node'(Node.js)or'classic'(TypeScriptpre-1.6).*/,
"baseUrl":"./"/*Basedirectorytoresolvenon-absolutemodulenames.*/,
"paths": {
"@*": ["packages/*"]
} /*Aseriesofentrieswhichre-mapimportstolookuplocationsrelativetothe'baseUrl'.*/,
"typeRoots": ["src"] /*Listoffolderstoincludetypedefinitionsfrom.*/,
},
"include": ["src"]
}
//tsconfig.node.json
{
"extends":"./tsconfig.isomorph",
"compilerOptions": {
"baseUrl":".",
"typeRoots": ["src"],
"types": ["node"]
},
"include": ["src"]
}
//tsconfig.web.json
{
"extends":"./tsconfig.isomorph",
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"jsx":"react-jsx"
}
}
這里的主要目的是為了讓每個(gè)包內(nèi)源碼能得到正確的校驗(yàn),每個(gè)包內(nèi)的目錄結(jié)構(gòu)是:
-dist/# 構(gòu)建產(chǎn)物
-src/# 包源碼
-tsconfig.json# 繼承../../tsconfig.xxx.json的殼配置(讓Vscode等IDE正常開(kāi)啟語(yǔ)言功能)
-package.json# 有統(tǒng)一的scripts(dev, dist)包腳手架
接下來(lái)你要想好你的包分哪幾種類型,比如一種分法是:
入口模塊(一般只有 1 個(gè)),命令為
cli核心模塊,命名模式為
core-xxx插件模塊,命名模式為
plugin-xxx工具模塊,命名模式為
utils-xxx服務(wù)端模塊,命名模式為
server-xxx配套調(diào)試工具模塊,命名模塊為
devtool-xxx
然后你為每一種類型編寫好腳手架模板,引入腳手架工具即可:
//package.json
{
"scripts": {
"addpkg":"yo xxx"
}
}
yarn addpkg
研發(fā)流配置
這里也要跟 Lerna 一樣先要問(wèn)一個(gè)問(wèn)題,每個(gè)包的版本是獨(dú)立的呢還是統(tǒng)一的,說(shuō)真的為了省事,建議統(tǒng)一方便很多,目前看起來(lái)也沒(méi)造成什么問(wèn)題。
借助工作區(qū)插件的能力,直接配置 scripts:
//package.json
{
"scripts": {
"ws:ver":"yarn workspaces foreach --exclude '+(server-*)' -pv exec npm version",
"ws:pub":"yarn workspaces foreach --exclude '+(server-*)' -vt npm publish --tag alpha --tolerate-republish",
"ws:prebuild":"yarn workspaces foreach -j 1000 -pvA run prebuild",
"ws:dev":"yarn workspaces foreach -j 1000 -pvA run dev",
"ws:dist":"yarn workspaces foreach -pvtA run dist",
}
}
通過(guò) yarn ws:ver 可以統(tǒng)一更改包版本
通過(guò) yarn ws:pub 可以統(tǒng)一發(fā)布包,并且把 server-* 類型包排除
通過(guò) yarn ws:dev/dist 可以本地一鍵編譯所有包
使用體驗(yàn)
依賴管理
Yarn 是個(gè)包管理器,最核心的實(shí)現(xiàn)就是依賴安裝,其特性建議細(xì)看文檔這里簡(jiǎn)單帶過(guò):
Offline Cache:簡(jiǎn)單地說(shuō)就是會(huì)將下載的包以 zip 緩存在.yarn/cache/ 里。
Plug’n Play:安裝后會(huì)生成.pnp.cjs,Hack Node.js 原生 require 方法達(dá)到直接加載.yarn/cache/ 中的 zip 包效果。
Zero-Install:將.yarn/cache/ 和.pnp.cjs 提交到 Git 倉(cāng)庫(kù)中并開(kāi)啟 PnP 模式后,協(xié)作者無(wú)需再安裝即可開(kāi)發(fā)。
PnP 模式跑起來(lái)后確實(shí)很爽,但后來(lái)因?yàn)樗木窒扌晕疫€是關(guān)掉了這個(gè)特性:
PnP 只 Hack 到 require 方法,沒(méi)有辦法很好地 Hack 各種 resolve,很大程度上依賴生態(tài)需要?jiǎng)e的庫(kù)引入 SDK 支持它,比如 storybook 這類工具源碼中很多 require.resolve 以及手動(dòng)拼接的在 node_modules / 下的文件路徑,這種實(shí)現(xiàn)就需要其本身去兼容 PnP。
修改.yarnrc.yml 配置以采用原生的 node_modules 安裝結(jié)構(gòu):
nodeLinker:node-modules簡(jiǎn)單地執(zhí)行后,就能得到一個(gè)相對(duì)完美的結(jié)構(gòu):
yarn install
-node_modules/# 公共依賴
-packages/
-xxx/
-node_modules/# 與公共依賴版本沖突的特殊依賴但這個(gè)實(shí)現(xiàn)也相對(duì)的復(fù)雜,作為使用者我還沒(méi)深入的看源碼理解其一些抽風(fēng)行為,平時(shí)你可能需要用到以下技巧:
有時(shí)候變動(dòng)依賴后某個(gè)工作區(qū)不沖突的依賴未提升到根目錄 node_modules (https://www.yarnpkg.cn/cli/dedupe)
yarn dedupe xxx
有時(shí)候倉(cāng)庫(kù)本地安裝的命令會(huì)崩 (https://www.yarnpkg.cn/cli/rebuild)。
yarn rebuild husky
有時(shí)候你會(huì)有鎖定依賴的需要。
//package.json
{
"resolutions": {
"roaring":"1.0.6",
"typeorm":"0.2.34",
"async":"3.2.0"
}
}
工作區(qū)指令
workspace 插件給 yarn 提供了批量給工作區(qū)(包)執(zhí)行命令的方式:
yarn workspaces foreach
......
這個(gè)命令最強(qiáng)大的地方的是它可以拓?fù)涫降貓?zhí)行,只要加上 -topological 參數(shù)即可。
但是它識(shí)別工作區(qū)命令執(zhí)行完成的方式比較弱,就是進(jìn)程退出,所以當(dāng)我執(zhí)行 yarn ws:dev 時(shí),tsc -w 的編譯掛起后使得拓?fù)鋱?zhí)行就是個(gè)雞肋了,而且控制臺(tái)輸出的也不好。
Link
用過(guò) npm link 的人都知道體驗(yàn)很不好,但 yarn link 的實(shí)現(xiàn)目前來(lái)看也很雞肋。
yarn link 實(shí)際上是基于 resolutions 來(lái)實(shí)現(xiàn)的,但經(jīng)常因?yàn)橐溄拥膫}(cāng)庫(kù)子孫依賴版本沖突不成功,而且成功后也常常跑不起來(lái)。
據(jù)我自身的經(jīng)驗(yàn)來(lái)說(shuō) link 功能實(shí)現(xiàn)其實(shí)挺復(fù)雜,往往不是一個(gè)簡(jiǎn)單創(chuàng)建一個(gè)軟鏈就可以的,要考慮:
當(dāng)加載到軟鏈模塊執(zhí)行其 require 時(shí),require 加載常常會(huì)尋址到其自身的 node_modules。
需要結(jié)合軟鏈倉(cāng)庫(kù)的依賴重新計(jì)算依賴樹。
......
從我這次的實(shí)踐角度來(lái)看,現(xiàn)實(shí)情況往往不要想那么多,直接創(chuàng)建的軟鏈就完事。
npm v7 軟鏈到全局調(diào)試 CLI 工具:
npm link
npm v6 鏈接其他倉(cāng)庫(kù):
npm link /path/to/local/dependency
總結(jié)
以上,就是我簡(jiǎn)單實(shí)踐 Yarn Monorepo 的一些經(jīng)驗(yàn)之談。
之所以選擇 Yarn 是因?yàn)槲也惶春?pnpm 軟鏈原理的實(shí)現(xiàn)(詳見(jiàn)參考),除了就事論事地去對(duì)比不同的工具,其實(shí)我選擇 Yarn 依舊是看重了其源碼倉(cāng)庫(kù)的質(zhì)量和背后 Facebook、Google 等公司的實(shí)力。
謝謝,大家多多交流。
參考
JavaScript 包管理器簡(jiǎn)史(npm/yarn/pnpm)https://mp.weixin.qq.com/s/0Nx093GdMcYo5Mr5VRFDjw
為什么現(xiàn)在我更推薦 pnpm 而不是 npm/yarn https://mp.weixin.qq.com/s/h7MfgVfR4c9YxtO44C-lkg

