乐趣区

关于前端:如何优雅的实现跨应用的代码共享

在 2020 年上半年,Webpack 公布了一项十分激动人心的个性:Module Federation(译为模块联邦),这个个性一经推出就取得了业界的宽泛关注,甚至被称为前端构建畛域的 Game Changer。实际上,这项技术的确很好地解决了多利用模块复用的问题,相比之前的各种解决方案,它的解决形式更加优雅和灵便。但从另一个角度来说,Module Federation 代表的是一种通用的解决思路,并不局限于某一个特定的构建工具,因而,在 Vite 中咱们同样能够实现这个个性,并且社区曾经有了比拟成熟的解决方案。

一、模块共享之痛

对于一个互联网产品来说,个别会有不同的细分利用,比方腾讯文档能够分为 word、excel、ppt 等等品类,抖音 PC 站点能够分为短视频站点、直播站点、搜寻站点等子站点,而每个子站又彼此独立,可能由不同的开发团队进行独自的开发和保护,看似没有什么问题,但实际上会常常遇到一些模块共享的问题,也就是说不同利用中总会有一些共享的代码,比方公共组件、公共工具函数、公共第三方依赖等等。对于这些共享的代码,除了通过简略的复制粘贴,还有没有更好的复用伎俩呢?

上面介绍几种常见的代码复用伎俩:

1.1 公布 npm 包

公布 npm 包是一种常见的复用模块的做法,咱们能够将一些专用的代码封装为一个 npm 包,而后在其余我的项目中援用这个 npm 包。具体的公布更新流程如下:

  1. 公共库 lib1 改变,公布到 npm;
  2. 所有的利用装置新的依赖,并进行联调。

封装 npm 包能够解决模块复用的问题,但它自身又引入了新的问题:

  • 开发效率问题。每次改变都须要发版,并所有相干的利用装置新依赖,流程比较复杂。
  • 我的项目构建问题。引入了公共库之后,公共库的代码都须要打包到我的项目最初的产物后,导致产物体积偏大,构建速度绝对较慢。

因而,这种计划并不能作为最终计划,只是临时用来解决问题的无奈之举。
 

1.2 Git Submodule

通过 git submodule 的形式,咱们能够将代码封装成一个公共的 Git 仓库,而后复用到不同的利用中,但也须要经验如下的步骤:

  1. 公共库 lib1 改变,提交到 Git 近程仓库;
  2. 所有的利用通过 git submodule 命令更新子仓库代码,并进行联调。

能够看到,整体的流程其实跟发 npm 包相差无几,依然存在 npm 包计划所存在的各种问题。

1.3 依赖内部化 + CDN 引入

所谓依赖内部化(external),指的是对于某些第三方依赖咱们并不需要让其参加构建,而是应用某一份专用的代码。依照这个思路,咱们能够在构建引擎中对某些依赖申明 external,而后在 HTML 中退出依赖的 CDN 地址,比方:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite App</title>
  </head>
  <body>
    <div id="root"></div>
    <!-- 从 CDN 上引入第三方依赖的代码 -->
    <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>
  </body>
</html>

如下面的例子所示,咱们能够对 react 和 react-dom 应用 CDN 的形式引入,个别应用 UMD 格局产物,这样不同的我的项目间就能够通过 window.React 来应用同一份依赖的代码了,从而达到模块复用的成果。不过,这种计划也有肯定的局限性:

  • 兼容性问题。并不是所有的依赖都有 UMD 格局的产物,因而这种计划不能笼罩所有的第三方 npm 包。
  • 依赖程序问题。咱们通常须要思考间接依赖的问题,如对于 antd 组件库,它自身也依赖了 react 和 moment,那么 react 和 moment 也须要 external,并且在 HTML 中援用这些包,同时也要严格保障援用的程序,比如说 moment 如果放在了 antd 前面,代码可能无奈运行。而第三方包背地的间接依赖数量个别很宏大,如果一一解决,对于开发者来说几乎就是噩梦。
  • 产物体积问题。因为依赖包被申明 external 之后,利用在援用其 CDN 地址时,会全量援用依赖的代码,这种状况下就没有方法通过 Tree Shaking 来去除无用代码了,会导致利用的性能有所降落。

1.4 Monorepo

