前言
由于 Web 应用在逐步取代传统的 PC 软件时,大规模 Web 应用在面对高复杂度和涉及团队成员广下无法同时保证 DX(developer experience)和 UX(user experience) 的困境。传统 Web 应用在开发大规模应用和多研发团队协作时面临问题:单一代码仓库维护难以灵活更新,SPA 模式优势性能好但权限、代码复用、构建时间等方面存在挑战,并且拆分成多个仓库维护解决权限、构建、技术体系等问题,但用户体验割裂,跳转页面影响性能,系统间通信困难,产品权限难统一,建立独立孤岛。
传统的 web 应用存在的问题:
- 单页应用模式下,即使只需要访问一个模块,也需要加载所有资源,导致首屏加载缓慢、内存占用高,甚至可能导致项目崩溃。
- 发布应用时需要将所有模块一起打包,即使只有很小的改动也需要重新打包整个项目,耗时通常在二十分钟到半个小时之间。
- 不同模块开发进度不统一时,无法只发布部分模块。有时必须将一些模块回滚至稳定版本,或者暂时不提交不合并,以确保新版本的稳定运行。
- 每个开发小组都需要拉取整个项目的代码才能进行开发调试,即使只负责维护一个模块,本地也会存在大量冗余代码。
什么是微前端
Techniques, strategies and recipes for building a modern web app with multiple teams that can ship features independently. -- Micro Frontends
微前端是一种多个团队通过独立发布功能的方式来共同构建现代化 web 应用的技术手段及方法策略。
简单的说微前端是前端领域的一种架构方法,其关键点还是解耦与分治, 旨在让前端应用也能像后端服务一样实现分布式部署。通过微前端的模块化和独立部署特性,可以让前端团队像后端团队一样独立开发、测试和部署功能,从而实现整个应用的分布式部署和独立运行。
未使用微服务的项目架构
使用微服务的项目架构
由此可见,微前端与普通 Web 应用的区别主要在于架构设计、模块化开发、技术栈多样性、部署和维护、以及用户体验和性能优化等方面。微前端架构更加灵活、可维护,并支持多团队协作开发,适用于大规模复杂 Web 应用的开发。选择合适的架构取决于项目需求和团队情况。
微前端架构蕴核心价值
- 团队自治
在公司中,团队通常根据业务进行划分。在没有微前端的情况下,多个团队共同维护一个项目可能会导致一些冲突,如代码合并和上线时间冲突等。引入微前端后,可以根据业务模块将项目拆分成小模块,每个模块由不同团队独立维护、开发和部署,实现团队自治,减少甚至消除与其他团队的冲突。
- 兼容老项目
对于古老的或其他巨石项目,公司可能不希望继续使用旧的技术栈进行维护。通过微服务方式拆分项目是一个很好的选择,可以使项目更具可维护性和灵活性。
- 跨技术栈
当需要为微前端系统新增业务模块时,只需单独创建一个项目。团队可以根据需要选择自己喜欢的技术栈,即使与其他模块采用不同技术栈也不会带来任何问题。这种灵活性使团队可以更好地发挥各自的优势。
微前端的缺点
-
复杂度从代码转向基础设施
-
整个应用的稳定性和安全性变得更加不可控
-
具备一定的学习和了解成本
-
需要建立全面的微前端周边设施,才能充分发挥其架构的优势
- 调试工具
- 监控系统
- 上层 Web 框架
- 部署平台
微前端架构使用场景
- 大规模企业级 Web 应用开发,能够有效管理复杂的项目结构和团队合作。
- 跨团队及企业级应用协作开发,不同团队独立开发和部署各自的微前端应用。
- 长期收益高于短期收益,微前端架构在长期维护和扩展中具有明显优势。
- 不同技术选型的项目,各个子应用可以采用不同的技术栈,灵活性更高。
- 单个产品中部分模块需要独立发布、灰度等能力,微前端可以实现精细化的部署。
- 微前端的目标并非取代 Iframe,而是更灵活的组织和管理应用。
- 应用的来源必须可信,微前端的各个部分应当安全可靠。
- 用户体验要求更高,微前端架构有利于优化用户体验和提升应用性能。
欧克,以上我们已经初步了解了微前端的背景以及基本概念,接下来将为大家介绍一些主流的微前端解决方案 👌
微前端的解决方案
在研究了大量资料后,以下为解决方案的总结,如有遗漏,欢迎补充~🙂
- 现有微前端解决方案:
- iframe
- Web Components
- ESM
- qiankun
- EMP
- 各解决方案的利弊:
iframe
可以直接加载其他应用,但无法做到单页导致许多功能无法正常在主应用中展示。web Components
及ESM
是浏览器提供给开发者的能力,能在单页中实现微前端,不过后者需要做好代码隔离,并且他们都是浏览器的新特性,都存在兼容性问题,微前端方面的探索也不成熟,只能作为面向未来的微前端手段。qiankun
基本上可以称为单页版的 iframe,具有沙箱隔离及资源预加载的特点,几乎无可挑剔。EMP
作为最年轻微前端解决方案,也是吸收了许多 web 优秀特性才诞生的,它在实现微前端的基础上,扩充了跨应用状态共享、跨框架组件调用、远程拉取 ts 声明文件、动态更新微应用等能力。同时,细心的小伙伴应该已经发现,EMP
能做到第三方依赖的共享,使代码尽可能地重复利用,减少加载的内容。
以下表格为各解决方案的总结:
解决方案 | 相对特点 | 缺点 |
---|---|---|
iframe | 天生隔离样式与脚本、多页 | 不是单页应用,会导致浏览器刷新 iframe url 状态丢失、后退前进按钮无法使用 |
弹框类的功能无法应用到整个大应用中,只能在对应的窗口内展示 | ||
由于可能应用间不是在相同的域内,主应用的 cookie 要透传到根域名都不同的子应用中才能实现免登录效果 | ||
每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程,占用大量资源的同时也在极大地消耗资源 | ||
iframe 的特性导致搜索引擎无法获取到其中的内容,进而无法实现应用的 seo | ||
Web Components | 天生隔离样式与脚本 | 无法兼容所有浏览器 |
ESM | 远程加载模块 | 无法兼容所有浏览器(但可以通过编译工具解决) |
需手动隔离样式(可通过 css module 解决) | ||
qiankun | HTML Entry 接入方式 | - |
EMP | 每个微应用独立部署运行 | 目前无法涵盖所有框架 |
动态更新微应用 | ||
去中心化 | ||
跨技术栈组件式调用 | ||
按需加载 | ||
应用间通信 | ||
生成对应技术栈模板 | ||
远程拉取 ts 声明文件 |
由此可见,qiankun 基本上可以称为单页版的 iframe,具有沙箱隔离和资源预加载的特点,几乎无可挑剔。稍后,将以 qiankun 为示例,为大家详细介绍微前端的项目架构。
微前端-qiankun 示例
需求:做一个 vue3 的微前端,以 vue3 为主应用,其他技术栈为子应用。
微前端分主应用和子应用,主应用主要是负责整个布局、注册子应用以及路由管理;子应用主要是按照业务拆分的独立的单页应用。
微前端的运行过程包括以下步骤:
- 浏览器访问主应用:主应用被下载到浏览器并开始运行。
- 主应用注册子应用:设置子应用相关配置,如子应用名称、入口、加载到的 DOM 元素、激活路径等。
- 启动主应用。
- 加载子应用:浏览器根据子应用入口下载子应用的 HTML 模版。
- 解析子应用:框架开始解析子应用的 HTML 模版,包括 DOM、JavaScript 和 CSS 资源。
- 加载子应用资源:浏览器开始下载子应用的 JavaScript 和 CSS 资源。
- 创建沙箱环境:主应用为子应用创建沙箱环境,以隔离各应用,避免相互干扰。
- 挂载子应用:子应用开始运行。
- 预加载其他子应用资源:微前端采用预加载技术,在网络空闲时加载其他子应用资源,以确保在唤起时资源已经准备就绪,能够快速运行。
1. 创建一个 vue3.0 的基座
vue create main-app
2. 安装 qiankun
$ yarn add qiankun # 或者 npm i qiankun -S
3. 创建 micro-app.ts
// 1.要加载的子应用列表
const microApps = [
{
id: "",
name: "pro-oms-web",// 子应用名称
entry:import.meta.env.MODE === "dev" ? "//localhost:5001" : "/main/omsweb/",//根据环境判断子应用运行地址
activeRule: "/main/omsweb",//匹配的路由
cname: "商品中心",
ename: "oms",
isShow: true,
defaultRoute: "",
},
{
id: "",
name: "pro-wms-web",
entry:
import.meta.env.MODE === "dev" ? "//localhost:7001" : "/main/wmsweb/",
activeRule: "/main/wmsweb",
cname: "订单中心",
ename: "wms",
isShow: true,
defaultRoute: "",
},
{
id: "",
name: "pro-tms-web",
entry:
import.meta.env.MODE === "dev" ? "//localhost:6001" : "/main/tmsweb/",
activeRule: "/main/tmsweb",
cname: "财务中心",
isShow: true,
ename: "tms",
defaultRoute: "",
},
...
];
export default microApps;
4. main.ts 引入 micro-app.ts
import { createApp } from "vue";
import { createRouter, createWebHistory } from "vue-router";
import { createStore } from "vuex";
import App from "./App.vue";
import { registerMicroApps, start } from "qiankun";
import mainApp from "./main-app";
const app = createApp(App);
const router = createRouter({
history: createWebHistory(),
routes: [],
});
const store = createStore({
state: {},
});
app.use(router);
app.use(store);
app.mount("#app");
registerMicroApps(mainApp, {
beforeLoad: (app) => {
console.log("before load app.name====>>>>>", app.name);
},
beforeMount: [
(app) => {
console.log(
"[LifeCycle] before mount %c%s",
"color: green;",
app.name
);
},
],
afterMount: [
(app) => {
console.log(
"[LifeCycle] after mount %c%s",
"color: green;",
app.name
);
},
],
afterUnmount: [
(app) => {
console.log(
"[LifeCycle] after unmount %c%s",
"color: green;",
app.name
);
},
],
});
start();
5. 配置主应用路由
在 main-app/src/view 文件夹下添加 mian.vue 文件作为入口文件
<template>
<div id="test-web"></div>
</template>
<script>
import { onMounted } from 'vue';
export default {
setup() {
onMounted(() => {
// Your mounted logic here
});
}
};
</script>
<style>
#test-web {
width: 100%;
height: 100%;
}
</style>
routes.ts
import { createRouter, createWebHistory } from "vue-router";
import { defineComponent } from "vue";
import HomeView from "../views/HomeView.vue";
const layout = defineComponent(() => import("../views/qiankun/index.vue"));
const routes = [
{
path: "/",
name: "home",
component: HomeView,
},
{
path: "/about",
name: "about",
component: () =>
import(/* webpackChunkName: "about" */ "../views/AboutView.vue"),
},
{
path: "/test-web/*",
meta: "test-web",
component: layout,
},
];
const router = createRouter({
history: createWebHistory(),
routes,
});
export default router;
6. 创建子应用
vue create goods-app
7. 修改子应用 main.ts
import { createApp } from "vue";
import { createWebHistory, createRouter } from "vue-router";
import { createStore } from "vuex";
import App from "./App.vue";
import router from "./router";
import store from "./store";
const app = createApp(App);
app.use(router);
app.use(store);
app.config.productionTip = false;
let instance = null;
function render(props = {}) {
const { container } = props;
console.log(11111111111111, window.__POWERED_BY_QIANKUN__, "字段值");
instance = app.mount(
container ? container.querySelector("#app") : "#app",
true
); //开启沙箱
}
if (!window.__POWERED_BY_QIANKUN__) {
console.log("独立运行");
render();
}
function storeTest(props) {
props.onGlobalStateChange &&
props.onGlobalStateChange(
(value, prev) =>
console.log(
`[onGlobalStateChange - ${props.name}]:`,
value,
prev
),
true
);
props.setGlobalState &&
props.setGlobalState({
ignore: props.name,
user: {
name: props.name,
},
});
}
export async function bootstrap() {
console.log("111111111111 [vue] vue app bootstraped");
}
export async function mount(props) {
console.log("11111111111 [vue] props from main framework", props);
storeTest(props);
render(props);
}
export async function unmount() {
instance.unmount();
instance.$el.innerHTML = "";
instance = null;
}
8. 注册子应用路由
<!--子应用页面代码-->
<template>
<div class="goods-app">我是子应用页面11</div>
</template>
<style lang="scss" scoped>
.goods-app {
cursor: pointer;
background-color: aqua;
}
</style>
<!--router代码-->
import { createRouter, createWebHistory } from 'vue-router';
import { defineAsyncComponent } from 'vue';
import HomeView from '../views/HomeView.vue';
const routes = [
{
path: '/',
name: 'home',
component: HomeView
},
{
path: '/about',
name: 'about',
component: defineAsyncComponent(() => import(/* webpackChunkName: "about" */ '../views/AboutView.vue'))
},
{
path: '/test',
name: 'test',
component: defineAsyncComponent(() => import(/* webpackChunkName: "about" */ '../views/subapp/index.vue'))
},
{
path: '/testtwo',
name: 'testtwo',
component: defineAsyncComponent(() => import(/* webpackChunkName: "about" */ '../views/subapp/two.vue'))
}
];
const router = createRouter({
history: createWebHistory(window.__POWERED_BY_QIANKUN__ ? '/test-web/' : '/'),
routes
});
export default router;
9. 修改 vite.config.ts
import { name } from "./package.json";
module.exports = {
publicPath: "/", // 打包相对路径
devServer: {
port: 8081, // 运行端口号
headers: {
"Access-Control-Allow-Origin": "*", // 防止加载时跨域
},
},
chainWebpack: (config) => config.resolve.symlinks(false),
configureWebpack: {
output: {
library: `${name}-[name]`,
libraryTarget: "umd", // 把微应用打包成 umd 库格式
// webpack5.0以上版本使用如下字段
chunkLoadingGlobal: `webpackJsonp_${name}`,
},
},
};
10. 修改引用主应用和子应用
主应用的 App.vue 添加如下代码
<script setup lang="ts"></script>
<template>
<el-config-provider namespace="eh">
<router-view></router-view>
</el-config-provider>
</template>
<script setup lang="tsx">
import { ElConfigProvider } from "element-plus";
import { onBeforeMount } from "vue";
import { useDictStore } from "@/stores/modules/dict";
const dictData = useDictStore();
onBeforeMount(() => {
// 获取前置数据
});
</script>
项目目录
常见问题
如何独立运行微应用?
if (!window.qiankunStarted) {
//判断是否为qiankun环境下运行
window.qiankunStarted = true;
start({});
}
微应用之间如何跳转?
微应用之间的跳转,或者微应用跳主应用页面,直接使用微应用的路由实例是不行的,如 react-router 的 Link 组件或 vue 的 router-link,原因是微应用的路由实例跳转都基于路由的 base
。有这几种办法可以跳转:
history.pushState()
:mdn 用法介绍- 直接使用原生 a 标签写完整地址,如:
<a href="http://localhost:8080/app1">app1</a>
- 修改 location href 跳转,如:
window.location.href = 'http://localhost:8080/app1'
如何同时激活两个微应用?
微应用何时被激活完全取决于你的 activeRule
配置,比如下面的例子里,我们将 reactApp
和 react15App
的 activeRule
逻辑设置成一致的:
registerMicroApps([
// 自定义 activeRule
{ name: 'reactApp', entry: '//localhost:7100', container, activeRule: () => isReactApp() },
{ name: 'react15App', entry: '//localhost:7102', container, activeRule: () => isReactApp() },
{ name: 'vueApp', entry: '//localhost:7101', container, activeRule: () => isVueApp() },
]);
start({ singular: false });
当在 start
方法中配置好 singular: false
后,只要 isReactApp()
返回 true
时,reactApp
和 react15App
将会同时被 mount。
页面上不能同时显示多个依赖于路由的微应用,因为浏览器只有一个 url,如果有多个依赖路由的微应用同时被激活,那么必定会导致其中一个 404。
如何解决拉取微应用 entry 时 cookie 未携带的问题
因为拉取微应用 entry 的请求都是跨域的,所以当你的微应用是依赖 cookie (如登陆鉴权)的情况下,你需要通过自定义 fetch 的方式,开启 fetch 的 cors 模式:
-
如果你是通过 registerMicroApps 加载微应用的,你需要在 start 方法里配置自定义 fetch,如:
import { start } from 'qiankun'; start({ fetch(url, ...args) { // 给指定的微应用 entry 开启跨域请求 if (url === 'http://app.alipay.com/entry.html') { return window.fetch(url, { ...args, mode: 'cors', credentials: 'include', }); } return window.fetch(url, ...args); }, });
-
如果你是通过 loadMicroApp 加载微应用的,你需要在调用时配置自定义 fetch,如:
import { loadMicroApp } from 'qiankun'; loadMicroApp(app, { fetch(url, ...args) { // 给指定的微应用 entry 开启跨域请求 if (url === 'http://app.alipay.com/entry.html') { return window.fetch(url, { ...args, mode: 'cors', credentials: 'include', }); } return window.fetch(url, ...args); }, });
- 如果你是通过 umi plugin 来使用 qiankun 的,那么你只需要给对应的微应用开启 credentials 配置即可:
export default { qiankun: { master: { apps: [ { name: 'app', entry: '//app.alipay.com/entry.html', + credentials: true, } ] } } }
如何解决子应用给 window 对象添加事件处理函数不生效的问题
由于子应用访问的 window 对象是被 qiankun 代理后的对象,因此直接给 window 对象添加事件处理函数是无效的,可以通过 addEventListener 给 window 添加事件监听器来解决该问题:
window.addEventListener('eventName', eventHandler);
当然,市面上目前比较流行的微前端框架有两种,不仅阿里基于 single-spa 的 qiankun,还有很多,比如京东基于 web-component 的 micro-app,大家有兴趣的话可以点赞收藏,后续会持续更新~
结语
综上所述,随着 Web 应用逐渐替代传统 PC 软件,我们面临着开发大规模应用和协作团队时的挑战。 在面对这些挑战时,我们需要综合考虑团队协作、开发体验和用户体验之间的平衡。通过合理的架构设计、技术选型和团队协作机制,我们可以克服这些困难,打造出既具有良好性能又提供流畅用户体验的大规模 Web 应用。愿我们在不断探索和实践的过程中,开拓出更加优秀和创新的解决方案,为 Web 应用发展贡献力量。
作者:洞窝-重阳