1,主流的微前端方案
- iframe
- 基座模式,主要基于路由分发,
qiankun和single-spa就是基于这种模式 - 组合式集成,即单独构建组件,按需加载,类似npm包的形式
- EMP,主要基于
Webpack5 Module Federation - Web Components
2,single-spa 与 qiankun
- single-spa很好地解决了路由和应用入口两个问题,但并没有解决应用加载问题,而是将该问题暴露出来由使用者实现(一般可以用system.js或原生script标签来实现);
- qiankun在此基础上封装了一个应用加载方案(即import-html-entry),并给出了js隔离、css样式隔离和应用间通信三个问题的解决方案,同时提供了预加载功能。
(1). 路由问题
single-spa是通过监听hashChange和popState这两个原生事件来检测路由变化的,它会根据路由的变化来加载对应的应用,相关的代码可以在single-spa的 src/navigation/navigation-events.js 中找到:
...
// 139行
if (isInBrowser) {
// We will trigger an app change for any routing events.
window.addEventListener("hashchange", urlReroute);
window.addEventListener("popstate", urlReroute);
...
// 174行,劫持pushState和replaceState
window.history.pushState = patchedUpdateState(
window.history.pushState,
"pushState"
);
window.history.replaceState = patchedUpdateState(
window.history.replaceState,
"replaceState"
);
(2). 应用入口
single-spa采用的是协议入口,即只要实现了single-spa的入口协议规范,它就是可加载的应用。single-spa的规范要求应用入口必须暴露出以下三个生命周期钩子函数,且必须返回Promise,以保证single-spa可以注册回调函数
(3). 应用加载
实际上single-spa并没有提供自己的解决方案,而是将它开放出来,由开发者提供。
我们看一下基于system.js如何启动single-spa:
<script type="systemjs-importmap">
{
"imports": {
"app1": "http://localhost:8080/app1.js",
"app2": "http://localhost:8081/app2.js",
"single-spa": "https://cdnjs.cloudflare.com/ajax/libs/single-spa/4.3.7/system/single-spa.min.js"
}
}
</script>
... // system.js的相关依赖文件
<script>
(function(){
// 加载single-spa
System.import('single-spa').then((res)=>{
var singleSpa = res;
// 注册子应用
singleSpa.registerApplication('app1',
() => System.import('app1'),
location => location.hash.startsWith(`#/app1`);
);
singleSpa.registerApplication('app2',
() => System.import('app2'),
location => location.hash.startsWith(`#/app2`);
);
// 启动single-spa
singleSpa.start();
})
})()
</script>
3,qiankun 原理
使用方式
//----主应用----
registerApplication({
name: 'vue',
pageEntry: 'http://localhost:8001',
activeRule: pathPrefix('/vue'),
container: $('#subapp-viewport')
})
registerApplication({
name: 'react',
pageEntry: 'http://localhost:8002',
activeRule:pathPrefix('/react'),
container: $('#subapp-viewport')
})
start()
//----子应用----
(1). 应用路由
每次 URL 改变时,都会调用 loadApps() 方法
import { loadApps } from '../application/apps'
const originalPushState = window.history.pushState
const originalReplaceState = window.history.replaceState
export default function overwriteEventsAndHistory() {
window.history.pushState = function (state: any, title: string, url: string) {
const result = originalPushState.call(this, state, title, url)
// 根据当前 url 加载或卸载 app
loadApps()
return result
}
window.history.replaceState = function (state: any, title: string, url: string) {
const result = originalReplaceState.call(this, state, title, url)
loadApps()
return result
}
window.addEventListener('popstate', () => {
loadApps()
}, true)
window.addEventListener('hashchange', () => {
loadApps()
}, true)
}
(2). 应用入口
- 卸载所有已失活的子应用
- 初始化所有刚注册的子应用
- 加载所有符合条件的子应用
export async function loadApps() {
// 先卸载所有失活的子应用
const toUnMountApp = getAppsWithStatus(AppStatus.MOUNTED)
await Promise.all(toUnMountApp.map(unMountApp))
// 初始化所有刚注册的子应用
const toLoadApp = getAppsWithStatus(AppStatus.BEFORE_BOOTSTRAP)
await Promise.all(toLoadApp.map(bootstrapApp))
const toMountApp = [
...getAppsWithStatus(AppStatus.BOOTSTRAPPED),
...getAppsWithStatus(AppStatus.UNMOUNTED),
]
// 加载所有符合条件的子应用
await toMountApp.map(mountApp)
}
(3). 应用加载
qiankun的作者将其封装成了npm插件import-html-entry
export default function parseHTMLandLoadSources(app: Application) {
return new Promise<void>(async (resolve, reject) => {
const pageEntry = app.pageEntry
// load html
const html = await loadSourceText(pageEntry)
const domparser = new DOMParser()
const doc = domparser.parseFromString(html, 'text/html')
const { scripts, styles } = extractScriptsAndStyles(doc as unknown as Element, app)
// 提取了 script style 后剩下的 body 部分的 html 内容
app.pageBody = doc.body.innerHTML
let isStylesDone = false, isScriptsDone = false
// 加载 style script 的内容
Promise.all(loadStyles(styles))
.then(data => {
isStylesDone = true
// 将 style 样式添加到 document.head 标签
addStyles(data as string[])
if (isScriptsDone && isStylesDone) resolve()
})
.catch(err => reject(err))
Promise.all(loadScripts(scripts))
.then(data => {
isScriptsDone = true
// 执行 script 内容
executeScripts(data as string[])
if (isScriptsDone && isStylesDone) resolve()
})
.catch(err => reject(err))
})
}
- 利用 ajax 请求子应用入口 URL 的内容,得到子应用的 HTML
- 提取 HTML 中
scriptstyle的内容或 URL,如果是 URL,则再次使用 ajax 拉取内容。最后得到入口页面所有的scriptstyle的内容 - 将所有 style 添加到
document.head下,script代码直接执行 - 将剩下的 body 部分的 HTML 内容赋值给子应用要挂载的 DOM 下
(4). js隔离
qiankun通过import-html-entry,可以对html入口进行解析,并获得一个可以执行脚本的方法execScripts。
app.window = new Proxy({}, {
get(target, key) {
if (Reflect.has(target, key)) {
return Reflect.get(target, key)
}
const result = originalWindow[key]
return (isFunction(result) && needToBindOriginalWindow(result)) ? result.bind(window) : result
},
set: (target, key, value) => {
this.injectKeySet.add(key)
return Reflect.set(target, key, value)
}
})
export function executeScripts(scripts: string[], app: Application) {
try {
scripts.forEach(code => {
// ts 使用 with 会报错,所以需要这样包一下
// 将子应用的 js 代码全局 window 环境指向代理环境 proxyWindow
const warpCode = `
;(function(proxyWindow){
with (proxyWindow) {
(function(window){${code}\n}).call(proxyWindow, proxyWindow)
}
})(this);
`
new Function(warpCode).call(app.sandbox.proxyWindow)
})
} catch (error) {
throw error
}
}
(5). css隔离
- 给子应用添加 id = 'single-spa-id-' + count++
- 子应用css增加作用域 items[0] =
${items[0]}[single-spa-name=${app.name}]
function handleCSSRules(cssRules: CSSRuleList, app: Application) {
let result = ''
Array.from(cssRules).forEach(cssRule => {
const cssText = cssRule.cssText
const selectorText = (cssRule as CSSStyleRule).selectorText
result += cssRule.cssText.replace(
selectorText,
getNewSelectorText(selectorText, app),
)
})
return result
}
let count = 0
const re = /^(\s|,)?(body|html)\b/g
function getNewSelectorText(selectorText: string, app: Application) {
const arr = selectorText.split(',').map(text => {
const items = text.trim().split(' ')
items[0] = `${items[0]}[single-spa-name=${app.name}]`
return items.join(' ')
})
// 如果子应用挂载的容器没有 id,则随机生成一个 id
let id = app.container.id
if (!id) {
id = 'single-spa-id-' + count++
app.container.id = id
}
// 将 body html 标签替换为子应用挂载容器的 id
return arr.join(',').replace(re, `#${id}`)
}
(6). 应用通信.
qiankun官方提供了一个简要的方案,思路是基于一个全局的globalState对象。
这个对象由基座应用负责创建,内部包含一组用于通信的变量,以及两个分别用于修改变量值和监听变量变化的方法:setGlobalState和onGlobalStateChange。
4,micro-app
使用方式
// vue2/src/main.js
import SimpleMicroApp from 'simple-micro-app'
SimpleMicroApp.start()
然后就可以在vue2项目中的任何位置使用micro-app标签。
<!-- page1.vue -->
<template>
<div>
<micro-app name='app' url='http://localhost:3001/'></micro-app>
</div>
</template>
(1). 利用custom-component引入
元素被插入到DOM时执行,此时去加载子应用的静态资源并渲染
// /src/element.js
// 自定义元素
class MyElement extends HTMLElement {
// 声明需要监听的属性名,只有这些属性变化时才会触发attributeChangedCallback
static get observedAttributes () {
return ['name', 'url']
}
constructor() {
super();
}
connectedCallback() {
// 创建微应用实例
const app = new CreateApp({ name: this.name, url: this.url, container: this, })
// 记入缓存,用于后续功能
appInstanceMap.set(this.name, app)
}
disconnectedCallback () {
// 元素从DOM中删除时执行,此时进行一些卸载操作
console.log('micro-app has disconnected')
}
attributeChangedCallback (attr, oldVal, newVal) {
// 元素属性发生变化时执行,可以获取name、url等属性的值
console.log(`attribute ${attrName}: ${newVal}`)
}
}
/**
* 注册元素
* 注册后,就可以像普通元素一样使用micro-app,当micro-app元素被插入或删除DOM时即可触发相应的生命周期函数。
*/
window.customElements.define('micro-app', MyElement)
(2). 加载入口.
//---- /src/app.js
import loadHtml from './source'
import Sandbox from './sandbox'
export default class CreateApp {
constructor ({ name, url, container }) {
loadHtml(this)
this.sandbox = new Sandbox(name)
}
mount () {
// 克隆DOM节点
const cloneHtml = this.source.html.cloneNode(true)
// 创建一个fragment节点作为模版,这样不会产生冗余的元素
const fragment = document.createDocumentFragment()
Array.from(cloneHtml.childNodes).forEach((node) => {
fragment.appendChild(node)
})
// 将格式化后的DOM结构插入到容器中
this.container.appendChild(fragment)
this.sandbox.start()
// 执行js
this.source.scripts.forEach((info) => {
(0, eval)(info.code)
})
}
unmount (destory) {
this.sandbox.stop()
// destory为true,则删除应用
if (destory) {
appInstanceMap.delete(this.name)
}
}
}
//--- src/source.js
import { fetchSource } from './utils'
export default function loadHtml (app) {
fetchSource(app.url).then((html) => {
html = html
.replace(/<head[^>]*>[\s\S]*?<\/head>/i, (match) => {
// 将head标签替换为micro-app-head,因为web页面只允许有一个head标签
return match
.replace(/<head/i, '<micro-app-head')
.replace(/<\/head>/i, '</micro-app-head>')
})
.replace(/<body[^>]*>[\s\S]*?<\/body>/i, (match) => {
// 将body标签替换为micro-app-body,防止与基座应用的body标签重复导致的问题。
return match
.replace(/<body/i, '<micro-app-body')
.replace(/<\/body>/i, '</micro-app-body>')
})
// 将html字符串转化为DOM结构
const htmlDom = document.createElement('div')
htmlDom.innerHTML = html
console.log('html:', htmlDom)
// 进一步提取和处理js、css等静态资源
extractSourceDom(htmlDom, app)
}).catch((e) => {
console.error('加载html出错', e)
})
}
(3). 创建沙箱.
创建Window的代理
// /src/sandbox.js
export default class SandBox {
active = false // 沙箱是否在运行
microWindow = {} // // 代理的对象
injectedKeys = new Set() // 新添加的属性,在卸载时清空
constructor () {
this.proxyWindow的代理 = new Proxy(this.microWindow, {
// 取值
get: (target, key) => {
// 优先从代理对象上取值
if (Reflect.has(target, key)) {
return Reflect.get(target, key)
}
// 否则兜底到window对象上取值
const rawValue = Reflect.get(window, key)
// 如果兜底的值为函数,则需要绑定window对象,如:console、alert等
if (typeof rawValue === 'function') {
const valueStr = rawValue.toString()
// 排除构造函数
if (!/^function\s+[A-Z]/.test(valueStr) && !/^class\s+/.test(valueStr)) {
return rawValue.bind(window)
}
}
// 其它情况直接返回
return rawValue
},
// 设置变量
set: (target, key, value) => {
// 沙箱只有在运行时可以设置变量
if (this.active) {
Reflect.set(target, key, value)
// 记录添加的变量,用于后续清空操作
this.injectedKeys.add(key)
}
return true
},
deleteProperty: (target, key) => {
// 当前key存在于代理对象上时才满足删除条件
if (target.hasOwnProperty(key)) {
return Reflect.deleteProperty(target, key)
}
return true
},
})
}
...
}
(4). js隔离.
// /src/sandbox.js
export default class SandBox {
...
// 修改js作用域
bindScope (code) {
window.proxyWindow = this.proxyWindow
return `;(function(window, self){with(window){;${code}\n}}).call(window.proxyWindow, window.proxyWindow, window.proxyWindow);`
}
}
// /src/app.js
export default class CreateApp {
mount () {
...
// 执行js
this.source.scripts.forEach((info) => {
(0, eval)(this.sandbox.bindScope(info.code))
})
}
}
(5).css隔离
const prefix = `micro-app[${appName}]`
//给css加上作用域,在选择器前面加前缀
dom.textContext = scopedRule(Array.from(dom.sheet.cssRules),prefix)
5,react MicroFrontend
100行代码写一个微前端组件
config ={
app:{
port:3000,
publicPath:'/app/',
renderkey:'App'
},
...
}
class MicroFrontend extend React.Component {
async componentDidMount(){
const {document,module} = this.props
const scriptId = `micro-frontend-script-${module}`
const linkId = `micro-frontend-link-${module}`
if(document.getElementById(scriptId) || document.getElementById(linkId)){
this.renderMicroFrontend()
return
}
const {port,publicPath}= config[module]
const host = process.env.NODE_ENV === 'development'?`localohost:${port}`:''
this.loadPage(host+publicPath,scriptId,linkId)
}
loadPage = async (path,scriptId,linkId){
const {data} = await axios.get(`${path}asset-mainfest.json`,{baseURL:'/'})
this.appendChild('link',{
rel:'stylesheet',
id:linkId,
type:'text/css',
href:data['main.css']
})
this.appendChild('script',{
id:scriptId,
type:'text/javascript',
href:data['main.js'],
onload:this.renderMicroFrontend
})
}
appendChild = (element,obj)=>{
let ele = document.createElement(element)
for (let k in obj) {
ele[k] = obj[k]
}
document.head.appendChild(ele)
}
renderMicroFrontend =() =>{
const {module,props} = this.props
let domId = `${module}-container`
const {renderkey} = config[module]
window[`render${renderkey}`]&& window[`render${renderkey}`](domId,props)
}
componentWillUnmount(){
const {module,props} = this.props
let domId = `${module}-container`
const {renderkey} = config[module]
window[`unmount${renderkey}`]&& window[`unmount${renderkey}`](domId)
}
render(){
return <div id={`this.props.module`}-container />
}
}
MicroFrontend.defaultProps = {
document,
module
}