微前端“容器”——microcosmos实现

9,578 阅读12分钟

本文内容是自己对微前端的一些浅见以及对最近写的一个微前端框架技术实现的总结。作者水平有限,欢迎大家多多指错,多提意见~ 源码地址:microcosmos:一个写着玩的微前端框架

然后谢谢大家的star,pr当然就更欢迎了~

微前端是什么

​ 我第一次听说微前端这个概念是在一年前左右偶然看到了美团的一篇技术博客:用微前端的方式搭建单页应用。然而那时候我连单页面应用是什么都还不知道,自然是看的一头雾水了。目前大家普遍认为微前端的概念由ThoughtWorks在2016年提出。四年的时间,飞速发展,目前我们已经能看到很多优秀的开源作品,如single-spaqiankunicestarkMicro Frontends etc.

​ 那微前端到底是什么呢?其实换个问题会更好的帮助我们认识:为什么需要微前端?

​ 你可能不知道微前端,但你应该知道微服务。

维基百科上的解释是这样的:

微服务是一种软件开发技术- 面向服务的体系结构(SOA)架构样式的一种变体,将应用程序构造为一组松散耦合的服务。在微服务体系结构中,服务是细粒度的,协议是轻量级的微服务是一种以业务功能为主的服务设计概念,每一个服务都具有自主运行的业务功能,对外开放不受语言限制的 API (最常用的是 HTTP),应用程序则是由一个或多个微服务组成。

​ 说白了微服务的出现主要是为了解决单体应用过于庞大过于复杂带来的一系列问题。微前端亦然。当大家发现传统的SPA在不断的迭代中慢慢进化成了巨石应用,使得应用的开发、部署、维护都变得异常困难。我们就迫切的需要一种方式将前端应用进行拆分,以此来分解复杂度。

​ 又或者单纯的分久必合合久必分罢了?

​ 我想,这个时候你一定想到了另一个概念,组件化。那微前端和组件化开发有什么区别呢?和组件化的区别?我觉得它们的设计思想都是一样的,包括前面说的微服务。在以前,我们提出组件化开发的概念,但它在我们如今的期望面前不够用了。诚然组件化的主要目的是追求更好的可复用和维护性,这点和微前端类似。但它对应用拆分的粒度是组件。微前端则是将前端应用分解成能够独立开发、测试、部署的子应用,而在用户看来仍然是内聚的单个产品,粒度是app,并且,因为独立开发,我们期望技术栈无关,这是非常重要的。我还没有工作经验,在这方面难谈太多,qiankun开发者的这篇文章很好的回答了为什么技术栈无关在微前端中如此重要。微前端的核心价值

理想的微前端是什么样呢?和声的观点我蛮赞同,那就是子工程是不知道自己是作为子工程在工作的。不过应用间通信的场景还是有的,不然大家也不会总是强调父子通信了。

为了实现我们的愿景,我们需要将多个独立的前端应用集成到一起,实现的方式当然有很多。

从前端的角度来说,主要是两种。构建时集成和运行时集成。

构建时集成,也就是代码分割。什么意思呢,我们可以把不同的app放到一起开发,给webpack配置多个入口,最后打包生成多个出口文件,以实现代码分割。这种方式目前来说只是看上去可行,但是没办法上沙箱,而且你还是没有实现独立开发,独立部署。

运行时集成主要是两种方案。一种,我想大家肯定都知道,iframe。实际上,如果不考虑用户体验,我觉得iframe就是一个完美的微前端方案。但是没办法,iframe带来的问题,使得我们没办法优先考虑它。比如iframe每次都会重新加载,在移动端兼容性差,并且还需要服务端帮忙,不然会有跨域问题。

在这里,我们要谈的是另一种方案,即实现一种容器,容器承载着主应用,通过在主应用中注册子应用的方式来实现微前端。

​ 下面是我用microcosmos写的一个微前端demo,主应用中包含了一个vue app和react app。

我想你应该已经知道微前端是什么了。接下来,让我们看看microcosmos的技术实现。

Microcosmos实现

整体架构

咕咕咕,下面这张图就是microcosmos的架构了,整体的架构很简单,你从对应的表情能看出来我对各个部分实现的满意程度。下面分别介绍。

相关API

引入

npm i microcosmos

import { start, register,initCosmosStore } from 'microcosmos';

注册子应用

