Monorepo 在 React Native 项目中的实践

本文基于 React Native 项目讲解 Monorepo,不过泛前端项目也可以参考。

什么是 Monorepo 呢?谷歌一下,找到下面一张图

monorepo-2021-10-15-01-50-17

看图释义:就是你有一个巨无霸项目(Monolith),里面模块甚多,依赖错综复杂,给维护带来了困难。

为了维护方便,你想要模块化,把不同功能划分到不同子项目中去。此时,你可以让每个子项目对应一个独立的 Git 仓库,让这些模块之间从物理上进行隔离,不能随意相互引用。这就是 Multirepo。

不过这些子项目本就是一体,它们组合在一起才能构成一个完整的项目,将它们分割到不同仓库,给开发、构建带来了不便。那么有没有更好的组织方式呢?那就是 Monorepo 了。不同功能依然划分到不同子项目,但这些项目都在同一个 Git 仓库中。

Monorepo 是一种代码组织方式,在微服务、iOS、Android 开发中也是经常使用。譬如 iOS 可以借助 Cocoapods 实现 Monorepo,而 Android 的开发工具 Android Studio 天然就支持 Monorepo。

笔者曾经写过一篇文章依赖注入实现组件化open in new window,来介绍 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 workspaceopen in new window

使用 Yarn Workspace 可以帮助我们实现前端项目的模块化、组件化。

如何使用 Yarn Workspace,看官方文档open in new window足矣。

使用以下命令,创建一个 React Native 项目

npx react-native-create-app MonoDemo

可以看到,生成的项目结构大致如下

我们参考 Android 原生项目组织代码的方式来组织我们的 React Native 项目代码。

  1. 修改 package.json 文件,添加如下配置
"workspaces": [
    "app",
    "packages/*"
],

这表明,app 目录本身,以及 packages 下的每一个子目录都是一个子项目(模块)。

  1. 创建 app 子项目(目录),并把 inde.js、App.tsx 移动到 app 目录下的 src 目录中。如图所示:

在 app 目录下创建 package.json 文件,内容为:

{
  "name": "app",
  "version": "1.0.0",
  "dependencies": {

  }
}
  1. 修改 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";
}
  1. 修改 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 前面有个空格。

如图:

monorepo-2021-10-15-01-51-40

现在,我们有了一个叫 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 文件夹下。

官方文档open in new window所示,为某个子项目安装依赖,使用如下形式

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-navigationopen in new windowreact-native-platformopen in new window

这些基础库,有 UI 相关的,有平台相关的,有第三方 SDK 相关的,它们都独立成库,每个库都配备 Example 项目,可以单独运行测试。

这些组件库没有相关性,互不依赖,它们不应该放在一起,而应该分离。

有的组件库,譬如日志组件,还包含服务器端代码,web 前端代码,这些代码都放到了同一个 repo 当中。

因为我们的 App 是由不同业务线和基础业务组合而成,不同业务线分割在不同子项目中,方便管理,万一日后某条业务线被砍,可以方便移除代码。

是的,我们按照业务线拆分子项目。

源码

最后附上源码open in new window,希望我们的经验能对你有所启发。

上次更新: