做一件事,无论大小,倘无恒心,是很不好的。--鲁迅
什么是微前端
一种类似于微服务的架构,它将微服务的理念应用于浏览器端,即将Web 应用由单一的单体应用转变为多个小型前端应用聚合为一的应用。 各个前端应用还可以独立运行、独立开发、独立部署。详细介绍见Micro Frontends 官网。
解决什么问题
将复杂项目拆分成多个子项目进行维护, 优点:
- 技术栈多样性:不局限于一种技术栈,可以应用Vue、React等技术栈
- 打包速度快:子项目单独打包,代码量变小,打包速度加快
- 各个模块耦合性降低:拆分成子项目后模块之间的耦合性降低,上线风险降低
- 代码复用性更高:拆分成独立的子项目复用性更高,复用成本也更低
- 重构更便捷:拆分的独立子项目逻辑更清晰,重构成本更低 缺点:
- 维护成本更高:拆分子项目越多,维护成本越高
- 技术成本更高:选择适合的一种微前端解决方案
已存在的微前端解决方案
方案 | 描述 | 优点 | 缺点 |
---|---|---|---|
nginx路由转发 | 通过Nginx配置反向代理来实现不同路径映射到不同应用,例如www.abc.com/app1对应app1,www.abc.com/app2对应app2,这种方案本身并不属于前端层面的改造,更多的是运维的配置 | 简单,快速,易配置 | 涉及到对应路由切换时,会触发浏览器刷新,影响体验 |
iframe嵌套 | 父应用单独是一个页面,每个子应用嵌套一个iframe,父子通信可采用postMessage或者contentWindow方式 | 实现简单,父子组件天生隔离 | 样式、兼容性、登录鉴权 |
Web Components | 每个子应用需要采用纯Web Components技术编写组件,是一套全新的开发模式 | 每个子应用拥有独立的script和css,也可单独部署 | 改造成本高 |
组合式应用路由分发 | 每个子应用独立构建和部署,运行时由父应用来进行路由管理,应用加载,启动,卸载,以及通信机制 | 纯前端改造 | 需要设计和开发,由于父子应用处于同一页面运行,需要解决子应用的样式冲突,变量对象污染,通信机制等技术点 |
组合式应用组件分发 | 每个子应用独立构建部署 | 纯前端改造,基于 webpack5 模块联邦机制 | 同上 |
现存在的微前端框架
-
single-spa
-
qiankun qiankun 官网 基于single-spa
-
EMP EMP 官网 基于 webpack5 Module Federation
基于single-spa示例(GithubDemo地址)
几个关键的包
-
single-spa
- 用途: 监听路由加载子模块
- 官网地址:single-spa.js.org/docs/gettin…
- github地址: github.com/single-spa/…
-
single-spa-vue
- 用途:包装Vue实例并返回single-spa加载子模块所需的生命周期钩子
- 地址: github.com/single-spa/…
-
single-spa-react
- 用途:包装React实例并返回single-spa加载子模块所需的生命周期钩子
- 地址:github.com/single-spa/…
-
stats-webpack-plugin
- 用途:子项目打包文件目录
- 地址: github.com/unindented/…
一个小例子 🌰
base主项目(技术栈:Vue)
main.js
import Vue from 'vue'
import App from './App.vue';
import { router , VueRouter} from './router';
import './single-spa.config.js';
Vue.config.productionTip = false;
Vue.use(VueRouter)
new Vue({
render: h => h(App),
router
}).$mount('#app')
single-spa.config.js
注意:通过.json文件动态加载子项目的js文件时,必须要等入口文件加载完毕再加载其他静态文件,不然会导致子项目抛出的生命周期挂载不到window上。因为入口文件没有完全执行,所以export的钩子没有挂载上。
import * as singleSpa from 'single-spa/lib/umd/single-spa.dev'; //导入single-spa
import axios from 'axios';
/*
* runScript:一个promise同步方法。可以代替创建一个script标签,然后加载服务
**/
const runScript = async (url) => {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.setAttribute('type', 'text/javascript');
script.src = url;
script.onload = () => {
resolve(url);
};
script.onerror = reject;
const firstScript = document.body.getElementsByTagName('script')[0];
document.body.insertBefore(script,firstScript);
});
};
/*
* getManifest:远程加载manifest.json 文件,解析需要加载的js
* url: manifest.json 链接
* bundle:entry名称
* */
const getManifest = (url, bundle) =>
new Promise((resolve) => {
axios.get(url).then( async (res) => {
// 一定是异步的
const { data } = res || {};
const { entrypoints, publicPath } = data;
const assets = entrypoints[bundle].assets;
for (let i = 0; i < assets.length; i++) {
const name = assets[i]?.name || assets[i];
// 等完全挂载完才能挂载下一个js文件
await runScript(publicPath + name).then(() => {
if (i === assets.length - 1) {
resolve();
}
});
}
});
});
singleSpa.registerApplication(
//注册微前端服务
'slaveReact',
async () => {
await getManifest('//127.0.0.1:9990/asset-stats.json', 'main');
return window.slaveReact;
},
(location) => {
// addShadowDom();
return location.pathname.startsWith('/react');
}, // 配置微前端模块前缀
{}
);
singleSpa.registerApplication(
//注册微前端服务
'slaveVue',
async () => {
await getManifest(
'//127.0.0.1:9989/asset-stats.json',
'app'
);
return window.slaveVue;
},
(location) => location.pathname.startsWith('/vue'), // 配置微前端模块前缀
{
components: {},
parcels: {},
}
);
singleSpa.start(); // 启动
window.addEventListener('single-spa:before-routing-event', () => {
console.log('before-routing-event');
});
window.addEventListener('single-spa:app-change', () => {
});
slave-vue-demo
main.js 入口文件导出生命周期钩子
import Vue from 'vue'
import App from './App.vue'
import singleSpaVue from 'single-spa-vue'
Vue.config.productionTip = false
const vueLifecycles = singleSpaVue({
Vue,
appOptions: {
render(h) {
return h(App);
},
el: '#slave'
},
});
export const bootstrap = vueLifecycles.bootstrap;
export const mount = vueLifecycles.mount;
export const unmount = vueLifecycles.unmount;
export default vueLifecycles;
vue.config.js
var StatsPlugin = require('stats-webpack-plugin');
module.exports = {
devServer: {
port: 9989,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': '*',
},
},
publicPath: 'http://127.0.0.1:9989',
configureWebpack: {
mode: 'development',
output: {
library: 'slaveVue', // 挂载名称
libraryTarget: 'umd', // 挂载类型
jsonpFunction: `webpackJsonp_slaveVue`
},
plugins: [
// 生成引入静态文件的文件
new StatsPlugin('asset-stats.json', {
chunkModules: true,
exclude: [/node_modules/],
}),
],
},
};
slave-react-demo
index.js
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import singleSpaReact from 'single-spa-react';
const lifecycles = singleSpaReact({
React,
ReactDOM,
rootComponent: () => <App />,
errorBoundary(err, info, props) {
return <div> Error </div>;
},
domElementGetter: () => document.getElementById('slave'),
});
// 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();
export const bootstrap = lifecycles.bootstrap;
export const mount = lifecycles.mount;
export const unmount = lifecycles.unmount;
由于此项目是create-react-app脚手架生成的,要生成所需引入的静态文件目录文件也需在webpack配置中引入stats-webpack-plugin
这个插件,所以使用 npm run eject
生成了项目中的webpack配置,具体修改可以参考 https://github.com/jdkwky/single-demo/blob/master/packages/react-demo/config/webpack.config.js
配置。