NX 重构 RN 项目

背景

所负责的一个 rn 项目,分为用户端和医生端,这两个端有很多可复用的地方,比如相似的界面,相似的组件,相似的逻辑。

在此前的架构设计可能是前期工期比较紧张的原因,简单粗暴地将将两个端放置在同一个仓库当中,仅通过文件夹作为区分

├── src
│   ├── assets
│   ├── components
│   ├── hooks
│   ├── client
│   ├── doctor
│   └── index.ts
├── package.json
└── README.md

大概就是类似这样的一个结构。

这带来了什么问题呢?

  1. 统一入口为 index.ts, 所有的 client 和 doctor 的页面都在这里注册,导致最终 bundle 出来的 包非常大,对用户端来说多了很多医生端的代码,同理对医生端来说,多了很多用户端的代码。
  2. 无法评估影响点,例如仅修改医生端的代码,构建的时候会把用户端的部分也重新构建。并且作为开发者而言,我们也不好评估修改某些代码时会影响到哪一端。
  3. 一旦构建就是两端同时构建,这导致构建时间变长。

解决方案

其实上面的问题本质上都是因为入口单一,都通过 index.ts 作为注册页面的入口。比较容易想到的是,把入口拆分成 client-index.ts 与 doctor-index.ts 类似这样的。
但这仍然存在问题:
项目是通过 gitlab ci 持续集成构建的,当有代码变更的时候,就会执行构建脚本。但 gitlab 显然无法得知需要构建的是 client 还是 doctor,如果不管三七二十一,我都构建,那么显然上述 2 和 3 的问题是无法解决的。
为了解决上面的问题,即如何能获知代码变更造成的影响点,我逐渐把目光放到了 monorepo 上。

monorepo

提到 monorepo,第一个反应应当是 lerna,但当时的 lerna 还是不能满足上面的需求。然后我又看了一些目前比较热门的 monorepo 框架,如 nx / rush 等。此时我发现一个 issue https://github.com/lerna/lerna/issues/3121 没错,在这个 issue 里提到,lerna 将转给 nx 来维护,由此我打算仔细研究一下 nx。

nx

翻阅文档可以看到 nx 对比 lerna

  • Parallelization and task dependencies - Nx automatically knows how your projects relate to each other. As a result, if project-a depends on project-b and you run the build command for project-a, Nx first runs the builds for all of project-a‘s dependencies and then the invoked project itself. Nx sorts these tasks to maximize parallelism.
  • Only run what changed - Using Nx affected commands you only really execute tasks on the projects that changed, compared to a given baseline (usually the main branch).
  • Caching - You get Nx’s computation caching for free. All operations, including artifacts and terminal output are restored from the cache (if present) in a completely transparent way without disrupting your DX. No configuration needed. Obviously this results in an incredible speed improvement.

nx 对比 lerna 强在于任务编排

  1. nx 可以自动分析项目依赖,在执行任务时可以将前置依赖的package 构建
  2. 通过 nx affected 命令,nx 可以仅运行有变更的项目
  3. nx 可以缓存构建结果
    从上述看出,nx 完美符合我的需求,因此打算将项目通过 nx 进行重构。

重构的过程及踩坑

重构的步骤比较简单:

  1. 由于结构变动比较大,因此直接初始化一个 react native 项目
    npx create-nx-workspace your-workspace-name \
    --preset=react-native \
    --appName=your-app-name
    
    npm install @nrwl/react-native --save-dev
    
    nx g @nrwl/react-native:app doctor
    
    nx g @nrwl/react-native:app client
  2. 将公共的代码抽成 libs
    nx g @nrwl/react-native:library shared-hooks
    nx g @nrwl/react-native:library shared-components
    nx g @nrwl/react-native:library shared-assets
  3. 修改引用,之前引用公共代码类似 import SomeComponent from '@components',现在则更新为 import SomeComponent from 'your-workspace-name/shared-component'。注意这里还需要修改一下 tsconfig 里的别名,以方便 import SomeImage from 'your-workspace-name/shared-assets'
    "your-workspace-name/shared-assets/*": ["libs/shared-assets/src/*"]
  4. 重点在 package 的脚本,构建脚本调整为
    "release": "nx affected --target=release --base=origin/main~1 --head=origin/main",
    当 gitlab ci 运行时,执行 npm run release 这个脚本,此时 nx affected 会检测到 origin/main~1 与 origin/main 不同的地方,找到受影响的项目,并且执行这些项目的 release 命令
  5. 上面提到的 --target=release 中的 release 是自定义的 excutor,即 nx 的执行脚本,关于这部分可以参考 https://nx.dev/executors/run-commands-builder 实际上最主要的功能就是执行 react-native-cli 的 bundle(当然 nx 也内置了 bundle 的 excutor)

踩坑

以上都比较顺利,唯一的坑点在于,导入 shared-assets 的图片在 app 中并不会加载出来。一开始我以为是 nx 的问题,后来经过探索发现实际上是 metro 的问题(react-native-cli 底层打包工具)。具体可以查看这个 issue https://github.com/facebook/metro/issues/290#issuecomment-543746458 大概的原因是因为 metro 在打包资源的时候,会将资源的相对路径也编译进去,引用外层的资源时,路径中就会包含 ../ 然而最终生成的代码目录显然找不到这样的路径,由此加载会失败。怎么办呢,这个 issue 里有人提出用类似 dir 这样的特殊字符来替换 ../

我在尝试了上述 https://github.com/facebook/metro/issues/290#issuecomment-917695784 的方案后,开发时正常,但是在最终构建之后依然无法显示出来(时间有点久了,忘记具体原因了,大概原因还是打包后的路径依然不正确导致的)。
最终结合了 issue 里的方案,调整了个目前运行起来没问题的方案(不得不吐槽下 metro 的文档,有一些 api 就一笔带过,只能翻 issue 看用法)

// metro.config.js
const { withNxMetro } = require('@nrwl/react-native');
const { getDefaultConfig } = require('metro-config');
const exclusionList = require('metro-config/src/defaults/exclusionList');

module.exports = (async () => {
  const {
    resolver: { sourceExts, assetExts },
  } = await getDefaultConfig();
  return withNxMetro(
    {
	    // ...
        // 将请求资源的路径 ../ 替换成 dir/
        assetPlugins: [require.resolve('./metro-asset-plugin')],
      },
      server: {
        // 开发环境替换图片资源引用路径,否则无法找到正确的路径
        enhanceMiddleware: (middleware) => {
          return (req, res, next) => {
            if (req.url.indexOf('/dir') !== -1) {
              req.url = req.url.replace(/dir\//g, '../');
            }
            return middleware(req, res, next);
          };
        },
      },
      // ...
    },
    // ...
  );
})();


// metro-asset-plugin.js
module.exports = function (assetData) {
  if (assetData.httpServerLocation.indexOf('../') !== -1) {
    assetData.httpServerLocation = assetData.httpServerLocation.replace(
      /\.\.\//g,
      'dir/'
    );
  }
  return assetData;
};

核心就在于 server.enhanceMiddleware 与 transformer.assetPlugins 替换图片资源引用路径。

总结

上述仅简单概述了一下通过 monorepo 重构 RN 项目的过程,隐去了不少细节,但通过阅读文档应当也能摸索出具体操作,这里就不再赘述。
通过上述的重构之后,构建后用户端体积减少至原来的 30%,而医生端则降低到原来的 80%,效果还是比较显著的。
当然 nx 的作用不仅于此,因为不是重点,因此也不打算展开。