前言
现在是北京时间 2023 年 12 月 26 日 08:48:35 星期二,心情不错写写作 😎。
正文来了
日记
说说昨天“忙 Day”星期一的事儿,那天写第一篇# 《狂人日记 NO.1》— 启示录,没错我就是标题党是大早上抽风写的,还定了个 5:30 的闹钟想着以后早起写写东西,呵呵,昨天我都没意识到闹钟响,据说是我自己闭着眼睛关掉后扭头又睡了,7 点才睁开眼...
星期一说忙也不忙,上午封装了两个组件,分别是 EditableTable,JsonEditor,适配了vue-element-plus-admin的表单组件,效果如下:
下午进行了卫星运营板块年终会议,听取了领导讲话,让普通员工对未来发展的定位和目标有了更多的了解,相信通过未来大家的共同努力,事业部会蒸蒸日上,大家也会拿到体面的薪资。
晚上下班,我一边吃饭,闺女在旁边模拟上厕所,还知道拿纸擦 pp(穿着衣服),擦完 pp 把纸放在了桌子上,我一看她,“怎么把纸放在桌子上了”。她一边挠头一边答到:“不好意思...不好意思”,乐死我了,才两周多一点,能说的词真是让大人都叹为观止。这孩子以前是喜欢用平板看小红书,后来我发现里边的视频良莠不齐,坚决卸载了,以后让她看儿童动画片,汪汪队,小猪佩奇都挺不错。以此来看,小孩通过看视频学习说话还真是挺高效的,网上说小孩一天不能超过 1 个小时的屏幕时间完全是制造焦虑,眼睛没那么容易瞎。就说我自己,从小玩电脑游戏,更疯狂的时候通宵玩,现在还是一枚成天盯着电脑的程序员,不过体检视力 5.0。当然,小孩看动画片的时候大人最好陪着一起看,也能从旁引导小孩。
昨天刷到一个抖音视频,里面是现任中国人民解放军海军导弹驱逐舰绍兴舰舰长的韦慧晓,她说“有两种价值观,一种价值观是戴着非常昂贵的手表,好显示出来自己身价百倍;另一种价值观是我这种价值观,一块不贵的手表,因为我戴过了,所以身价百倍”。
我感觉这句话挺好的点在于对自我价值的肯定。就像有的人能进大厂,显得很优秀;那可不可以因为自己的优秀,让自己所在的公司变成大厂呢。这一点值得所有人学习,大家共勉。
前端那些事儿
这段时间在做大数据平台的相关项目,借此机会和过往的项目重构经验,也对内部产品一体化大数据平台产品进行一次彻底重构。
旧的痛点
由于大数据平台的功能模块和页面相对比较多,也在开发过程中集成了两个主要且复杂的功能:数据 ETL,可视化大屏拖拉拽。这两个功能本身就可以作为一个单独的项目,其中的代码量巨大,业务逻辑缜密。旧项目就像一个大杂烩,将一系列功能全部集成到了这个 vue2 SPA(Single Page Application)里,在可拓展性上,大打折扣。
可扩展性是指,软件系统具备面对未来需求变化而进行扩展的能力。系统可根据新的需求做出少量或者不需要修改,无需对整个系统进行重构或重建。
新的架构
新的项目架构,以Sat-Repo(星仓)为基础,在 web 目录下,big-data-platform为后台主应用;big-data-platform-etl、big-data-platform-visual为后台子应用;big-data-platform-portal为前台主应用;big-data-platform-config为总体配置,其中包含公共 api,各个微前端项目环境、端口、基础路径的配置,Iframe+Postmate 通信方案的类型校验支持等。
Postmate is a promise-based API built on postMessage. It allows a parent page to speak with a child iFrame across origins with minimal effort.(Postmate 是基于 postMessage 构建的基于 Promise 的 API。它允许父页面与跨源的子 iFrame 进行通信,而且只需很少的工作即可实现。)
贴一下big-data-platform-config的主要代码
package.json
{
"name": "@sat/big-data-platform-config",
"version": "0.0.1",
"private": true,
"main": "dist/index.cjs",
"module": "dist/index.js",
"type": "module",
"types": "./dist/index.d.ts",
"scripts": {
"gen": "sg-cli gen -url http://172.18.10.58:32160/swagger-resources -c -ct typescript-axios -o ./src/api/generated -clean",
"build": "tsup src/index.ts --format cjs,esm --dts --clean",
"start-flow": "pnpm build && npm-run-all --parallel start:*",
"build-flow": "pnpm build && npm-run-all --parallel build:* && bash ./bundle.sh",
"start:bdp": "cd ../big-data-platform && pnpm dev",
"start:bdpe": "cd ../big-data-platform-etl && pnpm dev",
"start:bdpv": "cd ../big-data-platform-visual && pnpm dev",
"start:bdpp": "cd ../big-data-platform-portal && pnpm dev",
"build:bdp": "cd ../big-data-platform && pnpm build",
"build:bdpe": "cd ../big-data-platform-etl && pnpm build",
"build:bdpv": "cd ../big-data-platform-visual && pnpm build",
"build:bdpp": "cd ../big-data-platform-portal && pnpm build"
},
"dependencies": {
"axios": "^1.5.0"
},
"devDependencies": {
"npm-run-all": "^4.1.5",
"swagger2ts-gen-cli": "^2.0.5",
"tsup": "^8.0.1"
}
}
package.json中编写了 api 生成命令,让配置产物适配 ES Module 和 Commonjs 规范的 build 命令,start-flow 启动项目命令和 build-flow 快捷打包命令。
每个项目都添加了自动压缩为 zip 的打包插件 配合 build-flow 快捷打包命令使用:
// 开启gzip压缩
viteCompression({
verbose: true,
disable: false,
threshold: 1024 * 10, // 跳过小于10kb的文件
algorithm: 'gzip',
ext: '.gz',
success() {
// 自动压缩成zip
// @ts-ignore 骚操作:ZipPack按正常的放在plugins里始终比Compression早,也无法通过vite常规配置控制执行顺序
// 这里通过查看ZipPack的源码,强制获取 closeBundle钩子,在compression success后再调用
const { closeBundle } = ZipPack({
inDir: outDir,
pathPrefix: 'dist',
outFileName: `${outDir}.zip`,
})
closeBundle?.()
},
})
postmateTypes.ts
// #region ************************************ on, emit *********************************************
/**
* @edit 只需修改这里
* */
interface ParentListener {
routerPush: (
path:
| string
| {
path?: string // path为空就按照当前path执行
query: Recordable
},
) => any
pageToLogin: () => any
handle401: () => any
}
/**
* 父应用的监听方法类型
*/
export type PostmateParentOn = <T extends keyof ParentListener>(
eventName: T,
callback: ParentListener[T],
) => any
/**
* 子应用的触发方法类型
*/
export type PostmateChildEmit = <T extends keyof ParentListener>(
eventName: T,
...args: Parameters<ParentListener[T]>
) => any
// #endregion *************************************************************************************
// #region ************************************ etl: model,get,call *********************************************
/**
* @edit 只需修改这里
* */
interface EtlChildModelProperty {
helloworld: string
}
/**
* @edit 只需修改这里
* */
interface EtlChildModelCaller {
routerPush: (
path:
| string
| {
path?: string // path为空就按照当前path执行
query: Recordable
},
) => any
setUserToken: (params: { user: any; token: string }) => any
}
/**
* Etl子应用的model类型
*/
export type PostmateChildModelEtl = EtlChildModelProperty & EtlChildModelCaller
/**
* Etl父应用的get方法类型
*/
export type PostmateParentGetEtl = <T extends keyof EtlChildModelProperty>(
key: T,
) => Promise<EtlChildModelProperty[T]>
/**
* Etl父应用的call方法类型
*/
export type PostmateParentCallEtl = <T extends keyof EtlChildModelCaller>(
eventName: T,
...args: Parameters<EtlChildModelCaller[T]>
) => any
// #endregion *************************************************************************************
// #region ************************************ visual: model,get,call *********************************************
/**
* @edit 只需修改这里
* */
interface VisualChildModelProperty {
helloworld: string
}
/**
* @edit 只需修改这里
* */
interface VisualChildModelCaller {
routerPush: (
path:
| string
| {
path?: string // path为空就按照当前path执行
query: Recordable
},
) => any
setUserToken: (params: { user: any; token: string }) => any
}
/**
* Visual子应用的model类型
*/
export type PostmateChildModelVisual = VisualChildModelProperty &
VisualChildModelCaller
/**
* Visual父应用的get方法类型
*/
export type PostmateParentGetVisual = <
T extends keyof VisualChildModelProperty,
>(
key: T,
) => Promise<VisualChildModelProperty[T]>
/**
* Visual父应用的call方法类型
*/
export type PostmateParentCallVisual = <T extends keyof VisualChildModelCaller>(
eventName: T,
...args: Parameters<VisualChildModelCaller[T]>
) => any
// #endregion *************************************************************************************
postmateTypes.ts中做的类型体操是为了规范主子应用之间的通信,让不熟悉项目的开发人员也能一眼清晰的看到通信方式,怎么调用方法,怎么传参等等都有编辑器和 ts 类型检查,避免盲操。
- 主应用这样写:
// usePostmateParent.ts
import Postmate from 'postmate'
import type { ParentAPI } from 'postmate'
import type { MaybeRef } from 'vue'
export const usePostmateParent = <
T extends Pick<ParentAPI, 'call' | 'on' | 'get'>,
>({
container,
url,
classListArray,
}: {
container: Ref<HTMLDivElement | undefined>
url: MaybeRef<string>
classListArray?: MaybeRef<string[]>
}) => {
const parentInstance = shallowRef<Pick<ParentAPI, 'destroy' | 'frame'> & T>()
const postmate = shallowRef<Postmate>()
watch(
[container, () => unref(url), () => unref(classListArray)],
([container, url, classListArray]) => {
parentInstance.value?.destroy()
parentInstance.value = undefined
postmate.value = undefined
if (container && url) {
postmate.value = new Postmate({
container, // Element to inject frame into
url, // Page to load, must have postmate.js. This will also be the origin used for communication.
classListArray, //Classes to add to the iframe via classList, useful for styling.
})
postmate.value.then((instance) => {
parentInstance.value = instance as Pick<
ParentAPI,
'destroy' | 'frame'
> &
T
})
}
},
{
immediate: true,
},
)
onUnmounted(() => {
parentInstance.value?.destroy()
parentInstance.value = undefined
})
return parentInstance
}
<!-- VisualManage.vue -->
<script setup lang="ts">
/*
* Iframe 方案集成
*/
import { resetAfterLogout } from '@/router'
import { useAppStore } from '@/store/modules/app'
import {
PostmateParentCallVisual,
PostmateParentGetVisual,
PostmateParentOn,
microAppConfig,
} from '@sat/big-data-platform-config'
import { usePostmateParent } from '@sat/shared-hooks'
import { ElMessage } from 'element-plus'
const appStore = useAppStore()
const divRef = ref<HTMLDivElement>()
const url =
microAppConfig.micros.visual[import.meta.env.VITE_APP_MODE].pageMap.project
// 类型编辑到 sat-repo\web\big-data-platform-config\src\micro\postmateTypes.ts
const parentInstance = usePostmateParent<{
// 泛型重写返回类型
call: PostmateParentCallVisual
get: PostmateParentGetVisual
on: PostmateParentOn
}>({
container: divRef,
url: url,
classListArray: ['h-full', 'w-full'],
})
watch(parentInstance, (instance) => {
instance?.get('helloworld').then((res) => {
console.log('获取到子应用 model:' + res)
})
instance?.call(
'setUserToken',
JSON.parse(
JSON.stringify({
user: appStore.getUserInfo,
token: appStore.getToken || '',
}),
),
)
instance?.on('pageToLogin', () => {
console.log('主应用监听到子应用的emit:pageToLogin')
})
instance?.on('routerPush', (params) => {
console.log(
'主应用监听到子应用的emit:routerPush:' + JSON.stringify(params),
)
})
instance?.on('handle401', () => {
console.log('主应用监听到子应用的emit:handle401')
ElMessage.error({
message: '很抱歉,登录已过期,正在为您跳转至登录页面...',
onClose: () => {
resetAfterLogout()
},
})
})
})
</script>
<template>
<div
ref="divRef"
id="visual-iframe-container"
class="h-[calc(100%-10px)]"
></div>
</template>
<style lang="less" scoped></style>
- 子应用这样写:
// usePostmateChild.ts
import Postmate from 'postmate'
import type { ChildAPI } from 'postmate'
import type { MaybeRef } from 'vue'
export const usePostmateChild = <T extends ChildAPI, U extends Recordable>(
data: MaybeRef<U>,
) => {
const childInstance = shallowRef<T>()
const postmate = shallowRef<Promise<ChildAPI>>()
watch(
() => unref(data),
(val) => {
postmate.value = new Postmate.Model(val)
// When parent <-> child handshake is complete, events may be emitted to the parent
postmate.value.then((instance) => {
childInstance.value = instance as T
})
},
{
immediate: true,
deep: true,
},
)
onUnmounted(() => {
childInstance.value = undefined
postmate.value = undefined
})
return childInstance
}
<!-- App.vue -->
<script setup lang="ts">
import { usePostmateChild } from '@sat/shared-hooks'
import {
PostmateChildEmit,
PostmateChildModelVisual,
} from '@sat/big-data-platform-config'
window.$childInstance = usePostmateChild<
{
emit: PostmateChildEmit
},
PostmateChildModelVisual
>({
helloworld: 'hello world',
routerPush: (params) => {
router.push(params)
},
setUserToken: (data) => {
console.log('子应用 读取了 主应用 的token和user信息')
console.log(data)
systemStore.setItem(SystemStoreEnum.USER_INFO, {
[SystemStoreUserInfoEnum.USER_TOKEN]: data.token,
[SystemStoreUserInfoEnum.TOKEN_NAME]: 'Authorization',
[SystemStoreUserInfoEnum.USER_ID]: data.user?.user_id,
[SystemStoreUserInfoEnum.USER_NAME]: data.user?.username,
[SystemStoreUserInfoEnum.NICK_NAME]: data.user?.nickname,
})
// 获取 OSS 信息的 url 地址,用来拼接展示图片的地址
const getOssUrl = async () => {
const res = await ossUrlApi({})
if (res && res.code === ResultEnum.SUCCESS) {
systemStore.setItem(SystemStoreEnum.FETCH_INFO, {
OSSUrl: res.data?.bucketURL,
})
}
}
// 执行
getOssUrl()
},
})
watch(window.$childInstance, (instance) => {
// test
instance?.emit('pageToLogin')
instance?.emit('routerPush', '子应用触发主应用的routerPush')
})
</script>
appConfig.ts
/**
* 集成配置 (基础配置port,basePath,selfWebHistory) + iframe适配
* microAppConfig生产环境配置
*/
export const microAppConfig = {
main: {
name: 'big-data-platform',
dev: {
port: 8801,
basePath: '/',
selfWebHistory: '/',
},
prod: {
port: 8888,
basePath: '/',
selfWebHistory: '/',
},
},
micros: {
etl: {
name: 'big-data-platform-etl',
dev: {
port: 8802, // 开发环境不能用同一个端口运行
basePath: '/etl/',
selfWebHistory: '/etl/',
pageMap: {
entry: 'http://localhost:8802/etl/',
},
},
prod: {
port: 8888,
basePath: '/etl/', // 生产环境需要修改为对应的basePath
selfWebHistory: '/etl/',
pageMap: {
entry: 'http://localhost:8888/etl',
},
},
},
visual: {
name: 'big-data-platform-visual',
dev: {
port: 8803, // 开发环境不能用同一个端口运行
basePath: '/visual/',
selfWebHistory: '/visual/',
pageMap: {
entry: 'http://localhost:8803/visual',
project: 'http://localhost:8803/visual/project',
chart: 'http://localhost:8803/visual/chart/home',
},
},
prod: {
port: 8888,
basePath: '/visual/', // 生产环境需要修改为对应的basePath
selfWebHistory: '/visual/',
pageMap: {
entry: 'http://localhost:8888/visual',
project: 'http://localhost:8888/visual/project',
chart: 'http://localhost:8888/visual/chart/home',
},
},
},
},
}
export const portalAppConfig = {
main: {
name: 'big-data-platform-portal',
dev: {
port: 8804,
basePath: '/portal/',
selfWebHistory: '/portal/',
},
prod: {
port: 8888,
basePath: '/portal/',
selfWebHistory: '/portal/',
},
},
}
从 Qiankun 到 Iframe+Postmate 方案的心路历程
一开始是打算用 Qiankun 方案的,但是在调整架构的过程中,遇到了一系列问题。
big-data-platform主应用是从vue-element-plus-admin演变出来的,其中技术栈是 vue3+element-plus,但是big-data-platform-etl子应用是将旧的项目中的数据 ETL 模块抽取成了一个单独的应用,依然是 vue2+element-ui 的技术栈,只不过是做了精简。在用Qiankun集成后,由于天然比Iframe差的应用隔离,再加上element-plus和element-ui关于'el-'的命名空间冲突造成了全局样式的 bug,我不得不将主应用的命名空间进行修改调整,改成了'ep-'。这才解决这一问题。- 子应用使用的组件库
element-ui的tooltip组件会向 body 注入 html,但是由于父子应用样式隔离,导致tooltip样式异常,为了Qiankun的集成,我又在子应用中重写了document.appendChild方法才使得这一问题得以解决。 - 在集成子应用
big-data-platform-visual后,各种样式的 bug 终是成了压死骆驼最后的羽毛,我也因此弃用了Qiankun。
那怎么办呢,微前端的架构理念还得推进啊,否则单体应用是肯定扛不住扩展的,因此就用了原生的Iframe+Postmate方案。总体来看,效果还是不错的,比Qiankun简单有效,心智负担小,样式等可实现完全隔离,通信复杂那就在通信上做优化,从类型体操的支持到usePostmateParent和usePostmateChildhooks 函数的支持,该方案也越发的好用了不是。
欢迎评论
如果大家有什么看法或问题,也可以关注下方我的公众号哦 🌟