微前端落地的那些事

微前端落地的那些事

此文为2021年9月内部分享讲稿,PPT 下载见文末,2022年2月对外发布。文章内容吸取了很多社区已有文章精华,结合生产落地进行总结,若有侵权请联系删除。由于原文部分涉及到内部数据,进行了裁剪,可能导致描述不清。

对应示例已提供 Demo,地址见文末。

技术点:qiankun、Vue3、Vue Cli、Element Plus

摘要

前端,技术圈的娱乐圈。在大前端的趋势下,以往的前端开发模式已经不能很好地承载实际的项目需求,我们需要一系列方案来使我们的项目变得规范、可配置、优化等。今年最火的莫过于微前端和低代码,低代码没参与上,我们说说微前端。说一说在实施落地的那些事。

背景

由于项目体量(3年开发、5年推广、x亿投入)和规划及产品要求支持按功能模块独立上线、更新,降低单次上线风险。参与开发团队 4+(异地,人数最多的团队成员50+)。

多次沟通后,根据需求得出如下结论和相应的问题:

  • 平台系统体量大且功能多

    • 开发效率低
    • 多人协作成本高
    • 接入成本高
  • 平台系统周期长且敏感

    • 活跃周期长
    • 可用率要求高
    • 重复建设可不管理

诉求:

​ 建立体验良好、可持续维护的系统

核心解决两种场景问题:

  • 基于产品(体验)纬度
  • 基于技术架构纬度

基于上述原因,决定采用微前端解决方案。

巨石应用、iframe、框架组件、微前端都可以解决上述问题。 四种方式各有优缺点。微前端方案它的核心解决的业务场景,更多的是在体验和效率上找到一个平衡点。基于调研和对比,最终决定去选择微前端的技术方案,来对业务架构进行升级。

微前端概念

微前端架构是一种类似于微服务的架构,它将微服务的理念应用于浏览器端,即将 Web 应用由单一的单体应用转变为多个小型前端应用聚合为一的应用

由此带来的变化是,这些前端应用可以独立运行、独立开发、独立部署。以及,它们应该可以在共享组件的同时进行并行开发——这些组件可以通过 NPM 或者 Git Tag、Git Submodule 等来管理。

微前端是一种架构风格,将众多独立交付的前端应用组合成一个大型整体。

当然,软件架构领域没有免费的午餐:一切都要付出代价。一些微前端实现可能导致重复依赖,使用户不得不下载更多内容。此外,大幅提升的团队自治水平可能会让各个团队的工作愈加分裂。只不过我们认为这些风险都能控制在合理水平上,微前端终究还是利大于弊的。

更多详细介绍可阅读 微前端

什么时候用到微前端

  • 大规模企业级 Web 应用开发
  • 跨团队及企业级应用协作开发
  • 长期收益高于短期收益
  • 不同技术选型的项目
  • 内聚的单个产品中部分需要独立发布、灰度等能力

白话就是适用于老旧项目、巨石应用、协作人员多的项目

微前端虽火,但别把它当银弹,适用于老旧项目、巨石应用、协作人员多的项目。如果没特殊需求,常规方案能解决就用最简单的方式去实现。

微前端解决方案

  • single-spa 在官网中被自称是一个元框架,可以实现在一个页面将多个不同的框架整合。很多微前端方案基于此进行二次开发或者是灵感来源,支持 esm
  • qiankun 基于 single-spa 的微前端解决方案,生产可用
  • icestark 面向大型应用的微前端解决方案
  • MicroApp 一种用于构建微前端应用的极简方案,支持 esm(需要关闭沙箱)
  • Garfish 包含构建微前端系统时所需要的基本能力,任意前端框架均可使用。接入简单,可轻松将多个前端应用组合成内聚的单个产品。沙箱隔离机制更完善

    2022.8.16 更新 Garfish 官网链接

  • emp 基于Webpack5 Module Federation搭建的微前端解决方案

由于当时项目开始于2021年年初,基于当时的开源情况,选择的解决方案是 qiankun 。如果现在重新开始,是否支持 ES modules、沙箱的实现机制和隔离级别 也会成为选择的核心参考点。

若无特殊要求,建议使用当前使用人数最多的方案 qiankun

如果要求沙箱机制更完善可以尝试 Garfish

