微前端框架qiankun实践
如果你面临以下问题,可以考虑微前端
- 随着新业务的快速扩张,项目出现爆炸发展的问题,项目管理和协同开发的难度在不断上升。
- 随着项目文件越来越多,开发、构建、部署速度越来越慢,开发体验持续下降。
- 一个小改动却要全量打包,导致用户在一定时间内无法使用应用的其他功能,用户体验下降。
- 技术开发人员想要在新的项目中尝试不同的技术栈,一方面可摆脱老项目的沉重和技术陈旧问题,一方面可学习新的技术,提升自我,但产品要求无痛迁移(用户无感)、分批迁移(大版本迭代总可能出现奇奇怪怪的问题)或部分迁移(保留老项目部分内容,且所有新迭代都将在新项目中开发)。
那么什么是微前端?
微前端(Micro-Frontends)是一种类似于微服务的架构,它将微服务的理念应用于浏览器端,即将 Web 应用由单一的单体应用转变为多个小型前端应用聚合为一的应用。各个前端应用还可以独立运行、独立开发、独立部署。微前端不是单纯的前端框架或者工具,而是一套架构体系。
Why Not Iframe
关于qiankun
qiankun是一个基于single-spa的微前端实现库,旨在帮助大家能更简单、无痛的构建一个生产可用微前端架构系统。
微前端架构具备以下几个核心价值:
- 技术栈无关
主框架不限制接入应用的技术栈,微应用具备完全自主权- 独立开发、独立部署
微应用仓库独立,前后端可独立开发,部署完成后主框架自动完成同步更新- 增量升级
在面对各种复杂场景时,我们通常很难对一个已经存在的系统做全量的技术栈升级或重构,而微前端是一种非常好的实施渐进式重构的手段和策略- 独立运行时
每个微应用之间状态隔离,运行时状态不共享
微前端架构旨在解决单体应用在一个相对长的时间跨度下,由于参与的人员、团队的增多、变迁,从一个普通应用演变成一个巨石应用(Frontend Monolith)后,随之而来的应用不可维护的问题。这类问题在企业级 Web 应用中尤其常见。
qiankun实践微前端
以下使用vue项目为主应用、vue和react项目为子应用构建qiankun demo。
1. 创建一个新项目qiankun-demo
1 | mkdir qiankun-demo |
2. 在该目录内构建主应用main(vue项目)和子应用micro-vue和micro-react(vue项目和react项目)
我使用vue create和create-react-app创建的项目
并且为两个子应用配上了路由(vue-router和react-router-dom)
3. 配置主应用main
1 | cd main |
在主应用中注册qiankun,src目录下添加qiankun.js文件,配置qiankun的启动项,我的配置如下
1 | import { |
当微应用信息注册完之后,一旦浏览器的 url 发生变化,便会自动触发 qiankun 的匹配逻辑,所有 activeRule 规则匹配上的微应用就会被插入到指定的 container 中,同时依次调用微应用暴露出的生命周期钩子。
如果微应用不是直接跟路由关联的时候,你也可以选择手动加载微应用的方式:
1 | import { loadMicroApp } from 'qiankun'; |
在主应用main.js中增加以下内容,启动qiankun
1 | import startQiankun from './qiankun' |
在App.vue中增加容器subapp-viewport用于子应用渲染,router-view用于主应用页面渲染(这意味着我们可以在主应用中自定义路由,除了/vue和/react的路径,其它均走主应用路由渲染)
1 | <div id="subapp-viewport" v-show="!$route.name"></div> |
为解决开发环境跨域问题,可按以下内容配置vue.config.js
1 | module.exports = { |
4. 配置子应用micro-vue和micro-react(子应用不需要额外安装任何其他依赖即可接入 qiankun 主应用)
子应用配置主要有以下几个步骤:
a. 增加'Access-Control-Allow-Origin': '*'
解决开发环境跨域问题
b. 在入口js中导出相应的生命周期钩子
c. 配置微应用的打包工具
- 配置vue子应用micro-vue
修改vue.config.js修改main.js,导出相应的生命周期钩子1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20const name = require('./package').name;
module.exports = {
devServer: {
port: 2001,
headers: {
'Access-Control-Allow-Origin': '*' // 解决开发环境跨域问题
}
},
configureWebpack: { // 配置微应用打包工具,让主应用能正确识别微应用暴露出来的一些信息
output: {
// 微应用的包名,这里与主应用中注册的微应用名称一致${name}
library: `${name}-[name]`,
// 将你的 library 暴露为所有的模块定义下都可运行的方式
libraryTarget: 'umd',
// 按需加载相关,设置为 webpackJsonp_${name} 即可
jsonpFunction: `webpackJsonp_${name}`
}
},
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79/* eslint-disable no-undef */
import Vue from 'vue'
import App from './App.vue'
import routerConfig from './router/index.js'
import VueRouter from 'vue-router';
let instance = null
let router = null
if (window.__POWERED_BY_QIANKUN__) {
// 动态设置 webpack publicPath,防止资源加载出错
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__
}
function render (props = {}) {
if (props) {
// Vue.prototype.$app = props // 将props挂在prototype上
}
Vue.config.productionTip = false
Vue.use(VueRouter)
router = new VueRouter({
base: window.__POWERED_BY_QIANKUN__ ? '/vue' : '/',
mode: 'history',
routes: routerConfig
})
// 由于主应用渲染在#app上,为防止换用,增加判断,若使用了qiankun,则将子应用渲染在container下的#app中,若没有使用,则直接渲染在body下的#app中
// 也可以为当前子应用定制一个不同名的渲染位置,但要同时修改子应用index.html和app.vue的id并且保证不跟主应用的id冲突
const { container } = props
console.log(props)
instance = new Vue({
router,
render: h => h(App)
}).$mount(container ? container.querySelector('#app') : '#app')
}
/**
* 不存在主应用时可直接单独运行
*/
if (!window.__POWERED_BY_QIANKUN__) {
render()
}
/**
* bootstrap 只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。
* 通常我们可以在这里做一些全局变量的初始化,比如不会在 unmount 阶段被销毁的应用级别的缓存等。
*/
export async function bootstrap () {
console.log('[vue] vue app bootstraped')
}
/**
* 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
*/
export async function mount (props) {
console.log('[vue] vue app mount', props.test)
props.onGlobalStateChange && props.onGlobalStateChange((state, prev) => {
console.log(state, prev)
}, true);
props.setGlobalState && props.setGlobalState({ msg: 'vue子应用修改globalstate' })
render(props)
}
/**
* 应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例
*/
export async function unmount () {
console.log('[vue] vue app unmount')
instance.$destroy()
instance.$el.innerHTML = ''
instance = null
router = null
} - 配置react子应用micro-react
安装rescripts,用于修改webpack配置修改package.json1
npm i -D @rescripts/cli
在package.json同级目录下增加.rescriptsrc.js文件,内容如下1
2
3
4
5"scripts": {
"start": "rescripts start",
"build": "rescripts build",
"test": "rescripts test"
}在package.json同级目录下增加.env文件,内容如下,其中1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31const { name } = require('./package');
const path = require('path');
const resolve = (dir, sourceDir = __dirname) => path.join(sourceDir, dir);
module.exports = {
webpack: config => {
config.output.library = `${name}-[name]`;
config.output.libraryTarget = 'umd';
config.output.jsonpFunction = `webpackJsonp_${name}`;
config.output.globalObject = 'window';
config.resolve.alias = {
'@': resolve('src'),
}
return config;
},
devServer: config => {
config.headers = {
'Access-Control-Allow-Origin': '*',
};
// config.historyApiFallback = true;
// config.hot = false;
// config.watchContentBase = false;
// config.liveReload = false;
return config;
}
};PORT=2002
和WDS_SOCKET_PORT=2002
指定运行的端口号,BROWSER=none
表示运行该项目时不会默认打开浏览器1
2
3PORT=2002
WDS_SOCKET_PORT=2002
BROWSER=noneWDS_SOCKET_PORT=2002
一定要加,不加主应用会崩,报以下错误修改index.js内容,导出相应的生命周期钩子1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19webpackHotDevClient.js:60 WebSocket connection to 'ws://localhost:2000/sockjs-node' failed: Error during WebSocket handshake: Unexpected response code: 200
./node_modules/react-dev-utils/webpackHotDevClient.js @ webpackHotDevClient.js:60
__webpack_require__ @ bootstrap:856
fn @ bootstrap:150
1 @ index.js:29
__webpack_require__ @ bootstrap:856
checkDeferredModules @ bootstrap:45
webpackJsonpCallback @ bootstrap:32
eval @ universalModuleDefinition:11
webpackUniversalModuleDefinition @ universalModuleDefinition:9
eval @ universalModuleDefinition:10
eval @ main.chunk.js:784
geval @ index.js?50b2:162
exec @ index.js?50b2:179
schedule @ index.js?50b2:217
schedule @ index.js?50b2:222
schedule @ index.js?50b2:222
eval @ index.js?50b2:228
eval @ index.js?50b2:2271
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
// import reportWebVitals from './reportWebVitals';
if (window.__POWERED_BY_QIANKUN__) {
// eslint-disable-next-line no-undef
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
function render (props = {}) {
const { container } = props;
ReactDOM.render(<App/>, container ? container.querySelector('#root') : document.querySelector('#root'));
}
if (!window.__POWERED_BY_QIANKUN__) {
render();
}
export async function bootstrap () {
console.log('[react16] react app boostraped');
}
export async function mount (props) {
console.log('[react16] props from main framework', props);
props.onGlobalStateChange && props.onGlobalStateChange((value, prev) => {
console.log(`[onGlobalStateChange - ${props.name}]:`, value, prev);
}, true);
props.setGlobalState && props.setGlobalState({ msg: 'react16' });
render(props);
}
export async function unmount (props) {
const { container } = props;
ReactDOM.unmountComponentAtNode(container ? container.querySelector('#root') : document.querySelector('#root'));
}
// ReactDOM.render(
// <React.StrictMode>
// <RouterConfig></RouterConfig>
// </React.StrictMode>,
// document.getElementById('root')
// );
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
// reportWebVitals();5. 使用npm-run-all一键安装运行所有项目
在qiankun-demo目录下,运行以下代码修改qiankun-demo/package.json1
npm install --save-dev npm-run-all
接下来只需运行1
2
3
4
5
6
7
8
9
10
11"scripts": {
"install": "npm-run-all --serial install:main install:micro-react install:micro-vue",
"serve": "npm-run-all --parallel serve:*",
"install:main": "cd main && rm -rf node_modules && npm install",
"install:micro-react": "cd micro-react && rm -rf node_modules && npm install",
"install:micro-vue": "cd micro-vue && rm -rf node_modules && npm install",
"serve:main": "cd main && npm run serve",
"serve:micro-react": "cd micro-react && npm run start",
"serve:micro-vue": "cd micro-vue && npm run serve",
"test": "echo \"Error: no test specified\" && exit 1"
}npm install
即可安装主应用和子应用的package.json中定义的包,只需运行npm run serve
即可同时运行主应用和子应用6. 自动读取子应用路由配置,生成sidebar
一般内部系统都有一个左侧菜单栏,用于页面导航,但是由于我们有多个子应用,如果自己维护左侧菜单栏,则每次子应用增肌新页面时,都要手动在左侧菜单栏的配置文件中增加该页面的导航,十分麻烦。
我们希望能够自动读取子应用的路由配置文件,在每次npm run serve
时生成整合后的菜单栏配置文件。
首先,修改qiankun-demo/package.json的serve运行语句然后,在qiankun-demo目录下增加bin文件夹,在bin文件夹下增加start.js文件,其内容如下1
"serve": "node ./bin/start.js && npm-run-all --parallel serve:*"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35// nodejs对es6的支持并不好,像class, import这些关键字,react的jsx都无法使用。但是babel可以将这些都转化为es5。此处讲讲如何在node端使用babel。
// babel-register可以理解成一个小插件,将es6的东西转成es5,github地址:https://github.com/babel/babel/tree/master/packages/babel-register。
// 使用很简单,只需要在文件中加入
require('babel-register') ({
presets: [ 'env' ]
});
const fs = require('fs');
const path = require('path');
const config = [
{
name: 'micro-vue',
activeRule: '/vue'
}, {
name: 'micro-react',
activeRule: '/react',
}
];
let content = [];
const createSideMenu = () => {
const tips = `// ${
(new Date()).toLocaleString()
}从bin/start.js自动生成`;
config.forEach(item => {
const appRouterConfig = require(path.join(__dirname, `../${item.name}/src/router/appRouterConfig.js`));
content = content.concat(appRouterConfig.default.map(ele => ({ name: ele.name, path: item.activeRule + ele.path, meta: ele.meta })));
})
fs.writeFileSync(path.join(__dirname, '../main/src/sideMenu.js'), tips + '\n' + `export default ${JSON.stringify(content, null, 2)}\n`.replace(/\"/g, "\'"));
}
createSideMenu();7. 其他常见问题
- 如何在 Node.js 中使用 import / export 的三种方法
1
2
3
4
5
6
7
8// nodejs对es6的支持并不好,像class, import这些关键字,react的jsx都无法使用。但是babel可以将这些都转化为es5。此处讲讲如何在node端使用babel。
// babel-register可以理解成一个小插件,将es6的东西转成es5,github地址:https://github.com/babel/babel/tree/master/packages/babel-register。
require('babel-register') ({
presets: [ 'env' ]
}) - 解决vue-router报NavigationDuplicated: Avoided redundant navigation to current location 的问题
- react项目每次启动不打开默认浏览器
1
BROWSER=none
8. 项目地址
【参考】
记一次 微前端 qiankun 项目 实践 !!! 防踩坑指南
体验微前端(qiankun)
微前端原理和实战(single-spa qiankun)
qiankun 微前端实践总结(二)
qiankun 微前端应用实践与部署(三)
qiankun 微前端应用实践与部署(二)
qiankun 微前端方案实践及总结
基于qiankun落地部署微前端爬”坑“记