likes
comments
collection
share

Vite - 模块联邦

作者站长头像
站长
· 阅读数 10

现有的模块复用解决方案

发布 npm 包

将一些公共代码封装到一起,发布成一个 npm 包进行模块共享。

npm 包的发布更新流程大概如下:

Vite - 模块联邦

缺点:

  1. 开发效率问题:每次改动都需要发版,相关应用需要安装新依赖,流程比较复杂
  2. 项目构建问题。引入了公共库之后,公共库的代码都需要打包到项目最后的产物中,导致产物体积偏大,构建速度相对较慢。当然,tree shaking能解决一些体积的问题

Git Submodule

Submodule翻译过来就是“子模块”的意思,Git Submodule 就是在一个仓库中嵌套另一个仓库。

特点:

  • 相互独立:主仓库和子仓库都有各自的git记录,克隆主仓库时不会克隆子仓库的文件,只是克隆一个快链(gitlink)
  • 便于维护:不需要再把子仓库的文件拷贝过来

通过 Git Submodule 的方式来封装公共代码库,然后进行复用,大概的流程如下:

  1. 公共库 lib1 改动,提交到 Git 远程仓库
  2. 主应用通过git命令更新子仓库内容,然后进行联调

整体流程跟npm包没有太大的区别。

依赖外部化(external)+ CDN 引入

Vite中,先使用external排除要打包的依赖:

// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      external: ['react', 'react-dom'],
    },
  },
});

然后在index.html中通过<script>引入 CDN 中的第三方依赖,从而达到模块复用的效果。

<!-- index.html -->
<script src="https://cdn.jsdelivr.net/npm/react@17.0.2/index.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/react-dom@17.0.2/index.min.js"></script>

缺点:

  1. 兼容性问题。并不是所有的依赖都有UMD格式的产物,因此这种方案不能覆盖所有的第三方npm
  2. 依赖顺序问题。我们通常需要考虑间接依赖的问题,如对于antd组件库,它本身也依赖了reactmoment,那么reactmoment也要进行外部依赖处理,并且在HTML中引用这些包,同时也要严格保证引用的顺序,比如说moment如果放在了antd后面,代码可能无法运行。而第三方包背后的间接依赖数量一般很庞大,如果逐个处理,对于开发者来说简直就是噩梦
  3. 产物体积问题。因为是全量引入模块代码,就没有办法通过Tree Shaking来去除无用代码了,会导致应用的性能有所下降

Monorepo

Monorepo 是一种项目管理方式,将多个项目放在同一个仓库中进行管理,互相依赖的子项目通过软链的方式进行调试,代码复用显得非常方便,如果有依赖的代码变动,那么用到这个依赖的项目当中会立马感知到。

Vite - 模块联邦


缺点:

  1. 都在同一个仓库。如果是旧有项目,并且每个应用使用一个 Git 仓库的情况,那么使用 Monorepo 之后项目架构调整会比较大,也就是说改造成本会相对比较高
  2. 项目构建问题。跟发npm包的方案一样,所有的公共代码都需要进入项目的构建流程中,产物体积还是会偏大
  3. 开发效率问题。Monorepo 本身也存在一些天然的局限性,如项目数量多起来之后依赖安装时间会很久、项目整体构建时间会变长等等,我们也需要去解决这些局限性所带来的的开发效率问题。而这项工作一般需要投入专业的人去解决,如果没有足够的人员投入或者基建的保证,Monorepo 可能并不是一个很好的选择

模块联邦

概述

模块联邦(Module Federation)由Webpack在 2020 年上半年提出。

MF主要有两种模块:本地模块远程模块

本地模块就是当前构建流程中的一部分,而远程模块是在本地模块运行时进行导入,本地模块和远程模块之间又可以共享公共的依赖。

Vite - 模块联邦

在模块联邦中,每个模块既可以是本地模块,也可以是远程模块。

Vite - 模块联邦

模块联邦的优势如下:

  1. 任意粒度的模块共享。这里所指的模块粒度可大可小,包括第三方 npm 依赖、业务组件、工具函数,甚至可以是整个前端应用!而整个前端应用能够共享产物,代表着各个应用单独开发、测试、部署,这也是一种微前端的实现
  2. 优化构建产物体积。远程模块可以在本地模块运行时被拉取,而不用参与本地模块的构建,可以加速构建过程,同时也能减小构建产物
  3. 运行时按需加载。远程模块导入的粒度可以很小,如果你只想使用app1模块的add函数,只需要在app1的构建配置中导出这个函数,然后在本地模块中按照诸如import('app1/add')的方式导入即可,这样就很好地实现了模块按需加载
  4. 第三方依赖共享。通过模块联邦中的共享依赖机制,我们可以很方便地实现在模块间公用依赖代码,从而避免以往的external + CDN引入方案的各种问题