若没有类似于后台需要标签切换保存状态的需求,可以尝试 MicroApp

若微应用是第三方开发、部署,无法要求设置跨域,建议使用 icestark

不怕麻烦使用 single-spa ,什么都支持。

技术栈

  • 核心:Vue3、Vue CLI 5、TypeScript
  • UI库:Element Plus
  • 单元测试:Jest
  • E2E:TestCafe

实施过程

架构图

架构图

框架应用就负责整体的 Layout 跟微应用配置与注册渲染。从上面这张图上可以看到,框架应用会有一个通用的头部 Header,侧边栏 siderBar,除了 Layout 之外,还需要配置微应用的信息,比如 bundle url、基准路由等信息。微应用它其实就是按业务维度拆分开来的一些应用,通常来讲它可能就是一个 SPA 应用,并且会包含至少一到多个页面或路由。

原则上框架应用尽量避免包含具体页面的 UI 代码,如果框架应用做了过多的事情会带来以下问题:

  • 框架应用样式代码太多,会增加微应用和框架应用样式冲突概率

  • 框架应用为微应用提供其他能力比如一些全局 API,会破坏微应用的独立性,增加相互的耦合

  • 框架应用本质是一个中心化的部分,变更后原则上需要回归所有微应用,因此需要保证职责的简单,越简单的东西越稳定

流程图

流程图

我们可以从两个视角去看接入微前端架构后的工作流程,一个视角就是右边微应用的开发模式。微应用开发有独立的仓库,独立的开发、测试、布署流程。开发测试部署完之后,将应用的发布产物统一注册到框架应用里面,这些产物可能是 JS bundle 或 HTML 资源。

左边是一个框架应用的整体流程,框架应用会维护微应用的注册信息。用户在访问系统的时候,根据它之前注册的路由信息,它能够精确地匹配到当前需要加载的应用信息,根据相应的信息去加载应用的资源并最终渲染应用。

用户点击触发跳转的时候,如果路由变化触发的是一个内部应用跳转,那应用将会直接根据应用内部的路由逻辑渲染页面。如果涉及到一些跨应用的跳转,则又重新回到了上面路由的查找流程当中。

由于部署架构的设计要求,项目将采用 Multirepo 。如果条件支持,个人觉得采用 Monorepo更好

应用接入

采用 qiankun 对项目的侵入度并不高,和常规的 SPA 开发没多大区别。调整点如下:

主体应用

  1. 添加子应用及规则

    // src/micro/app.ts
    const apps: RegistrableApp<IAppProps>[] = [{
      name: 'emd-app',
      // 环境变量 https://next.cli.vuejs.org/zh/guide/mode-and-env.html#%E7%8E%AF%E5%A2%83%E5%8F%98%E9%87%8F
      // 如果将注册逻辑动态化-配置下发,就能实现应用编排
      entry: process.env.VUE_APP_EMD_APP_URL as string,
      container: '#frame',
      activeRule: '/emd'
     },{
        name: 'et-app',
        entry: process.env.VUE_APP_ET_APP_URL as string,
        container: '#frame',
        activeRule: '/et'
      },
      ......
    ];
    
    export default apps;
    复制代码
    // src/micro/index.ts
    import {
      registerMicroApps, start, addGlobalUncaughtErrorHandler, runAfterFirstMounted, setDefaultMountApp
    } from 'qiankun';
    import NProgress from 'nprogress';
    import 'nprogress/nprogress.css';
    import apps from './apps';
    
    /**
     * 注册子应用
     * 第一个参数 - 子应用的注册信息
     * 第二个参数 - 全局生命周期钩子
     */
    registerMicroApps(apps, {
      // 加载前
      beforeLoad: () => {
        NProgress.start();
        return Promise.resolve();
      },
      // 挂载后
      afterMount: () => {
        NProgress.done();
        return Promise.resolve();
      }
    });
    
    /**
     * 添加全局的未捕获异常处理器
     */
    addGlobalUncaughtErrorHandler((event: Event | string) => {
      const { message: msg, error } = event as any;
      // 加载失败时提示
      if (msg && msg.includes('died in status LOADING_SOURCE_CODE')) {
        console.error(`子应用 ${error?.appOrParcelName} 加载失败,请检查应用是否可运行`);
      }
    });
    
    /**
     * 设置默认进入的子应用
     */
    setDefaultMountApp('/');
    
    /**
     * runAfterFirstMounted
     */
    runAfterFirstMounted(() => {
      // console.log('[MainApp] first app mounted');
    });
    
    // 导出 qiankun 的启动函数
    export default start;
    
    复制代码
  2. 添加子应用渲染路由

    {
      path: ':micro(emd|et|rpt):endPath(.*)',
      name: 'MicroApp',
      component: () => import('@/views/MicroApp.vue')
    }
    复制代码
  3. 实现子应用加载

    // @/views/MicroApp.vue
    <template>
      <div id="frame"></div>
    </template>
    
    <script setup lang='ts'>
    import { onMounted } from 'vue';
    import start from '@/micro';
    
    onMounted(() => {
      if (window.qiankunStarted) return;
      window.qiankunStarted = true;
      start();
    });
    </script>
    复制代码

    若出现 window 的变量警告,可在 shims-vue.d.ts 添加

    // shims-vue.d.ts
    interface Window {
      qiankunStarted: boolean
    }
    复制代码