register([
  {
    name: 'sub-react',
    entry: "http://localhost:3001",
    container: "sub-react",
    matchRouter: "/sub-react"
  },
  {
    name: 'sub-vue',
    entry: "http://localhost:3002",
    container: "sub-vue",
    matchRouter: "/sub-vue"
  }
])

开始

start()

主应用路由方式

function App() {
 function goto(title, href) {
  window.history.pushState(href, title, href);
 }
 return (
    <div>
   <nav>
    <ol>
     <li onClick={(e) => goto('sub-vue', '/sub-vue')}>子应用一</li>
     <li onClick={(e) => goto('sub-react', '/sub-react')}>子应用二</li>
    </ol>
   </nav>
      <div id="sub-vue"></div>
      <div id="sub-react"></div>
  </div>
 )
}

子应用必须导出生命周期钩子函数

bootstrap、mount、unmount

export async function bootstrap() {
  console.log('react bootstrap')
}

export async function mount() {
  console.log('react mount')
  ReactDOM.render(<App />, document.getElementById('app-react'))
}

export async function unmount() {
  console.log('react unmout')
  let root = document.getElementById('sub-react');
  root.innerHTML = ''
}

全局状态通信/存储

应用之间通信的场景是有,但绝大多数情况下数据量少,频度低,所以全局Store设计的也很简单。

在主应用中:

  • initCosmosStore:初始化store

  • subscribeStore:监听store变化

  • changeStore:给store派发新值

  • getStore:获取store当前快照

let store = initCosmosStore({ name: 'chuifengji' })

store.subscribeStore((newValue, oldValue) => {

  console.log(newValue, oldValue);

})

store.changeStore({ name: 'wzx' })

store.getStore();

在子应用中:

