如何管理前端專案中的複雜依賴關係

doodlewind發表於2018-12-24

隨著前端工程規模的增加,各種第三方與自有依賴包的關係也日趨複雜。這時候可能產生什麼問題,又該如何解決呢?這裡分享我們前端團隊的一些實踐。

何謂複雜依賴關係

安裝依賴包,對於前端開發者來說不過就是一句 npm install xxx 的事。那麼,單純靠這種方式給一個專案安裝了很多依賴,就算是複雜的依賴關係嗎?這裡我們這樣定義「複雜」:

  • 你需要自己維護多個不同的包,來在最下游的業務專案中使用。
  • 除了被下游業務依賴外,這些包之間也可能存在依賴關係,它們也可能依賴上游的包。
  • 不同的包可能位於不同的 Git 倉庫,還有各自獨立的測試、構建與釋出流程。

如果純粹只靠 npm install,那麼所有的包都必須釋出到 NPM 之後才能被其他的包更新。在「聯調」這些包的時候,每次稍有更改都走一遍正式的釋出流程,無疑是非常繁瑣而影響效率的。我們有什麼現成的工具來解決這個問題呢?

社群工具 Takeaway

提到管理多個包之間的依賴關係,很多同學應該能馬上想到不少現成的工具,比如:

  • NPM 的 link 命令
  • Yarn 的 workspace 命令
  • Lerna 工具

這裡的「萬惡之源」就是 npm link 命令了。雖然熟悉它的同學多半知道它有不少問題,但它確實能解決基本的連結問題。快速複習一下使用方式:假設你維護的下游業務專案叫做 app,上游的依賴叫做 dep,那麼要想做到「dep 一改動,app 就能同步更新」,只需要這樣:

# 1. 在 dep 所在路徑執行
npm link

# 2. 在 app 所在路徑執行
npm link dep
複製程式碼

這樣就形成了 app 與 dep 之間基本的「連結」關係。只要進入 app 的 node_modules 檢視一下,不難發現 NPM 其實就是替你建立了一個作業系統的「快捷方式」(軟連結)跳到 dep 下而已。在存在多個互相依賴的包的時候,手動維護這個連結關係非常麻煩而且容易出錯,這時候你可以用社群的 yarn workspace 或 Lerna 來自動幫你管理這些包。由於這二者相當接近,在此我們只介紹在我們生產環境下使用的 Lerna 工具。

Lerna 的使用也是非常傻瓜的,你只需按下面的風格把各個依賴包放在同一個目錄下就行,無需對它們具體的構建配置做任何改動:

my-lerna-repo/
  package.json
  packages/
    dep-1/
      package.json
    dep-2/
      package.json
    dep-3/
      package.json
    ...
複製程式碼

然後一句 lerna bootstrap 就能夠自動處理好它們之間的依賴關係了——這裡每個包的 package.json 都可以放心地寫上其它包的名字了(注意這裡依據的是 package.json 中的 name 欄位,而非目錄名)。這樣,你可以放心地把這些包放置在同一個 Git 倉庫裡管理,而不用擔心繁瑣的初始化過程了——現在的 Babel 和 React 就是這麼幹的。

當然了,實際的場景並不是有了現成的命令或者工具就萬事大吉了。下面總結一些實踐中的依賴管理經驗吧:

迴圈依賴的產生與解除

在剛開始使用 Lerna 這樣的依賴管理工具時,一些同學可能會傾向於把依賴拆分得非常零散。這時是有可能出現迴圈依賴的情形的——A 包依賴了 B,而 B 包又依賴了 A。怎麼會出現這種情況呢?舉一個例子:

  1. 假設你在維護一個可複用的編輯器 editor 包。為了更好的 UI 元件化,你把它的 UI 部分拆分成了 editor-ui 包。
  2. editor-ui 的元件需要 editor 例項,因此你把 editor 列為了 editor-ui 的依賴。
  3. editor 的 Demo 頁面中想要展示帶完整 UI 的應用,因此你把 editor-ui 列為了 editor 的依賴。

這時候就出現了迴圈依賴。雖然 NPM 支援這種場景下的依賴安裝,但是它的出現會讓依賴關係變得難以理解,因此我們希望儘量做到直接避免它。這裡的好訊息是,迴圈依賴多數都和不太符合直覺的需求有關,在上面的例子裡,作為上游的 editor 包去依賴了下游的 editor-ui 包,這可以在方案評審時就明確指出,並只需改為在 editor-ui 包中展示 Demo 頁即可——如果出現了迴圈依賴,大膽地運用「這個需求不合理」的否決權吧。

多依賴包的初始化和同步

我們已經提到,lerna boostrap 能夠正確地完成多個包的依賴安裝和連結操作。但這是否意味著一個裝載了多個包的 Lerna 倉庫,只要這條命令就能夠讓這些包都正常地跑起來呢?這裡存在一點細節需要注意。

如果你管理的多個包先是配置了各自的構建和釋出命令,然後才通過 Lerna 合併到一起的話,可能出現這樣的問題:它們在 package.main 欄位下指定的入口都是形如 dist/index.js 下的構建後檔案,但相應的產物程式碼在現在一般是不提交到 Git 的。這時候拉下全新的程式碼想要跑起來時,即便工具正確地處理了連結關係,仍然有可能出現某個子包無法打包成功的情況——這時,就去被依賴的包目錄下手動 npm run build 一次了。當然,在這種情況下,更新了一個包的原始碼後,也需要對這個包做一次 build 操作生成產物後,其它的包才能同步。雖然這並沒有多少理解上的困難,但往往造成一些不必要的困擾,故而在此特地提及。