子应用

// main.ts
let emdRouter = null;
let emdApp: any = null;
let emdHistory: any = null;

function render(props: any = {}) {
  const { container } = props;
  // 由于安全需求,需要添加二级域名
  const urlPrefix = `${process.env.VUE_APP_URL_PRE || ''}/emd`;
  // eslint-disable-next-line no-underscore-dangle
  emdHistory = createWebHistory(window.__POWERED_BY_QIANKUN__ ? urlPrefix : '/');
  emdRouter = createRouter({
    history: emdHistory,
    routes
  });

  emdApp = createApp(App);
  emdApp.use(emdRouter);
  emdApp.use(store);
  emdApp.use(i18n);
  emdApp.use(permission);
  emdApp.mount(container ? container.querySelector('#app') : '#app');
}

// eslint-disable-next-line no-underscore-dangle
if (!window.__POWERED_BY_QIANKUN__) {
  render();
}

export async function bootstrap(): Promise<void> {
  console.log('emd-app bootstraped');
}

/**
 * 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
 */
export async function mount(props: any) {
  if (props) {
    // 注入 actions 实例,实现应用通信
    actions.setActions(props);
  }
  render(props);
}

export async function unmount() {
  emdApp.unmount();
  // eslint-disable-next-line no-underscore-dangle
  emdApp._container.innerHTML = '';
  emdApp = null;
  emdRouter = null;
  emdHistory.destroy();
}
复制代码

子应用接入到框架应用里,只要子应用实现了 bootstrapmountunmount 三个生命周期钩子,有这三个函数导出,框架应用就可以知道如何加载这个子应用。

当子应用第一次挂载的时候,会执行 bootstrap 做一些初始化,然后执行 mount 将它挂载。如果你是一个 Vue 技术栈的子应用,你可能就在 mount 里面写 createApp().render,把你的 Vue App 挂载到真实的节点上,把应用渲染出来。当你的应用切换走的时候,会执行 unmount 把应用卸载掉,当它再次回来的时候(典型场景:你从应用 A 跳到应用 B,过了一会儿又跳回了应用 A),这个时候我们是不需要重新执行一次所有的生命周期钩子的,我们不需要从 bootstrap 开始,我们会直接从 mount 阶段继续,这也就做到了应用的缓存。

qiankun 接入的更多细节可参考:

Nginx 部署

根据部署环境要求部署为多模块。由于微应用架构,子应用需要支持跨域,跨域配置:

location / {  
    add_header Access-Control-Allow-Origin *;
    add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS';
    add_header Access-Control-Allow-Headers 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization';
}
复制代码

遇到的那些坑

缓存问题

主应用更新后,偶尔会出现访问的为旧版本的情况,经排查,发现用户拿到的为 旧版本缓存的 index.html;子应用更新后,访问的还是旧版本文件,和主应用一样,均是 index.html 缓存问题。此为 SPA 通病,默认缓存策略为协商缓存,取消对应文件的缓存即可。

Nginx 配置如下:

location = /index.html {
  add_header Cache-Control no-cache;
}
复制代码

也可改为

