《狂人日记NO.3》— 不好意思...不好意思

213 阅读8分钟

前言

现在是北京时间 2023 年 12 月 26 日 08:48:35 星期二,心情不错写写作 😎。

正文来了

日记

说说昨天“忙 Day”星期一的事儿,那天写第一篇# 《狂人日记 NO.1》— 启示录,没错我就是标题党是大早上抽风写的,还定了个 5:30 的闹钟想着以后早起写写东西,呵呵,昨天我都没意识到闹钟响,据说是我自己闭着眼睛关掉后扭头又睡了,7 点才睁开眼...

星期一说忙也不忙,上午封装了两个组件,分别是 EditableTable,JsonEditor,适配了vue-element-plus-admin的表单组件,效果如下:

代码组织方式

页面呈现效果

下午进行了卫星运营板块年终会议,听取了领导讲话,让普通员工对未来发展的定位和目标有了更多的了解,相信通过未来大家的共同努力,事业部会蒸蒸日上,大家也会拿到体面的薪资。

晚上下班,我一边吃饭,闺女在旁边模拟上厕所,还知道拿纸擦 pp(穿着衣服),擦完 pp 把纸放在了桌子上,我一看她,“怎么把纸放在桌子上了”。她一边挠头一边答到:“不好意思...不好意思”,乐死我了,才两周多一点,能说的词真是让大人都叹为观止。这孩子以前是喜欢用平板看小红书,后来我发现里边的视频良莠不齐,坚决卸载了,以后让她看儿童动画片,汪汪队,小猪佩奇都挺不错。以此来看,小孩通过看视频学习说话还真是挺高效的,网上说小孩一天不能超过 1 个小时的屏幕时间完全是制造焦虑,眼睛没那么容易瞎。就说我自己,从小玩电脑游戏,更疯狂的时候通宵玩,现在还是一枚成天盯着电脑的程序员,不过体检视力 5.0。当然,小孩看动画片的时候大人最好陪着一起看,也能从旁引导小孩。

昨天刷到一个抖音视频,里面是现任中国人民解放军海军导弹驱逐舰绍兴舰舰长的韦慧晓,她说“有两种价值观,一种价值观是戴着非常昂贵的手表,好显示出来自己身价百倍;另一种价值观是我这种价值观,一块不贵的手表,因为我戴过了,所以身价百倍”。

image.png

我感觉这句话挺好的点在于对自我价值的肯定。就像有的人能进大厂,显得很优秀;那可不可以因为自己的优秀,让自己所在的公司变成大厂呢。这一点值得所有人学习,大家共勉。

前端那些事儿

这段时间在做大数据平台的相关项目,借此机会和过往的项目重构经验,也对内部产品一体化大数据平台产品进行一次彻底重构。

旧的痛点

由于大数据平台的功能模块和页面相对比较多,也在开发过程中集成了两个主要且复杂的功能:数据 ETL,可视化大屏拖拉拽。这两个功能本身就可以作为一个单独的项目,其中的代码量巨大,业务逻辑缜密。旧项目就像一个大杂烩,将一系列功能全部集成到了这个 vue2 SPA(Single Page Application)里,在可拓展性上,大打折扣。

可扩展性是指,软件系统具备面对未来需求变化而进行扩展的能力。系统可根据新的需求做出少量或者不需要修改,无需对整个系统进行重构或重建。

新的架构

项目结构截图

新的项目架构,以Sat-Repo(星仓)为基础,在 web 目录下,big-data-platform为后台主应用;big-data-platform-etlbig-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 方案的,但是在调整架构的过程中,遇到了一系列问题。

  1. big-data-platform主应用是从vue-element-plus-admin演变出来的,其中技术栈是 vue3+element-plus,但是 big-data-platform-etl子应用是将旧的项目中的数据 ETL 模块抽取成了一个单独的应用,依然是 vue2+element-ui 的技术栈,只不过是做了精简。在用Qiankun集成后,由于天然比Iframe差的应用隔离,再加上element-pluselement-ui关于'el-'的命名空间冲突造成了全局样式的 bug,我不得不将主应用的命名空间进行修改调整,改成了'ep-'。这才解决这一问题。
  2. 子应用使用的组件库 element-uitooltip组件会向 body 注入 html,但是由于父子应用样式隔离,导致tooltip样式异常,为了Qiankun的集成,我又在子应用中重写了document.appendChild方法才使得这一问题得以解决。
  3. 在集成子应用big-data-platform-visual后,各种样式的 bug 终是成了压死骆驼最后的羽毛,我也因此弃用了Qiankun

那怎么办呢,微前端的架构理念还得推进啊,否则单体应用是肯定扛不住扩展的,因此就用了原生的Iframe+Postmate方案。总体来看,效果还是不错的,比Qiankun简单有效,心智负担小,样式等可实现完全隔离,通信复杂那就在通信上做优化,从类型体操的支持到usePostmateParentusePostmateChildhooks 函数的支持,该方案也越发的好用了不是。

欢迎评论

如果大家有什么看法或问题,也可以关注下方我的公众号哦 🌟

诗传千古地负海涵