qiankun?这次我选了wujie!

写在最前:

本文面向对无界和微前端有一定了解的人群,不再对微前端的概念和无界的基础使用方式做解释说明

前言

掘金上搜wujie,那么大眼一看,好像全都是介绍的,并没有几个落地方案的分享。正好我上个月把部门内三个业务系统用wujie整合了一下,记录成文章和大家分享一下。(为什么不用qiankun?qiankun之前做了好多次了,这次想尝个鲜~)

背景说明

笔者部门内有三个管理系统,技术栈分别是:

A: Vue2 + Webpack4 + ant-design-vue@1.7.8:该项目是部门内"司龄"最长的,从部门成立之初起,所有的业务都堆在里边。

B: Vue3 + Webpack5 + ant-desgin-vue@3.2.20:由于业务目标不清晰以及前端开发各自为战,部分需求被拆出来了一个单独的项目进行开发,但实际上然并卵。

C: Vue3 + Vite2 + ant-design-vue@3.2.20:为了响应领导"统一前端UI规范"和"低代码降本增效"的号召,这个项目应运而生,使用JSON Scheme渲染列表页 + 手写Form表单的形式开发需求。

没错,就是3个纯业务向的管理系统。对接我们部门的大部分业务人员,日常都至少需要操作3个系统,甚至有些人还会用到别的部门的系统,甚至有的人习惯打开多个浏览器tab页来回切换对比同个页面的数据。。。poor guy。。。浏览器密密麻麻的全是tab页。。。

契机

某天,发生了如下对话:

  • 领导:业务部门老大说,系统间来回切换太麻烦了,有没有办法解决这个问题?
  • 我:有,微前端。
  • 领导:之前XXX不是用qiankun做过吗,问题很多,不了了之了。
  • 我:我看过他的代码,没有什么大问题,都是一些细节方面的小bug,而且还有别的微前端方案可以选择。
  • 领导:行,你安排一下,尽快上线
  • 我:好的。( 打工人被安排任务就是这么朴实,无华,且枯燥。。。)

为什么选择无界?

(此处省略万字长文对比分析qiankun、micro app、single-app...)

直接摆出站在个人角度以及团队技术、业务背景下选择无界的原因:

  1. 喜欢吃螃蟹:之前有过多次qiankun的落地经验,直接上qiankun,一点都不酷。(第一次了解到无界是22年的10月份左右,彼时的无界还在beta版,想尝尝鲜。况且就算使用无界出了岔子,也有信心能cover住)
  2. 子应用改造,侵入程度低:就像文档中宣传的那样,我用公司的项目跑demo,除去登录态的因素外,基本可以说是0改动接入,当时脑海中只有2个字----牛X!(当然,仅仅这样接入,离上生产的标准还相距甚远;而且最后我还是选择了类似qiankun根据宿主应用动态选择layout的布局方案,改造成本也可以说是不算低了,这个暂且按下不表)
  3. 方便独立开发、部署:与第2点相似但又不同:现有的项目有独立的域名、部署方案、且在生产环境已经稳定运行,在保留这些基础的前提下,无界的iframe方案算是最理想的出路(另外也有一点私心,如果生产环境的无界挂了,业务人员可以直接使用老的域名访问独立的子应用进行业务操作,毕竟出了生产事故是要通报批评的)

综上所述,确实没经过太多深思熟虑,想用就用,干就完了

干货区

下面,就是在我接入文章开头提到的3个系统后,总结出来的大致接入步骤:

  1. 准备主应用,在接入第一个系统之前,不出意外的要先准备宿主应用。
  2. 子系统登录态管理
  3. 根据宿主环境,选择layout方案
  4. 安装wujieEventBus(基于无界去中心化的通信系统做的二次封装)
  5. 子应用afterMount生命周期
  6. 子系统网络请求管理
  7. UI组件定位修复
  8. 公共状态提升

1.准备主应用

一个比较常规、纯净的管理系统,没有过多的封装,因为宿主应用本身,也不需要什么内容。技术栈为Vue3 + Vite2 + ant-design-vue@3.2.20(没错,和系统C的技术栈一致,主打的就是一个偷懒),放张目录结构大家就明白了,没什么特殊的,有些细节后边会提到。

2.子系统登录态管理

简单来说,对于一个子应用,无论你是基于JWT还是Cookie的用户鉴权方案,在他单独运行时发生登陆态失效的情况,是要被redirect到自己的Login页面去;而当集成到了无界中运行的时候,登录态失效则应该被redirect到主应用的Login页面。

一般情况下,有两个地方需要做处理:

  1. http响应拦截,以axios为例:
javascript 复制代码
if (response.status === 401) {
  if (window.__POWERED_BY_WUJIE__) {
    wujieEventBus.$emit("LOGIN_EXPIRED", APP_NAME_IN_WUJIE);
  } else {
    message.error("登录失效,请重新登录");
    router.replace("/login");
  }
}

window.__POWERED_BY_WUJIE__是无界注入到子应用window当中的一个全局变量。

wujieEventBus是我对无界自带的去中心化通信方式eventBus的封装,具体内容放在第四点展开讲,这里只需要知道,是通知主应用"我"登录失效了,并且附上"我"在主应用中的身份标识(对应组件方式使用无界的<WujieVue />所需的name属性)

  1. 路由守卫:可根据你的需要更改路由钩子,这里以beForeEach为例:
javascript 复制代码
router.beforeEach((to, from, next) => {
    if(validToken()) {
        // some your logic ...
        next();
    }else {
        wujieEventBus.$emit("LOGIN_EXPIRED", APP_NAME_IN_WUJIE);
    }
}

当然,通过路由守卫拦截下登录态失效的情况可能很少很少,但操作和上面是一样的:通知主应用"我"登录失效了,并且附上"我"在主应用中的身份标识

3.根据宿主环境,子应用动态选择layout方案

如果你的主应用布局是打算这样:

子应用甚至不用切换layout方案,在下方content区域中保留子应用所有的模块;上方的Menu区作为一个应用级的切换菜单。

但如果你的主应用是打算像这样常规布局:

想实现应用级的切换,大体上有三种思路:

  1. 主应用不设任何layout模块:即Header、Menu、Content全都是子应用的模块。那么就需要所有子应用都是这种布局,且每个子应用的Menu菜单都必须是所有应用菜单的集合,当切换到非自身的路由时,与宿主通信进行应用切换。
  2. 与1相同,Header、Menu、Content全都是子应用的模块,但Menu仍是自己的菜单。你问我怎么切换应用?加个position: fixed的悬浮球呗(或类似的可折叠菜单)。

通过hover悬浮球,展开/折叠菜单,点击进行应用切换。

说实话,这方案我自己都不相信有人会用。

  1. 而第三个,也就是我选择的方案:主应用设有Header和Menu,剔除所有子应用的Header和Menu,只保留子应用的Content模块接入进来。熟悉吗?就是接qiankun那套。

大概长这样:

xml 复制代码
<template v-if="!isInWujieContainer">
    <Menu />
    <Layout>
        <Header />
        <Layout>
            <keep-alive>
                <router-view />
            </keep-alive>
        </Layout>
    </Layout>
</template>
<template v-else>
    <keep-alive>
      <router-view />
    </keep-alive>
</template>

// const isInWujieContainer = window.__POWERED_BY_WUJIE__

为什么选择方案3,在我看来:Menu维护在主应用中,相比于对每个子应用的Menu进行侵入式改造,开发成本和维护成本都更小。Header维护在主应用中,可以方便的管理路由栈(面包屑、tab页签,这里多提一下,我的子应用接入方式是保活+sync路由同步)

既然Menu维护在了主应用中,那么问题来了:点击了Menu中的某个菜单,怎么通知子应用跳转到对应的路由?

我们都知道,当无界开启了url sync同步的时候,主应用、子应用的url变化规则是:子应用url发生变化时,子应用的iframe会与主应用进行通信,主应用同步更新url;当页面刷新时,子应用iframe会从主应用的url中读取路由信息,保证子应用路由状态不丢失。但是并没有一种规则是主应用主动发起改变url、并且子应用能同步更新路由的方案。

我的做法其实也很简单,点击主应用Menu中的菜单时,通过wujieEventBus进行广播,对应的子应用收到消息时,切换路由:

javascript 复制代码
// 主应用中点击Menu菜单
export const openChildRoute = (
  _router: RouterObj,
  app: AppCollection,
) => {
    // 通知子应用路由已改变,registerMountedQueue可以理解为给子应用注册一个mounted后需要立即执行的事件,防止出现跳转到一个还未初始化的子应用时,$emit miss的问题。
    EventBus.$registerMountedQueue(
      app,
      "CHANGE_ROUTE",
      { path: _router.path, app }
    );
    
   // 更新主应用自己的url和tab页签
   router.push(fullPath);
   store.commit("tabs/setList", {
     fullPath,
     name: _router?.name || "",
     title: _router?.name,
   });
   setActiveKey(fullPath);
};
javascript 复制代码
// 子应用收到消息
wujieEventBus.$on("CHANGE_ROUTE", function ({ path, query, app }) {
  if (app !== APP_NAME_IN_WUJIE) return;
  router.push({ path, query });
});

并且CHANGE_ROUTE这个事件可以是双向的:可以由主应用主动发起,通知子应用改变路由;也可以由子应用主动发起,通知主应用改变url和tab页签的显示状态。

之所以这样设计,是因为我们的系统中存在一种特殊的路由页面,他不存在于Menu菜单中,是必须通过点击页面中的指定按钮才能进入。所以对于这类页面,必须是由子应用主动发起的。

4.安装wujieEventBus

无界提供了一套去中心化的通信方案,去中心化的优点显而易见:

  • 不关心发送方和接收方是谁,可以是不同应用之间通信,可以是一个应用内不同路由通信,可以是一个应用内不同组件通信
  • 可以很方便的一对多通信

但同时也有一个致命的缺点:通信成功的前提是建立在通信双方都online的情况下

假设这样一个场景:用户从站外的某个带参链接进入系统,参数的目的是告诉系统要重定向到指定子应用的指定路由,甚至具体要打开某个弹框。

正常情况下,主应用判断url参数做跳转的逻辑不管放在哪里,都存在子应用未加载完成的可能性。

(如果你说每个子应用component的afterMount事件里都写一遍,fine,你赢了)

这个时候,只需要对无界的eventBus稍作改动,即可满足需求:

javascript 复制代码
import WujieVue from "wujie-vue3";
import { AppCollection } from "@/constant";
import store from '@/store';
const { bus } = WujieVue;
type EventList = "LOGIN_EXPIRED" | "EVENT_NAME1" | "EVENT_NAME2"; // 一些事件类型涉及到公司业务,这里省去了

type EventBusInstance = {
  $emit: (e: EventList, params: Record<string, any>) => void;
  $on: (e: EventList, fn: (...args: any[]) => void) => void;
  $registerMountedQueue: (
    app: AppCollection,
    e: EventList,
    params: Record<string, any>
  ) => void; // 将事件注册到子应用mount成功的的事件队列中
  $cleanMountedQueue: (app: AppCollection) => void; // 清空子应用mount事件队列
};

type Queue = {
  [app in AppCollection]?: any[];
};

let instance: EventBusInstance | undefined = undefined;

export default () => {
  const queue: Queue = {};
  if (!instance) {
    instance = {
      $emit: (event, params) => bus.$emit(event, params),
      $on: (event, fn) => bus.$on(event, fn),
      $registerMountedQueue: (app, event, params) => {
        const isMounted = store.state.globalState.appMounted[app]; // store中存储了子应用是否mount完成的状态
        const fn = () => bus.$emit(event, params);

        // 子应用已挂载完成可以直接通信
        if (isMounted) return fn();

        if (queue[app] && queue[app]!.length) {
          queue[app]!.push(fn);
        } else {
          queue[app] = [fn];
        }
      },
      $cleanMountedQueue: (app) => {
        while (queue[app] && queue[app]!.length) {
          const fn = queue[app]!.shift();
          fn();
        }
      },
    };
  }

  return instance;
};

为每个子应用都维护一个事件队列,主应用通过$registerMountedQueue注册事件时,若对应子应用已经mount完成,则直接emit进行通信;若子应用没有mount完成,则将注册的事件推入队列中。

子应用afterMount钩子中调用$cleanMountedQueue,清空属于自己的事件队列。

目前根据业务需要,只做了这一点封装,后续有可能会继续补充。

当然前边提到的这个场景,肯定还有许多不同的解决方案,根据自己的项目因地制宜才是最重要的。

5.子应用afterMount生命周期

上边第4点已经提到过,子应用afterMount钩子中要做两件事情:

  1. store中保存自己mount完成的状态。
  2. 调用$cleanMountedQueue清空自己的事件队列。

6.子系统网络请求管理

网络请求管理,主要解决的是跨域问题,分两种:

  • 调用后端服务跨域 如果你的用户鉴权是基于cookie的,那最方便的就是使用无界推荐的方法:将主应用的fetch自定义改写后传给子应用。如果你的用户鉴权是基于JWT或者你使用了其他的http请求库,赶快买上两杯咖啡贿赂一下运维大佬,给子应用对应的服务配置下Response Header,支持主应用域名的跨域资源共享。但是要切记,生产环境不要使用 Access-Control-Allow-Origin: *

  • 请求子应用静态资源跨域

刚才为啥要让买两杯咖啡,因为一杯是改后端服务支持跨域,还有一杯是改前端静态资源服务器(比如Nginx)支持跨域。

至此,你(wo)的无界微前端方案已经落地大半了,不出意外的话,除了个别地方的样式比较古怪,业务流程已经没啥大问题了,下面的工作就是各个页面点一点,修一修奇怪的样式问题。

7.UI组件定位修复

无界官方针对element-plus冒泡系列组件弹出位置不正确的解决方案是给子应用的body添加position: relative,但我这边使用ant-design-vue@1.7.8的项目并不是弹出位置不正确,而是弹出方向不对,只能暂时通过调整组件位置+修改placement的方式见一个改一个。

我这边还有一些使用左弹出的drawer组件也会有问题,起始位置并不是屏幕最左边,而是content区域的最左边。

不知是否是无界的bug,drawer有个fixed定位的包裹容器,按理来说,创建这个包裹容器的时候会使用webcomponent代理的appendChild方法,可以突破iframe的区域限制,但通过审查元素发现,这个position: fixed; left: 0的元素,开始位置还是iframe的左侧。。。导致drawerposition: absolute的主体开始位置也只能是iframe的左侧。但又不是所有的左弹出drawer都有这个问题,很神奇。。。没办法,只好把这些有问题的暂且改为右弹出。。。有解决方案的朋友也可以交流一下。。。

8.公共状态提升

其实从这里开始,就属于优化的范畴了,目前只做了这一趴,后续有其他优化会持续补充。

做公共状态提升的原因,简单来讲就是:除了登录用户的信息以外,我们不同系统中也有着很多相同的枚举数据,这些数据本身也是从同样的接口中读的,存在vuex/pinia中。所以当一个系统独立运行时,他数据获取的逻辑不变;当作为子应用接入了微前端体系中时,只需要从主应用中等待数据同步,不需要自己再调接口去取。

javascript 复制代码
// 主应用
export default () => {
  const duties = [
    // some http request callbacks
  ];
  duties.forEach(async (d) => {
    const { action, type, commition } = d;
    const data = await action();
    store.commit(commition, data);
    bus.$registerMountedQueue(
      'APP_NAME', // 业务系统name标识
      "SYNC_STATE",
      {
        type,
        data: toRaw(data),
      }
    );
  });
};
javascript 复制代码
// 子应用
const state = {
    // a vuex state
}

const mutations = {
    // a vuex mutation
}

const actions = {
    // a vuex action
}

if(window.__POWERED_BY_WUJIE__){
    wujieEventBus.$on("SYNC_STATE", ({ type, data }) => {
      const [updateFn, stateKey, ...restPath] = type;
      let config = state[stateKey];
      if (restPath && restPath.length) {
        set(config, restPath, data); // lodash set
      } else {
        config = data;
      }
      mutations[updateFn](state, config);
    });
}else {
    // old logic, init all states by actions
}

结语

这篇文章从开篇到写下结语,中间经历了一整个星期。后半部分整体写的比较仓促,可能有些地方和起笔之初的设想有所出入;并且许多的细节之处涉及到公司业务也没有做过多的说明。有不明白的地方、或者有想交流的同学也可以留言,我会尽可能的做答复。

另外做个说明,其实最开始的时候文章标题叫【无界(wujie-micro)微前端落地方案分享】,后来才改成现在这个名字,原因有二:

  • 这并不是一套完整的落地方案,只是我对我落地整个过程中,值得记录、分享的一些点的总结
  • 原先的名字有种让人一看就不想点进来的感觉

行吧,第一版先到这里,欢迎真诚交流,但如果你来抬杠?阿,对对对~ 你说的都对~

相关推荐
寒雒1 小时前
【Python】实战:实现GUI登录界面
开发语言·前端·python
独上归州1 小时前
Vue与React的Suspense组件对比
前端·vue.js·react.js·suspense
Komorebi⁼1 小时前
Vue核心特性解析(内含实践项目:设置购物车)
前端·javascript·vue.js·html·html5
明月清风徐徐1 小时前
Vue实训---0-完成Vue开发环境的搭建
前端·javascript·vue.js
SameX1 小时前
HarmonyOS Next 企业数据备份与恢复策略
前端·harmonyos
SameX1 小时前
HarmonyOS Next 企业数据传输安全策略
前端·harmonyos
daopuyun1 小时前
LoadRunner小贴士|开发Web-HTTP/HTML协议HTML5相关视频应用测试脚本的方法
前端·http·html
李先静1 小时前
AWTK-WEB 快速入门(1) - C 语言应用程序
c语言·开发语言·前端
MR·Feng1 小时前
使用Electron将vue2项目打包为桌面exe安装包
前端·javascript·electron
萧大侠jdeps1 小时前
图片生成视频-右进
前端·javascript·音视频