if (\$request_filename ~ .*\.(htm|html)$){add_header Cache-Control no-store;} 
复制代码

Nginx Zip 支持

静态资源未压缩的情况下,性能、体验不佳。Zip 开启 Nginx 配置如下:

gzip  on;
gzip_min_length  1k;
gzip_buffers     4 16k;
gzip_http_version 1.1;
gzip_comp_level 9;
gzip_types text/plain text/css application/xml text/javascript application/javascript application/json image/svg+xml font/ttf;
gzip_vary on;
复制代码

不是所有文件压缩了都好,过小的文件压缩后体积会变大。因设置一个压缩阈值,比如上面为 1K

element-plus 打包 Icon 乱码

在测试环境中有概率会出现字体图标乱码的情况,刷新后又正常。经排查为 dart-sassunicode 编码的 bug

icon.jpg

解决方式有两种:

  1. dart-sass 换为 node-sass
  2. dart-sass 编译前先处理 unicode 编码

方案一 node-sass 技术过时、官方不在维护、编译慢、国内下载时间长还大概率下载失败,不考虑。那就只能选择方案二,根据 dart-sass 上 issue 的提示,用了一个 postcss 插件

解决方案:

  1. 安装 postcss-sass-unicode

  2. 添加 postcss.config.js

    // postcss.config.js
    module.exports = {
      plugins: [
        require('postcss-sass-unicode'),
        require('autoprefixer')
      ]
    };
    复制代码

添加后打包效果

image-20211014142614006.png

不过有个问题有点意思,就是 element、element plus 官网的图标审查 style 都是乱码,但是展示正确,不知道是否加了特殊处理。不过不重要了,项目中的图标现在已经向 svg icon 转移,element plus 也已经转完了。对比icon font ,虽然使用的时候部分场景不方便,但是最终产物要小很多,大概几百K。font icon 不管你使用多少,字体文件都必须全量下载,svg icon 可以做到按需应用、build。

element-plus 按需引用

以前组件库按需应用需要在使用的组件内进行按需引入,或者全局按需引入。前者不方便,每次都要引入,后者极易变成全量引入。能否有方式支持我使用的什么组件,在打包阶段自动实现按需引入。在一次分享里,祖师爷分享了一个神器,只管用,插件会帮你把使用的组件按需引入进来。几乎支持所有流行的 Vue UI 组件库,如 Ant Design Vue、Element Plus、Vant、Naive UI

配置如下:

  1. 安装 unplugin-vue-components :npm i unplugin-vue-components -D

  2. 添加配置 plugin

    const Components = require('unplugin-vue-components/webpack');
    const { ElementPlusResolver } = require('unplugin-vue-components/resolvers');
    
    module.exports = {
      chainWebpack: (config) => {
        config.plugin('Components')
          .use(Components({
            resolvers: [ElementPlusResolver({ importStyle: false })]
          }));
      }
    };
    复制代码

KeepAlive 支持

2022-03-02 10-25-20.gifcask.2022-03-02 13_15_15-min.gif

产品希望支持面包屑标签切换,并能缓存页面状态。根据我们上面的实现,自动加载子应用并不能通过 KeepAlive 标签实现状态缓存,

查看 qiankun 的 API 说明后,发现可使用 loadMicroApp 手动加载子应用实现。

设计思路

  1. 通过主体应用的导航卫士 router.beforeEach 实现对需要缓存的路由映射记录

  2. 通过 qiankun 的通信机制将缓存信息发送到子应用,子应用负责各自的 KeepAlive

    微前端的KeepAlive 和单体的有区别。多个应用会存在多个Vue实例,所以每个应用均需要实现 KeepAlive。

  3. 根据路由匹配规则,实现对子应用的手动加载,并缓存其状态

实现

主体应用

导航卫士,一般这里面会设计权限拦截。

/**
 * 是否存在缓存数据中
 * @param cachedViews
 * @param view
 */
const hasView = (cachedViews: ICachedView[], view: ICachedView) => cachedViews.some((v) => v.fullPath === view.fullPath);

/**
 * 路由缓存信息封装
 * @param route
 */
const cachedViewEncapsulation = (route: RouteLocationNormalized): ICachedView => ({
  path: route.path,
  fullPath: route.fullPath,
  query: route.query,
  name: route.name || ''
});

