Skip to content

初探微前端 ​

微前端(Micro Frontend)是一种架构模式,它允许将一个大型的单页面应用程序(SPA)拆分成多个独立部署的小型前端应用。这些小型应用可以在不同的团队间开发、维护,并且可以独立地部署和升级。微前端架构能够带来多方面的优势,其中包括性能优化。

微前端的优势 ​

  1. **可维护性:**将一个大的应用分解为多个小的、可管理的部分,使得每个部分都可以由不同的团队负责,提高了项目的可维护性。
  2. **可扩展性:**随着应用的增长,可以轻松地添加新的微前端应用,而不必担心对现有系统造成影响。
  3. **技术栈灵活性:**不同的微前端应用可以采用不同的技术和框架,使得团队可以根据需求选择最适合的技术栈。
  4. **独立部署:**每个微前端应用可以独立部署,无需等待整个大应用的重新构建和部署,加快了迭代速度。
  5. **性能优化:**微前端架构可以通过以下几个方面来优化项目性能:
    • **按需加载:**微前端应用可以根据需要动态加载,这意味着用户只加载和执行他们当前需要的功能,减少了初始加载时间和资源消耗。
    • **减小包体积:**每个微前端应用只需要打包和加载其自身所需的部分,而不是整个大应用的所有代码和资源,从而减小了包体积。
    • **缓存策略:**由于每个微前端应用是独立的,可以针对不同的应用制定更精细的缓存策略,提高缓存命中率。
    • **异步加载:**通过懒加载(Lazy Loading)等技术,可以在用户真正需要的时候再加载相关的微前端应用,进一步提升性能。

微前端的实现方式 ​

  1. iframe:

    • 使用iframe标签将微前端应用嵌入到主应用中,通过iframe的src属性指定微前端应用的地址,实现微前端的加载。
    • 需要通过postMessage进行通信。
    • 完美的沙箱机制自带应用隔离
    • 缺点:
      • iframe无法直接访问主应用的DOM
      • 双滚动条、弹框只能在iframe中,在内部切换刷新就会丢失状态..
  2. single-spa

    • single-spa通过路由劫持实现应用的加载(采用SystemJS),提供应用间公共组件加载及公共业务逻辑处理。子应用需要暴露固定的钩子bootstrap、mount、unmount 接入协议
    • 基于props主子应用间通信,子应用通过props.onGlobalStateChange监听全局状态变化,通过props.setGlobalState设置全局状态。
    • 缺点:
      • 配置复杂,需要自己实现各种钩子,如:bootstrap、mount、unmount、update、unmount。
      • 无沙箱机制,需要实现自己实现JS沙箱以及CSS沙箱
      • 需要对原有的应用进行改造、子应用间相同资源重复加载问题
  3. Module Federation

    • 通过模块联邦将组件进行打包导出使用
    • 共享模块的方式进行通信
    • 无CSS沙箱和JS沙箱
    • 缺点:需要webpack5

systemjs实现 ​

  1. systemjs是如何定义的,先看打包后的结果,System.register(依赖列表,callback返回一个setters、execute)
  2. react、react-dom这种如果版本一致,可以提成公共模块,在生产环境打包的时候externals配置,减少重复打包。这样可以通过cdn引入,加载后调用setters,将对应的结果赋予给webpack
  3. 本质就是先加载依赖列表,再去加载真正的逻辑,内部通过script脚本加载资源(JSONP),通过window快照来保存先后状态
html
<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,封装了微前端的实现细节,简化了微前端的配置,让开发者只需要关注微前端的实现,而无需关注微前端的实现细节。

工作原理 ​

  1. 父应用的结构:有一个 <div id='container'></div> 作为子应用的容器。
  2. qiankun 的处理: 当 qiankun 加载子应用时,它会进行以下操作:
    • 创建一个 shadow DOM 或者使用一个简单的 div 作为子应用的沙箱容器。
    • 在这个沙箱容器内,qiankun 会动态创建一个 <div id="root"></div> 元素。
    • 这个新创建的 div 会被插入到父应用中指定的容器(即 <div id='container'></div>)内。
  3. 子应用的渲染: 当子应用的 mount 函数被调用时,qiankun 会传递一个 props 对象,其中包含 container 属性。这个 container 就是 qiankun 为子应用创建的沙箱容器。
  4. 当子应用执行 container.querySelector('#root') 时,它实际上是在 qiankun 创建的沙箱容器内查找 id="root" 的元素,而不是在整个文档中查找。

主应用 ​

主应用需要安装qiankun npm install qiankun,并通过registerMicroApps注册微应用,通过start启动微应用。

index.js

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

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

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

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配置

  1. npm install @rescripts/cli --force
  2. 新建一个rescript.config.js文件,内容如下:
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;
  }
}
  1. 修改package.json文件,添加如下内容:
js
  "scripts": {
    "start": "rescripts start",
    "build": "rescripts build",
    "test": "rescripts test",
    "eject": "rescripts eject"
  },

另外要注意处理静态资源路径问题public_path.js

js
if (window.__POWERED_BY_QIANKUN__) {
  //eslint-disable-next-line
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}

新建.env文件保证端口号和主应用配置一致

js
PORT=10001
WDS_SOCKET_PORT=10001

子应用(Vue) ​

js
//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) ​

html
<!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>

动态加载 ​

js
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之前)的样式隔离

js
start({ 
  sandbox: { 
    strictStyleIsolation: true, //缺点:样式会污染全局,无法控制
    experimentalStyleIsolation: true //缺点:就是子应用中的dom元素如果挂在body上,会导致样式污染
  } 
})

shadowDom实现

html
 <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快照 浪费内存

js
    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) // 100

proxy实现js沙箱

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只能单例的情况下使用,同时加载多个应用就会有混乱的风险

多个实例来实现沙箱

js
    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 上的属性以及方法 其中有一个鼠标事件处理的问题

js
 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 并且控制台报错了

js
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,问题解决。

qiankun ​