最近需要将工作中的一个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包最好每次删掉,否则通常它们都只会增量更新