umi + qiankun 微前端实践

2,366 阅读12分钟

umi + qiankun 微前端实践

前言

项目中有使用微前端嵌套子应用的需求,父应用与子应用都是使用 umi3 构建的 React 应用,因此选择使用 umi3 结合插件 @umijs/plugin-qiankun

与 iframe 对比

iframe 的优点:

  • 非常简单,无需任何改造
  • 完美隔离,JS、CSS 都是独立的运行环境
  • 不限制使用,页面上可以放多个 iframe 来组合业务

iframe 的缺点:

  • 无法保持路由状态,刷新后路由状态就丢失(这点也不是完全不能解决,可以讲路由作- 为参数拼接在链接后,刷新时去参数进行页面跳转)
  • 完全的隔离导致与子应用的交互变得极其困难
  • iframe 中的弹窗无法突破其本身
  • 整个应用全量资源加载,加载太慢

single-spa 通过劫持路由的方式来做子应用之间的切换,但接入方式需要融合自身的路由,有一定的局限性。

qiankun 对 single-spa 做了一层封装。通过 import-html-entry 包解析 HTML 获取资源路径,然后对资源进行解析、加载。通过对执行环境的修改,它实现了 JS 沙箱、样式隔离 等特性。

介绍

qiankun 是一个基于 single-spa 的微前端实现库,旨在帮助大家能更简单、无痛的构建一个生产可用微前端架构系统。

@umijs/plugin-qiankun

Umi 应用一键开启 qiankun 微前端模式。

安装插件

yarn add @umijs/plugin-qiankun -D

主应用配置

由于项目中需要动态请求子应用信息和路由,因此需要在 umirc.ts 文件中先注册主应用,然后在 app.ts 文件中动态加载子应用信息。

  1. 插件注册主应用

    在 umirc.ts

    import { defineConfig } from "umi";
    export default {
      hash: true,
      qiankun: {
        master: {},
        // 插件构建期配置子应用写法
        // master: {
        //   // 注册子应用信息
        //   apps: [
        //     {
        //       name: 'app1', // 唯一 id
        //       entry: '//localhost:7001', // html entry
        //       props: { apiPrefix: "app1" },
        //     },
        //     {
        //       name: 'app2', // 唯一 id
        //       entry: '//localhost:7002', // html entry
        //       props: { apiPrefix: "app2" },
        //     },
        //   ],
        // },
      },
      title: "主应用",
    };
    
  2. 注册子应用 apps 与路由信息 routes

    app.ts

    import { getAppInfo } from "@/services/common";
    
    // 动态请求子应用信息和路由
    // 从接口中获取子应用配置,export 出的 qiankun 变量是一个 promise
    export const qiankun = getAppInfo().then((res) => {
      const { errCode, data } = res || {};
      if (String(errCode) !== "0") {
        console.error("请求子应用信息和路由出错了!!!");
        return;
      }
      const { apps, routes } = data;
    
      // const _apps = [
      //   ...apps.map(app => {
      //     // 本地测试
      //     if (app.name === 'app1') {
      //       return {
      //         name: 'app1',
      //         entry: 'http://localhost:8001',
      //       };
      //     }
      //     return app;
      //   }),
      // ];
      return {
        // 注册子应用信息
        apps,
        // 注册路由
        routes,
        // 支持更多的其他配置,详细看这里 https://qiankun.umijs.org/zh/api/#start-opts
        // 完整生命周期钩子,请看 https://qiankun.umijs.org/zh/api/#registermicroapps-apps-lifecycles
        lifeCycles: {
          // 只会触发一次,加载时间较长
          // props 为当前正在加载的 子应用的配置信息
          beforeLoad: (props: unknown) => {
            // 自定义 loading
            let wrapEle = document.querySelector(".spin-wrap");
            if (!wrapEle) {
              wrapEle = document.createElement("div");
              wrapEle.setAttribute("class", "spin-wrap");
              wrapEle.setAttribute(
                "style",
                "position: absolute; top: 50%; left: 50%;"
              );
              document.body.appendChild(wrapEle);
            }
            let spinEle = document.querySelector(`#${spinId}`);
            if (!spinEle) {
              wrapEle.innerHTML = spinNodeHTML;
            }
            // console.log('beforeLoad...');
          },
          beforeMount: (props: unknown) => {
            // console.log('beforeMount', props);
          },
          afterMount: (props: unknown) => {
            // 删除 loading
            let wrapEle = document.querySelector(".spin-wrap");
            let spinEle = document.querySelector(`#${spinId}`);
            if (wrapEle && spinEle) {
              wrapEle.removeChild(spinEle);
            }
            // console.log('afterMount', props);
          },
          afterUnmount: (props: unknown) => {
            // console.log('afterMount', props);
          },
        },
      };
    });
    

