微前端
概念
微前端借鉴了微服务的理念,将一个庞大的应用拆分成多个独立灵活的小型应用
每个应用都可以独立开发,独立运行,独立部署,还可以随意组合,降低了耦合度,更加灵活
特性
技术栈无关:主框架不限制接入应用的技术栈,子应用可自主选择技术栈(vue,react,jq,ng等)
独立开发/部署:各个团队之间仓库独立,单独部署,互不依赖
增量升级:当一个应用庞大之后,技术升级或重构相当麻烦,而微应用具备渐进式升级的特性
独立运行时:微应用之间运行时互不依赖,有独立的状态管理
场景
后台管理系统
最外面一层可以当主应用,里面可以放不同的子应用,子应用不受技术的限制
Web商店(未来趋势)
例如一些导航网站,可以提供微前端的接入
网站可以入驻该导航网站,还可以提供一些API增加交互,有点类似小程序
小程序可以调用微信的一些能力例如支付,扫码等;导航也可以提供一些API,网站接入之后提供API调用
微前端方案
iframe方案
特点:
不足:
- DOM割裂感严重,弹框只能在iframe里,而且有滚动条
- 通讯非常麻烦,刷新iframe会导致url状态丢失
- 前进后退按钮无效
qiankun方案
qiankun方案是基于single-spa的微前端方案,官网
特点:
- html entry方式引入子应用,相比JS,entry极大的降低了应用改造的成本
- 完备的沙箱方案:为JS沙箱做了SnapshotSandbox、LegacySandbox、ProxySandbox三套渐进增强方案;为CSS沙箱做了strictStyleIsolation、experimentalStyleIsolation两套适用不同场景的方案
- 静态资源能够预加载
不足:
- 适配成本较高,工程化、生命周期、静态资源路径、路由等都要做一系列的适配工作
- CSS沙箱采用严格隔离会有各种问题,JS沙箱在某些场景下执行性能下降严重
- 无法同时激活多个子应用,也不支持子应用保活
- 无法支持Vite等ESModule脚本运行
底层原理:
- JS沙箱使用proxy进行快照后,用
with(window){}
包裹,with内的window其实就是proxy.window,声明变量var name = 'yajue'
时,实际这个变量挂到了proxy.window,而不是真正的window - CSS沙箱,前者为shadowDOM隔离,后者类似Vue的scoped(
[data-qiankun-426732]
)
micro-app 方案
micro-app是基于webcomponent+qiankun sandbox的微前端方案,官网
特点:
- 使用webcomponet加载子应用,相比single-spa,这种注册监听方案更加优雅
- 复用经过大量项目验证过的qiankun沙箱机制,使框架更加可靠
- 组件式api更符合使用习惯,支持子应用保活
- 降低子应用改造成本,提供静态资源预加载能力
不足:
接入成本与qiankun相比有所降低,但是路由依然存在依赖(虚拟路由已解决)多应用激活后,无法保持各子应用的路由状态,刷新后会全部丢失(虚拟路由已解决)- CSS沙箱依然无法绝对隔离,但JS沙箱使用全局变量查找缓存,性能有所优化
- 支持Vite运行,但必须使用plugin改造子应用,且其JS代码无法沙箱隔离,非常不友好
- 对于不支持webcompnent的浏览器没有做降级处理
底层原理:
- JS隔离跟qiankun类似,也是使用proxy + with
- CSS隔离使用自定义前缀,类似scoped
EMP方案
EMP方案是基于Webpack5模块联邦的微前端方案,官网
特点:
- Webpack联邦编译可以保证所有子应用依赖解耦
- 应用间去中心化的调用、共享模块
- 模块远程TS支持;
不足
- 对Webpack强依赖,所以对老旧项目不友好
- 没有有效的CSS沙箱和JS沙箱,需要靠用户自觉
- 子应用保活、多应用激活无法实现
- 主、子应用的路由可能冲突
底层原理:类似拆包,也可以叫模块共享,例如React模块可以共享给Vue项目用;Vue2的组件可以共享给Vue3用
无界方案
官网
特点:
- 接入简单,只需四五行代码
- 不需要针对Vite额外处理
- 预加载
- 应用保活机制
不足
- 需要使用一个空的iframe隔离JS
- 子应用的Axios需要自行适配
- iframe沙箱的src设置了主应用的host,初始化iframe时,需要等iframe的
location.origin
,从’about:blank’初始化为主应用的host,此处采用计时器等待,不是很优雅
底层原理:
- 使用shadowDom隔离CSS
- 使用空的iframe隔离JS
- 使用proxy通讯
微前端前置知识
webComponents
相当于原生JS实现的组件,拥有样式隔离、外部传参等特性
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| <!DOCTYPE html> <html lang="en">
<head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> <script src="./index.js"></script> </head>
<body> <ya-jue msg="114514" age="24"></ya-jue> <div>我是div</div>
<template id="yajue"> <style> div { background-color: cyan; } </style> <div> 我是template </div> </template> </body> </html>
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
| window.onload = ()=> { class Yajue extends HTMLElement { constructor() { super() let dom = this.attachShadow({mode: 'open'}) let template = document.querySelector('#yajue') as HTMLTemplateElement
dom.appendChild(template.content.cloneNode(true))
console.log(this.getAttr('msg'), this.getAttr('age')) }
private getAttr(attr: string) { return this.getAttribute(attr) }
connectedCallback () { console.log('类似Vue的Mounted') }
disconnectedCallback () { console.log('类似Vue的Destory') }
attributeChangedCallback (name: any, oldVal: any, newVal: any) { console.log('类似Vue的Watch,有属性变化时自动触发') } }
window.customElements.define('ya-jue', Yajue) }
|
monorepo架构
微前端通常是一个主应用+多个子应用的结构,如果每个子应用都得用install安装一次依赖,费时费力
采用monorepo架构,一次install即可安装所有子应用的依赖
目录结构
此处的vue-project是主应用,web文件夹内是数个子应用
配置monorepo
在根目录新建pnpm-workspace.yaml文件,写入配置:
1 2 3 4 5
| packages: - 'vue-project/*' - 'web/**'
|
配置完成后,在根目录使用一次install即可,pnpm会将所有公共依赖项抽离到外层,而里层依赖项都是最核心的
如果想在根目录单独开启某一个子应用服务,可以使用
如果想在根目录设立全局代码,使主应用和每个子应用都可引用,可以使用
1
| pnpm -F [应用名] add [全局代码文件夹名]
|
无界微前端
简单使用
无界为一些框架封装了组件,可以直接使用,需要安装依赖:
1 2 3
| npm i wujie-vue2 -S # vue2用的 npm i wujie-vue3 -S # vue3用的 npm i wujie-react -S # react用的
|
此处主应用是Vue3,main.ts中,可以将wujie像插件一样应用:
1 2 3 4 5 6 7 8
| import './assets/main.css'
import { createApp } from 'vue' import App from './App.vue' import Wujie from 'wujie-vue3'
createApp(App).use(Wujie).mount('#app')
|
在App.vue中使用组件引入子应用,其中name是子应用名、url是子应用地址:
1 2 3 4 5 6 7 8 9 10 11
| <template> <div> <h1>我是主应用</h1> <div style="display: flex;"> <!-- Vue3子应用 --> <WujieVue name="vue3" url="http://127.0.0.1:5174/" width="50%"></WujieVue> <!-- React子应用 --> <WujieVue name="react" url="http://127.0.0.1:5175/" width="50%"></WujieVue> </div> </div> </template>
|
两个子应用成功被引入主应用了:
wujie-vue3
wujie-vue3是作者封装的可在Vue3中快速使用的包,此处对封装过程进行还原
目录:
安装依赖:
1 2 3 4 5
| pnpm i wujie pnpm i vue -d pnpm i webpack webpack-cli -d pnpm i typescript -d pnpm i ts-loader -d
|
初始化wujie子应用需要一些配置项,编写配置项类型:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| import type { plugin } from 'wujie' type lifecycle = (appWindow: Window) => any; interface Props { width: string; height: string; name: string; url: string; html?: string; loading?: HTMLElement; sync?: boolean; prefix?: { [key: string]: string }; alive?: boolean; props?: { [key: string]: any }; fiber?: boolean; degrade?: boolean; attrs?: { [key: string]: any }; degradeAttrs?: { [key: string]: any }; replace?: (codeText: string) => string; fetch?: (input: RequestInfo, init?: RequestInit) => Promise<Response>; plugins: Array<plugin>; beforeLoad?: lifecycle; beforeMount?: lifecycle; afterMount?: lifecycle; beforeUnmount?: lifecycle; afterUnmount?: lifecycle; activated?: lifecycle; deactivated?: lifecycle; };
export { Props }
|
封装wujie子应用的Vue组件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107
| import { defineComponent, h, getCurrentInstance, onMounted, watch, onBeforeUnmount } from 'vue' import { startApp, bus } from 'wujie' import type { PropType } from 'vue' import { Props } from './type' import type { App } from 'vue'
const wujie = defineComponent({ props: { width: { type: String, default: "" }, height: { type: String, default: "" }, name: { type: String, default: "", required: true }, loading: { type: HTMLElement, default: undefined }, url: { type: String, default: "", required: true }, sync: { type: Boolean, default: undefined }, prefix: { type: Object, default: undefined }, alive: { type: Boolean, default: undefined }, props: { type: Object, default: undefined }, attrs: { type: Object, default: undefined }, replace: { type: Function as PropType<Props['replace']>, default: undefined }, fetch: { type: Function as PropType<Props['fetch']>, default: undefined }, fiber: { type: Boolean, default: undefined }, degrade: { type: Boolean, default: undefined }, plugins: { type: Array as PropType<Props['plugins']>, default: null }, beforeLoad: { type: Function as PropType<Props['beforeLoad']>, default: null }, beforeMount: { type: Function as PropType<Props['beforeMount']>, default: null }, afterMount: { type: Function as PropType<Props['afterMount']>, default: null }, beforeUnmount: { type: Function as PropType<Props['beforeUnmount']>, default: null }, afterUnmount: { type: Function as PropType<Props['afterUnmount']>, default: null }, activated: { type: Function as PropType<Props['activated']>, default: null }, deactivated: { type: Function as PropType<Props['deactivated']>, default: null }, },
setup(props: Props, { emit }) { const instance = getCurrentInstance()
const init = () => { startApp({ name: props.name, url: props.url, el: instance?.refs.wujie as HTMLElement, loading: props.loading, alive: props.alive, fetch: props.fetch, props: props.props, attrs: props.attrs, replace: props.replace, sync: props.sync, prefix: props.prefix, fiber: props.fiber, degrade: props.degrade, plugins: props.plugins, beforeLoad: props.beforeLoad, beforeMount: props.beforeMount, afterMount: props.afterMount, beforeUnmount: props.beforeUnmount, afterUnmount: props.afterUnmount, activated: props.activated, deactivated: props.deactivated, }) }
const handlerEmit = (event: string, ...args: any[])=> { emit(event, ...args) }
onMounted(()=> { bus.$onAll(handlerEmit) init() })
watch([props.name, props.url], ()=> { init() })
onBeforeUnmount(()=> { bus.$offAll(handlerEmit) })
return () => h('div', { style: { width: props.width, height: props.height, }, ref: 'wujie' }) } })
wujie.install = function(app: App) { app.component("WujieVue", wujie) }
export default wujie
|
webpack打包配置,使用了新一代的JS编译器SWC而不是babel:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| const { Configuration } = require("webpack") const path = require('path')
const config = { entry: './src/index.ts', mode: "none", output: { filename: 'index.js', path: path.resolve(__dirname, 'lib'), library: "wujieVue", libraryTarget: "umd", umdNamedDefine: true }, externals: { vue: "vue", wujie: "wujie" }, module: { rules: [ { test: /\.ts$/, use: 'swc-loader' } ] } }
module.exports = config
|
为了打包ESM模块,还需要配置.swcrc
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| { "$schema": "https://json.schemastore.org/swcrc", "jsc": { "parser": { "syntax": "typescript" }, "target": "es5", "loose": false, "externalHelpers": false, "keepClassNames": false }, "minify": false }
|
编写声明文件index.d.ts:
1 2 3 4 5 6 7 8
| import { bus, preloadApp, destroyApp, setupApp } from "wujie" import type { App } from 'vue' declare const WujieVue: { install: (app: App) => void } export default WujieVue
|
配置package.json:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
| { "name": "test3", "version": "1.0.0", "description": "", "main": "lib/index.js", "module": "esm/index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "lib": "webpack", "esm": "swc src/index.ts -d esm" }, "files": [ "lib", "esm", "index.d.ts" ], "keywords": [], "author": "", "license": "ISC", "types": "./index.d.ts", "dependencies": { "@swc/cli": "^0.1.62", "@swc/core": "^1.3.78", "swc-loader": "^0.2.3", "wujie": "^1.0.18" }, "devDependencies": { "ts-loader": "^9.4.4", "typescript": "^5.1.6", "vue": "^3.3.4", "webpack": "^5.88.2", "webpack-cli": "^5.1.4" } }
|
预加载
使用
使用预加载,需要从wujie的实例导出preloadApp,参数需和startApp一致,预加载必须开启exec选项
1 2 3 4 5 6 7 8 9 10 11 12
| import { createApp } from 'vue' import App from './App.vue' import Wujie from 'wujie-vue3' import { preloadApp } from 'wujie'
const app = createApp(App)
app.use(Wujie).mount('#app')
preloadApp({name:"vue3", url:"http://127.0.0.1:5174", exec: true}) preloadApp({name:"react", url:"http://127.0.0.1:5175", exec: true})
|
原理
配置项中,fiber的默认值为true,由于子应用的执行会阻塞主应用的渲染线程,当fiber为true时,JS会采取类似react fiber的模式进行间断执行
每个JS文件的执行都被包裹在requestIdleCallback方法中,每执行一个JS可以返回响应外部的输入,但颗粒度取决于JS文件
如果子应用单个JS文件过大,可以通过拆包的形式降低,使fiber模式效益最大化
MDN:window.requestIdleCallback()
方法接收一个函数,这个函数将在浏览器空闲时期被调用。这使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件,如动画和输入响应。函数一般会按先进先调用的顺序执行,然而,如果回调函数指定了执行超时时间timeout
,则有可能为了在超时前执行函数而打乱执行顺序
requestIdleCallback会在浏览器空闲时执行,一般电脑的屏幕刷新率为60Hz(一秒钟对屏幕进行60次刷新)
浏览器的一帧(约16.6ms)做了很多事:
- 处理用户事件event,例如click,input、change等
- 执行定时器任务
- 执行requestAnimationFrame
- 执行DOM的回流与重绘
- 计算更新图层的绘制指令
- 绘制指令合并主线程
如果一帧时间内,该做的事全都做完了,那么剩下的时间就是空闲时间(但一般来说这个时间很少)
如果完全没有任务执行,浏览器就会获得50ms空闲时间
1 2 3 4 5
| requestIdleCallback(function(deadline) { console.log(deadline.timeRemaining()) })
|
react也有预加载机制,但是react并未使用requestIdleCallback
据说是requestIdleCallback经过测试可能会使一帧超过16.6ms,这样视觉上就会变卡
所以react16中使用requestAnimationFrame + postMessage进行了实现
react18又换为MessageChannel实现,采用队列方式去执行任务
另外,即使将setTimeout的延时设置为0 也会产生最小4ms毫秒的延迟
传参
wujie将子应用的JS存放于iframe内,所以可以通过windows和子应用通讯
全局变量
在主应用内定义一个全局变量:
子应用可以访问到它:
1
| console.log(window.parent.yajue)
|
props
在主应用通过props给子应用注入参数:
1
| <WujieVue :props="omg" name="vue3" url="http://127.0.0.1:5174/" width="50%" v-if="show"></WujieVue>
|
子应用可以获取到它:
1
| console.log(window.$wujie.props)
|
为防止报错,可以添加声明文件:
1 2 3 4 5 6 7
| declare global { interface Window { $wujie: { props: Record<string, any> } } }
|
Event Bus
主应用可以发送或监听事件:
1 2 3 4 5 6 7
| bus.$on('vue3', (data: any)=> { console.log(data, "主应用收到事件啦!") })
bus.$emit('main', "主应用发话啦!")
|
子应用也可以:
1 2 3 4 5 6 7
| window.$wujie.bus.$emit('vue3', '子应用发话啦!')
window.$wujie.bus.$on('main', (data: any)=> { console.log(data, "子应用收到事件啦!") })
|
模块联邦技术
模块联邦技术是否是一种微前端尚且存在争议,但它可以让多个独立构建的应用之间动态地调用彼此的模块
这使得应用可轻易地被拆分,做到跨应用模块共享
模块联邦技术和webpack5强耦合,是内置的webpack5插件
构建两个项目,host(主应用)和remote(远程应用):
remote项目可以使用自己的模块
remote/bootstrap.js:
1 2 3 4 5 6
| import { addList } from "./list"
let app = document.getElementById("app")
app.innerHTML = "<h2>remote</h2>" addList()
|
remote/list.js:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
|
let wrap = document.createElement("div")
let list = [ {name:"aaa",age:21}, {name:"bbb",age:22}, {name:"ccc",age:23}, {name:"ddd",age:24}, {name:"eee",age:25} ]
list.forEach(item=> { let p = document.createElement("p") p.innerHTML = `${item.name} - ${item.age}` wrap.appendChild(p) })
export const addList = ()=> { document.body.appendChild(wrap) }
|
remote/index.js:
host项目想要使用remote项目的模块
host/bootstrap.js:
1 2 3 4 5 6
| import("remote/addList").then(({ addList }) => { let app = document.getElementById("app")
app.innerHTML = `<h2>host</h2>` addList() })
|
host/index.js:
但这样一定会报错,因为没有配置模块联邦
remote项目的webpack配置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| const { Configuration } = require('webpack') const HtmlWebpackPlugin = require('html-webpack-plugin')
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin")
const config = { mode: 'none', entry: './index.js', output: { filename: 'bundle.js' }, devServer: { port: 11451, }, plugins: [ new HtmlWebpackPlugin({ template: './index.html' }), new ModuleFederationPlugin({ name: "remote", filename: "remoteEntry.js", exposes: { "./addList": "/list.js" } }) ] }
module.exports = config
|
host项目的webpack配置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| const { Configuration } = require('webpack') const HtmlWebpackPlugin = require('html-webpack-plugin') const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin")
const config = { mode: 'none', entry: './index.js', output: { filename: 'bundle.js' }, devServer: { port: 11452, }, plugins: [ new HtmlWebpackPlugin({ template: './index.html' }), new ModuleFederationPlugin({ name: "host", remotes: { remote: "remote@http://localhost:11451/remoteEntry.js" } }) ] }
module.exports = config
|
成功了,host项目能使用remote项目的模块了
将主应用host打包后,会发现,主应用使用了CDN方式导入远程应用的模块
假设多个项目需要共用一个模块,使用NPM包,每个项目都需要install,倘若包版本更新,还得再给每个项目install新版本;而CDN引入时,一定会引入当前的最新版本