作为一种新的项目管理形式,Monorepo 也能够很好地解决模块复用的问题。在 Monorepo 架构下,多个我的项目能够放在同一个 Git 仓库中,各个相互依赖的子项目通过软链的形式进行调试,代码复用显得十分不便,如果有依赖的代码变动,那么用到这个依赖的我的项目当中会立马感知到。

不得不抵赖,对于利用间模块复用的问题,Monorepo 是一种十分优良的解决方案,但与此同时,它也有一些应用上的限度。

  • 所有的利用代码必须放到同一个仓库。如果是旧有我的项目,并且每个利用应用一个 Git 仓库的状况,那么应用 Monorepo 之后我的项目架构调整会比拟大,也就是说革新老本会绝对比拟高。
  • Monorepo 自身也存在一些人造的局限性,如我的项目数量多起来之后依赖安装时间会很久、我的项目整体构建工夫会变长等等,咱们也须要去解决这些局限性所带来的的开发效率问题。而这项工作个别须要投入业余的人去解决,如果没有足够的人员投入或者基建的保障,Monorepo 可能并不是一个很好的抉择。
  • 我的项目构建问题。跟 发 npm 包的计划一样,所有的公共代码都须要进入我的项目的构建流程中,产物体积还是会偏大。
     

二、Module Federation 外围概念

上面咱们就来正式介绍 Module Federation,即模块联邦解决方案,看看它到底是如何解决模块复用问题的。模块联邦中次要有两种模块: 本地模块和近程模块。

本地模块即为一般模块,是以后构建流程中的一部分,而近程模块不属于以后构建流程,在本地模块的运行时进行导入,同时本地模块和近程模块能够共享某些依赖的代码,如下图所示:

值得强调的是,在模块联邦中,每个模块既能够是本地模块,并导入其它的近程模块,又能够作为近程模块,被其余的模块导入。如上面这个例子所示:

以上就是模块联邦的次要设计原理,当初咱们来好好剖析一下这种设计到底有哪些劣势:

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

从以上的剖析你能够看到,模块联邦近乎完满地解决了以往模块共享的问题,甚至可能实现利用级别的共享,进而达到微前端的成果。上面,咱们就来以具体的例子来学习在 Vite 中如何应用模块联邦的能力来解决代码复用。

三、Module Federation 利用实战

社区中曾经提供了一个比拟成熟的 Vite 模块联邦计划: vite-plugin-federation,这个计划基于 Vite(或者 Rollup) 实现了残缺的模块联邦能力。接下来,咱们基于它来实现模块联邦利用。首先,初始化两个 Vue 的脚手架我的项目 host 和 remote,而后别离装置 vite-plugin-federation 插件,命令如下:

npm install @originjs/vite-plugin-federation -D

而后在配置文件 vite.config.ts 中退出如下的配置代码:

// 近程模块配置
// remote/vite.config.ts
import {defineConfig} from "vite";
import vue from "@vitejs/plugin-vue";
import federation from "@originjs/vite-plugin-federation";


// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue(),
    // 模块联邦配置
    federation({
      name: "remote_app",
      filename: "remoteEntry.js",
      // 导出模块申明
      exposes: {
        "./Button": "./src/components/Button.js",
        "./App": "./src/App.vue",
        "./utils": "./src/utils.ts",
      },
      // 共享依赖申明
      shared: ["vue"],
    }),
  ],
  // 打包配置
  build: {target: "esnext",},
});


// 本地模块配置
// host/vite.config.ts
import {defineConfig} from "vite";
import vue from "@vitejs/plugin-vue";
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",},
});

在如上的配置中,咱们实现了近程模块的模块导出及近程模块在本地模块的注册,对于近程模块的具体实现,能够参考 Github 仓库的代码。接下来,咱们起点关注下如何应用近程模块。

首先,咱们须要对近程模块进行打包,在 remote 门路下依赖执行命令:

// 打包产物
pnpm run build
// 模仿部署成果,个别会在生产环境将产物上传到 CDN 
npx vite preview --port=3001 --strictPort

而后,咱们在 host 我的项目中应用近程模块,示例代码如下。

<script setup lang="ts">
import HelloWorld from "./components/HelloWorld.vue";
import {defineAsyncComponent} from "vue";
// 导入近程模块
// 1. 组件
import RemoteApp from "remote_app/App";
// 2. 工具函数
import {add} from "remote_app/utils";
// 3. 异步组件
const AysncRemoteButton = defineAsyncComponent(() => import("remote_app/Button")
);
const data: number = add(1, 2);
</script>


