使用微前端架构方案(qiankun)改造现有 web 应用初体验

269 阅读4分钟

前言

本文主要记录作者首次使用微前端架构进行应用开发,并最终部署的过程

最近我司的领导决定改变以往传统的单体前端应用开发方式,开始调研微前端技术架构并做方案选型。

为什么我们拥抱微前端呢?理由如下:

  • 可以将一个大型项目中的各个独立的业务模块拆分出来,便于后续的迭代更新
  • 业务模块高度拆分后,便于后续的新项目开发时,快速接入
  • 某些客户可能只需要一个定制系统的 A, B 两个模块,那么在搭建好一个定制的基座后,只需要完成 A, B 两个子应用的接入并略微调整部署便可快速交付
  • 项目的业务模块拆分使得每个业务应用可以独立开发独立部署
  • 子应用与子应用的技术栈都是独立的,也就是说 A 业务应用可以是基于 React 开发, B 业务应用可以是基于 Vue 开发
  • .....

技术选型

在了解到了微前端的架构方式后,我们便着手了技术方案的选型,目前业界主流的实现的微前端解决方案有如下:

其中,qiankun 是上面四个解决方案中,star 数(14.6K)最高并且社区最为活跃的,在互联网上相关的文章也能得到更多搜索结果。

由于时间紧任务重,我们小组并没有足够的时间来进行各个技术方案的对比验证。为了保险起见,我们决定还是使用 qiankun 来对公司的巨石应用改造成微前端的方式。

着手改造

改造基座

在阅读了 qiankun官方文档后,我们开始根据应用的模块划分进行拆分。

qiankun 加载子应用的方式分为 基于路由加载手动挂载,第一种加载方式并不适合我司的业务场景。

因为每个基座应用是无法预先知道自己需要接入哪些子应用,最终都是根据接口返回的鉴权后的路由跳转某些特定的路由才开始加载子应用这一操作。

于是,手动挂载成为了最适合我们的方案。当跳转到特定的微应用路由时,通过手动加载对应的微应用去实现子模块的加载。

image.png

为了能够实现根据匹配路由手动加载微应用这一方案,需要对基座的路由菜单进行配置。

比如我们组就这样命名:

将路由中以 mfe 开头的一级路径 视作为一个子应用,加载完毕后跳转到该子应用下的路由。

例如 http://localhost:8080/mfe-office-app/employee/add 视作为加载名为 mfe-office-app 这个子应用,并跳转到该子应用下的 /employee/add 这个路由。

我们的路由菜单是由一个后台管理模块进行配置的,在该模块下,由开发人员手动录入并维护菜单路由

image.png

当业务路由改造完毕后,基座应用在处理接口返回过来的路由时,还需要进行额外的处理。

代码作简略后如下:

