此文章记录巨石应用改造为微前端过程中遇到的问题以及解决方法,如读者遇到不好理解的地方可评论或者私信.
解决什么问题
- 业务耦合
- 新老架构的兼容
- 项目参与人数多,冲突
- 项目庞大
- 启动/热更新/打包速度缓慢
- 灵活扩展
- 分解复杂度
原则
- 技术栈无关
- 独立开发/部署
- 环境隔离
- 依赖复用
- 统一消息通信
改造条件
- 明确的业务边界/高度集中
- 项目是否庞大到需要拆分
- 技术老旧,扩展困难,不易维护
- 协同开发效率低
常见框架
- 京东 MicroApp 基于 WebComponent,轻量/高效/功能强大的微前端框架
- 蚂蚁 qiankun 基于 single-spa 的封装
- 社区 single-spa 将多个单页面应用聚合为一个整体应用的 JavaScript 微前端框架
- 阿里 icestark ****面向大型应用的微前端解决方案
- YY emp 基于Webpack5 Module Federation 除了具备微前端的能力外,还实现了跨应用状态共享、跨框架组件调用的能力
- 社区 magic-microservices 一款基于 Web Components 的轻量级的微前端工厂函数
qiankun 优点
- 保证技术栈统一(Vue
- 改造成本低
- 社区活跃
- 文档明确(single-spa ❌
拆分规则
- 核心原则:高内聚低耦合
- 减少彼此重新和依赖
- 不要拆分太细致
- 考虑未来场景
- 子应用只做自己的事情
- 保持核心业务的独立性
- 业务关联紧密的可以合为一个子应用,相反可以拆成多个子应用
使用 qiankun
💡 选择基座的模式-
通用中心路由基座式:只有公共功能的主应用
菜单栏、登录、退出...不包含任何业务逻辑
-
特定中心路由基座式: 一个含业务代码的项目
作为基座,所有新功能作为子应用引入
- 自动模式: 使用
registerMicroApp
+start
, 监听路由变化加载子应用 - 手动模式: 使用
loadMicroApp
手动注册子应用
应用加载流程图
💡 qiankun是如何通过 import-html-entry 加载微应用简易流程:
- qiankun 会用 原生
fetch
方法,请求微应用的entry
获取微应用资源,然后通过response.text
把获取内容转为字符串。 - 将 HTML 字符串传入 processTpl 函数,进行 HTML 模板解析,通过正则匹配
HTML
中对应的javaScript
(内联、外联)、css
(内联、外联)、代码注释、entry
、ignore
收集并替换,去除html/head/body
等标签,其他资源保持原样 - 将收集的
styles
外链URL对象通过fetch
获取css
,并将css
内容以<style>
的方式替换到原来 link标签的位置 - 收集
script
外链对象,对于异步执行的JavaScript
资源会打上async
标识 ,会使用 requestIdleCallback 方法延迟执行。 - 接下来会创建一个匿名自执行函数包裹住获取到的
js
字符串,最后通过eval
去创建一个执行上下文执行js
代码,通过传入proxy
改变window
指向,完成JavaScript
沙箱隔离。源码位置。 - 由于 qiankun 是自执行函数执行微应用的
JavaScript
,因此在加载后的微应用中是看不到JavaScript
资源引用的,只有一个资源被执行替换的标识。 - 当一切准备就绪的时候,执行微应用的
JavaScript
代码,渲染出微应用
路由模式选择与改造
💡 最好的路由模式就是主应用、子应用都统一模式,可以减少不同模式之间的兼容工作资源共享
-
共享模块方式
npm
依赖git submodule or git subtree
webpack Externals
(无法支持多版本共存
的情况webpack DLL
-
通过主应用共享资源给子应用
props
方式window
方式
应用通信
- 基于
URL
- 使用简单、通用性强,但能力较弱,不适用复杂的业务场景
- 基于
Props
- 应用给子应用传值。适用于主子应用共享组件、公共方法调用等
- 发布/订阅模式
- 一对多关系,观察者和被观察者是抽象耦合的。但是数据链路难跟踪
- 状态管理模式
- 能够统一管理,链路清晰,易维护
- 基于
localStorage
、sessionStorage
实现的通信方式- 不推荐,因为
JSON.stringify()
会造成数据丢失,它只会对Number、String、Booolean、Array
转换,对于undefined、function、NaN、 regExp、Date
都会丢失本身的值
- 不推荐,因为
- qiankun
initGlobalState
实践
原项目
改造项目为云管理平台,针对多云和混合云环境的云基础设施等资源管理,提供”云管理+云服务+云运营+云监控”服务
- 项目庞大,
800+
路由 2K+
组件- 启动/构建服务缓慢,
5
分钟起步 - 对于刚接手人员来说太复杂
拆分子应用
根据业务拆分为1 个主应用(main-web), 6 个子应用,主应用采用通用中心路由基座式
,
路由
主应用和子应用统一采用 history
模式
// 子应用
new Router({
base: window.__POWERED_BY_QIANKUN__ ? '/subAppName/' : '/',
mode: 'history',
routes: constantRouter
})
资源共享
构建公共组件库,发布到 npm
通信
使用 qiankun 的 initGlobalState
-
主应用
// actions.js 初始化 actions import { initGlobalState } from 'qiankun' import type { MicroAppStateActions } from 'qiankun' import store from '@/store' const initialState = { name: 'main-web', onmessage: (data: any) => console.log(data), userData: null, permissions: null } const actions: MicroAppStateActions = initGlobalState(initialState) actions.onGlobalStateChange((state, prev) => { // state: 变更后的状态; prev 变更前的状态 // console.log('from:', state.name, 'current:', state, 'pre:', prev) store.commit('SET_ONMESSAGE', state.onmessage) }) export default actions
-
何时使用(通知子应用)
用户登录后保存用户数据
用户登录后保存权限数据(路由和按钮权限)
-
-
子应用
// actions.js 初始化 actions class Actions { actions = { name: '', onGlobalStateChange: emptyAction, setGlobalState: emptyAction } init(props: any, callback: any) { this.actions = props; this.onGlobalStateChange(callback) } onGlobalStateChange(callback: any, fireImmediately:boolean = true) { return this.actions.onGlobalStateChange(callback, fireImmediately); } setGlobalState(state: IState) { this.actions.setGlobalState({ ...state, name: this.actions.name }); } } const actions = new Actions(); export default actions;
-
何时使用
在
mount
生命周期中注册actions
,监听主应用的state
,变化时进行保存用户数据和操作路由// main.js // 监听主应用的 state,变化时进行保存/操作用户数据和路由 export async function mount(props: any) { console.log('cmp app mounted') render(props) actions.init(props, (state: any) => { const { permissions, userData } = state; userData && store.commit('SET_USERDATA', userData); if (!store.getters.addRoutes && permissions) { store.dispatch('permission/GenerateRoutes', permissions); } }) }
当子应用注册
websocket
时,将监听事件传递给主应用actions.setGlobalState({ name: 'cmp-web', onmessage })
-
子应用单独运行时处理
本地开发或者生产环境单独运行子应用时,使体验和从主应用访问无差异
- 子应用中的
login
/404
/401
等组件与主应用中的相同 - 子应用在初始化时单独获取权限和用户数据
let instance: any = null
function render(props: any = {}) {
const { appPath = '', container } = props;
instance = new Vue({
router,
store,
render: h => h(App)
}).$mount(container ? container.querySelector('#app') : '#app');
store.commit('SET_APP_PATH', appPath)
}
if (!(window as any).__POWERED_BY_QIANKUN__) {
console.log('独立运行子应用')
// 单独获取权限和用户数据
startPermission()
render()
}
// 子应用单独运行时不会运行 mount 函数
export async function mount(props: any) {
console.log('cmp app mounted')
render(props)
actions.init(props, (state: any) => {
const { permissions, userData } = state;
userData && store.commit('SET_USERDATA', userData);
if (!store.getters.addRoutes && permissions) {
store.dispatch('permission/GenerateRoutes');
}
})
}
部署
注意修改 webpack
中的 publicPath
地址
- Nginx 配置
# 访问主应用 https://xxx.xxx.xxx:60006
server {
listen 60006 ssl;
server_name localhost;
root /opt/consoles/main-web/;
location / {
root /opt/consoles/main-web/;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
location ~^/(subAppName1|subAppName2|subAppName3|subAppName4|subAppName5)\/(static\/img) {
proxy_pass https://127.0.0.1:60003;
}
location /web-common-resource {
proxy_pass https://127.0.0.1:60003;
}
}
# 访问子应用 https://xxx.xxx.xxx:60003/subAppName1
server {
listen 60003 ssl;
server_name localhost;
root /opt/consoles;
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods *;
add_header Access-Control-Allow-Headers *;
location / {
root /opt/consoles/subAppName1/;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
location ~^/(subAppName1|subAppName2|subAppName3|subAppName4|subAppName5|web-common-resource) {
root /opt/consoles;
index index.html index.htm;
try_files $uri $uri/ /$1/index.html;
}
}