/**
 * 导航卫士
 */
router.beforeEach(async (to, from, next) => {
  ........
  // login 等部分页面不需要 KeepAlive
  if (!to.meta.noKeepAlive) {
    store.dispatch('addCachedViews', cachedViewEncapsulation(to));
  }
  next();
});
复制代码

全局状态,添加缓存映射记录。主要为:面包屑标签名称、url信息等

export default createStore<IStoreType>({
  state: {
    cachedViews: []
  },
  getters: {
    cachedViews: (state): ICachedView[] => state.cachedViews
  },
  mutations: {
    /**
     * 页面缓存变更
     * @param state
     * @param view
     * @constructor
     */
    ADD_CACHE_VIEWS(state, view) {
      // fullPath 匹配
      if (hasView(state.cachedViews, view)) {
        return;
      }
      // 同路径多标签处理,ET模块
      if (view?.query?.sign) {
        state.cachedViews.push({ ...view });
      } else {
        // path 匹配,更新路由参数
        const index = state.cachedViews.findIndex((v) => v.path === view.path);
        if (index > -1) {
          state.cachedViews[index] = view;
        } else {
          state.cachedViews.push({ ...view });
        }
      }
      actions.setCachedViews(state.cachedViews);
    },
    DEL_CACHED_VIEWS(state, view) {
      const index = state.cachedViews.findIndex((v) => v.fullPath === view.fullPath);
      state.cachedViews.splice(index, 1);
      actions.setCachedViews(state.cachedViews);
    },
    DEL_ALL_CACHED_VIEWS(state) {
      state.cachedViews = [];
      actions.setCachedViews([]);
    },
    DEL_OTHER_CACHED_VIEWS(state, view) {
      if (state.cachedViews.length === 1) return;
      state.cachedViews = [view];
      actions.setCachedViews(state.cachedViews);
    }
  },
  actions: {
    addCachedViews({ commit }, view) {
      commit('ADD_CACHE_VIEWS', view);
    },
    delCachedViews({ commit }, view) {
      commit('DEL_CACHED_VIEWS', view);
    },
    delAllCachedViews({ commit }) {
      commit('DEL_ALL_CACHED_VIEWS');
    },
    delOtherCachedViews({ commit }, view) {
      commit('DEL_OTHER_CACHED_VIEWS', view);
    }
  }
});
复制代码

布局组件

// Layout.vue
<template>
  <el-main>
    <router-view v-slot="{ Component, route }">
      <transition>
        <keep-alive :include="cachedViews">
          <component :is="Component" :key="route.meta.usePathKey ? route.path : undefined"/>
        </keep-alive>
      </transition>
    </router-view>
  </el-main>
</template>

<script setup lang='ts'>
import { computed } from 'vue';
import { useStore } from 'vuex';

const store = useStore();

const cachedViews = computed(() => store.state.cachedViews.map((v) => v.name));
</script>
复制代码

子应用加载组件

// @/views/MicroApp.vue
<template>
  <div>
    <div id="frame"></div>
  </div>
</template>

<script lang='ts'>
import { defineComponent, reactive, watch, onMounted, onUnmounted } from 'vue';
import { useRoute } from 'vue-router';
import { useStore } from 'vuex';
import { loadMicroApp } from 'qiankun';
import NProgress from 'nprogress';
import type { ICachedView } from '@xx/base-core';
import apps from '@/micro/apps';