主应用装载子应用

有两种方法:

a. 使用路由绑定的方式,在 umirc.ts 文件中,配置 routes 路由信息,可以将主应用的路由与子应用路由配置在一起,例如:

export default {
  routes: [
    {
      path: "/",
      component: "../layouts/index.js",
      routes: [
        {
          path: "/app1",
          component: "./app1/index.js",
          routes: [
            {
              path: "/app1/user",
              component: "./app1/user/index.js",
            },
            // 配置微应用 app1 关联的路由
            {
              path: "/app1/project",
              microApp: "app1",
            },
          ],
        },
        // 配置 app2 关联的路由
        {
          path: "/app2",
          microApp: "app2",
        },
        {
          path: "/",
          component: "./index.js",
        },
      ],
    },
  ],
};

微应用路由也可以配置在运行时,通过 src/app.ts 添加,项目中目前使用的是这种方法:

export const qiankun = fetch("/config").then(({ apps }) => {
  return {
    apps,
    routes: [
      {
        path: "/app1",
        microApp: "app1",
      },
    ],
  };
});

b. 使用 <MicroApp /> 组件的方式

建议使用这种方式来引入不带路由的子应用。 否则请自行关注微应用依赖的路由跟当前浏览器 url 是否能正确匹配上,否则很容易出现微应用加载了,但是页面没有渲染出来的情况。

import { MicroApp } from "umi";

export function MyPage() {
  return (
    <div>
      <MicroApp name="app1" />
    </div>
  );
}

子应用配置

  1. 插件注册(config.js)

    export default {
      qiankun: {
        // 特殊情况,当 app2 应用,既可以作为 app1 的子应用,也可以嵌套子应用 app3 时,master 和 slave 属性可以同时存在
        master: {},
        slave: {},
      },
    };
    

    在 package.json 中删除 private:true 字段,添加 name 字段,子应用配置完毕。

  2. 配置运行时生命周期钩子(可选)

    插件会自动为你创建好 qiankun 子应用需要的生命周期钩子,但是如果你想在生命周期期间加一些自定义逻辑,可以在子应用的 src/app.ts 里导出 qiankun 对象,并实现每一个生命周期钩子,其中钩子函数的入参 props 由主应用自动注入, 注册 apps 时传入的 props 参数。

    export const qiankun = {
      // 应用加载之前
      async bootstrap(props) {
        // console.log('主应用传给微应用的 props:', props);
        console.log("app1 bootstrap", props);
      },
      // 应用 render 之前触发
      async mount(props) {
        console.log("app1 mount", props);
        // 若需要全局使用,可以绑定到 window 上
        window.qiankunProps = props;
      },
      // 应用卸载之后触发
      async unmount(props) {
        console.log("app1 unmount", props);
      },
    };
    

父子应用通讯

有两种方式可以实现

配合 useModel 使用(推荐)

