前端微服务架构解析:qiankun 运行原理详解
前端微服务在现在的前端项目中也不陌生,经常在一些大型的项目中有看到过它的身影,而如今最火也最常用的,当初qiankun了,今天我们来聊一聊qiankun是如何实现微服务的
一、前端微服务架构的价值与应用场景
首先,为什么需要有微服务这么一种技术架构呢,它的出现解决了什么问题呢?我们先来看几个场景。
- 假设你正在维护迭代一个大型项目,但是你每次迭代只是在修改项目的某一个模块,但你每次的代码开发,提交,部署都需要拉上整个项目。耗费的资源更多,速度更慢。这时候你是不是就会想,要是可以只部署这一个模块就好了
- 假设你公司有一个很陈旧的老项目,用的很老旧的技术架构,你们需要在这个的基础之上去迭代新的需求,想用新的技术架构去进行迭代,你是不是就会想,要是新增部分,能独立开发,独立部署就好了
是的,此时,微服务它就诞生了
解决的问题与优势:
- 巨石应用拆分:解决单体前端工程臃肿、维护困难的问题,支持多团队并行开发
- 技术栈无关:主应用与子应用可使用不同框架(React/Vue/Angular),实现渐进式升级
- 独立部署,可跨团队协作:子应用可独立构建部署,无需整体发布,降低发布风险
- 资源按需加载:仅加载当前路由匹配的子应用,优化首屏性能
典型应用场景:
- 大型后台管理系统(如阿里云控制台)
- 多产品线聚合平台(如电商导购门户)
- 遗留系统现代化改造
- 跨团队协作项目
**二、qiankun 核心实现原理详解
微服务的工作本质,是能够把多个独立项目能够拼接组装成一个大型项目,那既然是拼接的,我们肯定就会疑惑,它是如何把几个毫无联系的独立应用串起来的,它中间做了些什么呢?又是如何进行数据通信的呢?又是如何保证几个应用相互不冲突呢?
诶,下面我们就来看看,qiankun是怎么做的
2.1 子应用注册机制
首先,会有一个主应用的概念,用来管理协调各个子应用,而其他子应用,都需要在主应用中进行注册
原理:通过配置对象声明子应用信息,qiankun 创建应用管理器进行统一调度
// 主应用注册子应用
import { registerMicroApps, start } from 'qiankun';
// registerMicroApps内部最终还是通过调用single-spa的registerApplication方法去注册应用
registerMicroApps([
{
name: 'react-app', // 应用唯一标识
entry: '//localhost:3001', // 子应用入口地址
container: '#subappReact', // 挂载容器
activeRule: '/react', // 路由匹配规则
props: { user: 'admin' } // 透传数据
},
{
name: 'vue-app', // 应用唯一标识
entry: '//localhost:3002', // 子应用入口地址
container: '#subappVue', // 挂载容器
activeRule: '/vue', // 路由匹配规则
props: { user: 'admin' } // 透传数据
}
]);
start(); // 启动 qiankun
关键注解:
activeRule支持函数/字符串/正则,实现动态路由匹配props实现主应用向子应用的数据注入- 应用实例存储在全局
apps数组中统一管理
子应用注册,并配置了匹配规则,当项目运行时,浏览器访问路由页面或页面路由发生变化时,qiankun会用当前的路由去和注册的规则表进行一个匹配,当匹配到某个规则时,会开始去加载当前规则的子应用(根据配置的entry地址加载子应用),加载完成后,将加载到的内容挂载到配置的container元素上,这个container元素,一定是主应用中的某个元素。
2.2 路由规则匹配
实现流程:
- 监听
hashchange或popstate事件 - 遍历所有注册应用的
activeRule - 执行匹配算法确定需加载的子应用
// 路由匹配核心逻辑(简化版)
function matchApp(currentPath) {
return apps.filter(app => {
if (typeof app.activeRule === 'function') {
return app.activeRule(currentPath);
}
// pathPrefix是个工具函数,里面是一系列的匹配规则
return pathPrefix(app.activeRule, currentPath);
});
}
通过匹配确定了需要加载某个应用后,会去取到该app的entry属性,通过这个地址去加载子应用,加载完成后,去执行子应用。那么我们之前有说到,每个子应用,它都是相互独立的,是不会相互影响的,那当多个子应用代码集中在一起执行时,必然要通过隔离机制,将各个子应用以及主应用相互隔离,这必然要用到沙箱机制
2.3 沙箱机制(核心安全隔离)
qiankun主要用到3种沙箱类型
沙箱类型:
| 类型 | 原理 | 适用场景 |
|---|---|---|
LegacySandbox |
单例代理 | 兼容性要求高 |
ProxySandbox |
多例代理 | 多实例并行 |
SnapshotSandbox |
快照恢复 | IE 等老旧浏览器 |
现在最普遍的就是ProxySandbox,我们以ProxySandbox来做说明
代理沙箱:
代理沙箱的本质是为每个微应用创建一个 window 的代理对象,微应用对全局变量的操作都发生在代理上,不会影响真实的 window。可以理解为,每个子应用代码中的顶级对象不再是window,而是一个集成了window部分属性的代理对象,因此,所有子应用的全局变量定义,函数定义等,本质都会挂在这个代理对象下,然后子应用代码都执行在一个自调用的函数中。从而实现js互不干扰
// 代理沙箱实现(核心代码)
// 简化的 ProxySandbox 实现
class ProxySandbox {
constructor(name) {
this.name = name;
// 创建一个空的 fakeWindow 作为初始全局对象副本
const rawWindow = window; // 保存真实的 window
const fakeWindow = Object.create(null);
// 用于记录在沙箱内新增或修改的全局变量
this.updatedPropsMap = new Map();
// 创建 Proxy 代理 fakeWindow
this.proxy = new Proxy(fakeWindow, {
set: (target, prop, value) => {
// 将修改记录在 updatedPropsMap 中,而不是直接设置到 rawWindow
this.updatedPropsMap.set(prop, value);
// 这里也可以选择将修改同步到 fakeWindow 上,供内部读取
target[prop] = value;
return true;
},
get: (target, prop) => {
// 优先从修改记录中取
if (this.updatedPropsMap.has(prop)) {
return this.updatedPropsMap.get(prop);
}
// 否则,从真实的 window 上读取(如一些原生 API)
const value = rawWindow[prop];
if (typeof value === 'function') {
return value.bind(rawWindow); // 保持函数执行上下文正确
}
return value;
},
has: (target, prop) => {
return prop in rawWindow || this.updatedPropsMap.has(prop);
}
});
}
}
css隔离:
而css隔离主要是通过给每个子应用的所有样式添加一个特殊的前缀(data-qiankun=xxx)来实现
// 样式隔离的简化思路(通常在处理 HTML 模板时进行)
function processHTML(html, appName) {
// 使用一个唯一的属性选择器为所有样式规则添加作用域
const scopedHTML = html.replace(/<style>([\s\S]*?)<\/style>/g, (match, styleContent) => {
const scopedStyle = styleContent.replace(/([^{]+\{)([^}]+)(\})/g, (fullMatch, selectorPart, contentPart, endPart) => {
// 简化处理:为所有简单选择器添加 [data-qiankun="appName"] 前缀
// 实际实现会更复杂,需要处理各种复杂选择器
const scopedSelector = selectorPart.split(',').map(selector => {
return `[data-qiankun="${appName}"] ${selector.trim()}`;
}).join(', ');
return `${scopedSelector} { ${contentPart} ${endPart}`;
});
return `<style>${scopedStyle}</style>`;
});
return scopedHTML;
}
2.4 子应用加载执行流程
生命周期时序:
加载逻辑:
当通过路由匹配到需要加载的app后,会通过importEntry方法去加载子应用,方法内部会利用import-html-entry库去解析子应用,解析出模板,样式以及需要执行的script等,执行script时,会放置在沙箱中执行
// 简化的 loadApp 函数核心逻辑
export async function loadApp(appConfig) {
// 1. 获取并解析 HTML 入口
const { template, getExternalScripts, getExternalStyleSheets } = await importHTML(appConfig.entry);
// 2. 创建沙箱环境
const sandbox = createSandbox(appConfig.name);
sandbox.active(); // 激活沙箱
const fakeWindow = sandbox.proxy;
// 3. 获取并执行样式
const styles = await getExternalStyleSheets();
styles.forEach(styleContent => {
const style = document.createElement('style');
style.textContent = styleContent;
document.head.appendChild(style);
});
// 4. 关键逻辑:获取脚本并放在沙箱中执行
const scripts = await getExternalScripts();
scripts.forEach(scriptCode => {
// 使用 with 语句和 Proxy 将脚本中的全局变量访问指向伪造的 window (fakeWindow)
const wrappedCode = `
with(fakeWindow) {
${scriptCode}
}
`;
// 在沙箱上下文(如 iframe 或 Proxy 创建的隔离环境)中执行代码
(function(fakeWindow) { eval(wrappedCode); }).bind(sandbox.proxy)(sandbox.proxy);
});
// 5. 返回一个包含生命周期钩子的对象
return {
// 通常,子应用的 JS 入口文件会将其生命周期函数挂载到全局,这里需要获取它们
bootstrap: window[`${appConfig.name}_bootstrap`],
mount: window[`${appConfig.name}_mount`],
unmount: window[`${appConfig.name}_unmount`],
};
}
with语句:改变 JavaScript 的作用域链查找顺序。代码中所有未明确作用域的变量(如 window)会优先从 sandbox.proxy(也就是fakeWindow)中查找。
2.5 生命周期调度机制
而子应用中,需要暴露对应的生命周期接口,供主应用调用,主应用会在子应用挂载时,调用对应的生命周期函数
标准化生命周期:
// 子应用暴露的接口(React 示例)
export const bootstrap = async () => {
console.log('初始化资源');
};
export const mount = async (props) => {
ReactDOM.render(<App/>, props.container);
};
export const unmount = async (props) => {
ReactDOM.unmountComponentAtNode(props.container);
};
qiankun 调度引擎:
// 生命周期执行器(核心)
async function execHooks(app, hookName) {
const hooks = app[hookName] || [];
for (const hook of hooks) {
await hook(app.props); // 透传容器等参数
}
}
// 挂载流程
async function mountApp(app) {
await execHooks(app, 'beforeMount');
await execHooks(app, 'mount'); // 执行子应用的 mount
await execHooks(app, 'afterMount');
}
2.6 通信原理
通信机制特点:
- 发布订阅模式:状态变更通知所有关联应用
- 单向数据流:主应用作为数据源
- 防循环触发:通过事务ID避免无限循环
qiankun 提供了一个简单的全局状态管理工具,基于发布-订阅模式
// 简化的 initGlobalState 实现
class GlobalState {
constructor(initialState = {}) {
this.state = initialState;
this.listeners = [];
}
setGlobalState(newState) {
this.state = { ...this.state, ...newState };
// 通知所有订阅者
this.listeners.forEach(listener => listener(this.state));
}
onGlobalStateChange(callback) {
this.listeners.push(callback);
// 返回一个取消订阅的函数
return () => {
const index = this.listeners.indexOf(callback);
if (index > -1) {
this.listeners.splice(index, 1);
}
};
}
}
let globalStateInstance = null;
export function initGlobalState(state) {
if (globalStateInstance === null) {
globalStateInstance = new GlobalState(state);
}
return globalStateInstance;
}
子应用可以通过onGlobalStateChange监听state的变化,并通过setGlobalState去改变全局状态,全局状态一旦发生改变,会立刻通知所有的其他监听者
export async function mount(props) {
// 监听数据变化
props.onGlobalStateChange((state) => {
// state: 变更后的状态;
if (!state.routes.length) {
const _state = {
routes: routes
}
// 设置数据
props.setGlobalState(_state);
}
}, true); // 第二个参数设置为 true 表示一上来就执行一次
render(props)
}
start初始化
主应用注册完子应用后,会调用start方法进行初始化
// 基于 src/apis.ts 和 src/start.ts 简化
export function start(opts: FrameworkConfiguration = {}) {
// 1. 设置全局变量,标记 qiankun 启动状态
window.__POWERED_BY_QIANKUN__ = true; // :cite[2]
// 2. 初始化全局配置,使用默认值合并用户配置
frameworkConfiguration = {
prefetch: true,
singular: true, // 默认单例模式
sandbox: true, // 默认开启沙箱
...opts,
};
const {
prefetch,
singular = true,
sandbox = true,
...importEntryOpts
} = frameworkConfiguration;
// 3. 配置预加载策略
if (prefetch) {
// 在第一个应用挂载后预加载其他应用资源 :cite[2]
listenAfterFirstMount(() => prefetchAfterFirstMounted(apps, prefetch));
}
// 4. 设置沙箱模式
if (sandbox) {
if (window.Proxy) {
// 支持 Proxy 的浏览器使用先进的沙箱
if (singular) {
legacySandbox = true;
} else {
proxySandbox = true;
}
} else {
// 不支持 Proxy 的浏览器使用降级方案 SnapshotSandbox :cite[1]
snapshotSandbox = true;
}
}
// 5. 启动 single-spa
startSingleSpa({ urlRerouteOnly: true }); // :cite[7]
frameworkStartedDefer.resolve(); // 标记框架已启动
}
三、模块联邦
有时当我们有些功能模块需要在各个组件之间复用时,比如A应用的一个模块,希望其他应用也能够调用它。这是一个很常见的需求,之前,webpack5提供了一个模块联邦的能力,很好的解决了这个问题。
模块联邦在构建阶段就明确了模块的“提供方”和“消费方”。在运行时,消费方应用会通过一个全局的模块映射表,动态拉取并提供方应用暴露的代码并执行。这更像是“代码复用”而非传统“通信”,但能达到共享逻辑和状态的效果。
实现原理:
远程应用:暴露一些模块给其他应用使用。这些模块可以是组件、页面、工具函数等。
主机应用:消费(使用)远程应用暴露的模块。
它不是在“加载一个应用”,而是在“加载一个模块”,而这个模块恰好可以是一个完整的 React/Vue 应用。
配置详情:
远程应用配置 (app1/webpack.config.js)
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: ‘app1’,
filename: ‘remoteEntry.js’, // 入口文件
exposes: {
‘./Button’: ‘./src/Button’, // 暴露一个按钮组件
‘./App’: ‘./src/App’, // 暴露整个App组件
},
shared: { react: { singleton: true }, ‘react-dom’: { singleton: true } }, // 共享依赖
}),
],
};
主机应用配置 (host/webpack.config.js):
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: ‘host’,
remotes: {
app1: ‘app1@http://localhost:3001/remoteEntry.js’, // 引用远程应用
},
shared: { react: { singleton: true }, ‘react-dom’: { singleton: true } },
}),
],
};
使用:
// 像导入本地模块一样导入远程模块!
import RemoteButton from ‘app1/Button’;
import RemoteApp from ‘app1/App’;
function HostApp() {
return (
<div>
<h1>我是主应用</h1>
<RemoteButton />
<RemoteApp />
</div>
);
}
四、总结
加载流程复盘:
用户访问 https://portal.com。
1、主应用加载,初始化路由系统。
2、用户点击导航跳转到 /app1。
3、主应用的路由器捕获到变化,查询映射表,发现 /app1 对应 //app1.com/bundle.js。
4、主应用通过 动态脚本加载(如 System.import() 或 自己创建
5、主应用创建沙箱环境,在沙箱中运行微应用代码
6、微应用的脚本执行后,会向全局(如 window)暴露其生命周期函数(window.app1.bootstrap, window.app1.mount 等)。
7、主应用先调用 app1.bootstrap() 进行初始化。
8、主应用准备好一个 DOM 容器(如
9、微应用 app1 在自己的 mount 方法中,将整个应用渲染到主应用提供的容器中。
10、当用户从 /app1 导航到 /app2 时,主应用会先调用 app1.unmount() 进行清理,然后重复步骤 4-9 来加载和挂载 app2。
本文剖析了 qiankun 的大致的架构设计与实现,其目标是让读者能够以一个更简单的角度来更简单的理解qiankun以及微服务的大致原理。
更多推荐
所有评论(0)