qiankun 是一个基于 single-spa 的微前端实现库,旨在帮助大家能更简单、无痛的构建一个生产可用微前端架构系统。
什么是微前端
微前端是一种多个团队通过独立发布功能的方式来共同构建现代化 web 应用的技术手段及方法策略。
微前端架构具备以下几个核心价值:
- 技术栈无关
- 独立开发、独立部署
- 增量升级
- 独立运行时
安装qiankun
npm i qiankun -S
主应用
在主应用中注册微应用
import { registerMicroApps, start } from 'qiankun'
registerMicroApps([
{
name: 'react app', // app name registered
entry: '//localhost:7100',
container: '#yourContainer',
activeRule: '/yourActiveRule',
},
{
name: 'vue app',
entry: { scripts: ['//localhost:7100/main.js'] },
container: '#yourContainer2',
activeRule: '/yourActiveRule2',
},
])
start()
微应用
webpack项目
新增public-path.js文件
if (window.__POWERED_BY_QIANKUN__) {
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
react应用
设置 history 模式路由的 base
<BrowserRouter basename={window.__POWERED_BY_QIANKUN__ ? '/app-react' : '/'}>
import './public-path';
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
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 bootstraped');
}
export async function mount(props) {
console.log('[react16] props from main framework', props);
render(props);
}
export async function unmount(props) {
const { container } = props;
ReactDOM.unmountComponentAtNode(container ? container.querySelector('#root') : document.querySelector('#root'));
}
npm i -D @rescripts/cli
修改webpack配置
根目录新增 .rescriptsrc.js:
const { name } = require('./package');
module.exports = {
webpack: (config) => {
config.output.library = `${name}-[name]`;
config.output.libraryTarget = 'umd';
config.output.jsonpFunction = `webpackJsonp_${name}`;
config.output.globalObject = 'window';
return config;
},
devServer: (_) => {
const config = _;
config.headers = {
'Access-Control-Allow-Origin': '*',
};
config.historyApiFallback = true;
config.hot = false;
config.watchContentBase = false;
config.liveReload = false;
return config;
},
};
修改package.json文件
- "start": "react-scripts start",
+ "start": "rescripts start",
- "build": "react-scripts build",
+ "build": "rescripts build",
- "test": "react-scripts test",
+ "test": "rescripts test",
- "eject": "react-scripts eject"
Vue应用
vue2应用
import './public-path';
import Vue from 'vue';
import VueRouter from 'vue-router';
import App from './App.vue';
import routes from './router';
import store from './store';
Vue.config.productionTip = false;
let router = null;
let instance = null;
function render(props = {}) {
const { container } = props;
router = new VueRouter({
base: window.__POWERED_BY_QIANKUN__ ? '/app-vue/' : '/',
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;
router = null;
}
修改vue.config.js
const { name } = require('./package');
module.exports = {
devServer: {
headers: {
'Access-Control-Allow-Origin': '*',
},
},
configureWebpack: {
output: {
library: `${name}-[name]`,
libraryTarget: 'umd', // 把微应用打包成 umd 库格式
jsonpFunction: `webpackJsonp_${name}`,
},
},
}
vue3应用
import './public-path.js'
import { createApp } from 'vue'
import App from './App.vue'
import { createRouter, createWebHistory } from 'vue-router'
import { routes } from './router'
let router: any = null
let instance: any = null
function render (props: any = {}) {
const { container } = props
router = createRouter({
history: createWebHistory((window as any).__POWERED_BY_QIANKUN__ ? '/vue3-app/' : '/'),
routes
})
instance = createApp(App)
instance.use(router).mount(container ? container.querySelector('#app') : '#app')
}
if (!(window as any).__POWERED_BY_QIANKUN__) {
render()
}
export async function bootstrap () {
console.log('[vue3] vue app bootstraped')
}
export async function mount (props: any) {
console.log('[vue3] props from main framework', props)
render(props)
}
export async function unmount () {
instance.unmount()
instance = null
router = null
}
webpack配置文件修改vue2相同
angular应用
在 src 目录新增 public-path.js 文件
if (window.__POWERED_BY_QIANKUN__) {
// eslint-disable-next-line no-undef
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
设置 history 模式路由的 base,src/app/app-routing.module.ts 文件
+ import { APP_BASE_HREF } from '@angular/common';
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule],
// @ts-ignore
+ providers: [{ provide: APP_BASE_HREF, useValue: window.__POWERED_BY_QIANKUN__ ? '/app-angular' : '/' }]
})
如果window上出现报错,可以在src目录下新建global.d.ts声明window对象
export {}
declare global{
interface Window{
__POWERED_BY_QIANKUN__?: string
}
}
修改入口文件,src/main.ts 文件
import './public-path';
import { enableProdMode, NgModuleRef } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
if (environment.production) {
enableProdMode();
}
let app: void | NgModuleRef<AppModule>;
async function render() {
app = await platformBrowserDynamic()
.bootstrapModule(AppModule)
.catch((err) => console.error(err));
}
if (!(window as any).__POWERED_BY_QIANKUN__) {
render();
}
export async function bootstrap(props: Object) {
console.log(props);
}
export async function mount(props: Object) {
render();
}
export async function unmount(props: Object) {
console.log(props);
// @ts-ignore
app.destroy();
}
安装 @angular-builders/custom-webpack 插件, 注意:angular 9 项目只能安装 9.x 版本,angular 10 项目可以安装最新版。
npm i @angular-builders/custom-webpack -D
在根目录增加 custom-webpack.config.js ,内容为:
const appName = require('./package.json').name;
module.exports = {
devServer: {
headers: {
'Access-Control-Allow-Origin': '*',
},
},
output: {
library: `${appName}-[name]`,
libraryTarget: 'umd',
jsonpFunction: `webpackJsonp_${appName}`,
},
};
修改 angular.json
- "builder": "@angular-devkit/build-angular:browser",
+ "builder": "@angular-builders/custom-webpack:browser",
"options": {
+ "customWebpackConfig": {
+ "path": "./custom-webpack.config.js"
+ }
}
解决 zone.js 的问题
- 在父应用引入
zone.js,需要在import qiankun之前引入。 - 将微应用的
src/polyfills.ts里面的引入zone.js代码删掉。
- import 'zone.js';
在微应用的 src/index.html 里面的 标签加上下面内容,微应用独立访问时使用。
<!-- 也可以使用其他的CDN/本地的包 -->
<script src="https://cdn.jsdelivr.net/npm/zone.js@0.11.4/dist/zone.min.js"></script>
修正ng build打包报错问题,修改tsconfig.json 文件
- "target": "es2015",
+ "target": "es5",
+ "typeRoots": [
+ "node_modules/@types"
+ ],
为了防止主应用或其他微应用也为 angular 时, 会冲突的问题,建议给 加上一个唯一的 id,比如说当前应用名称。
- <app-root></app-root>
+ <app-root id="angular9"></app-root>
- selector: 'app-root',
+ selector: '#angular9 app-root',
非webpack应用
接入非常简单,只需要额外声明一个 script,用于 export 相对应的 lifecycles
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Purehtml Example</title>
</head>
<body>
<div>
Purehtml Example
</div>
</body>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.6.0/dist/jquery.min.js"></script>
<!-- entry是用来告诉qiankun这是入口模块 -->
+ <script src="//yourhost/entry.js" entry></script>
</html>
在 entry js 里声明 lifecycles
const render = ($) => {
$('#purehtml-container').html('Hello, render with jQuery');
return Promise.resolve();
};
((global) => {
global['purehtml'] = {
bootstrap: () => {
console.log('purehtml bootstrap');
return Promise.resolve();
},
mount: () => {
console.log('purehtml mount');
return render($);
},
unmount: () => {
console.log('purehtml unmount');
return Promise.resolve();
},
};
})(window);
如果使用vite,处理与webpack类似,打包需要选择使用umd的方式。
手动加载微应用
通常这种场景下微应用是一个不带路由的可独立运行的业务组件。微应用不宜拆分过细,建议按照业务域来做拆分。
在组件中可以调用loadMicroApp加载微应用。下面为示例代码
<script lang="ts">
import Vue from 'vue'
import { loadMicroApp } from 'qiankun'
export default Vue.extend({
data () {
return {
microApp1: null as any,
microApp2: null as any
}
},
mounted () {
this.loadMicroApp()
},
methods: {
loadMicroApp () {
this.microApp1 = loadMicroApp({
name: 'app1',
entry: '//localhost:3003',
container: this.$refs.app1 as HTMLElement,
props: { brand: 'qiankun' }
})
this.microApp2 = loadMicroApp({
name: 'app2',
entry: '//localhost:3002',
container: this.$refs.app2 as HTMLElement,
props: { brand: 'qiankun' }
})
}
},
destroyed () {
this.microApp1.unmount()
this.microApp2.unmount()
}
})
</script>
<template>
<div class="about">
<h1>This is an about page</h1>
<div ref="app1"></div>
<div ref="app2"></div>
</div>
</template>
手动加载方式也可以加载带路由的微应用。使用时需注意主应用与微应用的路由匹配。
预加载
- opts
- prefetch
true,默认值。配置为true,则会在第一个微应用 mount 完成后开始预加载其他微应用的静态资源all,配置为all则主应用 start 后即开始预加载所有微应用静态资源- 配置为
string[]则会在第一个微应用 mounted 后开始加载数组内的微应用资源 - 配置为
function则可完全自定义应用的资源加载时机 (首屏应用及次屏应用)
- prefetch
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import 'zone.js'
import { registerMicroApps, start } from 'qiankun'
Vue.config.productionTip = false
registerMicroApps([
{
name: 'reactApp',
entry: '//localhost:3002',
container: '#container',
activeRule: '/react'
},
{
name: 'hello',
entry: '//localhost:3001',
container: '#container',
activeRule: '/hello'
},
{
name: 'angular',
entry: '//localhost:4200',
container: '#container',
activeRule: '/angular'
},
{
name: 'jquery',
entry: '//localhost:3003',
container: '#container',
activeRule: '/jquery'
}
])
// 启动 qiankun
start({
// 默认值为true,在第一个微应用 mount 完成后开始预加载其他微应用的静态资源
prefetch: true
})
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')
微应用的路由模式选择
activeRule使用location.pathname区分微应用
主应用使用location.pathname,微应用可以是history或hash模式
registerMicroApps([
{
name: 'vue-app',
entry: 'http://localhost:8081',
container: '#container',
activeRule: '/vue-app',
},
]);
- 当微应用是
history模式时,设置路由 base 即可(参考之前的) - 当微应用是
hash模式时,三种路由的表现不一致
Vue应用
修改路由模式即可
...
function render (props: any = {}) {
const { container } = props
router = new VueRouter({
routes,
// history模式时使用
// base: window.__POWERED_BY_QIANKUN__ ? '/hello/' : '/',
// hash模式
mode: 'hash'
})
instance = new Vue({
router,
store,
render: h => h(App)
}).$mount(container ? container.querySelector('#app') : '#app')
}
...
React应用
修改路由模式即可
import { HashRouter } from 'react-router-dom'
function render(props) {
const { container } = props
ReactDOM.render(
<React.StrictMode>
<HashRouter
// basename={window.__POWERED_BY_QIANKUN__ ? '/react' : '/'}
>
<App />
</HashRouter>
</React.StrictMode>,
container ? container.querySelector('#root') : document.querySelector('#root')
);
}
angular
修改angular.json文件
...
"build": {
"builder": "@angular-builders/custom-webpack:browser",
"options": {
+ "baseHref": "/angular",
}
}
...
修改src/app/app-routing.module.ts文件
@NgModule({
imports: [RouterModule.forRoot(routes, {
+ useHash: true
})],
exports: [RouterModule],
- providers: [{ provide: APP_BASE_HREF, useValue: window.__POWERED_BY_QIANKUN__ ? '/angular/' : '/' }]
})
activeRule使用location.hashname区分微应用
{
name: 'angular',
entry: '//localhost:4200',
container: '#container',
activeRule: '/angular'
}
Vue应用
Vue应用需要修改路由mode为hash外,还需要修改下路由routes。
export const routes: Array<RouteConfig> = [
{
path: '/hello',
component: () => import('../views/Layout.vue'),
children: [{
path: '',
name: 'Home',
component: Home
}, {
path: 'about',
name: 'About',
component: () => import( '../views/About.vue')
}]
}
]
微应用的导航链接要写完整路径或者可以直接通过传递name跳转
<div id="nav">
<router-link :to="{name:'Home'}">Home</router-link> |
<router-link to="/hello/about">About</router-link>
</div>
React应用
React不需要任何设置,和history模式一样,只需要加上basename就可以了
function render(props) {
const { container } = props;
ReactDOM.render(
<React.StrictMode>
<HashRouter
basename={window.__POWERED_BY_QIANKUN__ ? '/react' : '/'}
>
<App />
</HashRouter>
</React.StrictMode>,
container ? container.querySelector('#root') : document.querySelector('#root')
);
}
Angular应用
Angular应用也很简单,和history模式一样。如果之间修改过angular.json文件的baseHref需要删掉
@NgModule({
imports: [RouterModule.forRoot(routes, {
useHash: true
})],
exports: [RouterModule],
providers: [{ provide: APP_BASE_HREF, useValue: window.__POWERED_BY_QIANKUN__ ? '/angular' : '/' }]
})
同时存在多个微应用时
如果一个页面同时展示多个微应用,需要使用 loadMicroApp 来加载。
如果这些微应用都有路由跳转的需求,要保证这些路由能互不干扰,需要使用 momery 路由。vue-router 使用 abstract 模式,react-router 使用 memory history 模式,angular-router 不支持。
总结
主应用建议使用history模式。
从上面可以看到,history模式优势非常明显,微应用配置更简单。
开发启动
开发学习过程中,每次启动多个服务,一个一个启动有点麻烦,这时我们可以借助npm-run-all这个模块
npm i npm-run-all -D
在项目目录下package.json文件中设置启动命令。npm-run-all命令的文档参考 github.com/mysticatea/…
如下面代码所示,我们就可以通过npm start启动多个应用来进行开发调试,通过npm run build打包多个应用
{
"name": "xxx",
"version": "1.0.0",
"scripts": {
"preview": "serve static -p 5000",
"start": "npm-run-all -p start:*",
"start:main": "cd main && npm start",
"start:react": "cd react-app && npm start",
"start:vue": "cd vue-app && npm start",
"build": "npm-run-all -p build:*",
"build:main": "cd main && npm run build",
"build:react": "cd react-app && npm run build",
"build:vue": "cd vue-app && npm run build"
},
"devDependencies": {
"npm-run-all": "^4.1.5",
}
}
部署
建议:主应用和微应用都是独立开发和部署,即它们都属于不同的仓库和服务。
主应用和微应用部署到同一个服务器(同一个 IP 和端口)
主应用
Vue应用,配置Vue应用的环境变量。请注意,只有 NODE_ENV,BASE_URL 和以 VUE_APP_ 开头的变量将通过 webpack.DefinePlugin 静态地嵌入到客户端侧的代码中
[
{
name: 'reactApp',
// 最后必须以/结尾,否则会导致图片加载不成功
entry: process.env.VUE_APP_REACT_ENTRY,
container: '#container',
activeRule: '/react-app'
},
{
name: 'vueApp',
entry: process.env.VUE_APP_VUE2_ENTRY,
container: '#container',
activeRule: '/vue-app'
}
}
微应用
webpack打包publicPath配置
// vue.config.js
module.exports = {
publicPath: '/child/vue-history/',
};
路由base设置
base: window.__POWERED_BY_QIANKUN__ ? '/app-vue-history/' : '/child/vue-history/',
React 应用,webpack配置和vue应用一样
module.exports = {
output: {
publicPath: '/child/react-history/',
},
};
路由配置
<BrowserRouter basename={window.__POWERED_BY_QIANKUN__ ? '/app-react-history' : '/child/react-history/'}>
不在同一服务器上
- 第一种方案是服务端设置允许跨域,这种方式不需要配置。
- 微应用不允许跨域访问
允许跨域
如果服务器本身不支持跨域,可以设置允许跨域.以nginx为例
location / {
# 允许的跨域请求地址,* 表示任意
add_header Access-Control-Allow-Origin *;
# 允许的跨域请求方法
add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS';
# 允许的跨域请求头
add_header Access-Control-Allow-Headers 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization';
# 给 OPTIONS 添加 204的返回,是为了处理在发送 POST 请求时 Nginx 依然拒绝访问的错误
if ($request_method = 'OPTIONS') {
return 204;
}
}
不允许跨域
例如主应用在 A 服务器,微应用在 B 服务器,使用路径 /app1 来区分微应用,即 A 服务器上所有 /app1 开头的请求都转发到 B 服务器上,即通过代理的方式解决跨域
nginx配置
location /app1/ {
proxy_pass http://www.b.com/app1/;
proxy_set_header Host $host:$server_port;
}
配置webpack的publicPath
module.exports = {
output: {
publicPath: `/app1/`,
},
};
注意,微应用打包的 publicPath 加上 /app1/ 之后,必须部署在 /app1 目录,否则无法独立访问。
微应用间的通信
initGlobalState(state)
主应用
import { initGlobalState, MicroAppStateActions } from 'qiankun';
// 初始化 state
const actions: MicroAppStateActions = initGlobalState(state)
actions.onGlobalStateChange((state, prev) => {
// state: 变更后的状态; prev 变更前的状态
console.log(state, prev)
});
// 更新state时调用
actions.setGlobalState(state)
// 关闭状态检测
actions.offGlobalStateChange()
微应用,在mount钩子函数中调用
// 从生命周期 mount 中获取通信方法,使用方式和 master 一致
export function mount(props) {
props.onGlobalStateChange((state, prev) => {
// state: 变更后的状态; prev 变更前的状态
console.log(state, prev);
});
props.setGlobalState(state);
}