给vue-element-admin接入qiankun的微前端开发实践总结🍀

4,789 阅读30分钟

🌺前言

这是一篇给vue-element-admin接入qiankun的微前端学习文章。在本文中,我通过一个项目给大家展示如何给以vue-element-admin项目接入微前端应用,且在此基础上实现关于通信、面包屑等高级特性。下面列举一下此项目所包含的主应用和子应用:

  • 主应用: 基于 vue-element-admin的基础模板vue-admin-template开发。之所以用该模板,是因为本文重点在于讲述如何给单体项目实现微前端,而往往在接入之前需要先熟悉主应用的结构,这里挑选了很多人都熟悉的 vue-element-admin开发模板,能让大家更快上手熟悉主应用然后把思路集中在整个接入过程上。

  • 子应用: 本项目中的子应用有 4 个,分别用不同的技术栈开发,如下所示:

    1. Vue2App: 技术栈为Vue@2+VueRouter@3+Vuex@3,用vue-cli创建。此子应用有自己的路由规则,因此要带VueRouter@3

    2. ReactApp: 技术栈为React@18+Typescript+ReactRouter@6,用create-react-app@5创建。此子应用有自己的路由规则,因此要带ReactRouter@6

    3. Vue3App: 技术栈为Vue@3+Pinia+Typescript,用vue-cli创建,此处没用VueRouter@4有两个原因:

      • VueRouter@4对路由的处理与qiankun不兼容,在实践中冒出各种 bug,等官方版本更新后我会不断测试直至 bug 修复后才放出带路由的Vue3App
      • 子应用如果页面不受路由的变化而影响,其实是不需要ReactRouterVueRouter这类路由管理库的,这里弄一个无路由管理的子应用来做对比。
    4. HTMLApp: 技术栈为JQeury,纯HTML项目。开发中存在部分微应用是纯HTML页面或者是基于模板引擎如jspart-template渲染生成的HTML页面。

本文主要说微前端的接入和开发。关于部署环节的实践放到另一篇文章基于docker部署微前端项目的入门实践指南🐋里。

本文所展示的项目地址为micro-fe

感谢🙏:感谢掘金一周在11.03期把本文评为金选文章之一。

🍒关于微前端项目的仓库模式

本文项目为了方便大家阅览,把主应用子应用的代码都放在一个代码仓库里。但在实际开发中,建议主应用和每个子应用分别放在不同的仓库里。因为多仓库模式单仓库模式有以下优点:

  1. git独立

    • 分支管理相对简洁:不会出现多个分支分别对应主应用子应用的开发特性。
    • 提交记录不密集:因为只有一个应用的提交而不是所有应用的提交。
    • label不混乱:如果存在打上label的操作,则需要加上前缀去分辨不同应用,不然会出现多个相同的版本。
  2. CICD流水线独立:只有对应当前应用的流水线,不会出现多条对应不同应用的流水线。且如果使用单仓库模式,则流水线的触发条件需要更加复杂,不能出现一个应用更新触发流水线导致所有应用进入CICD环节。

🌻接入qiankun过程

在接入之前,我们要先确定如何加载子应用。qiankun提供了两种加载子应用的方式:

  1. registerMicroAppsstart:用registerMicroApps注册子应用,然后调用start开启监听url,根据url的变化来自动加载被注册的子应用。
  2. loadMicroApp:用loadMicroApp,自行判断时机手动加载子应用。

qiankun官网中优先推荐使用第一种加载方式。但是,由于本项目中主应用使用Vue,使用第一种加载方式会让Vue内置的KeepAlive组件和Transition组件(当mode=out-in)失效报错。且在本项目的写法中使用第一种加载模式在生产环境中会偶尔出现加载子应用失败的情况。因此在本项目中我们使用第二种加载方式loadMicroApp加载应用。本章节的主要内容分为两部分:

  1. 详细讲述使用loadMicroApp加载方式进行微前端接入
  2. 分析为什么registerMicroApps加载方式会让KeepAliveTransition(mode=out-in)失效报错 (可跳过)

🌱使用loadMicroApp加载方式进行微前端接入

接入过程从主应用子应用两个角度来讲述

主应用

1. 新建用于渲染子应用的组件 MicoAppLayout

该项目的子应用是通过二级菜单的点击显示出来的,如下所示:

二级菜单展示效果.gif

因此需要一个专门渲染子应用的组件MicroAppLayout,该组件里负责渲染应用基座(即挂载子应用的容器contaienr),其代码如下所示:

<template>
  <div :id="id" />
</template>

<script>
  import { loadMicroApp } from "qiankun";
  import { mapState } from "vuex";

  export default {
    props: {
      id: {
        required: true,
        type: String,
      },
    },
    data() {
      return {
        microApp: undefined,
      };
    },
    mounted() {
      // 在mounted时加载子应用,确保子应用挂载的节点已经渲染到页面上
      // this.$route.meta.microApp中含loadMicroApp的第一形成中需要的参数
      this.microApp = loadMicroApp(this.$route.meta.microApp, {
        sandbox: {
          experimentalStyleIsolation: true, // 采用实验性沙箱,该作用会在后面说到
        },
      });
    },
    beforeDestroy() {
      this.microApp.unmount();
    },
  };
</script>

2. 创建呈现子应用的路由 microAppRoutes

在创建VueRouter实例时,microAppRoutes会和项目的其他路由一起作为routes参数进行路由注册。

// master-app/src/router/modules/micro-app.js
import Layout from "@/layout";
import MicroAppLayout from "@/layout/MicroAppLayout";
import Vue from "vue";

const microAppRoutes = [
  // ReactApp子应用的路由信息
  {
    path: "/app-react",
    component: Layout,
    children: [
      {
        // 这里的path使用高级匹配模式,此处写法意味这任何以/app-react/index开头的URL都会让主应用加载出子应用
        // 因为React App子应用有自己的路由规则,例如子应用中有page-a的路由,
        // 当导航到子应用的page-a路由时,我们的页面URL为 "/app-react/index"(主应用路由)+"/page-a(子应用路由)"
        // 注意子应用的vue-router的版本要保证在3.6.5以上
        path: "index*",
        name: "AppReact",
        // 通过Vue.extend给渲染组件的name赋值。如果想用keep-alive,就必须给组件赋予不同的name值,
        // 因为keep-alive的原理是用纯对象通过KV方式缓存当前组件的VNode,而缓存的key值是当前组件的name
        component: Vue.extend({ ...MicroAppLayout, name: "AppReact" }),
        props: { id: "app-react" },
        meta: {
          title: "ReactTSApp",
          // meta.microApp传入loadMicroApp需要的参数
          microApp: {
            name: "react app",
            entry: "//localhost:3001",
            container: "#app-react",
            activeRule: "/app-react/index",
            props: {
              // 把对应的路由前缀传给子应用
              basepath: "/app-react/index",
            },
          },
          noCache: true,
          // 此处的menuPath用于指定二级菜单在点击后所导航到的路径。
          // vue-element-admin的二级菜单是根据routes信息来渲染的,而此处的path使用了高级匹配模式,如果不更改原有逻辑情况下点击二级菜单,当前路由会变成/app-react/index*,
          // 为避免上述情况,这里添加menuPath属性且改写了SiderBarItem的路径生成逻辑,如果存在该属性则优先使用该属性生成路径
          menuPath: "index",
          icon: "el-icon-coin",
        },
      },
    ],
  },
  // Vue2App子应用的路由信息
  {
    path: "/app-vue",
    component: Layout,
    children: [
      {
        path: "index*",
        name: "AppVue",
        component: Vue.extend({ ...MicroAppLayout, name: "AppVue" }),
        props: { id: "app-vue" },
        meta: {
          title: "VueApp",
          microApp: {
            name: "vue app",
            entry: "//localhost:3002",
            container: "#app-vue",
            activeRule: "/app-vue/index",
            props: {
              basepath: "/app-vue/index",
            },
          },
          noCache: true,
          menuPath: "index",
          icon: "el-icon-coin",
        },
      },
    ],
  },
  // Vue3App子应用的路由信息,由于Vue3App自身没有路由规则,因此path不需要用高级模式,直接index即可。也不需要menuPath。
  // 下面的HTMLApp同理。
  {
    path: "/app-vue3",
    component: Layout,
    children: [
      {
        path: "index",
        name: "AppVue3",
        component: Vue.extend({ ...MicroAppLayout, name: "AppVue3" }),
        props: { id: "app-vue3" },
        meta: {
          title: "Vue3App",
          microApp: {
            name: "vue3 app",
            entry: "//localhost:3004",
            container: "#app-vue3",
            activeRule: "/app-vue3/index",
            props: {},
          },
          icon: "el-icon-coin",
        },
      },
    ],
  },
  // HTMLApp子应用的路由信息
  {
    path: "/app-purehtml",
    component: Layout,
    children: [
      {
        path: "index",
        name: "AppPurehtml",
        component: Vue.extend({ ...MicroAppLayout, name: "AppPurehtml" }),
        props: { id: "app-purehtml" },
        meta: {
          title: "PureHTMLApp",
          microApp: {
            name: "purehtml app",
            entry: "//localhost:3003",
            container: "#app-purehtml",
            activeRule: "/app-purehtml/index",
            props: {},
          },
          icon: "el-icon-coin",
        },
      },
    ],
  },
];

