【实战】umi3.x+qiankun+模块联邦 搭建微前端应用(一)— 项目初始化

2,017 阅读5分钟

前言

终于要对我们公司的“巨石”项目动手改造了,该项目是基于umi@3.5.27框架搭建的,目前已经有上百个路由了,每次在这上面搬砖都是一次痛苦的煎熬: 项目启动时,偶尔还会报

飞书20220817-153230.jpg 当然这个问题是有办法解决的,可以在启动命令加上:NODE_OPTIONS=--max_old_space_size=4096,比如

"dev": "NODE_OPTIONS=--max_old_space_size=4096 UMI_ENV=dev umi dev"

如果说上一个问题还能通过骚操作解决掉,那这个问题一定不能忍:每次热更新我同事都会开玩笑说:去上个WC让子弹再飞一会 -_-

基于上诉原因,我们决定对这个项目进行微前端改造,期间踩过很多坑,在此记录下,供有需要的看官使用。

系列文章传送门:

【实战】umi3.x+qiankun+模块联邦 搭建微前端应用(二)— 集成qiankun

【实战】umi3.x+qiankun+模块联邦 搭建微前端应用(三)— 项目部署

项目需求及技术手段

  1. 因为这个项目已经上线很久了,我们的基本的原则是不能改变用户的操作习惯,这里采用的是 umi3.x 提供的 MicroAppWithMemoHistory,来实现应用的跳转;
  2. 应用或页面切换希望能保持状态,类似于实现vue的keep-alive效果;
  3. 各应用之间希望能复用组件及公共方法,那么这里就需要用到webpack5提供的 模块联邦了;

废话不多说,我们开始吧;

初始化项目

首先主应用采用 umi3.x,两个子应用:app1 采用 vue3;app2 采用 umi3.x。

用 pnpm 作为包管理器,因为umi3.x官方还没完全支持,umi4.x已经支持了,因此这里会有坑,后续会提到;

各个项目的初始化流程根据各自官网搭建,此处略。最终生成的目录结构如下:

umi-qiankun
├── README.md
├── config
│ ├── config.ts
│ └── routes.ts
├── package.json
├── pnpm-lock.yaml
├── scripts
│ ├── build.js
│ └── utils.js
├── src
│ └── pages
├── sub-projects
│ ├── app1
│ └── app2
├── tsconfig.json
└── typings.d.ts

这里需要注意两个子应用,放在了 sub-projects 这个目录下了

项目配置

在主应用安装

pnpm add -D @umijs/plugin-qiankun

pnpm add @ant-design/icons @ant-design/pro-layout @umijs/route-utils lodash path-to-regexp

注意 @ant-design/icons @ant-design/pro-layout @umijs/route-utils lodash path-to-regexp 注意这几个如果包管理器采用 npm 或 yarn 时是不用安装,但这个我们采用的是 pnpm 则需要安装,这是 pnpm 没有幽灵依赖造成的

注册子应用

// config/config.ts
  qiankun: {
    master: {
      // 注册子应用信息
      apps: [
        {
          name: 'app1', // 唯一 id
          entry: '//localhost:8001', // html entry
        },
        {
          name: 'app2', // 唯一 id
          entry: '//localhost:8002', // html entry
        },
      ],
    },
  },

routes.ts 的配置

// config/routes.ts
export default [
  {
    path: "/",
    name: "首页",
    component: "@/pages/home",
  },
  {
    path: "/about",
    name: "about",
    component: "@/pages/about",
  },
  {
    path: "/app1",
    name: "app1",
    microApp: "app1", // 这里就是微应用,与上面对应
    microUrl:'/', // 自定义字段,后续做子应用内页面跳转有用
  },
  {
    path: "/app2",
    name: "app2",
    microApp: "app2", // 这里就是微应用,与上面对应
    microUrl:'/', // 自定义字段,后续做子应用内页面跳转有用
  },
    {
    path: "/app2_about",
    name: "app2-about",
    microApp: "app2", // 这里就是微应用,与上面对应
    microUrl:'/about', // 自定义字段,后续做子应用内页面跳转有用
  },
];

在子应用的配置

这里需要注意的是在 vue3.x 项目中需要安装

pnpm add vite-plugin-qiankun

这个 vite 插件是专门针对 qiankun 微应用使用的

在 main.ts 文件中配置

// vue3 main.ts  无关代码自行省略
// @ts-nocheck
import { createApp } from "vue";
import App from "./App.vue";
import {
  renderWithQiankun,
  qiankunWindow,
} from "vite-plugin-qiankun/dist/helper";

let router = null;
let instance = null;

renderWithQiankun({
  mount(props) {
    render(props);
    instance.config.globalProperties.$onGlobalStateChange =
      props.onGlobalStateChange;
    instance.config.globalProperties.$setGlobalState = props.setGlobalState;
  },
  bootstrap() {
    console.log("%c ", "color: green;", "sub-vite2-vue3 app bootstraped");
  },
  unmount(props: any) {
    instance.unmount();
    instance._container.innerHTML = "";
    instance = null;
    router = null;
  },
});

