微前端原理简介
大致方案
- Nginx路由转发
- iframe嵌套
- Web Components
- 组合式应用路由分发
实际项目中,一般是Nginx路由转发搭配某个微前端框架实现,而微前端框架又是由上面几种技术+一些补充混合实现的。
例如:
- qiankun: function + proxy + with
- micro-app: web components
- wujie: web components 和 iframe。
使用具体的微前端框架时,主要考虑的点有
- 应用间的通讯
- 应用间的状态管理
- js 沙箱
- css 隔离
- 预加载
- 公共依赖的处理
- 路由状态管理
- 是否支持html entry
- 应用支不支持保活
- 子应用之间的互相嵌套
- 是否有生命周期的设计
接下来看看微前端框架主要需要干一些什么。
核心功能点
一、监听路由变化
这块比较简单,了解一下即可。
- 监听 hash 路由:
window.onhashchange
- 监听 history 路由history.go、history.back、history.forward 使用 popstate 事件
window.onpopstate
- 监听popstats变化
监听路由的变化后,拿到当前路由的路径 window.location.pathname
,然后根据 registerMicroApps 的参数 apps
查找子应用,然后去执行加载逻辑。
二、子应用加载
HTML Entry
子应用插槽绑定好一个dom节点,然后动态地插拔加载页面内容...
首先importHTML的参数为需要加载的页面url,拿到后会先通过 fetch方法
读取页面内容。例如:
js
export const importHTML = url => {
const html = await fetch(currentApp.entry).then(res => res.text()
const template = document.createElement('div')
template.innerHTML = html
const scripts = template.querySelectAll('script')
const getExternalScripts = () => {
console.log('解析所有脚本: ', scripts)
}
const execScripts = () => {}
return {
template, // html 文本
getExternalScripts, // 获取 Script 脚本
execScripts, // 执行 Sript 脚本
}
}
最后的返回为一个对象,属性为:
-
template
:处理过的html模板
-
assetPublicPath
:静态资源地址 -
getExternalScripts
:获取前面解析的脚本
数组的方法 -
getExternalStyleSheets
:获取前面解析的样式表
数组的方法 -
execScripts
:执行该模板文件中所有的JS
脚本文件,并且可以指定脚本的作用域 -proxy
对象
注意:内联script一般会用eval去执行。
接下来是处理模板的逻辑,js脚本和css链接会通过特定的方法去执行请求,请求回来后经过一层处理后才会作用于子应用中,最后处理完了就会渲染到绑定的节点上。
IFrame
简单省事,给个链接直接搞定,自带加载体系,JS、CSS隔离,但是弊端也很多:
-
⽆论是使⽤postMessage还是通过 iframeEl.contentWindow 去获取 iFrame 元素的 Window 对象,⼜或者是直接⽗⻚⾯调⽤⼦⻚⾯⽅法:FrameName.window.childMethod();⼦⻚⾯调⽤⽗⻚⾯⽅法:parent.window.parentMethod();来通讯都并不是太过友好的事情,需要设计⼀套规范的通讯标准,实在过于麻烦,并且状态管理、公共依赖的处理等都能通过其他的方式封装实现
-
路由状态丢失,刷新一下,iframe 的 url 状态就丢失了
-
dom 割裂严重,弹窗只能在 iframe 内部展示,无法覆盖全局,并且事件传递上存在者很大的问题,例如拖拽
-
白屏时间太长,对于SPA 应用应用来说无法接受
-
难以做预加载
-
应用完全没办法保活,每次都是新的加载
-
iframe和主⻚⾯共享连接池,⽽浏览器对相同域的连接有限制,所以会影响⻚⾯的并⾏加载,出现iframe中的资源占⽤了可⽤连接⽽阻塞了主⻚⾯的资源加载
-
样式和布局限制:IFrame 的内容在页面中是独立的,它们具有自己的 CSS 样式和布局上下文。这导致在微前端架构中难以实现全局样式的一致性,以及子应用之间的布局和交互的协调问题。
-
浏览器安全性限制:由于安全策略的限制,IFrame 之间的跨域通信可能受到限制,特别是在涉及跨域资源访问和共享数据时。这可能导致在微前端架构中需要处理复杂的安全性问题。
三、子应用运行时隔离
JS沙箱(作用域隔离)
-
worker
- 每个子应用对应一个worker脚本...
-
IFrame
- 天然的JS沙箱
- 可以直接使用隐藏的Iframe来专门执行js
-
LegacySandbox
- 自己维护一个对象,与dom分隔开,动态地记录和保存
-
SnapshotSandbox
- 使用Proxy配合Window完成维护工作,但还是有污染window的风险
-
ProxySandbox
- 直接基于Proxy实现一个fakeWindow,直接完全隔离
ProxySandbox
是最完备的沙箱模式,完全隔离了主子应用的状态。
除了window
上的东西还有一些需要相互隔离的副作用,比如事件监听、定时器
等。监听劫持主要做了以下处理,了解即可:
patchTimer(计时器劫持)
patchWindowListener(window 事件监听劫持)
patchHistoryListener(路由监听)
CSS沙箱(样式隔离)
-
Shadow DOM
- 可以参考web component的原理
strictStyleIsolation
模式下 qiankun 会为每个微应用的容器包裹上一个shadow dom
节点,所有的子应用都被 #shadow-root 所包裹
,从而确保微应用的样式不会对全局造成影响。- 作用域问题,例如内部弹窗到Body时样式丢失
-
CSS Scoped
-
第三方依赖需要特殊处理
-
mount
,和unmount
时:统一走HTML Entry拦截处理加上公共前缀 -
运行时动态加载的样式:
有一些样式文件或者js文件是使用的远程请求的方式动态append到页面中的,这里面就又有一层隔离的问题了。
qiankun
的解决方案和上述的一些解决方案同出一辙:增强appendChild
和insertBefore
方法,并添加了一些其它的逻辑,让其根据是否是子应用
决定link
、style
、script
元素的插入位置是在主应用还是微应用 ,并且劫持script
标签的添加,支持远程加载脚本和设置脚本的执行上下文
,也就是前面说的Proxy
。
-
四、父子应用通信
主要方案:
-
全局eventBus(发布订阅模式)
-
props 通信,基座应用可以通过props向子应用注入数据和方法
-
window 通信,由于在设计上子应用运行的iframe的src和主应用是同域的,所以相互可以直接通信
具体框架实现方案
qiankun
qiankun
我们可以认为是由single-spa
和import-html-entry
两个库结合,并进行二次开发
的产物。下面简单介绍一下实现的大致方案。
子应用加载
使用的是上面提到的HTML Entry。
JS沙箱
使用的是上面提到的ProxySandbox。
CSS沙箱
qiankun
的样式隔离有两种方式,一种是严格样式隔离,通过 shadow dom
来实现,另一种是实验性的样式隔离,就是 scoped css
,两种方式不可共存。
父子应用通信
使用的是GlobalState,原理其实就是上面提到的全局公共BUS
wujie
wujie 是一个iframe + webComponent 的解决方案,且很好地解决了Iframe做沙箱的痛点。
在应用 A 中构造一个shadowRoot 和iframe,然后将应用 B 的html写入shadowRoot中,js运行在iframe中,因为iframe的js隔离真的很完美。
即:子应用加载在一个shadowRoot里,JS在iframe中执行并代理到这个shadowRoot上。
子应用加载
Htmlentry + 渲染到shadowRoot上
创建了一个标签名为wujie-app
的Webcomponent
,JS、CSS加载。
JS沙箱
- 基本原理:直接把js丢在一个隐藏的iframe里执行
- 细节:在iframe中拦截document对象,统一将dom指向shadowRoot,此时比如新建元素、弹窗或者冒泡组件就可以正常约束在shadowRoot内部。
CSS沙箱
基于上面说的shadowRoot
父子应用通信
-
Props 注入
- 子应用通过 $wujie.props 可以拿到主应用注入的数据
-
window.parent 通信
-
子应用和主应用同源,可以通过 window.parent 和主应用通信
window.document.querySelector("iframe[name=子应用id]").contentWindow.xxx
// 子应用调用 window.parent.xxx
-
-
去中心化的通信
- 上面提到的EventBus
简易JS沙盒
js
const rawWindow = window
function exeCode(code, sandbox) {
window.__MICRO_TOY_CONTEXT__ = sandbox.global
const _code = `;(function(window, self) {
with(window) {
${code}
}).call(window.__MICRO_TOY_CONTEXT__,window.__MICRO_TOY_CONTEXT__)`
// 规避with严格模式问题, new Function -- vuejs template compiler
;(0, eval)(_code)
}
export function createSandbox() {
const fakeWindow = {}
const sandbox = {
global: {}, // proxy window
run, // script run window inject
stop, // 暂停沙箱
isRun: false // 是否运行
}
function run(code) {
// 访问fakeWindow的属性,没有的话,从全局window取
// 设置fakeWindow的属性,设置到fakeWindow上
sandbox.global = new Proxy(fakeWindow, {
get(target, key) {
if (Reflect.has(target, key)) {
return Reflect.get(target, key)
}
const rawValue = Reflect(rawWindow, key)
if (typeof rawValue === 'function') {
const valueStr = rawValue.toString()
// console、alert等方法正常从window取
// 如果不是函数,且不是class
if (!/^function\s+[A-Z]/.test(valueStr) && !/^class\s+/.test(valueStr)) {
return rawValue.bind(rawWindow)
}
return rawValue
}
},
set(target, key, value) {
target[key] = value
return true
}
})
exeCode(code, sandbox)
}
function stop() {
sandbox.isRun = false
}
return sandbox
}
参考资料
《基于wujie的解决方案来简单聊聊微前端 (2022年团队内分享PPT)》https://juejin.cn/post/7377567987119620147?searchId=202407042059284DD9DF8539CDD9CE2571
https://github.com/phodal/microfrontends?tab=readme-ov-file#纯-web-components-技术构建
《微前端框架 之 qiankun 从入门到源码分析》https://juejin.cn/post/6885211340999229454#heading-34