微前端应用(singl-spa和qiankun)

916 阅读7分钟

什么是微前端

微前端是一种类似微服务的架构,它将微服务的理念应用于浏览器端,即将单页面前端应用由单一的单体应用转变为把多个小型应用聚合唯一的应用。各个前端应用还可以独立开发、独立部署。

路由分发式微前端

路由分发式架构应该目前是采用最多、最容易的微前端方案。路由分发式微前端,即通过路由将不同的业务分发到不同的独立前端应用中。

single-spa 微前端框架

single-spa

single-spa 是一个前端微服务化的 Javascript 前端解决方案(本身没有处理样式、js执行隔离),实现了路由劫持和应用加载

使用

本实例基座和子应用都是用vue技术栈:

  1. 基座提供注册逻辑
  2. 子应用提供三个协议和打包方式

基座设置

  1. 基座应用安装single-spa

    yarn add single-spa -S

  2. 修改main.js入口文件

    import Vue from 'vue'
    import App from './App.vue'
    import router from './router'
    import { registerApplication, start } from 'single-spa'
    
    Vue.config.productionTip = false
    
    const loadScript = async (url) => {
      return new Promise((resolve, reject) => {
        const script = document.createElement('script')
        script.src = url
        script.onload = resolve
        script.onerror = reject
        document.head.appendChild(script)
      })
    }
    
    registerApplication('vueApp',
      async () => {
        console.log('加载vue子应用')
        // 加载子应用打包好的lib,
        await loadScript('http://localhost:10001/js/chunk-vendors.js')
        await loadScript('http://localhost:10001/js/app.js')
        return window.singleVue
      },
      location => location.pathname.startsWith('/vue') // 用户切换到 /vue 的路径下加载vue应用
    )
    
    start()
    
    new Vue({
      router,
      render: h => h(App)
    }).$mount('#app')
    
    

    在基座中调用 single-spa 中提供给我们的 registerApplicationstart

    1. registerApplication 参数有四个
    • appName: string: 子应用项目名
    • applicationOrLoadingFn: Application<{}>: 加载子应用打包的lib文件
    • activityFn: ActivityFn:加载子应用的规则,匹配路由
    • customProps?: {} | CustomPropsFn<{}>:通信参数,可以向子应用传值
    1. start 函数
    • start函数用于启动应用
  3. App.vue 设置

    <template>
       <div id="app">
         <router-link to='/vue'>加载vue应用</router-link>
         <!-- 加载vue应用 -->
         <div id="parent"></div>
       </div>
     </template>
    
     <style>
    
     </style>
    

    在基座app.vue 中设置一个跳转路由地址/vue

    <div id="parent"></div> 用于放置子应用。

子应用(vue)

  1. 在子类项目中引入single-spa-vue

    yarn add single-spa-vue -S

  2. 配置子应用 main.js 文件

    import Vue from 'vue'
    import App from './App.vue'
    import router from './router'
    import singleSpaVue from 'single-spa-vue'
    
    Vue.config.productionTip = false
    
    const appOptions = {
      el: '#parent', // 挂载到父应用的中
      router,
      render: h => h(App)
    }
    const vueLifeCycle = singleSpaVue({
      Vue,
      appOptions
    })
    
    // 如果父应用引用我
    if (window.singleSpaNavigate) {
      __webpack_public_path__ = 'http://localhost:10001/'
    } else {
      new Vue({
        router,
        render: h => h(App)
      }).$mount('#app')
    }
    
    // 协议接入 父应用会调用这些方法
    export const bootstrap = vueLifeCycle.bootstrap
    export const mount = vueLifeCycle.mount
    export const unmount = vueLifeCycle.unmount
    
    1. 微应用会向window中注册window.singleSpaNavigate,子应用可以通过singleSpaNavigate,来判断子应用的启动方式,通过基座加载子应用和只启动子应用。
    2. 子应用导出 bootstrapmountunmount三个协议,基座应用会调用这些方法。
  3. 配置 vue.config.js 文件

    const { defineConfig } = require('@vue/cli-service')
    module.exports = defineConfig({
      transpileDependencies: true,
      configureWebpack: {
        output: {
          library: 'singleVue',
          libraryTarget: "umd"
        },
        devServer: {
          port: 10001
        }
      }
    })
    
    1. 配置打包方式umd
    • library的值在所有子应用中需要唯一

    • 导出umd格式的包,在全局对象上挂载属性package.name,基座应用需要通过这个全局对象获取一些信息,比如子应用导出的生命周期函数

    1. 服务开启的端口号

qiankun

qiankun

qiankun 基于single-spa, 提供了更加开箱即用的 API ( single-spa + sandbox+ import-html-entry ) 做到了技术栈无关、并且接入简单(像iframe 一样简单)。qiankun是通过fetch方法直接把html插入到容器里,所以子项目需要允许跨域。