export default microAppRoutes;

子应用

子应用的接入方式都大同小异,子应用可以查看项目源码去了解,反正和qiankun官方给出的接入例子差不多。这里拿ReactApp子应用来说以下子应用接入过程。目前官方只给出react@15react@16的接入方式,而 ReactApp子应用的react版本为18,在部分react@15react@16接入过程中用到的API在此版本里已失效,因此此处的接入方式与官方的不同,但可行。

1. 在 src 目录新增 public-path.js

if (window.__POWERED_BY_QIANKUN__) {
  // __webpack_public_path__ 相当于webpack配置文件中的output.publicPath,用于给index中的引入文件路径加公共路径前缀
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}

关于output.publicPath更多解释可看官网教程#outputpublicpath

2. 修改入口文件index.tsx

// 引入上一个步骤中创建得public-path.js
import "./public-path";
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { BrowserRouter } from "react-router-dom";

let root: ReactDOM.Root | null;

function render(props: any) {
  const { container, basepath } = props;
  if (container) {
    root = ReactDOM.createRoot(container.querySelector("#root"));
  } else {
    root = ReactDOM.createRoot(document.getElementById("root") as HTMLElement);
  }
  root.render(
    <React.StrictMode>
      {/* 使用history模式路由。basename使用从主应用里传进来得basepath。用window.__POWERED_BY_QIANKUN__判断当前应用是否通过qiankun渲染 */}
      <BrowserRouter basename={window.__POWERED_BY_QIANKUN__ ? basepath : "/"}>
        <App />
      </BrowserRouter>
    </React.StrictMode>
  );
}

if (!window.__POWERED_BY_QIANKUN__) {
  render({});
}

export async function mount(props: any) {
  render(props);
}

export async function unmount(props: any) {
  root!.unmount();
}

3. 修改webpack配置

因为项目是通过create-react-app(下称CRA)创建得,我们要看目前支持无需eject来修改CRA配置得插件有哪些:

  1. customize-cra:支持CRA2.x 和 3.x 版本
  2. react-app-rewired:支持CRA1.x(react-app-rewired@1.6.2)和 2.x 版本
  3. craco: 支持CRA4.x 版本
  4. @rescripts/cli: 官方没说明,可能是支持CRA所有版本,但实践中发现不支持修改CRA5.x 关于postcss配置

这里我们使用@rescripts/cli去修改。我们新建.rescriptsrc.js来调整webpack配置,内容如下所示:

const { name } = require("./package");
const path = require("path");

module.exports = [
  {
    webpack: (config) => {
      config.output.library = `${name}-[name]`;
      config.output.libraryTarget = "umd";
      // chunkLoadingGlobal为webpack5属性,如webpack4里的jsonpFunction
      config.output.chunkLoadingGlobal = `webpackJsonp_${name}`;
      config.output.globalObject = "window";
      config.resolve.alias = {
        "@": path.resolve(__dirname, "./src"),
      };
      return config;
    },

    devServer: (_) => {
      const config = _;

      config.headers = {
        "Access-Control-Allow-Origin": "*",
      };
      config.historyApiFallback = true;
      // hot是一个决定是否开启热更新的布尔量。
      // qiankun官网实例中hot为false。本项目中的子应用webpack.devServer配置中hot都置为true,是因为在开发阶段,我们需要通过热更新去调试子应用
      config.hot = true;
      config.liveReload = false;
      config.port = 3001;
      return config;
    },
  },
];

接入效果:

下面的依次点击二级菜单来显示接入的四个应用,如下所示:

接入效果.gif

以上代码可以在micro-fe的feature_loadmicroapp分支可看到。(因为这个项目一开始是用registerMicroApps加载方式来开发,因此loadMicroApp加载方式的分支不是主分支master)

🌱使用registerMicroAppsstart加载方式进行引入

对于该接入方式,本文就不详细描述了,有兴趣的可以点击查看本项目代码micro-fe-master。其实和 loadMicroApps加载应用的方式的主要区别在于MicroAppLayout.vue的写法不同。

🌱为什么registerMicroApps不支持KeepAliveTransition(mode=out-in)

本节为涉及到Vue源码的分析,没兴趣的可以跳过,不会影响对本文接下来的内容的阅读。

不支持KeepAlive组件的原因分析

众所周知,KeepAliveVue里用于缓存子组件实例的抽象组件,它能让子组件在二次渲染时,恢复之前的数据状态。这里简单说一下KeepAlive的原理,首先要知道Vue的页面渲染机制分为render阶段和update阶段:

  1. render阶段:先通过组件的render函数生成VNode实例(Vue中的虚拟节点)
  2. update阶段:如果是组件首次渲染,则根据VNode生成真正的DOM元素插入到父节点里,如果是组件二次更新渲染,则比较更新前后的VNode,根据对比不同来更新VNode对应的DOM元素

render阶段中,当KeepAlive首次渲染,其子组件对应的VNode会存储到KeepAlive内部,而VNode.componentInstance指向该子组件实例(Vue.prototype.$mount执行后返回的结果就是一个组件实例),而组件实例componentInstance.$el指向子组件实例对应的DOM

而当KeepAlive更新渲染时,KeepAlive会比较新VNode是否与缓存里的VNode一致,若一致,则直接把新VNodecomponentInstance指向缓存里的VNode.componentInstance。在update阶段时,在把VNode渲染成DOM时会判断VNode.componentInstance是否存在且VNode.data.keepAlive是否为真。若是则直接把VNode.componentInstance.$el,即缓存组件对应的DOM插入到父DOM上。

关于KeepAlive的原理更详细的描述可看Vue.js 技术揭秘-keep-alive

