最近需要將工作中的一個TS包拆出一部分代碼,以便在多個團隊和項目中共享。原以為這會是一項特別簡單的工作,但是也花了兩天才大致拆成功。因此記錄一下,也給有類似需求的同學一點經驗。
所拆項目的大致功能:整個項目的結構大致分為:
- 一個基類和多個實現類,我們需要拆出一個實現類到包里,因此基類也得放到這個包里
- 一個代碼生成工具,會讀取目錄下的所有配置文件并生成ts代碼,這個工具也得放到包里
我們希望拆完之后的項目滿足這些條件:
- 拆出的包(以下稱子包或子項目)可以獨立發布,方便外部用戶使用
- 為了快速驗證和減少開發過程中的額外步驟,原項目(以下也可能稱母項目)可以本地引用子項目,而不用每次有修改都先提一個PR發布新版本,再通過升級版本號引用最新的改動
總而言之,就是我們雖然對外發布了這個包,且外部會通過包名+版本號來引用,但是團隊內部項目開發時還是希望能通過本地引用直接引用到最新的改變。下面總結一下如何引用本地包和拆包后的代碼需要注意什么。
小心緩存帶來的編譯、包導入不生效問題
由于接下來需要經常修改子包的配置,所以要特別注意緩存帶來的問題,這樣如果遇到奇怪的問題還能有印象是緩存帶來的問題。
雖然TS老手可能已經知道緩存的坑,但是作為新手還是很容易被緩存導致的問題搞得很迷惑。如果遇到奇怪的問題,比如
- 敲了
tsc --build
卻沒有生成編譯后的文件 - 有的ts編譯生成了
.d.ts
,有的卻沒有,但是.js
文件都存在,造成子項目引用時找不到類型 - 刪除
node_modules
,再跑yarn install
也不會安裝依賴 - 子項目沒有升版本就打包,母項目引用時還是安裝的沒有修改之前的版本
這三個都是我在拆子包的開發過程中遇到的問題,經常讓我百思不得其解,還以為是自己改了什么配置改錯了,但是改回來之后還是不工作。
其中1&2都是由于ts編譯緩存造成的,緩存文件的文件名叫tsconfig.tsbuildinfo
,它會記錄最近一次編譯,用于支持ts的增量編譯功能。根據配置的不同(是否開啟 composite=true
),可能生成在項目根目錄或者是構建輸出目錄下。如果刪除了整個構建輸出目錄(比如下文我們會配置/lib
為輸出目錄)但是沒有清除緩存,那重新跑構建命令,也不會生成構建目錄!因為ts編譯器是通過對比源文件和增量編譯緩存文件的差別來決定是否要重新編譯的,而如果擅自刪除了輸出目錄,緩存文件和輸出文件很就存在不同步的情況,這就是為什么重新構建可能不生效的原因!
3&4的問題是因為我們的項目中使用了yarn,而npm/yarn會有緩存。比如3#就是要刪除yarn.lock/package-lock.json
文件后再yarn install
而4#最簡單的辦法是本地引用包時,一旦子包修改,就升一個版本再打包,等要push代碼的時候再改回原來的版本。比如在package.json
里增加這些scripts
:
"scripts": {"build": "tsc --build","clean": "rimraf lib && rimraf tsconfig.tsbuildinfo","rebuild": "yarn clean && yarn build","package": "yarn rebuild && yarn pack","local-pack": "npm version prerelease && yarn package"},
這樣當你執行yarn rebuild
或者yarn local-pack
時,會自動清理lib
目錄和編譯緩存;測試打包時也會自動升一個版本,避免出錯。
引用本地包的方法
在介紹拆包需要怎么改代碼之前,我們先說怎么本地引用包,這是因為如果不知道拆完的包該怎么被本地引用,那根本沒法編譯原來的項目,更別提怎么測試子項目功能是否正常了。
通過查找多種資料(包括問AI),大概有以下幾種引用本地包的方法:
- 在
tsconfig.ts
中配置包的本地映射:在compilerOptions.paths
中加上一行"{packageName}":["{pathToLocalPackage}"]
,個人感覺這種方法對于測試堪稱完美,子項目的源碼修改會立即反映到別的項目中。它的缺陷是沒法很好地測到子包被發布出去之后,其它項目通過package.json
引用時是否能正常工作,這是因為子項目中的代碼都是源碼級引用,隨母項目的編譯而編譯,因此會掩蓋一些問題,比如使用絕對路徑(比如import xx from src/moduleA
來導入模塊可能并不會報錯,但是通過package.json
引用時就會找不到指定模塊。 - 直接在
package.json
里使用引用本地項目的目錄:使用yarn add
或者直接修改package.json
,添加"{packageName}":"file:{subPeojectFolderPath}"
。這種方式也能實現源碼級引用,但是它有一個很大的缺陷,file
引用目錄時,會將整個項目文件夾復制到node_modules
內,導致子項目里的node_modules
也會被原樣拷貝過去。占用空間不說,它會導致子項目、母項目對同一包的引用出現沖突。AI還提供了一些使用peerDependencies
的建議,實際上并沒有用:它并非引用包的不同版本出現了沖突,而是子項目和母項目里的node_modules
起了沖突!那可能有人會說,如果我子項目不安裝依賴呢?如果子項目不安裝依賴,那也沒法編譯,也會造成很多問題。所以實際上我最不推薦這種做法。 - 使用
yarn link
:先在子項目下執行yarn link
,然后在母項目下執行yarn link "{packageName}"
。這種方式和1#很類似,但是子包是通過編譯后代碼來引用的(去決定于子包里package.json
如何配置),更能測試出一些引用問題。 - 先將子項目本地打包,再通過文件引用打包出的tar ball:先在子項目下執行
yarn pack
,然后在母項目下執行yarn add"{packageTarBallPath}"
。這種方法是最能模擬子包發布之后,被其它項目引用的行為的,因為它可以測試我們的打包配置是否正確,比如后文提到的tsconfig.json
和package.json
這兩個配置文件里,錯誤的配置會導致包無法被正確引用。 - yarn workspace:創建一個workspace,將兩個項目添加到workspace中。兩個項目會共享一個
node_modules
依賴,又保持獨立性,避免了2#中的問題,很優雅。缺點是可能需要改變項目的目錄結構,需要用一個父目錄來包含多個子項目。
綜上,這些方法中不推薦2#,如果可以接受改變目錄結構則5#看起來是最優雅的辦法。
如果不能改變目錄結構,那追求便捷度首推1#,因為修改可以立刻反映到母項目,IDE可以立刻檢查出有沒有語法錯誤,編譯過程也很流暢,同時開發中也無需跑額外的命令來關聯兩個項目;如果按照與真實引用環境的差別排序,首推4#。剩下的3#有點雞肋,因為它不是通過修改package.json
來改變項目的引用關系,而是需要跑兩個額外的命令臨時關聯兩個項目,因此只適合臨時測試。
我在自己的項目中,用的是方法4#。因為我們依賴的庫如果使用方法1#會導致一些奇怪的錯誤,子項目變動也不會很大,因此我們主要考慮保證項目能正確運行。只不過這樣每次如果需要修改并測試子項目,都要重新編譯。實際開發可以考慮1#和4#結合的方法,先用1#保證編譯通過,再用4#保證運行正確。
修改子項目
除了把代碼都拷到另一個獨立的項目之外,還有一些值得注意的點,主要是注意
- 配置好
package.json
和tsconfig.json
,確保打包的代碼能被正確引用 - 避免絕對路徑代碼,要使用位置無關性代碼,確保子包中代碼運行結果符合預期
tsconfig.json和package.json
我們需要修改tsconfig.json
來保證編譯后的代碼會生成在正確的目錄,同時為了保證發布的包可以被正確引用我們需要配置package.json
,寫明項目的入口文件。
tsconfg
compilerOptions.outDir
指明了編譯后的js/ts文件放在哪個目錄下。比如我們配置成lib
,那就會在lib
文件夾下看到編譯后的代碼compilerOptions.rootDir
指明了源碼文件的儲存路徑。編譯后生成的文件會保持rootDir
為根目錄的目錄結構。通常我們的源代碼放在src
目錄下,如果不設置rootDir
時,默認會以項目所在的目錄為根目錄,編譯后的代碼會放在lib/src
目錄下。當rootDir
設置成了src
,那生成的編譯后代碼就會放在lib
下而不是lib/src
下,使打包出的目錄層級更清晰(否則保持src
目錄會讓人很迷惑,一般src
用于存放源代碼而不是生成的代碼)
package.json
以下配置需要匹配tsconfig.json
的配置,如果不太確定,可以跑tsc編譯看看生成的目錄里對應的文件放在哪了。
types
:指定了編譯出的.d.ts
文件。它是ts的類型聲明文件,通常用于保證ts編譯期類型系統正常工作main
:指定了編譯出的.js
文件入口。它包含了真正的代碼實現(而編譯后的.d.ts
只包含類型聲明,有點類似于接口與實現或者頭文件和源文件的差別)
設置peerDependencies
通常我們在項目有三種和依賴相關的設定:
dependencies
:項目的依賴,基本上可以認為代碼里要import的包都需要加到這個依賴配置中peerDependencies
:指定了當該項目被消費方使用時,所使用的包版本。如果沒有指定,則會使用dependencies
中指定的版本(自己在使用yarn時驗證了這個行為)devDependencies
:項目開發時需要,但是生產環境不需要的依賴,通常不是代碼里import的包。例子包括一些命令行工具(比如可用于刪除文件的rimraf
包,通常用于在build之前清理生成文件),代碼格式化工具等
我相信大家已經非常了解devDependencies
是什么,但對peerDependencies
可能不夠了解。peerDependencies
通常在庫的package.json
中被聲明,它有兩方面語義:
- 告訴消費這個包的項目,庫需要運行在某些包的特定版本之上
- 對于peer里指定的包,庫會“盡量”和主項目共享同一份副本。共享同一份副本意味著二者中的代碼調用peer中的共同依賴時,會指向同一個本地路徑,不會造成包依賴沖突(比如類型不兼容)的問題。
從2#中可以看出peerDependencies
和dependencies
最大區別:前者會盡量共享同一個包的副本,避免重復安裝和依賴不兼容,這些問題在使用庫時是很常見的問題;而后者則大多數情況下會重復安裝,即使包的名稱和版本都相同。現在只剩一個問題沒有解決,什么叫“盡量”共享同一副本呢?根據包管理器的不同,大致如下的一些行為:
- 如果peer版本和主項目兼容,則會共享同一個包,這一點在多個包管理工具中都是相同的
- 如果peer版本和主項目不兼容,則可能有多種行為:
- 繼續使用主項目的包版本,可能會有包兼容性檢查的警告
- 如果主項目安裝了滿足peer版本的包,則主項目和庫會運行在不同版本的依賴之上
- 報錯,依賴安裝失敗
也有一些配置項如peerDependenciesMeta
會影響這些行為,但多數情況下都是使用包管理器的默認行為。這會造成一些令人迷惑的結果。比如我實際操作中,有一個依賴A的版本指定的是^0.20250101.1901000
這樣的版本,在yarn 22.x版本下,它使用的是讓主項目和庫運行在不同版本依賴的策略,因此即使主項目更新了依賴A的版本,主項目調用子包時,子包使用的還是子包指定的版本。
另外再說一個有趣的知識點,^
在版本號中的含義是“第一個非零的版本號一致”。對于常規版本號,比如1.2.3
,就意味著1.x.x
,而對于0.2.3
就意味著0.2.x
了。我在項目中使用的依賴是以日期作為第一個非零主版本號,因此就會導致即使只把依賴更新到第二天的版本,yarn也會認為版本是不兼容的,轉而讓主項目和子包使用不一樣的依賴版本,造成了這個隱蔽的問題。
測試
由于這兩個配置文件主要影響子包中的代碼能否被正確引用,所以通過在母項目中跑tsc --build
編譯就能看出配置是否正確。
本地模塊import改為相對路徑引用
本地模塊導入指的是通過類似import {xx} from "../classA"
這樣的代碼來導入本地某個模塊。如果我們的包不需要給別人用,那我們完全可以寫一個絕對路徑,比如import {xx} from "src/folderA/classA"
。但是另一個項目B引用時如果遇到絕對路徑的導入,一般會以項目B的根目錄作為根目錄來查找這個模塊,而子包里寫的這句import則是以子包的根目錄作為根目錄,自然就會導致找不到模塊的錯誤。
可以搜索一下子項目里有沒有用到"src
這樣的關鍵字,用到的話記得改掉。
使用外部傳入的路徑來計算外部文件的真實路徑
假如子包中有一些讀取外部文件(非子包文件)的代碼,比如讀取某個目錄下所有的配置文件,讀取某個TS模塊等,我們都要甄別這些路徑,把讀取的路徑改為真實路徑。比如曾經我們的代碼在拆之前,可能相對于讀取的目錄有固定的相對路徑比如__dirname/../anotherFolder/anotherModule
,拆分后得讓代碼調用方把真實路徑傳進來,而不能還用之前基于當前模塊路徑計算的方法。從代碼層面看,這些方法需要傳的參數變多了。
可以搜索一下子項目里有沒有用到__dirname
,__filename
這樣的關鍵字,用到的話記得改掉。
發布
確認好發布的包可以正常工作后,發布基本上是最簡單的步驟了。通常工作中會使用獨立的npm倉庫,可以用如下命令發布某個目錄下的包,或者打包好的tarball到指定倉庫
yarn publish [<tarball>|<folder>] --registry <url>
常見問題
打出的包被別人引用,看到node_modules
下也成功安裝了庫文件,但是用包名引用提示“找不到模塊”
package.json
中main
和types
指定錯了,要確保目錄層級和包里package.json
與指定文件的相對路徑一致
主項目運行時,提示子包內的模塊找不到(通常是require語句報錯)
通常是因為子包使用了絕對路徑而非相對路徑
主項目更新了依賴,主項目中嘗試直接調用依賴,用的是新版本依賴,但是子項目調用的還是舊版本的依賴
沒有設置好peerDependencies
,詳見文章對應章節
修改了子項目的代碼,打包后安裝到主項目,發現主項目里并沒有安裝更新的包
通常是緩存問題,檢查如下方面:
- 最好確保每次用
npm version
命令升一個版本再打包安裝 - 生成目錄和tgz包最好每次刪掉,否則通常它們都只會增量更新