qiankun?这次我选了wujie!

写在最前:

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

前言

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

背景说明

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

A: Vue2 + Webpack4 + [email protected]:该项目是部门内"司龄"最长的,从部门成立之初起,所有的业务都堆在里边。

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

C: Vue3 + Vite2 + [email protected]:为了响应领导"统一前端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 + [email protected](没错,和系统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,但我这边使用[email protected]的项目并不是弹出位置不正确,而是弹出方向不对,只能暂时通过调整组件位置+修改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)微前端落地方案分享】,后来才改成现在这个名字,原因有二:

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

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

相关推荐
zhougl99642 分钟前
html处理Base文件流
linux·前端·html
花花鱼1 小时前
node-modules-inspector 可视化node_modules
前端·javascript·vue.js
HBR666_1 小时前
marked库(高效将 Markdown 转换为 HTML 的利器)
前端·markdown
careybobo2 小时前
海康摄像头通过Web插件进行预览播放和控制
前端
杉之4 小时前
常见前端GET请求以及对应的Spring后端接收接口写法
java·前端·后端·spring·vue
喝拿铁写前端4 小时前
字段聚类,到底有什么用?——从系统混乱到结构认知的第一步
前端
再学一点就睡4 小时前
大文件上传之切片上传以及开发全流程之前端篇
前端·javascript
木木黄木木5 小时前
html5炫酷图片悬停效果实现详解
前端·html·html5
请来次降维打击!!!6 小时前
优选算法系列(5.位运算)
java·前端·c++·算法
難釋懷6 小时前
JavaScript基础-移动端常见特效
开发语言·前端·javascript