你是否还在为“巨石应用”或“跨技术栈集成”而头疼不已?微前端,或许正是你需要的解决方案。它借鉴微服务理念,将单体SPA(单页面应用)拆分为独立开发、独立部署、独立运行的微应用(小型SPA)。
一、Web架构演进
图1:Web应用架构演进示意图
Web应用架构的演进可以分为三个阶段:
- 单体应用
在前期的web应用中,前后端代码紧密耦合在一起(例如经典的JSP技术)。后端查询数据库,并把数据套入html模板返回给浏览器。这种架构的缺点显而易见:用户体验差(每次交互都需要刷新整个页面),更适合展示静态网页
- 前后端分离
AJAX技术的兴起,使得网页在不刷新html的情况下获取后端数据,并局部刷新页面。至此,前后端分离,前端更专注于用户交互并通过XMLHttpRequeat和后端数据交互,SPA成为这个阶段的典型代表。但是,随着业务发展和代码堆积,应用复杂度变得不可控,最终导致了难以维护的“巨石应用”。
- 微服务
后端应用根据业务领域拆分为多个独立的微服务,服务之间通过远程调用进行交互,从而实现复杂度可控。前端调用后端接口时,一般会经过API网关或BFF路由到具体的微服务。
面对业务需求膨胀带来的应用复杂度,后端通过微服务进行拆解分治,而前端还处于传统的SPA,又该如何解决这个问题呢?
二、前端困局
- 巨石应用
用户体验需求的不断提升和前端技术的快速发展,使得SPA的复杂度呈指数级增长。这导致:代码修改牵一发而动全身,维护成本高昂;构建速度缓慢,资源加载延迟,严重影响用户体验(UX)和开发体验(DX)
- 跨技术栈集成
在逐步重构历史系统时,新老应用需要共存;在整合历史应用时,不同技术栈的业务应用需要集成在一起。核心难点在于:不同技术栈,无法统一编码和部署。
上面两个问题表面看似迥然不同,但“巨石应用”的拆分需求与“跨技术栈集成”的整合需求,其本质都是如何实现有效的“系统解耦”。因此,借鉴后端“微服务”的成功经验来解决前端问题,成为一种自然的思路。
三、微服务和微前端
微服务是一种应用架构,它将一个大型单体应用拆分为多个独立的小型服务,并通过轻量级的通信协议组织起来。重点在于独立二字,即独立开发、独立部署、独立运行。微服务具备以下优势:
- 系统解耦:每个服务聚焦单一的业务功能,不互相依赖,系统复杂度可控。
- 快速迭代:新增功能或修复bug时,只要更新对应的服务,不用覆盖整个系统。
- 故障隔离:单个服务故障不会导致整个系统崩溃。
- 多技术栈共存:不同服务可以用不同技术栈开发,解决了逐步重构的多技术栈共存难题。
微前端(Micro Frontends)正是借鉴了微服务的核心理念,将一个单体应用(SPA)拆分为多个“微应用”(SPA)。这些微应用同样遵循“独立开发、独立部署、独立运行”的原则,并通过路由分发机制(通常由“基座应用”负责)组织在一起,共同构成完整的用户界面。
图2:微前端架构示意图
了解了什么是微前端,接下来我们将介绍几个主流的微前端框架,包括使用方法和各自的特点。
四、开山鼻祖:single-spa
作为微前端领域的开创者,single-spa 提出了一种革命性的架构:把微应用当做react/Vue组件进行管理。每个微应用需要导出挂载、卸载等生命周期函数,single-spa会根据url变化调用这些函数控制微应用的挂载和卸载。
1. 使用方法
- 注册微应用。
import { registerApplication } from 'single-spa';
registerApplication({
name: 'app1',
app: () => import('src/app1/main.js'),
activeWhen: '/app1',
customProps: {
some: 'value',
}
);
singleSpa.start() // 启动基座应用
示例说明:当用户访问基座应用的 '/app1' 路由时,single-spa 会动态加载并执行 app1 的 main.js 入口文件,触发其 mount 生命周期函数,最终将 app1 的界面渲染到指定的 DOM 节点。
- 导出微应用。
import React from 'react';
import ReactDOM from 'react-dom';
import App from './index.tsx'
export const bootstrap = () => {
// 可选:微应用首次挂载前执行一次,常用于初始化全局资源
}
export const mount = () => {
ReactDOM.render(<App/>, document.getElementById('root'));
}
export const unmount = () => {
// 可选:微应用卸载时执行,用于清理资源(如事件监听、定时器、DOM元素等)
}
微应用要导出bootstrap、mount、unmount等生命周期函数,single-spa会根据url变化调用这些函数控制微应用的加载和卸载。
2. 局限性
single-spa实现了路由转发和微应用生命周期管理等核心功能,但是也存在以下局限性。
- 基于js entry的资源加载模式
注册时指定微应用的入口js文件路径,如果文件名有变化,需要同步更新基座应用的配置。并且,默认是在编译时加载微应用资源,无法动态加载,违背“独立部署”的原则。另外,微应用只能打包成一个js文件,无法按需加载,影响性能。
- 缺乏沙箱隔离机制
当有多个子应用时,存在js全局变量冲突和css冲突。,比如:微应用A声明了一个全局变量 window.a,这时候切换到微应用B,B也有一个全局变量window.a,如何保证访问到正确的值?这种冲突可能导致难以预测的行为或直接导致应用崩溃
五、更完善的框架:qiankun
qiankun 在 single-spa 的基础上进一步完善,提供了开箱即用的能力,是目前国内最流行的微前端解决方案之一。
1. 使用方法
- 在基座应用中注册微应用。
import { registerMicroApps, start } from 'qiankun';
registerMicroApps([
{
name: 'app1',
entry: 'http://domain1/app1',
container: '#container',
activeRule: '/app1',
},
{
name: 'app2',
entry: 'http://domain2/app2',
container: '#container',
activeRule: '/app2',
},
]);
start();
使用方法跟single-spa差异不大,但是entry不再是具体的js文件路径,而是微应用的入口URL(通常是其index.html的访问地址)。这是 qiankun 解决 single-spa js entry 限制的核心—引入html entry。qiankun会请求入口地址,获取入口html并解析html,然后构造请求去加载html中的script、style资源。
- 导出微应用。
import React from 'react';
import ReactDOM from 'react-dom';
import App from './index.tsx'
export const bootstrap = () => {}
export const mount = (props) => {
ReactDOM.render(<App/>, props.container.getElementById('root'));
}
export const unmount = () => {}
- 微应用打包配置
const packageName = require('./package.json').name;
module.exports = {
output: {
library: `${packageName}-[name]`,
libraryTarget: 'umd',
jsonpFunction: `webpackJsonp_${packageName}`,
},
};
微应用打包成一个umd库,并设置库名称为注册微应用时的name,,便于qiankun正确加载和执行打包后的代码。
2. 优点
- 基于html entry的资源加载模式
在single-spa中要实现动态加载微应用资源,得借助SystemJS工具。
import { registerApplication } from 'single-spa';
registerApplication({
name: 'app1',
app: () => System.import('http://domain1/app1/main.js'),
activeWhen: '/app1',
customProps: {
some: 'value',
}
);
singleSpa.start() // 启动基座应用
只有实现了动态加载,微应用才能独立部署。另外也不再需要关注入口js文件,html entry能够根据入口地址自动加载html、js、css资源。这种方式更符合常规的Web应用加载模式,天然支持动态加载和独立部署。
- js沙箱
qiankun通过js沙箱,解决了js全局变量冲突。具体是通过proxy代理window对象,为每个微应用创建一个window对象副本。当切换应用时,微应用通过proxy访问到对应的window对象副本。
- css隔离
通过shadow dom实现css隔离,这是浏览器原生支持的。为每个微应用创建一个shadow dom的父节点,shadow dom内部的dom和css不会影响外部,外部也不会访问到内部的数据。
还有一种方案就是scoped css,在css选择器里加一个前缀,不同微应用的css选择器就不会冲突。qiankun更推荐使用此方案。
- 应用状态管理
qiankun提供了设置全局状态和监听全局状态的方法,从而实现基座应用和微应用的状态管理。
主应用
import { initGlobalState, MicroAppStateActions } from 'qiankun';
// 初始化 state
const actions: MicroAppStateActions = initGlobalState(state);
actions.onGlobalStateChange((state, prev) => {
// state: 变更后的状态; prev 变更前的状态
console.log(state, prev);
});
actions.setGlobalState(state);
actions.offGlobalStateChange();
微应用
// 从生命周期 mount 中获取通信方法,使用方式和 master 一致
export function mount(props) {
props.onGlobalStateChange((state, prev) => {
// state: 变更后的状态; prev 变更前的状态
console.log(state, prev);
});
props.setGlobalState(state);
}
六、qiankun避坑指南
- 微应用资源跨域
默认情况下,浏览器用<script>和<style>加载js、css资源,不受同源策略限制。而qiankun是由框架构造XHR请求获取微应用的js、css资源,存在跨域问题(主应用和微应用的域名不一致),通过修改微应用的nginx配置即可。
server {
listen 80;
server_name app1.com;
location / {
add_header Access-Control-Allow-Origin main1.com; // 允许基座应用(main1.com)访问
}
}
- dialog组件无法找到body节点
qiankun支持两种沙箱方案,一种是shadow dom,虽然shadow dom可以实现彻底的css隔离,但也会导致微应用无法访问html、body等全局DOM节点,例如dialog、Modal等组件无法找到body节点,进而无法挂在到DOM上。所以一般推荐用另一种方案,即scoped css。
// shadow dom
start({
sandbox: {
strictStyleIsolation: true,
}
})
// scoped css
start({
sandbox: {
experimentalStyleIsolation: true,
}
})
- 微应用hash路由不生效
当基座应用使用history路由,微应用使用hash路由时,会出现在基座应用跳转微应用页面时路由不生效。因为在基座应用是使用的history api,不会触发hash事件。解决方案就是在基座应用路由微应用页面时加上触发hash事件的逻辑。
七、为什么不用iframe
iframe是浏览器原生技术,常用来在网页中嵌入另一个网页,这一点和微前端是不谋而合的。但是它的强隔离性也带来了一系列问题:
- 刷新会丢失路由状态
- dom割裂,iframe内部的弹窗无法全局展示
- 跨域,客户端的登录态无法共享,子应用需要重新登录
- 通信复杂,只能通过postmessage传递消息
因此,对于追求无缝用户体验、复杂交互和状态共享的现代 Web 应用,iframe 通常不是微前端的理想选择。
八、总结
微前端架构借鉴微服务的成功经验,将单体SPA拆分为独立的微应用(独立开发、独立部署、独立运行),解决了“巨石应用”和“跨技术栈集成”的难题。同时也介绍主流微前端框架(single-spa,qiankun,iframe)的相关实践。后续我们将继续探索微前端的核心实现原理,敬请期待。
参考资料
一个js库就把你的网页的底裤🩲都扒了——import-html-entry
微前端方案 qiankun 只是更完善的 single-spa