无界微前端从踩坑到突围

2,150 阅读7分钟

前期框架调研

qiankun VS wujie VS micro-app

参考文档

官网

wujie-micro.github.io/doc/

github仓库

github.com/Tencent/wuj…

无界Polyfill

(为无界微前端提供更好的场景兼容方案)

wujie-polyfill.github.io/doc/plugins…

基础设置

父应用配置

参数介绍

name: string;

唯一性用户必须保证

url: string;

需要渲染的url

html?: string;

需要渲染的html, 如果用户已有则无需从url请求

props?: { [key: string]: any };

注入给子应用的数据

attrs?: { [key: string]: any };

自定义运行iframe的属性

degradeAttrs?: { [key: string]: any };

自定义降级渲染iframe的属性

replace?: (

code

:

string

) => string;

代码替换钩子

fetch?: (

input

:

RequestInfo

,

init

?:

RequestInit

) =>

Promise

;

自定义fetch,资源和接口

alive?: boolean;

子应用保活模式,state不会丢失

exec?: boolean;

预执行模式

fiber?: boolean;

js采用fiber模式执行

degrade?: boolean;

子应用采用降级iframe方案

plugins: Array;

子应用插件

beforeLoad?: lifecycle;

子应用生命周期

beforeMount?: lifecycle;

afterMount?: lifecycle;

beforeUnmount?: lifecycle;

afterUnmount?: lifecycle;

没有做生命周期改造的子应用不会调用

activated?: lifecycle;

deactivated?: lifecycle;

非保活应用不会调用

loadError?: loadErrorHandler

子应用资源加载失败后调用

main.ts配置

微前端-无界实践 此为简易版整理

import WujieVue from 'wujie-vue3'; // 无界基于vue框架组件封装,父应用是vue3就用wujie-vue3,父应用是vue2就用wujie-vue2
import lifecycles from './lifecycle'; // 子应用生命周期
import plugins from './plugin'; // 子应用插件
const { setupApp, preloadApp, bus } = WujieVue; 

// 在 xxx-sub 路由下子应用将激活路由同步给主应用,主应用跳转对应路由高亮菜单栏
bus.$on('sub-route-change', (subName, { fullPath, hash, matched, meta, name, params, path, query }) => {
    const currentPath = router.currentRoute.value.href;
    if (subName + path !== currentPath.split('?')[0]) {
        router.push({ path, params, query });
    }
});

bus.$on('logOutTip', () => {
    // 401的情况只有【登录页面】按钮,且提示为【当前登录已过期】,子应用过来的情况 目前只有登录状态失效的情况
    ElMessageBox.confirm('当前登录已过期', '提示', {
        confirmButtonText: '登录页面',
        showClose: false,
        showCancelButton: false,
        closeOnClickModal: false,
        type: 'warning',
    }).then(() => {
        window.location.href = import.meta.env.VITE_ADMIN_URL;
    });
});

bus.$on('logOut', () => {
    window.location.href = import.meta.env.VITE_ADMIN_URL;
});

const attrs = {};

setupApp({
    name: 'bskpt-admin', // 唯一性用户必须保证
    url: import.meta.env.VITE_CHILD_ALL + '/', // 需要渲染的url
    attrs,
    exec: true, // 预执行模式
    props,
    // fetch: credentialsFetch,
    plugins,
    prefix: { 'prefix-dialog': '/dialog', 'prefix-location': '/location' },
    degrade,
    ...lifecycles,
});

if (window.localStorage.getItem('preload') !== 'false') {
    preloadApp({
        name: 'bskpt-admin',
    });
}

app.use(WujieVue).use(router).use(i18n).mount('#app');

src/lifecycle.ts

import { setMicroAppLoading } from '@/utils/microAppLoading';
const lifecycles = {
    beforeLoad: (appWindow: any) => console.info(`${appWindow.__WUJIE.id} beforeLoad 生命周期`),
    beforeMount: (appWindow: any) => console.info(`${appWindow.__WUJIE.id} beforeMount 生命周期`),
    afterMount: () => setMicroAppLoading(false),
    beforeUnmount: (appWindow: any) => console.info(`${appWindow.__WUJIE.id} beforeUnmount 生命周期`),
    afterUnmount: (appWindow: any) => console.info(`${appWindow.__WUJIE.id} afterUnmount 生命周期`),
    activated: (appWindow: any) => console.info(`${appWindow.__WUJIE.id} activated 生命周期`),
    deactivated: (appWindow: any) => console.info(`${appWindow.__WUJIE.id} deactivated 生命周期`),
    loadError: (url: string, e: any) => console.info(`${url} 加载失败`, e),
};