<template>
  <div>
    <img alt="Vue logo" src="./assets/logo.png" />
    <HelloWorld />
    <RemoteApp />
    <AysncRemoteButton />
    <p> 利用 2 工具函数计算结果: 1 + 2 = {{data}}</p>
  </div>
</template>


<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}
</style>

而后,应用 npm run dev 启动我的项目后就能够看到如下的后果。

利用 2 的组件和工具函数逻辑曾经在利用 1 中失效,也就是说,咱们实现了近程模块在本地模块的运行时引入。让咱们来梳理一下整体的应用流程:

  1. 近程模块通过 exposes 注册导出的模块,本地模块通过 remotes 注册近程模块地址。
  2. 近程模块进行构建,并部署到云端。
  3. 本地通过 import ‘ 近程模块名称 /xxx’ 的形式来引入近程模块,实现运行时加载。

四、Module Federation 实现原理

从以上示例中大家能够看到,Module Federation 应用比较简单,对已有我的项目来说革新老本并不大。那么,这么弱小而易用的个性是如何在 Vite 中得以实现的呢?接下来,咱们来深刻探索一下 MF 背地的实现原理,剖析 vite-plugin-federation 这个插件背地到底做了些什么。
 

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

  • Host 模块: 即本地模块,用来生产近程模块。
  • Remote 模块: 即近程模块,用来生产一些模块,并裸露运行时容器供本地模块生产。
  • Shared 依赖: 即共享依赖,用来在本地模块和近程模块中实现第三方依赖的共享。
     

首先,咱们来看看本地模块是如何生产近程模块的。之前,咱们在本地模块中写过这样的引入语句。

import RemoteApp from "remote_app/App";

咱们来看看 Vite 将这段代码编译成了什么样子呢。

// 为了不便浏览,以下局部办法的函数名进行了简化
// 近程模块表
const remotesMap = {'remote_app':{url:'http://localhost:3001/assets/remoteEntry.js',format:'esm',from:'vite'},
  'shared':{url:'vue',format:'esm',from:'vite'}
};


async function ensure() {const remote = remoteMap[remoteId];
  // 做一些初始化逻辑,临时疏忽
  // 返回的是运行时容器
}


async function getRemote(remoteName, componentName) {return ensure(remoteName)
    // 从运行时容器外面获取近程模块
    .then(remote => remote.get(componentName))
    .then(factory => factory());
}


// import 语句被编译成了这样
// tip: es2020 产物语法曾经反对顶层 await
const __remote_appApp = await getRemote("remote_app" , "./App");

能够看到,除了 import 语句被编译之外,在代码中还增加了 remoteMap 和一些工具函数,它们的目标很简略,就是通过拜访远端的运行时容器来拉取对应名称的模块。而运行时容器其实就是指近程模块打包产物 remoteEntry.js 的导出对象,咱们来看看它的逻辑是怎么的:

// remoteEntry.js
const moduleMap = {"./Button": () => {return import('./__federation_expose_Button.js').then(module => () => module)
  },
  "./App": () => {dynamicLoadingCss('./__federation_expose_App.css');
    return import('./__federation_expose_App.js').then(module => () => module);
  },
  './utils': () => {return import('./__federation_expose_Utils.js').then(module => () => module);
  }
};


// 加载 css
const dynamicLoadingCss = (cssFilePath) => {
  const metaUrl = import.meta.url;
  if (typeof metaUrl == 'undefined') {console.warn('The remote style takes effect only when the build.target option in the vite.config.ts file is higher than that of"es2020".');
    return
  }
  const curUrl = metaUrl.substring(0, metaUrl.lastIndexOf('remoteEntry.js'));
  const element = document.head.appendChild(document.createElement('link'));
  element.href = curUrl + cssFilePath;
  element.rel = 'stylesheet';
};


// 要害办法,裸露模块
const get =(module) => {return moduleMap[module]();};