在使用qiankun的项目中,会有用KeepAlive缓存子应用状态的需求,但要运行成功必须满足两个条件:

  1. 子应用需要用loadMicroApps方式加载:在列举加载应用方式时,我们说过registerMicroApps这种加载方式会让应用随着路由变化而挂载销毁,这种机制注定了应用是不能存活在KeepAlive的内部缓存里的,因为当我们切换路由后。路由规则与当前路由不匹配的子应用会自动调用unmount销毁自身,尽管该子应用还在KeepAlive的内部缓存里。

  2. 子应用不能有路由管理插件:子应用不能带VueRouterReactRouter这类路由管理插件,因为这些插件也会监听路由的变化,即使存活在KeepAlive的内部缓存里。路由的变化还是会让子应用的视图发生变化,达不到缓存数据状态的效果。

本文项目中的Vue3AppHTMLApp子应用都满足上面的条件,因此可用KeepAlive缓存,大家可通过下面的动图查看效果:

keepalive效果.gif

在上面的交互中,分别在Vue3AppHTMLApp输入框中输入123456。在切换别的子应用后再切换回去时,输入框呈现的还是当初输入的内容。

不支持Transition组件(mode=out-in)的原因分析

Transition用于给元素或组件添加进入/离开过渡效果。接下来我们简单说一下Transition的工作原理。

image.png

从上图中的Enter阶段分析:

假设当前transition组件中name设为ani。则我们需要设置全局css样式如下所示:

.ani-enter {
  opacity: 0;
}

.ani-enter-active {
  transition: opacity 0.5s;
}

/* 其实ani-enter-to样式存在与否皆可 */
.ani-enter-to {
  opacity: 1;
}

接下来说一下整个Enter阶段中过渡动画的运行原理

  1. 一开始子元素或者子组件首次渲染时,会给DOM元素的class加上ani-enterani-enter-active样式
  2. 在上一步完成后,会通过window.requestAnimationFrame或者setTimeout(当前浏览器不支持raf时使用)跳到下一帧,且在下一帧中对DOM元素去掉ani-enter样式和加上ani-enter-to样式
  3. 在上一步完成后,会通过addEventListener监听DOM元素的transitionEnd事件,在transitionEnd事件触发后会移除ani-enter-activeani-enter-to样式。

Leave阶段与Enter阶段类似,只不过在最后一步中在移除样式后还会移除DOM节点。

transition组件不设置mode时,Enter阶段和Leave阶段同时进行。但如果设置了modeout-in后,会在旧DOMLeave阶段完成后,才开始进入新DOMEnter阶段。而modein-out则相反。

而如果我们的应用是在registerMicroApps注册,则当路由变化时,挂载子应用的MicroAppLayout组件因为out-in的过渡机制还没渲染到页面上(还处于在旧DOM的 Leave 阶段),此时子应用会找不到容器节点container而报错。而如果用loadMicroApp加载子应用,且是在组件mounted后才加载,则可避免出现找不到容器节点container而报错。

🌼微前端核心建议

在学会接入qiankun后,我们就要进入进阶阶段去学习更多涉及到微前端的高级特性,但在进入之前,我们要先学习一下关于微前端的五个比较核心的建议。这些建议出自 Micro Frontends in Action 的作者Michael Geers,原文链接在此micro-frontends。这些建议并不是硬性要求,但对于之后处理更复杂的涉及到微前端的需求时,这些建议值得我们去借鉴设计。

🌿1. 技术不可知主义

每个团队应该选择自己的技术栈以及技术进化路线,而不是与其他团队步调一致。

在给子应用选取技术栈时,不应该把兄弟子应用或主应用的技术栈作为考量的因素。选取技术栈时,主要考虑开发特性所需要的技术开发团队所熟悉的技术栈

🌿2. 隔离团队之间的代码

即便所有团队都使用同样的框架,也不要共享同一个运行时环境。构建自包含的 Apps。不要依赖共享的状态或者全局变量。

即使子应用之间或子主应用之间有相同的依赖,也不要抽取成一个公共的依赖库。很多开发者会想着这样子可以减少请求资源的体积以提高页面加载速度,但这样子会增加两个应用之间运行环境的耦合性,因为他们是共享同一个依赖库。

特别是用Vue作为技术栈的应用,更要注意上述问题。在Vue的应用里,我们会用Vue.use来注册插件,我们来看看use方法的源码 (这里展示Vue3的源码,因为Vue3的比较简洁,Vue2的这部分源码与Vue3的区别仅在于多了个installedPlugin数组来记录已注册的插件)

// packages\runtime-core\src\compat\global.ts
Vue.use = (p, ...options) => {
  if (p && isFunction(p.install)) {
    p.install(Vue as any, ...options);
  } else if (isFunction(p)) {
    p(Vue as any, ...options);
  }
  return Vue;
};

从上可知,use只是简单调用插件的install函数或插件自身,重点在于插件内部的代码逻辑。我们拿element-ui中涉及到install的源码来分析:

// src\index.js
const components = [
  Pagination,
  Dialog,
  Autocomplete,
  Dropdown,
  // ... 省略,所有组件就不一一展示出来了
];

const install = function (Vue, opts = {}) {
  components.forEach((component) => {
    Vue.component(component.name, component);
  });

  Vue.use(InfiniteScroll);
  Vue.use(Loading.directive);

  Vue.prototype.$ELEMENT = {
    size: opts.size || "",
    zIndex: opts.zIndex || 2000,
  };

  Vue.prototype.$loading = Loading.service;
  Vue.prototype.$msgbox = MessageBox;
  Vue.prototype.$alert = MessageBox.alert;
  Vue.prototype.$confirm = MessageBox.confirm;
  Vue.prototype.$prompt = MessageBox.prompt;
  Vue.prototype.$notify = Notification;
  Vue.prototype.$message = Message;
};

从上可知element-ui在被注册时,会调用Vue.component注册全局组件和对Vue.prototype新增属性。那么问题来了,如果有两个应用,他们依赖的element-ui版本不一致,而他们的vue因版本相同被提取成公共依赖包。 当两个应用相继加载时,随着不同版本的element-uiinstall函数被执行时,会存在诸如el-input这类全局组件以及Vue.prototype中的属性被最新注册的版本所覆盖的情况。此时就会出现先加载的子应用里的element-ui与开发中约定的版本不一致。

Vue的生态中有很多插件都是通过Vue.use来注册的,而也有相当一部分是通过Vue.prototype.$xx挂载到原型对象上的。如果两个应用使用公共的Vue,就有可能会出现诸如此类的全局组件或者全局属性被覆盖的情况。

因此,对于依赖,即使是相同库相同版本的依赖,也不要抽取出来当公共依赖库。因为无法保证全局变量和原型链属性是否不会被覆盖的情况。

🌿3. 建立团队自己的前缀

在还不能做到完全隔离的环境下,通过命名规约进行隔离。对于 CSS, 事件,Local Storage 以及 Cookies 之类的环境之下,通过命名空间进行的隔离可以避免冲突,以及所有权。

为了隔离应用之间的环境,qiankun设计了JS 沙箱样式沙箱,具体作用如下:

  • JS 沙箱:当子应用新增或更改window中的属性时,会对其操作进行记录。在单例模式下(默认为单例模式,除非显式指定singularfalse),在子应用销毁时,会把window的属性改为原来的属性,具体例子如下所示:

    // 1. 主应用加载时定义window.app
    window.app = "masterapp";
    
    // 2. 子应用读取window.app的值
    console.log(window.app); // 显示'master-app'
    
    // 3. 子应用A更改window.app的值
    window.app = "micro-a";
    console.log(window.app); // 'micro-a'
    
    // 3. 切换到子应用B且读取window.app的值时,window.app依旧是master-app
    console.log(window.user); // 'master-app'
    

    注意此机制只对window的一级属性生效,如果你是对window.Data.prototype.xx这类非一级属性进行新增更改,则不会生效。

  • 样式沙箱:当子应用存在创建 scriptlinkstyle 这三种 DOM 标签的创建时,会插入到该子应用的文档中,当子应用被销毁时,也会清除对应的这三种 DOM 标签。