export async function mount(rootStore) {

  rootStore.subscribeStore((newValue, oldValue) => {
    console.log(newValue, oldValue);
  }
  
  rootStore.changeStore({ name: 'xjp' }).then(res => console.log(res))
  
  rootStore.getStore();
  
  instance = new Vue({
    router,
    store,
    render: h => h(App)
  }).$mount('#app-vue')
}

html-loader

html-loader是通过获取页面的html,来获取app的信息,相对的一种方法是JS-loader,Js-loader和子应用的耦合性要高一点,子应用得和主应用约定好承载容器不是。

那html-loader是如何工作的呢?其实很简单,就是通过应用的入口地址,如:http://localhost:3001, 再调用fetch函数。获取到html的text格式信息后,我们需要从中取出我们需要的部分挂载到子应用承载点上。下面这张图是上面那个微前端demo的element结构。你可以看到子应用被挂在id为sub-react的标签下。

如何来做呢?

我想你的第一反应可能是正则,我一开始也是用正则来处理的,但是我后来发现,正则太难完备了(原谅我这个正则盲)我总能写出示例让我自己的正则导出错误的结果。并且用正则来写,代码看着确实挺乱的,后期维护也不太方便。既然是html字符串,为什么我们不用dom api来处理呢?第一反应又是iframe,直接新建一个iframe,利用src属性加载iframe。问题来了,我怎么知道iframe什么时候加载好了?onload吗,显然不行,我们只是为了取出数据而已。DOMContentLoaded?像下面这样,写一个ready函数,还是不行,DOMContentLoaded会等待js执行完才回调。对SPA来说,这时间可能有点长了。

function iframeReady(iframe: HTMLIFrameElement, iframeName: string): Promise<Document> {
    return new Promise(function (resolve, reject) {
        window.frames[iframeName].addEventListener('DOMContentLoaded', () => {
            let html = iframe.contentDocument || (iframe.contentWindow as Window).document;
            resolve(html);
        });
    });
}

没办法,只好想别的办法,写定时函数来判断dom中是否存在body节点,通过适当调整定时函数的执行周期,好像可以,但我们无法知道子应用的结构,依赖于body还是不行的,太不可靠了。

function iframeReady(iframe: HTMLIFrameElement): Promise<Document> {
    return new Promise(function (resolve, reject) {
        (function isiframeReady() {
            if (iframe.contentDocument.body || (iframe.contentWindow as Window).document.body) {
                resolve(iframe.contentDocument || (iframe.contentWindow as Window).document)
            } else {
                setInterval(isiframeReady, 10)
            }
        })()
    })
}

而且要获取到iframe的contentWindow的话你需要将iframe挂在到dom上,确实,可以设置为display:none,但太不优雅了。怎么看怎么不舒服。

srcdoc?是个不错的选择,可惜IE不支持这个新属性。

那就将正则和DOM API结合吧。我们通过正则获取head和body节点下的内容,这两个正则还是挺容易完备的,再将它们innerHtmlcreateElement出的一个div节点中,通过DOM API来遍历。DOM的结构是稳定的,我们可以轻松可靠的获取我们想要的内容,即html结构信息和js。

js隔离

微前端沙箱没有完美实践?

微前端中既然存在多个独立开发的应用,自然需要隔离js,采取的方式是构建沙箱。在浏览器当中,沙箱隔离了操作系统和浏览器渲染引擎,限制进程对操作系统资源的访问和修改。实际上,如果我们需要的app需要执行一些信任度不高的外部js的时候你也是需要沙箱的。一般情况下,我们说的沙箱强调的是两层,隔离和安全。js沙箱本身是个蛮大的坑,好在大部分情况下代码安全都不是微前端要考虑的问题,主应用对接入的子应用不能信任这样的情况还是比较少。微前端中的沙箱要考虑的是第一层,完全的隔离反而会带来问题。

如果不考虑全局对象,不考虑DOM和BOM,我们要做的事情其实非常简单。使用new Function,这样子应用之间的变量都运行在函数作用域中,自然不会冲突了,但是我们还是得考虑全局变量,考虑DOM和BOM。特别是那些个框架大多都改了原生对象。那我们如何实现window的隔离呢?

主要的思路有三种:

快照沙箱:

快照沙箱实际上就是在应用mount时激活生成快照,在unmount时失活恢复原有环境。比如app A挂载时修改了一个全局变量window.appName = 'vue',那我就可以记录下当前的快照(修改前的属性值)。当app A卸载时,我就可以把当前的快照和当前环境进行比对,获知原有环境从而恢复运行环境。

class SnapshotSandbox {
    constructor() {
        this.proxy = window; 
        this.modifyPropsMap = {}; // 修改了哪些属性
        this.active();
    }
    active() {
        this.windowSnapshot = {}; // window对象的快照
        for (const prop in window) {
            if (window.hasOwnProperty(prop)) {
                // 将window上的属性进行拍照
                this.windowSnapshot[prop] = window[prop];
            }
        }
        Object.keys(this.modifyPropsMap).forEach(p => {
            window[p] = this.modifyPropsMap[p];
        });
    }
    inactive() {
        for (const prop in window) { // diff 差异
            if (window.hasOwnProperty(prop)) {
                // 将上次拍照的结果和本次window属性做对比
                if (window[prop] !== this.windowSnapshot[prop]) {
                    // 保存修改后的结果
                    this.modifyPropsMap[prop] = window[prop]; 
                    // 还原window
                    window[prop] = this.windowSnapshot[prop]; 
                }
            }
        }
    }
}

let sandbox = new SnapshotSandbox();
((window) => {
    window.a = 1;
    window.b = 2;
    window.c = 3
    console.log(a,b,c)
    sandbox.inactive();
    console.log(a,b,c)
})(sandbox.proxy);

快照沙箱的思路很简单,也很容易做到子应用的状态保持,但是显然快照沙箱只能支持单实例的场景,对于多实例共存的场景,它就无能为力了。

借用iframe:

啊这个,也太没逼格了。开玩笑,其实iframe也不好做,虽然我们通过它可以拿到完全隔离的 windowdocument 等上下文。但还是不能直接加以使用的,你得通过postMessage,建立iframe和主应用之间的通信。不然路由啥的还玩个锤子。

proxy代理:

class ProxySandbox {
    constructor() {
        const rawWindow = window;
        const fakeWindow = {}
        const proxy = new Proxy(fakeWindow, {
            set(target, p, value) {
                target[p] = value;
                return true
            },
            get(target, p) {
                return target[p] || rawWindow[p];
            }
        });
        this.proxy = proxy
    }
}
let sandbox1 = new ProxySandbox();
let sandbox2 = new ProxySandbox();
window.a = 1;
((window) => {
    window.a = {a:'ldl'};
    console.log(window.a)
})(sandbox1.proxy);a:'ldl'
((window) => {
    window.a = 'world';
    console.log(window.a)
})(sandbox2.proxy);

上面这个proxy是很简单了,读时优先获取"拷贝值",没有就代理到原值,写时代理到“拷贝值”。但它存在着诸多问题,且不说各种恶意代码,如果全局对象使用self、this、globalThis,代理就无效了,只代理get和set也是不够的。最重要的,只是在一定程度上隔离了全局变量而已,window的原生对象和方法,全部失效。

function getOwnPropertyDescriptors(target: any) {
    const res: any = {}
    Reflect.ownKeys(target).forEach(key => {
        res[key] = Object.getOwnPropertyDescriptor(target, key)
    })
    return res
}


export function copyProp(target: any, source: any) {
    if (Array.isArray(target)) {
        for (let i = 0; i < source.length; i++) {
            if (!(i in target)) {
                target[i] = source[i];
            }
        }
    }
    else {
        const descriptors = getOwnPropertyDescriptors(source)
        //delete descriptors[DRAFT_STATE as any]
        let keys = Reflect.ownKeys(descriptors)
        for (let i = 0; i < keys.length; i++) {
            const key: any = keys[i]
            const desc = descriptors[key]
            if (desc.writable === false) {
                desc.writable = true
                desc.configurable = true
            }
            if (desc.get || desc.set)
                descriptors[key] = {
                    configurable: true,
                    writable: true, 
                    enumerable: desc.enumerable,
                    value: source[key]
                }
        }
        target = Object.create(Object.getPrototypeOf(source), descriptors)
        console.log(target)
    }
}

export function copyOnWrite(draftState: {
    originalValue: {
        [key: string]: any;
    };
    draftValue: any;
    onWrite: any;
    mutated: boolean;
}) {
    const { originalValue, draftValue, mutated, onWrite } = draftState;
    if (!mutated) {
        draftState.mutated = true;
        if (onWrite) {
            onWrite(draftValue);
        }
        copyProp(draftValue, originalValue);
    }
}

沙箱难做的原因是,是因为有矛盾点,那就是我们既希望能做到尽可能的隔离,但你又不应当做到完全的隔离。在这个界限之间,就会有冲突。

microcosmos的沙箱就是用proxy实现的,目前的做法是通过copy-on-write实现部分window部分下对象的拷贝,window下的方法还是bind到原方法上的,这个确实没什么好办法。如果怕造成冲突,可以通过添加黑白名单的方式,限制子应用对某些方法的访问,或者自己模拟实现一些方法,再进行通信。不管哪种方案,都不够优雅。

我对这个部分的实现很不满意, 代码参考自immer,这个库实在有太多可以借鉴的东西。

感兴趣的可以自己研究下:immer

css隔离

我们需要在微前端容器设计中考虑隔离CSS吗?

其实我个人觉得这不是微前端容器要考虑的内容,因为这个问题和微前端无关,几乎是在有css起,我们就在遭遇这样的问题,SPA时代更是已经成了必须要考虑的问题。所以在microcosmos中我没有去解决css隔离的问题。你依然要像开发SPA一样,采取 BEM(Block Element Modifier) 约定项目前缀,css module,css-in-js等方案。

而像qiankun所说的 Dynamic Stylesheet其实蛮无聊的(我自己也加了hh),子应用的装卸自然包含着css的装卸,但是这不能保证子应用与主应用之间没有冲突,更不用说还可能存在多个子应用并行的情况。(当然了,他们现在也提出了其他方案,值得期待!)

那你可能会说,Why not shadow dom?

shadow dom确实天生隔离样式,我们很多的开源组件库都使用了shadow dom。但是要把整个应用挂在shadow dom风险还是太大了。会出现各种各样的问题。

比如React17之前,为了减少 DOM 上的事件对象来节省内存,优化页面性能,同时也为了实现事件调度机制,所有的事件都代理到document元素上。 而shadow dom 里触发的事件,在外层拿到 event.target 的时候,只会拿到 host(宿主元素),所以导致了 react 的事件调度出现问题。

如果你不了解react,我解释一下。

在React 的「合成事件机制」中「事件」并不会直接绑定到具体的 DOM 元素上,而是通过在 document 上绑定的 ReactEventListener 来管理, 当时元素被单击或触发其他事件时,事件被 dispatch 到 document 时将由 React 进行处理并触发相应合成事件的执行。

对于shadow dom,因为主文档内部的脚本并不了解 shadow dom 内部,尤其是当组件来自于第三方库,所以,为了保持细节简单,浏览器会重新定位(retarget)事件。当事件在组件外部捕获时,shadow DOM 中发生的事件将会以 host 元素作为目标

这将让 React 在处理合成事件时,不认为 ShadowDOM 中元素基于 JSX 语法绑定的事件被触发了。

当然了,更大的问题是shadow dom只是隔离了内部与外部,内部还是会有冲突的可能呀。

life-cycle

生命周期循环是个大遍历。

每次路由发生有效改变的时候我们需要触发lifeCycle,对已经注册的app进行遍历,该卸载的卸载,该注入的注入。

lifeCycle会遍历子应用列表,依次执行它们的生命周期函数,这里有个小问题,子应用的生命周期函数是如何被主应用获取到的,如果你和我一样不熟悉webpack,或许会陷入这样的困惑,事实上,webpack以umd格式进行打包的话,require函数会将export出的函数合成一个model挂到window上。这样我们就可以获取啦。

本身倒没有什么问题,只是我写的lifeCycle,对于应用状态依赖有点弱。。比如说,第一次进入某个子应用需要fetch,后面就不应需要了。我的做法是通过函数缓存来实现,但是整个生命周期的执行没有丝毫变化,这样似乎不太好,不够优雅。

这里倒是有个要补充的点,我们希望用户的点击触发window.history.pushState事件,以此来显式的改变地址栏url,但是我们还需要对pushSate进行监听来触发函数切换应用。pushState又是没法被直接监听的,我们需要对window.history.pushState事件进行包装,通过监听自定义事件来监听history变化,下面是实现函数。

export function patchEventListener(event: any, ListerName: string) {
    return function (this: any) {
        const e = new Event(ListerName);
        event.apply(this, arguments)
        window.dispatchEvent(e);
    };
}
window.history.pushState = patchEventListener(window.history.pushState, "cosmos_pushState");
window.addEventListener("cosmos_pushState", routerChange);

应用通信

需求决定实现。

理想的微前端可能都不需要这个设计,因为我们说了,子应用是不知道自己是作为子应用在运行的,但是毕竟只是理想。我们还是会有一些父子通信的需求。一般情况下,在微前端中,父子应用之间,子应用之间的通信频度较低,数据量较小。

所以在microcosmos 里我只是运用了简单的发布订阅来实现。靠,你这也太简单了吧。(别骂了别骂了,能用就行

export function initCosmosStore(initData) {
    return window.MICROCOSMOS_ROOT_STORE = (function () {
        let store = initData;
        let observers: Array<Function> = [];
        function getStore() {
            return store;
        }
        function changeStore(newValue) {
            return new Promise((resolve, reject) => {
                if (newValue !== store) {
                    let oldValue = store;
                    store = newValue;
                    resolve(store);
                    observers.forEach(fn => fn(newValue, oldValue));
                }
            })
        }
        function subscribeStore(fn) {
            observers.push(fn);
        }
        return { getStore, changeStore, subscribeStore }
    })()
}

预加载

预加载是为了降低白屏时间,获取更流畅的应用切换效果,对于一些通过微前端实现的工作台,主应用上可能注册了十几个甚至更多的子应用,我们往往不会在短时间内都执行它们,那通过预加载,就能够提前抓取子应用的数据信息,让微前端的优势发挥到极致。

要注意的是浏览器同域名下的并发请求数量是有限制的,不同浏览器可能都不太一样,比如在chrome上可能是6,所以我们需要对子应用列表进行切片,再通过promise 链式调用。

至此,microcosmos的技术实现就讲完啦,当然还是有些小细节,没法全部来讲。

总结

微前端的架构虽然看起来简单,但如果真的要做一个高可用的版本,还有很多的路要走,相信未来我们会有更完善的一整套微前端工程化方案,而不是局限于容器。

PS:即将发布的webpack5的特性之一module federation使得JavaScript 应用得以从另一个 JavaScript 应用中动态地加载代码 —— 同时共享依赖。如果某应用所消费的 federated module 没有 federated code 中所需的依赖,Webpack 将会从 federated 构建源中下载缺少的依赖项, webpack能够更好更方便地支持不同工程之间构建产物的互相加载,让我们一起看看这最终会给微前端带来什么。

引用