function render(props = {}) {
  const { container } = props;

  instance = createApp(App);
  instance.use(router);
  instance.mount(container ? container.querySelector("#app") : "#app");
}

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

在 app2 项目中依然需要安装

pnpm add -D @umijs/plugin-qiankun

在 config/config.ts 文件中配置

  qiankun: {
    slave: {},
  },

到这里,不出意外的话应该能正常访问各子应用了。接下来我们来思考一个场景,有一个页面或一个子应用需要填写复杂表单,这时候中途如果来回切换页面,那么辛辛苦苦填的一堆数据就没有了。基于这个场景,很自然的衍生出一个需求,如何保持页面状态?

我们梳理一下需求,并在右侧内容区域新增一个页签(类似浏览器的页签)功能,页签左右切换是可以保持状态的,只有当点击左侧菜单时如果页签已经存在则重新加载,如果不存在则新开一个页签。

具体实现:

在主应用下新建 src/layouts/index.tsx

import { useRef } from 'react';
import { LikeOutlined, UserOutlined } from '@ant-design/icons';
import {
  PageContainer,
  ProLayout,
  SettingDrawer,
} from '@ant-design/pro-layout';
import { history, IRouteComponentProps } from 'umi';
import { useState } from 'react';
import Content from '@/components/Layout/Content';
import routes from '../../config/routes';

const defaultRoutes = {
  route: {
    path: '/',
    routes,
  },
};

export default (props: IRouteComponentProps) => {
  const contentRef = useRef(null);

  const [pathname, setPathname] = useState(props.location.pathname);

  return (
    <div
      id="test-pro-layout"
      style={{
        height: '100vh',
      }}
    >
      <ProLayout
        {...defaultRoutes}
        location={{
          pathname,
        }}
        menuItemRender={(item, dom) => (
          <a
            onClick={() => {
              setPathname(item.path || '/');
              contentRef.current.resetPaneContent(item.path);
            }}
          >
            {dom}
          </a>
        )}
      >
        <Content {...props} ref={contentRef} setPathname={setPathname} />
      </ProLayout>
    </div>
  );
};

在主应用新建:src/components/Layout/Content.txt

import { withRouter, history, MicroAppWithMemoHistory } from 'umi';
import {
  useState,
  useEffect,
  cloneElement,
  forwardRef,
  useImperativeHandle,
  Fragment,
} from 'react';
import { Tabs } from 'antd';

const { TabPane } = Tabs;

const Content: any = forwardRef(
  ({ children, location, setPathname, refInstance, ...rest }, ref) => {
    const [cacheChildren, setCacheChildren] = useState(new Map());

    const onTabChange = (tab) => {
      history.push(tab);
      setPathname(tab);
    };

    useEffect(() => {
      if (cacheChildren.has(location.pathname)) return;

      const currentRoute = rest.route.routes.find(
        (i: any) => i.path === location.pathname,
      );

      const content = cloneElement(
        children,
        {},
        children.props.children.map((child) => {
          return cloneElement(child);
        }),
      );

      const newCacheChildren = cacheChildren.set(location.pathname, {
        ...currentRoute,
        key: 0,
        tab: currentRoute.name,
        content,
      });

      setCacheChildren(new Map(newCacheChildren));
    }, [location.pathname]);

    const resetPaneContent = (path: string) => {
      let oldEl = cacheChildren.get(path);
      if (cacheChildren.has(path)) {
        // 更新当前页签的key,使其可以重新渲染
        oldEl.key += 1;
        cacheChildren.set(path, oldEl);
        setCacheChildren(new Map(cacheChildren));
      }
      setTimeout(() => {
        history.push(path);
      }, 30);
    };

    useImperativeHandle(refInstance, () => ({
      resetPaneContent,
    }));

    return (
      <Tabs
        hideAdd
        activeKey={location.pathname}
        onChange={onTabChange}
        type="editable-card"
      >
        {[...cacheChildren.values()].map((pane) => (
          <TabPane tab={pane.tab} key={pane.path}>
            {pane.microApp ? (
              <MicroAppWithMemoHistory
                key={pane.key}
                name={pane.microApp}
                url={pane.microUrl}
              ></MicroAppWithMemoHistory>
            ) : (
              <Fragment key={pane.key}>{pane.content}</Fragment>
            )}
          </TabPane>
        ))}
      </Tabs>
    );
  },
);

const Comp = withRouter(Content);

// 这里的forwardRef与withRouter结合会有问题,因此使用了这种方式进行ref的转发
export default forwardRef((props, ref) => (
  <Comp {...props} refInstance={ref} />
));

最终实现效果:

20220817-203343.gif

总结

这个章节我们主要是初步搭建了一个微前端的基础框架,包括采用pnpm作为包管理器、主、子应用目录结构的划分、qiankun的配置等,并在此基础上实现了页签功能,使得我们可以保存页面状态。万里长征刚踏出第一步,码字不易,求点赞收藏,更欢迎批评指正。下一个章节继续说说:集成webpack5 模块联邦实现公共组件、公共方法的复用;