MontoApp(三) - 动态 script 方案
本节继续介绍微前端的另一种方案:动态 script。顾名思义,就是将当前的微应用打包后,将入口的 js 文件通过 script 加载进主应用,从而达到微前端的效果。他可能是目前实现起来最简单的一种方案。
仓库地址:tutorial/dynamic-script 分支
1. 主应用
我们用 node 启动一个主应用:
app.listen(port.main, host);
并在主应用里写入接口请求:
app.post("/microapps", function (req, res) {
// 这里可以是管理后台新增菜单后存储到数据库的数据
// 从而可以通过管理后台动态配置微应用的菜单
res.json([
{
name: "micro1",
id: "micro1",
// 这里暂时以一个入口文件为示例
script: `http://${host}:${port.micro}/micro1.js`,
style: `http://${host}:${port.micro}/micro1.css`,
// 挂载到 window 上的启动函数 window.micro1_mount
mount: "micro1_mount",
// 挂载到 window 上的启动函数 window.micro1_unmount
unmount: "micro1_unmount",
prefetch: true,
},
{
name: "micro2",
id: "micro2",
script: `http://${host}:${port.micro}/micro2.js`,
style: `http://${host}:${port.micro}/micro2.css`,
mount: "micro2_mount",
unmount: "micro2_unmount",
prefetch: true,
},
]);
});
上面配置的这两个子应用,自己各自单独在自己端口启动即可。(这里公用了一个子应用端口,实际情况可能子应用端口不一样)
然后配置主应用的 html,并配置加载功能:
<div class="container">
<!-- 微应用渲染的插槽 -->
<div id="micro-app-slot"></div>
</div>
loadScript({ script, id }) {
return new Promise((resolve, reject) => {
const $script = document.createElement("script");
$script.src = script;
$script.setAttribute("micro-script", id);
$script.onload = resolve;
$script.onerror = reject;
document.body.appendChild($script);
});
}
loadStyle({ style, id }) {
return new Promise((resolve, reject) => {
const $style = document.createElement("link");
$style.href = style;
$style.setAttribute("micro-style", id);
$style.rel = "stylesheet";
$style.onload = resolve;
$style.onerror = reject;
document.head.appendChild($style);
});
}
removeStyle({ id }) {
const $style = document.querySelector(`[micro-style=${id}]`);
$style && $style?.parentNode?.removeChild($style);
}
hasLoadScript({ id }) {
const $script = document.querySelector(`[micro-script=${id}]`);
return !!$script;
}
hasLoadStyle({ id }) {
const $style = document.querySelector(`[micro-style=${id}]`);
return !!$style;
}
主应用初始化:
init() {
this.processMicroApps();
this.navClickListener();
this.hashChangeListener();
}
processMicroApps() {
this.getMicroApps().then((res) => {
this.microApps = res;
this.prefetchMicroAppStatic();
this.createMicroAppNav();
});
}
prefetchMicroAppStatic() {
const prefetchMicroApps = this.microApps?.filter(
(microapp) => microapp.prefetch
);
prefetchMicroApps?.forEach((microApp) => {
microApp.script && this.prefetchStatic(microApp.script, "script");
microApp.style && this.prefetchStatic(microApp.style, "style");
});
}
createMicroAppNav(microApps) {
const fragment = new DocumentFragment();
this.microApps?.forEach((microApp) => {
// TODO: APP 数据规范检测 (例如是否有 script. mount. unmount 等)
const button = document.createElement("button");
button.textContent = microApp.name;
button.id = microApp.id;
fragment.appendChild(button);
});
nav.appendChild(fragment);
}
其中 getMicroApps 是服务获取微应用列表信息,主应用姑且使用 hashchange 配置路由检测:
hashChangeListener() {
// 监听 Hash 路由的变化,切换微应用
// 这里设定一个时刻页面上只有一个微应用
window.addEventListener("hashchange", () => {
this.microApps?.forEach(async (microApp) => {
// 匹配需要激活的微应用
if (microApp.id === window.location.hash.replace("#", "")) {
console.time(`fetch microapp ${microApp.name} static`);
// 加载 CSS 样式
microApp?.style &&
!this.hasLoadStyle(microApp) &&
(await this.loadStyle(microApp));
// 加载 Script 标签
microApp?.script &&
!this.hasLoadScript(microApp) &&
(await this.loadScript(microApp));
console.timeEnd(`fetch microapp ${microApp.name} static`);
window?.[microApp.mount]?.("#micro-app-slot");
// 如果存在卸载 API 则进行应用卸载处理
} else {
this.removeStyle(microApp);
window?.[microApp.unmount]?.();
}
});
});
}
这里在子应用卸载时没有移除 js,是因为重新加载时顶级作用域会被重新执行,比如 let a = 1; 这样反复声明会报错。 同时,不写 removeScript,js 在
window
上挂载了子应用的mount
和unmount
方法也不会造成污染,没必要删除。
2. 子应用改造
当然,各个子应用入口文件要改造,暴露钩子函数:
// micro1.js
// 立即执行的匿名函数可以防止变量 root 产生冲突
(function () {
let root;
// 要保证各个子应用名称不一样
window.micro1_mount = function (slot) {
// 以下其实可以是 React 框架或者 Vue 框架生成的 Document 元素,这里只是做一个简单的示例
root = document.createElement("h1");
root.textContent = "微应用1";
// 在微应用插槽上挂载 DOM 元素
const $slot = document.querySelector(slot);
$slot?.appendChild(root);
};
window.micro1_unmount = function () {
if (!root) return;
root.parentNode?.removeChild(root);
};
})();
// micro1.css
h1 {
color: green;
}
同时子应用也要启动起来:
app.listen(port.micro, host);
这样,script 标签的 src 才能够去找到服务器上子应用的 js 入口文件。
本文用了hash路由配置切换,本身有设计缺陷,实战中需要自己设置更完善的路由方案。
基于以上描述,其具有以下优点:
- 主应用可以动态增减. 更新子应用的数量,实现动态部署。
- 微应用可以如正常应用一样进行优化,如代码分割,静态资源分离,CDN加速等。
- 不需要如NPM库一样,配置额外的配置文件使其打包成依赖库。
- 主子应用天然同域(除了加载js时要跨域)
其问题也是与npm方案一样:
- 主应用与微应用的全局变量. CSS样式. 本地存储的数据无法做到很好的隔离。
- 子应用需要改造适配,同 NPM 方案一样没办法完全解耦合。
与 NPM 方案差异:
- 本方案不用去除 script
- 本方案的 script 可以做 chunk,而NPM方案只能做到 Tree Shaking。动态 script 分成了多段 chunk,体积更少了,而 NPM 则是全量构建到包里的 (使用 require 自动引入全部包),此时性能优化上应该是有差异的。
但是感觉NPM方案,只要放入入口文件,是不是也能做到分chunk?比如这个不使用 require('react-micro-app'),而是使用动态引入:
import("react-micro-app").then(chunk => {})
打 chunk 是将懒加载. 公共依赖分离,是 webpack 自动化处理的过程。亲测会报错,目前还不知道可不可行。