export default lifecycles;

环境变量

// .env.development
VITE_CHILD_ALL = '//localhost:9091/bskpt-admin/sub'

// .env.dev
VITE_CHILD_ALL = '//common.hlestudy.com:17006/bskpt-admin/sub'

在父应用使用一个vue页面承载子应用

src/views/Sub/index.vue

<template>
    <!-- 单例模式,name相同则复用一个无界实例,改变url则子应用重新渲染实例到对应路由 -->
    <WujieVue width="100%" height="100%" name="bskpt-admin" :url="vue2Url" :props="{ token, rules, userInfo }"></WujieVue>
</template>

<script setup lang="ts">
import { useRoute } from 'vue-router';
import { getToken } from '@/core/auth';
import { useUserStore } from '@/store/modules/user';

const queryParams = useRoute().query;
const queryString =
    Object.keys(queryParams).length !== 0
        ? '?' +
          Object.keys(queryParams)
                .map((key) => encodeURIComponent(key) + '=' + encodeURIComponent(queryParams[key]))
                .join('&')
        : '';

const vue2Url = computed(() => import.meta.env.VITE_CHILD_ALL + useRoute().path + queryString);
const token = getToken();
const userStore = useUserStore();
const rules = userStore.rulesPermission;
const userInfo = userStore.userInfo;
</script>

动态路由

src/store/modules/permission.ts

const Sub = () => import('@/views/Sub/index.vue');

