Qiankun实践——加载一个微应用
前言
哈喽,大家好,我是海怪。
上两篇文章分别带大家手写了 Qiankun 的 JS 沙箱以及 CSS 沙箱。这两次实践都是分开做的,比较割裂,做完一遍可以加深对沙箱的理解,但具体 Qiankun 怎么把它们用在加载微应用上的,可能还有些疑惑。
因此,今天直接带大家手写一个加载微应用的函数,直接用示例来加深大家对加载微应用的理解。
本次实践依然以简单理解为主,源码的 loadMicroApp
内容是比较多的,而且会涉及到 single-spa 以及 import-html-entry 的内容,所以为更好理解以及篇幅限制,我这里依然做了精简。
文章代码会放在我的 这个仓库 mini-load-micro-app 中,需要的自行提取即可。那废话不多说,我们直接开始吧。
准备工作
首先,我们来做一些必要的准备工作。这里需要两个 HTML,一个为主应用,另一个为微应用。在主应用里我们要添加一个存放微应用的容器,以及一些交互按钮:
<html lang="en">
<head>
<meta charset="UTF-8">
<title>主应用</title>
</head>
<body>
<h1>这里的主应用</h1>
<button onclick="loadApp()">加载子应用</button>
<button onclick="unloadApp()">卸载子应用</button>
<div id="container"></div>
<!-- css-isolation -->
<script src="./css-isolation/scopedCSSIsolation.js"></script>
<!-- js-sandbox -->
<script src="./js-sandbox/SnapshotSandbox.js"></script>
<!-- load -->
<script src="./load/index.js"></script>
<!--测试代码-->
<script>
function loadApp() {
// 这里选择自己的 microApp.html 的本地地址
loadMicroApp('#container', 'microApp', 'http://localhost:63342/loadMicroApp/microApp.html');
}
function unloadApp() {
document.querySelector('#container').innerHTML = '';
}
</script>
</body>
</html>
其中
http://localhost:63342/loadMicroApp/microApp.html
填写的是微应用的本地地址,大家按自己的本地微应用地址来写就好了。我这里用了 WebStorm 自带的 Http 服务器,所以端口是随机分配的。
微应用里,我们引入 Bootstrap 样式,以及写些登录页的内容:
<html lang="en">
<head>
<meta charset="UTF-8">
<title>登录页</title>
<style>
h1 {
color: red;
}
</style>
<!-- Bootstrap -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.1/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<h1>登录页</h1>
<p>请输入你的账号和密码</p>
<div>
<label for="account">
账号:
<input id="account" type="text">
</label>
<label for="password">
账号:
<input id="password" type="password">
</label>
</div>
<button class="btn btn-primary" onclick="login()">登录</button>
<script>
console.log('我是微应用');
window.login = () => {
const account = document.querySelector('#account').value;
const password = document.querySelector('#password').value;
alert(`账号:${account},密码:${password}`);
}
</script>
</body>
</html>
现在登录页看起来是这样的:
对于沙箱部分,这里我简单抽取了 CSS 的 Scoped CSS 沙箱
以及 JS 的 Snapshot 沙箱
,代码如下:
// 多种规则
const RuleType = {
STYLE: 1,
MEDIA: 4,
SUPPORTS: 12,
}
function ruleStyle(rule, prefix) {
//匹配 p {..., a { ..., span {... 这类字符串
return rule.cssText.replace(/^[\s\S]+{/, (selectors) => {
// 匹配 div,body,span {... 这类字符串
return selectors.replace(/(^|,\n?)([^,]+)/g, (selector, _, matchedString) => {
// 将 p { => div[data-app-name=微应用名] p {
return `${prefix} ${matchedString.replace(/^ */, '')}`;
})
});
}
function rewrite(rules, prefix) {
let css = '';
rules.forEach((rule) => {
switch (rule.type) {
case RuleType.STYLE:
css += ruleStyle(rule, prefix);
break;
// case RuleType.MEDIA:
// css += this.ruleMedia(rule, prefix);
// break;
// case RuleType.SUPPORTS:
// css += this.ruleSupport(rule, prefix);
// break;
default:
css += `${rule.cssText}`;
break;
}
});
return css;
}
function processCSS(appElement, stylesheetElement, appName) {
// 生成 CSS 选择器:div[data-app-name=微应用名字]
const prefix = `${appElement.tagName.toLowerCase()}[data-app-name="${appName}"]`;
// 生成临时 <style> 节点
const tempNode = document.createElement('style');
document.body.appendChild(tempNode);
tempNode.sheet.disabled = true
if (stylesheetElement.textContent !== '') {
// 将原来的 CSS 文本复制一份到临时 <style> 上
const textNode = document.createTextNode(stylesheetElement.textContent || '');
tempNode.appendChild(textNode);
// 获取 CSS 规则
const sheet = tempNode.sheet;
const rules = [...sheet?.cssRules ?? []];
// 生成新的 CSS 文本
stylesheetElement.textContent = this.rewrite(rules, prefix);
// 清理
tempNode.removeChild(textNode);
}
}
function scopedCSSIsolation(appName, contentHtmlString) {
// 清理 HTML
contentHtmlString = contentHtmlString.trim();
// 创建一个容器 div
const containerElement = document.createElement('div');
// 生成内容 HTML 结构
containerElement.innerHTML = contentHtmlString; // content 最高层级必需只有一个 div 元素
// 获取根 div 元素
const appElement = containerElement.firstChild;
// 打上 data-app-name=appName 的标记
appElement.setAttribute('data-app-name', appName);
// 获取所有 <style></style> 元素内容,并将它们做替换
const styleNodes = appElement.querySelectorAll('style') || [];
[...styleNodes].forEach((stylesheetElement) => {
processCSS(appElement, stylesheetElement, appName);
})
return appElement;
}
class SnapshotSandbox {
windowSnapshot = {}
modifiedMap = {}
proxy = window;
constructor() {
}
active() {
// 记录 window 旧的 key-value
Object.entries(window).forEach(([key, value]) => {
this.windowSnapshot[key] = value;
})
// 恢复上一次的 key-value
Object.keys(this.modifiedMap).forEach(key => {
window[key] = this.modifiedMap[key];
})
}
inactive() {
this.modifiedMap = {};
Object.keys(window).forEach(key => {
// 如果有改动,则说明要恢复回来
if (window[key] !== this.windowSnapshot[key]) {
// 记录变更
this.modifiedMap[key] = window[key];
window[key] = this.windowSnapshot[key];
}
})
}
}
加载原理
现在我们来思考一下:给你一个 HTML 地址,你该如何加载这个微应用?
一个简单的思路就是:
- 发 Http 请求,获取 HTML 内容
- 通过
container.innerHTML = html
的方式挂载到容器上 - 然后再拿到 JS 代码,使用
eval(code)
的方式执行 JS 代码
大致是这个方向,这里我画了个更详细的流程图方便大家理解:
这里的难点在于对于一些外部资源的处理:把外部的 <link>
转为内联的 <style>
,以及获取外部的 <script src="xxx">
JS 代码,最终放在一起执行。
实现框架
上面的图看着有些复杂,我们可以先把大致的框架实现出来:
// JS 沙箱
window.sandbox = new SnapshotSandbox();
window.proxy = window.sandbox.proxy;
// 加载微应用逻辑
const loadMicroApp = async (containerSelector, name, url) => {
// 获取 HTML
const html = await (await fetch(url)).text();
// 解析 html
const { template, scripts, styles } = processTpl(html);
// 远程加载所有外部样式,把 <link> 注释掉,再转化为 <style>
const embedHtml = await getEmbedHtml(template, styles);
// CSS 隔离
const wrapped = `<div class="wrapper">${embedHtml}</div>`;
const appElement = scopedCSSIsolation(name, wrapped);
// 再追加包裹的内容
const containerElement = document.querySelector(containerSelector);
containerElement.appendChild(appElement);
// 执行 JS
execScripts(scripts);
}
在实现框架的同时,我们要把上两篇提到的沙箱隔离也要用上:这里会生成一个 JS 沙箱,存到了 window.proxy
以及 window.sandbox
上,而 CSS 沙箱则是拿到了整串处理过的 HTML,把里面的 <style>
的 CSS 代码重新再处理一次,从而实现样式隔离。
最后的 execScripts
可以利用刚刚生成的 JS 沙箱,把微应用的 JS 代码放在里面执行:
// 在沙箱中执行 JS 代码
const execScripts = (scripts) => {
// 激活沙箱
window.sandbox.active();
// 遍历执行 JS 代码
getExternalScripts(scripts)
.then(scriptText => {
const code = `
;function fn (window) {
${scriptText}
}
fn(window.proxy);
`
eval(code);
})
}
好了,现在整体实现也差不多了,接来下我们慢慢实现细节部分。
实现细节
processTpl
processTpl
主要是通过正则来匹配对应的 <link>
以及 <script>
,然后将它们分别收集起来:
// 一些正则表达式,可以不用管
const ALL_SCRIPT_REGEX = /(<script[\s\S]*?>)[\s\S]*?<\/script>/gi;
const SCRIPT_SRC_REGEX = /.*\ssrc=(['"])?([^>'"\s]+)/;
const LINK_TAG_REGEX = /<(link)\s+.*?>/isg;
const STYLE_HREF_REGEX = /.*\shref=(['"])?([^>'"\s]+)/;
// 将 <link> 转为 <style>/* CSS 代码 */ xx </style>
const genLinkReplaceSymbol = (linkHref, preloadOrPrefetch = false) => `<!-- ${preloadOrPrefetch ? 'prefetch/preload' : ''} link ${linkHref} replaced by import-html-entry -->`;
// * 匹配 HTML 中的 <link> 以及 <script>
// * 将 <link> 转换为注释 <!-- href ->
// * 收集外部 <link> 的 href 地址
// * 收集内联 <script> 代码以及外部 <script> 的 src 地址
const processTpl = (html) => {
let styles = [], scripts = [];
const template = html
// 匹配 <link> 标签
.replace(LINK_TAG_REGEX, match => {
// <link rel = "stylesheet" href = "xxx" />
const styleHref = match.match(STYLE_HREF_REGEX);
// 获取 href 属性值
const href = styleHref && styleHref[2];
// 记录这里 href
styles.push(href);
// 源码会把这里变成注释,这里简化为直接干掉,变成空字符串
return genLinkReplaceSymbol(href);
})
// 匹配 <script></script>
.replace(ALL_SCRIPT_REGEX, (match, scriptTag) => {
// 如果是外部的 script,通过是否包含 src 来判断
if (scriptTag.match(SCRIPT_SRC_REGEX)) {
// <script src = "xx" />
const matchedScriptSrcMatch = scriptTag.match(SCRIPT_SRC_REGEX);
// 脚本地址
let matchedScriptSrc = matchedScriptSrcMatch && matchedScriptSrcMatch[2];
// 记录 script 的 src 地址
scripts.push(matchedScriptSrc);
return match;
}
// 此时为内联 script 的情况
else {
scripts.push(match);
return match;
}
})
return { template, scripts, styles }
}
可以看到,通过 JS 强大的 replace
函数,我们可以精确地处理 HTML 的细节部分。这里主要做了:
- 匹配 HTML 中的
<link>
以及<script>
- 将
<link>
转换为注释<!-- href ->
- 收集外部
<link>
的 href 地址 - 收集内联
<script>
代码以及外部<script>
的 src 地址
Qiankun 的正则表达式有些复杂,在学习的时候我们可以不用管,直接假设它们就是干这个的,而且是正确的。
细心地你会发现 <link>
内容被注释掉了,而不是直接删掉,这是为什么呢?一方便当然是为了做个标记,另一个原因我们接下来继续看。
getEmbedHtml
收集了 link
以及 script
后,我们先对远程的 CSS 进行处理:
// 通过 fetch 发 HTTP 请求获取外部 CSS 代码
const getExternalStyleSheets = (styles) => {
return Promise.all(styles.map(styleLink => {
return fetch(styleLink).then(response => response.text());
}
));
}
// 将 <link> 的 CSS 代码解出来放到 HTML 中
const getEmbedHtml = (template, styles) => {
let embedHTML = template;
return getExternalStyleSheets(styles, fetch)
.then(styleSheets => {
// 通过循环,将之前设置的 link 注释标签替换为 style 标签,即 <style>/* href地址 */ xx </style>
embedHTML = styles.reduce((html, styleSrc, i) => {
html = html.replace(genLinkReplaceSymbol(styleSrc), `<style>/* ${styleSrc} */${styleSheets[i]}</style>`);
return html;
}, embedHTML);
return embedHTML;
});
}
可以看到,在把 CSS 代码替换到原有位置时,刚刚注释掉的 <link>
被直接拿到当成了替换目标,这也是为什么 Qiankun 要把 <link>
注释掉而不是直接干掉的原因之一。
getExternalScripts
对于 getExternalScripts
则是要同时处理外部 JS 以及内联 JS,因为 sciprts
数组既存着内联代码又存放着 src
地址,当然这也很容易实现:
// 通过 fetch 发 HTTP 请求获取外部 JS 代码
// 如果是内联代码,那么直接返回
const getExternalScripts = (scripts) => {
// 遍历所有 script 的 src 地址
return Promise.all(scripts.map(script => {
// 字符串,要不是链接地址,要不是脚本内容(代码)
if (isInlineCode(script)) {
// 内联代码
return getInlineCode(script);
} else {
// 外部代码,则加载脚本
return fetch(script).then(response => response.text());
}
},
));
}
这样一来,getExternalScripts
返回值则全部为 JS 代码了,可以直接被 execScripts
函数来执行。
整体效果
实现完后,我们就可以通过点击主应用的按钮来加载和卸载微应用了,最终效果如下图示所:
我们打开控制台,会发现微应用的 Bootstrap CSS 代码已经从 <link>
“解压” 出来变成 <style>
了:
而且,因为我们用 Scoped CSS 的方式来做样式隔离,所以对应的选择器也变成了 div[data-app-name="microApp"] .h3div { ....
。
在 Console 中也可以看到微应用的执行效果:
点一下登录,依然可以正确弹出 Alert:
问题
在这个简单的实践中,相信你也发现不少问题。
执行入口
上面只是简单地遍历 scripts
数组,然后一个个地执行。显示这样是比较挫的,因为你这样默认第一个 JS 为入口文件,万一别人是第二、第三个才是入口,那这样的限制就太多了。
因此无论如何,Qiankun 都需要你提供一个入口标识来指定最先执行的文件。除了这个原因,由于 Qiankun 是基于 single-spa 开发的,而 single-spa 要求开发者必须提供生命周期的实现,所以 Qiankun 也会从这个入口里拿到对应的生命周期,把它们丢给 single-spa 去执行。
这也可以解释入口为什么这么重要,而围绕这个入口问题的报错,大家可以稳步到我的这篇 彻底解决 qiankun 找不到入口的问题 文章,里面对入口的原理以及实现做了很详细的说明。
隔离不彻底
发现 Scoped CSS 的隔离方式下,微应用依然是暴露在全局当中的,如果主应用有 div { color: red }
这样全局 CSS 代码,那么微应用一样会受到影响,这也是 Scoped CSS 的一个缺点。
那么如果换成 Shadow DOM 隔离呢?这也并不完美,由于 Shadow DOM 的天然隔离特点,导致微应用里调用 document.querySelect
会找不到微应用的元素。 即使 attchShadow
里的 mode 设置为 open
,也只能通过 element.shadowRoot.querySelector
的方式来寻找 DOM。
这也可以解释为什么 Qiankun 对于一些全局 UI 组件不是那么友好,例如 Modal、Drawer、Popover,因为这些组件一般都会挂载到全局的 document.body
上。由于你又要做隔离,又要跨应用访问,这很容易出现样式问题。
比较经典的例子就是主微应用都有 AntDesign 时,这些全局组件就会有问题,具体可见 《如何确保主应用跟微应用之间的样式隔离》。
执行时机
上面我们都是通过手动点击按钮来激活和卸载应用的,真实的场景应该是通过路由 Url 的变换来实现应用的加载和卸载,比如 /baidu
就加载百度应用,/taobao
就加载淘宝应用。实际上,这就是 single-spa 实现逻辑,它监听了路由以此来管理多个应用,而 Qiankun 则这个基础上做的更友好一些,我们只需要提供路由以及微应用地址即可。
总结
最后来总结一下这次实践:
加载微应用其实就是发请求获取 HTML 文本,以及处理 HTML 文本的操作。其中我们要针对 <link>
标签,将其转化为 <style>
标签,因为只有转化了才能进一步实现 CSS 的隔离。在做 CSS 隔离的时候,我们也发现了目前 CSS 隔离方案的一些问题。
而对于 JS 代码,则是直接放在沙箱中执行。不过我们在实践过程中也发现了需要提供 JS 入口的这个问题。
以上就是本次分享的内容,代码会放在 这个仓库 mini-load-micro-app,需要的自行提取即可。如果你喜欢我的分享,可以来一波一键三连,点赞、在看就是我最大的动力,比心 ❤️
转载自:https://juejin.cn/post/7153141951183716383