使用

应用组成:

  1. 基座使用vue-cli创建的 vue2 项目。
  2. 子应用分别是 vue-cli创建的 vue2 和 create-react-app创建的 react18 项目。

项目地址

安装与配置

在创建完成基座应用和子应用后需要在基座应用中安装 qiankun,yarn add qiankun -S

基座应用

  1. 修改 main.js 文件,配置基座入口文件
    import Vue from 'vue'
    import App from './App.vue'
    import router from './router'
    import ElementUI from 'element-ui'
    import 'element-ui/lib/theme-chalk/index.css'
    import { registerMicroApps, start } from 'qiankun'
    const apps = [
      {
        name: 'vueApp', // 应用名字
        entry: '//localhost:10002', // 默认会加载这个html 解析里面的 js 动态执行 (子应用必须支持跨域)
        container: '#vue',
        activeRule: '/vue',
        props: { msg: '向子应用传值', id: 111 }
      },
      {
        name: 'reactApp',
        entry: '//localhost:10003', // 默认会加载这个html 解析里面的 js 动态执行 (子应用必须支持跨域)
        container: '#react',
        activeRule: '/react'
      }
    ]
    
    registerMicroApps(apps) // 注册应用
    start({
      prefetch: false // 预加载
    }) // 开启
    
    Vue.use(ElementUI)
    
    Vue.config.productionTip = false
    
    new Vue({
      router,
      render: h => h(App)
    }).$mount('#app')
    
    从qiankun中导入### registerMicroApps(apps, lifeCycles?)start:
    • 配置子应用(apps)的项目名称(name)、入口地址(entry)、在父应用的放置容器(container)、路由匹配规则(activeRule)、还可配置向子应用的传参(props)
    • 配置全局的微应用生命周期(lifeCycles
    • 开启应用(start)
    • 另外,基座应用使用了element-ui,采用全局引入。
  2. 基座 app.vue 文件
    <template>
      <div class="base_app">
        <!-- 基座可以使用自己的路由也可以引用其他应用 -->
        <el-menu :router='true' mode='horizontal'>
          <el-menu-item index="/">base应用</el-menu-item>
          <el-menu-item index="/vue">vue应用</el-menu-item>
          <el-menu-item index="/react">react应用</el-menu-item>
        </el-menu>
        <router-view/>
        <div id="vue"></div>
        <div id="react"></div>
      </div>
    </template>
    
    <style>
    
    </style>
    
    app.vue 文件配置了一个导航包含:
  • 基座自己的路由即放置基座组件的 router-view 标签
  • 跳转子vue应用的跳转链接和vue应用的 #vue 挂载点。
  • 跳转子react应用的跳转链接和react应用的 #react 挂载点。
  1. 基座结果展示 image.png vue子应用

  2. 子应用的 main.js 文件

    import Vue from 'vue'
    import App from './App.vue'
    import VueRouter from 'vue-router'
    import routes from './router'
    
    // Vue.config.productionTip = false
    
    let instance = null
    
    function render (props) {
      const router = new VueRouter({
        base: window.__POWERED_BY_QIANKUN__ ? '/vue' : '/',
        mode: 'history',
        routes
      })
      instance = new Vue({
        router,
        render: h => h(App)
      }).$mount('#vue_app') // 挂载到自己的html 中,基座会拿到这个挂载后的html 将其插入进去
    }
    
    // 使用 webpack 运行时 publicPath 配置
    if (window.__POWERED_BY_QIANKUN__) {
      __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__
    }
    
    // 独立运行微应用
    if (!window.__POWERED_BY_QIANKUN__) {
      render()
    }
    
    // 定义子组件的协议
    export async function bootstrap (props) {
      console.log('vue 子应用 bootstrap', props)
    }
    export async function mount (props) {
      console.log('vue 子应用 mount', props)
      render(props)
    }
    export async function unmount (props) {
      console.log('vue 子应用 unmount', props)
      instance.$destroy()
    }
    

    子应用main.js配置:

    1. 定义渲染函数 render(): 根据qiankun提供的全局变量 window.__POWERED_BY_QIANKUN__,判断当前应用是否是由qiankun启动的子应用,如果是:设置router的基路由为 /vue,否则:还是使用/。然后挂载到自己应用的容器中。
    2. 如果是微应用启动设置webpack运行时路径publicPath,保证基座应用导入子应用打包的文件时http地址为子应用的地址。
    3. 如果是当前应用自启动(独立运行)时,直接执行 render()
    4. 导出 bootstrapmountunmount子应用生命周期钩子
    • props:基座应用传递的值 image.png
    • unmount中卸载当前应用的实例instance.$destroy()
  3. 子应用路由文件

    import Vue from 'vue'
    import VueRouter from 'vue-router'
    import HomeView from '../views/HomeView.vue'
    
    Vue.use(VueRouter)
    
    const routes = [
     {
       path: '/',
       name: 'home',
       component: HomeView
     },
     {
       path: '/about',
       name: 'about',
       component: () => import(/* webpackChunkName: "about" */ '../views/AboutView.vue')
     }
    ]
    export default routes
    
    
  4. vue.config.js

    const { defineConfig } = require('@vue/cli-service')
    module.exports = defineConfig({
     transpileDependencies: true,
     configureWebpack: {
       output: {
         library: 'vueApp',
         libraryTarget: 'umd'
       }
     },
     devServer: {
       port: 10002,
       headers: {
         'Access-Control-Allow-Origin': '*'
       }
     }
    })
    

    配置子应用的打包和子应用的端口并且设置允许跨域。

  5. vue子应用展示

    image.png

react子应用

  1. 配置 index.js 入口文件
    import React from 'react';
    import ReactDOM from 'react-dom/client';
    import './index.css';
    import App from './App';
    // import reportWebVitals from './reportWebVitals';
    let root = null
    function render() {
      const container = document.getElementById('root')
      root = ReactDOM.createRoot(container);
      root.render(
        <React.StrictMode>
          <App />
        </React.StrictMode>,
      )
    }
    
    // 使用 webpack 运行时 publicPath 配置
    if (window.__POWERED_BY_QIANKUN__) {
      __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__
    }
    
    // 独立运行微应用
    if (!window.__POWERED_BY_QIANKUN__) {
      render()
    }
    
    // 定义子组件的协议
    export async function bootstrap (props) {
      console.log('react 子应用 bootstrap', props)
    }
    export async function mount (props) {
      console.log('react 子应用 mount', props)
      render(props)
    }
    export async function unmount (props) {
      root.unmount()
    }
    
    react 中入口文件的配置和vue子应用的配置步骤基本相同。 因为使用的是react18版本,所以使用的 ReactDOM.createRoot(container); 创建节点。并且使用root.unmount()卸载应用。
  2. App.js
    import './App.css';
    import { BrowserRouter, Route, Link, Routes } from 'react-router-dom'
    
    function App() {
      return (
        <BrowserRouter className="App" basename={window.__POWERED_BY_QIANKUN__ ? '/react' : '/'}>
         <Link to={'/'}>首页</Link>
         <Link to={'/about'}>关于</Link>
         <Routes>
            <Route path='/' element={<div>home page</div>} />
            <Route path='/about' element={<div>about page</div>} />
          </Routes>
        </BrowserRouter>
      );
    }
    
    export default App;
    
    
    app.js文件:
  • 安装react-router-dom配置路由
  • basename={window.__POWERED_BY_QIANKUN__ ? '/react' : '/'}通过 __POWERED_BY_QIANKUN__判断加载方式设置基路由。
  1. 修改打包配置

    react-app-rewired

  • create-creact-app项目,如果需要手动修改配置,需先npm run eject弹出配置,这个过程是不可逆的
  • 这里使用react-app-rewired。它的作用是用来帮助你重写react脚手架配置。
    1. 安装 npm i react-app-rewired --save-dev

    2. 修改 package.json 配置

        "scripts": {
          "start": "react-app-rewired start",
          "build": "react-app-rewired build",
          "test": "react-app-rewired test",
          "eject": "react-app-rewired eject"
        },
      
    3. 新增 config-overrides.js

      module.exports = {
        webpack: (config) => {
          config.output.library = 'reactApp'
          config.output.libraryTarget = 'umd'
          config.output.publicPath = 'http://localhost:10003/'
          return config
        },
        devServer: (configFn) => {
          return function (proxy, allowedHost) {
            const config = configFn(proxy, allowedHost)
            config.headers = {
              'Access-Control-Allow-Origin': '*'
            }
            return config
          }
        }
      }
      
      
    4. 根目录下新建 .env 文件

        PORT=10003
        WDS_SOCKET_PORT=10003
      
      
    5. 结果展示

      image.png

qiankun全局数据

initGlobalState定义全局数据

  • onGlobalStateChange: (callback: OnGlobalStateChangeCallback, fireImmediately?: boolean) => void, 在当前应用监听全局状态,有变更触发 callback,fireImmediately = true 立即触发 callback
  • setGlobalState: (state: Record<string, any>) => boolean, 按一级属性设置全局状态,微应用中只能修改已存在的一级属性
  • offGlobalStateChange: () => boolean,移除当前应用的状态监听,微应用 umount 时会默认调用
    const { onGlobalStateChange, setGlobalState, offGlobalStateChange } = initGlobalState({})
    

使用

  1. 在主应用的入口文件中定义

    在主应用中定义要共享给子应用的数据initGlobalState

    // vue main.js
    // 初始化 state
    const { onGlobalStateChange, setGlobalState, offGlobalStateChange } = initGlobalState({
      msgBase: '主应用定义的global state',
    })
    
    onGlobalStateChange((state, prev) => {
      // state: 变更后的状态; prev 变更前的状态
      console.log('%c 基座应用 onGlobalStateChange 最新数据', 'font-size: 18px; color: #FF0000', state, prev);
    }, true)
    
    
  2. 在子应用(vue)的入口文件中使用

    定义 getGlobalState 函数,通过调用qiankun提供的onGlobalStateChange可以获取到主应用中定义的全局数据,并将监听数据变化的函数onGlobalStateChange和更新全局数据的函数setGlobalState注册到vue实例中,以便可以在vue子应用的组件中使用。

    function getGlobalState(props) {
      props.onGlobalStateChange &&
        props.onGlobalStateChange(
          (value, prev) => {
            console.log(`%c [vue 子应用 onGlobalStateChange]: ${props.name} `, 'font-size: 20px; color: purple', value, prev)
          },
          true,
        )
      Vue.prototype.$globalStateChange = props.onGlobalStateChange
      Vue.prototype.$setGlobalState = props.setGlobalState
    }
    

    在子应用的mount生命周期中调用自定义函数getGlobalState

    export async function mount (props) {
         getGlobalState(props)
         render(props)
    }
    

    在vue子应用中调用监听数据变化的函数onGlobalStateChange和更新全局数据的函数setGlobalState

    <template>
     <div class="home">
       <img alt="Vue logo" src="../assets/logo.png">
       <button @click="handleChangeGlobalState()">更改全局state</button>
     </div>
    </template>
    
    <script>
    // @ is an alias to /src
    
    export default {
     name: 'HomeView',
     data() {
       return {
         globalState: {}
       }
     },
     mounted() {
       if (this.$globalStateChange) {
         this.$globalStateChange((cur) => {
           console.log('%c --------vue 子应用最新数据----------------', 'font-size: 18px; color: #07FF07', cur);
           this.globalState = cur
         }, true)
       }
     },
     methods: {
       handleChangeGlobalState() {
         this.$setGlobalState({
           ...this.globalState,
           msgBase: 'vue 子应用更新了 global state',
           msgVue: 'vue子应用新增了数据'
         })
       }
     }
    }
    </script>
    
    

    结果:

    在vue的自组件中可以获取到全局数据: 当点击更新全局state按钮时: 点击完按钮会更新全局数据,主应用onGlobalStateChange函数监听到最新的数据,同时子应用也会得到最新的数据。由于只能更新主应用中initGlobalState定义的字段,所以只能更新mngBase字段。 image.png

  3. 在子应用react中使用

    在入口文件index.js的mount生命周期中通信,可以将监听数据变化的函数onGlobalStateChange和更新全局数据的函数setGlobalState注册到react全局中,以便可以在react子应用的组件中使用。

    export async function mount (props) {
      props.onGlobalStateChange((state, prev) => {
        // state: 变更后的状态; prev 变更前的状态
        console.log('%c 子应用中监听 global state', 'font-size: 18px; color: #6666FF', state, prev);
      }, true)
      React.$globalStateChange = props.onGlobalStateChange
      React.$setGlobalState = props.setGlobalState
      console.log('react 子应用 mount', props)
      render(props)
    }
    

    在react子应用中获取全局数据并且修改全局数据

    import './App.css';
    import { BrowserRouter, Route, Link, Routes } from 'react-router-dom'
    import React, { useEffect, useState } from 'react';
    
    function App() {
      const [globalState, setGlobalState] = useState({});
    
      useEffect(() => {
        if (React.$globalStateChange) {
          React.$globalStateChange((cur) => {
            console.log('%c --------react 子应用最新数据----------------', 'font-size: 18px; color: #FF0000', cur);
            setGlobalState(cur)
          }, true)
        }
      }, [])
    
    
      const handleGlobalState = () => {
        React.$setGlobalState({
          ...globalState,
          msgBase: 'react 中更改全局数据',
          msgReact: 'react中新增了数据'
        })
      }
    
      return (
        <BrowserRouter className="App" basename={window.__POWERED_BY_QIANKUN__ ? '/react' : '/'}>
         <Link to={'/'}>首页</Link>
         <Link to={'/about'}>关于</Link>
         <Routes>
            <Route path='/' element={<div>
              home page
              <button onClick={handleGlobalState}>react 更新global state</button>
            </div>} />
            <Route path='/about' element={<div>about page</div>} />
          </Routes>
        </BrowserRouter>
      );
    }
    
    export default App;
    
    

    结果: react子应用中可以获取到主应用定义的全局数据 image.png

    当点击react子应用的更新按钮后,会更新全局数据并通知到主应用。 image.png

    当再次切换到vue应用后可以看到,能够获取到在react组件中更新过的最新全局数据。 image.png