阅读 191

Monorepo 在 React Native 项目的实践

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

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

repo-intro.jpeg

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

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

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

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

笔者曾经写过一篇文章依赖注入实现组件化,来介绍 Android 项目如何做组件化,使用的就是 Android Studio 天然支持的 Monorepo 来组织代码。

app-structure.jpg

如上图所示,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
复制代码

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

repo-initial.png

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

  1. 修改 package.json 文件,添加如下配置

    "workspaces": [
        "app",
        "packages/*"
    ],
    复制代码

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

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

    repo-app.png

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

     {
       "name": "app",
       "version": "1.0.0",
       "dependencies": {
    
       }
     }
    复制代码
  3. 修改 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";
    }
    复制代码
  4. 修改 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 前面有个空格。

    如图:

    repo-ios-config.png

现在,我们有了一个叫 app 的子项目,并且整个工程可以正常运行了。这也是我们拆分一个 Monolith 项目为 Monorepo 项目的第一步。

一个完整的项目是由多个子项目组合而成,app 是整个工程的入口,负责组装其它子项目,app 可以依赖其它子项目,但其它子项目不能依赖 app

我们把其它的子项目都放到 packages 目录下。

模块间依赖

在 packages 目录下,创建三个子项目,为别为 common, module-a, module-b, 如图所示

repo-packages.png

我们给所有子模块都添加了 @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 目录看一眼

repo-magic.png

我们在 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 就会得到未经声明不得导入的提示。

项目管理者只需要关注每个子项目的 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-navigationreact-native-platform

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

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

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

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

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

源码

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

文章分类
前端
文章标签