手写简版「微前端」框架

783 阅读8分钟

1. 前言

本篇文章主要是介绍「微前端」的核心概念,以及该怎么落地微前端。将会介绍两个比较成熟的微前端框架 single-spaqiankun 的基本使用,最后结合上述知识点从零实现一个简易版本的微前端框架

2. 什么是微前端

微前端是⼀种多个团队通过独⽴发布功能的⽅式来共同构建现代化 web 应⽤的技术⼿段及⽅法策略。简单来说就是将不同的功能按照不同的维度拆分成多个子应用,然后通过一个主应用来加载这些子应用,前端的核心就是拆分子应用,拆完后再合

微前端架构具备以下⼏个核⼼价值:

  1. 技术栈无关:主框架不限制接入应用的技术栈,子应用具备完全自主权
  2. 独⽴开发、独⽴部署:微应用仓库独立,前后端可独立开发,部署完成后主框架自动完成同步更
  3. 增量升级:在面对各种复杂场景时,我们通常很难对一个已经存在的系统做全量的技术栈升级或重构,而微前端是一种非常好的实施渐进式重构的手段和策略
  4. 独立运行时:每个微应用之间状态隔离,运行时状态不共享

那要怎么使用微前端呢?

  1. single-spa:single-spa 是一个用于前端微服务化的 JavaScript 前端解决方案,实现了路由劫持和应用的加载 (缺点:没有处理样式隔离和 js 执行隔离)
  2. qiankun:基于 single-spa 封装,提供了更加开箱即用的 API(single-spa + sandbox + import-html-entry), 做到了技术栈无关。处理了样式隔离问题,确保微应用之间样式互相不干扰。JS 沙箱,确保微应用之间 全局变量/事件 不冲突。

3. single-spa 框架的使用

3.1 构建子应用

  1. 初始化子应用
// 1. 初始化项目,这里子应用使用的是vue
vue create single-vue

// 2. 在子应用使用single-spa-vue
cd single-vue && yarn add single-spa-vue
  1. 修改main.js
import Vue from 'vue'

// 1. vue 项目引入 single-spa-vue。如果是react, 则是 single-spa-react
import singleSpaVue from 'single-spa-vue'
import App from './App.vue'

Vue.config.productionTip = false

// 2.
const appOptions = {
  el: '#vue', // 挂载到父应用中,id为vue的标签中
  router,
  render: function(h) {
    return h(App)
  },
}

const vueLifeCycle = singleSpaVue({
  Vue,
  appOptions,
})

// 如果是父应用加载了当前子应用
if (window.singleSpaNavigate) {
  // 在webpack打包的设置
  // 这样在父应用引用子应用的文件的时候,路径才是对的
  __webpack_public_path__ = 'http://localhost:8081/'
} else {
  // 子应用也可以自己独立运行
  delete appOptions.el
  new Vue(appOptions).$mount('#app')
}

// 3. 协议接入并导出,因为父应用会调用这些方法
export const bootstrap = vueLifeCycle.bootstrap
export const mount = vueLifeCycle.mount
export const unmount = vueLifeCycle.unmount

// 4.
// 除了以上的调整,我们还需要父应用加载当前的子应用
// 所以需要将子应用打包成一个 lib 去给父应用使用 (修改 vue.config.js 中的 webpack 配置)
  1. 修改 vue.config.js
module.exports = {
  configureWebpack: {
    output: {
      library: 'singleVue',
      libraryTarget: 'umd',
    },
    devServer: {
      port: 8081,
    },
  },
}

3.2 搭建主应用

  1. 初始化主应用
// 1. 初始化项目,这里主应用也使用的是vue
vue create base-vue

// 2. 在主应用使用single-spa
cd base-vue && yarn add single-spa
  1. 修改main.js
import Vue from 'vue'
// 1. 父应用引入sigle-spa
import { registerApplication, start } from 'single-spa'
import App from './App.vue'
import router from './router'

Vue.config.productionTip = false

async function loadScript(url) {
  return new Promise((resolve, reject) => {
    let script = document.createElement('script')
    script.src = url
    script.onload = resolve
    script.onerror = reject
    document.head.appendChild(script)
  })
}

// 2. 注册子应用
registerApplication(
  'vueApp',
  async () => {
    // 调用方法,这里必须是一个promise
    console.log('加载vue子应用的文件')
    await loadScript('http://localhost:8081/js/chunk-vendors.js')
    await loadScript('http://localhost:8081/js/app.js')
    return window.singleVue // 子应用打包出来的
  },
  (location) => location.pathname.startsWith('/vue') // 用户切换到/vue的时候,需要加载vue子应用
)