export default defineComponent({
  name: 'MicroApp',
  setup() {
    const microList = reactive<any>({});
    const appRoute = useRoute();
    const appStore = useStore();

    /**
     * 监听路由变化,新增/修改/删除 缓存
     * @param path
     */
    const activationHandleChange = async (path: string) => {
      const activeRules: string[] = apps.map((app) => app.activeRule as unknown as string);
      const isMicro = activeRules.some((rule) => path.startsWith(rule));
      if (!isMicro) return;
      const conf = apps.find((app) => path.startsWith(app.activeRule.toString()));
      if (!conf) return;
      // 如果已经加载过一次,则无需再次加载
      const current = microList[conf.activeRule.toString()];
      if (current) return;

      // 缓存当前子应用
      NProgress.start();
      const micro = loadMicroApp({ ...conf });
      microList[conf.activeRule.toString()] = micro;
      try {
        await micro.mountPromise;
      } catch (e) {
        console.error(e);
      } finally {
        NProgress.done();
      }
    };

    const hasCachedViews = (key: string, arr: string[]) => arr.some((url: string) => url.startsWith(key));
    
    /**
     * 关闭 tab 标签,卸载已全部关闭的子应用
     * @param newVal
     * @param oldVal
     */
    const unmountMicApp = (newVal: number, oldVal: number) => {
      if (newVal > oldVal) return;
      const cachedViewsAppUrls = appStore.state.cachedViews.map((item: ICachedView) => item.path);
      const keys = Object.keys(microList);
      keys.forEach((key: string) => {
        if (!hasCachedViews(key, cachedViewsAppUrls)) {
          microList[key].unmount();
          delete microList[key];
        }
      });
    };

    watch(() => appRoute.path, activationHandleChange);

    watch(() => appStore.state.cachedViews.length, unmountMicApp);

    onMounted(() => {
      if (window.qiankunStarted) return;
      window.qiankunStarted = true;
      activationHandleChange(appRoute.path);
    });

    onUnmounted(() => {
      window.qiankunStarted = false;
      Object.values(microList).forEach((mic: any) => {
        mic.unmount();
      });
    });
  }
});
</script>
复制代码

如果出现以下异常,可以将不同的子应用挂载在不同的节点上,某个子应用标签全关后,卸载对应的子应用。 image.png image.png

子应用

需要在 App.vue 中实现 KeepAlive 匹配,信息从 qiankun 应用通信中获取

// App.vue
<template>
  <router-view v-slot="{ Component, route }">
    <keep-alive :include="canKeepAlive">
      <component :is="Component" :key="route.fullPath"/>
    </keep-alive>
  </router-view>
</template>

<script setup lang="ts">
import { computed, ref } from 'vue';
import { useStore } from 'vuex';
import { actions } from '@xx/base-core';
import type { ICachedView } from '@xx/base-core';
import routes from '@/router';

const store = useStore();

const cacheViews = ref<ICachedView[]>([]);

actions.onGlobalStateChange((state: Record<string, any>) => {
  cacheViews.value = state.cachedViews;
}, true);

/**
 * 缓存组件名数组
 */
const canKeepAlive = computed(() => {
  // emd 为子应用前缀
  const emdCacheViews = cacheViews.value.filter((item) => item.path.startsWith('/emd/'));
  const cacheViewNames = emdCacheViews.map((item) => routes.find((route) => `/emd${route.path}` === item.path)) || [];
  return cacheViewNames.map((name) => name!.name);
});
</script>
复制代码

可以考虑将此提取为公共组件,仅应用前缀有区别。

幽灵依赖

幽灵依赖,解释起来很简单,即某个包没有被安装(package.json 中并没有,但是用户却能够引用到这个包)。带来的问题就是某个依赖版本变更后,编译报错,找不到某个依赖。

引发这个现象的原因一般是因为 node_modules 结构所导致的,例如使用 yarn 对项目安装依赖,依赖里面有个依赖叫做 foo,foo 这个依赖同时依赖了 bar,yarn 会对安装的 node_modules 做一个扁平化结构的处理(npm v3 之后也是这么做的),会把依赖在 node_modules 下打平,这样相当于 foo 和 bar 出现在同一层级下面。那么根据 nodejs 的寻径原理,用户能 require 到 foo,同样也能 require 到 bar。

pnpm.png

推荐使用 PNPM 管理依赖,。它会创建非扁平的 node_modules 目录,代码仅能访问当前项目所设定的依赖包。建议在项目中限制安装方式,禁止非pnpm安装依赖:

{
    "scripts": {
        "preinstall": "npx only-allow pnpm"
    }
}
复制代码

总结

以上为微前端在实际项目中落地的主要问题总结,当前项目中也还存在很多问题需要改进,如状态管理的冗余、构建困境(Bundle or Bundleless)、自动化覆盖范围等,我们会尝试解决现有问题并持续迭代演进,也欢迎有更多相关方向的经验交流。

附件

PPT

Coding

下载 PPT、源码 的同学请点个 👍

分类:
前端
标签: