乾坤微前端改造
作者:YoungDo
业务场景:项目前期已使用 vue2
+ js
重构完成干预效果分析模块,且公司前端组件库目前维护了 vue2
组件库(集采、抗网、干预分析、新患教、处方评估模块已配套使用), vue3
组件库(处方点评配套使用),后续需求开发页面中已存在多数已经从点评剥离出来的且干预重构模块可以直接使用的组件在 vue3
组件库中,但此时已使用 vue2
组件库开发完成了分析模块以及项目整体的布局(侧边栏、全局状态管理、顶部导航栏、路由管理等);此时,存在以下几种方案:
- 将
vue2
项目重构为vue3
,或者将vue3
组件库中的代码重构成vue2
- 使用
iframe
,开发两个项目,两个项目通过iframe
发送信息进行交互、嵌套 - 使用微前端框架进行改造
为什么不使用重构代码的方式
- 如果将
vue2
改成vue3
,业务代码的改造工作量不大,主要是vue2/vue3
使用的脚手架不可能是一致的,这样我们各自在将布局例如消息盒子、更多组件、侧边栏、顶部栏分别封装入vue2/vue3
组件库时,写法肯定也是不一致的,vue2
改vue3
等于是所有组件库中的组件重写、重新测试,时间成本过大;如若vue3
组件改成vue2的写法后放入vue2
组件库其实也是成本原因,如若有更好的方法,谁也不愿意多写一次代码。当然使用该种方法也是有好处,便于后续维护,能让别人看的清晰。
为什么不使用iframe
- 该种业务场景下,很多公司愿意去选择
iframe
,再去维护一个项目,这不是不合理的,甚至是最好的解决方案:不论是样式隔离、js
隔离这类问题统统都能被完美解决;唯二的成本就是需要了解iframe
的各种通信方式,以及无法应对各种交互时的力不从心;iframe
由于安全性考虑,并没有提供足够的权限获得交互方面的体验,如若后续增加较为繁琐的需求,怕是要推倒重来,以下列举为什么不选择iframe
的核心点:url
不同步。浏览器刷新iframe url
状态丢失、后退前进按钮无法使用UI
不同步,DOM
结构不共享。想象一下屏幕右下角 1/4 的iframe
里来一个带遮罩层的弹框,同时我们要求这个弹框要浏览器居中显示,还要浏览器resize
时自动居中- 全局上下文完全隔离,内存变量不共享。
iframe
内外系统的通信、数据同步等需求,主应用的cookie
要透传到根域名都不同的子应用中实现免登效果。 - 慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程
为什么选择乾坤框架
- 乾坤是阿里开发的一套成熟的微前端方案,它能完美解决iframe的痛点:通信繁琐、url不同步、加载慢、权限问题等,且目前是最好的微前端解决方案
改造
目前我们已知的是主体框架项目已经存在,采用 vue2 + js + 内部vue2组件库
,剩余未改造模块计划采用 vue3 + ts + 内部vue3组件库
改造,该种情况满足乾坤的1对多模式:1个基座、多个子项目,虽然当前只存在一个子项目,且改子项目无单独部署、单独打包逻辑,所以无需多创建一个项目增加维护成本
-
目录格式的确定:vue2项目作为基座,所以无需任何目录变动,只需要在根目录增加子项目的目录,例:
sub
,故结构为web-intervention/sub
-
基座中安装
qiankun
npm i qiankun -S
-
基座中需要腾出子应用的空间
dom
:src/layout/components/AppMain.vue
// 默认展示区域
<transition name="fade-transform" mode="out-in">
<router-view v-if="!$route.meta.keepAlive" :key="key" />
</transition>
// 通过乾坤进入的页面 只有在路由匹配时显示 且填入唯一的id便于后续注册
<transition name="fade-transform" mode="out-in">
<div v-if="$route.name=='sub'" id="sub" class="c-container full-fill"/>
</transition>
-
基座的路由改造:目前干预是只取了干预分析的路由进行
push
,后续可以针对除了干预分析模块其他的路由前端处理手动添加一层sub/
,方便乾坤项目匹配,当然如果后端给的话那更好,主要是为了和基座路由进行区分并被乾坤解析src/router/qiankun.js
{
path: '/sub/*',
name: 'sub',
component: () => import('@/layout/index')
}
-
接下来我们可以进行乾坤的注册
src/qiankun.js
// web-intervention/qiankun.js
import store from '@/store'
import { registerMicroApps } from 'qiankun'
/** location.pathname是否存在 routerPrefix */
function genActiveRule(routerPrefix, currentRoute = '') {
return location => location.pathname.indexOf(routerPrefix) !== -1
}
// 主应用共享出去的数据
const msg = {
state: store.state,
isQiankun: true,
system: 'main'
}
// 注册子应用
registerMicroApps(
[
{
/**
* name: 子服务有唯一性 - 这个需要与子服务webpack name一致
* entry: 子服务入口 - 通过该地址加载微应用
* container: 子服务挂载节点 - 微应用加载完成后将挂载在该节点上 - 与上述id一致
* activeRule: 子服务触发的路由规则 - 触发路由规则后将加载该微应用 - 与上述创建子服务路由前缀一致
* props 共享数据到子服务
* sandbox 开启沙箱
*/
name: 'sub', // 根据实际情况来
entry: process.env.NODE_ENV === 'production' ? '/pgintervene/sub/' : `//localhost:10200`,
container: '#sub',
activeRule: genActiveRule('/sub'),
props: msg, // 共享数据到子服务
sandbox: {
strictStyleIsolation: true
}
}
],
{
// 挂载前回调
beforeLoad: [
app => {
console.log('before load', app)
}
],
// 挂载后回调
beforeMount: [
app => {
console.log('before mount', app)
}
],
// 卸载后回调
afterUnmount: [
app => {
console.log('after unload', app)
}
]
}
)
从代码中我们可以看出注册函数在子模块在匹配到 sub
路由时,将子模块挂载到了 id
为 sub
的 dom
元素下,并且将基座的全局状态 store
(可能存在用户名、token
等)下发到了子模块,当然这里做了区分,如果是开发环境,子模块就是10200
端口启动的项目,否则生产环境是 /pgintervene/sub/
下的内容, pgintervene
是线上地址的前缀,也就是打包出来的包名
-
基座中引入注册文件
src/main.js
import './qiankun'
-
子项目增加public-path.ts并在main.ts中引用
src/public-path.ts
// @src/public-path.js if (window.__POWERED_BY_QIANKUN__) { // eslint-disable-next-line camelcase __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__ }
-
子项目的配置,其中主要是
vue.config.js
与main.ts
的修改sub/src/main.ts
let instance = null
let Router = null
const baseUrl = process.env.NODE_ENV === 'production' ? '/pgintervene/sub' : '/sub'
function render(props : any = {}) {
const { container } = props
// 如果是乾坤环境
if (window.__POWERED_BY_QIANKUN__) {
const routes = router.routes
// 在 render 中创建 VueRouter,可以保证在卸载微应用时,移除 location 事件监听,防止事件污染
const base = window.__POWERED_BY_QIANKUN__ ? baseUrl : '/'
Router = createRouter({
history: createWebHistory(base),
routes,
})
instance = createApp(App)
.use(Router)
.use(ElementPlus)
.mount(container ? container.querySelector('#app') : '#app')
} else {
instance = createApp(App).use(router.router).use(ElementPlus).mount('#app')
}
}
// 独立运行时,直接挂载应用
if (!window.__POWERED_BY_QIANKUN__) {
render()
}
main.ts
中主要是为了区分三种情况进行区分
-
生产环境路由的前缀是
/pgintervene/sub
-
开发环境如果是从基座进入子项目路由前缀是
/sub
-
开发环境如果是直接打开子项目直接
render
且路由前缀直接就是/
-
当然如果采取分包模式就不需要我这种方法了,
nginx
做映射就好 -
其中还存在一些生命周期:
bootstrap
:只会在微应用初始化的时候调用一次,通常我们可以在这里做一些全局变量的初始化,比如不会在unmount
阶段被销毁的应用级别的缓存等;mount
:应用每次进入都会调用mount
方法,通常我们在这里触发应用的渲染方法;unmount
:应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例
vue.config.js
主要是为了解决打包的问题
-
publicPath
需要区分环境,代表的是文件的引用地址,打包改造主要也是和项目的目录接口相似,采用嵌套的模式:pgintervene/sub
,所以引用地址生产环境需要注意加上前置包名,开发环境就是当前微服务运行的环境即可,这样基座也不会丢失子模块的静态文件sub/vue.config.js
publicPath: process.env.NODE_ENV === 'production' ? '/pgintervene/sub/' : `//localhost:${port}`,
-
outputDir
打包文件目录,根据当前目录设计,需要打包在基座打包文件的sub文件中sub/vue.config.js
outputDir: '../pgintervene/sub',
-
devServer
本地代理中需要加入跨域头sub/vue.config.js
headers: { 'Access-Control-Allow-Origin': '*', },
-
configureWebpack
中需要加入官方配置
sub/vue.config.js
configureWebpack: { output: { // 子服务的包名,这里与主应用中注册的微应用名称一致 library: 'sub', // 根据实际情况来 // 将你的 library 暴露为所有的模块定义下都可运行的方式 libraryTarget: 'umd', // 按需加载相关,设置为 webpackJsonp_子服务名称 即可 jsonpFunction: `webpackJsonp_sub`, // 根据实际情况来 }, }
至次,你应该能通过启动基座与子项目后,点击基座的
sub
菜单看到子模块了,但是项目中还存在很多问题需要修正。
优化
-
打包优化、自动化优化:
由于项目未采用分包,基座项目原先采用的是
gitlab
的自动化,子项目打包在config.js
中配置是打入基座中,所以无需在自动化再进行文件操作,我们只需改造基座的打包命令,分别代表了打包当前基座,打包子项目与同时打包基座与子项目package.json
"build": "vue-cli-service build", "build:sub": "cd sub && npm run build", "build:prod": "vue-cli-service build && npm run build:sub",
自动化只需要修改npm 依赖包的安装位置
.gitlab-ci.yml
# 安装基座依赖 - npm install --registry=http://10.1.1.161:4837 - cd sub # 安装子项目依赖 - npm install --registry=http://10.1.1.161:4837 - cd .. - npm run build:prod
这样修改的好处就是每个工具都各尽其职,无需在
yml
中写过于繁琐的逻辑,且本地与云端打包逻辑完全一致,简单易懂,webpack
、npm
、yml
各自完成自己的一小部分即可 -
项目启动命令优化,项目开发大部分场景是要基座与子项目同时启动的,每次去切换目录进行启动过于繁琐,npm并不支持同时启动两个
node
项目,使用concurrently
插件能解决不能同时启动的问题,与打包相同,存在三个不同功能的命令,注意该插件目前最新版存在解析问题,当前项目中退回到了早些版本package.json
"start": "vue-cli-service serve", "start:sub": "cd sub && npm run start", "start:prod": "concurrently -r \"npm run start\" \"npm run start:sub\"",
-
样式隔离:
由于我们的组件库
vue2
是基于element-ui
搭建,vue3
是基于element-plus
搭建,存在大量相同命名的样式class
,在一起运行时,样式或因层级优先不一致导致覆盖,经测试使用乾坤沙箱模式并无法解决父子关系的样式污染,只能解决子与子项目的污染,目前我们vue3
组件库的element-plus
版本过低还不支持重写el
前缀,且即使修改了前缀也许我们的组件库并没有按照组件库原有的设计规范书写组件,故通过postcss-change-css-prefix
插件修改基座组件样式前缀,内部应该是正则所有el前缀的样式进行替换,无需其他任何修改,经测试,能解决element-ui
与plus
的样式污染问题postcss.config.js
const addCssPrefix = require('postcss-change-css-prefix') module.exports = { plugins: [ addCssPrefix({ prefix: 'el-', replace: 'gp-' }) ] }
webpack (vue.config.js)
module: { rules: [ { test: /\.js$/, include: [ path.resolve(__dirname, './node_modules/web-vue2-front-end-lib/lib') ], use: [ { loader: 'change-prefix-loader', options: { prefix: 'el-', replace: 'gp-' } } ] } ] }
-
lint
规范优化:由于官方不再维护且推荐使用
eslint
,首先移除了子项目的tslint
,基座本来使用的就是eslint
,但配置的是js的规范,由于eslint
的特性,子目录也会向上寻找eslint
的规则,导致ts
报了很多错,后续采用子模块单独安装eslint
,且使用root
限定范围,基座使用eslint
的js
规范(规则模仿了element-ui
风格),子模块采用eslint
的ts
规范(element-plus
的风格) -
node
版本同步由于一些依赖兼容的
node
版本不一致,不可能运行一次就切换一次node
版本,当然有较好的方案:nvm+bash
命令,通过不同项目目录定义.nvmrc
限定node
版本,进入终端通过bash
命令自动化切换,但后续通过降低依赖版本解决了,该方案就没用到
至此,框架的基础改造就到此结束了,当然,也许在后续开发中还会存在一些意想不到的问题,可以提前关注乾坤项目的issue去排查、测试。
原文链接:https://blog.stride.fun/blogs/category1/2023/61201.html