// 3.
start()

new Vue({
  router,
  render: function(h) {
    return h(App)
  },
}).$mount('#app')

  1. 修改App.vue
<template>
  <div id="app">
    <div id="nav">
      <router-link to="/vue">vue子应用</router-link> |
    </div>
    
    <!-- 子应用加载的位置 -->
    <div id="vue"></div>
    
    <router-view/>
  </div>
</template>

3.3 single-spa 总结

  1. 虽然可以在父应用中实现加载不同的子应用,但是不够灵活,不能动态加载 js 文件
  2. 样式不隔离,如果加载多个子应用,可能会导致样式错乱
  3. 没有 js 沙箱的机制,在不同子应用切换的时候,用的都是同一个全局对象

4. qiankun 框架的使用

4.1 搭建主应用

  1. 初始化主应用
// 1. 初始化项目,主应用是vue2.x
vue create qiankun-base

// 2. 
cd qiankun-base && yarn add qiankun
  1. 修改 main.js 和 App.vue
// main.js
import Vue from 'vue'
import App from './App.vue'
import router from './router'

import {
  registerMicroApps,
  start,
  initGlobalState,
  MicroAppStateActions,
} from 'qiankun'

Vue.config.productionTip = false

// 初始化 state
let state = {
  a: 1,
  b: 2,
}
const actions = initGlobalState(state)
// 主项⽬项⽬监听和修改
actions.onGlobalStateChange((state, prev) => {
  // state: 变更后的状态; prev 变更前的状态
  console.log(state, prev)
})
actions.setGlobalState({ a: 3 })

// 子应用
const apps = [
  {
    name: 'vueApp', // 应⽤的名字
    entry: '//localhost:8081', // 默认会加载这个html 解析⾥⾯的js 动态的执⾏。(⼦应⽤必须⽀持跨域)
    container: '#vue', // 容器名
    activeRule: '/vue', // 激活的路径
    props: { a: 1 },
  },
  {
    name: 'reactApp',
    entry: '//localhost:3000',
    container: '#react',
    activeRule: '/react',
  },
]

let app = new Vue({
  router,
  render: function(h) {
    return h(App)
  },
}).$mount('#app')

app.$nextTick(() => {
  // 注册子应用
  registerMicroApps(apps, {
    beforeLoad: [
      (app) => {
        console.log(app)
        console.log('[LifeCycle] before load %c%s', 'color: green;', app.name)
      },
    ],
    beforeMount: [
      (app) => {
        console.log('[LifeCycle] before mount %c%s', 'color: green;', app.name)
      },
    ],
    afterUnmount: [
      (app) => {
        console.log('[LifeCycle] after unmount %c%s', 'color: green;', app.name)
      },
    ],
  })
  //开启沙盒模式
  start({
    // sandbox: { strictStyleIsolation: true },
    prefetch: false, // 取消预加载
  })
})
// App.vue
<template>
  <div id="vue-app">
    <div id="nav">
      <!-- 基座⾥可以放⾃⼰的路由 -->
      <router-link to="/">Home</router-link> |
      <!-- 也可以引⽤其他⼦应⽤ -->
      <router-link to="/vue">Vue</router-link> |
      <router-link to="/react">React</router-link>
    </div>
    <!-- 默认路由 -->
    <router-view />

    <!-- 其他⼦应⽤挂载 -->
    <div id="vue"></div>
    <div id="react"></div>
  </div>
</template>

4.2 搭建vue子应用

  1. 子应用使用的是vue3,直接赢vue-cli工具初始化就好了,不过多赘述
  2. 修改 main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'

let app = null
function render(props) {
  app = createApp(App)
  app.use(router).mount('#app') // 这⾥是挂载到⾃⼰的html中 基座会拿到这个挂载后的html 将其加入页面
}

// 父应用在加载当前子应用的时候
if (window.__POWERED_BY_QIANKUN__) {
  // 动态添加publicPath
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__
} else {
  // 默认独⽴运⾏
  render()
}
// ⼦应用的协议,父应用中需要调用
export async function bootstrap(props) {}
export async function mount(props) {
  render(props)
}
export async function unmount(props) {
  // vue3移除该⽅法,若采⽤vue2,需要⼿动摧毁
  // app.$destroy();
  // app.$el.innerHTML = "";
}
  1. 修改 vue.config.js
module.exports = {
  devServer: {
    port: 8081,
    headers: {
      'Access-Control-Allow-Origin': '*', // 基座应⽤⼦项⽬资源,需⽀持跨域
    },
  },
  configureWebpack: {
    output: {
      library: 'vueApp', // ⼦应⽤名
      libraryTarget: 'umd', // umd⽅式打包
    },
  },
}
  1. 修改路由配置
