微前端的概念
微前端可以理解为存在于浏览器中的微服务。借鉴了微服务的架构理念,将微服务的概念扩展到了前端。如果对微服务的概念比较陌生的话,可以理解为微前端就是将一个大型的前端应用拆分成多个模块,每个微前端模块可以由不同的团队进行管理,并可以自主选择框架,并且有自己的仓库,可以独立部署上线。
一般微前端多应用于企业中的中后台项目中,因为企业内部的中后台项目存活时间都比较长,最后演变成一个巨石应用的概率往往高于其他类型的web应用。这就带来了 技术栈落后、编译部署慢 等系列问题。
以常见的电商平台为例,大型电商平台的后台管理系统由几个模块构成,包括商品管理、库存管理、物流管理等模块,但是因为历史原因,这个项目一开始用jquery编写的,因为本着能跑就行的原则,这10年期间业务上一直没有太大改动所以就延续了下来,直到今天还是用的jquery维护的。如果需要开展新的业务,不可能再用jquery开发,如果我们引入微前端结构,在微前端系统中需要新增一个业务模块时,只需要单独的新建一个项目,至于项目采用什么技术栈,完全可以由团队自己去定义,即使和其他模块用的不同的技术栈也不会有任何的问题。
已有的微前端架构方案
iframe
iframe大家都很熟悉,每个子应用通过iframe标签来嵌入到父应用中,iframe具有天然的隔离属性,各个子应用之间以及子应用和父应用之间都可以做到互不影响。但是iframe也有很多缺点:
- url不同步,如果刷新页面,iframe中的页面的路由会丢失,子应用页面会强制重新刷新。
- 全局上下文完全隔离,无法做到内存变量共享。
- UI不同步,比如iframe中的页面如果有带遮罩层的弹窗组件,则遮罩就不能覆盖整个浏览器,只能在iframe中生效。
- 渲染慢,性能较差。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程。
single-spa
官网:zh-hans.single-spa.js.org/docs/gettin… single-spa是最早的微前端框架,可以兼容很多技术栈。single-spa首先在基座中注册所有子应用的路由,当URL改变时就会去进行匹配,匹配到哪个子应用就会去加载对应的那个子应用。相对于iframe的实现方案,single-spa中基座和各个子应用之间共享着一个全局上下文,并且不存在URL不同步和UI不同步的情况,但是single-spa也有以下的缺点:
- 没有实现js隔离和css隔离
- 需要修改大量的配置,包括基座和子应用的,无法做到开箱即用
qiankun
qiankun是阿里开源的一个微前端的框架,qiankun有什么优势呢?
- 基于single-spa封装的,提供了更加开箱即用的API
- 技术栈无关,任意技术栈的应用均可使用/接入,不论是 React/Vue/Angular/JQuery 还是其他等框架。
- HTML Entry的方式接入,像使用iframe一样简单
- 实现了single-spa不具备的样式隔离和js隔离
- 资源预加载,在浏览器空闲时间预加载未打开的微应用资源,加速微应用打开速度。
基于qiankun的微前端应用
qiankun 框架在阿里内部已经经过一批线上应用的充分检验和打磨,具备成熟的大型企业框架实践能力,所以是可以放心使用的。结合已有微前端架构我们选择qiankun 进行实战开发,本文的源码链接:gitee.com/zhengechitu… 项目目录如下:
├── base-app // 基座 - 基于react应用的基座
├── react-app // react子应用,create-react-app创建的react应用,使用webpack打包
├── vue-app // vue子应用,vite创建的子应用
└── umi-app // umi脚手架创建的子应用
基座是create-react-app
脚手架加上antd
组件库搭建的项目,也可以选择vue或者其他框架,一般来说,基座只提供加载子应用的容器,尽量不写复杂的业务逻辑。
基座开发
- 安装qiankun
// 安装qiankun
npm i qiankun // 或者 yarn add qiankun
- 修改入口文件
// 在基座react应用的入口文件 index.tsx 中引入核心 api:
import { start, registerMicroApps } from 'qiankun';
// 1. 添加子应用列表
const appList = [
{
name: "react-app", // 子应用的名称
entry: '//localhost:3001', // 默认会加载这个路径下的html,解析里面的js
activeRule: "/react-app", // 匹配的路由
container: "#sub-app" // 加载的容器
},
{
name: "vue-app", // 子应用的名称
entry: '//localhost:3002', // 默认会加载这个路径下的html,解析里面的js
activeRule: "/vue-app", // 匹配的路由
container: "#sub-app" // 加载的容器
},
{
name: "umi-app", // 子应用的名称
entry: '//localhost:3003', // 默认会加载这个路径下的html,解析里面的js
activeRule: "/umi-app", // 匹配的路由
container: "#sub-app" // 加载的容器
}
]
// 2. 注册子应用
registerMicroApps(appList, {
beforeLoad: [async app => console.log('beforeLoad', app.name)],
beforeMount: [async app => console.log('beforeMount', app.name)],
afterMount: [async app => console.log('afterMount', app.name)],
})
start() // 3. 启动微服务
当子应用注册完之后一旦浏览器的 url 发生变化,便会自动触发 qiankun 的匹配逻辑。 所有 activeRule
规则匹配上的微应用就会被插入到指定的 container
中
-
registerMicroApps(apps, lifeCycles?) —— 注册所有子应用,qiankun会根据activeRule去匹配对应的子应用并加载
-
start(options?) —— 启动 qiankun,可以进行预加载和沙箱设置
react子应用开发
使用create-react-app
脚手架创建,webpack
进行配置,我们用react-app-rewired
工具来改造webpack配置。
- 改造子应用的入口文件
let root: Root
// 将render方法用函数包裹,供后续主应用与独立运行调用
function render(props: any) {
const { container } = props
const dom = container ? container.querySelector('#root') : document.getElementById('root')
root = createRoot(dom)
root.render(
<BrowserRouter basename='/react-app'>
<App/>
</BrowserRouter>
)
}
// 判断是否在qiankun环境下,非qiankun环境下独立运行
if (!(window as any).__POWERED_BY_QIANKUN__) {
render({});
}
// 子应用加载时候的各个生命周期
// bootstrap 只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用 mount 钩子,不会再重
export async function bootstrap() {
console.log('react app bootstraped');
}
// 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
export async function mount(props: any) {
render(props);
}
// 应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例
export async function unmount(props: any) {
root.unmount();
}
- 新增public-path.js: 这里配置主要的作用是,当通过乾坤调用时动态的给 webpack 的public_path 赋予主应用的根路径。
if (window.__POWERED_BY_QIANKUN__) {
// 动态设置 webpack publicPath,防止资源加载出错
// eslint-disable-next-line no-undef
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__
}
- 修改webpack配置文件
// 在根目录下新增config-overrides.js文件并新增如下配置
const { name } = require('./package');
module.exports = {
webpack: (config) => {
config.output.library = `${name}-[name]`;
config.output.libraryTarget = 'umd';
// webpack 5 需要把 jsonpFunction 替换成 chunkLoadingGlobal
config.output.jsonpFunction = `webpackJsonp_${name}`;
config.output.globalObject = 'window';
return config;
}
};
vue子应用
创建子应用
# 创建子应用,选择 vue3+vite
npm create vite@latest
改造子应用
- 安装
vite-plugin-qiankun
依赖包
npm i vite-plugin-qiankun # yarn add vite-plugin-qiankun
- 修改vite.config.js
import qiankun from 'vite-plugin-qiankun';
defineConfig({
base: '/vue-app', // 和基座中配置的activeRule一致
server: {
port: 3002,
cors: true,
origin: 'http://localhost:3002'
},
plugins: [
vue(),
qiankun('vue-app', { // 配置qiankun插件
useDevMode: true
})
]
})
- 修改main.ts
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import { renderWithQiankun, qiankunWindow } from 'vite-plugin-qiankun/dist/helper';
let app: any;
if (!qiankunWindow.__POWERED_BY_QIANKUN__) {
createApp(App).mount('#app');
} else {
renderWithQiankun({
// 子应用挂载
mount(props) {
app = createApp(App);
app.mount(props.container.querySelector('#app'));
},
// 只有子应用第一次加载会触发
bootstrap() {
console.log('vue app bootstrap');
},
// 更新
update() {
console.log('vue app update');
},
// 卸载
unmount() {
console.log('vue app unmount');
app?.unmount();
}
});
}
umi子应用
我们使用最新的umi4去创建子应用,创建好后只需要简单的配置就可以跑起来
- 安装插件
npm i @umijs/plugins
- 配置.umirc.ts
export default {
base: '/sub-umi',
npmClient: 'npm',
plugins: ['@umijs/plugins/dist/qiankun'],
qiankun: {
slave: {},
}
};
完成上面两步就可以在基座中看到umi子应用的加载了。
如果想在qiankun的生命周期中做些处理,需要修改下入口文件
export const qiankun = {
async mount(props: any) {
console.log(props)
},
async bootstrap() {
console.log('umi app bootstraped');
},
async afterMount(props: any) {
console.log('umi app afterMount', props);
},
};
关于qiankun框架的补充说明
1.样式隔离
qiankun实现了各个子应用之间的样式隔离,但是基座和子应用之间的样式隔离没有实现,所以基座和子应用之间的样式还会有冲突和覆盖的情况。
解决方法:
- 每个应用的样式使用固定的格式
- 通过
css-module
的方式给每个应用自动加上前缀
2.子应用间的跳转
- 如果主应用和微应用都是
hash
模式,主应用根据hash
来判断微应用,则不用考虑这个问题。 - 如果在
history
模式下微应用之间的跳转,或者微应用跳主应用页面,直接使用微应用的路由实例是不行的。原因是微应用的路由实例跳转都基于路由的base
。有两种办法可以跳转:- history.pushState()
- 将主应用的路由实例通过
props
传给微应用,微应用这个路由实例跳转。
具体方案:在基座中复写并监听history.pushState()
方法并做相应的跳转逻辑
基座的祝页面组件 App.ts 中实现监听 pushState 方法来实现跳转
// 重写函数
const _wr = function (type: string) {
const orig = (window as any).history[type]
return function () {
const rv = orig.apply(this, arguments)
const e: any = new Event(type)
e.arguments = arguments
window.dispatchEvent(e)
return rv
}
}
window.history.pushState = _wr('pushState')
// 在这个函数中做跳转后的逻辑
const bindHistory = () => {
const currentPath = window.location.pathname;
setSelectedPath(
routes.find(item => currentPath.includes(item.key))?.key || ''
)
}
// 绑定事件
window.addEventListener('pushState', bindHistory)
3.全局状态管理
一般来说,各个子应用是通过业务来划分的,不同业务线应该降低耦合度,尽量去避免通信,但是如果涉及到一些公共的状态或者操作,qiankun也是支持的。qinkun提供了一个全局的GlobalState
来共享数据,基座初始化之后,子应用可以监听到这个数据的变化,也能提交这个数据。
基座:
在基座的入口文件index.tsx 中实现数据的持久化:
// 基座初始化
import { initGlobalState } from 'qiankun';
const actions = initGlobalState(state);
// 主项目项目监听和修改
actions.onGlobalStateChange((state, prev) => {
// state: 变更后的状态; prev 变更前的状态
console.log(state, prev);
});
actions.setGlobalState(state);
子应用:
// 子项目监听和修改
export function mount(props) {
props.onGlobalStateChange((state, prev) => {
// state: 变更后的状态; prev 变更前的状态
console.log(state, prev);
});
props.setGlobalState(state);
}