在 Vite 中应用

源码仓库:module-federation

Vite 社区已经提供了一个成熟的解决方案,就是插件 vite-plugin-federation,通过使用该插件来实现模块联邦。

该插件不只是在 Vite 中使用,在Rollup中也可以使用。与 Webpack Module Federation 兼容,也就是说,不同构建工具实现的模块联邦是可以兼容的,当然也有一些限制,详细的可以看下插件的文档说明。

在需要实现模块联邦的项目中安装插件:

npm install @originjs/vite-plugin-federation --save-dev

使用Vite创建一个本地模块项目host,进行如下配置:

// vite.config.ts
import federation from '@originjs/vite-plugin-federation';

export default defineConfig({
  plugins: [
    vue(),
    federation({
      // 定义远程模块入口
      remotes: {
        'remote-app': 'http://localhost:3001/assets/remoteEntry.js',
      },
      // 共享依赖声明
      shared: ['vue'],
    }),
  ],

  build: {
    target: 'esnext',
  },
});

再创建一个远程模块项目remote,配置如下:

// vite.config.ts
import federation from '@originjs/vite-plugin-federation';

export default defineConfig({
  plugins: [
    vue(),
    federation({
      name: 'remote-app',
      // 模块入口文件
      filename: 'remoteEntry.js',
      // 导出模块声明
      exposes: {
        './Button': './src/components/Button.js',
        './App': './src/App.vue',
        './utils': './src/util.ts',
      },
      // 共享依赖声明
      shared: ['vue'],
    }),
  ],

  build: {
    target: 'esnext',
  },
});

对远程模块所属项目remote进行模拟部署:

# 打包
npm run build

# 预览导包后的产物,模拟部署
npx vite preview --port=3001 --strictPort

回到本地模块项目host中,在App.vue中使用远程模块:

<!-- App.vue -->
<template>
  <img alt="Vue logo" src="./assets/logo.png" />
  <HelloWorld msg="Hello Vue 3 + TypeScript + Vite" />
  <RemoteApp />
  <RemoteButton />
  <p>
    <b>使用远程模块的 add 函数计算 1 + 1 = {{ add(1, 1) }}</b>
  </p>
</template>

<script setup lang="ts">
  import HelloWorld from './components/HelloWorld.vue';
  import { defineAsyncComponent } from 'vue';
  // 下面是导入远程模块
  import RemoteApp from 'remote-app/App';
  import { add } from 'remote-app/utils';

  const RemoteButton = defineAsyncComponent(() => import('remote-app/Button'));
</script>

运行host项目看效果:

Vite - 模块联邦

模块联邦使用的整体流程如下:

  1. 远程模块通过exposes注册导出的模块,本地模块通过remotes注册远程模块地址
  2. 远程模块进行编译打包,部署到服务器
  3. 本地模块通过import导入模块使用,例如:import { add } from 'remote-app/utils'

实现原理

在 Vite 中,模块联邦的功能由插件 vite-plugin-federation 来实现的,下面来看看它的实现原理。

总体而言,实现模块联邦有三大主要要素:

  • 本地模块:用来消费远程模块
  • 远程模块:用来生产一些模块,并暴露运行时容器供本地模块消费
  • 共享依赖:用来在本地模块和远程模块中实现第三方依赖的共享

本地模块和远程模块编译后产生了这些文件:

本地模块项目:

Vite - 模块联邦

远程模块项目:

Vite - 模块联邦

本地模块

在本地模块中,导入了远程模块的 App 组件:

// host/src/App.vue
import RemoteApp from 'remote-app/App';

该语句编译后:

// host/dist/assets/index.b72ad8c4.js
// 简化了部分函数名称

// 远程模块表
const remotesMap = {
  'remote_app':{url:'http://localhost:3001/assets/remoteEntry.js',format:'esm',from:'vite'},
};

// 该方法会在 ensure 函数中进行调用,进行初始化操作
const wrapShareModule = remoteFrom => {
  // remoteFrom 可以是 vite 也可以是 webpack,get 方法获取模块时会进行判断处理
  return {
    'vue':{'3.2.37':{get:()=>get('./__federation_shared_vue.js', remoteFrom), loaded:1}}
  }
};

