路由劫持 VS 沙箱隔离 VS HTML Entry解析:构建高性能微前端实践

205 阅读7分钟

如果你对构建高性能微前端感兴趣,不妨来手写一个微前端框架吧。主要有四个核心功能:注册微应用、路由劫持、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);
}
  1. 发送请求,获取网页资源。
const fetchResource = (url:string) => fetch(url).then(res => res.text());
  1. 解析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的解析能力,处理链接字符串中的转义字符,例如 &amp;转换为 & 。
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);
}
  1. 获取外联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;
}
  1. 创建一个包裹元素,并将微应用的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);
}
  1. 获取外联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

single-spa官网

qiankun官网

微前端方案 qiankun 只是更完善的 single-spa

HTML Entry 源码分析

qiankun 2.x 运行时沙箱 源码分析

Garfish 微前端实现原理

在腾讯换了新部门,微前端 + 重构 Vue -> React 项目实战落地总结

微前端在小米 CRM 系统的实践

有赞美业微前端的落地总结

字节跳动是如何落地微前端的

前端微服务在字节跳动的打磨与应用

将微前端做到极致-无界方案

一文看透 Module Federation