const init = () => {// 初始化逻辑,用于共享模块,临时省略}


export {dynamicLoadingCss, get, init}

从运行时容器的代码中咱们能够得出一些要害的信息:

  • moduleMap 用来记录导出模块的信息,所有在 exposes 参数中申明的模块都会打包成独自的文件,而后通过 dynamic import 进行导入。
  • 容器导出了非常要害的 get 办法,让本地模块可能通过调用这个办法来拜访到该近程模块。

至此,咱们就梳理分明了近程模块的运行时容器与本地模块的交互流程,如下图所示。

接下来,咱们持续剖析共享依赖的实现。拿之前的示例我的项目来说,本地模块设置了 shared: [‘vue’]参数之后,当它执行近程模块代码的时候,一旦遇到了引入 vue 的状况,会优先应用本地的 vue,而不是远端模块中的 vue。

让咱们把焦点放到容器初始化的逻辑中,回到本地模块编译后的 ensure 函数逻辑。

// host


// 上面是共享依赖表。每个共享依赖都会独自打包
const shareScope = {'vue':{'3.2.31':{get:()=>get('./__federation_shared_vue.js'), loaded:1}}
};
async function ensure(remoteId) {const remote = remotesMap[remoteId];
  if (remote.inited) {
    return new Promise(resolve => {if (!remote.inited) {remote.lib = window[remoteId];
          remote.lib.init(shareScope);
          remote.inited = true;
        }
        resolve(remote.lib);
    });
  }
}

能够发现,ensure 函数的次要逻辑是将共享依赖信息传递给近程模块的运行时容器,并进行容器的初始化。接下来咱们进入容器初始化的逻辑 init 中。

const init =(shareScope) => {globalThis.__federation_shared__= globalThis.__federation_shared__|| {};
  // 上面的逻辑大家不必深究,作用很简略,就是将本地模块的 ` 共享模块表 ` 绑定到近程模块的全局 window 对象上
  Object.entries(shareScope).forEach(([key, value]) => {const versionKey = Object.keys(value)[0];
    const versionValue = Object.values(value)[0];
    const scope = versionValue.scope || 'default';
    globalThis.__federation_shared__[scope] = globalThis.__federation_shared__[scope] || {};
    const shared= globalThis.__federation_shared__[scope];
    (shared[key] = shared[key]||{})[versionKey] = versionValue;
  });
};

当本地模块的共享依赖表可能在近程模块拜访时,近程模块内也就可能应用本地模块的依赖 (如 vue) 了。当初咱们来看看近程模块中对于 import {h} from ‘vue’ 这种引入代码被转换成了什么样子了呢,如下所示。

// __federation_expose_Button.js
import {importShared} from './__federation_fn_import.js'
const {h} = await importShared('vue')

不难看到,第三方依赖模块的解决逻辑都集中到了 importShared 函数,让咱们来一探到底。

// __federation_fn_import.js
const moduleMap= {
  'vue': {get:()=>()=>__federation_import('./__federation_shared_vue.js'),
     import:true
   }
};
// 第三方模块缓存
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) {
  // 从 window 对象中寻找第三方包的包名,如果发现有挂载,则获取本地模块的依赖
  if (xxx) {return await getHostDep();
  } else {return getConsumerSharedModule(name); 
  }
}


async function getConsumerSharedModule(name , shareScope) {if (moduleMap[name]?.import) {const module = (await moduleMap[name].get())();
    moduleCache[name] = module;
    return module;
  } else {console.error(`consumer config import=false,so cant use callback shared module`);
  }
}

因为近程模块运行时容器初始化时曾经挂载了共享依赖的信息,近程模块外部能够很不便的感知到以后的依赖是不是共享依赖,如果是共享依赖则应用本地模块的依赖代码,否则应用近程模块本身的依赖产物代码,示意图如下。

五、小结

首先,我给你介绍了模块复用的问题有哪些历史解决方案,次要包含公布 npm 包、Git Submodule、依赖内部化 + CDN 导入和 Monorepo 架构,也剖析了各自的劣势与局限性,而后引出 Module Federation(MF) 的概念,并剖析了它为什么能近乎完满地解决模块共享问题,次要起因包含实现了任意粒度的模块共享、缩小构建产物体积、运行时按需加载以及共享第三方依赖这四个方面。

 
接下来,我用一个具体的我的项目示例来通知你如何在 Vite 中应用模块联邦的个性,即通过 vite-plugin-federation 这个插件来实现 MF 的搭建。最初,我也给你具体介绍了 MF 底层的实现原理,从本地模块、近程模块、共享依赖三个视角来给你分析 MF 的实现机制和外围编译逻辑。

退出移动版