微前端的描述
微前端是一种类似于微服务的架构,将前端应用分解成多个部分,每个部分都能够独立运行、独立测试、独立交付。
微前端主要解决两个问题:
- 跨团队协作
- 项目新老迭代
1、iframe
优点:
- 浏览器原生支持
- 接入简单
- 完美隔离,js、css、dom 完全隔离
- 多应用激活,可以在页面上显示多个 iframe
缺点(引用):
- url 不同步,刷新一下,ifram e的 url 状态就丢失了,前进后退按钮无法使用。
- dom 割裂严重。iframe 里的弹窗无法覆盖全局。
- 通信困难。只能通过 postmessage 传递序列化的消息。
- 慢。白屏时间太长。子应用每次进入都需要浏览器上下文重建、资源重新加载。
2、iframe + Web Component ------ 腾讯:无界
wujie 的方案是利用 iframe 的优势,解决 iframe 的缺点。
2.1 wujie 的使用
2.2 无界的方案
- 应用加载机制和 js 沙箱机制
利用 iframe 实现沙箱,让子应用脚本在 iframe 里运行,利用 Web component 的 custom element 和 shadow dom 实现样式隔离。通过代理 iframe 的 document 的查询类接口(getElementByTagName,getElementById等)到 Web component 上,实现两者的关联。
-
路由同步机制
在 iframe 内部进行 history.pushState,浏览器会自动在 joint seesion history 中添加 iframe 的 session-history,浏览器的前进、后退在不做任何处理的情况下就可以直接作用于子应用。
劫持 iframe 的 history.pushState 和 history.replaceState,就可以将子应用的 url 同步到主应用的 query 参数上,当刷新浏览器初始化 iframe 时,读回子应用的 url 并使用 iframe 的 history.replaceState 进行同步。
-
通信机制
承载子应用的 iframe 和主应用是同域的,所以可以进行通信。通信方式:
-
Props 注入
子应用通过 $wujie.props 可以拿到主应用注入的数据。
-
window.parent 通信
子应用和主应用同源,可以通过 window.parent 和主应用通信。
主应用调用子应用的全局数据:
dartwindow.document.querySelector("iframe[name=子应用id]").contentWindow.xxx
子应用调用主应用的全局数据:
javascriptwindow.parent.xxx
-
去中心化的通信
通过 EventBus 事件总线实例,注入到主应用和子应用,实现去中心化通信。
-
dart
具体实现:
主应用里注册微应用
1、setupApp ------ 主应用
注册并通过name做缓存
渲染时(以react为例):
2、startApp ------ 在子应用componentDidMount中执行
1. new Wujie({})
- 创建iframe,将sandbox放到iframe._WUJIE上,代理window, document, location(劫持location, 将doument的查询类接口代理到shadowRoot)
- 创建bus
2.importHTML
- 解析html 创建dom template 获取script和styleSheets
3. active
- 路由同步,window.history.replaceState
- 准备shadow自定义wujie-app element,renderTemplateToShadowRoot
4. start
- 将script插入到iframe里
insertScriptToIframe函数开始执行。fiber ?? requestIdleCallback
- 执行js fiber ?? requestIdleCallback
区分before,sync,defer,async
除了async不放入execQueue外,其他都放到execQueue里串行执行。因为async不需要保证执行顺序。
最后触发load事件
通信:
3、EventBus
一个存储对象 cbs
订阅 $on(name, fn) cbs[name] = [fn]
发布 $emit(name, ...args) 遍历cbs[name]并执行fn
3、single-spa ------ 阿里:qiankun
qiankun 是基于 single-spa 的微前端框架,那为什么需要再包裹一层呢? qiankun 解决了 single-spa 的哪些问题呢?
single-spa
- 需要主应用指定加载哪些 js、css,如果子应用打包逻辑发生变化,主应用也要跟着修改
- 一个页面加载多个子应用时,之间可能会存在样式冲突,js冲突
- 多个子应用之间的通信问题
qiankun
1、html 自动加载:import-html-entry
根据 ur l入口 html 文件,解析出 scripts、styles 去单独加载,其余部分做转换后放到 dom 里。
head 部分转换成 qiankun-head,把 script 部分提取出来单独加载。
支持预加载,在空闲时(requestIdleCallback)解析 script 和 style。
js
const fetch = window.fetch.bind(window)
let embedHTMLCache = {}
function importHTML(url) {
const assetPublicPath = getPublicPath(url)
return (embedHTMLCache[url] || (embedHTMLCache[url] = fetch(url))).then(response => {
// 解析html文件
const { template, scripts, entry, styles } = processTpl(response.text(), assetPublicPath)
return {
template,
assetPublicPath,
getExternalScripts: () => getExternalScripts(scripts, fetch),
getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch),
execScripts: (proxy) => execScripts(scripts[scripts.length - 1], scripts, proxy, { fetch })
}
})
}
// 获取资源路径
function getPublicPath(entry) {
const { origin, pathname } = new URL(entry, location.href);
const paths = pathname.split('/');
// 移除最后一个元素
paths.pop();
return `${origin}${paths.join('/')}/`;
}
// 执行script脚本
function execScripts(entry, scripts, proxy = window, opts = {}) {
return getExternalScripts(scripts, fetch).then(scriptsText => {
function schedule(i, resolvePromise) {
if(i < scripts.length) {
const scriptSrc = scripts[i]
const inlineScript = scriptsText[i]
evalCode(scriptSrc, inlineScript)
if(!entry && i === scripts.length - 1) {
resolvePromise()
} else {
schedule(i + 1, resolvePromise)
}
}
}
return new Promise(resolve => schedule(0, resolve))
})
}
// eval执行脚本
const evalCache = {};
function evalCode(scriptSrc, code) {
const key = scriptSrc;
if (!evalCache[key]) {
const functionWrappedCode = `(function(){${code}})`;
evalCache[key] = (0, eval)(functionWrappedCode);
}
const evalFunc = evalCache[key];
evalFunc.call(window);
}
// 获取可执行的script脚本
function getExecutableScript(scriptText) {
return `;(function(window, self, globalThis){with(window){;${scriptText}}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`
}
2、js、css沙箱:
js 隔离:隔离 window 全局变量
- 快照,加载子应用前先记录下 window 的属性,卸载后恢复之前的快照
- diff,加载子应用后记录对 window 属性的修改,卸载之后恢复回去
- Proxy,创建一个代理对象,每个子应用访问到的都是这个代理对象
快照和diff都不能同时存在多个子应用,一般使用Proxy
css隔离:shadow dom 和 scoped css
js
import { registerApplication, start } from 'single-spa';
// 注册子应用
function registerMicroApps(apps, lifeCycles) {
apps.forEach((app) => {
const { name, activeRule, props, ...appConfig } = app
registerApplication({
name,
app: async () => {
const microAppConfigs = (
await loadApp({ name, props, ...appConfig }, frameworkConfiguration, lifeCycles)
)()
return microAppConfigs
},
activeWhen: activeRule,
customProps: props,
})
})
}
// 根据entry url做处理,并返回single-spa中app需要的字段
async function loadApp(app, configuration = {}, lifeCycles) {
const { entry, name: appName } = app
const {
singular = false,
sandbox = true,
globalContext = window,
...importEntryOpts
} = configuration
// 根据name获取缓存里的实例id
const appInstanceId = genAppInstanceIdByName(appName)
// 解析html
const { template, execScripts, assetPublicPath, getExternalScripts } = await importEntry(entry, importEntryOpts)
// 用<div id="xx" data-name="name" data-version="xx"></div>包裹。将<head></head>替换成<qiankun-head></qiankun-head>
const appContent = getDefaultTplWrapper(appInstanceId, sandbox)(template)
// 创建element
let initialAppWrapperElement = createElement(
appContent,
strictStyleIsolation = false,
scopedCSS = true,
appInstanceId,
)
render({ element: initialAppWrapperElement })
let sandboxContainer;
if (sandbox) {
sandboxContainer = createSandboxContainer(
appInstanceId,
useLooseSandbox = false,
global,
)
// 用沙箱的代理对象作为接下来使用的全局对象
global = sandboxContainer.instance.proxy
mountSandbox = sandboxContainer.mount
unmountSandbox = sandboxContainer.unmount
}
const scriptExports = await execScripts(global, sandbox && !useLooseSandbox)
const { bootstrap, mount, unmount, update } = getLifecyclesFromExports(
scriptExports,
appName,
global,
)
return {
name: appInstanceId,
bootstrap,
mount: [..., mount],
unmount: [...,unmount],
update,
}
}
// 创建element,创建shadowdom,给css添加scope
function createElement(appContent, strictStyleIsolation, scopedCSS, appInstanceId) {
const containerElement = document.createElement('div')
containerElement.innerHTML = appContent
const appElement = containerElement.firstChild
// shadowDom
if(strictStyleIsolation) {
const { innerHTML } = appElement;
appElement.innerHTML = '';
let shadow
if (appElement.attachShadow) {
shadow = appElement.attachShadow({ mode: 'open' });
} else {
shadow = appElement.createShadowRoot()
}
shadow.innerHTML = innerHTML;
}
if (scopedCSS) {
appElement.setAttribute('data-qiankun', appInstanceId)
const styleNodes = appElement.querySelectorAll('style') || []
// 遍历styleNodes,给每个styleNode都添加前缀
forEach(styleNodes, (stylesheetElement) => {
css.process(appElement, stylesheetElement, appInstanceId)
})
}
return appElement
}
// 创建沙箱
function createSandboxContainer(appName, useLooseSandbox, globalContext) {
let sandbox
if(window.Proxy) {
sandbox = useLooseSandbox ? new LegacySandbox(appName, globalContext) : new ProxySandbox(appName, globalContext)
} else {
sandbox = new SnapshotSandbox(appName)
}
return {
instance: sandbox,
async mount() { sandbox.active() },
async unmount() { sandbox.inactive() },
}
}
/**
* 子应用记住改动,卸载时还原
*/
class LegacySandbox {
/** 沙箱期间新增的全局变量 */
addedPropsMapInSandbox = new Map()
/** 沙箱期间更新的全局变量 */
modifiedPropsOriginalValueMapInSandbox = new Map()
/** 持续记录更新的(新增和修改的)全局变量的 map,用于在任意时刻做 snapshot */
currentUpdatedPropsValueMap = new Ma()
setWindowProp(prop, value, toDelete) {
if (value === undefined && toDelete) {
delete this.globalContext[prop]
} else if (typeof prop !== 'symbol') {
Object.defineProperty(this.globalContext, prop, { writable: true, configurable: true })
this.globalContext[prop] = value
}
}
active() {
if (!this.sandboxRunning) {
this.currentUpdatedPropsValueMap.forEach((v, p) => this.setWindowProp(p, v))
}
this.sandboxRunning = true
}
inactive() {
this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => this.setWindowProp(p, v))
this.addedPropsMapInSandbox.forEach((_, p) => this.setWindowProp(p, undefined, true))
this.sandboxRunning = false
}
name;
proxy;
globalContext;
type;
sandboxRunning = true;
constructor(name, globalContext = window) {
this.name = name
this.globalContext = globalContext
this.type = 'LegacyProxy'
const { addedPropsMapInSandbox, modifiedPropsOriginalValueMapInSandbox, currentUpdatedPropsValueMap } = this
const rawWindow = globalContext
const fakeWindow = Object.create(null)
const setTrap = (p, value, originalValue) => {
if (this.sandboxRunning) {
if (!rawWindow.hasOwnProperty(p)) {
addedPropsMapInSandbox.set(p, value)
} else if (!modifiedPropsOriginalValueMapInSandbox.has(p)) {
modifiedPropsOriginalValueMapInSandbox.set(p, originalValue);
}
currentUpdatedPropsValueMap.set(p, value)
}
return true
}
const proxy = new Proxy(fakeWindow, {
set: (_, p, value) => {
const originalValue = rawWindow[p]
return setTrap(p, value, originalValue)
},
get() {return rawWindow[p]}
})
this.proxy = proxy
}
}
/**
* 基于 Proxy 实现的沙箱
*/
let activeSandboxCount = 0
class ProxySandbox {
updatedValueSet = new Set()
document = document;
name;
type;
proxy;
sandboxRunning = true;
active() {
if (!this.sandboxRunning) activeSandboxCount++;
this.sandboxRunning = true;
}
inactive() {
if (--activeSandboxCount === 0) {
Object.keys(this.globalWhitelistPrevDescriptor).forEach((p) => {
const descriptor = this.globalWhitelistPrevDescriptor[p]
if (descriptor) {
Property(this.globalContext, p, descriptor)
} else {
delete this.globalContext[p]
}
})
}
this.sandboxRunning = false;
}
globalWhitelistPrevDescriptor = {}
globalContext
constructor(name, globalContext = window) {
this.name = name;
this.globalContext = globalContext;
this.type = SandBoxType.Proxy;
const { updatedValueSet } = this;
const { fakeWindow } = createFakeWindow(globalContext)
const proxy = new Proxy(fakeWindow, {
set: (target, p, value) => {
if (typeof p === 'string' && globalVariableWhiteList.indexOf(p) !== -1) {
this.globalWhitelistPrevDescriptor[p] = Object.getOwnPropertyDescriptor(globalContext, p)
globalContext[p] = value
} else {
if (!target.hasOwnProperty(p) && globalContext.hasOwnProperty(p)) {
const descriptor = Object.getOwnPropertyDescriptor(globalContext, p)
Object.defineProperty(target, p, descriptor)
} else {
target[p] = value
}
}
updatedValueSet.add(p)
},
get: (target, p) => {return target[p]},
})
this.proxy = proxy
}
}
// copy Window对象
function createFakeWindow(globalContext) {
const fakeWindow = {}
Object.getOwnPropertyNames(globalContext).forEach((p) => {
const descriptor = Object.getOwnPropertyDescriptor(globalContext, p)
Object.defineProperty(fakeWindow, p, descriptor)
})
return { fakeWindow }
}
3、应用状态管理
- props
- globalState
主应用里做全局状态初始化,子应用获取全局状态 getGlobalState 和状态变化时的处理: onGlobalStateChange
发布订阅的模式
-
globalState:全局变量对象
-
deps:保存订阅方法 onGlobalStateChangeCallback = (state, prevState)
-
emitGlobal:触发 state 全局监听
forEach遍历dep并执行callback
-
initGlobalState
return一个对象:{
onGlobalStateChange(callback: onGlobalStateChangeCallback),并触发emitGlobal,
setGlobalState(state),改变全局变量,并触发emitGlobal,
offGlobalStateChange,移除监听
}
主应用将return的对象通过props传给子应用,子应用可监听和修改 globalState
4、路由监听
当子应用是通过路由(activeRule)切换加载时,single-spa 需要监听路由变化,来加载子应用。加载的方法为 reroute,该方法会根据当前路由改变所有子应用的状态(挂载,卸载等)。
按路由加载的使用:
浏览器的路由模式有hash 路由, history 路由,如何监听路由变化?
hash路由的监听可以通过 onhashchange 事件
history 路由通过监听 popState 事件?
以下方法是可以触发 popState 事件的,因为以下方法都会重新加载页面:
history.back() 返回浏览器会话历史中的上一页,跟浏览器的回退按钮功能相同
history.foward() 指向浏览器会话历史中的下一页,跟浏览器的前进按钮功能相同
history.go() 跳转到浏览器会话历史中指定的一个记录页
但 history.replaceState() 将当前的 url 替换成指定的数据 和 history.pushState() 进入到指定的url,history历史+1,这两个方法都不会刷新页面,也不会触发 popState 方法。ps:react-router 中的路由跳转使用的 history.pushState
所以要监听 history 路由的变化需要重写 history.replaceState 和 history.pushState 方法:
4、附录:
参考链接: