跳到主要内容

认识

2023年11月29日
柏拉文
越努力,越幸运

一、认识


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

  1. Host模块: 即本地模块,用来消费远程模块。

  2. Remote模块: 即远程模块,用来生产一些模块,并暴露运行时容器供本地模块消费。

  3. Shared依赖: 即共享依赖,用来在本地模块和远程模块中实现第三方依赖的共享。

二、细节


2.1 消费远程模块

首先,我们来看看本地模块是如何消费远程模块的。之前,我们在本地模块中写过这样的引入语句:

import RemoteApp from "remote_app/App";

import RemoteApp from "remote_app/App"; 编译如下:

// 远程模块表
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和一些工具函数,它们的目的很简单,就是通过访问远端的运行时容器来拉取对应名称的模块。

2.2 远程生产模块

运行时容器其实就是指远程模块打包产物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 }

从运行时容器的代码中我们可以得出一些关键的信息:

  1. moduleMap用来记录导出模块的信息,所有在exposes参数中声明的模块都会打包成单独的文件,然后通过 dynamic import 进行导入。

  2. 容器导出了十分关键的get方法,让本地模块能够通过调用这个方法来访问到该远程模块。

至此,我们就梳理清楚了远程模块的运行时容器与本地模块的交互流程,如下图所示

Preview

2.3 共享依赖模块

本地模块设置了shared: ['vue']参数之后,当它执行远程模块代码的时候,一旦遇到了引入vue的情况,会优先使用本地的 vue,而不是远端模块中的vue

Preview

让我们把焦点放到容器初始化的逻辑中,回到本地模块编译后的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 => {
.then(lib => {
// lib 即运行时容器对象
if (!remote.inited) {
remote.lib = lib;
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`);
}
}

由于远程模块运行时容器初始化时已经挂载了共享依赖的信息,远程模块内部可以很方便的感知到当前的依赖是不是共享依赖,如果是共享依赖则使用本地模块的依赖代码,否则使用远程模块自身的依赖产物代码。最后我画了一张流程图,你可以参考学习:

Preview

参考资料


模块联邦: 如何实现优雅的跨应用代码共享