// router.js
function generateRoute (page: FlatMenuItemDTO, customizedInfo: CustomizedInfoDTO, flatMenus: FlatMenuItemDTO[]) {
    // ....
    const isMicroAppRoute = path.authRoute.match(/\/(?:.*?)\//)[1].startsWith("mfe"); 
    const route: PageRoute = {
        name: routeName,
        path: page.authRoute,
        component: () =>
            // 如果要生成的路由是一个子应用路由,则跳转到 mfe-entry 这个组件内进行挂载操作
            import(`@/views${isMicroAppRoute ? "/mfe-entry/index" : page.component}.vue`)
                .then((component) => component)
                .catch((err) => {
                    logger(err);
                    return import("@/views/404/index.vue");
                 }),
         meta: {
            isMicroAppRoute,
            mfeAppName,
       }
    }
    // ....
    return route;
}

其中,mfe-entry 组件是加载子应用的一个组件,当导航的路由是一个子应用路由时,<router-view />会渲染这个组件并在组件的生命周期中执行加载子应用的一个操作。

下面是 mfe-entry 组件内部分的加载子应用的代码:

// mfe-entry.vue
<template>
    <div un-flex un-h-full un-p-4 un-rounded un-bg-white>
        <a-spin>
            <div :id="domId"></div>
        </a-spin>
    </div>
</template>

<script lang="ts" setup>
const route = useRoute();

const mountMicroApp = () => {
    // 在 mfe-entry.vue 中我们需要根据加载的子应用,找到该子应用的配置文件
    // 这个配置文件由我们和后端协商处理,根据首次打开项目就请求配置文件的接口,并将数据存入到 store 中
    const microAppConfigureStore = useMicroAppConfigureStore();
    const mfeAppName = route.meta.mfeAppName; 
    const domId = `micro-app-${mfeAppName}`;
    const loading = ref(true);
    
    loadMicroApp({
         name: mfeAppName,
         entry: microAppConfigureStore[import.meta.env.VITE_APP_MODE].subAppEntry[item.name],
         container: `#${domId}`,
     }, {
         sandbox: {
            experimentalStyleIsolation: true,
         }
     });
     
  actions.onGlobalStateChange((state, prev) => {
       if (state.mounted) {
           loading.value = false;
       }
       // .....
  })
}

onMounted(mountMicroApp)
</script>

此外,基座应用需要和子应用进行数据的传递和通信, 我们在基座应用中生成权限路由后,还需要初始化一次 qiankun 的 globalState

// global-state.js
actions = initGlobalState({
    accessInfo,
    routes,
    isTokenFresh,
    isTokenExpired,
    //...
})

以上是整个基座应用的大致处理的介绍,在使用 qiankun 改造基座应用的时候,其实,需要改造的代码并不多。

主要是处理如何挂载子应用这一块的细节,打包方式还是和以前保持一致。

改造子应用

那么确定好了哪些业务模块是紧密耦合后,接下来就是对这些业务代码进行抽取并放到新创建的业务仓库中。

在刚阅读 qiankun 的时候,发现官方文档并未提及如何使用 Vite 打包子应用。经过仔细阅读后得知 qiankun v2 用到了 webpack 中的运行时的 publicPath 特性。

运行时的 publicPath 能够使得 qiankun 预加载及引入异步脚本。

__webpack_public_path__ = process.env.ASSET_PATH;

但是那个时候 Vite 2 并没有实现动态的 publicPath

我顺着 Vite 的 issue 列表中找到了一个 #3522 关于 publicPath 的请求支持。目前来看这个特性应该是在 Vite 3.* 已经正式搭载了,但是直到 Vite 4 此功能仍然是具有实验性质的。 cn.vitejs.dev/guide/build…

其次,因为 Vite 遵循原生 ESM 方式运行打包代码,但 ESM 会使得 qiankun 的 js 沙箱无效。因为 qiankun 实现的沙箱直接将全局的 window 对象做了一层拦截代理操作。而 ESM 的中的脚本访问的 window 是顶级作用于下的,是无法访问到 qiankun 提供的 代理 window 的。

所以,就只能用 webpack 进行开发打包吗?

作为 Vite 的虔诚信徒,在 qiankun 的 issue 列表经过一番搜索后。惊喜的发现了一个名叫 vite-plugin-qiankun 的插件。

微信截图_20230728174114.png

虽然已经找到了解决方案,但是当时在是否坚持使用 Vite 开发的时候我们小组是纠结的。

因为这个插件已经一年没维护了,而且 npm 包每个月的下载量基本维持在 2K 以下。该库的作者也已经很久没有处理 issue 列表的问题。

image.png

这些因素导致我们不确定该方案是否真的能应用于我们的生产环境。但是不实践是得不到想要的结果。为了验证这个方案是否可行,我们快速的搭建一个基座和基于这个插件的子应用原型,并安装了相关的 UI 库后打包部署发现是具有可行性的。

于是这让我们坚定了使用 Vite 进行开发的信心。

子应用的改造,首先需要在 vite.config.js 中 引入此插件,并将 name 保持和主应用加载的 name 一致。

import qiankun from "vite-plugin-qiankun";

//...
plugins: [
     qiankun(env.VITE_APP_NAME, {
        useDevMode: true,
    }),
]
//...

接着,还需要在主应用的入口文件声明 qiankun 需要调用的各个生命周期函数。

具体代码简略后如下所示:

// main.ts
import { renderWithQiankun } from "vite-plugin-qiankun/es/helper";

function renderMicroApp(props: QiankunProps) {
    const container = props.container;

    if (!container) {
        console.warn("子应用无法脱离基座应用单独运行");
    } else {
        app = createApp(AppRoot, {
            appNameCamelized: props.appNameCamelized,
        });

        // 注入 pinia
        const pinia = createPinia();
        app?.use(pinia);
        // 设置 storage 前缀
        setPrefix(props.appStoragePrefix);
        // global state setter 初始化
        setStateTrigger(props.setGlobalState);
        // 设置微应用的一些属性
        const mfeStore = useMfeStore();
        mfeStore.setMfeProps({
            enableCache: isBoolean(props.enableCache) ? props.enableCache : true,
            appPrefix: props.appPrefix,
            appName: props.appName,
            appNameCamelized: props.appNameCamelized,
            appCompName: props.appCompName,
            appStoragePrefix: props.appStoragePrefix,
        });

        // 通知基座,加载成功
        props.setGlobalState({
            [`${props.appNameCamelized}Mounted`]: true,
        });

        // 设置一个开关,防止重复安装插件
        let initState = true;
        props.onGlobalStateChange((state: any, prev: any) => {
            if (initState) {
                initState = false;
                setMicroAppStoreState(props, state, prev, true);

                const c = container
                    ? container.querySelector(`#${import.meta.env.VITE_APP_NAME}`)
                    : document.querySelector('[data-app="micro-app-root"]');
                const router = createInitRouter(props.appPrefix);
                app?.use(router).use(globalComponents).mount(c);
            } else {
                setMicroAppStoreState(props, state, prev, false);
            }

            // 主应用 refreshToken 后,通知子应用 token 是新鲜的,需要消费请求队列
            if (state.isTokenFresh) {
                consumeRequestQueue();
                // 重置 flag,加一个定时器保证其他微应用能 consumeRequestQueue
                setTimeout(() => {
                    setState({
                        isTokenFresh: false,
                    });
                }, 0);
            }
        }, true);
    }
}

renderWithQiankun({
    bootstrap() {
        // write your codes...
    },
    mount(props) {
        renderMicroApp(props);
    },
    unmount() {
        app?.unmount();
    },
    update() {
        // write your codes...
    },
});

当子应用被加载时,会执行 renderWithQiankun() 内的各个生命周期方法。

mount 钩子中会执行 renderMicroApp 方法中的一系列操作。上述代码除了创建 vue 应用 后,接着对基座传递的 props 进行处理并存入到 store 中。

在子应用中对于 public 文件夹内的静态资源的请求我发现是不能用相对路径去写的。因为如果用相对路径请求的则是基座里的 public 文件夹下的资源。

目前我们的方案是将子应用里的静态资源全部放入到 assets 文件夹内,然后通过别名的方式引入 assets 文件夹的静态资源。

<img src="~@/assets/img/404.png" alt="404_not_found" />

如果有朋友有更好的方案,欢迎指正~

最后,我们还需要在 vite.config.ts 中更改生产环境的 base 路径。

 base: env.VITE_MODE === "dev"
            ? "/"
            : `/subapp/${process.env.CI_PROJECT_NAME}/${process.env.CI_VERSION}`,

由于,我们的子应用不是和基座应用部署在一个服务器下的,而是放到一个 CDN 服务器下。

所以还需要配置基座的服务器 nginx 文件,将所有 /subapp/ 下的请求资源全部转发到我们的 CDN 服务器中。

location /subapp/ {
    proxy_pass https://cdn.xxx.com/subapp/;
    proxy_set.header Host Shost:Sserver port;
}

最后一步弄完,整个子应用的部署就大功告成了。

总结

至此,历时一周,整个前端应用改造成微前端这样的开发方式,就这样接近尾声了。

这次改造之旅,也让我学到了很多的新奇知识,希望这篇文章能够帮助到那些初次接触微前端的开发者就最好不过了~