const filterAsyncRoutes = (routes: RouteRecordRaw[], menuPermission: string[], depth: number, parentPath: string) => {
    const depthWithPermission = [1, 2];
    const asyncRoutes: RouteRecordRaw[] = [];
    depth++;
    routes.forEach((route) => {
        const tmpRoute = { ...route, depth, parentPath }; // ES6扩展运算符复制新对象
        tmpRoute.meta = {};

        // 判断用户(角色)是否有该路由的访问权限
        if (
            (hasPermission(menuPermission, tmpRoute) && depthWithPermission.includes(depth)) ||
            !depthWithPermission.includes(depth)
        ) {
            tmpRoute.path = '/' + route.url.replace(/(^\/*)|(\/*$)/g, '');
            tmpRoute.meta.title = route.menuName;
            tmpRoute.meta.hidden = !depthWithPermission.includes(depth - 1);
            if (tmpRoute.component) {
                if (tmpRoute.component?.toString() == 'Layout') {
                    tmpRoute.component = Layout;
                } else {
                    const component = modules[`../../views/${tmpRoute.component}.vue`];
                    if (component) {
                        tmpRoute.component = component;
                    } else {
                        tmpRoute.component = Sub;
                    }
                }
            } else {
                const component = returnComponent(tmpRoute.path, router.options.routes);
                if (component) {
                    tmpRoute.component = component;
                } else {
                    tmpRoute.component = Sub;
                }
            }
            if (tmpRoute.parentMenuId === -1) {
                tmpRoute.component = Layout;
            }
            if (tmpRoute.nodes) {
                tmpRoute.nodes = filterAsyncRoutes(tmpRoute.nodes, menuPermission, depth, tmpRoute.path);
            }
            tmpRoute.children = tmpRoute.nodes;
            asyncRoutes.push(tmpRoute);
        }
    });
    return asyncRoutes;
};

子应用配置

main.js配置

if (window.__POWERED_BY_WUJIE__) {
  let instance;
  window.__WUJIE_MOUNT = () => {
    document.documentElement.classList.add("isFormIframe");
    instance = new Vue({
      beforeCreate() {
        Vue.prototype.$bus = this;
      },
      router,
      store,
      render: (h) => h(App),
    }).$mount("#app");
    console.log(window.$wujie?.props);
    if (window.$wujie?.props.token) {
      setToken(window.$wujie?.props.token);
    } else {
      setToken(localStorage.getItem("Saas_Token"));
    }
    if (window.$wujie?.props.rules) {
      store.commit("setRules", window.$wujie?.props.rules);
    }
    if (window.$wujie?.props.userInfo) {
      store.commit("setUserInfo", window.$wujie?.props.userInfo);
    }
  };
  window.__WUJIE_UNMOUNT = () => {
    // 因为elementUI没有抛出messageBox的实例,暂时只能通过dom判断;如果不判断,当实例不存在的时候,doClose() 会找不到实例,从而报错阻塞进程
    const messageBoxDiv = document.getElementsByClassName("el-message-box");
    if (messageBoxDiv.length) {
      MessageBox.close();
    }
    instance.$destroy();
  };
} else {
  new Vue({
    beforeCreate() {
      Vue.prototype.$bus = this;
    },
    router,
    store,
    render: (h) => h(App),
  }).$mount("#app");
}

App.vue配置

 watch: {
    // 在 vue2-sub 路由下主动告知主应用路由跳转,主应用也跳到相应路由高亮菜单栏
    $route() {
      const { fullPath, hash, matched, meta, name, params, path, query } =
        this.$route;
      window.$wujie?.bus.$emit("sub-route-change", "bskpt-admin", {
        fullPath,
        hash,
        matched,
        meta,
        name,
        params,
        path,
        query,
      });
    },
  },

src/router/index.js

  base: process.env.BASE_URL, // BASE_URL = "/bskpt-admin/sub/"

vue.config.js

  publicPath: process.env.BASE_URL, // BASE_URL = "/bskpt-admin/sub/"

  devServer: {
    headers: {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Credentials': true,
      "Access-Control-Allow-Headers": "X-Requested-With,Content-Type",
      "Access-Control-Allow-Methods": "PUT,POST,GET,DELETE,OPTIONS",
      "Content-Type": "application/json; charset=utf-8",
    },
}

Access-Control-Allow-Origin

Access-Control-Allow-Origin 响应标头指定了该响应的资源是否被允许与给定的来源(origin)共享。

Access-Control-Allow-Origin: *

服务器允许任何来源的请求访问其资源

Access-Control-Allow-Origin:

可以设置为一个具体的源(origin),以指定允许访问资源的来源。<origin> 在这里应该被替换为实际的源(域名、协议和端口),例如 https://example.com

也可以配置为一个逗号分隔的多个来源(但实际还是一个来源)

Access-Control-Allow-Origin: null

备注: null 不应该被使用:“返回 Access-Control-Allow-Origin: "null" 似乎是安全的,但任何使用非分级协议(如 data:file:)的资源和沙盒文件的 Origin 的序列化都被定义为‘null’。许多用户代理将授予这类文件对带有 Access-Control-Allow-Origin: "null" 头的响应的访问权,而且任何源都可以用 null 源创建一个恶意文件。因此,应该避免将 ACAO 标头设置为‘null’值。”

Access-Control-Allow-Credentials

用于在请求要求包含 credentials([Request.credentials](https://developer.mozilla.org/zh-CN/docs/Web/API/Request/credentials) 的值为 include)时,告知浏览器是否可以将对请求的响应暴露给前端 JavaScript 代码。

Access-Control-Allow-Credentials: true

这个头的唯一有效值(区分大小写)。如果不需要 credentials,相比将其设为 false,请直接忽视这个头。

Access-Control-Allow-Headers

列出了将会在正式请求的 [Access-Control-Request-Headers](https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Access-Control-Request-Headers) 字段中出现的首部信息。

Access-Control-Allow-Methods

应答中明确了客户端所要访问的资源允许使用的方法或方法列表。

Content-Type

Content-Type 实体头部用于指示资源的 MIME 类型 media type

在响应中,Content-Type 标头告诉客户端实际返回的内容的内容类型。浏览器会在某些情况下进行 MIME 查找,并不一定遵循此标题的值; 为了防止这种行为,可以将标题 [X-Content-Type-Options](https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/X-Content-Type-Options) 设置为 nosniff。

src/core/sso.js

import url from "url";
import { setToken, setLoginFrom } from "./auth.js";
// 单点登录
// 入口地址查询
try {
  const pathReg = /\/sso\/auth$/;
  // 符合单点登录授权地址
  const data = url.parse(location.href, true);
  if (
    pathReg.test(location.pathname) &&
    data.query &&
    data.query.Authorization
  ) {
    const query = Object.assign({}, data.query);
    delete query.Authorization;
    window.location.search = url.format({
      query,
    }); // 删除query参数
    if (data.query && data.query.Authorization)
      setToken(data.query.Authorization);

    if (data.query && data.query.loginFrom) setLoginFrom(data.query.loginFrom);
  }
} catch (error) {
  console.error(error);
}

功能适配

  1. 去掉原先的header、sidebar

  2. 基础接口由父应用调用,子应用接收父应用传值

    1. 获取用户信息 getUserInfo

    2. 获取用户菜单 findAuthByUserId

    3. 菜单列表 listInit

    4. 获取按钮权限 getInterfaceById

  3. 公共功能提取,例如:header、sidebar、消息通知

  4. 登录逻辑改造

踩坑点

父应用

url什么时候要加"/",什么时候不加"/"

图1为setupApp配置,加"/"

子应用加载过慢,添加一个loading

  1. src/utils/microAppLoading.ts

    import { ref } from 'vue';

    export let microAppLoading = ref(false);

    export function setMicroAppLoading(loading: boolean) { microAppLoading.value = loading; }

  2. src/layout/index.vue

    import { microAppLoading } from '@/utils/microAppLoading';

  3. src/permission.ts

    import { setMicroAppLoading } from '@/utils/microAppLoading'; router.afterEach((to) => { ... if (to.path.replace(/(^/)|(/$)/g, '') === 'home') { // 如果是home,就直接关掉loading,否则走到子应用,再去关闭。当前只有home,所以仅判断home,后期多个静态路由时,应该增加判断 setMicroAppLoading(false); } });

  4. src/lifecycle.ts

    import { setMicroAppLoading } from '@/utils/microAppLoading'; const lifecycles = { beforeLoad: (appWindow: any) => console.info(${appWindow.__WUJIE.id} beforeLoad 生命周期), beforeMount: (appWindow: any) => console.info(${appWindow.__WUJIE.id} beforeMount 生命周期), afterMount: () => setMicroAppLoading(false), beforeUnmount: (appWindow: any) => console.info(${appWindow.__WUJIE.id} beforeUnmount 生命周期), afterUnmount: (appWindow: any) => console.info(${appWindow.__WUJIE.id} afterUnmount 生命周期), activated: (appWindow: any) => console.info(${appWindow.__WUJIE.id} activated 生命周期), deactivated: (appWindow: any) => console.info(${appWindow.__WUJIE.id} deactivated 生命周期), loadError: (url: string, e: any) => console.info(${url} 加载失败, e), };

    export default lifecycles;

插件修复了wujie框架下UI事件由子应用传递时,target会指向到WUJIE-APP标签问题。当前现象:子应用elementui 使用远程搜索时,非中文和退格,参数为undefined

package.json

"wujie-polyfill": "^1.0.6"

src/plugin.ts

import { EventTargetPlugin } from 'wujie-polyfill';
const plugins = [
        EventTargetPlugin(),
        ...
]

重定向路由只能在最后面加(否则接口还没获取到的时候就跳转到home了),所以加在了动态路由那边

 if (menuPermission?.data) {
        userStore.setMenus(menuPermission?.data);
        const accessRoutes = await permissionStore.generateRoutes(menuPermission.data);
        accessRoutes.forEach((route) => {
            router.addRoute(route);
        });
        router.addRoute({
            path: '/:pathMatch(.*)*',
            redirect: '/home',
            meta: { hidden: true },
        });
        next({ ...to, replace: true });
    }

向子应用传递query

src/views/Sub/index.vue

<template>
    <!-- 单例模式,name相同则复用一个无界实例,改变url则子应用重新渲染实例到对应路由 -->
    <WujieVue width="100%" height="100%" name="bskpt-admin" :url="vue2Url" :props="{ token, rules, userInfo }"></WujieVue>
</template>

<script setup lang="ts">
...
const vue2Url = computed(() => import.meta.env.VITE_CHILD_ALL + useRoute().path + queryString);
...
</script>

单例模式下,子应用svg加载问题

在单例模式下,子应用的 js 只会运行一次,如果采用 svg symbol 方案作为 icon,在第一次渲染会挂载到子应用的 body 上面,但是当子应用发生切换再次切换回来,会将上一次渲染的dom全部都丢弃,导致 svg symbol 全部丢失,从而 icon 渲染不出来

无界内部其实会将部分元素存储在 styleSheetElements 数组中,主要是动态产生的样式,这个不能丢弃,等到子应用再次被渲染时,又将 styleSheetElements 数组中的元素重新插入到元素里面

此时我们可以 styleSheetElements 将 svg symbol 也存储进去,等到下次渲染的时候会从数组中拿出来重新渲染,这样 icon 就可以渲染出来了

  plugins: [
    {
      appendOrInsertElementHook(element, iframeWindow) {
        if (
          element.nodeName === "svg" &&
          (element.getAttribute("aria-hidden") === "true" ||
            element.style.display === "none" ||
            element.style.visibility === "hidden" ||
            (element.style.height === "0px" && element.style.width === "0px"))
        ) {
          iframeWindow.__WUJIE.styleSheetElements.push(element);
        }
      },
    },
  ],

子应用监听document的一些操作 无法执行

解决方案

src/plugin.ts

documentAddEventListenerHook(iframeWindow: any, type: any, handler: any, options: any) {
        options = {
                capture: false,
        };
        // 为了解决clickoutside指令在父应用不生效的bug
        // 副作用:会劫持所有的mouseup、mousedown事件,包括业务代码,如果后续不想被劫持,可在options单独传递参数self: true用来防止被劫持
        if (['mouseup', 'mousedown'].includes(type) && !(isObject(options) && options?.self)) {
                document.addEventListener(type, handler, options);
        }
},
documentRemoveEventListenerHook(iframeWindow: any, type: any, handler: any, options: any) {
        if (['mouseup', 'mousedown'].includes(type)) {
                document.removeEventListener(type, handler, options);
        }
},

复现场景

  1. 子应用使用elementui el-cascader

  2. 截图1中展开了级联选择器

  3. 菜单从截图1切换到截图2,截图2中没有级联选择器,级联选择器仍然存在(认为不应该存在)

截图3可以看出el-cascader使用了指令clickoutside 截图4可以看出clickoutside指令监听了document,认为是document监听问题

子应用

message这个东西有副作用,子应用每次切换都会将dom全部销毁,但是message默认渲染的dom不会消失,所以需要在销毁子应用的时候主动调用message的销毁函数告诉message要重新渲染容器

src/main.js

  window.__WUJIE_UNMOUNT = () => {
    // 因为elementUI没有抛出messageBox的实例,暂时只能通过dom判断;如果不判断,当实例不存在的时候,doClose() 会找不到实例,从而报错阻塞进程
    const messageBoxDiv = document.getElementsByClassName("el-message-box");
    if (messageBoxDiv.length) {
      MessageBox.close();
    }
  };

src/core/permission.js

  if (getToken()) {
    await store
      .dispatch("loadBaseData")
      .then(() => {
        next();
      })
      .catch(async (error) => {
        const isExpired = error?.code === 401;
        await MessageBox.confirm(
          !isExpired ? "初始数据加载失败!" : "当前登录已过期",
          "提示",
          {
            confirmButtonText: "登录页面",
            cancelButtonText: "重新加载数据",
            showClose: false,
            showCancelButton: !isExpired,
            closeOnClickModal: false,
            type: "warning",
          }
        )
          .then(() => {
            if (window.__POWERED_BY_WUJIE__) {
              window.$wujie?.bus.$emit("logOut");
            } else {
              store.dispatch("logout").then(() => {
                next();
              });
            }
          })
          .catch(() => {
            window.location.reload();
            next();
          });
      });
  } 

待优化

单例模式切换成保活模式改造点

改造原因

需求期望部分子应用路由需要缓存(keep-alive),但在单例模式下,切换路由子应用会经历以下阶段:销毁当前应用实例 => 同步新路由 => 创建新应用实例,意味着每次都是新实例,keep-alive对子应用无效果

在保活模式下,子应用内部的数据和路由的状态不会随着页面切换而丢失,经测试,在父应用中添加keep-alive对子应用的webComponent无效,在子应用内部添加keep-alive可以完成缓存效果

改造步骤

父应用

  1. setupApp中将添加保活模式配置项alive: true

  2. 父应用不使用keep-alive组件,同时去除路由动态组件的key字段,避免每次路由切换后动态组件重载

子应用

  1. 子应用中添加keep-alive,同时获取缓存的路由Name(路由组件需要添加name,确保组件和路由表中的name保持一致)添加至keep-alive的include属性中

子应用加载时机

原先在父应用中setupApp是在main.js中加载的,会出现父应用用户信息接口还没调用完成,但子应用页面已经预加载的情况。调整方案如下:

  1. 将setupApp位置调整至父应用的路由守卫中,在等待用户信息接口调用完成后才执行setupApp

  2. 在WujieVue组件中也传入props,包含用户信息,路由信息等

路由多次push

保活模式下,父子路由同步方面的功能无界并没有帮我们实现,会出现同一个页面触发了两次push(父应用和子应用都会push),点击浏览器返回上一步无效,需要点击两次

由于两次push,我们只需将子应用的路由push全部改为replace即可解决

父子路由流程复杂

原监听事件双向触发,排查问题困难,因此改造路由,路由变更都由父应用触发,使路由流程变为单向

暂时无法在飞书文档外展示此内容

  1. 劫持子应用的push和replace,使其直接触发父应用的push或replac事件,同时将原子应用的push和replace事件改名为originalPush和originalReplace挂载至子应用路由原型对象上,以便后续使用

  2. 由于在保活模式下,父子应用的路由无界没有帮我们做到一致,需要自行处理。因此在Sub组件中对路由做监听,并触发admin-route-change事件

  3. 在子应用App.vue中对admin-route-change事件做监听,并调用originalReplace真正修改子应用路由。注意:这里不能调用originalPush,否则浏览器返回上一步需要点击两次才有效

  4. 处理页面刷新的情况,在子应用的window.__WUJIE_MOUNT中手动触发originalReplace事件,将子应用路由切换到当前路由