项目A提供remote
项目B提使用remote
这里使用vite @originjs/vite-plugin-federation为例
源码
项目A
// index 提供的方法
const version = '1.0.0';
const name = 'project2'
const getVersion = () => version
const getName = () => name
export {
version,
name
}
export default {
getVersion,
getName
}
// vite配置
federation({
name: 'module-name',
filename: 'remoteEntry.js',
exposes: {
'./project2': './src/packages/index.ts',
},
})
项目B
// vite配置
federation({
remotes: {
'remotes': "http://localhost:5001/assets/remoteEntry.js",
}
}),
// 使用
import project2, {version} from 'remotes/project2'
console.log(project2, project2.getVersion(), version) // 打印出A提供的变量和方法
构建的结果
项目A
// remoteEntry.js 入口问价
// 这里构建的结果任然是es6 module形式
// __vitePreload 加载资源的方法
import { _ as __vitePreload } from './preload-helper.1c052cf7.js';
const exportSet = new Set(['Module', '__esModule', 'default', '_export_sfc']);
// 入口
let moduleMap = {
"./project2":() => {
dynamicLoadingCss([]);
// // 兼容处理,判断是直接返回module还是module.default 这里是具体的代码,会在remoteEntry中做加载
return __federation_import('./__federation_expose_Project2_39d66529.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)
}
};
// 缓存加载的文件
const seen = {};
// 加载css文件
const dynamicLoadingCss = (cssFilePaths) => {
const metaUrl = import.meta.url; // 这里是remoteEntre.js的路径
if (typeof metaUrl == 'undefined') { // 兼容问题,es必须>=2020版本
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
}
// 通过截取获取base路径
const curUrl = metaUrl.substring(0, metaUrl.lastIndexOf('remoteEntry.js'));
// 循环创建link标签加载css
cssFilePaths.forEach(cssFilePath => {
const href = curUrl + cssFilePath;
// 判断是否加载过
if (href in seen) return
seen[href] = true;
const element = document.head.appendChild(document.createElement('link'));
element.href = href;
element.rel = 'stylesheet';
});
};
async function __federation_import(name) {
return __vitePreload(() => import(name),true?[]:void 0);
}
const get =(module) => {
return moduleMap[module]();
};
// 加载外部依赖
const init =(shareScope) => {
// 这里globalThis === window https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/globalThis
// __federation_shared__是挂在在全局(window)上的一个变量用来记录已加载的shared资源
globalThis.__federation_shared__= globalThis.__federation_shared__|| {};
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;
});
};
// 这里暴露了三个方法,会在B应用中使用
export { dynamicLoadingCss, get, init };
// __federation_expose_Project2_39d66529.js
// 编写的代码的构建结果
const version = "1.0.0";
const name = "project2";
const getVersion = () => version;
const getName = () => name;
var index = {
getVersion,
getName
};
export { index as default, name, version };
项目B
const p = function polyfill() {
const relList = document.createElement('link').relList;
if (relList && relList.supports && relList.supports('modulepreload')) {
return;
}
for (const link of document.querySelectorAll('link[rel="modulepreload"]')) {
processPreload(link);
}
new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type !== 'childList') {
continue;
}
for (const node of mutation.addedNodes) {
if (node.tagName === 'LINK' && node.rel === 'modulepreload')
processPreload(node);
}
}
}).observe(document, { childList: true, subtree: true });
function getFetchOpts(script) {
const fetchOpts = {};
if (script.integrity)
fetchOpts.integrity = script.integrity;
if (script.referrerpolicy)
fetchOpts.referrerPolicy = script.referrerpolicy;
if (script.crossorigin === 'use-credentials')
fetchOpts.credentials = 'include';
else if (script.crossorigin === 'anonymous')
fetchOpts.credentials = 'omit';
else
fetchOpts.credentials = 'same-origin';
return fetchOpts;
}
function processPreload(link) {
if (link.ep)
// ep marker = processed
return;
link.ep = true;
// prepopulate the load record
const fetchOpts = getFetchOpts(link);
fetch(link.href, fetchOpts); // 预加载
}
};
p();
const scriptRel = 'modulepreload';
const seen = {}; // 缓存
const base = '/';
// 加载remote 通过link标签提前加载缓存下来
const __vitePreload = function preload(baseModule, deps) {
debugger
// @ts-ignore
if (!true || !deps || deps.length === 0) {
return baseModule();
}
return Promise.all(deps.map((dep) => {
debugger
// @ts-ignore
dep = `${base}${dep}`;
// @ts-ignore
if (dep in seen)
return;
// @ts-ignore
seen[dep] = true;
const isCss = dep.endsWith('.css');
const cssSelector = isCss ? '[rel="stylesheet"]' : '';
// @ts-ignore check if the file is already preloaded by SSR markup
if (document.querySelector(`link[href="${dep}"]${cssSelector}`)) {
return;
}
// @ts-ignore
const link = document.createElement('link');
// @ts-ignore
link.rel = isCss ? 'stylesheet' : scriptRel;
if (!isCss) {
link.as = 'script';
link.crossOrigin = '';
}
link.href = dep;
// @ts-ignore
document.head.appendChild(link);
if (isCss) {
return new Promise((res, rej) => {
link.addEventListener('load', res);
link.addEventListener('error', () => rej(new Error(`Unable to preload CSS for ${dep}`)));
});
}
})).then(() => {
debugger
return baseModule()
});
};
// 配置中的remote
const remotesMap = {
'remotes':{url:'http://localhost:5001/assets/remoteEntry.js',format:'esm',from:'vite'}
};
// 加载js的方法
const loadJS = async (url, fn) => {
const resolvedUrl = typeof url === 'function' ? await url() : url;
const script = document.createElement('script');
script.type = 'text/javascript';
script.onload = fn;
script.src = resolvedUrl;
document.getElementsByTagName('head')[0].appendChild(script);
};
const scriptTypes = ['var'];
const importTypes = ['esm', 'systemjs'];
// 外部依赖的js
const wrapShareModule = remoteFrom => {
return {
}
};
async function __federation_method_ensure(remoteId) {
const remote = remotesMap[remoteId];
if (!remote.inited) {
if (scriptTypes.includes(remote.format)) {
// umd模式,挂载到window上模式
// loading js with script tag
return new Promise(resolve => {
const callback = () => {
if (!remote.inited) {
remote.lib = window[remoteId];
remote.lib.init(wrapShareModule(remote.from));
remote.inited = true;
}
resolve(remote.lib);
};
return loadJS(remote.url, callback);
});
} else if (importTypes.includes(remote.format)) {
// loading js with 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) {
const shareScope = wrapShareModule(remote.from);
// 调用A提供的init方法
lib.init(shareScope);
remote.lib = lib;
remote.lib.init(shareScope);
remote.inited = true;
}
resolve(remote.lib);
});
});
})
}
} else {
return remote.lib;
}
}
// 兼容处理 module.default 和 module 这里加载export default导出的的模块
function __federation_method_unwrapDefault(module) {
return (module?.__esModule || module?.[Symbol.toStringTag] === 'Module') ? module.default : module
}
// 加载remotes资源 并返回 export {} 导出的对象集合
function __federation_method_getRemote(remoteName, componentName){
/** @description 调用remote的get方法 获取export {} 的方法 */
return __federation_method_ensure(remoteName).then((remote) => {
// 这里是A提供的get方法
return remote.get(componentName).then((factory) => {
return factory()
})
}
);
}
/**
* 这是写的源码转换的结果
* import project2, {version} from 'remotes/project2'
* console.log(project2, project2.getVersion(), version)
*/
const __federation_var_remotesproject2 = await __federation_method_getRemote("remotes" , "./project2");
let project2 = __federation_method_unwrapDefault(__federation_var_remotesproject2);
let {version} = __federation_var_remotesproject2;
console.log(project2, project2.getVersion(), version);
总结
所有的moderation的实现方式都类似:
- 加载远程模块
- 解析出远程模块的方法
- 调用远程方法
这里@originjs/vite-plugin-federation借用了es module的特性,对浏览器版本要求要求也比较高
加载远程模块的方式一般都是借助script标签,动态创建script标签插入到页面中,然后监听script的onload事件
实现一个load
// js加载器
// 这里简化了很多东西,只是为了描述下下实现逻辑。
const seen = {}; // 缓存
function loadJs(jsPath, async = false) {
const promise = new Promise((resolve, reject) => {
if(seen[jsPath]){
resolve();
}else {
const script = document.createElement('script');
script.src = jsPath;
script.async = async;
script.onload = resolve;
script.onerror = reject;
document.getElementsByTagName('head')[0].appendChild(script);
}
})
return promise
}
window.__federation__ = {
loadJs
}
// 提供方法的远程模块
const version = "1.0.0";
const name = "project2";
const getVersion = () => version;
const getName = () => name;
// 这里借助了widnow对象
window.__federation__project2 = {
name,
version,
getName,
getVersion
};
// 使用
window__federation__.loadJs('xxx.js').then(() => {
console.log(winddow__federation__project2.name, winddow__federation__project2.getName)
})
上面实现方式依赖了window上的变量,好处是使用了umd模式,浏览器兼容好,不好的地方时是污染window对象。如果想要像开始的@originjs/vite-plugin-federation那样,就需要借助es的特性,实现解析模块的代码。
这里还有个地方需要注意,script的async标签,这里采用的是异步模式,异步加载不影响页面加载和其他js的执行。有些情况是需要使用同步模式的,比如模块都依赖一个基础环境包(react、vue),那么这个基础包就要采用同步模式(script async设置为true),不然会出现子模块加载完,开始解析的时候没有找基础报的依赖的情况。同步加载会带来一个性能问题,就是http2的并行请求就用不了了。
实现一个异步load
window.__federation__project2 = {
// 不直接暴露方法,而是通过一个promise获取对应的方法,然后调用
// 应用只需要加载一个很小的“入口”文件(方法集合)
// 这样不仅实现了异步加载,还实现了按需加载调用
getGetNameFn: async() => {
await load('jsPath');
return winddow__federation__project2.getName
},
};
// 使用
window.__federation__project2.getGetNameFn().then((getName) => {
console.log(getName())
})
当然还有其他很多方式能实现异步效果,都比上面的方案更好。比如:通过观察者模式,通注册事件,在加载完成之后,发送消息,这个比较经典的工具是rxjs。甚至在进一步通过proxy重写对象的get,实现自动加载,就节省了上面手动导出getGetNameFn过程了。
解决了什么问题
相对npm包的形式,他提供了一种可配置化的模式,npm在每次发包的时候都需要经过构建和发布才能生效,federation实现了动态更新,没有特殊要求,模块发布更新了,应用里也就是更新了。当然如果有需要也可以通过配置方式实现版本控制、AB测试的能力