关于这两种沙箱的更多分析可看这篇文章 qiankun 2.x 运行时沙箱 源码分析

但很明显的,这两种沙箱不能完全解决我们有关样式和公共变量冲突的情况。因此,应用内部最好用命名规约进行约束。

不过,样式约束除了命名规范外,我们还可以使用CSS In JSCSS Module进行样式隔离。在下面的 🌽进阶-🍃关于样式隔离中我会详细举例说明。

🌿4. 原生浏览器标准优先于框架封装的 API

使用用于通信的原生浏览器事件机制 ,而不是自己构建一个 PubSub 系统。如果确实需要设计一个跨团队的通信 API,那么也尽量让设计简单为好。

在通信方面最好使用js原生的API或者qiankun框架提供的API,这样子可以简化通信的逻辑以及减少开发者理解通信机制的时间成本。本文所展示的项目中,通信分两个方向进行不同技术的设计:

  • 主应用->子应用:使用qiankun提供的initGlobalStateaction.setGlobalStateonGlobalStateChange来处理。

  • 子应用->主应用:使用js原生API提供的dispatchEventCustomEventaddEventListener处理。

以上只是列举了所涉及到的技术点,关于通信的具体的实现方式在下面章节中的 🌽进阶-🍃主应用和子应用的通信中我会详细说明。

🌿5. 构建高可用的网络应用

即便在 Javascript 执行失败的情况下,站点的功能也应保证可用。使用同构渲染以及渐进增强来提升体验和性能。

不仅限于微前端中的每一个应用,即使是每一个成熟的面向用户的单体应用,都要做到上面引用中的要求。

引用中说到两点,这里分开进行说明:

  • 即便在 Javascript 执行失败的情况下,站点的功能也应保证可用。

    对于js执行失败的情况,我们要做到可以捕捉其抛出的错误,不让错误呈现到页面上(例如白屏)。通常我们会一并使用一下两种方式捕获全局错误:

    1. window.addEventListener('err',xxx)window.onerror:捕捉所有异步任务同步任务中抛出的错误,以及资源加载异常
    2. window.addEventListener('unhandledrejection',xxx):捕捉 Promise中的未处理的错误

    对于使用Vue框架的应用,除了上面两者我们还会使用 Vue.config.errorHandler,其用于捕捉所有Vue组件中生命周期函数v-on自定义事件v-on``DOM原生监听事件里冒出的错误。比起window.onerror,前者可以更加详细地显示错误是从哪个事件里抛出的。

    而对于使用React框架的应用,即使使用window.onerrorReact16中同步任务中错误的抛出还是会导致整个组件树的崩溃,从而让页面白屏。对此官方推荐我们会使用ErrorBoundary可以捕获发生在其子组件树任何位置的 JavaScript 错误,并打印这些错误,同时展示降级 UIReact没有提供现成的ErrorBoundary组件,我们可以参考官网对应的文章ErrorBoundary 错误边界自行编写,或者使用目前有现成的开源库react-error-boundaryErrorBoundary可以捕获所包裹的React组件树里的渲染生命周期函数构造函数中所冒出的错误,从而对这些错误进行UI降级显示。

  • 使用同构渲染以及渐进增强来提升体验和性能。

    如果要实现同构渲染,则要保证主应用和子应用都是用next.jsnuxt.js这类为同构渲染而生的脚手架创建出来的。

    对于同构渲染,在本文中所展示的项目里都不会使用到。原因是本文属于前端入门项目,如果在前端的基础上实现同构渲染,则加大本项目复杂度。而且本项目的主应用是用 vue-element-admin改造的,而 vue-element-admin本身是一个客户端渲染 CSR的项目,而不是用next.jsnuxt.js这类为同构渲染而生的脚手架创建出来的项目。

    渐进式增强指的是针对低版本浏览器进行构建页面,保证最基本的功能,然后再针对高级浏览器进行效果、交互等改进和追加功能达到更好的用户体验渐进式增强需要有足够的前端开发经验和相当的测试人力,这个要看每个开发团队的能力而行。

    渐进增强的重点在于,当你处于项目初期,在引入任何复杂的功能前你只使用最基本的 web 技术。这样在任何的情况下你都有支持保证更复杂功能运行的基础。一旦团队对网站的核心体验已经很有自信,并且在不依赖网速、浏览器、设备时也能运行,这时你就可以开始引入更加复杂的功能和布局。

🌽进阶

本章节讲述如何实现一些涉及到微前端的高级特性

🍃主应用和子应用的通信

在上一章节中的第四条核心建议中,提及到通信机制尽量使用原生API或框架提供的API,保持简单可靠。而在本项目里,子应用和主应用的通信中,我们分开两个方向去单独设计,如下所示:

image.png

  1. 主应用->子应用:使用qiankun提供的APIinitGlobalStatesetGlobalStateonGlobalStateChange。主应用通过initGlobalState初始化全局数据globalState,后通过setGlobalState设置全局数据globalState时,子应用通过onGlobalStateChange监听到数据变化,在回调函数中获取globalState

  2. 子应用->主应用:使用js原生提供的dispatchEventCustomEventaddEventListener。子应用通过window.dispatchEvent(new CustomEvent('micro-app-dispatch',{detail: action}))通知主应用,注意CustomEvent中第一形参'micro-app-dispatch'为事件名称,第二形参为事件配置,在detail里我们可以传自定义数据,这里传的action类似于Redux Store中的Action,格式为{type: string,payload: any}。然后主应用通过window.addEventListener('micro-app-dispatch',callback)监听到事件,在回调函数callback上读取事件中的detail,然后根据type来知道子应用想要主应用执行什么操作。

两个方向的通信本质上其实都是PubSub系统,以类似发布订阅模式来进行通知。这里不把两个方向的通信共用一个PubSub系统是为了防止数据流的混乱。

目前qiankunglobalState标记为下一个版本弃用,在控制台中我们可以看到下图的警告信息。如果不想使用globalState通信方式,那上述子应用->主应用的dispatchaddEventListener通信方式也可以替代globalState通信方式。

image.png

接下来我们通过项目代码,更细致地理解如何实现两个方向的通信。

主应用->子应用

举一个很常见的需要用到主应用->子应用通信的场景:通常网站有设置主题色的功能,那如果这个项目是一个微前端项目,那么在主应用设置主题色后,子应用的样式颜色也要对应改变。接下来,我们来看一下在本项目里怎么实现这个需求:

主应用

Vuex Storegetters里新增一个计算属性microAppStatemicroAppState包含主应用分享给子应用的全局状态。

// master-app/src/store/getters.js
const getters = {
  //... 省略无关代码

  // 新增microAppState计算属性
  microAppState: (state) => ({
    // theme为主应用的主题颜色值
    theme: state.settings.theme,
  }),
};
export default getters;

初始化把microAppState作为globalState全局状态的初始值。对其值进行深度监听,有变化时通过actions.setGlobalState传给子应用。把这些通信操作统一放在一个mixin上。

// master-app/src/mixin/micro-app.js
const actions = initGlobalState(store.getters.microAppState);