...
const router = createRouter({
  history: createWebHistory('/vue'), // publicPath,也可以通过判断独⽴应⽤或者⼦应⽤进⾏处理
  routes,
})
...

4.3 搭建react子应用

  1. 使用create-react-app 创建项目
  2. 修改入口文件
import React from 'react'
import ReactDOM from 'react-dom'
import './index.css'
import App from './App'

function render() {
  ReactDOM.render(
    <React.StrictMode>
      <App />
    </React.StrictMode>,
    document.getElementById('root')
  )
}
if (!window.__POWERED_BY_QIANKUN__) {
  render()
}
export async function bootstrap() {}
export async function mount() {
  render()
}
export async function unmount() {
  ReactDOM.unmountComponentAtNode(document.getElementById('root'))
  1. 重写react中的webpack配置⽂件 (config-overrides.js),react-app-rewired插件达到和类似vue.config.js格式配置webpack
// 1. 安装:yarn add react-app-rewired -D

// 2. 在根目录下新建配置文件 config-overrides.js
module.exports = {
  webpack: (config) => {
    config.output.library = `reactApp`
    config.output.libraryTarget = 'umd'
    config.output.publicPath = 'http://localhost:3000/'
    return config
  },
  devServer: function (configFunction) {
    return function (proxy, allowedHost) {
      const config = configFunction(proxy, allowedHost)
      config.headers = {
        'Access-Control-Allow-Origin': '*', // 基座应⽤⼦项⽬资源,需⽀持跨域
      }
      return config
    }
  },
}

// 3. 修改 package.json 文件
...
"scripts": {
    "start": "react-app-rewired start",
    "build": "react-app-rewired build",
    "test": "react-app-rewired test",
},

至此,就可以使用 qiankun 框架,实现在主应用中加载不同的子应用。

5. 实现一个简版「微前端」 框架

5.1 初始化项目

  1. 安装依赖
npm init -y

npm install rollup rollup-plugin-serve -D
  1. 配置 rollup 打包
// 根目录下创建配置文件:rollup.config.js
import serve from 'rollup-plugin-serve'

module.exports = {
  input: './src/index.js',
  output: {
    file: './lib/umd/my-single-spa.js',
    format: 'umd',
    name: 'singleSpa',
    sourcemap: true,
  },
  plugins: [
    serve({
      openPage: './index.html',
      contentBase: '',
      port: 8000,
    }),
  ],
}
  1. package.json
{
  "scripts": {
    "dev": "rollup -c -w"
  }
}
  1. 项目文件夹的结构
.
├── package.json
├── rollup.config.js
├── index.html
└── src
    ├── index.js
    ├── start.js
    ├── applications
    ├── lifecycles
    └── navigation     

5.2 了解相关概念和文件说明

  1. 微前端的场景主要是:将应用拆分为多个app加载,或将多个不同的应用当成app组合在一起加载。为了更好的约束app和行为,要求每个app必须提供完整的生命周期函数(bootstrapmountunmount),使微前端框架可以更好地跟踪和控制它们
singleSpa.registerApplication(
    'appName1',
    async (props) => {
      return {
        bootstrap: async (props) => {
          console.log('bootstrap1')
        },
        mount: async (props) => {
          console.log('mount1')
        },
        unmount: async (props) => {
          console.log('unmount1')
        },
      }
    },
    (location) => location.pathname.indexOf('/app1') > -1,
)
  1. 应用的状态 为了更好的管理每一个子应用,特地增加了状态,每个应用共存在11个状态,如图:

single-spa.png

状态说明:

// src/applications/app.helper.js
export const NOT_LOADED = 'NOT_LOADED' // 未加载,应用初始状态
export const LOAD_SOURCE_CODE = 'LOAD_SOURCE_CODE' // 加载app代码中
export const NOT_BOOTSTRAPPED = 'NOT_BOOTSTRAPPED' // 还没调用 bootstrap 方法
export const BOOTSTRAPPING = 'BOOTSTRAPPING' // 启动中
export const NOT_MOUNTED = 'NOT_MOUNTED' // 还没调用 mount 方法
export const MOUNTING = 'MOUNTING' // 挂载中
export const MOUNTED = 'MOUNTED' // 挂载成功
export const UNMOUNTING = 'UNMOUNTING' // 卸载中
export const SKIP_BECAUSE_BROKEN = 'SKIP_BECAUSE_BROKEN' // 加载时参数校验未通过
export const LOAD_ERROR = 'LOAD_ERROR' // 加载时遇到致命错误
export const UPDATING = 'UPDATING' // 更新中
  1. 主要文件

src/applications/app.js:提供 registerApplication 注册应用的方法

import { reroute } from '../navigation/reroute'

/**
 * @param {*} appName     应用名称
 * @param {*} loadApp     加载的应用
 * @param {*} activeWhen  当激活的是会调用 loadApp
 * @param {*} customProps 自定义的属性
 */

const apps = [] // 存放所有的应用
export function registerApplication(appName, loadApp, activeWhen, customProps) {
  apps.push({
    name: appName,
    loadApp,
    activeWhen,
    customProps,
    status: NOT_LOADED,
  })
  reroute()
}

src/start.js:提供 start 启动应用的方法

import { reroute } from './navigation/reroute'

export let started = false
export function start() {
  // start 是否挂载子应用
  started = true
  reroute()
}
  1. src/navigation/reroute.js:处理应用的核心方法
  2. 其中处理应用声明周期的方法放在 src/lifecycles 目录下
import { getAppChanges } from '../applications/app'
import { started } from '../start'
import { toLoadPromise } from '../lifecycles/load'
import { toUnmountPromise } from '../lifecycles/unmount'
import { toBootstrapPromise } from '../lifecycles/bootstrap'
import { toMountPromise } from '../lifecycles/mount'

export function reroute() {
  // 需要知道哪些应用要加载
  // 需要知道哪些应用要挂载
  // 需要知道哪些应该要卸载

  const { appToLoad, appToMount, appToUnmount } = getAppChanges()

  if (started) {
    return performAppChanges()
  } else {
    return loadApps()
  }

  // 预加载应用
  async function loadApps() {
    // 获取到 传进来的 bootstrap,mount,unmount,然后放到 app 上
    let apps = await Promise.all(appToLoad.map(toLoadPromise))
  }

  // 根据路径来挂载应用
  async function performAppChanges(app) {
    // 先卸载不需要的应用
    let unmountPromises = appToUnmount.map(toUnmountPromise)

    // 去加载需要的应用
    appToLoad.map(async (app) => {
      app = await toLoadPromise(app)
      app = await toBootstrapPromise(app)
      return await toMountPromise(app)
    })
    appToMount.map(async (app) => {
      app = await toBootstrapPromise(app)
      return await toMountPromise(app)
    })
  }
}

src/navigation/navigator-events.js:处理页面切换和拦截子应用注册的事件,确保主应用的切换页面事件先执行

import { reroute } from './reroute'

export const routingEventsListeningTo = ['hashchange', 'popstate']

function urlReroute() {
  reroute([], arguments)
}

const capturedEventListeners = {
  hashchange: [],
  popstate: [],
}

// 拦截页面切换(hash改变),处理应用加载的逻辑在最前面
window.addEventListener('hashchange', urlReroute)
window.addEventListener('popstate', urlReroute)

// 除此之外,用户还可能绑定自己的路由事件,比如说vue-router
// 拦截子应用所有注册的事件,以便确保主应用的事件总是第一个执行
const originalAddEventListener = window.addEventListener
const originalRemoveEventListener = window.removeEventListener

// 拦截加载的应用的事件
window.addEventListener = function (eventName, fn) {
  // 如果是 hash 改变事件,并且没缓存过,则先存起来
  if (
    routingEventsListeningTo.indexOf(eventName) >= 0 &&
    !capturedEventListeners[eventName].some((listener) => listener == fn)
  ) {
    capturedEventListeners[eventName].push(fn)
    return
  }
  return originalAddEventListener.apply(this, arguments)
}

window.removeEventListener = function (eventName, fn) {
  if (routingEventsListeningTo.indexOf(eventName) >= 0) {
    capturedEventListeners[eventName] = capturedEventListeners[
      eventName
    ].filter((l) => l !== fn)
    return
  }
  return originalRemoveEventListener.apply(this, arguments)
}

// 除了 hash 路由,还有浏览器路由
function patchedUpdateState(updateState, methodName) {
  return function () {
    const beforeUrl = window.location.href
    updateState.apply(this, arguments) // 调用切换页面路由的方法
    const afterUrl = window.location.href

    if (beforeUrl !== afterUrl) {
      urlReroute(new PopStateEvent('popstate')) // new PopStateEvent('popstate')  构建一个事件源
    }
  }
}

window.history.pushState = patchedUpdateState(
  window.history.pushState,
  'pushState'
)

window.history.replaceState = patchedUpdateState(
  window.history.replaceState,
  'replaceState'
)

5.3 完整项目代码

github仓库地址:github.com/hsbao/my-si…