前言
虽然 monorepo 一题之前聊过很多,对技术选择边界也反复洞察,但随着时间的流逝和实践的领域、量级拓宽,自省:越发重要的心智点是哪个?
毫无疑问是: 引用模块热更新
什么是 “引用模块热更新” :就是你在 monorepo 中开发项目时,跨子包引用代码,改动其他子包的源代码文件,要能在当前项目中自动检测到,支持热更新,从而你就不需要去重新构建那个子包的产物,然后手动刷新页面了。
下面我们从几个角度探讨一种 trade off 的丝滑解法,在阅读前,我们默认读者已经具备了 熟练的 webpack 基础 或是 框架开发者 。
解法
Tranpile Includes
Why not watch ?
先做一个质问,为什么不能 watch 模式启动那个子包,然后他自己就可以随改动,自动重构建产物了?
-
一是构建时间有延迟。假如是 tsc 还好,假如是 rollup rebuild 单文件花费的时间更是让人难以忍耐;其次对于大型子包 sdk ,最快的 tsc 都要近 10s 时,更不用说 rollup 了。 当然,也可以说用 esbuild 就解决了构建时间的问题了吧,但是 esbuild bundle 是单文件的,单文件变动意味着整体 reload ,而不是局部 hmr 了,同样对主项目会造成整体刷新的破坏,试想你打开了一个 Modal 弹窗组件,下次要重新打开(这里不聊 unjs 思路的单文件 transpile ,不是本文重点)。 -
二是检测不到产物变化。由于子包链接在 node_modules 里,一般框架侧都会 exclude 排除掉,不会监听,所以即使是产物变动了也不会热更新页面。
所以解法是什么,有什么痛点我们就聚焦解什么,很显然,既然他不识别,我们就手动配 webpack rule 规则让他在我们的范围内。
规则分离
因为我们的子包代码都是 ts 的,所以必然要让 ts 资源全部进入我们的 transpile 范围,故需要让 .js 和 .ts 资源匹配规则分离,所有 .ts 规则不设 exclude ,.js 规则继续保持 exclude 排除掉 node_modules ,示例如下:
{
test: /\.js$/,
exclude: /node_modules/
use: [
]
}
{
test: /\.(ts|tsx|jsx)$/,
use: [
]
}
如此一来,第三方包的 .ts 资源就会进入 transpile 范围,当然,还有更细致的做法,比如对 exclude 做函数判断,在某个 @scope/* 内就不要 exclude 。
重定位
如上我们解了无法 transpile 其他子包 ts 资源的问题,现在我们重定位他们,直接读取这些子包的源码,这样就不需要构建他们了,让他们真正成为我们项目的一部分。
做法是:首先需要找到这些子包的 src 源码位置,然后通过 alias 重定位包的引用到他的源码处。如何寻找?这里推荐使用最流行的 monorepo 发包工具 changesets 的底层 @manypkg/get-packages ,示例如下:
import { getPackages } from '@manypkg/get-packages'
import { join } from 'path'
const collectAllProjectsEntryAlias = async () => {
const workspaces = await getPackages(
projectRootPath
);
return workspaces.packages.reduce(
(obj, pkg) => {
const name = pkg.packageJson?.name;
if (name) {
obj[name] = join(pkg.dir, 'src');
}
return obj;
},
{},
);
}
这里只是一个简易逻辑,根据场景的不同,你可能还需要规范化你子包 package.json 中的字段,以及判断文件夹存在性,还有预期在使用 npm 包的版本还是本地版本等。
之后你只需要将他们 merge 进 alias 即可。
回顾一下,在以前,我们使用的是 "main": "dist/index.js" 导出的内容,而现在我们通过 alias :
{
alias: {
'some-pkg': '/path/to/some-pkg/src'
}
}
使用的是他的源码 some-pkg/src/index.ts 导出的内容,搭配对 ts 的 transpile,即可做到无缝热刷新!
TypeScript Type Support
无疑,直接定位到 src 是无法识别到类型变动的,我们直接使用 tsconfig.json#compilerOptions.paths 来做识别就可以了,示例如下:
{
"compilerOptions": {
"paths": {
"some-pkg/*": ["../packages/some-pkg/*"],
}
}
}
如果要做自动化,可以考虑让项目 tsconfig.json 继承框架生成的 .temp/tsconfig.base.json 来动态修改。
到此为止,我们的热更新问题完全解掉 😎 ,但真的有这么简单吗?
Ensure Single Instance
多实例的底层逻辑
在 monorepo 中,我们很容易忽视一个重要事实:
- 在读子包时,他的依赖是从他的目录作为起始点想上寻找的。
这意味着假如你正在使用 react 开发可复用的组件,此时按照规范,你应该让使用你组件包的人的 react 唯一,也就是不能将 react 写入 dependencies ,而是写入 devDependencies ,因为你本地开发要使用,同时为了提示安装你包的人,还要将其写入 peerDependencies :
{
"devDependencies": {
"react": "^18.0.0"
},
"peerDependencies": {
"react": "^18.0.0"
}
}
但是在 monorepo 中可不同于 npm 包的使用只会安装 dependencies ,子包的 devDependencies 同样在我们本地!这就会导致该包的组件使用的 react 是从他目录向上寻找到的 react ,和主应用中的 react 不一样!造成多 hooks 应用崩溃问题。
对于 react 重复来说,这是致命错误,会导致应用崩溃。另一个不致命的经典 case 是:两份 antd ,会导致 Message 弹出提示不成队列。
在这种 “多实例” 影响下,先不论致命错误我们无法承受,各种不好的体验问题,甚者很多依赖都是子包带了一份,主应用也有一份,最后产物体积成倍增加。
困境解法
关于这一问题,也被我自称为 peerDependencies 困境,我们之前在 pnpm monorepo 体系中探讨过:
该文中的解法有两个,一是通过 alias 别名统一定位到主应用保持实例唯一;二是将他们都提升到全局,从而两者都向上会找到同一个实例。无论是哪种解法都不具备自动的拓展性,有人工劳作成本。
有没有一种自动化的丝滑解决方案?
可以有,只要一切都在约定规范内。这里的 trade off 是,我们通过 自动读 同仓库的其他子包 package.json 的 peerDependencies 字段,然后将他们全 尝试定位 到我们的主应用即可。
这里有两个关键词:
-
自动读:原理同如上解热更新的方法,使用 workspace 工具读出来这些子包 package.json 中的 peerDependencies 有哪些就可以了。 -
尝试定位:一些依赖主应用必定有,比如 react ,而一些不重要的、主应用用不到的依赖,我们的主应用根本不需要装,所以是 尝试定位 ,定位不到就用子包的,定位到了就用主应用的保持唯一。此处的方法应该是 requre.resolve() 或 resolve() ,简单示例如下: import resolve from 'resolve'
import { dirname } from 'path'
const alias = {}
try {
const depPkgDir = dirname(resolve.sync(
`${depName}/package.json`,
{ basedir: projectRootPath }
))
alias[depName] = depPkgDir
} catch {}
需要定位防止多实例的依赖一定在 peerDependencies 有吗?正确的,一定有,只要开发规范。
如此一来,我们就可以解掉多实例的问题。回过头来,只要开发者按照正式的 FE 界开发规范,能把依赖写在 peerDependencies 就可以做到自动加载,在业界规范下说,这不是一种约定了,而是一种基础能力。
另外,对于重要的依赖,会导致应用 crash 崩溃的核心依赖我们应该手动锁死位置,如 react 核心的几个依赖:
-
react -
react-router -
react-router-dom
当然,对于内部开发的场景,为了彻底避免开发者不规范开发子包,不写 peerDependencies ,可以维护一份重点依赖名单,他们一般是 ui 库或是每个项目都可能有的依赖,比如 antd / arco-design 等,然后给这些重点依赖在框架侧就永久重定位。
注:不要过大预期定位的优点,不是核心依赖尽量不要定位,定位应该保持最小范围,大部分依赖他们往往并不会导致体验问题或致命错误,但是他们的版本在子包和主应用不一样,一旦出现大版本 Breaking change 则得不偿失。
总结
我们通过解决热更新和重要的多实例问题,让 monorepo 的仓内项目开发更加丝滑。
进一步下钻,我们根本不需要让开发者理解这一切,这些逻辑应该在框架侧黑盒自带,当识别到该项目在 monorepo 中自动开启,因为这一切对于开发者来说都很 “理所当然“ 。
往广度谈,monorepo 的丝滑方法还有哪些,比如使用 pnpm 、changesets , 框架侧的丝滑做法还有哪些,比如自动 polyfill 、默认最佳现代构建策略等,由于这些不在本文主题范围,留给读者自行探索研究。
以上。
|