export default {
  computed: {
    ...mapGetters(["microAppState"]),
  },
  watch: {
    microAppState: {
      handler(val) {
        actions.setGlobalState({
          ...val,
        });
      },
      deep: true,
    },
  },
};

把上面的mixin引入到主文件中,名为microAppMixin。混入到Vue根组件里。

// master-app/src/main.js
import microAppMixin from "@/mixin/micro-app";

new Vue({
  el: "#app",
  name: "MasterApp",
  router,
  store,
  mixins: [microAppMixin],
  render: (h) => h(App),
});

在下面所有子应用的mount方法中,调用props.onGlobalStateChange(callback,true)监听接受全局状态,此处的第二形参为true意味着立即触发 callback

Vue2App

// sub-app/vue-app/src/main.js
export async function mount(props) {
  props.onGlobalStateChange((state) => {
    // 把主题色存到Vue2App子应用的Vuex Store里
    store.dispatch("app/changeTheme", state.theme);
  }, true);
  render(props);
}

ReactApp

// sub-app/react-ts-app/src/index.tsx
export async function mount(props: any) {
  props.onGlobalStateChange((state: any) => {
    // 把主题色存到ReactApp子应用的Redux Store里
    store.dispatch(appActions.updateTheme(state.theme));
  }, true);
  render(props);
}

Vue3App

export async function mount(props: any) {
  render(props);
  props.onGlobalStateChange((state: any) => {
    // 把主题色存到Vue3App子应用的Pinia Store里
    const app = useAppStore();
    app.changeTheme(state.theme);
  });
}

下面来看看切换主题色时,每个子应用的效果:

Vue2App

vue2app-主题色.gif

ReactApp

reactApp-主题色.gif

Vue3App

vue3App-主题色.gif

至此,主应用->子应用方向的通信已实现。更多细节可查看项目代码。

子应用->主应用

下面举两个很常见的需要用到子应用->主应用通信的场景:

  • 场景一: 子应用在交互中需要跳转到主应用别的页面里进行操作,此时需要通过通信告诉主应用,主应用来执行路由跳转逻辑。

    有些人会说可以直接用过window.history.pushState原生API更改路由来进行页面跳转,对此我不太建议这么做。在用到路由管理插件(VueRouterReactRouter)的项目里,我们尽量使用插件提供给我们的跳转API。而且,在使用ReactRouter的项目里,用window.history.pushState是不会导致页面响应式变化的。

  • 场景二: 子应用在发出请求时,后端判断请求中携带的token过期因此返回状态码 401。子应用获取 401 响应后,通过通信告诉主应用进行执行重新登录操作。

接下来我通过在项目里实现场景一这个需求:

主应用中,对上面曾经展示过的microAppMixin添加以下逻辑

// master-app\src\mixin\micro-app.js
const actions = initGlobalState(store.getters.microAppState);
// CustomEvent('micro-app-dispatch')事件的回调函数
const handleMicroAppDispatchEvent = (e) => {
  // 取出event.detail,根据type和payload来处理
  const { detail: action } = e;
  switch (action.type) {
    case "CHANGE_ROUTE":
      router.push(action.payload);
      break;
    default:
      break;
  }
};

export default {
  computed: {
    ...mapGetters(["microAppState"]),
  },
  created() {
    this.listenMicroAppDispatchEvent();
  },
  methods: {
    listenMicroAppDispatchEvent() {
      // window监听micro-app-dispatch事件
      window.addEventListener(
        "micro-app-dispatch",
        handleMicroAppDispatchEvent
      );
      // 在Vue实例销毁时移除监听
      this.$once("beforeDestroy", () => {
        window.removeEventListener(handleMicroAppDispatchEvent);
      });
    },
  },
  watch: {
    microAppState: {
      handler(val) {
        actions.setGlobalState({
          ...val,
        });
      },
      deep: true,
    },
  },
};

子应用里,在需要跳转时调用以下函数即可:

const changeRoute = () => {
  window.dispatchEvent(
    new CustomEvent("micro-app-dispatch", {
      detail: {
        type: "CHANGE_ROUTE",
        // 这里写跳转到Vue2App应用的页面
        payload: "/app-vue/index",
      },
    })
  );
};

效果如下所示:

跳转页面效果.gif

至此,子应用->主应用方向的通信已实现。更多细节可查看项目。

🍃全局导航面包屑

在微前端项目中,实现一个同时包含主应用和子应用路由的全局导航面包屑是个常见的开发需求。本项目实现的思路是: 子应用路由变化时(包括子首次加载),把自身的路由匹配信息通过子应用->主应用通信发给主应用,主应用存放到Vuex Store里,同时全局导航面包屑会读取Vuex Store里的路由信息来响应式生成面包屑组件。接下来从代码层面详细说一下是怎么实现的:

首先,在主应用的Vuex Store里新增一个状态用于存放子应用的路由信息:

// master-app/src/store/modules/microApp.js
const state = {
  // 用于存放子应用的路由信息
  routes: [],
};

const mutations = {
  SET_ROUTES: (state, routes) => {
    state.routes = routes;
  },
};

const actions = {
  updateRoutes({ commit }, routes) {
    commit("SET_ROUTES", routes);
  },
};

export default {
  namespaced: true,
  state,
  mutations,
  actions,
};

在处理CustomEvent('micro-app-dispatch')事件的回调函数里新增对更新子应用路由信息的处理逻辑

const handleMicroAppDispatchEvent = (e) => {
  const { detail: action } = e;
  switch (action.type) {
    case "CHANGE_ROUTE":
      router.push(action.payload);
      break;
    // 新增逻辑
    case "UPDATE_ROUTES":
      // 子应用路由信息会放在detail.payload里,和Redux Action一样的数据格式
      store.dispatch("microApp/updateRoutes", action.payload);
      break;
    default:
      break;
  }
};

编写全局导航面包屑,这里对 vue-element-admin中已存在Breadcrumb组件的代码进行一些修改,修处我都附带了注释,如下所示:

<!-- master-app/src/components/Breadcrumb/index.vue -->
<template>
  <el-breadcrumb class="app-breadcrumb" separator="/">
    <transition-group name="breadcrumb">
      <el-breadcrumb-item v-for="(item,index) in levelList" :key="item.path">
        <span
          v-if="item.redirect==='noRedirect'||index==levelList.length-1"
          class="no-redirect"
          >{{ item.meta.title }}</span
        >
        <a v-else @click.prevent="handleLink(item)">{{ item.meta.title }}</a>
      </el-breadcrumb-item>
    </transition-group>
  </el-breadcrumb>
</template>

