本文内容是本人对微前端的一些浅见以及对最近写的一个微前端框架技术实现的总结。作者程度无限,欢送大家多多指错,多提意见~
源码地址:microcosmos:一个写着玩的微前端框架而后谢谢大家的star,pr当然就更欢送了~
微前端是什么
我第一次据说微前端这个概念是在一年前左右偶尔看到了美团的一篇技术博客:用微前端的形式搭建单页利用。然而那时候我连单页面利用是什么都还不晓得,天然是看的一头雾水了。目前大家普遍认为微前端的概念由ThoughtWorks在2016年提出。四年的工夫,飞速发展,目前咱们曾经能看到很多优良的开源作品,如single-spa、qiankun、icestark、Micro 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节点下的内容,这两个正则还是挺容易齐备的,再将它们innerHtml
到createElement
出的一个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也不好做,尽管咱们通过它能够拿到齐全隔离的 window
、document
等上下文。但还是不能间接加以应用的,你得通过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可能更好更不便地反对不同工程之间构建产物的相互加载,让咱们一起看看这最终会给微前端带来什么。
援用
- 微服务概念
- Micro Frontends
- new 一个 immer by ayqy
- Module Federation