1. 背景
我们团队负责的RN项目分为common和 SP plugin两个仓库,都使用yarn classic作为包管理工具。 其中 common 是基础bundle,包括react、react-native等基础包,SP plugin是我们团队的业务bundle,内部不包含common bundle的内容。SP App可以看做是一个RN plugin 容器,会集成多个团队的业务RN plugin 。SP App 会先将common bundle内置到app中,当用户进入不同的业务场景时,App只需要加载每个业务plugin即可。RN架构详情请参考:Shopee SZ去中心化的 React Native 架构探索
最近需要将我们团队负责的两个仓库使用pnpm改造为monorepo
2. 基础知识
在改造之前我们需要了解一些基础知识
React Native使用 metro 作为打包工具
metro打包过程分为以下三个阶段
- Resolution: 从入口模块出发分析模块依赖关系并构建模块依赖图,内部使用jest-haste-map 来进行依赖分析与文件监听。这个阶段与Transformation阶段并行。
- Transformation:此阶段主要将模块代码转换为目标平台可识别的格式(通常通过babel进行转换)
- Serialization:此阶段主要是将Transform后的模块进行序列化,组合所有模块生成一个或多个bundle。一个bundle就是一个JavaScript文件 react-native官方提供了一些基础命令
这些命令都是 @react-native-community/cli 这个包实现的,bundle和start命令内部通过调用metro来构建bundle。
题外话:在打包业务bundle时怎么排除common bundle的内容?
在打包common bundle和业务bundle时配置相同的 serializer.createModuleIdFactory 来生成 module id,打包common bundle后记录所有common bundle中的module id。随后在打包业务bundle时配置 serializer.processModuleFilter,如果发现 module id存在于common bundle module id中,则将此模块排除
3. 工程改造
接下来就是工程改造,将两个工程放到一个工程中,并在 pnpm-workspace.yaml 文件中配置workspace目录
# pnpm-workspace.yaml
packages:
- 'bundles/**'
工程目录如下
.
├── bundles/
│ ├── common/
│ │ ├── deploy/
│ │ ├── babel.config.js
│ │ ├── index.js
│ │ ├── metro.config.js
│ │ ├── package.json
│ │ └── README.md
│ └── business-plugin/
│ ├── deploy/
│ ├── src/
│ │ └── index.ts
│ ├── babel.config.js
│ ├── metro.config.js
│ ├── metro.config.dev.js
│ ├── index.dev.js
│ ├── package.json
│ └── README.md
├── .eslintrc.js
├── .gitignore
├── .gitlab-ci.yml
├── .prettierrc
├── package.json
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── README.md
└── tsconfig.json
src/index.ts
import 'react'
// import other pages
// ...
其中bundles/common 是common包,bundles/business-plugin是业务包。
在scripts中新增两个命令
business-plugin/package.json
{
"scripts": {
"dev": "react-native start --confg metro.config.dev.js",
"build-android": "react-native bundle --config metro.config.js --entry-file src/index.ts --bundle-output dist/android/sp.android.js --assets-dest dist/android --dev false --reset-cache --platform android"
"build-ios": "react-native bundle --config metro.config.js --entry-file src/index.ts --bundle-output dist/android/sp.android.js --assets-dest dist/android --dev false --reset-cache --platform android"
}
}
3.1 验证 build 命令
至此工程基本已经配置好了,我们先来测试bundle是否能构建成功。运行 pnpm build-android 命令,果然不出所料报错了
这种情况显然是resolve react的过程报错了,奇怪的是明明已经安装了react,但为什么会找不到呢?于是我开始debug resolve的逻辑 可以看到metro resolver 最终查找到react对应的package.json路径,这个路径肯定是存在的,因为我们已经安装了react,确实存在当前的node_modules中,但是这个if 判断结果却是false(当然下面会继续尝试resolve index文件,但仍然会失败),让我们继续 debug 进入 context.doesFileExist
可以看到内部会通过 jest-haste-map 构建的 hasteFS 查找文件,那么hasteFS._files 里面为什么没有包含react/package.json呢?
其实在一开始,metro 会先根据配置创建一个Jest-haste-map 实例,内部根据metro.config.js中的watcherFolders 配置来指定将watcherFolders目录下所有的文件加载到内存中(并监听变化的文件目录),即hasteFS._files属性(这是一个map数据结构,key为文件的相对路径,value为文件信息),后续当resolve 文件时可以直接通过此数据结构判断文件是否存在)
可以看到,如果我们不传watcherFolders配置时,默认为当前执行脚本的目录(即process.cwd()),那么内部是如果查找watcherFolders目录下所有文件的呢?接着往下看
haste-jest-map会先检查当前环境是否已经安装watchman且是否配置了useWatchman(如果没有配置,默认为true),如果两个条件都满足就使用watchman查找文件,否则使用node模块查找文件。为了方便理解,我们直接debug进入node模块
如果当前系统支持 find 原生命令则使用 find命令查找,否则使用nodejs fs模块查找,同样为了方便理解,我们直接debug到node fs查找文件的逻辑
可以看到,haste-jest-map 利用 fs.readdir api依次读取 watchFolders 目录下的文件,这里需要重点关注,hast-jest-map 排除了 symbolic link的文件(软连接) ,也就是说最终的hasteFS._files 中并没有包含软链接的文件。
debug到这里,我们应该可以知道metro resolve 失败的原因了。
3.1.1 resolve file失败原因总结
-
使用了pnpm改造工程,默认pnpm会将所有包提升至顶层目录的node_moduels/.pnpm中,在workspace node_module中只会保留一份软链接文件,并指向node_modeuls/.pnpm下对应的文件
-
我们使用的metro版本为0.59.0,并不支持追踪软链接到真实路径(0.72.0版本开始支持resolver.unstable_enableSymlinks 配置),所以 react 解析到的路径仍然为plugin workspace下的node_modules/react,这是一个软链接路径
-
jest-haste-map会根据传入的watchFolder 配置查找所有文件并存入hasteFS._files,hasteFS._files会忽略软链接文件。metro resolve会通过hasteFS._files 查找文件是否存在。
所以 metro 尝试使用 plugin/node_modules/react 路径去hasteFS._file中查找,自然是会失败的。
3.1.2 resolve error解决
知道了resolve失败的原因,我们应该让metro能通过软链接追踪到真实的文件路径,同时配置 watchFolder 监听真正的文件目录
有以下几种方案
-
配置 pnpm node-linker 为 hoisted, 这样就能保持node_modules与yarn classic同样的目录结构且不会有软链接
-
升级metro版本到0.72.0,并配置resolver.unstable_enableSymlinks 和 watchFolders
-
不升级metro,使用社区提供的 @rnx-kit/metro-resolver-symlinks 包来解决追踪软链接的问题,同时配置 resover.resolverRequest和watchFolder
因为我们使用 pnpm 来管理工程就是为了使用pnpm的优点,所以不考虑方案一。由于种种原因我们目前还不能升级metro版本,所以当前采用方案三。最终看到bundle可以成功构建! 🎉🎉🎉
3.2 验证start命令
build成功之后,我们还要继续测试start 命令,理论上start命令仅仅多了一个启动本地服务器的步骤,其余步骤与build一致,应该不会出现错误。
但是当我运行start 命令,并在浏览器输入 localhost:8080/index.dev.bundle
(入口文件bundle) 访问并构建bundle时,熟悉的错误又出现了
通过debug,发现 在 hasteFS._files中确实找不到react对应的文件,这是为什么呢?按照上面的分析,我们已经配置了resover.resolverRequest和watchFolder,理论上不会出现这个错误才对呀?
那么我想问题应该出现在 @rnx-kit/metro-resolver-symlinks 中,于是我开始debug @rnx-kit/metro-resolver-symlinks源码
@rnx-kit/metro-resolver-symlinks 核心原理是先通过 nodejs 中的 fs.lstatSync 和 fs.readlinkSync 方法来追踪软链接的真实文件路径,然后再传入真实文件路径调用原生的 metro resolver
最终发现,resolverRequest接收到的platfom为null,导致了@rnx-kit/metro-resolver-symlinks 走了原生的 metro resolver 逻辑,自然就无法追踪软链接了。
那么这个platform是怎么来的呢?继续debug
最终发现在 start 命令中,platform是从 url 的query获取的,然而为了方便测试,我没用真机,而是直接在浏览器输入 localhost:8080/index.dev.bundle
, 并没有传入 platform参数,所以导致了后续resolve失败,🤡🤡🤡🤡 !!!在浏览器输入localhost:8080/index.dev.bundle?platform=android
(事实上,native就是传入这样的URL来支持本地调试的)就成功了~~~
那为什么build命令不报错呢?
因为我们在构建命令中传入了 --platform android
参数~~~🙈🙈🙈