什么是微前端?
微前端是一种多个团队通过独立发布功能的方式来共同构建现代化 web 应用的技术手段及方法策略。
微前端架构具备以下几个核心价值
* 技术栈无关
主框架不限制接入应用的技术栈,微应用具备完全自主权
* 独立开发、独立部署
微应用仓库独立,前后端可独立开发,部署完成后主框架自动完成同步更新
* 增量升级
在面对各种复杂场景时,我们通常很难对一个已经存在的系统做全量的技术栈升级或重构,而微前端是一种非常好的实施渐进式重构的手段和策略
* 独立运行时
每个微应用之间状态隔离,运行时状态不共享
基于这个呢出现了一个相对完善的微前端架构系统qiankun
。
特性
基于 single-spa
封装,提供了更加开箱即用的 API。技术栈无关
,任意应用均可 使用/接入,不论是 React/Vue/Angular/JQuery 还是其他等框架。HTML Entry
接入方式,让你接入微应用像使用 iframe 一样简单。样式隔离
,确保微应用之间样式互相不干扰。JS 沙箱
,确保微应用之间 全局变量/事件 不冲突。资源预加载
,在浏览器空闲时间预加载未打开的微应用资源,加速微应用打开速度。
实践开始
参考官方的 examples 代码,项目根目录下有基座main和其他子应用sub-vue
,sub-react
,搭建后的初始目录结构如下:
├── common //公共模块
├── main // 基座
├── react // react子应用
└── vue // vue子应用
基座配置(main)
基座应该保持简洁(qiankun官方demo甚至直接使用原生html搭建
),不应该做涉及业务的操作。
所以按照官网的来搭建后的目录结构如下:
├── node_modules
├── index.html
├── index.js // 子应用的配置
└── webpack.config.js // webpack的一些配置
└── package.json
但是 我们的项目肯定是要用到UI库
的,所以vue脚手架
走起
// 在此之前我们需要做一些先手工作
// 初始化一个package.json 用来安装依赖
npm init
$ yarn add qiankun # 或者 npm i qiankun -S
// 为简化启动配置
$ npm install npm-run-all --save-dev 或者 yarn add npm-run-all --dev
vue create demo
修改 main.js
import Vue from 'vue'
import App from './App.vue'
import { registerMicroApps, start, setDefaultMountApp } from 'qiankun';
Vue.config.productionTip = false
new Vue({
render: h => h(App),
}).$mount('#app')
/**
* Step1 注册子应用
*/
registerMicroApps(
[
{
name: 'react1',
entry: '//localhost:7100',
container: '#subapp-viewport',
activeRule: '/react1',
},
{
name: 'vue1',
entry: '//localhost:7101',
container: '#subapp-viewport',
activeRule: '/vue1',
},
],
{
beforeLoad: [
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);
},
],
},
);
/**
* Step2 设置默认进入的子应用
*/
setDefaultMountApp('/react1');
/**
* Step3 启动应用
*/
start();
在App.vue
中,需要声明main.js
配置的子应用挂载div(注意id一定要一致)
,修改App.vue
:
<template>
<div class="mainapp">
<header class="mainapp-header">
<h1>这里是个头部公用</h1>
</header>
<div class="mainapp-main">
<!-- 侧边栏 -->
<ul class="mainapp-sidemenu">
<li @click="goToUrl('/react1')">react1</li>
<li @click="goToUrl('/vue1')">Vue1</li>
<li @click="goToUrl('/jquery1')">jquery1</li>
</ul>
<!-- 子应用 -->
<main id="subapp-viewport"></main>
</div>
</div>
</template>
<script>
export default {
name: 'mainapp',
methods: {
goToUrl(subapp) {
history.pushState(null, subapp, subapp)
}
},
}
</script>
这样,基座就算配置完成了。项目启动后,子应用将会挂载到<main id="subapp-viewport"></main>
中。
子应用配置
vue子应用
为了方便管理项目与基座同目录创建子目录
用Vue-cli
在项目根目录新建一个叫vue1
的子应用,子应用的名称最好与父应用在基座main中main.js配置的名称
一致(这样可以直接使用package.json
中的name
作为output
)。
新增vue.config.js
,devServer
的端口改为与主应用配置的一致,且加上跨域headers
和output
配置.
const path = require('path');
const { name } = require('./package');
function resolve(dir) {
return path.join(__dirname, dir);
}
const port = 7101; // dev port
module.exports = {
outputDir: 'dist',
assetsDir: 'static',
filenameHashing: true,
devServer: {
hot: true,
disableHostCheck: true,
port,
overlay: {
warnings: false,
errors: true,
},
headers: {
'Access-Control-Allow-Origin': '*',
},
},
// 自定义webpack配置
configureWebpack: {
resolve: {
alias: {
'@': resolve('src'),
},
},
output: {
// 把子应用打包成 umd 库格式
library: `${name}-[name]`,
libraryTarget: 'umd',
jsonpFunction: `webpackJsonp_${name}`,
},
},
};
新增src/public-path.js
if (window.__POWERED_BY_QIANKUN__) {
// eslint-disable-next-line no-undef
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
改造main.js
import './public-path'; // 引入
import Vue from 'vue';
import VueRouter from 'vue-router';
import App from './App.vue';
import routes from './router';
Vue.config.productionTip = false;
let router = null;
let instance = null;
function render(props = {}) {
const { container } = props;
router = new VueRouter({
base: window.__POWERED_BY_QIANKUN__ ? '/vue1' : '/',
mode: 'history',
routes,
});
instance = new Vue({
router,
render: h => h(App),
}).$mount(container ? container.querySelector('#app') : '#app');
}
// 独立运行时
if (!window.__POWERED_BY_QIANKUN__) {
render();
}
export async function bootstrap() {
console.log('vue 启动视图');
}
export async function mount(props) {
console.log('来自vue', props);
render(props);
}
export async function unmount() {
instance.$destroy();
instance.$el.innerHTML = '';
instance = null;
router = null;
}
至此,基础版本的vue子应用
配置好了。
react子应用
$ npx create-react-app react1
新增 .env
文件添加PORT
变量,端口号与父应用配置的保持一致。
.env文件
SKIP_PREFLIGHT_CHECK=true
BROWSER=none
PORT=7100
WDS_SOCKET_PORT=7100
安装插件 @rescripts/cli
,当然也可以选择其他的插件,例如 react-app-rewired。
$ npm install react-app-rewired --save-dev 或者 npm i -D @rescripts/cli
根目录新建config-overrides.js
来填写一些weback
的配置
const { name } = require('./package.json');
module.exports = {
webpack: function override(config, env) {
// 解决主应用接入后会挂掉的问题:https://github.com/umijs/qiankun/issues/340
config.entry = config.entry.filter(
(e) => !e.includes('webpackHotDevClient')
);
config.output.library = `${name}-[name]`;
config.output.libraryTarget = 'umd';
config.output.jsonpFunction = `webpackJsonp_${name}`;
return config;
},
devServer: (configFunction) => {
return function (proxy, allowedHost) {
const config = configFunction(proxy, allowedHost);
config.open = false;
config.hot = false;
config.headers = {
'Access-Control-Allow-Origin': '*',
};
return config;
};
},
};
或者根目录新增 .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;
},
};
- "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"
.rescriptsrc.js 或者 config-overrides.js 存在一个即可
src
下新建 public-path.js
if (window.__POWERED_BY_QIANKUN__) {
// eslint-disable-next-line no-undef
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
修改index.js
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'));
}
到这里react的基础版本也配置好了
最后修改最外层的package.json
"scripts": {
"install": "npm-run-all --serial install:*",
"install:main": "cd main && npm i",
"install:vue1": "cd vue1 && npm i",
"install:react1": "cd react1 && npm i",
"start": "npm-run-all --parallel start:*",
"start:react1": "cd react1 && npm start",
"start:vue1": "cd vue1 && npm start",
"start:main": "cd main && npm start",
"test": "echo \"Error: no test specified\" && exit 1"
},
/**
* bootstrap 只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。
* 通常我们可以在这里做一些全局变量的初始化,比如不会在 unmount 阶段被销毁的应用级别的缓存等。
*/
export async function bootstrap() {
console.log('react app bootstraped');
}
/**
* 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
*/
export async function mount(props) {
ReactDOM.render(<App />, props.container ? props.container.querySelector('#root') : document.getElementById('root'));
}
/**
* 应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例
*/
export async function unmount(props) {
ReactDOM.unmountComponentAtNode(props.container ? props.container.querySelector('#root') : document.getElementById('root'));
}
/**
* 可选生命周期钩子,仅使用 loadMicroApp(手动加载微应用) 方式加载微应用时生效
*/
export async function update(props) {
console.log('update props', props);
}
通信
qiankun
内部提供了 initGlobalState
方法用于注册 MicroAppStateActions
实例用于通信,该实例有三个方法,分别是:
onGlobalStateChange: (callback: OnGlobalStateChangeCallback, fireImmediately?: boolean) => void
, 在当前应用监听全局状态,有变更触发callback,fireImmediately = true
立即触发callback
setGlobalState: (state: Record<string, any>) => boolean
, 按一级属性设置全局状态,微应用中只能修改已存在的一级属性offGlobalStateChange: () => boolean
,移除当前应用的状态监听,微应用umount
时会默认调用
首先,我们在主应用中注册一个 MicroAppStateActions
如下
// main/src/action/actions.ts
import { initGlobalState, MicroAppStateActions } from "qiankun";
const initialState = {};
const actions: MicroAppStateActions = initGlobalState(initialState);
export default actions;
注册 MicroAppStateActions
实例后,我们在需要通信的组件中使用该实例,并注册 观察者
函数
在App.vue中写入方法
mounted() {
actions.onGlobalStateChange((state, prevState) => {
console.log("改变前 ", prevState.userInfo);
console.log("改变后", state.userInfo);
});
},
methods: {
goToUrl(subapp) {
history.pushState(null, subapp, subapp)
},
writeBtn() {
let userInfo = {
name: '曲小强',
age: 27,
sex: '男',
desc: '肥宅快乐水,活力一整天。'
}
actions.setGlobalState({userInfo});
}
}
如上监听到数据的变化了
子应用的改造
// vue1/src/action/actions.js
function emptyAction() {
// 警告:提示当前使用的是空 Action
console.warn("当前使用的是空 Action");
}
class Actions {
// 默认值为空 Action
actions = {
onGlobalStateChange: emptyAction,
setGlobalState: emptyAction
};
/**
* 设置 actions
*/
setActions(actions) {
this.actions = actions;
}
/**
* 映射
*/
onGlobalStateChange(...args) {
return this.actions.onGlobalStateChange(...args);
}
/**
* 映射
*/
setGlobalState(...args) {
return this.actions.setGlobalState(...args);
}
}
const actions = new Actions();
export default actions;
我们创建 actions
实例后,我们需要为其注入真实 Actions
。我们在入口文件 main.js
的 render
函数中注入,代码实现如下
/**
* 渲染函数
* 主应用生命周期钩子中运行/子应用单独启动时运行
*/
let instance = null;
function render(props) {
if (props) {
// 注入 actions 实例
actions.setActions(props);
}
router = new VueRouter({
base: window.__POWERED_BY_QIANKUN__ ? "/vue" : "/",
mode: "history",
routes,
});
// 挂载应用
instance = new Vue({
router,
render: (h) => h(App),
}).$mount("#app");
}
后续补充中...
参考文献
qiankun官网 https://qiankun.umijs.org/zh/guide/getting-started