此文为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 开发没多大区别。调整点如下:
主体应用
-
添加子应用及规则
// 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; 复制代码
-
添加子应用渲染路由
{ path: ':micro(emd|et|rpt):endPath(.*)', name: 'MicroApp', component: () => import('@/views/MicroApp.vue') } 复制代码
-
实现子应用加载
// @/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();
}
复制代码
子应用接入到框架应用里,只要子应用实现了 bootstrap
、mount
和 unmount
三个生命周期钩子,有这三个函数导出,框架应用就可以知道如何加载这个子应用。
当子应用第一次挂载的时候,会执行 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-sass
对 unicode
编码的 bug。
解决方式有两种:
- 将
dart-sass
换为node-sass
- 在
dart-sass
编译前先处理unicode
编码
方案一 node-sass
技术过时、官方不在维护、编译慢、国内下载时间长还大概率下载失败,不考虑。那就只能选择方案二,根据 dart-sass
上 issue 的提示,用了一个 postcss 插件
解决方案:
-
添加
postcss.config.js
// postcss.config.js module.exports = { plugins: [ require('postcss-sass-unicode'), require('autoprefixer') ] }; 复制代码
添加后打包效果
不过有个问题有点意思,就是 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
配置如下:
-
安装 unplugin-vue-components :
npm i unplugin-vue-components -D
-
添加配置 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 支持
产品希望支持面包屑标签切换,并能缓存页面状态。根据我们上面的实现,自动加载子应用并不能通过 KeepAlive 标签实现状态缓存,
查看 qiankun 的 API 说明后,发现可使用 loadMicroApp
手动加载子应用实现。
设计思路
-
通过主体应用的导航卫士
router.beforeEach
实现对需要缓存的路由映射记录 -
通过 qiankun 的通信机制将缓存信息发送到子应用,子应用负责各自的 KeepAlive
微前端的KeepAlive 和单体的有区别。多个应用会存在多个Vue实例,所以每个应用均需要实现 KeepAlive。
-
根据路由匹配规则,实现对子应用的手动加载,并缓存其状态
实现
主体应用
导航卫士,一般这里面会设计权限拦截。
/**
* 是否存在缓存数据中
* @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>
复制代码
如果出现以下异常,可以将不同的子应用挂载在不同的节点上,某个子应用标签全关后,卸载对应的子应用。
![]()
子应用
需要在 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 管理依赖,。它会创建非扁平的 node_modules 目录,代码仅能访问当前项目所设定的依赖包。建议在项目中限制安装方式,禁止非pnpm安装依赖:
{
"scripts": {
"preinstall": "npx only-allow pnpm"
}
}
复制代码
总结
以上为微前端在实际项目中落地的主要问题总结,当前项目中也还存在很多问题需要改进,如状态管理的冗余、构建困境(Bundle or Bundleless)、自动化覆盖范围等,我们会尝试解决现有问题并持续迭代演进,也欢迎有更多相关方向的经验交流。
附件
PPT
Coding
下载 PPT、源码 的同学请点个 👍