需确保已安装 @umijs/plugin-model 或 @umijs/preset-react

  1. 主应用使用下面任一方式透传数据:

    1.1 如果你用的 MicroApp 组件模式消费微应用,那么数据传递的方式就跟普通的 react 组件通信是一样的,直接通过 props 传递即可:

    function MyPage() {
      const [name, setName] = useState(null);
      return (
        <MicroApp name={name} onNameChange={(newName) => setName(newName)} />
      );
    }
    

    1.2 如果你用的 路由绑定式 消费微应用,那么你需要在 src/app.ts 里导出一个 useQiankunStateForSlave 函数,函数的返回值将作为 props 传递给微应用,如:

    // src/app.ts
    export function useQiankunStateForSlave() {
      const [masterState, setMasterState] = useState("Hello World");
    
      return {
        masterState,
        setMasterState,
      };
    }
    
  2. 微应用中会自动生成一个全局 model,可以在任意组件中获取主应用透传的 props 的值。

    import { useModel } from "umi";
    
    function MyPage() {
      const { masterState, setMasterState } = useModel(
        "@@qiankunStateFromMaster"
      );
      return <div>{masterState}</div>;
    }
    

    主应用也可以获取到 useQiankunStateForSlave 函数 的返回值。

    // src/index.ts
    import { useModel } from "umi";
    
    function MasterPage() {
      const { masterState, setMasterState } = useModel("@@qiankunStateForSlave");
    
      return (
        <div>
          <button onClick={() => setMasterState("Hello Qiankun")}>
            修改全局 state
          </button>
          {masterState}
        </div>
      );
    }
    

    或者可以通过高阶组件 connectMaster 来获取主应用透传的 props

    import { connectMaster } from "umi";
    
    function MyPage(props) {
      return <div>{JSON.stringify(props)}</div>;
    }
    
    export default connectMaster(MyPage);
    
  3. 和 <MicroApp /> 的方式一同使用时,会额外向子应用传递一个 setLoading 的属性,在子应用中合适的时机执行 masterProps.setLoading(false),可以标记微模块的整体 loading 为完成状态。

基于 props 传递

类似 react 中组件间通信的方案。

  1. 主应用中配置 apps 时以 props 将数据传递下去(参考主应用运行时配置一节)

    // src/app.js
    
    export const qiankun = fetch("/config").then((config) => {
      return {
        apps: [
          {
            name: "app1",
            entry: "//localhost:2222",
            props: {
              onClick: (event) => console.log(event),
              name: "xx",
              age: 1,
            },
          },
        ],
      };
    });
    
  2. 子应用在生命周期钩子中获取 props 消费数据(参考子应用运行时配置一节)

嵌套子应用

除了导航应用 Master 之外,App1 与 App2 均依赖同一个浏览器 url,为了让 App1 与 App2,两个应用同时存在,我们需要在运行时将 App1 和 App2 的路由改为 memory 类型。

  1. 在 Master 中加入 master 配置

    export default {
      qiankun: {
        master: {
          // 注册子应用信息
          apps: [
            {
              name: "app1", // 唯一 id
              entry: "//localhost:7001", // html entry
            },
            {
              name: "app2", // 唯一 id
              entry: "//localhost:7002", // html entry
            },
          ],
        },
      },
    };
    
  2. 通过 <MicroAppWithMemoHistory /> 引入 App1 和 App2

    import { MicroAppWithMemoHistory } from "umi";
    
    export function MyPage() {
      return (
        <div>
          // 我们可以在url出书写子应用中的路由,而对应加载模块
          <MicroAppWithMemoHistory name="app1" url="/user1" />
          <MicroAppWithMemoHistory name="app2" url="/user2" />
        </div>
      );
    }
    

API

MasterOptions

  • apps: 子应用配置

  • route?: 子应用运行时需要注册的微应用路由

  • sandbox?: 是否启用沙箱,默认值为 true ,更多文档请看

    • { strictStyleIsolation: true }: 表示开启严格的样式隔离模式。这种模式下 qiankun 会为每个微应用的容器包裹上一个 shadow dom 节点,从而确保微应用的样式不会对全局造成影响。

      注意: 使用 shadow dom 节点可能会影响某些 react 事件。详情请看

      缺点:很明显,子应用的弹窗、抽屉、popover 等因找不到主应用的 body 会丢失或者样式不对;

    • { experimentalStyleIsolation: true }: 实验性方案,开启时 qiankun 会改写子应用所添加的样式为所有样式规则增加一个特殊的选择器规则来限定其影响范围,因此改写后的代码会表达类似为如下结构:

      // 假设应用名是 react16
      .app-main {
        font-size: 14px;
      }
      
      div[data-qiankun-react16] .app-main {
        font-size: 14px;
      }
      

      注意: @keyframes, @font-face, @import, @page 将不被支持 (i.e. 不会被改写)

      缺点:子应用的弹窗、抽屉、popover 因插入到了主应用的 body,所以导致样式丢失或应用了主应用了样式

  • prefetch?: 是否启用 prefetch 特性,默认值为 true ,更多文档请看