// 初始化操作,加载远程模块
async function ensure(remoteId) {
  const remote = remoteMap[remoteId];
  if (!remote.inited) {
    // 进行初始化操作
    if (scriptTypes.includes(remote.format)) {
      // 通过 <script> 加载模块
      return new Promise(resolve => {
        const callback = () => {
          if (!remote.inited) {
            // 初始化逻辑,lib 就是远程模块入口
            remote.lib = window[remoteId];
            // 共享依赖(Vue)
            remote.lib.init(wrapShareModule(remote.from));
            remote.inited = true;
          }
          resolve(remote.lib);
        };
        return loadJS(remote.url, callback);
      });
    } else if (importTypes.includes(remote.format)) {
      // 通过 import 加载模块
      return new Promise(resolve => {
        const getUrl = typeof remote.url === 'function' ? remote.url : () => Promise.resolve(remote.url);
        getUrl().then(url => {
          __vitePreload(() => import(/* @vite-ignore */ url),true?[]:void 0).then(lib => {
            if (!remote.inited) {
              // 初始化逻辑,lib 就是远程模块入口
              const shareScope = wrapShareModule(remote.from);
              lib.init(shareScope);
              remote.lib = lib;
              // 共享依赖(Vue)
              remote.lib.init(shareScope);
              remote.inited = true;
            }
            resolve(remote.lib);
          });
        });
      })
    }
  } else {
    // 直接返回远程模块
    return remote.lib;
  }
}

// 获取指定的模块
function getRemote(remoteName,  componentName){
  return ensure(remoteName)
    		.then(
          remote => remote.get(componentName).then(factory => factory())
        );
}

// import 语句被编译后:
const App = await getRemote("remote-app" , "./App");

针对import语句进行了处理,然后通过一些工具函数来从远程模块中获取需要的模块数据。

远程模块

而远程模块中的代码编译后如下:

// remote/dist/assets/remoteEntry.js
// remoteEntry.js 是在配置文件中声明的模块文件入口

// 模块信息
let moduleMap = {
  './App': () => {
    dynamicLoadingCss(['__federation_expose_App.fb379786.css']);
    return __federation_import('./__federation_expose_App.93722fe1.js').then(
      (module) =>
        Object.keys(module).every((item) => exportSet.has(item))
          ? () => module.default
          : () => module
    );
  },
  // Button 和 utils 的处理逻辑跟 App 一样,只是加载文件不同
  './Button': () => { /* ... */ },
  './utils': () => { /* ... */ },
};

// 使用 <link> 加载 css
const dynamicLoadingCss = (cssFilePaths) => { /* ... */ }

// 关键方法,暴露模块
const get = (module) => {
  return moduleMap[module]();
};

// 该方法在上面的本地模块中进行初始化时会进行调用
const init = (shareScope) => {
  // 初始化逻辑,用于共享模块,例如配置文件中的共享依赖 Vue
}

export { dynamicLoadingCss, get, init }

远程模块会记录好模块信息,提供共享依赖、模块获取等工具函数。

App.vue模块中,导入vue的语句被编译成了这样:

// remote/dist/assets/__federation_expose_App.93722fe1.js
import {importShared} from './__federation_fn_import.js'
const { /* ... */ } = await importShared('vue')

上面代码中,共享依赖vue通过函数importShared进行获取了:

// remote/dist/assets/__federation_fn_import.js

const moduleCache = Object.create(null);

// 获取共享依赖
async function importShared(name, shareScope = 'default') {
  return moduleCache[name]
    ? new Promise((r) => r(moduleCache[name]))
    : getProviderSharedModule(name, shareScope);
}

// 初始化共享依赖,并进行保存
async function getProviderSharedModule(name, shareScope) {
  let module = null;
  if (globalThis?.__federation_shared__?.[shareScope]?.[name]) {
    // 获取本地模块共享的依赖,例如 vue
  }
  if (module) {
    moduleCache[name] = module;
    return module;
  } else {
    // 本地模块中没有共享依赖,那就用自己的
    return getConsumerSharedModule(name);
  }
}

// 共享依赖信息
const moduleMap = {
  vue: {
    get: () => () => __federation_import('./__federation_shared_vue.js'),
    import: true,
  },
};
// 当本地模块没有提供对应的共享依赖时,远程模块就使用自己的共享依赖,例如 vue
async function getConsumerSharedModule(name, shareScope) {
  if (moduleMap[name]?.import) {
    const module = (await moduleMap[name].get())();
    moduleCache[name] = module;
    return module;
  } // ...
}

总结

Vite - 模块联邦