Monorepo 项目实现子包的热更新

567 阅读4分钟

问题

在 Monorepo 项目开发过程中,为了方便,我们一般都将当前仓库中的子包通过 workspace 的方式直接引用。但是子包对外的入口一般都是编译构建后的文件,我们用 workspace 方式引用的也是子包 build 后的文件。这就会导致我们每次修改子包的代码后,必须要重新 build 一下才能在父项目中生效。

分析

watch

我们是不是可以用 watch 模式启动那个子包,然后他自己就可以在改动后自动编译构建产物了?

但是这还是不完美。因为构建是一个比较耗时的操作,修改后还需要等待几秒到几十秒的延迟,而且由于子包连接在node_modules 里,一般工程中都会排除掉对它的监听,所以即使是产物变动了也不会热更新页面。

别名

在 vite 中我们可以通过配置 alias 别名来简化导入路径的书写。它的本质其实就是将导入路径中的别名字符串替换为对应的绝对路径地址。

例如有下面配置

export default defineConfig({
    resolve: {
        alias: {
          '@': path.resolve(__dirname, './src')
        }
    }
})

我们写的如下代码:

import xxx from '@/utils';

通过 vite 编译后就会变成:

import xxx from '/@fs/D:/xxx项目/src/utils';

那我们是不是也可以利用这个别名功能,将从子包构建目录导入的路径替换为从子包源码路径导入。

export default defineConfig({
    resolve: {
        alias: {
          '子包name': path.resolve(__dirname, '../子包目录/src/index.js')
        }
    }
})

通过配置面别名后,所有 from '子包name' 的引用都会被编译为 from '/@fs/D:/子包目录/src/index.js'。这就达到了直接引用子包源码的效果,也就实现了子包的热更新能力。

解决

上面在 vite 配置中自己一个个写子包的别名也还是比较麻烦。正好 vite 插件支持修改 vite 配置的,我们可以写一个插件来自动配置别名。

在插件中,我们需要得到当前工程中有哪些是通过 workspace 方式引入的子包。这个可以通过读取分析 package.json 中的 dependencies 字段来得到。

同时我们还需要得到子包的物理路径。这个可以用 @manypkg/get-packages 插件,它可以获取到当前仓库中的所有包的信息。

import path from 'path';
import { getPackages } from '@manypkg/get-packages';
import packageJson from './package.json';

// 构建工程中所有 workspace 引用子包的别名
async function collectEntryAlias() {
  // 获取仓库中的所有包的信息
  const workspaces = await getPackages(process.cwd());
  // 遍历 dependencies  
  return Object.entries(packageJson.dependencies).reduce((cur, pkg) => {
    const [name, version] = pkg;
    // 过滤出是 workspace 引用的包
    if (name && version.startsWith('workspace')) {
      // 读取该包的信息
      const packageObj = workspaces.packages.find((p) => p.packageJson.name === name);
      if (packageObj) {
        // 构建 alias
        cur.push({
          find: new RegExp(`^${name}$`),
          replacement: path.resolve(packageObj.dir, './src/index.js')
        });
      }
    }
    return cur;
  }, []);
}

这样我们就把别名配置构建出来了。然后我们来写插件。

export async function Aliases() {
  const aliases = await collectEntryAlias();

  return {
    name: 'aliases',
    enforce: 'pre',
    apply: 'serve', // 只有 dev 环境才需要
    config(config) {
      config.resolve = {
        ...config.resolve,
        alias: config.resolve?.alias ? [...toArray(config.resolve.alias), ...aliases] : aliases
      };
    }
  };
}

我们把插件配置上:

import { Aliases } from './src/plugin/aliases.js';

export default defineConfig({
  plugins: [
    vue(),
    Aliases()
  ],
  resolve: {
    extensions: ['.js', '.vue', '.ts'],
    alias: {
      '@': path.resolve(__dirname, './src')
    }
  }
});

查看效果又发现了一个问题:子包中配置的 @ 别名会被替换成当前父包工程的所在路径。这是由于我们运行的是子包的源码,但是上下文环境却是父包的造成的。

要解决这个问题,只有由我们自己来实现子包的别名替换功能。

vite 插件有个 transform 方法可以给我们来修改编译的代码,我们可以在这个方法里面来进行子包文件的别名替换。

首先我们需要知道有哪些子包,我们可以在上面构建别名配置的 collectEntryAlias 方法中用一个集合来记录下来。

const packageDirSet = new Set();

async function collectEntryAlias() {
  const workspaces = await getPackages(process.cwd());

  return Object.entries(packageJson.dependencies).reduce((cur, pkg) => {
    const [name, version] = pkg;

    if (name && version.startsWith('workspace')) {
      const packageObj = workspaces.packages.find((p) => p.packageJson.name === name);
      if (packageObj) {
        cur.push({
          find: new RegExp(`^${name}$`),
          replacement: path.resolve(packageObj.dir, './src/index.js')
        });
        packageDirSet.add(packageObj.dir);
      }
    }
    return cur;
  }, []);
}

在插件 transform 方法里我们就可以通过第二个参数当前处理的文件路径来判断是否是子包的文件。

export async function Aliases() {
  
  return {
    name: 'aliases',
    enforce: 'pre',
    apply: 'serve', // 只有 dev 环境才需要
    config(config) {},
    transform(code, id) {
      // 取根目录路径
      const dir = id.split('/src/')[0];
      const key = dir.replace(/\//g, '\\');
      if (packageDirSet.has(key)) {
        // 将引用地方的 `@` 别名替换为子包的绝对路径
        code = code.replace(/from '@\//g, `from '${dir}/src/`)
                    .replace(/import\('@\//g, `import('${dir}/src/`);
      }
      return {
        code
      };
    }
  };
}

总结

本文带大家分析了在 Monorepo 项目中如何实现子包的热更新方法。当然上面子包别名替换的实现还不够完美,只固定写死了对 @ 别名的处理,更好的是可以自动分析子包中别名的配置,来自动替换。感兴趣的小伙伴可以自己尝试实现一下。

如果文章中有不对、可以优化的地方欢迎在评论区指出,谢谢。