App

  • name: 子应用唯一 id.

  • entry: 子应用 html 地址,数据格式为 string | { script: string[], styles: [] }。

  • credentials?: 拉取 entry 时是否需要携带 cookie,默认值为 false 更多文档请看

  • props?: 主应用传递给子应用的数据, 默认值为 {}

Route

  • path: 路由 path

  • microApp: 关联的微应用名称

  • microAppProps?: 微应用配置,数据格式为 :{autoSetLoading?: boolean, className?: string, wrapperClassName?: string}, 默认值为 {}

踩坑

为什么微应用加载的资源会 404

  • 官方文档这个问题的本质原因在于 webpack 加载资源时未使用正确的 publicPath。

  • qiankun 将外链样式改成了内联样式,但是字体文件和背景图片等静态资源的加载路径是相对路径。

  • 而 css 文件一旦打包完成,就无法通过动态修改 publicPath 来修正其中的字体文件和背景图片的路径。

所有子应用编译打包部署后,当主应用加载子应用,子应用加载自身的静态资源时,由于 其对应的静态资源文件里面的字体图片等引用是相对路径,会出现子应用的资源相对路径拼接主应用 domain 的情况,即子应用的资源会在主应用的 domain 下进行资源的寻找,导致加载失败的情况。

例如:

.block {
  width: 500px;
  height: 350px;
  background: url("../assets/bg.png") no-repeat center;
}

@font-face {
  font-family: "MyCustomFont";
  font-display: swap;
  src: url("fonts/Ming_MSCS.ttf?t=1669805015482") format("truetype");
}

子应用开发模式没有这个问题,开发模式下这个路径会被注入 publicPath, 但是经过 qiankun 打包后会出现加载 404 问题。

根本原因在于字体文件虽然经过了 webpack 处理,但是没有被注入路径前缀。

解决办法:

  1. 所有图片等静态资源上传至 cdn,css 中直接引用 cdn 地址(推荐)

  2. 借助 webpack 的 url-loader 将字体文件和图片打包成 base64(适用于字体文件和图片体积小的项目)(推荐)

    先引入 url-loader 包

      yarn add url-loader -D
    
    module.exports = {
      chainWebpack: (config) => {
        config.module
          .rule("fonts")
          .use("url-loader")
          .loader("url-loader")
          .options({})
          .end();
        config.module
          .rule("images")
          .use("url-loader")
          .loader("url-loader")
          .options({})
          .end();
      },
    };
    
  3. 借助 webpack 的 file-loader ,在打包时给其注入完整路径(适用于字体文件和图片体积比较大的项目),也可以结合方法 2,将小文件转 base64

    先引入 url-loader, file-loader 包

     yarn add url-loader -D
     yarn add file-loader -D
    
    const publicPath =
      process.env.NODE_ENV === "production"
        ? "https://qiankun.umijs.org/"
        : "http://localhost:8001";
    module.exports = {
      chainWebpack: (config) => {
        config.module
          .rule("fonts")
          .use("url-loader")
          .loader("url-loader")
          .options({
            limit: 4096, // 小于4kb将会被打包成 base64
            fallback: {
              loader: "file-loader",
              options: {
                name: "fonts/[name].[hash:8].[ext]",
                publicPath,
              },
            },
          })
          .end();
        config.module
          .rule("images")
          .use("url-loader")
          .loader("url-loader")
          .options({
            limit: 4096, // 小于4kb将会被打包成 base64
            fallback: {
              loader: "file-loader",
              options: {
                name: "img/[name].[hash:8].[ext]",
                publicPath,
              },
            },
          });
      },
    };
    