存在上下游的依賴管理

在真實場景中,依賴其實並不能完全通過 Lerna 等工具管理,而是存在著上下游的區分的。這是什麼概念呢?如下圖:

如何管理前端專案中的複雜依賴關係

一般來說,上游的基礎庫(如 Vue / Lodash 等)並不適合直接匯入自有的巨集倉庫中維護,而下游的具體業務專案多數也是與這些自有依賴獨立的,它們同樣在 Lerna 工具的控制範圍之外。這時,我們仍然需要回到基本的 npm link 命令來建立本地的連結關係。但這可能會帶來更多的問題。例如,假設你在 Lerna 中管理 editor 與 editor-ui 兩個依賴,而業務專案 app 依賴了它們,這時候你不難把 editor 與 editor-ui 都 link 到 app 下。但這時的連結關係很容易被破壞,考慮下面的工作流:

  1. 你為了修復 app 中 editor 的一些問題,更新了 editor 的程式碼,並在本地驗證通過。
  2. npm publish 了 editor 與 editor-ui 的新版本。
  3. 你在 app 中 npm install editor editor-ui 並提交相應的改動。

Boom!執行了最後一步後,不光 app 與 editor 之間的連結關係會被破壞,editor 與 editor-ui 之間的連結關係也會被破壞。這就是軟連結的壞處了:下游的變更也會影響上游。這時,你需要重新做一次 lerna bootstrapnpm link 才能把這些依賴關係重新建立好,對於頻繁迭代的業務專案來說,這是相當棘手的。對這個問題,我們提出的變通方案包括兩部分:

  • 可以部署一個專門用於依賴安裝的業務專案環境。
  • 可以編寫自己的 link 命令來替代 npm link

前者聽起來麻煩,但實際上只需要把 app 目錄複製一份即可。假設複製後得到了 app-deps 目錄,那麼:

  • 將 editor-ui 與 editor 都 link 到 app 目錄下,使用它們在本地開發。
  • 在需要更新依賴版本時,在 app-deps 目錄下執行 npm install editor 即可。這不會 app 專案中破壞原有的連結關係。

當然,這時候 app 與 app-deps 之間的依賴可能不完全同步——這個問題只要有 pull 程式碼的習慣就能解決。另外的一種問題情形在於,如果下游的業務專案採用了 CNPM 等非 NPM 的包管理器來安裝依賴,那麼這時候原生的 link 命令容易失敗。還是套用前面的例子,這時候我們可以在 editor 專案中建立 link 命令,來替代 npm link

// link.js
const path = require('path');
const { exec } = require('./utils'); // 建議將 childProcess.exec 封裝為 Promise

const target = process.argv[2];
console.log('Begin linking……');

if(!target) {
    console.warn('Invalid link target');
    return;
}

const baseDir = path.join(__dirname, '../');
// 區分相對路徑與絕對路徑
const targetDepsDir = target[0] === '/'
    ? path.join(target, 'node_modules/my-editor')
    : path.join(__dirname, '../', target, 'node_modules/my-editor');

console.log(`${baseDir}${targetDepsDir}`);

exec(`rm -rf ${targetDepsDir} && ln -s ${baseDir} ${targetDepsDir}`)
.then(() => {
    console.log('? Link done!');
})
.catch(err => {
    console.error(err);
    process.exit(1);
});
複製程式碼

這樣只要在 editor 的 package.json 中增加一條 "link": "node ./link.js" 配置,就能通過 npm link path/to/app 的形式來完成連結了。這個連結操作跳過了不少中間步驟,因此比 NPM 原生的 link 速度要高得多,也能適配 CNPM 安裝的業務專案。

對於「自有依賴 → 下游業務」的情形,這兩個方式基本能保證開發節奏的順暢。但還有一個問題,就是「上游依賴 → 自有依賴」的時候,仍然可能需要折騰。這對應於什麼情況呢?

一般來說,最上游的基礎庫應當是相當穩定的。但是你同樣可能需要修改甚至維護這樣的基礎庫。比如,我們的 editor 編輯器依賴了我們開源的歷史狀態管理庫 StateShot,這時候就需要本地連結 StateShot 到 editor 中了。

這個場景不能繼續前面的 npm link 套路嗎?當然可以,不過上游的基礎庫並不需要頻繁的迭代來同步時,我們建議使用 npm pack 命令來替代 link,以保證依賴結構的穩定性。如何使用這個命令呢?只需要這樣:

  1. 假設你有上游的 base 包,那麼在它的目錄下構建它之後,執行 npm pack
  2. pack 生成 base.tgz 之後,在 Lerna 管理的 editor 包下執行 npm install path/to/base.tgz
  3. lerna bootstrap 保證連結關係正確。

pack 的好處在於避開了軟連結的坑,還能更真實地模擬一個包從釋出到安裝的流程,這對於保證釋出的包能夠正常安裝使用來說,是很有用的。

總結

前端的工程化還在演化之中,從最簡單的 npm install 到各色命令與工具,相信未來的趨勢一定是能夠讓我們更加省心地維護好更大規模的專案,也希望文中的一些實踐能夠對前端同學有所幫助。

相關文章