初探微前端
微前端(Micro Frontend)是一种架构模式,它允许将一个大型的单页面应用程序(SPA)拆分成多个独立部署的小型前端应用。这些小型应用可以在不同的团队间开发、维护,并且可以独立地部署和升级。微前端架构能够带来多方面的优势,其中包括性能优化。
微前端的优势
- **可维护性:**将一个大的应用分解为多个小的、可管理的部分,使得每个部分都可以由不同的团队负责,提高了项目的可维护性。
- **可扩展性:**随着应用的增长,可以轻松地添加新的微前端应用,而不必担心对现有系统造成影响。
- **技术栈灵活性:**不同的微前端应用可以采用不同的技术和框架,使得团队可以根据需求选择最适合的技术栈。
- **独立部署:**每个微前端应用可以独立部署,无需等待整个大应用的重新构建和部署,加快了迭代速度。
- **性能优化:**微前端架构可以通过以下几个方面来优化项目性能:
- **按需加载:**微前端应用可以根据需要动态加载,这意味着用户只加载和执行他们当前需要的功能,减少了初始加载时间和资源消耗。
- **减小包体积:**每个微前端应用只需要打包和加载其自身所需的部分,而不是整个大应用的所有代码和资源,从而减小了包体积。
- **缓存策略:**由于每个微前端应用是独立的,可以针对不同的应用制定更精细的缓存策略,提高缓存命中率。
- **异步加载:**通过懒加载(Lazy Loading)等技术,可以在用户真正需要的时候再加载相关的微前端应用,进一步提升性能。
微前端的实现方式
iframe:
- 使用iframe标签将微前端应用嵌入到主应用中,通过iframe的src属性指定微前端应用的地址,实现微前端的加载。
- 需要通过postMessage进行通信。
- 完美的沙箱机制自带应用隔离
- 缺点:
- iframe无法直接访问主应用的DOM
- 双滚动条、弹框只能在iframe中,在内部切换刷新就会丢失状态..
single-spa
- single-spa通过路由劫持实现应用的加载(采用SystemJS),提供应用间公共组件加载及公共业务逻辑处理。子应用需要暴露固定的钩子bootstrap、mount、unmount 接入协议
- 基于props主子应用间通信,子应用通过props.onGlobalStateChange监听全局状态变化,通过props.setGlobalState设置全局状态。
- 缺点:
- 配置复杂,需要自己实现各种钩子,如:bootstrap、mount、unmount、update、unmount。
- 无沙箱机制,需要实现自己实现JS沙箱以及CSS沙箱
- 需要对原有的应用进行改造、子应用间相同资源重复加载问题
Module Federation
- 通过模块联邦将组件进行打包导出使用
- 共享模块的方式进行通信
- 无CSS沙箱和JS沙箱
- 缺点:需要webpack5
systemjs实现
- systemjs是如何定义的,先看打包后的结果,
System.register(依赖列表,callback返回一个setters、execute) - react、react-dom这种如果版本一致,可以提成公共模块,在生产环境打包的时候externals配置,减少重复打包。这样可以通过cdn引入,加载后调用setters,将对应的结果赋予给webpack
- 本质就是先加载依赖列表,再去加载真正的逻辑,内部通过script脚本加载资源(JSONP),通过window快照来保存先后状态
<script>
const newMapUrl = {}
function processScript(){
Array.from(document.querySelectorAll('script')).forEach(script=>{
if(script.type === 'systemjs-importmap'){
const imports = JSON.parse(script.innerHTML).imports
Object.keys(imports).forEach(key=> newMapUrl[key] = imports[key])
}
})
}
function load(id){
return new Promise((resolve, reject)=>{
const script = document.createElement('script')
script.src = newMapUrl[id] || id
script.async = true
document.body.appendChild(script)
script.addEventListener('load', ()=>{
let _lastRegister = lastRegister
lastRegister = null
resolve(_lastRegister)
})
})
}
let set = new Set() //先保存window上的属性
function saveGlobalProperty(){
for(let k in window){
set.add(k)
}
}
saveGlobalProperty()
function getLastGlobalProperty(){ //快照对比得到新增的属性
for(let k in window){
if(set.has(k)) continue
set.add(k) //保存新增的属性
return window[k]
}
}
let lastRegister
class SystemJS {
import(id){//这个id可以是一个第三方的路径cdn
return Promise.resolve(processScript()).then(()=>{
const lastSepIndex = location.href.lastIndexOf('/')
const baseUrl = location.href.slice(0, lastSepIndex + 1)
if(id.startsWith('./')){
return baseUrl + id.slice(2)
}
}).then((id)=>{
let execute
return load(id).then((register)=>{
let { setters, exe } = register[1](()=>{})
execute = exe
// execute 是真正执行的渲染逻辑
// setters 是用来保存加载后的资源,加载资源调用setters
return [register[0],setters]
}).then(([registeration, setters])=>{
return Promise.all(registeration.map((dep,i)=>{
return load(dep).then(res=>{
setters[i](getLastGlobalProperty())
})
}))
}).then(()=>{
return execute()
})
})
}
register(deps, declare){
lastRegister = [deps, declare]
}
}
const System = new SystemJS()
System.import('./app.js').then(res=>{
console.log('模块加载完毕')
})
</script>qiankun
qiankun是基础的微前端框架,基于single-spa,封装了微前端的实现细节,简化了微前端的配置,让开发者只需要关注微前端的实现,而无需关注微前端的实现细节。
工作原理
- 父应用的结构:有一个
<div id='container'></div>作为子应用的容器。 - qiankun 的处理: 当 qiankun 加载子应用时,它会进行以下操作:
- 创建一个 shadow DOM 或者使用一个简单的 div 作为子应用的沙箱容器。
- 在这个沙箱容器内,qiankun 会动态创建一个
<div id="root"></div>元素。 - 这个新创建的 div 会被插入到父应用中指定的容器(即
<div id='container'></div>)内。
- 子应用的渲染: 当子应用的 mount 函数被调用时,qiankun 会传递一个 props 对象,其中包含 container 属性。这个 container 就是 qiankun 为子应用创建的沙箱容器。
- 当子应用执行 container.querySelector('#root') 时,它实际上是在 qiankun 创建的沙箱容器内查找 id="root" 的元素,而不是在整个文档中查找。
主应用
主应用需要安装qiankun npm install qiankun,并通过registerMicroApps注册微应用,通过start启动微应用。
index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import './registerApp.js' // 注册子应用
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);registerApp.js
import { registerMicroApps, start } from 'qiankun'
const loader = (loading) => {
console.log('loading', loading)
}
registerMicroApps([
{
name: 'reactApp',
entry: '//localhost:10001', //默认reactApp启动的入口是10000端口
activeRule: '/react', //默认reactApp的匹配规则是/react
container: '#container', // 默认reactApp的挂载点是#container
loader
},
{
name: 'vueApp',
entry: '//localhost:10002',
activeRule: '/vue',
container: '#container',
loader
}
], {//lifecycles
beforeLoad: app => {
console.log('before load', app.name)
},
beforeMount: app => {
console.log('before mount', app.name)
},
afterMount: app => {
console.log('after mount', app.name)
},
beforeUnmount: app => {
console.log('before unmount', app.name)
},
afterUnmount: app => {
console.log('after unmount', app.name)
}
})
start()app.js
import { BrowserRouter, Link } from "react-router-dom";
import './App.css';
function App() {
return (
<div className="App">
<BrowserRouter>
<Link to="/react">React应用</Link>
<Link to="/vue">Vue应用</Link>
</BrowserRouter>
<div id='container'></div>
</div>
);
}
export default App;子应用(React)
子应用要注意render的容器修改,以及qiankun要求应用暴露的方式需要是umd格式 index.js
import './public_path' //注意放最开头,处理子应用静态资源加载路径问题
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
let root;
function render(props) {
const container = props.container
root = ReactDOM.createRoot(container ? container.querySelector('#root') : document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
}
// qiankun提供了一些标识,用于标识当前应用是否在父应用中被渲染
if (!window.__POWERED_BY_QIANKUN__) {
render({});
}
//qiankun要求 应用暴露的方式需要是umd格式
export async function bootstrap(props) {
console.log('[m-react] react app bootstraped', props);
}
export async function mount(props) {
console.log('[m-react] props from main framework', props);
//外层基座的容器叫container容器
render(props);
}
export async function unmount(props) {
root.unmount();
}如何修改creat-react-app的webpack配置
npm install @rescripts/cli --force- 新建一个rescript.config.js文件,内容如下:
module.exports = {
//重写配置之后记得package.json中把项目启动命令react-scripts start改为rescripts start
webpack: (config) => {
config.output.libraryTarget = 'umd';
config.output.library = 'm-react'; //打包的格式是umd格式
return config;
},
devServer: (config) => { //允许跨域
config.headers = {
'Access-Control-Allow-Origin': '*',
};
return config;
}
}- 修改package.json文件,添加如下内容:
"scripts": {
"start": "rescripts start",
"build": "rescripts build",
"test": "rescripts test",
"eject": "rescripts eject"
},另外要注意处理静态资源路径问题public_path.js
if (window.__POWERED_BY_QIANKUN__) {
//eslint-disable-next-line
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}新建.env文件保证端口号和主应用配置一致
PORT=10001
WDS_SOCKET_PORT=10001子应用(Vue)
//main.js
import './public_path.js'
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import App from './App.vue'
import routes from './router'
let app
let router
let history
function render(props) {
app = createApp(App)
history = createWebHistory(window.__POWERED_BY_QIANKUN__ ? '/vue' : '/')
router = createRouter({ history, routes })
app.use(router)
const container = props.container
app.mount(container ? container.querySelector('#app') : document.getElementById('app'))
}
if (!window.__POWERED_BY_QIANKUN__) {
render({})
}
export const bootstrap = async () => {
console.log('vue app bootstraped')
}
export const mount = async (props) => {
render(props)
}
export const unmount = async () => {
app.unmount()
history.destroy();
app = null;
router = null
}
//vue.config.js
const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
transpileDependencies: true,
devServer: {
port: 10002,
headers: {
'Access-Control-Allow-Origin': '*'
}
},
configureWebpack: {
output: {
library: 'm-vue',
libraryTarget: 'umd',
}
}
})
//route.js
import HomeView from '../views/HomeView.vue'
const routes = [
{
path: '/',
name: 'home',
component: HomeView
},
{
path: '/about',
name: 'about',
// route level code-splitting
// this generates a separate chunk (about.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import(/* webpackChunkName: "about" */ '../views/AboutView.vue')
}
]
export default routes子应用(Static)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>静态应用</title>
</head>
<body>
<div id="static"></div>
<script entry>//如果有多个脚本,需要加上 entry标识入口
const app = document.getElementById('static');
// 最终导出接入协议即可
function render() {
app.innerHTML = 'static'
}
if (!window.__POWERED_BY_QIANKUN__) {
render()
}
window['MeshEditor'] = {
bootstrap: async () => {
console.log('static bootstrap')
},
mount: async () => {
render()
},
unmount: async () => {
app.innerHTML = ''
}
}
</script>
</body>
</html>动态加载
import { BrowserRouter, Link } from "react-router-dom";
import './App.css';
import { useEffect, useRef } from "react";
import { loadMicroApp } from "qiankun";
function App() {
const containerRef = useRef(null);
useEffect(() => {
loadMicroApp({
name: 'm-static',
entry: '//localhost:10001',
container: containerRef.current,
props: { msg: 'hello world' }
})
}, [])
return (
<div className="App">
<BrowserRouter>
<Link to="/react">React应用</Link>
<Link to="/vue">Vue应用</Link>
</BrowserRouter>
<div ref={containerRef}></div>
<div id='container'></div>
</div>
);
}
export default App;sandbox
css沙箱
qiankun(3.0之前)的样式隔离
start({
sandbox: {
strictStyleIsolation: true, //缺点:样式会污染全局,无法控制
experimentalStyleIsolation: true //缺点:就是子应用中的dom元素如果挂在body上,会导致样式污染
}
})shadowDom实现
<div id="outer">abc</div>
<script>
//shadomDOM video audio标签
const template = `<div id="qiankun-xxx">
<div id="inner">bcd</div>
<style>div{color:red}</style>
</div>
`
const container = document.createElement('div')
container.innerHTML = template
const appElement = container.firstChild
let oldContent = appElement.innerHTML
appElement.innerHTML = '' //老的内容
let shadow = appElement.attachShadow({ mode: 'closed' })
shadow.innerHTML = oldContent
document.body.appendChild(appElement)
console.log(document.getElementById('inner')) //null
</script>js沙箱
js沙箱 防止应用加载的时候 对window造成污染
快照实现js沙箱 先保留a应用的属性 失活的时候 把修改的属性存起来 激活的时候还原 缺点:window快照 浪费内存
class SnapshotSandbox {
constructor() {
this.modifyPropsMap = {}
}
active() {
this.windowSnapShot = {}
// 保存当前window的属性
Object.keys(window).forEach(key => {
this.windowSnapShot[key] = window[key]
})
Object.keys(this.modifyPropsMap).forEach(key => {
window[key] = this.modifyPropsMap[key]
})
}
inactive() {
this.modifyPropsMap = {}
Object.keys(window).forEach(key => {
if (window[key] !== this.windowSnapShot[key]) {
this.modifyPropsMap[key] = window[key]
window[key] = this.windowSnapShot[key]
}
})
}
}
let sandbox = new SnapshotSandbox()
sandbox.active()
window.a = 100
sandbox.inactive()
console.log(window.a) // undefined
sandbox.active()
console.log(window.a) // 100proxy实现js沙箱
//proxy 实现沙箱
class LegacySandbox {
constructor() {
//1.修改的内容 2.新增的内容 3.不管修改还是新增的
this.modifyPropsMap = new Map()
this.addedPropsMap = new Map()
this.currentPropsMap = new Map()
//访问的时候通过window,设置的时候经过代理保存一遍
const fakeWindow = Object.create(null)
const proxy = new Proxy(fakeWindow, {
get: (target, key, receiver) => {
return window[key]
},
set: (target, key, value) => {
if (!window.hasOwnProperty(key)) {
this.addedPropsMap.set(key, value) //新增的属性
} else if (!this.modifyPropsMap.has(key)) {
this.modifyPropsMap.set(key, window[key]) //保存旧的内容
}
this.currentPropsMap.set(key, value)
window[key] = value //修改成最新的内容
}
})
this.proxy = proxy
}
setWindowProps(key, value) {
if (value === undefined) {
delete window[key]
} else {
window[key] = value //覆盖修改前的
}
}
active() {
this.currentPropsMap.forEach((value, key) => {
this.setWindowProps(key, value)
})
}
inactive() {
this.modifyPropsMap.forEach((value, key) => {
this.setWindowProps(key, value)
})
this.addedPropsMap.forEach((value, key) => {
this.setWindowProps(key, undefined)
})
}
}
let legacySandbox = new LegacySandbox()
legacySandbox.active()
legacySandbox.proxy.b = 300
legacySandbox.inactive()
console.log(window.b) // undefined
legacySandbox.active()
console.log(window.b) // 300基于proxy实现的沙箱可以节约内存 snapshot和基于proxy实现的sandbox只能单例的情况下使用,同时加载多个应用就会有混乱的风险
多个实例来实现沙箱
class ProxySandbox {
constructor() {
this.running = false
const fakeWindow = Object.create(null)
this.proxy = new Proxy(fakeWindow, {
get: (target, key) => {
return key in target ? target[key] : window[key]
},
set: (target, key, value) => {
if (this.running) {
target[key] = value
}
return true
}
})
}
active() {
if (!this.running) this.running = true
}
inactive() {
this.running = false
}
}
let sandbox1 = new ProxySandbox()
let sandbox2 = new ProxySandbox()
sandbox1.active()
sandbox2.active()
sandbox1.proxy.a = 100
sandbox2.proxy.a = 200
console.log(window.a, sandbox1.proxy.a, sandbox2.proxy.a) // undefined 100 200
sandbox1.inactive()
sandbox2.inactive()
sandbox1.proxy.a = 300
sandbox2.proxy.a = 400
console.log(window.a, sandbox1.proxy.a, sandbox2.proxy.a) // undefined 100 200工作随记集锦
子应用window的使用
使用 qiankun 来进行子应用拆分的时候遇到了以下问题 因为子应用是一个原生的 html 项目,所以有很多挂载在 window 上的属性以及方法 其中有一个鼠标事件处理的问题
propagateMouseEventToCanvas(event) {
if (!event.isSimulated) {
console.log(window ,'-------------------------------');
const eventType = event.type;
// Create and dispatch a simulated event to the canvas
const simulatedEvent = new MouseEvent(eventType, {
clientX: event.clientX,
clientY: event.clientY,
screenX: event.screenX,
screenY: event.screenY,
button: event.button,
buttons: event.buttons,
ctrlKey: event.ctrlKey,
shiftKey: event.shiftKey,
altKey: event.altKey,
metaKey: event.metaKey,
relatedTarget: event.relatedTarget,
bubbles: true,
cancelable: true,
view: window
});
simulatedEvent.isSimulated = true; // Custom property to mark the event as simulated
this._canvas.dispatchEvent(simulatedEvent);
}
}
btnsWrapper.addEventListener('mousemove', this.scene.propagateMouseEventToCanvas.bind(this.scene));
btnsWrapper.addEventListener('mousedown', this.scene.propagateMouseEventToCanvas.bind(this.scene));
btnsWrapper.addEventListener('mouseup', this.scene.propagateMouseEventToCanvas.bind(this.scene));打印的时候发现 window 为一个 proxy Object 并且控制台报错了
helpers.ts:122 Uncaught TypeError: Failed to construct 'MouseEvent': Failed to read the 'view' property from 'UIEventInit': Failed to convert value to 'Window'.目前有两个解决方案
- 把
view:window参数去除可以解决 - 在主应用中sandbox设置成false
子应用卡顿问题
我的子应用是一个模型编辑器,单独跑的时候不会有任何卡顿,但是接入qiankun跑起来的时候会在模型编辑过程中卡顿。 最终发现原因竟然是:控制台console.log的问题,去除子应用中的部分console.log,问题解决。