<script>
  import pathToRegexp from "path-to-regexp";
  import { mapState } from "vuex";
  export default {
    data() {
      return {
        levelList: null,
      };
    },
    computed: {
      ...mapState({
        microAppRoutes: (state) => state.microApp.routes,
      }),
      // 声明一个包含主应用路由信息和子应用路由信息的计算属性,用watch进行监听
      routeIncludeMicroApp() {
        return [this.$route, this.microAppRoutes];
      },
    },
    watch: {
      // 当routeIncludeMicroApp发生变化时,更新levelList以响应式更新全局导航面包屑
      routeIncludeMicroApp: {
        handler() {
          this.getBreadcrumb();
        },
        immediate: true,
      },
    },
    methods: {
      getBreadcrumb() {
        let matched = this.$route.matched.filter(
          (item) =>
            item.meta && item.meta.title && item.meta.breadcrumb !== false
        );
        const first = matched[0];

        if (!this.isDashboard(first)) {
          matched = [
            { path: "/dashboard", meta: { title: "Dashboard" } },
          ].concat(matched);
        }

        this.levelList = matched
          .filter(
            (item) =>
              item.meta && item.meta.title && item.meta.breadcrumb !== false
          )
          // levelList在主应用路由下承接子应用的路由信息
          .concat(
            this.microAppRoutes
              .filter(
                (item) =>
                  item.meta && item.meta.title && item.meta.breadcrumb !== false
              )
              // 给子应用的路由信息打上microApp的布尔标记
              .map((item) => ({ ...item, microApp: true }))
          );
      },
      isDashboard(route) {
        const name = route && route.name;
        if (!name) {
          return false;
        }
        return (
          name.trim().toLocaleLowerCase() === "Dashboard".toLocaleLowerCase()
        );
      },
      pathCompile(path) {
        const { params } = this.$route;
        var toPath = pathToRegexp.compile(path);
        return toPath(params);
      },
      handleLink(item) {
        const { redirect, path, meta, parent, microApp } = item;
        // 只对主应用中带redirect路由进行处理,子应用带redirect的路由不处理
        if (!microApp && redirect) {
          this.$router.push(redirect);
          return;
        }
        // 对模糊匹配写法的路由进行处理
        if (meta.microApp && meta.menuPath) {
          const microAppPath = meta.menuPath.startsWith("/")
            ? meta.menuPath
            : (parent.path || "") + "/" + meta.menuPath;
          this.$router.push(microAppPath);
          return;
        }
        this.$router.push(this.pathCompile(path));
      },
    },
  };
</script>

接下来添加对子应用的路由的监听逻辑,如果变化则把路由匹配信息发给主应用:

Vue2App

这里把监听逻辑放到混入 mixinuploadRoutesMixin里,uploadRoutesMixin会混入到Vue根组件里。

// sub-app\vue-app\src\mixin\micro-app\upload-routes-mixin.js
// uploadRoutesMixin包含关于上传子应用路由信息的逻辑,在微前端项目里会混入到Vue2App的根组件里
const uploadRoutesMixin = {
    watch: {
      $route: {
        handler() {
          // 通过this.$route.matched获取匹配当前路由的路由信息
          const matched = this.$route.matched
            // .filter(item => item.meta && item.meta.title && item.meta.breadcrumb !== false)
            .map((item) => ({
              ...item,
              path: this.$router.options.base + item.path,
            }));
          // 通过通信发给主应用
          window.dispatchEvent(
            new CustomEvent("micro-app-dispatch", {
              detail: { type: "UPDATE_ROUTES", payload: matched },
            })
          );
        },
        immediate: true,
      },
    },
    // 在离开Vue2App子应用页面时,清空子应用路由信息
    beforeDestroy() {
      window.dispatchEvent(
        new CustomEvent("micro-app-dispatch", {
          detail: { type: "UPDATE_ROUTES", payload: [] },
        })
      );
    },
};

ReactApp

这里把监听逻辑放到自定义 hooksuseUploadRoutes里,useUploadRoutes会在入口组件App里被调用。

import { UNSAFE_NavigationContext } from 'react-router-dom';
import { matchRoutes, useLocation } from 'react-router-dom';
import { useContext, useEffect } from 'react';
import { routes } from '@/App';

export default function useUploadRoutes() {
  const { basename } = useContext(UNSAFE_NavigationContext);

  const location = useLocation();

  useEffect(() => {
    if (window.__POWERED_BY_QIANKUN__) {
      // 通过react-router提供的matchRoutes获取匹配路由信息
      const matched = matchRoutes(routes, location.pathname)!.map(({ route, pathname }) => ({
        path: basename + pathname,
        // @ts-ignore
        meta: route.meta,
      }));

      window.dispatchEvent(
        new CustomEvent('micro-app-dispatch', {
          detail: {
            type: 'UPDATE_ROUTES',
            payload: matched,
          },
        }),
      );
    }
  }, [basename, location.pathname]);

  // 在离开ReactApp子应用页面时,清空子应用路由信息
  useEffect(
    () => () => {
      window.dispatchEvent(
        new CustomEvent('micro-app-dispatch', {
          detail: {
            type: 'UPDATE_ROUTES',
            payload: [],
          },
        }),
      );
    },
    [],
  );
}

最后我们来看看效果:

Vue2App

Vue2App面包屑.gif

ReactApp

ReactApp面包屑.gif

至此,完成全局导航面包屑的特性。

🍃子应用加载中和加载失败的处理

在子应用加载时,我们要对子应用加载过程的动画效果以及加载失败后的效果做处理。接下来就说以下本项目对这两种情况的处理。

首先在主应用的Vuex Store里添加两个全局属性errorloading,分别代表子应用是否加载失败和是否加载中。

// master-app\src\store\modules\microApp.js
const state = {
  //  微应用是否加载中
  loading: false,
  // 微应用是否加载失败
  error: false,
  routes: []
}

const mutations = {
  SET_LOADING: (state, loading) => {
    state.loading = loading
  },
  SET_ERROR: (state, error) => {
    state.error = error
  },
  SET_ROUTES: (state, routes) => {
    state.routes = routes
  }
}

const actions = {
  changeLoading({ commit }, loading) {
    commit('SET_ERROR', false)
    commit('SET_LOADING', loading)
  },
  changeError({ commit }, error) {
    commit('SET_LOADING', false)
    commit('SET_ERROR', error)
  },
  updateRoutes({ commit }, routes) {
    commit('SET_ROUTES', routes)
  }
}

export default {
  namespaced: true,
  state,
  mutations,
  actions
}

然后对MicroAppLayout组件添加加载中和加载失败两种交互效果,如下所示:

<template>
  <div>
    <!-- 用element-ui中的v-loading作为加载中的效果 -->
    <div v-if="!error" :id="id" v-loading="loading" :class="{'loading-container':loading}" />
    <!-- 用element-ui中的el-result作为加载失败的效果 -->
    <el-result v-else icon="error" title="微应用加载失败" />
  </div>
</template>

<script>
export default {
  props: {
    // ...省略以前逻辑
  },
  computed: {
    // 从Vuex Store中导出这两种状态
    ...mapState({
      loading: state => state.microApp.loading,
      error: state => state.microApp.error
    })
  },
  mounted() {
    // ...省略以前逻辑
  }
}
</script>
<style lang="scss" scoped>
  .loading-container{
    height: 80vh;
  }
</style>

已知子应用有两种加载方式:registerMicroAppsloadMicroApp 。两种加载方式下对加载错误和正在加载的状态的处理不同。因此这里分开对两种加载子应用的方式进行实现。

registerMicroApps加载方式下实现加载中和加载失败的处理

对于加载失败的情况,可通过qiankun提供的APIaddErrorHandler来监听。该API其实是single-spa里的同名API,用于捕捉在子应用从加载到销毁的几个生命周期中(bootstrapmountupdateunmount)出现的错误,我们通过下面的写法来记录子应用加载是否失败:

import { addErrorHandler } from 'qiankun'

addErrorHandler((error) => {
  store.dispatch('microApp/changeError', true)
  // 该函数下捕捉到的错误将不再往上抛出,因此需要在控制台输出错误信息
  console.error(error)
})

对于正在加载的情况,可以通过registerMicroApps的第一形参中注册的每一个应用里添加loader属性,该属性在loading状态变化时会被调用执行,我们通过下面的写法来记录子应用是否正在加载中:

const loader = (loading) => {
  store.dispatch('microApp/changeLoading', loading)
}

