React Native构建踩坑记录

1,564 阅读7分钟

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 作为打包工具

2023-12-10-14-11-07-image.png

metro打包过程分为以下三个阶段

  1. Resolution: 从入口模块出发分析模块依赖关系并构建模块依赖图,内部使用jest-haste-map 来进行依赖分析与文件监听。这个阶段与Transformation阶段并行。
  2. Transformation:此阶段主要将模块代码转换为目标平台可识别的格式(通常通过babel进行转换)
  3. Serialization:此阶段主要是将Transform后的模块进行序列化,组合所有模块生成一个或多个bundle。一个bundle就是一个JavaScript文件 2023-12-11-10-16-10-image.png react-native官方提供了一些基础命令
  • bundle: 根据传入的 JavaScript entry file 构建 bundle
  • start: 启动本地开发服务器

这些命令都是 @react-native-community/cli 这个包实现的,bundle和start命令内部通过调用metro来构建bundle。

2023-12-10-14-10-35-image.png

题外话:在打包业务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 命令,果然不出所料报错了 2023-12-10-14-56-02-image.png

这种情况显然是resolve react的过程报错了,奇怪的是明明已经安装了react,但为什么会找不到呢?于是我开始debug resolve的逻辑 2023-12-11-10-33-22-image.png 可以看到metro resolver 最终查找到react对应的package.json路径,这个路径肯定是存在的,因为我们已经安装了react,确实存在当前的node_modules中,但是这个if 判断结果却是false(当然下面会继续尝试resolve index文件,但仍然会失败),让我们继续 debug 进入 context.doesFileExist 2023-12-11-10-37-51-image.png 2023-12-11-10-38-59-image.png 2023-12-11-10-40-05-image.png

可以看到内部会通过 jest-haste-map 构建的 hasteFS 查找文件,那么hasteFS._files 里面为什么没有包含react/package.json呢?

其实在一开始,metro 会先根据配置创建一个Jest-haste-map 实例,内部根据metro.config.js中的watcherFolders 配置来指定将watcherFolders目录下所有的文件加载到内存中(并监听变化的文件目录),即hasteFS._files属性(这是一个map数据结构,key为文件的相对路径,value为文件信息),后续当resolve 文件时可以直接通过此数据结构判断文件是否存在) 2023-12-11-10-51-46-image.png

可以看到,如果我们不传watcherFolders配置时,默认为当前执行脚本的目录(即process.cwd()),那么内部是如果查找watcherFolders目录下所有文件的呢?接着往下看 2023-12-11-10-54-43-image.png

haste-jest-map会先检查当前环境是否已经安装watchman且是否配置了useWatchman(如果没有配置,默认为true),如果两个条件都满足就使用watchman查找文件,否则使用node模块查找文件。为了方便理解,我们直接debug进入node模块 2023-12-11-10-59-28-image.png

如果当前系统支持 find 原生命令则使用 find命令查找,否则使用nodejs fs模块查找,同样为了方便理解,我们直接debug到node fs查找文件的逻辑 2023-12-11-11-02-38-image.png

可以看到,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下对应的文件 2023-12-11-11-15-05-image.png 2023-12-11-11-15-54-image.png

  • 我们使用的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-linkerhoisted, 这样就能保持node_modules与yarn classic同样的目录结构且不会有软链接 2023-12-11-11-29-53-image.png

  • 升级metro版本到0.72.0,并配置resolver.unstable_enableSymlinks 和 watchFolders image.png

  • 不升级metro,使用社区提供的 @rnx-kit/metro-resolver-symlinks 包来解决追踪软链接的问题,同时配置 resover.resolverRequest和watchFolder 2023-12-11-11-37-43-image.png

因为我们使用 pnpm 来管理工程就是为了使用pnpm的优点,所以不考虑方案一。由于种种原因我们目前还不能升级metro版本,所以当前采用方案三。最终看到bundle可以成功构建! 🎉🎉🎉 2023-12-11-11-45-32-image.png

3.2 验证start命令

build成功之后,我们还要继续测试start 命令,理论上start命令仅仅多了一个启动本地服务器的步骤,其余步骤与build一致,应该不会出现错误。

但是当我运行start 命令,并在浏览器输入 localhost:8080/index.dev.bundle (入口文件bundle) 访问并构建bundle时,熟悉的错误又出现了

2023-12-11-11-50-52-image.png

通过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

2023-12-11-12-00-25-image.png

最终发现,resolverRequest接收到的platfom为null,导致了@rnx-kit/metro-resolver-symlinks 走了原生的 metro resolver 逻辑,自然就无法追踪软链接了。

那么这个platform是怎么来的呢?继续debug

2023-12-11-12-05-23-image.png

最终发现在 start 命令中,platform是从 url 的query获取的,然而为了方便测试,我没用真机,而是直接在浏览器输入 localhost:8080/index.dev.bundle , 并没有传入 platform参数,所以导致了后续resolve失败,🤡🤡🤡🤡 !!!在浏览器输入localhost:8080/index.dev.bundle?platform=android(事实上,native就是传入这样的URL来支持本地调试的)就成功了~~~

2023-12-11-12-09-25-image.png

2023-12-11-12-09-41-image.png

那为什么build命令不报错呢?

因为我们在构建命令中传入了 --platform android 参数~~~🙈🙈🙈