Monorepo 在 React Native 项目中的实践
本文基于 React Native 项目讲解 Monorepo,不过泛前端项目也可以参考。
什么是 Monorepo 呢?谷歌一下,找到下面一张图
看图释义:就是你有一个巨无霸项目(Monolith),里面模块甚多,依赖错综复杂,给维护带来了困难。
为了维护方便,你想要模块化,把不同功能划分到不同子项目中去。此时,你可以让每个子项目对应一个独立的 Git 仓库,让这些模块之间从物理上进行隔离,不能随意相互引用。这就是 Multirepo。
不过这些子项目本就是一体,它们组合在一起才能构成一个完整的项目,将它们分割到不同仓库,给开发、构建带来了不便。那么有没有更好的组织方式呢?那就是 Monorepo 了。不同功能依然划分到不同子项目,但这些项目都在同一个 Git 仓库中。
Monorepo 是一种代码组织方式,在微服务、iOS、Android 开发中也是经常使用。譬如 iOS 可以借助 Cocoapods 实现 Monorepo,而 Android 的开发工具 Android Studio 天然就支持 Monorepo。
笔者曾经写过一篇文章依赖注入实现组件化,来介绍 Android 项目如何做组件化,使用的就是 Android Studio 天然支持的 Monorepo 来组织代码。
如上图所示,app 是个子项目,它负责组装其它项目,也是整个工程的入口。business-a-ui、business-b-ui、business-c、common-api、common-ui 也都是子项目。
这些子项目(模块)从物理上都分属不同的文件目录,那么如何禁止它们随意导入其它模块呢?答案是:依赖声明。如果一个子项目不声明依赖另外一个子项目,那么就不能导入。
我们如何在 React Native 项目或者前端项目中实现 Monorepo 呢?
有哪些工具可以帮我们将不同业务线或功能划分到不同子项目(目录)中去?
怎样并禁止子项目(目录)之间随意导入呢?
Yarn Workspace
Yarn 是 Node 的包管理器,相对 Npm 的优点之一便是 yarn workspace。
使用 Yarn Workspace 可以帮助我们实现前端项目的模块化、组件化。
如何使用 Yarn Workspace,看官方文档足矣。
使用以下命令,创建一个 React Native 项目
npx react-native-create-app MonoDemo
可以看到,生成的项目结构大致如下
我们参考 Android 原生项目组织代码的方式来组织我们的 React Native 项目代码。
- 修改 package.json 文件,添加如下配置
"workspaces": [
"app",
"packages/*"
],
这表明,app 目录本身,以及 packages 下的每一个子目录都是一个子项目(模块)。
- 创建 app 子项目(目录),并把 inde.js、App.tsx 移动到 app 目录下的 src 目录中。如图所示:
在 app 目录下创建 package.json 文件,内容为:
{
"name": "app",
"version": "1.0.0",
"dependencies": {
}
}
- 修改 android/app/build.gradle 文件,把
entryFile: "index.js"
替换为entryFile: "app/src/index.js"
project.ext.react = [
entryFile: "app/src/index.js",
enableHermes: false, // clean and rebuild if changing
]
修改 MainApplication.java 文件,把 getJSMainModuleName
的返回值由 index
修改为 app/src/index
@Override
protected String getJSMainModuleName() {
return "app/src/index";
}
- 修改 AppDelegate.m 文件,将
jsBundleURLForBundleRoot
的值由index
修改为app/src/index
NSURL *jsCodeLocation = [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"app/src/index" fallbackResource:nil];
使用 Xcode 打开 ios 项目,修改 Build Phases 中名为 Bundle React Native code and images 的脚本,在 react-native-xcode.sh 后面添加参数 app/src/index.js
。注意 app 前面有个空格。
如图:
现在,我们有了一个叫 app
的子项目,并且整个工程可以正常运行了。这也是我们拆分一个 Monolith 项目为 Monorepo 项目的第一步。
一个完整的项目是由多个子项目组合而成,app
是整个工程的入口,负责组装其它子项目,app
可以依赖其它子项目,但其它子项目不能依赖 app
。
我们把其它的子项目都放到 packages 目录下。
模块间依赖
在 packages 目录下,创建三个子项目,为别为 common, module-a, module-b, 如图所示
我们给所有子模块都添加了 @sdcx
作为 scope,一方面避免和其它第三方组件库有冲突,另一方面方便导入。
common
common 模块中的文件和内容如下
// common/package.json
{
"name": "@sdcx/common",
"version": "1.0.0",
"main": "src/index",
"dependencies": {}
}
由于 main 字段默认值是 index, 而我们的代码文件都放到了 src 中,因此需要手动指定 main 的值为 src/index。
// common/src/index.ts
export function log(...args: string[]) {
console.log(...args)
}
export const DEFAULT_NAME = 'Listen'
module-a
module-a 模块中的文件和内容如下
// module-a/package.json
{
"name": "@sdcx/module-a",
"version": "1.0.0",
"main": "src/index",
"dependencies": {
"@sdcx/common": "1.0.0"
}
}
模块 module-a 在它的 package.json 文件中声明了对 common 模块的依赖,由于 common 模块在它的 package.json 文件中配置 name 为 @sdcx/common,因此其它模块在声明对 common 模块的依赖时,也要使用这个名字。
// module-a/src/index.ts
import { log } from '@sdcx/common'
export function setupGlobalStyle() {
log('现在开始设置全局样式')
// 配置全局样式
Garden.setStyle({
topBarStyle: 'dark-content',
statusBarColorAndroid: Platform.Version > 21 ? undefined : '#4A4A4A',
})
}
module-b
module-b 模块中的文件和内容如下
// module-b/package.json
{
"name": "@sdcx/module-b",
"version": "1.0.0",
"main": "src/index",
"dependencies": {}
}
// module-b/src/Flower.tsx
export function Flower() {
return <Image source={require('./images/flower_1.png')} />
}
// module-b/src/index.ts
export * from './Flower'
调整 app 模块
修改 app 模块中的文件和内容如下
app 声明了对其它所有子模块的依赖
// app/package.json
{
"name": "app",
"version": "1.0.0",
"dependencies": {
"@sdcx/common": "1.0.0",
"@sdcx/module-a": "1.0.0",
"@sdcx/module-b": "1.0.0"
}
}
app 在 index.ts 文件中使用 module-a 模块
// app/src/index.ts
import { setupGlobalStyle } from '@sdcx/module-a'
// 配置全局样式
setupGlobalStyle()
app 在 App.tsx 文件中使用了 common 和 module-b 模块
// app/src/App.tsx
import { DEFAULT_NAME, log } from '@sdcx/common'
import { Flower } from '@sdcx/module-b'
function App() {
const [name, setName] = useState(DEFAULT_NAME)
const [text, setText] = useState('')
return (
<View style={styles.container}>
<View style={styles.row}>
<Flower />
<Welcome name={name} />
</View>
<Button
title="确定"
onPress={() => {
const n = text || DEFAULT_NAME
log(`向 ${n} 打招呼`)
setName(n)
}}
/>
</View>
)
}
背后的魔法
可见,依赖子项目就像依赖第三方库一样。Yarn Workspace 是怎么做到的呢?
是通过软链,从 node_modules 链接到子项目所在对应文件夹。
当我们通过 @sdcx/common
这样的方式导入依赖时,默认会去 node_modules 目录下查找,那能不能找到呢?还真找到了,打开 node_modules 目录看一眼
我们在 node_modules 目录下 @sdcx
这个 scope 找到了我们的三个子项目,仔细看一下,这三个目录右边都有一个箭头,这表示软链接。Node 模块解析器首先在 node_modules 下寻找,发现这几个模块是软链,然后顺着软链找到了模块的真正所在。
限制导入
Multirepo 把不同模块分割在不同 Git 仓库中,从物理上隔离了模块,不会出现导入未经声明依赖的模块的问题。
而在 Monorepo 中,是否可以导入未经声明依赖的模块呢?
我们的 module-b,并未在它的 package.json 文件中声明依赖 common,那么它是否可以导入并使用 common 模块呢?我们来试试看
修改 module-b/src/Flower.tsx 文件
import { log } from '@sdcx/common'
export function Flower() {
useEffect(() => {
log(`渲染了 Flower `)
})
return <Image source={require('./images/flower_1.png')} />
}
运行项目,发现毫无问题,我们可以在控制台上看到 渲染了 Flower 字样。
修改为
import { log } from '../../packages/common'
也一样毫无问题。
未经声明依赖,就可以导入并使用其它子模块,无法满足从物理上隔离的需求。
此时,我们需要引入 eslint-plugin-workspaces 这个 eslint 插件来拯救。
yarn add eslint-plugin-workspaces -D -W
-D 表示这是个 dev 依赖,-W 表示把依赖安装到 workspace 中,也就是项目根目录下的 package.json 文件中,因为我们的项目现在有好多个 package.json 文件呢。
然后配置 .eslintrc.js 文件
module.exports = {
root: true,
plugins: ['workspaces'],
rules: {
'workspaces/no-relative-imports': 'error',
'workspaces/require-dependency': 'error',
},
}
运行 npm run lint
就会得到未经声明不得导入的提示。
项目 owner 只需要关注每个子项目的 package.json 文件,就可以知道是否有模块依赖了它不应该依赖的模块,保证依赖路径,确保项目可维护性。
如果某些情况还限制不了,可以配合 no-restricted-imports 这条 eslint 规则使用。
安装第三方依赖
每个子项目都可以有自己的依赖,但整个工程只有一个 node_module 文件夹,因为 yarn 会把这些依赖拍平,都放到根目录的 node_module 文件夹下。
如官方文档所示,为某个子项目安装依赖,使用如下形式
yarn workspace @sdcx/module-b add @react-native-community/viewpager
如果想要为所有子模块都安装同样的依赖,使用如下形式
yarn add eslint-plugin-workspaces -D -W
为什么不用 Lerna
说起 Monorepo,几乎都会提到 Lerna。而且 Yarn Workspace 可以和 Lerna 配合使用。总的来说,Yarn 负责依赖管理,Lerna 负责发布。React Native 工程是一个 App,并不需要发布到 Npm 仓库,Lerna 在这里没有用武之地。
基础库适合使用 Monorepo 吗
我们 App 团队有 30 几个基础库,譬如开源的有 hybrid-navigation,react-native-platform。
这些基础库,有 UI 相关的,有平台相关的,有第三方 SDK 相关的,它们都独立成库,每个库都配备 Example 项目,可以单独运行测试。
这些组件库没有相关性,互不依赖,它们不应该放在一起,而应该分离。
有的组件库,譬如日志组件,还包含服务器端代码,web 前端代码,这些代码都放到了同一个 repo 当中。
因为我们的 App 是由不同业务线和基础业务组合而成,不同业务线分割在不同子项目中,方便管理,万一日后某条业务线被砍,可以方便移除代码。
是的,我们按照业务线拆分子项目。
源码
最后附上源码,希望我们的经验能对你有所启发。