registerMicroApps([
  {
    name: 'react app',
    entry: '//localhost:3001',
    container: '#app-react',
    // 加入loader
    loader,
    activeRule: '/app-react/index',
    props: {
      basepath: '/app-react/index'
    }
  },
  // ...别的应用就不一一列出
])

最后实现出来的效果如下所示:

应用正常加载的效果:

子应用加载效果.gif

注意此处的加载动画有两段,第一段转圈的动画是本节中实现的,第二段条状的动画是子应用本身带有的。为什么要存在两段加载动画呢?

首先要知道:上面效果里的三个子应用都是 SPA(单页面项目),我们要知道SPA单页面项目的一个比较大的缺点就是由于是 CSR(客户端渲染),因此需要加载一个较大的 js入口文件,因此会导致首次加载时白屏时间过长。

而第一段加载动画,是存在于子应用的HTML页面(即http://localhost:300x/index.html)的加载过程。但加载完HTML页面后还需要加载里面附带的 js入口文件,而此时就会呈现第二段加载动画,直至 js入口文件加载完成且执行完成后才结束。两个加载动画若缺一个,则会有短暂的白屏时间,降低体验效果。

为什么第一段加载动画不可以持续到 js入口文件加载完成且执行完成后才结束,在qiankun中提供的API中,无论是registerMicroApps里的loader,以及loadMicroApp里的mountPromise,都只能捕获子应用HTML页面的加载过程,不能捕捉HTML页面所附加的其他静态资源的加载过程。

而这里的第二段加载动画是直接写在html静态页面上的,如下所示:

<!DOCTYPE html>
<html lang="">
  <head>
    <!-- 省略其他DOM元素 -->
    <style type="text/css">
	#app .pic{
            width: 64px;
            height: 64px;
            position: absolute;
            top: 0;
            bottom: 0;
            left:0;
            right:0;
            margin: 40vh auto 0;
      }

        #app .pic i{
            display: block;
            float: left;
            width: 6px;
            height: 50px;
            background: rgb(24, 144, 255);
            margin: 0 2px;
            transform: scaleY(0.4);
            animation: load 1.2s infinite;
        }

        #app .pic i:nth-child(1){animation-delay:0.1s }
        #app .pic i:nth-child(2){animation-delay:0.2s }
        #app .pic i:nth-child(3){animation-delay:0.3s }
        #app .pic i:nth-child(4){animation-delay:0.4s}
        #app .pic i:nth-child(5){animation-delay:0.5s }

        @keyframes load{
            0%,40%,100%{transform: scaleY(0.4)}
            20%{transform:scaleY(1) }
        }
    </style>
  </head>
  <body>
    <div id="app">
        <div class="pic">
            <i></i>
            <i></i>
            <i></i>
            <i></i>
            <i></i>
        </div>
    </div>
  </body>
</html>

js入口文件执行时,会把div#appDOM元素作为挂载节点渲染页面内容,此时div#app里的呈现加载动画的div.picDOM元素就会被覆盖。

应用正常失败的效果:

子应用加载失败效果.gif

loadMicroApp加载方式下实现加载中和加载失败的处理

loadMicroApp加载方式下实现加载中和加载失败的处理比较简单,直接在MicroAppLayout组件的mounted生命周期函数上加逻辑,如下所示:

<script>
  mounted() {
    this.$store.dispatch('microApp/changeLoading', true)
    this.microApp = loadMicroApp(
      this.$route.meta.microApp,
      {
        sandbox: {
          experimentalStyleIsolation: true // 实验性沙箱
        }
      }
    )
    // 通过mountPromise来捕获子应用挂载结果
    this.microApp.mountPromise
      .then(()=>{
        this.$store.dispatch('microApp/changeLoading', false)
      })
      .catch(()=>{
        this.$store.dispatch('microApp/changeError', true)
      })
  },
}
</script>

效果和registerMicroApps加载方式下实现的一致,这里就不重复展示了。

🍃支持Vue-Devtool可对主子应用调试

以本项目为例,如果我们Vue-Devtools是可以调试主应用Vue3AppVue3Vue-Devtool支持比较好,无需我们手动优化),但不能调试Vue2App,如果想支持调试,则通过以下方式实现:

新建一个mixindevtoolEnhanceMixin,用于混入到Vue2App的根组件里,devtoolEnhanceMixin代码如下所示:

const devtoolEnhanceMixin = {
    data() {
      return {
        subDiv: undefined,
      };
    },
    mounted() {
      // 在body上创建一个div元素,并把其__vue__属性指向根组件自身
      // Vue-Devtool会扫描body的子元素上是否有__vue__属性,若有则纳入到devtool自身上
      if (process.env.NODE_ENV === "development") {
        this.subDiv = document.createElement("div");
        this.subDiv.__vue__ = this;
        document.body.appendChild(this.subDiv);
      }
    },
    beforeDestroy() {
      this.subDiv.__vue__ = null;
      document.body.removeChild(this.subDiv);
      this.subDiv = null;
    },
};

接下来我们看看效果,注意效果中Vue-devtools的版本为6.2.1

vue-devtools支持.gif

从上面效果中,我们可以看到可通过下拉框切换Vue应用,选项中应用的名字取值于Vue根组件的name

🍃关于样式隔离

样式隔离是指应用之间的样式不会互相影响。具体指:

  1. 子应用之间样式不互相影响
  2. 子应用的样式不会影响主应用的样式
  3. 主应用的样式不会影响子应用的样式

下面我们来分析一下常见的样式隔离方法是否可以实现上面三点以及其上手难度:

qiankunsandbox配置strictStyleIsolation

strictStyleIsolationtrue时,qiankun会把把子应用的HTML内容转变为Shadow DOM。如下所示:

image.png

Web components 的一个重要属性是封装——可以将标记结构、样式和行为隐藏起来,并与页面上的其他代码相隔离,保证不同的部分不会混在一起,可使代码更加干净、整洁。其中,Shadow DOM 接口是关键所在,它可以将一个隐藏的、独立的 DOM 附加到一个元素上。

Shadow DOM 允许将隐藏的 DOM 树附加到常规的 DOM 树中——它以 shadow root 节点为起始根节点,在这个根节点的下方,可以是任意元素,和普通的 DOM 元素一样。

image.png

关于Shadow DOM的更多分析可看深入理解Shadow DOM v1MDN 使用 shadow DOM

整个Shadow Tree中的样式不受外部样式的影响,而其自身的样式也不会影响到外部。因此满足隔离的三个条件。

但要注意的是,Shadow DOM是不允许被外部访问和操作(除非attachShadowmode设为open),因此一切以querySelector之类的查找DOM元素的行为和setAttributes之类的设置DOM属性的行为都不奏效。如果在子应用里的开发代码里存在上述操作,则会失效。

且在qiankun项目的控制台说了strictStyleIsolation会被废弃,因此不推荐使用。

image.png

结论:

子应用之间样式不互相影响: ✅

子应用的样式不会影响主应用的样式: ✅

主应用的样式不会影响子应用的样式: ✅

上手难度:简单😘

备注:需要注意子应用代码不能有查找设置内部DOM元素的行为,且strictStyleIsolation在下一个版本中会被废弃。

qiankunsandbox配置experimentalStyleIsolation

experimentalStyleIsolation 被设置为 true 时,qiankun 会对子应用中所有的样式统一加上一层命名空间。举下面的例子:

/* 当在ReactApp子应用中存在样式: */
.App-header {
  /* 省略属性 */
}

/* 经qiankun进行experimentalStyleIsolation处理后转变为: */
div[data-qiankun="react app"] .App-header {
  font-size: 14px;
}

