一、前言:微前端是什么?
- 微前端不是特指某一项技术,而是一种思想。是由2016年 ThoughtWorks Technology Radar 中提出的,借鉴后端微服务的架构模式,将 Web 应用由单一的单体应用转变为多个小型前端应用,聚合为一的应用。
- 所以微前端不是指具体的库,不是指具体的框架,不是指具体的工具,而是一种理想与架构模式。
- 微前端的核心三大原则就是:独立运行、独立部署、独立开发 所以满足这些的最佳人选就是 “iframe”!!!
为什么不用 iframe,这几乎是所有微前端方案第一个会被 challenge 的问题。但是大部分微前端方案又不约而同放弃了 iframe 方案,自然是有原因的,并不是为了 "炫技" 或者刻意追求 "特立独行"。
如果不考虑体验问题,iframe 几乎是最完美的微前端解决方案了。
iframe 最大的特性就是提供了浏览器原生的硬隔离方案,不论是样式隔离、js 隔离这类问题统统都能被完美解决。但他的最大问题也在于他的隔离性无法被突破,导致应用间上下文无法被共享,随之带来的开发体验、产品体验的问题。
- url 不同步。浏览器刷新 iframe url 状态丢失、后退前进按钮无法使用。
- UI 不同步,DOM 结构不共享。想象一下屏幕右下角 1/4 的 iframe 里来一个带遮罩层的弹框,同时我们要求这个弹框要浏览器居中显示,还要浏览器 resize 时自动居中..
- 全局上下文完全隔离,内存变量不共享。iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果。
- 慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程。
微前端能解决我们什么问题?
举例: 一个持续多年的应用,经历几年的业务的更新迭代,当项目发展到一定程度的时候就会遇到以下问题
- 业务模块之间不断的堆叠,交错引用,业务耦合如何治理?
- 老技术、老代码不敢动,新技术、新架构又想用?
- 万年技术债?既要跟随业务敏捷迭代,又要保证代码库向好发展,旧的框架类库如何平稳升级?
- 一个项目多个团队开发,你冲突我,我冲突你,如何解决并行开发的冲突?
- 代码库持续膨胀,难以维护的项目代码,是屎上雕花?还是从头再来?
有没有一种可以分解复杂度,提升协作效率,支持灵活扩展的架构模式? 微前端应运而生—— “更友好的iframe” 将一个巨无霸应用拆解为一个个独立的微应用应用,而用户又是无感知的!
微前端核心原则:
- 技术栈无关: 主应用不限制子应用接入的技术栈,每个应用的技术栈选型可以配合业务情景选择。
- 独立开发、独立部署:既可以组合运行,也可以单独运行。
- 环境隔离:应用之间 JavaScript、CSS 隔离避免互相影响
- 消息通信:统一的通信方式,降低使用通信的成本
- 依赖复用:解决依赖、公共逻辑需要重复维护的问题
这意味着我们可以循序渐进的进行巨石应用的拆解,去技术升级、去架构尝试、去业务拆解等等。以低成本、低风险的进行,为项目带来更多可能性
我们的项目适不适合改造成微前端项目模式?
看我们的项目满足不满足微前端化,先看能不能满足以下几点即可。
- 是否有明确的业务边界,业务是否高度集中。
- 业务是否高度耦合、项目是否足够庞大到需要拆分。
- 团队中存在多个技术栈并且无法统一,需要接入同一套主系统。
- 技术老旧,扩展困难,维护吃力不讨好。
- 开发协同、部署维护等工作,效率低下,一着不慎,满盘皆输。
市面框架对比:
- magic-microservices 一款基于 Web Components 的轻量级的微前端工厂函数。
- icestark 阿里出品,是一个面向大型系统的微前端解决方案
- single-spa 是一个将多个单页面应用聚合为一个整体应用的JavaScript 微前端框架
- qiankun 蚂蚁金服出品,基于 single-spa 在 single-spa 的基础上封装
- EMP YY出品,基于Webpack5 Module Federation 除了具备微前端的能力外,还实现了跨应用状态共享、跨框架组件调用的能力
- MicroApp 京东出品,一款基于WebComponent的思想,轻量、高效、功能强大的微前端框架
综合以上方案对比之后,我们确定采用了 qiankun
特定中心路由基座式的开发方案,原因如下:
- 保证技术栈统一、微应用之间完全独立,互不影响。
- 友好的“微前端方案“,与技术栈无关接入简单、像iframe一样简单
- 改造成本低,对现有工程侵入度、业务线迁移成本也较低。
- 和原有开发模式基本没有不同,开发人员学习成本较低。
- qiankun 的微前端有 3 年使用场景以及 Issue 问题解决积累,社区也比较活跃,在踩坑的路上更容易自救~
二、使用,主应用注册,微应用挂载
qiankun 注册微应用的方式:
1、自动模式:使用 registerMicroApps + start,路由变化加载微应用
- 当微应用信息注册完之后,一旦浏览器的 url 发生变化,便会自动触发 qiankun 的匹配
- 首次load应用,创建子应用实例,渲染。
- 切到其他子应用后切回,会重新创建新的子应用实例并渲染。
- 之前的子应用实例 qiankun 直接不要了,即使你没有手动销毁实例。
- 采用这种模式的话 一定要在子应用暴露的 unmount 钩子里手动销毁实例,不然会导致内存泄漏。
-
activeRule -
string | (location: Location) => boolean | Array<string | (location: Location) => boolean>
必选,微应用的激活规则。 -
支持直接配置字符串或字符串数组,如
activeRule: '/app1'
或activeRule: ['/app1', '/app2']
,当配置为字符串时会直接跟 url 中的路径部分做前缀匹配,匹配成功表明当前应用会被激活。 -
支持配置一个 active function 函数或一组 active function。函数会传入当前 location 作为参数,函数返回 true 时表明当前微应用会被激活。如
location => location.pathname.startsWith('/app1')
- 自动挂载:registerMicroApps + start
// ps:只需要主应用安装即可
yarn add qiankun 或
npm i qiankun -S
// 主应用/scr/main.js
import { registerMicroApps, start } from 'qiankun';
// 1. 获取微应用配置
const MICRO_CONFIG = [
{
name: 'vue app', // 应用的名字 必填 唯一
entry: '//localhost:3000', // 默认会加载这个html 解析里面的js 动态的执行 (子应用必须支持跨域)fetch
container: '#yourContainer', // 挂载具体容器 ID
// 3. 根据路由匹配,激活的子应用
activeRule: '/yourActiveRule',
props: {
xxxx: '/' // 下发给子应用的消息
}
}
]
// 2. 注册微应用
registerMicroApps(MICRO_CONFIG)
// 启动微服务
start({
// 沙箱
sandbox:{
experimentalStyleIsolation:true
}
})
// sandbox - boolean | { strictStyleIsolation?: boolean, experimentalStyleIsolation?: boolean } - 可选,是否开启沙箱,默认为 true。
💡 当微应用信息注册完之后,一旦浏览器的 url 发生变化,便会自动触发 qiankun 的匹配逻辑。 所有 activeRule 规则匹配上的微应用就会被插入到指定的 container 中,同时依次调用微应用暴露出的生命周期钩子。
2、手动模式:使用 loadMicroApp 手动注册微应用
- 每个子应用都有一个唯一的实例ID,reload时会复用之前的实例
- 如果需要卸载则需要手动卸载 xxxMicroApp.unmount()
注:由于registerMicroApps的特性,会导致路由的keep alive 失效。
如果微应用不是直接跟路由关联的时候,你可以选择手动加载微应用的方式会更加灵活。
手动挂载: loadMicroApps
// 任意页面都可以注册
import { loadMicroApp } from 'qiankun';
// 获取应用配置并手动挂载,挂载后返回挂载对象
this.microApp = loadMicroApp({
name: 'vue app', // 应用的名字 必填 唯一
entry: '//localhost:7100', // 默认会加载这个html 解析里面的js 动态的执行 (子应用必须支持跨域)fetch
container: '#yourContainer', // 挂载具体容器 ID
activeRule: '/yourActiveRule', // 根据路由 激活的子应用
props: {
xxxx: '/' // 下发给子应用
}
})
this.microApp.unmount() // 手动销毁~
// 主应用
<div id='yourContaier'>
<route-link to='yourActiveRule'></route-link>
</div>
注意:容器和路由要和main.js挂载的保持一致
// router index.js
routes = [
{path:'/yourRule:pathMatch(.*)'},
component:()=>import()
]
四、微应用挂载
-
React 微应用,以
create react app
生成的react 18
项目为例
-
在
src
目录新增public-path.js
:if (window.__POWERED_BY_QIANKUN__) { __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__; }
-
设置
history
模式路由的base
:<BrowserRouter basename={window.__POWERED_BY_QIANKUN__ ? '/app-react' : '/'}>
-
入口文件
index.js
修改,为了避免根 id#root
与其他的 DOM 冲突,需要限制查找范围。
import './public-path';
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import { BrowserRouter } from 'react-router-dom';
import { createRoot } from 'react-dom/client';
import 'moment/locale/zh-cn';
import 'antd/dist/antd.min.css';
function render(props: any) {
const { container } = props;
const root = createRoot(container ? container.querySelector('#root') : document.querySelector('#root'));
root.render(
// @ts-ignore
// 通过qiankun启动时,全局加根路由,需与主应用配置一致
<BrowserRouter basename={window.__POWERED_BY_QIANKUN__ ? '/dataProcess' : '/'} >
<App />
</BrowserRouter>
);
};
// @ts-ignore
if (!window.__POWERED_BY_QIANKUN__) {
render({});
};
export async function bootstrap() {
console.log('[react18] react app bootstraped');
};
export async function mount(props: any) {
console.log('[react18] props from main framework', props);
render(props);
};
export async function unmount(props: any) {
const { container } = props;
ReactDOM.unmountComponentAtNode(container ? container.querySelector('#root') : document.querySelector('#root'));
};
配置文件,使用 craco 配置,根目录下创建 craco.config.js
const { name } = require('./package');
module.exports = {
webpack: {
configure: {
output: {
library: `${name}-[name]`,
libraryTarget: 'umd',
chunkLoadingGlobal: `webpackJsonp_${name}`,
globalObject: 'window',
}
}
},
devServer: {
headers: {
'Access-Control-Allow-Origin': '*',
},
host: '0.0.0.0', //指定使用地址,默认localhost,0.0.0.0代表可以被外界访问
historyApiFallback: true,
hot: true,
liveReload: true,
// 配置代理解决跨域,以下为示例
proxy:{
"/api":{
target:process.env.REACT_APP_BASEURL,
changeOrigin:true,
}
}
},
};
修改 package.json 中的 scripts命令,我们项目配置了不同环境变量
"scripts": {
"serve:dev": "env-cmd -f .env.dev craco start",
"serve:st": "env-cmd -f .env.st craco start",
"serve:uat": "env-cmd -f .env.uat craco start",
"serve:prod": "env-cmd -f .env.prod craco start",
"build:dev": "env-cmd -f .env.dev craco build",
"build:st": "env-cmd -f .env.st craco build",
"build:uat": "env-cmd -f .env.uat craco build",
"build:prod": "env-cmd -f .env.prod craco build",
"test": "jest",
"eject": "react-scripts eject",
"lint": "eslint --ext .js --ext .tsx src"
},
-
Vue 微应用,以
vue 2.x
项目为例
-
在
src
目录新增public-path.js
:if (window.__POWERED_BY_QIANKUN__) { __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__; }
-
入口文件
main.js
修改,为了避免根 id#app
与其他的 DOM 冲突,需要限制查找范围。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}`, }, }, };
小结 经历这几步,qiankun 父应用与微应用就接入完成了。当父应用完成加载微应用的时候,微应用就会遵循对应的解析 规则,插入到父应用的HMTL中了。
👉 选择路由
最好的路由模式就是主应用、子应用都统一模式,可以减少不同模式之间的兼容工作
主模式 | 子模式 | 推荐 | 接入影响 | 解决方案 | 备注 |
---|---|---|---|---|---|
hash | hash | 强烈推荐 | 无 | ||
hash | history | 不推荐 | 有 | history.pushState | 改造成本大 |
history | history | 强烈推荐 | 无 | ||
history | hash | 推荐 | 无 |
PS: 每个模式之间的组合并不是接入就可以完成的,都需要一些改造,如:增加路由前缀,路由配置base设置,不同的模式activeRule的规则都不同
⚒ 路由改造工作
新增微应用路由前缀
新增前缀不是微应用必须的,但是为了从 URL 上与其他应用隔离,也是为了接入旧应用的时候,能让 activeRule 方法能识别并激活应用,故新增路由前缀。
父应用路由表
[
// 主应用 router.js:如果想匹配任意路径,我们可以使用通配符 (*):
{
path: '/your-prefix',
name: 'Home',
component: Home
},
// 特定页面兜底 会匹配以 `/your-prefix` 开头的任意路径
// 如:/your-prefix/404 , /your-prefix/no-permission ....
{
path: '/your-prefix/*',
name: 'Home',
component: Home
}
]
复制代码
PS:子应用路由切换,由于应用与路由都是通过 URL 注册与销毁的,当子应用路由跳转地址,无法与父应用的路由地址匹配上的时候页面会销毁,需要注意路由匹配,或者增加路由兜底。
子应用 hash 模式
// hash 模式不能使用base,只能改前缀
new VueRouter({
mode: 'hash',
routes: [
{
//增加路由前缀判断
path: `${ window.__POWERED_BY_QIANKUN__ ? 'your-prefix' : ''}/login`,
component: _import('login/index.vue')
}
]
})
复制代码
子应用 history 模式
new VueRouter({
mode: 'history',
// **针对子应用是 history模式的时候,只用设置 router base 就好了,不用像hash 这么麻烦**
base: window.__POWERED_BY_QIANKUN__ ? 'your-prefix' : null,
routes: [
{
path: '/login',
component: _import('login/index.vue')
}
]
})
五、通信,使用 initGlobalState
- 主应用
// src/const/micro/actions.js
import { initGlobalState } from 'qiankun'
export const initialState = {}
const actions = initGlobalState(initialState)
export default actions
复制代码
- 主应用使用
import actions from '@/const/micro/actions'
// 设置
actions.setGlobalState({
xxxxDataKey: xxxValue
})
// 监听全局
actions.onGlobalStateChange((state, prev) => {
console.log(state, prev, '子应用的 state: 变更后的状态; prev 变更前的状态')
})
复制代码
- 微应用
// src/const/micro/actions.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(...args) {
return this.actions.onGlobalStateChange(...args)
}
// 映射设置
setGlobalState(...args) {
return this.actions.setGlobalState(...args)
}
}
const actions = new Actions()
export default actions
复制代码
- 微应用使用
import actions from './const/micro/actions'
export async function mount(props) {
actions.setActions(props) // 设置一下 actions 对象
}
actions.onGlobalStateChange((state, prev) => {
// 监听公共应用下发 state: 变更后的状态; prev 变更前的状态
})
复制代码
六、样式污染问题
以 antd 为例:
// 1、配置 webpack 修改 less 变量
{
loader: 'less-loader',
+ options: {
+ modifyVars: {
+ '@ant-prefix': 'yourPrefix',
+ },
+ javascriptEnabled: true,
+ },
}
// 2、配置 antd ConfigProvider
import { ConfigProvider } from 'antd';
export const MyApp = () => (
<ConfigProvider prefixCls="yourPrefix">
<App />
</ConfigProvider>
);
sandbox - boolean
| { strictStyleIsolation?: boolean, experimentalStyleIsolation?: boolean }
- 可选,是否开启沙箱,默认为 true
。
因为我们选的沙箱隔离方案为实验性沙箱experimentalStyleIsolation,会遇到在子应用点开任一有弹出消息控件时回到主应用,弹出层消息并没有消失,还好antd提供了getPopupContainer方法可以解决。
给控件添加getPopupContainer={triggerNode => triggerNode.parentElement}
<Select
defaultValue="lucy"
getPopupContainer={triggerNode => triggerNode.parentElement || document.body}
style={{ width: 120 }}
allowClear >
<Option value="lucy">Lucy</Option>
</Select>