如果你对构建高性能微前端感兴趣,不妨来手写一个微前端框架吧。主要有四个核心功能:注册微应用、路由劫持、HTML Entry解析和沙箱隔离。对微前端不太熟悉的同学可以看上一篇文章《微前端破局:告别巨石应用》
一、注册微应用
微应用信息包括:
- name: 微应用名称,唯一标识。
- entry: 微应用入口地址,用于获取网页静态资源。
- activeRule: 激活规则,在哪些路由规则下渲染这个微应用。
- container: 渲染容器,将微应用渲染到基座应用中的container元素。
微前端框架提供注册方法,用于将微应用注册到框架中。
export interface IMicroApp {
name:string;
entry:string;
activeRule:string;
container: string;
}
export const apps: IMicroApp[] = [];
export const registerMicroApps = (appList:IMicroApp[]) => {
apps = apps.concat(appList);
}
在基座应用中注册微应用。
export const microApps: IMicroApp[] = [
{
name:'reactMicroApp',
activeRule:'/react/',
container:'#micro-container',
entry:'//localhost:9002/',
},
{
name:'vueMicroApp',
activeRule:'/vue/',
container:'#micro-container',
entry:'//localhost:9003/',
}
]
registerMicroApps(microApps);
二、路由劫持
当路由发生变化时,微前端框架劫持路由,并根据激活规则找到待渲染的微应用。
spa的路由模式有两种,hash和history。hash模式比较简单,只需要监听hashchange事件即可。history模式需要考虑两种场景,第一是监听popstate事件(当浏览器前进或后退时会触发)。第二是重写pushstate()和replacestate(),用于监听应用主动触发路由跳转的情况。
//重写pushState()和repleteState()
const rewritePopstate = (updateState, eventName) => {
return function (){
const e = new Event(eventName);
// 1.触发自定义事件,通知微前端框架发生了路由跳转
window.dispatchEvent(e);
// 2.更新state
updateState.apply(this, arguments);
}
}
const routeChange = ()=>{
console.log("路由切换了");
}
const rewriteRouter = ()=>{
window.history.pushState = rewritePopstate(window.history.pushState, 'popstate');
window.history.replaceState = rewritePopstate(window.history.replaceState, 'popstate');
window.addEventListener('hashchange', routeChange);
window.addEventListener('popstate', routeChange);
}
根据激活规则进行路由匹配,找到待渲染的微应用。
const shouldBeActive = (app:IMicroApp) => app.activeRule.test(window.location.pathname);
const activeApp = microApps.filter(app => shouldBeActive(app));
三、HTML Entry解析
当路由匹配到微应用之后,获取对应微应用的网页资源,并渲染到指定容器。实现思路是,首先,获取微应用的入口html并做解析:提取外联css链接、外联js链接和内联js代码。然后,处理css和js:获取外联css文件,并转换为内联样式。获取外联js文件,执行所有js。最后,把微应用渲染到指定容器。
export const loadMicroApp = async (app:IMicroApp) => {
// 1.获取微应用的入口html
const htmlText = await fetchResource(app.entry);
// 2.解析html,提取外联css链接、外联js链接和内联js代码
const {styles, inlineScripts, scriptSrcs, parsedHtml} = await parseHtml(htmlText, app.entry);
// 3.将外联css转换为内联样式
const embedHtml = embedCss(parsedHtml, styles);
// 4.创建微应用元素,并渲染到主应用容器
const appElement = createAppElement(embedHtml);
render(appElement, app.container);
// 5.获取外联js文件,并在沙箱环境中执行所有js代码
execJs(inlineScripts, scriptSrcs);
}
- 发送请求,获取网页资源。
const fetchResource = (url:string) => fetch(url).then(res => res.text());
- 解析html,通过正则匹配提取html中的css和js代码。
const LINK_TAG_REGEX = /<(link)\s+[\s\S]*?>/ig;
const STYLE_HREF_REGEX = /.*\shref=('|")?([^>'"\s]+)/;
const STYLE_TYPE_REGEX = /\s+rel=('|")?stylesheet\1.*/;
const ALL_SCRIPT_REGEX = /(<script[\s\S]*?>)[\s\S]*?<\/script>/gi;
const SCRIPT_SRC_REGEX = /.*\ssrc=('|")?([^>'"\s]+)/;
const parseHtml = (htmlText:string, baseUrl:string) => {
let styleHrefs = [];
let inlineScripts = [];
let scriptSrcs = [];
const parsedHtml = htmlText.replace(LINK_TAG_REGEX, match => {
// 提取外联css链接
const styleType = !!match.match(STYLE_TYPE_REGEX);
if(styleType) {
const styleHrefMatch = match.match(LINK_HREF_REGEX);
if(styleHrefMatch){
const styleUrl = hasProtocol(styleHrefMatch) ? styleHrefMatch : getEntirePath(styleHrefMatch, baseUrl);
const parsedStyleUrl = parseUrl(styleUrl);
styleHrefs.push(parsedStyleUrl);
return `<!-- ${parsedStyleUrl} 已被微前端替换 -->`
}
}
}).replace(All_SCRIPT_REGEX, function(match, scriptTag){
// 提取外联js链接和内联js代码
const scriptSrcMatch = scriptTag.match(SCRIPT_SRC_REGEX);
if(scriptSrcMatch){
const scriptSrc = scriptSrcMatch[2];
const scriptUrl = hasProtocol(scriptSrc) ? scriptSrc : getEntirePath(scriptSrc, baseUrl);
const parsedScriptUrl = parseUrl(scriptUrl);
scriptSrcs.push(parsedScriptUrl);
return `<!-- ${parsedScriptUrl} 已被微前端替换 -->`
} else {
const inlineScript = getInlineCode(match);
inlineScripts.push(inlineScript);
return '<!-- 内联js代码已被微前端替换 -->'
}
})
return { styleHrefs, inlineScripts, scriptSrcs, parsedHtml}
}
- 处理外联的js、css链接,如果不是以http(s)开头的,需要用微应用的入口地址作为baseUrl构建完整的链接。
const hasProtocol = (url:string) => /^https?:\/\//i.test(url);
const getEntirePath = (path:string, baseUrl:string) => new URL(path, baseUrl).toString();
- 利用DOMParser的解析能力,处理链接字符串中的转义字符,例如 &转换为 & 。
const parseUrl = (url:string) => {
const parser = new DOMParser();
const html = `<script src=${url}></script>`;
const doc = parser.parseFromString(html, 'text/html');
return doc.scripts[0];
}
- 提取内联代码,例如 <script> alert(1); </script>提取出 alert(1);
const getInlineCode = (tag:string) => {
const start = tag.indexOf('>') + 1;
const end = tag.LastIndexOf('<');
return tag.substring(start, end);
}
- 获取外联css资源,并将获取到的css代码嵌入到html中。
const embedCss = async (html:string, styleSrcs:string[]) => {
const styleSheets = await Promise.all(styleSrcs.map(async url => fetchResource(url)));
const embedHtml = styleSheets.reduce((html, styleSheet, index) => {
const src = styleSrcs[index];
html.replace(`<!-- ${src} 已被微前端替换 -->`, `<style>/* ${src} */ ${styleSheet} <style>`);
}, html);
return embedHtml;
}
- 创建一个包裹元素,并将微应用的html字符串设置为innerHTML。然后在主应用容器中渲染微应用元素。
const createAppElement = (htmlText:string) => {
const htmlWithQiankunHead = htmlText.replace('<head>', '<qiankun-head>').replace('</head>', '</head>');
const appContentWrapper = `<div>${htmlWithQiankunHead}</div>`;
const containerElement = document.createElement('div');
containerElement.innerHTML = appContentWrapper;
return containerElement.firstChild;
}
const render = (appElement:Element, container:string) => {
const containerElement = document.querySelector(container);
while(containerElement.firstChild){
containerElement.removeChild(containerElement.firstChild);
}
containerElement.appendChild(appElement);
}
- 获取外联js资源,并在沙箱环境中执行js代码。
const execJs = async (inlineScripts:string[], scriptSrcs:string[]) => {
const externalScripts = await Promise.all(scriptSrcs.map(url => fetchResource(url)));
const allScripts = [...inlineScripts,...externalScripts];
return await Promise.all(allScripts.map(script => execCode(script));
}
- 调用eval()解析并执行js代码
const execCode = async (code:string) => {
const evalFunc = eval(`(function(){${code})`);
evalFunc.call(window);
}
四、沙箱隔离
当有多个微应用运行时,会出现js全局变量污染和css样式冲突。
- 隔离js运行环境的方案有两种:快照沙箱和代理沙箱。
快照沙箱的实现思路是,当微应用被激活时,保存当前的window快照,并恢复上一次激活期间的window快照;当微应用被失活时,保存激活期间的window快照,恢复激活前的window快照。
class SnapshotSandbox {
constructor() {
this.windowSnapshot = {};
this.modifyPropsMap = {};
}
active(){
// 保存当前的window快照
Object.keys(window).forEach(p => this.windowSnapshot[p] = window[p]);
// 恢复上一次激活期间的window快照
Object.keys(this.modifyPropsMap).forEach(p => window[p] = this.modifyPropsMap[p]);
}
inactive(){
Object.keys(window).forEach(p => {
//保存激活期间的window快照,恢复激活前的window快照
if(window[p] !== this.windowSnapshot[p]){
this.modifyPropsMap[p] = window[p];
window[p] = this.windowSnapshot[p];
}
});
}
}
代理沙箱的实现思路比较简单,通过Proxy API给每个微应用创建一个隔离的沙箱环境,然后将window对象的读写操作代理到沙箱环境。
class ProxySandbox {
constructor() {
this.sandboxRunning = false;
this.fakeWindow = Object.create(null);// 隔离的沙箱环境
const rawWindow = window;// 原始的运行环境
this.proxy = new Proxy(this.fakeWindow, {
get: (target, key) => {
// 优先访问沙箱环境,再降级访问原始环境
return target[key] !== undefined ? target[key] : rawWindow[key];
},
set: (target, key, value) => {
// 修改沙箱环境
this.sandboxRunning === true && target[key] === value;
return true;
}
});
}
active() {
this.sandboxRunning = true;
}
inactive() {
this.sandboxRunning = false;
}
}
- 实现CSS隔离的方案也有两种,分别是Shadow DOM和Scoped CSS。
Shadow DOM的核心思路是把微应用渲染在一个隔离的容器。在渲染微应用时,创建一个Shadow DOM容器,然后将微应用挂载到这个容器内部。
const createAppElement = (htmlText:string) => {
const containerElement = document.createElement('div');
const shadow = containerElement.attachShadow({mode: 'open'});
shadow.innerHTML = htmlText;
return shadow.firstChild;
}
Shadow DOM是强隔离性的,即外部样式无法穿透到容器内部,内部样式也不会泄露到容器外部。这种强隔离性也带来了一些问题,例如消息弹框组件无法挂载到body上,导致显示异常。
Scoped CSS的核心思路是给每个微应用的CSS选择器添加唯一的前缀。在渲染微应用时,给容器元素添加唯一的自定义数据属性data-,然后重写微应用的style sheet,给每个css选择器添加前缀data-。
const createAppElement = (htmlText:string, appName: string) => {
const containerElement = document.createElement('div');
containerElement.setAttribute('data-microapp', appName);
containerElement.innerHTML = htmlText;
return containerElement.firstChild;
}
const rewriteCss = (cssText: string, appName: string) => cssText.replace(/(^|,\n?)([^,]+)/g, (item, p, s) => `${p}[data-microapp=${appName}] s.replace(/^ */)`, "");
最终效果:
<div data-microapp="app1">
<style>
[data-microapp="app1"] .button {color:red;}
</style>
</div>
沙箱隔离技术选型参考:
| 方案类型 | 实现方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 代理沙箱 | Proxy劫持window | 多实例支持,按需代理 | 不兼容IE | 现代浏览器环境 |
| 快照沙箱 | 状态保存/恢复 | 全浏览器兼容 | 性能差,不支持多实例 | 兼容IE的老系统 |
| Shadow DOM | 创建隔离DOM树 | 强样式隔离 | 全局组件失效,事件穿透难 | 简单嵌入型应用 |
| Scoped CSS | 选择器重写(属性前缀) | 平衡兼容性与隔离性 | 需解析所有CSS规则 | 复杂业务系统(推荐) |
总结
文章实现了微应用的四个核心功能,包括微应用注册、路由劫持、HTML Entry解析和沙箱隔离。揭开微前端神秘的面纱,进一步加深对微前端技术原理的了解。同时,也学习了一些基础知识在框架中的应用,例如history api,hash事件,DOMParser,shadow dom,数据属性data-*,eval(),innerHTML,proxy对象,call()绑定作用域等等。希望文章内容对大家有所帮助。
参考资料
从零开发——微前端框架实践 一个js库就把你的网页的底裤🩲都扒了——import-html-entry
微前端方案 qiankun 只是更完善的 single-spa