微前端
背景
随着SPA大规模的应用,紧接着就带来一个新问题:一个规模化应用需要拆分。
一方面功能快速增加导致打包时间成比例上升,而紧急发布时要求是越短越好,这是矛盾的。另一方面当一个代码库集成了所有功能时,日常协作绝对是非常困难的。而且最近十多年,前端技术的发展是非常快的,每隔两年就是一个时代,导致同志们必须升级项目甚至于换一个框架。但如果大家想在一个规模化应用中一个版本做好这件事,基本上是不可能的。
最早的解决方案是采用iframe的方法,根据功能主要模块拆分规模化应用,子应用之间使用跳转。但这个方案最大问题是导致页面重新加载和白屏。
什么是微前端
微前端是一种类似于微服务的架构,是一种由独立交付的多个前端应用组成整体的架构风格,将前端应用分解成一些更小、更简单的能够独立开发、测试、部署的应用,而在用户看来仍然是内聚的单个产品。有一个基座应用(主应用),来管理各个子应用的加载和卸载。
所以微前端不是指具体的库,不是指具体的框架,不是指具体的工具,而是一种理想与架构模式。
微前端的核心三大原则就是:独立运行、独立部署、独立开发
微前端的价值
微前端架构具备以下几个核心价值:
-
技术栈无关
主框架不限制接入应用的技术栈,微应用具备完全自主权 -
独立开发、独立部署
微应用仓库独立,前后端可独立开发,部署完成后主框架自动完成同步更新 -
增量升级
在面对各种复杂场景时,我们通常很难对一个已经存在的系统做全量的技术栈升级或重构,而微前端是一种非常好的实施渐进式重构的手段和策略 -
独立运行时
每个微应用之间状态隔离,运行时状态不共享
场景
微前端选型
技术方案 | 描述 | 技术栈 | 优点 | 缺点 | 单独构建 / 部署 | 构建速度 | SPA 体验 | 项目侵入性 | 学习成本 | 通信难度 |
---|---|---|---|---|---|---|---|---|---|---|
iframe | 每个微应用独立开发部署,通过 iframe的方式将这些应用嵌入到父应用系统中 | 无限制 | 1. 技术栈无关,子应用独立构建部署2. 实现简单,子应用之间自带沙箱,天然隔离,互不影响 | 体验差、路由无法记忆、页面适配困难、无法监控、依赖无法复用,兼容性等都具有局限性,资源开销巨大,通信困难 | 支持 | 正常 | 不支持 | 高 | 低 | 高 |
Nginx 路由转发 | 通过Nginx配置实现不同路径映射到不同应用 | 无限制 | 简单、快速、易配置 | 在切换应用时触发发页面刷新,通信不易 | 支持 | 正常 | 不支持 | 正常 | 低 | 高 |
Npm 集成 | 将微应用抽离成包的方式,发布Npm中,由父应用依赖的方式使用,构建时候集成进项目中 | 无限制 | 1. 编译阶段的应用,在项目运行阶段无需加载,体验流畅2.开发与接入成本低,容易理解 | 1. 影响主应用编译速度和打包后的体积2. 不支持动态下发,npm包更新后,需要重新更新包,主应用需要重新发布部署 | 不支持 | 慢 | 支持 | 高 | 高 | 正常 |
通用中心路由基座式 | 微应用可以使用不同技术栈;微应用之间完全独立,互不依赖。统一由基座工程进行管理,按照DOM节点的注册、挂载、卸载来完成。 | 无限制 | 子应用独立构建,用户体验好,可控性强,适应快速迭代 | 学习与实现的成本比较高,需要额外处理依赖复用 | 支持 | 正常 | 支持 | 高 | 高 | 正常 |
特定中心路由基座式 | 微应用业务线之间使用相同技术栈;基座工程和微应用可以单独开发单独部署;微应用有能力复用基座工程的公共基建。 | 统一技术栈 | 子应用独立构建,用户体验好,可控性强,适应快速迭代 | 学习与实现的成本比较高,需要额外处理依赖复用 | 支持 | 正常 | 支持 | 高 | 高 | 正常 |
webpack5 模块联邦 | webpack5 模块联邦 去中心模式、脱离基座模式。每个应用是单独部署在各自的服务器,每个应用都可以引用其他应用,也能被其他应用所引用 | 统一技术栈 | 基于webpack5,无需引入新框架,学习成本低,像引入第三方库一样方便,各个应用的资源都可以相互共享应用间松耦合,各应用平行的关系 | 需要升级Webpack5技术栈必须保持一致改造旧项目难度大 | 支持 | 正常 | 支持 | 低 | 低 | 低 |
市面框架对比:
- magic-microservices 一款基于 Web Components 的轻量级的微前端工厂函数。
- icestark 阿里出品,是一个面向大型系统的微前端解决方案
- single-spa 是一个将多个单页面应用聚合为一个整体应用的JavaScript 微前端框架
- qiankun 蚂蚁金服出品,基于 single-spa 在 single-spa 的基础上封装
- EMP YY出品,基于Webpack5 Module Federation 除了具备微前端的能力外,还实现了跨应用状态共享、跨框架组件调用的能力
- MicroApp 京东出品,一款基于WebComponent的思想,轻量、高效、功能强大的微前端框架
综合以上方案对比之后,我们确定采用了 qiankun 特定中心路由基座式的开发方案,原因如下:
- 保证技术栈统一 Vue、微应用之间完全独立,互不影响。
- 友好的“微前端方案“,与技术栈无关接入简单、像iframe一样简单
- 改造成本低,对现有工程侵入度、业务线迁移成本也较低。
- 和原有开发模式基本没有不同,开发人员学习成本较低。
- qiankun 的微前端有 3 年使用场景以及 Issue 问题解决积累,社区也比较活跃,在踩坑的路上更容易自救~
为什么不用iframe
为什么不用 iframe,这几乎是所有微前端方案第一个会被 challenge 的问题。但是大部分微前端方案又不约而同放弃了 iframe 方案,自然是有原因的,并不是为了 “炫技” 或者刻意追求 “特立独行”。
如果不考虑体验问题,iframe 几乎是最完美的微前端解决方案了。
iframe 最大的特性就是提供了浏览器原生的硬隔离方案,不论是样式隔离、js 隔离这类问题统统都能被完美解决。但他的最大问题也在于他的隔离性无法被突破,导致应用间上下文无法被共享,随之带来的开发体验、产品体验的问题。
- url 不同步。浏览器刷新 iframe url 状态丢失、后退前进按钮无法使用。
- UI 不同步,DOM 结构不共享。想象一下屏幕右下角 1/4 的 iframe 里来一个带遮罩层的弹框,同时我们要求这个弹框要浏览器居中显示,还要浏览器 resize 时自动居中…
- 全局上下文完全隔离,内存变量不共享。iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果。
- 慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程。
其中有的问题比较好解决(问题1),有的问题我们可以睁一只眼闭一只眼(问题4),但有的问题我们则很难解决(问题3)甚至无法解决(问题2),而这些无法解决的问题恰恰又会给产品带来非常严重的体验问题, 最终导致我们舍弃了 iframe 方案。
qiankun
qiankun 是一个基于 single-spa 的微前端实现库,中文文档齐全,且国内生态很好
特性
📦 基于 single-spa 封装,提供了更加开箱即用的 API
📱 技术栈无关,任意技术栈的应用均可 使用/接入,不论是React/Vue/Angular/JQuery 还是其他等框架。
💪 HTML Entry 接入方式,让你接入微应用像使用 iframe一样简单。
🛡 样式隔离,确保微应用之间样式互相不干扰。
🧳 JS 沙箱,确保微应用之间 全局变量/事件 不冲突。
⚡️资源预加载,在浏览器空闲时间预加载未打开的微应用资源,加速微应用打开速度。
🔌 umi 插件,提供了@umijs/plugin-qiankun 供 umi 应用一键切换成微前端架构系统。
坑:
1.react18.x 的卸载组件unmountComponentAtNode已废弃
(createRoot:创建用于render和unmout的根节点。用它代替ReactDOM.render)
2.vite不兼容沙箱
沙箱(sandbox): 沙箱其实是一种工具,或者可以理解为一个黑盒,用于隔离当前执行的环境作用域和外部的其他作用域。而在JavaScript中就意味着,在沙箱中的操作被限死在当前作用域,不会对其他模块和个人沙箱造成任何影响。
3.qiankun 应用之间 UI 样式冲突问题
问题原因:配置(基座vue2+element UI + 微应用vue3+elementPlus),会导致基座内虽然有微应用但是element的css样式无法显示
初步问题定义为基座开启了样式隔离导致沙箱内的css类无法加载var()函数
start({
sandbox: {
strictStyleIsolation: true,//样式隔离严格模式
}
})
(element ui直接使用的颜色而element plus使用的var()来配置的引入的颜色)
vue2+elementUI的样式可成功显示
vue3+elementPlus样式无法显示
解决方法,基座关闭沙箱取消样式隔离,在每个应用之间添加 class 区分 或加 scoped
给element-plus添加自定义类名
约定式编程
这里我们可以采用一定的编程约束:1.尽量不要使用可能冲突全局的 class 或者直接为标签定义样式;
2.定义唯一的 class 前缀,现在的项目都是用诸如 antd 这样的组件库,这类组件库都支持自定义组件 class 前缀;
3.主应用一定要有自定义的 class 前缀;
React版本
主基座react18.x+微应用react17.x
1.基座和微应用内安装qiankun
主应用和微应用.env本地端口已分别设置3010和3011
npm i qiankun -S
2.在基座index.js配置
import { registerMicroApps, start } from 'qiankun';
registerMicroApps([
{
name: 'react app', // app name registered
entry: '//localhost:3011',//需要匹配的url
container: '#react-app1',//挂载结点
activeRule: '/react-app1',//路由
},
// {
// name: 'vue app',
// entry: { scripts: ['//localhost:7100/main.js'] },
// container: '#yourContainer2',
// activeRule: '/yourActiveRule2',
// },
]);
start()
3.可根据官网配置webpack,本次采用的是react-app-rewired
在微应用内安装插件
npm i react-app-rewired
在package.json中设置
在 src 目录新增 public-path.js:解决静态资源无法显示问题
/* eslint-disable no-undef */
//默认eslint会报错,不用管,忽略掉
if (window.__POWERED_BY_QIANKUN__) {
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
在根目录中创建config-overrides.js:
const { name } = require('./package');
module.exports = {
webpack: (config) => {
// umd方式注入
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;
},
};
重写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'));
}
4.页面显示
回到基座页面App.js,id需要和配置中的
function App() {
return (
<div className="App">
基座react
<div id='react-app1'></div>
</div>
);
}
export default App;
Vue版本
1、安装qiankun
npm i qiankun -S
2.基座设置main.js(vue2)
import {
registerMicroApps,
start ,
// addGlobalUncaughtErrorHandler,
// initGlobalState
} from 'qiankun';
registerMicroApps([
{
name: 'vueApp',
entry: '//localhost:9001',
container: '#container',
activeRule: '/about1/app-vue',
},
{
name: 'vueApp2',
entry: '//localhost:9002',
container: '#app-vue2',
activeRule: '/app-vue2',
},
{
name: 'vueApp3',
entry: '//localhost:9003',
container: '#app-vue3',
activeRule: '/app-vue3',
},
{
name: 'react app', // app name registered
entry: '//localhost:3011',
container: '#react-app',
activeRule: '/react-app1',
},
]);
// 启动 qiankun
start({
sandbox: {
strictStyleIsolation: true,//样式隔离
}
})
3.设置微应用(vue2)
1.设置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}`,
},
},
};
2.src目录添加public-path.js
/* eslint-disable no-undef */
if (window.__POWERED_BY_QIANKUN__) {
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
3.将routes导出,router在main.js里设置
import Vue from 'vue'
import VueRouter from 'vue-router'
import HomeView from '../views/HomeView.vue'
Vue.use(VueRouter)
const routes = [
{
path: '/',
name: 'home',
component: HomeView
},
{
path: '/about',
name: 'about',
// route level code-splitting
// this generates a separate chunk (about.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import(/* webpackChunkName: "about" */ '../views/AboutView.vue')
}
]
// const router = new VueRouter({
// mode: 'history',
// base: process.env.BASE_URL,
// routes
// })
export default routes
4.重写main.js
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-vue2/' : '/',
// base:"/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;
}
4.设置微应用(vue3)
前三步骤与设置微应用vue版本一致
重写main.js
import './public-path';
import { createApp } from 'vue';
import { createRouter, createWebHistory } from 'vue-router';
import App from './App';
// import routes from './routes';
import HomeView from './views/HomeView.vue'
const routes = [
{
path: '/',
name: 'home',
component: HomeView
},
{
path: '/about',
name: 'about',
// route level code-splitting
// this generates a separate chunk (about.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import(/* webpackChunkName: "about" */ './views/AboutView.vue')
}
]
// qiankun window全局变量
const isQK = window.__POWERED_BY_QIANKUN__;
let app = null,
router = null;
const render = (props = {}) => {
const { container } = props;
console.log('props.prefix',props.prefix,container)
router = createRouter({
history: createWebHistory(window.__POWERED_BY_QIANKUN__ ? '/app-vue3/' : '/',),
routes
});
app = createApp(App);
app.use(router);
app.mount(container ? container.querySelector('#app') : '#app');
};
// 独立运行
if (!isQK) render();
// 初始化
export async function bootstrap() {
console.log('%c ', 'color: green;', 'vue3.0 app bootstraped');
}
// 挂载
export async function mount(props) {
setTimeout(() => {
// 例子 - 关闭局部loading
props.setGlobalState({ closeLoading: true });
console.log('-------关闭局部loading---------');
}, 3000);
render(props);
console.log('app=======', app);
props.onGlobalStateChange((state) => {
// state: 变更后的状态; prev 变更前的状态
// console.log('===子应用====', state, prev);
console.log('===子应用====', state);
});
}
// 销毁
export async function unmount() {
app.unmount();
app._container.innerHTML = '';
app = null;
router = null;
}
5.设置微应用(在主应用下的路由里设置微应用vue2)
前四步骤与设置vue的微应用相同
1.在基座内的路由内about下设置动态匹配路由
2.在微应用的main.js里的重写的路由里添加上基座的路由
通过qiankun判断是否是嵌入在基座内是的话条跳转否则就是本地默认路由配置
3.基座内设置跳转
4.基座路由下设置插入匹配的#continer
5.基座内设置continer和activeRule
6.设置微应用(react)
和上面的react基座内设置react微应用一致
数据通信
在微前端架构中,我们应该按业务划分出对应的子应用,而不是通过功能模块划分子应用。这么做的原因有两个:
在微前端架构中,子应用并不是一个模块,而是一个独立的应用,我们将子应用按业务划分可以拥有更好的可维护性和解耦性。
子应用应该具备独立运行的能力,应用间频繁的通信会增加应用的复杂度和耦合度。
综上所述,我们应该从业务的角度出发划分各个子应用,尽可能减少应用间的通信,从而简化整个应用,使得我们的微前端架构可以更加灵活可控。
qiankun props 传值
在注册应用信息的方法registerMicroApps中,通过props给子应用传递数据。此方法优势是用法很简单,且可以传递组件和方法,缺点是只能从主应用传递给子应用。有不少场景不太适用。
props - object - 可选,主应用需要传递给微应用的数据。
一般可通过主应用的store传递给子应用
子组件获取到的数据
initGlobalState
qiankun通过initGlobalState, onGlobalStateChange, setGlobalState实现主应用的全局状态管理
onGlobalStateChange: (callback: OnGlobalStateChangeCallback, fireImmediately?: boolean) => void, 在当前应用监听全局状态,有变更触发 callback,fireImmediately = true 立即触发 callback
setGlobalState: (state: Record<string, any>) => boolean, 按一级属性设置全局状态,微应用中只能修改已存在的一级属性
offGlobalStateChange: () => boolean,移除当前应用的状态监听,微应用 umount 时会默认调用
主应用给子应用传值
主应用 main.js
import {
initGlobalState,
} from 'qiankun';
export const initialState = {
globalLocation: {
id: 1234,
station: '北京'
}
}
const actions = initGlobalState(initialState) //初始化全局数据
actions.onGlobalStateChange((newState, prev) => { //监听全局状态
console.log(newState, prev)
for (let key in newState) {
initialState[key] = newState[key]
}
});
子应用 action,js
// /src/qiankun/action.js
function emptyAction() {
// 提示当前使用的是空 Action
console.warn("Current execute action is empty!");
}
class Actions {
// 默认值为空 Action
actions = {
onGlobalStateChange: emptyAction,
setGlobalState: emptyAction,
};
/**
* 设置 actions
*/
setActions(actions) {
this.actions = actions;
}
/**
* 映射
*/
onGlobalStateChange() {
return this.actions.onGlobalStateChange(...arguments);
}
/**
* 映射
*/
setGlobalState() {
return this.actions.setGlobalState(...arguments);
}
}
const actions = new Actions();
export default actions;
子应用 main.js
/**
* 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
*/
export async function mount(props) {
console.log('[vue] props from main framework', props);
props.onGlobalStateChange((state, prev) => {
// state: 变更后的状态; prev 变更前的状态
console.log('这是主应用传来的值',state, prev);
},true);//第二个参数为 true,表示立即执行一次观察者函数
actions.setActions(props) //子项目的入口文件中设置子应用的全局state
render(props)
}
子应用传值给主应用
import action from "../action";
export default {
name: "HelloWorld",
props: {
msg: String,
},
created(){
console.log('VVVVVVVVVVVVVVVVV');
},
mounted() {
// 接收state
action.onGlobalStateChange((state) => {
console.log(state);
}, true);
},
methods: {
changeValue() {
// 修改state
action.setGlobalState({ count: 789});//每次执行时都会出发一次mounted
},
},
};