1. 前言
本篇文章主要是介绍「微前端」
的核心概念,以及该怎么落地微前端。将会介绍两个比较成熟的微前端框架 single-spa
和 qiankun
的基本使用,最后结合上述知识点从零实现一个简易版本的微前端框架
。
2. 什么是微前端
微前端是⼀种多个团队通过独⽴发布功能的⽅式来共同构建现代化 web 应⽤的技术⼿段及⽅法策略。简单来说就是将不同的功能按照不同的维度拆分成多个子应用,然后通过一个主应用来加载这些子应用,前端的核心就是拆分子应用,拆完后再合
。
微前端架构具备以下⼏个核⼼价值:
- 技术栈无关:主框架不限制接入应用的技术栈,子应用具备完全自主权
- 独⽴开发、独⽴部署:微应用仓库独立,前后端可独立开发,部署完成后主框架自动完成同步更
- 增量升级:在面对各种复杂场景时,我们通常很难对一个已经存在的系统做全量的技术栈升级或重构,而微前端是一种非常好的实施渐进式重构的手段和策略
- 独立运行时:每个微应用之间状态隔离,运行时状态不共享
那要怎么使用微前端呢?
- single-spa:
single-spa
是一个用于前端微服务化的 JavaScript 前端解决方案,实现了路由劫持和应用的加载(缺点:没有处理样式隔离和 js 执行隔离)
- qiankun:基于 single-spa 封装,提供了更加开箱即用的 API(single-spa + sandbox + import-html-entry), 做到了技术栈无关。处理了样式隔离问题,确保微应用之间样式互相不干扰。JS 沙箱,确保微应用之间 全局变量/事件 不冲突。
3. single-spa 框架的使用
3.1 构建子应用
- 初始化子应用
// 1. 初始化项目,这里子应用使用的是vue
vue create single-vue
// 2. 在子应用使用single-spa-vue
cd single-vue && yarn add single-spa-vue
- 修改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 配置)
- 修改 vue.config.js
module.exports = {
configureWebpack: {
output: {
library: 'singleVue',
libraryTarget: 'umd',
},
devServer: {
port: 8081,
},
},
}
3.2 搭建主应用
- 初始化主应用
// 1. 初始化项目,这里主应用也使用的是vue
vue create base-vue
// 2. 在主应用使用single-spa
cd base-vue && yarn add single-spa
- 修改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')
- 修改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 总结
- 虽然可以在父应用中实现加载不同的子应用,但是不够灵活,不能动态加载 js 文件
- 样式不隔离,如果加载多个子应用,可能会导致样式错乱
- 没有 js 沙箱的机制,在不同子应用切换的时候,用的都是同一个全局对象
4. qiankun 框架的使用
4.1 搭建主应用
- 初始化主应用
// 1. 初始化项目,主应用是vue2.x
vue create qiankun-base
// 2.
cd qiankun-base && yarn add qiankun
- 修改 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子应用
- 子应用使用的是vue3,直接赢vue-cli工具初始化就好了,不过多赘述
- 修改 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 = "";
}
- 修改 vue.config.js
module.exports = {
devServer: {
port: 8081,
headers: {
'Access-Control-Allow-Origin': '*', // 基座应⽤⼦项⽬资源,需⽀持跨域
},
},
configureWebpack: {
output: {
library: 'vueApp', // ⼦应⽤名
libraryTarget: 'umd', // umd⽅式打包
},
},
}
- 修改路由配置
...
const router = createRouter({
history: createWebHistory('/vue'), // publicPath,也可以通过判断独⽴应⽤或者⼦应⽤进⾏处理
routes,
})
...
4.3 搭建react子应用
- 使用create-react-app 创建项目
- 修改入口文件
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'))
- 重写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 初始化项目
- 安装依赖
npm init -y
npm install rollup rollup-plugin-serve -D
- 配置 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,
}),
],
}
- package.json
{
"scripts": {
"dev": "rollup -c -w"
}
}
- 项目文件夹的结构
.
├── package.json
├── rollup.config.js
├── index.html
└── src
├── index.js
├── start.js
├── applications
├── lifecycles
└── navigation
5.2 了解相关概念和文件说明
- 微前端的场景主要是:将应用拆分为多个app加载,或将多个不同的应用当成app组合在一起加载。为了更好的约束app和行为,要求每个app必须提供完整的生命周期函数(
bootstrap
、mount
、unmount
),使微前端框架可以更好地跟踪和控制它们
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,
)
- 应用的状态 为了更好的管理每一个子应用,特地增加了状态,每个应用共存在11个状态,如图:
状态说明:
// 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' // 更新中
- 主要文件
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()
}
src/navigation/reroute.js
:处理应用的核心方法- 其中处理应用声明周期的方法放在
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…