一、介绍
1、概述
qiankun 是基于 single-spa 做了二次封装的微前端框架,通过解决了 single-spa 的一些弊端和不足,来帮助大家实现更简单、无痛的构建一个生产可用的微前端架构系统。
qiankun在子应用加载时,通过标识为entry或者加载的最后一个js文件为入口文件,子应用入口文件中需导出子应用生命周期(bootstrap、mount、unmount、update:可选)
2、主要优势
- 技术兼容性好,各个子应用可以基于不同的技术架构
- 代码库更小、内聚性更强
- 便于独立编译、测试和部署,可靠性更高
- 耦合性更低,各个团队可以独立开发,互不干扰
- 可维护性和扩展性更好,便于局部升级和增量升级
是否需要微前端架构可以参考博文:你可能并不需要微前端 - 知乎
3、缺点
- 子应用间的资源共享能力较差,使得项目总体积变大
- 开发人员要处理分布式系统的复杂性
4、题外话
为什么不用 iframe,这几乎是所有微前端方案第一个会被 challenge 的问题。但是大部分微前端方案又不约而同放弃了 iframe 方案,自然是有原因的,并不是为了 "炫技" 或者刻意追求 "特立独行"。
如果不考虑体验问题,iframe 几乎是最完美的微前端解决方案了。
iframe 最大的特性就是提供了浏览器原生的硬隔离方案,不论是样式隔离、js 隔离这类问题统统都能被完美解决。但他的最大问题也在于他的隔离性无法被突破,导致应用间上下文无法被共享,随之带来的开发体验、产品体验的问题。
-
url 不同步。浏览器刷新 iframe url 状态丢失、后退前进按钮无法使用。
- UI 不同步,DOM 结构不共享。想象一下屏幕右下角 1/4 的 iframe 里来一个带遮罩层的弹框,同时我们要求这个弹框要浏览器居中显示,还要浏览器 resize 时自动居中..
- 全局上下文完全隔离,内存变量不共享。iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果。
- 慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程。
其中有的问题比较好解决(问题1),有的问题我们可以睁一只眼闭一只眼(问题4),但有的问题我们则很难解决(问题3)甚至无法解决(问题2),而这些无法解决的问题恰恰又会给产品带来非常严重的体验问题, 最终导致我们舍弃了 iframe 方案。
二、问题
Q1:vite 的模块加载方式是 esm, 而 qiankun 并不支持在非 module script 标签内解析 esm 格式的代码,导致子应用无法正确加载
A1:可参考:vite-plugin-qiankun
Q2:样式隔离
A2:
import { start, loadMicroApp } from 'qiankun'
// 说明:结合加载子应用方式选择其中一种即可
// 方式1: 启动 qiankun
start({
sandbox: {
strictStyleIsolation: true,
}
})
// 方式2: 手动加载子应用 reactApp
loadMicroApp('reactApp', {
sandbox: {
strictStyleIsolation: true,
}
})
Q3:如何实现对 localStorage/cookie 等进行隔离,localStorage[key] 、response set-Cookie 等如何处理
A3: 沙箱里默认行为也是没有的,因为我们当时实践的场景是,隔离 localStorage/cookie 带来的麻烦可能比收益会更多。因为大部分时候恰好是希望共享 cookie 或者 localStorage 的,比如登录这种场景。如果确实需要,也可以自己在钩子里拿到沙箱实例,植入自定义行为。
其他常见问题及解决方案,请参考:https://www.cnblogs.com/goloving/p/14881461.html
三、框架搭建
demo已上传GitHub,请参考:web-qiankun
主应用(mic-main vue3+vite+ts+element-plus+pinia)
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
import VueSetupExtend from 'vite-plugin-vue-setup-extend'
import ElementPlus from 'unplugin-element-plus/vite'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
// https://vitejs.dev/config/
// 设置文根
// const BASE_URL = '/mic-pc-main/'
const BASE_URL = '/'
export default defineConfig({
plugins: [
vue(),
VueSetupExtend(),
ElementPlus(),
AutoImport({
imports: ['vue', 'vue-router', 'pinia'],
resolvers: [ElementPlusResolver()]
}),
Components({
resolvers: [ElementPlusResolver()]
})
],
css: {
preprocessorOptions: {
stylus: {
imports: [resolve(__dirname, './src/assets/css/components/theme.styl')]
}
}
},
resolve: {
// 设置快捷指向
alias: {
'@': resolve(__dirname, 'src')
}
},
base: BASE_URL,
server: {
port: 8086,
host: '0.0.0.0',
https: false,
open: false, // 启动服务是否自动打开浏览器
cors: true, // 跨域
// 代理
proxy: {
'/xxx': {
target: 'http://xxx.xxx.xxx.xxx:xxxx',
changeOrigin: true,
secure: false
}
}
}
})
// main.ts 重点:startMicros
import { createApp } from 'vue'
import App from './App.vue'
import router from './routers/index'
import { setupStore } from './stores/index'
import { getConf } from '@/conf/conf'
import { injectResponsiveStorage } from '@/utils/storage/responsive'
import { useI18n } from '@/i18n/i18n'
import '@/plugins/flexible'
import { directivesHook } from '@/directives/index'
import elementPlusFn from '@/plugins/element'
import { SHA1 } from '@/utils/utils'
import { PROPS_TYPE } from '@/micro/type'
import { startMicros } from '@/micro/index'
const app = createApp(App)
/**
* 需要传递的一些方法
*/
const props: PROPS_TYPE = {
SHA1: SHA1
}
// 获取初始化配置信息
getConf(app).then(async (config: any) => {
app.use(router)
await router.isReady()
// 默认状态处理
injectResponsiveStorage(config)
// 状态管理
setupStore(app)
elementPlusFn(app)
app.use(useI18n)
// 指令
directivesHook(app)
// 启动微服务
startMicros(props)
app.mount('#app')
})
// micro/index.ts
import { registerMicroApps, addGlobalUncaughtErrorHandler, start } from 'qiankun'
import { getApps } from './apps'
import { PROPS_TYPE } from '@/micro/type'
/**
* @description 添加全局异常捕获
*/
addGlobalUncaughtErrorHandler((event: any) => {
console.error(event)
const { message } = event
if (message && message.includes('died in status LOADING_SOURCE_CODE')) {
// message.error('微应用加载失败,请检查应用是否可运行')
console.error('微应用加载失败,请检查应用是否可运行')
} else {
console.error(message)
}
})
/**
* 开始加载子程序
* @param {*} props 默认需要传递的一些属性
*/
export function startMicros(props: PROPS_TYPE) {
const apps = getApps(props)
console.log('startMicros', apps)
registerMicroApps(apps, {
/**
* @description 应用加载之前
* @param {*} app
*/
beforeLoad: (app: any) => {
console.log('beforeLoad', app)
// NProgress.start()
return Promise.resolve()
},
/**
* @description 微应用挂载之后
* @param {*} app
*/
afterMount: (app: any) => {
console.log('afterMount', app)
// NProgress.done()
return Promise.resolve()
},
/**
* @description 微应用卸载之后
* @param {*} app
*/
afterUnmount: (app: any) => {
console.log('after unmount', app.name)
return Promise.resolve()
}
})
start({
prefetch: 'all',
sandbox: {
experimentalStyleIsolation: true // 开启沙箱样式隔离
}
})
}
// app.ts
export const getApps = (props: AnyObject) => {
return [
{
name: 'mic-pc-home',
entry: process.env.NODE_ENV === 'production' ? window.location.origin + '/micPcHome/' : `//${window.location.hostname}:8081/`,
container: '#frameSection',
activeRule: '/mic-pc-home',
props: Object.assign(props, { name: 'mic-pc-home' })
},
{
name: 'mic-pc-dispatch',
entry: process.env.NODE_ENV === 'production' ? window.location.origin + '/micPcDispatch/' : `//${window.location.hostname}:8082/`,
container: '#frameSection',
activeRule: '/mic-pc-dispatch',
props: Object.assign(props, { name: 'mic-pc-dispatch' })
}
]
}
子应用(mic-pc-home vue3+vite+ts)
子应用使用vite,需要通过vite-plugin-qiankun接入
// public-path.ts (src目录下添加)
// 判断是否是微前端环境
if ((window as any).__POWERED_BY_QIANKUN__) {
// eslint-disable-next-line no-undef
// @ts-ignore
// 如果是qiankun子应用,则动态注入路径
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__
}
// vite.config.ts
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
import VueSetupExtend from 'vite-plugin-vue-setup-extend'
import qiankun from 'vite-plugin-qiankun'
import ElementPlus from 'unplugin-element-plus/vite'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
// https://vitejs.dev/config/
// 设置文根 用于nginx上下文
const BASE_URL = '/mic-pc-home/'
// useDevMode 开启时与热更新插件冲突
// 如果是在主应用中加载子应用vite,必须打开这个,否则vite加载不成功, 单独运行没影响
const useDevMode = true
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd())
const config = {
plugins: [
vue(),
VueSetupExtend(),
qiankun('mic-pc-home', { useDevMode }),
ElementPlus(),
AutoImport({
imports: ['vue', 'vue-router', 'pinia'],
resolvers: [ElementPlusResolver()]
}),
Components({
resolvers: [ElementPlusResolver()]
})
],
css: {
preprocessorOptions: {
stylus: {
imports: [resolve(__dirname, './src/assets/css/components/theme.styl')]
}
}
},
resolve: {
// 设置快捷指向
alias: {
'@': resolve(__dirname, 'src')
}
},
build: {
commonjsOptions: {
transformMixedEsModules: true
}
},
define: {
'process.env': env
},
base: BASE_URL,
server: {
port: 8081,
host: '0.0.0.0',
https: false,
open: false, // 启动服务是否自动打开浏览器
cors: true, // 跨域
// 代理
proxy: {
'/xxx': {
target: 'http://xxx.xxx.xxx.xxx:xxxx',
changeOrigin: true,
secure: false
}
}
}
}
return config
})
// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import router from './routers/index'
import { setupStore } from './stores/index'
import { useI18n } from '@/i18n/i18n'
import '@/plugins/flexible'
import { directivesHook } from '@/directives/index'
import elementPlusFn from '@/plugins/element'
// 主子应用对接
import { renderWithQiankun, qiankunWindow } from 'vite-plugin-qiankun/dist/helper'
import './public-path'
import { micInit, micUnmount } from './mic-api/index'
// vue实例
let instance = null
const renderObj = {
// 状态管理器
store: null,
/**
* @desc: vue实例函数
* @param props 非必需,默认app
* @return {*}
**/
async render(props: any) {
const { container } = props
console.log('props', props, container)
instance = createApp(App)
// 路由初始化
instance.use(router)
await router.isReady()
// 状态管理
setupStore(instance)
elementPlusFn(instance)
instance.use(useI18n)
// 指令
directivesHook(instance)
instance.mount(container ? container.querySelector('#app') : '#app')
// 判断是否是微前端环境
if ((window as any).__POWERED_BY_QIANKUN__) {
// 子应用初始化
micInit(props)
}
},
/**
* @desc: 微应用事件接收处理
* @param {*} props
* @return {*}
**/
globalStateFn(props: any = {}) {
props &&
props.onGlobalStateChange &&
props.onGlobalStateChange(
/**
* 接收主应用 setGlobalState
* @param {*} value 传过来的值
* @param {*} prev 改变以前的值
*/
(value: any, prev: any) => {
console.log('onGlobalStateChange', value, prev)
},
true
)
}
}
/**
* bootstrap:bootstrap 只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。
* 通常我们可以在这里做一些全局变量的初始化,比如不会在 unmount 阶段被销毁的应用级别的缓存等。
* mount:应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
* unmount:应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例
*/
renderWithQiankun({
bootstrap: async () => {
console.log('[vue] vue app bootstraped')
},
mount: (props: any) => {
renderObj.render(props)
},
unmount: async () => {
await micUnmount()
renderObj.store = null
// instance.$destroy()
instance = null
},
update: () => {
console.log('update')
}
})
// 独立运行时,直接挂载应用
if (!qiankunWindow.__POWERED_BY_QIANKUN__) {
renderObj.render({})
}
// mic-api/index.ts
/**
* @desc: 主子应用交互处理类
**/
import { getCurrentInstance } from 'vue'
// 主应用传递实例
let micMain = null
let global: any = null
// 主数据初始化
export const micInit = (props: any) => {
console.log('props', props)
micMain = props
global = getCurrentInstance()?.appContext.config.globalProperties
global.$SHA1 = props.$SHA1
}
/**
* @desc: 子应用注销调用
**/
export const micUnmount = () => {
console.log('子应用注销调用')
}
子应用(mic-pc-dispatch vue3+ts+webpack)
其余配置文件一致,主要体现在vue.config.js
// vue.config.js
const { defineConfig } = require('@vue/cli-service')
const path = require('path')
const packageName = require('./package.json').name
const UglifyJsPlugin = require('terser-webpack-plugin')
console.log('packageName', packageName)
// 生产环境根目录 -- 用于nginx上下文
const PROD_BASE_URL = '/mic-pc-dispatch/'
const outputDir = 'mic-pc-dispatch'
let port = '8082'
let host = 'localhost'
const publicPath = process.env.NODE_ENV === 'production' ? PROD_BASE_URL : `//${host}:${port}`
module.exports = defineConfig({
// 根路径
publicPath: publicPath,
// 静态资源文件夹
assetsDir: 'static',
productionSourceMap: false,
outputDir: outputDir,
lintOnSave: true,
transpileDependencies: true,
// 打包方式设置为umd
configureWebpack: config => {
if (process.env.NODE_ENV === 'development') {
config.devtool = 'source-map'
}
// 警告 webpack 的性能提示
config.performance = {
hints: 'warning',
// 入口起点的最大体积 整数类型(以字节为单位)
maxEntrypointSize: 50000000,
// 生成文件的最大体积 整数类型(以字节为单位 300k)
maxAssetSize: 30000000,
// 只给出 js 文件的性能提示
assetFilter: function (assetFilename) {
return assetFilename.endsWith('.js')
}
}
config.resolve.alias = Object.assign(config.resolve.alias, {
'@': resolvePath('src')
})
// 自定义webpack配置
config.output = Object.assign(config.output, {
// 把子应用打包成 umd 库格式
library: `${packageName}`,
libraryTarget: 'umd',
// jsonpFunction: `webpackJsonp_${packageName}`,
globalObject: 'window'
})
if (process.env.NODE_ENV === 'production') {
config.plugins.push(
new UglifyJsPlugin({
terserOptions: {
warnings: false,
compress: {
drop_debugger: true, // console
drop_console: true,
pure_funcs: ['console.log'] // 移除console
}
},
exclude: /\/node_modules/,
sourceMap: false,
parallel: true
})
)
} else {
// 为开发环境修改配置...
}
},
// 本地服务器
devServer: {
host: '0.0.0.0', // IP
port: port, // 端口号
headers: {
'Access-Control-Allow-Origin': '*'
},
// 代理 完整配置参考:https://github.com/chimurai/http-proxy-middleware#proxycontext-config
proxy: {
'/xxx': {
target: 'http://xxx.xxx.xxx.xxx:xxxx', // 四川机关事务局-生产环境-互联网
changeOrigin: true,
secure: false
}
}
},
chainWebpack: config => {
// 解决 cli3 热更新失效 https://github.com/vuejs/vue-cli/issues/1559
config.resolve.symlinks(true)
const types = ['vue-modules', 'vue', 'normal-modules', 'normal']
types.forEach(type => addStyleResource(config.module.rule('stylus').oneOf(type)))
config.module
.rule('fonts')
.use('url-loader')
.loader('url-loader')
.options({
limit: 4096, // 小于4kb将会被打包成 base64
fallback: {
loader: 'file-loader',
options: {
name: 'fonts/[name].[hash:8].[ext]',
publicPath: publicPath
}
}
})
.end()
config.module
.rule('images')
.use('url-loader')
.loader('url-loader')
.options({
limit: 4096, // 小于4kb将会被打包成 base64
fallback: {
loader: 'file-loader',
options: {
esModule: false,
name: 'img/[name].[hash:8].[ext]',
publicPath
}
}
})
}
})
function resolvePath (dir) {
return path.join(__dirname, './', dir)
}
function addStyleResource (rule) {
rule
.use('style-resource')
.loader('style-resources-loader')
.options({
patterns: [path.resolve(__dirname, './src/assets/css/imports.styl')]
})
}