微前端qiankun从搭建到部署的实践
一、项目架构
后台系统一般都是上下或左右的布局。下图粉红色是基座,只负责头部导航,绿色是挂载的整个子应用,点击头部导航可切换子应用。
[图片上传失败...(image-c1e1e6-1652834072321)]
参考官方的examples代码,项目根目录下有基座main和其他子应用sub-vue,sub-react,搭建后的初始目录结构如下:
├── common //公共模块
├── main // 基座
├── sub-react // react子应用
└── sub-vue // vue子应用
基座是用vue
搭建,子应用有react
和vue
。
二、基座配置
基座main采用是的Vue-Cli3搭建的,它只负责导航的渲染和登录态的下发,为子应用提供一个挂载的容器div,基座应该保持简洁(qiankun官方demo甚至直接使用原生html搭建),不应该做涉及业务的操作。
qiankun这个库只需要在基座引入,在main.js中注册子应用,为了方便管理,我们将子应用的配置都放在:main/src/micro-app.js下。
const microApps = [
{
name: 'sub-vue',
entry: '//localhost:7777/',
activeRule: '/sub-vue',
container: '#subapp-viewport', // 子应用挂载的div
props: {
routerBase: '/sub-vue' // 下发路由给子应用,子应用根据该值去定义qiankun环境下的路由
}
},
{
name: 'sub-react',
entry: '//localhost:7788/',
activeRule: '/sub-react',
container: '#subapp-viewport', // 子应用挂载的div
props: {
routerBase: '/sub-react'
}
}
]
export default microApps
然后在src/main.js中引入
import Vue from 'vue';
import App from './App.vue';
import { registerMicroApps, start } from 'qiankun';
import microApps from './micro-app';
Vue.config.productionTip = false;
new Vue({
render: h => h(App),
}).$mount('#app');
registerMicroApps(microApps, {
beforeLoad: app => {
console.log('before load app.name====>>>>>', app.name)
},
beforeMount: [
app => {
console.log('[LifeCycle] before mount %c%s', 'color: green;', app.name);
},
],
afterMount: [
app => {
console.log('[LifeCycle] after mount %c%s', 'color: green;', app.name);
}
],
afterUnmount: [
app => {
console.log('[LifeCycle] after unmount %c%s', 'color: green;', app.name);
},
],
});
start();
在App.vue中,需要声明micro-app.js配置的子应用挂载div(注意id一定要一致),以及基座布局相关的,大概这样:
头部导航
这样,基座就算配置完成了。项目启动后,子应用将会挂载到中。
三、子应用配置
(1)vue子应用
1.用Vue-cli在项目根目录新建一个sub-vue的子应用,子应用的名称最好与父应用在src/micro-app.js中配置的名称一致(这样可以直接使用package.json中的name作为output)。
新增vue.config.js,devServer的端口改为与主应用配置的一致,且加上跨域headers和output配置。
// package.json的name需注意与主应用一致
const { name } = require('../package.json')
module.exports = {
configureWebpack: {
output: {
library: `${name}-[name]`,
libraryTarget: 'umd',
jsonpFunction: `webpackJsonp_${name}`,
}
},
devServer: {
port: process.env.VUE_APP_PORT, // 在.env中VUE_APP_PORT=7788,与父应用的配置一致
headers: {
'Access-Control-Allow-Origin': '*' // 主应用获取子应用时跨域响应头
}
}
}
2.新增src/public-path.js
(function() {
if (window.__POWERED_BY_QIANKUN__) {
if (process.env.NODE_ENV === 'development') {
// eslint-disable-next-line no-undef
__webpack_public_path__ = `//localhost:${process.env.VUE_APP_PORT}/`;
return;
}
// eslint-disable-next-line no-undef
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
})();
3.src/router/index.js改为只暴露routes,new Router改到main.js中声明。
4.改造main.js,引入上面的public-path.js,改写render,添加生命周期函数等,最终如下:
import './public-path' // 注意需要引入public-path
import Vue from 'vue'
import App from './App.vue'
import routes from './router'
import store from './store'
import VueRouter from 'vue-router'
Vue.config.productionTip = false
let instance = null
function render (props = {}) {
const { container, routerBase } = props
const router = new VueRouter({
base: window.__POWERED_BY_QIANKUN__ ? routerBase : process.env.BASE_URL,
mode: 'history',
routes
})
instance = new Vue({
router,
store,
render: (h) => h(App)
}).$mount(container ? container.querySelector('#app') : '#app')
}
if (!window.__POWERED_BY_QIANKUN__) {
render()
}
export async function bootstrap () {
console.log('[vue] vue app bootstraped')
}
export async function mount (props) {
console.log('[vue] props from main framework', props)
render(props)
}
export async function unmount () {
instance.$destroy()
instance.$el.innerHTML = ''
instance = null
}
至此,基础版本的vue子应用配置好了,如果router
和vuex
不需用到,可以去掉。
四、全局状态管理
qiankun
通过initGlobalState, onGlobalStateChange, setGlobalState实现主应用的全局状态管理,然后默认会通过props
将通信方法传递给子应用。先看下官方的示例用法:
主应用:
// main/src/main.js
import { initGlobalState } from 'qiankun';
// 初始化 state
const initialState = {
user: {} // 用户信息
};
const actions = initGlobalState(initialState);
actions.onGlobalStateChange((state, prev) => {
// state: 变更后的状态; prev 变更前的状态
console.log(state, prev);
});
actions.setGlobalState(state);
actions.offGlobalStateChange();
子应用:
// 从生命周期 mount 中获取通信方法,props默认会有onGlobalStateChange和setGlobalState两个api
export function mount(props) {
props.onGlobalStateChange((state, prev) => {
// state: 变更后的状态; prev 变更前的状态
console.log(state, prev);
});
props.setGlobalState(state);
}
这两段代码不难理解,父子应用通过onGlobalStateChange这个方法进行通信,这其实是一个发布-订阅的设计模式。
ok,官方的示例用法很简单也完全够用,纯JavaScript的语法,不涉及任何的vue或react的东西,开发者可自由定制。
如果我们直接使用官方的这个示例,那么数据会比较松散且调用复杂,所有子应用都得声明onGlobalStateChange对状态进行监听,再通过setGlobalState进行更新数据。
因此,我们很有必要对数据状态做进一步的封装设计。我这里主要考虑以下几点:
- 主应用要保持简洁简单,对子应用来说,主应用下发的数据就是一个很纯粹的object,以便更好地支持不同框架的子应用,因此主应用不需用到vuex。
- vue子应用要做到能继承父应用下发的数据,又支持独立运行。
子应用在mount声明周期可以获取到最新的主应用下发的数据,然后将这份数据注册到一个名为global的vuex module中,子应用通过global module的action动作进行数据的更新,更新的同时自动同步回父应用。
因此,对子应用来说,它不用知道自己是一个qiankun子应用还是一个独立应用,它只是有一个名为global的module,它可通过action更新数据,且不再需要关心是否要同步到父应用(同步的动作会封装在方法内部,调用者不需关心),这也是为后面支持子应用独立启动开发做准备。
五、主应用的状态封装
主应用维护一个initialState
的初始数据,它是一个object
类型,会下发给子应用。
// main/src/store.js
import { initGlobalState } from 'qiankun';
import Vue from 'vue'
//父应用的初始state
// Vue.observable是为了让initialState变成可响应:https://cn.vuejs.org/v2/api/#Vue-observable。
let initialState = Vue.observable({
user: {},
});
const actions = initGlobalState(initialState);
actions.onGlobalStateChange((newState, prev) => {
// state: 变更后的状态; prev 变更前的状态
console.log('main change', JSON.stringify(newState), JSON.stringify(prev));
for (let key in newState) {
initialState[key] = newState[key]
}
});
// 定义一个获取state的方法下发到子应用
actions.getGlobalState = (key) => {
// 有key,表示取globalState下的某个子级对象
// 无key,表示取全部
return key ? initialState[key] : initialState
}
export default actions;
这里有两个注意的地方:
-
Vue.observable
是为了让父应用的state变成可响应式,如果不用Vue.observable包一层,它就只是一个纯粹的object,子应用也能获取到,但会失去响应式,意味着数据改变后,页面不会更新。 -
getGlobalState
方法,这个是有争议的
由于官方还不支持getGlobalState,所以需要显示地在注册子应用时通过props去下发该方法:
import store from './store';
const microApps = [
{
name: 'sub-vue',
entry: '//localhost:7777/',
activeRule: '/sub-vue',
},
{
name: 'sub-react',
entry: '//localhost:7788/',
activeRule: '/sub-react',
}
]
const apps = microApps.map(item => {
return {
...item,
container: '#subapp-viewport', // 子应用挂载的div
props: {
routerBase: item.activeRule, // 下发基础路由
getGlobalState: store.getGlobalState // 下发getGlobalState方法
},
}
})
export default microApps
六、Vue子应用的状态封装
前面说了,子应用在mount时会将父应用下发的state,注册为一个叫global
的vuex module,为了方便复用我们封装一下:
// sub-vue/src/store/global-register.js
/**
*
* @param {vuex实例} store
* @param {qiankun下发的props} props
*/
function registerGlobalModule(store, props = {}) {
if (!store || !store.hasModule) {
return;
}
// 获取初始化的state
const initState = props.getGlobalState && props.getGlobalState() || {
menu: [],
user: {}
};
// 将父应用的数据存储到子应用中,命名空间固定为global
if (!store.hasModule('global')) {
const globalModule = {
namespaced: true,
state: initState,
actions: {
// 子应用改变state并通知父应用
setGlobalState({ commit }, payload) {
commit('setGlobalState', payload);
commit('emitGlobalState', payload);
},
// 初始化,只用于mount时同步父应用的数据
initGlobalState({ commit }, payload) {
commit('setGlobalState', payload);
},
},
mutations: {
setGlobalState(state, payload) {
// eslint-disable-next-line
state = Object.assign(state, payload);
},
// 通知父应用
emitGlobalState(state) {
if (props.setGlobalState) {
props.setGlobalState(state);
}
},
},
};
store.registerModule('global', globalModule);
} else {
// 每次mount时,都同步一次父应用数据
store.dispatch('global/initGlobalState', initState);
}
};
export default registerGlobalModule;
main.js
中添加global-module的使用:
import globalRegister from './store/global-register'
export async function mount(props) {
console.log('[vue] props from main framework', props)
globalRegister(store, props)
render(props)
}
可以看到,该vuex模块在子应用mount时,会调用initGlobalState
将父应用下发的state初始化一遍,同时提供了setGlobalState
方法供外部调用,内部自动通知同步到父应用。子应用在vue页面使用时如下:
export default {
computed: {
...mapState('global', {
user: state => state.user, // 获取父应用的user信息
}),
},
methods: {
...mapActions('global', ['setGlobalState']),
update () {
this.setGlobalState('user', { name: '张三' })
}
},
};
这样就达到了一个效果:子应用不用知道qiankun的存在,它只知道有这么一个global module可以存储信息,父子之间的通信都封装在方法本身了,它只关心本身的信息存储就可以了。
ps: 该方案也是有缺点的,由于子应用是在mount时才会同步父应用下发的state的。因此,它只适合每次只mount一个子应用的架构(不适合多个子应用共存);若父应用数据有变化而子应用又没触发mount,则父应用最新的数据无法同步回子应用。想要做到多子应用共存且父动态传子,子应用还是需要用到qiankun提供的onGlobalStateChange的api监听才行
七、子应用切换Loading处理
子应用首次加载时相当于新加载一个项目,还是比较慢的,因此loading是不得不加上的。
官方的例子中有做了loading的处理,但是需要额外引入import Vue from 'vue/dist/vue.esm'
,这会增加主应用的打包体积(对比发现大概增加了100KB)。
八、抽取公共代码
不可避免,有些方法或工具类是所有子应用都需要用到的,每个子应用都copy一份肯定是不好维护的,所以抽取公共代码到一处是必要的一步。
根目录下新建一个common
文件夹用于存放公共代码,如上面的多个vue子应用都可以共用的global-register.js
,或者是可复用的request.js
和sdk
之类的工具函数等。这里代码不贴了,请直接看demo。
公共代码抽取后,其他的应用如何使用呢? 可以让common发布为一个npm私包,npm私包有以下几种组织形式:
- npm指向本地file地址:
npm install file:../common
。直接在根目录新建一个common目录,然后npm直接依赖文件路径。 - npm指向私有git仓库:
npm install git+ssh://xxx-common.git
。 - 发布到npm私服。
需要注意的是,由于common是不经过babel和pollfy的,所以引用者需要在webpack打包时显性指定该模块需要编译,如vue子应用的vue.config.js需要加上这句:
module.exports = {
transpileDependencies: ['common'],
}
九、子应用支持独立开发
微前端一个很重要的概念是拆分,是分治的思想,把所有的业务拆分为一个个独立可运行的模块。
从开发者的角度看,整个系统可能有N个子应用,如果启动整个系统可能会很慢很卡,而产品的某个需求可能只涉及到其中一个子应用,因此开发时只需启动涉及到的子应用即可,独立启动专注开发,因此是很有必要支持子应用的独立开发的。如果要支持,主要会遇到以下几个问题:
- 子应用的登录态怎么维护?
- 基座不启动时,怎么获取到基座下发的数据和能力?
在基座运行时,登录态和用户信息是存放在基座上的,然后基座通过props下发给子应用。但如果基座不启动,只是子应用独立启动,子应用就没法通过props获取到所需的用户信息了。因此,解决办法只能是父子应用都得实现一套相同的登录逻辑。为了可复用,可以把登录逻辑封装在common中,然后在子应用独立运行的逻辑中添加登录相关的逻辑。
// sub-vue/src/main.js
import { store as commonStore } from 'common'
import store from './store'
if (!window.__POWERED_BY_QIANKUN__) {
// 这里是子应用独立运行的环境,实现子应用的登录逻辑
// 独立运行时,也注册一个名为global的store module
commonStore.globalRegister(store)
// 模拟登录后,存储用户信息到global module
const userInfo = { name: '我是独立运行时名字叫张三' } // 假设登录后取到的用户信息
store.commit('global/setGlobalState', { user: userInfo })
render()
}
// ...
export async function mount (props) {
console.log('[vue] props from main framework', props)
commonStore.globalRegister(store, props)
render(props)
}
// ...
!window.POWERED_BY_QIANKUN表示子应用处于非qiankun内的环境,即独立运行时。此时我们依然要注册一个名为global的vuex module,子应用内部同样可以从global module中获取用户的信息,从而做到抹平qiankun和独立运行时的环境差异。
PS:我们前面写的
global-register.js
写得很巧妙,能够同时支持两种环境,因此上面可以通过commonStore.globalRegister
直接引用。
十、子应用独立仓库
随着项目发展,子应用可能会越来越多,如果子应用和基座都集合在同一个git仓库,就会越来越臃肿。
若项目有CI/CD,只修改了某个子应用的代码,但代码提交会同时触发所有子应用构建,牵一发动全身,是不合理的。
同时,如果某些业务的子应用的开发是跨部门跨团队的,代码仓库如何分权限管理又是一个问题。
基于以上问题,我们不得不考虑将各个应用迁移到独立的git仓库。由于我们独立仓库了,项目可能不会再放到同一个目录下,因此前面通过npm i file:../common
方式安装的common就不适用了,所以最好还是发布到公司的npm私服或采用git地址形式(目前已经搭建了Verdaccio Npm库)。
qiankun-example
为了更好展示,仍将所有应用都放在同一个git仓库下。
十一、子应用独立仓库后聚合管理
子应用独立git仓库后,可以做到独立启动独立开发了,这时候又会遇到问题:开发环境都是独立的,无法一览整个应用的全貌。
虽然开发时专注于某个子应用时更好,但总有需要整个项目跑起来的时候,比如当多个子应用需要互相依赖跳转时,所以还是要有一个整个项目对所有子应用git仓库的聚合管理才行,该聚合仓库要求做到能够一键install所有的依赖(包括子应用),一键启动整个项目。
这里主要考虑了三种方案:
- 使用
git submodule
。 - 使用
git subtree
。 - 单纯地将所有子仓库放到聚合目录下并
.gitignore
掉。 - 使用lerna管理。
git submodule
和git subtree
都是很好的子仓库管理方案,但缺点是每次子应用变更后,聚合库还得同步一次变更。
考虑到并不是所有人都会使用该聚合仓库,子仓库独立开发时往往不会主动同步到聚合库,使用聚合库的同学就得经常做同步的操作,比较耗时耗力,不算特别完美。
所以第三种方案比较符合目前团队的情况。聚合库相当于是一个空目录,在该目录下clone所有子仓库,并gitignore
,子仓库的代码提交都在各自的仓库目录下进行操作,这样聚合库可以避免做同步的操作。
由于ignore了所有子仓库,聚合库clone下来后,仍是一个空目录,此时我们可以写个脚本scripts/clone-all.sh
,把所有子仓库的clone命令都写上:
# 子仓库一
git clone [email protected]
# 子仓库二
git clone [email protected]
然后在聚合库也初始化一个package.json
,scripts加上:
"scripts": {
"clone:all": "bash ./scripts/clone-all.sh",
},
这样,git clone聚合库下来后,再npm run clone:all
就可以做到一键clone所有子仓库了。
前面说到聚合库要能够做到一键install和一键启动整个项目,我们参考qiankun的examples,使用npm-run-all来做这个事情。
- 聚合库安装
npm i npm-run-all -D
。 - 聚合库的package.json增加install和start命令:
"scripts": {
...
"install": "npm-run-all --serial install:*",
"install:main": "cd main && npm i",
"install:sub-vue": "cd sub-vue && npm i",
"install:sub-react": "cd sub-react && npm i",
"start": "npm-run-all --parallel start:*",
"start:sub-react": "cd sub-react && npm start",
"start:sub-vue": "cd sub-vue && npm start",
"start:main": "cd main && npm start"
},
npm-run-all
的--serial
表示有顺序地一个个执行,--parallel
表示同时并行地运行。(qiankun-example有事例)
配好以上,一键安装npm i
,一键启动npm start
。
vscode eslint配置
如果使用vscode,且使用了eslint的插件做自动修复,由于项目处于非根目录,eslint没法生效,所以还需要指定eslint的工作目录:
// .vscode/settings.json
{
"eslint.workingDirectories": [
"./main",
"./sub-vue",
"./sub-react",
"./common"
],
"eslint.enable": true,
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"search.useIgnoreFiles": false,
"search.exclude": {
"**/dist": true
},
}
十二、子应用相互跳转
除了点击页面顶部的菜单切换子应用,我们的需求也要求子应用内部跳其他子应用,这会涉及到顶部菜单active状态的展示问题:sub-vue
切换到sub-react
,此时顶部菜单需要将sub-react
改为激活状态。有两种方案:
- 子应用跳转动作向上抛给父应用,由父应用做真正的跳转,从而父应用知道要改变激活状态,有点子组件
$emit
事件给父组件的意思。 - 父应用监听
history.pushState
事件,当发现路由换了,父应用从而知道要不要改变激活状态。
由于qiankun
暂时没有封装子应用向父应用抛出事件的api,如iframe的postMessage
,所以方案一有些难度,不过可以将激活状态放到状态管理中,子应用通过改变vuex中的值让父应用同步就行,做法可行但不太好,维护状态在状态管理中有点复杂了。
所以我们这里选方案二,子应用跳转是通过history.pushState(null, '/sub-react', '/sub-react')
的,因此父应用在mounted时想办法监听到history.pushState
就可以了。由于history.popstate
只能监听back/forward/go
却不能监听history.pushState
,所以需要额外全局复写一下history.pushState
事件。
// main/src/App.vue
export default {
methods: {
bindCurrent () {
const path = window.location.pathname
if (this.microApps.findIndex(item => item.activeRule === path) >= 0) {
this.current = path
}
},
listenRouterChange () {
const _wr = function (type) {
const orig = history[type]
return function () {
const rv = orig.apply(this, arguments)
const e = new Event(type)
e.arguments = arguments
window.dispatchEvent(e)
return rv
}
}
history.pushState = _wr('pushState')
window.addEventListener('pushState', this.bindCurrent)
window.addEventListener('popstate', this.bindCurrent)
this.$once('hook:beforeDestroy', () => {
window.removeEventListener('pushState', this.bindCurrent)
window.removeEventListener('popstate', this.bindCurrent)
})
}
},
mounted () {
this.listenRouterChange()
}
}
十三、性能优化
每个子应用都是一个完整的应用,每个vue子应用都打包了一份vue/vue-router/vuex
。从整个项目的角度,相当于将那些模块打包了多次,会很浪费,所以这里可以进一步去优化性能。
首先我们能想到的是通过webpack的externals
或主应用下发公共模块进行复用。
但是要注意,如果所有子应用都共用一个相同的模块,从长远来看,不利于子应用的升级,难以两全其美。
现在觉得比较好的做法是:主应用可以下发一些自身用到的模块,子应用可以优先选择主应用下发的模块,当发现主应用没有时则自己加载;子应用也可以直接使用最新的版本而不用父应用下发的。
这个方案参考自qiankun 微前端方案实践及总结-子项目之间的公共插件如何共享,思路说得非常完整。
十四、部署
考虑到主应用和子应用共用域名时可能会存在路由冲突的问题,子应用可能会源源不断地添加进来,因此我们将子应用都放在xx.com/subapp/
这个二级目录下,根路径/
留给主应用。
步骤如下:
-
主应用main和所有子应用都打包出一份html,css,js,static,分目录上传到服务器,子应用统一放到
subapp
目录下,最终如:├── main │ └── index.html └── subapp ├── sub-react │ └── index.html └── sub-vue └── index.html
- 配置nginx,预期是
xx.com
根路径指向主应用,xx.com/subapp
指向子应用,子应用的配置只需写一份,以后新增子应用也不需要改nginx配置,以下应该是微应用部署的最简洁的一份nginx配置了。
server {
listen 80;
server_name qiankun.fengxianqi.com;
location / {
root /data/web/qiankun/main; # 主应用所在的目录
index index.html;
try_files $uri $uri/ /index.html;
}
location /subapp {
alias /data/web/qiankun/subapp;
try_files $uri $uri/ /index.html;
}
}
nginx -s reload
后就可以了。
乾坤事例:qiankun.fengxianqi.com/
单独访问子应用:
- subapp/sub-vue, 注意观察vuex数据的变化。
- subapp/sub-react