其中div[data-qiankun="react app"]ReactApp子应用的HTML内容所挂载的父元素,如下所示

image.png

但这种处理并不能避免主应用的样式影响到子应用的样式,因为如果父应用存在同名的样式,则子应用的同名样式则会被干扰,如下所示:

/* 假设主应用存在以下样式 */
.app{
  color: red;
}

/* 如果子应用存在同名样式,即使有命名空间,使用该样式的DOM中color也会为red,除非覆盖color属性 */
.app{
  height: 100%;
  /* 覆盖color属性 */
  color: blue;
}

结论:

子应用之间样式不互相影响: ✅

子应用的样式不会影响主应用的样式: ✅

主应用的样式不会影响子应用的样式: ❌

上手难度:简单😘

Vue中的scoped属性

scoped属性的处理下,样式会加上属性选择器,如下所示:

/* 原本是#app的id选择器会自动加上[data-v-a83bd3b0]属性选择器,其中后面的是hash串用于防重复 */
#app[data-v-a83bd3b0] {
  /* 省略 */
}

而用上该样式的DOM元素也会自动加上data-v-a83bd3b0属性:

<div data-v-a83bd3b0 id="app"></div>

但此举作用和experimentalStyleIsolation一样,也是防不了主应用的样式影响到子应用的样式。

结论:

子应用之间样式不互相影响: ✅

子应用的样式不会影响主应用的样式: ✅

主应用的样式不会影响子应用的样式: ❌

上手难度:简单😘

规范样式的类名命名写法

开发者可以约定每个应用的样式的类名命名规则。目前使用比较广泛的类名命名规则是BEMelement-ui就是使用BEM进行类名命名约束的。约定样式命名规则可以避免样式冲突,但需要考虑命名规则的复杂度以及执行度。

结论:

子应用之间样式不互相影响: ✅

子应用的样式不会影响主应用的样式: ✅

主应用的样式不会影响子应用的样式: ✅

上手难度:难😵

备注:命名规则的复杂度以及执行度需要考虑和平衡

CSS Module

CSS Module用于对样式的类名进行更改,使其类名独一无二不会与其他类名相同导致样式冲突,拿本项目中的ReactApp子应用中的代码做例子:

// sub-app\react-ts-app\src\components\ChangeMicroAppButton\index.module.scss
// 注意上面的文件名后缀为module.scss。CRA创建的项目会内置支持css module,只要把样式文件写成module.less或module.scss或module.css。就会自动把其样式内容转化为css module
.button-row {
  margin: 8px;
  text-align: center;
}

然后在组件代码中引入上述的文件作为CSS Module对象进行使用,如下所示:

// sub-app\react-ts-app\src\components\ChangeMicroAppButton\index.tsx
// css module文件导出后是个对象,里面集成了多个以类名作为键值的样式
import styles from './index.module.scss';

const ChangeRouteButton: FC = () => {
  const changeRoute = () => {
    // ...省略
  };

  return (
    // 在className中赋值为styles对象中的类名
    <div className={styles['button-row']}>
      <button onClick={changeRoute}>跳转到vue app 微应用</button>
    </div>
  );
};

最后渲染到页面上的HTML代码如下所示:

image.png

可看到class类名和我们在样式文件中写的类名不一致但相似。CSS Module对类名修改的规则可自己配置。关于CSS Module更多的可看阮一峰的网络日志 CSS Modules 用法教程

Vue也有对CSS Module进行内置支持,且Vue2Vue3CSS Module的用法一致,想了解可直接看教程Vue3 CSS Module

结论:

子应用之间样式不互相影响: ✅

子应用的样式不会影响主应用的样式: ✅

主应用的样式不会影响子应用的样式: ✅

上手难度:中等😏

CSS In JS

CSS In JS作用在于在组件代码中编写css样式,此举可把样式凝聚于组件中。在React中常用于实现CSS In JS的库是styled-components,我们借助官方的例子来看一下它的用法:

import React from 'react';

import styled from 'styled-components';

// Create a <Title> react component that renders an <h1> which is
// centered, palevioletred and sized at 1.5em
const Title = styled.h1`
  font-size: 1.5em;
  text-align: center;
  color: palevioletred;
`;

// Create a <Wrapper> react component that renders a <section> with
// some padding and a papayawhip background
const Wrapper = styled.section`
  padding: 4em;
  background: papayawhip;
`;

// Use them like any other React component – except they're styled!
<Wrapper>
  <Title>Hello World, this is my first styled component!</Title>
</Wrapper>

如果要在Vue中实现CSS In JS,可看此库vue-styled-components

CSS In JS方面也有不少缺点,例如:

  1. 对比于CSS Module,用CSS In JS会大大增加代码量,且代码块中混杂着组件的样式(css)和行为(js),可读性较差。
  2. js里写css没有智能拼写提示方面的支持,且没有类似prettierstylelint的插件用于支持代码格式约束。
  3. 如果要支持sassless方面的样式语言需要更多的配置。脚手架对其支持度不如CSS Module一样开箱即用。

结论:

子应用之间样式不互相影响: ✅

子应用的样式不会影响主应用的样式: ✅

主应用的样式不会影响子应用的样式: ✅

上手难度:中等😏

备注:写法和配置复杂,不推荐使用


总结

下面直接来一份表格对以上的样式隔离方法进行总结:

隔离方法子应用之间不影响子应用不影响主应用主应用不影响子应用上手难度备注
qiankunsandbox配置strictStyleIsolation简单😘需要注意子应用代码不能有查找设置内部DOM元素的行为,且strictStyleIsolation在下一个版本中会被废弃。
qiankunsandbox配置experimentalStyleIsolation简单😘
Vue中的scoped简单😘
规范样式命名写法难😵命名规则的复杂度以及执行度需要考虑和平衡
CSS Module中等😏
CSS In JS中等😏写法和配置复杂,不推荐使用

上面的隔离方法中,个人推荐使用CSS Module。不过使用哪种隔离方法要看团队的人力以及代码工作量。

🍃关于登录态鉴权

由于本项目没有对应的后端,我也没写相关的mock接口和请求逻辑,因此在此章节中关于登录态的分析讲述不能完全体现在项目代码上。但我还是会说一下在微前端项目的开发实践中,我和后端是如何进行登录态的刷新和判断的。

首先,在首次访问页面时,判断是否具有token(存放于cookie或者webstorage)或者token已过期。如果没有则跳转到登录页面,顺利登录后获取token存放在cookie或者webstorage里,以便下次访问页面时不再重新进入登录流程。这部分逻辑与 vue-element-admin中的一样。

在交互过程中,如果手动退出,则调用logout方法,该方法做一系列清除token和跳转登录页面的操作。

重点在于如何处理在请求数据时token过期导致后端返回401未鉴权状态码。如果是主应用发出的接口,则在收到401后立即调用logout方法即可。如果是子应用发出接口且收到401,则立即通过子应用->主应用通信触发主应用调用logout方法即可。可在主应用处理子应用'micro-app-dispatch'中添加该逻辑,如下所示:

const handleMicroAppDispatchEvent = (e) => {
  const { detail: action } = e
  switch (action.type) {
    // ...省略其他case

    // 添加主应用收到信息后的跳转逻辑
    case 'LOGOUT':
      logout(action.payload)
      break
    default:
      break
  }
}

这样子即可顺利保证微前端项目中的登录态稳定性。

🌾后记

这篇文章写到这里就结束了,如果觉得有用可以点赞收藏,如果有疑问可以直接在评论留言,欢迎交流 👏👏👏。