微应用静态资源一定要支持跨域

由于 qiankun 是通过 fetch 去获取微应用的引入的静态资源的,所以必须要求这些静态资源支持跨域。

嵌入子应用后,主应用 title 被修改

主应用嵌入子应用后,主应用的 document.title 变成了子应用的 document.title

可以在 入口文件 app.js 中监听并防止修改

/**
 * 禁止所有子应用修改页面的标题
 */
try {
  document.title = "主应用";
  window.originalTitle = document.title;
  Object.defineProperty(document, "title", {
    get: () => "主应用",
    set: () => {},
  });
} catch (e) {}

确保主应用跟微应用之间的样式隔离

qiankun 将会自动隔离微应用与微应用之间的样式(开启沙箱的情况下),

可以通过手动的方式确保主应用与微应用之间的样式隔离。比如给主应用的所有样式添加一个前缀,或者假如你使用了 ant-design 这样的组件库,你可以通过下面的配置方式给主应用样式自动添加指定的前缀。

以 antd 为例:分两步进行

  1. 配置 webpack 修改 less 变量

    module.exports = {
      lessLoader: {
        options: {
          modifyVars: {
            "@ant-prefix": "myapp",
          },
          javascriptEnabled: true,
        },
      },
    };
    

    一点说明:

    lessLoader 里面的 modifyVars 会修改 less 文件里面的样式,这样就可以与 html 里面的样式匹配了。

  2. 配置 antd ConfigProvider

    import { ConfigProvider } from "antd";
    
    export const MyApp = () => (
      <ConfigProvider prefixCls="myapp">
        <App />
      </ConfigProvider>
    );
    

    prefixCls 属性的配置,会把 html 里面的样式名修改为新的样式前缀名

  3. 使用 沙箱 strictStyleIsolation, experimentalStyleIsolation 配置(不推荐,可能会引入新问题,详情请看 sandbox 介绍)

独立运行微应用

当微应用需要进行独立部署,可以使用这个全局变量来区分当前是否运行在 qiankun 的主应用的上下文中:

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

export const mount = async () => render();

子应用缓存 keep-alive

当主应用切到其他子应用后,会重新创建新的子应用实例并渲染,并把之前构建的子应用实例销毁。

子应用 keep-alive 其实就是想在子应用切换时不卸载掉,仅仅是样式上的隐藏(display: none),这样下次打开就会更快,并且保留之前子应用的状态。

可以使用 umi 插件 umi-plugin-keep-alive 实现。

yarn add umi-plugin-keep-alive -D

从 umi 中导出 KeepAlive,包裹在需要被缓存的组件上

import { KeepAlive } from "umi";

function KaInject(props) {
  const { route, history } = props;
  const { location } = history;
  const { pathname, search } = location;
  const { path, OriginComponent } = route;

  console.log("name", pathname);

  return (
    <KeepAlive
      name={pathname}
      // name={`${pathname}${search}`}
      // id={`${pathname}${search}`}
      // pathname={`${pathname}${search}`}
      saveScrollPosition="screen"
    >
      {OriginComponent ? <OriginComponent {...props} /> : "unknown"}
    </KeepAlive>
  );
}

export default KaInject;

在 app.ts 注册子应用路由时,使用 KaInject 组件对子应用组件进行包裹。

import { getAppInfo } from "@/services/common";
import KaInject from "./KaInject";

// 拦截子应用路由
const injectQiankunRoute = (routes) => {
  console.log("routesroutesroutesroutes", routes);
  return (routes || []).map((route) => ({
    ...route,
    OriginComponent: route.component,
    component: KaInject,
  }));
};

export const qiankun = getAppInfo().then((res) => {
  const { errCode, data } = res || {};
  if (String(errCode) !== "0") {
    console.error("请求子应用信息和路由出错了!!!");
    return;
  }
  const { apps, routes } = data;
  const newRoutes = injectQiankunRoute(routes);
  console.log("routesroutes", newRoutes);

  return {
    apps,
    // 注册路由
    routes: newRoutes,
    lifeCycles: {},
  };
});