"我们的文档系统用乾坤拆成了主应用和文档子应用,现在文档子应用需要加载后台分 10 批返回的 5 万字 Markdown 流式数据,怎么确保在乾坤环境下既能实时渲染,又不影响主应用性能?"
面试官的问题直击乾坤微前端的核心痛点 ------ 主应用与子应用的资源隔离、通信效率。我决定从乾坤的架构特性出发,在之前 "四步实操流程" 的基础上,补充微前端环境下的特殊处理方案。
一、先明确乾坤环境的 "流式数据处理前提"
在动手前,必须先解决乾坤微前端的两个关键限制,否则流式处理会出现 "资源阻塞" 或 "通信失效":
- 子应用资源加载策略:乾坤默认会拦截子应用的fetch请求,需配置excludeAssetFilter排除流式接口,避免主应用缓存分片数据;
- 主 - 子应用通信方式:如果流式数据需共享给主应用(比如进度展示),不能用props(同步通信不支持流式),需用乾坤的initGlobalState或EventBus做异步通信。
面试官补充:"文档子应用独立处理流式渲染,只需把加载进度同步给主应用的进度条组件。" 明确需求后,开始制定方案。
二、乾坤子应用的 "五步流式处理实操"
以 Vue3 + 乾坤的文档子应用为例,完整流程需兼顾 "流式处理" 和 "微前端适配":
第一步:配置乾坤,放行流式接口
在主应用注册子应用时,必须排除流式接口的拦截,否则text/event-stream类型的请求会被乾坤当作普通静态资源缓存:
javascript
// 主应用 src/micro-app.js(乾坤注册子应用配置)
import { registerMicroApps, start } from 'qiankun';
registerMicroApps([
{
name: 'doc-app', // 子应用名称
entry: '//localhost:8081', // 子应用地址
container: '#doc-container', // 子应用容器
activeRule: '/doc', // 激活路由
// 关键:排除流式接口,避免乾坤拦截
excludeAssetFilter: (assetUrl) => {
// 匹配子应用的流式接口(需和子应用接口路径一致)
return assetUrl.includes('/api/markdown-stream');
}
}
]);
start();
同时在子应用的public-path.js中,确保fetch不被乾坤重写(Vue3 子应用为例):
javascript
// 子应用 src/public-path.js
if (window.__POWERED_BY_QIANKUN__) {
// 仅重写静态资源路径,不重写fetch
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
第二步:子应用初始化流式请求(适配乾坤通信)
在子应用的DocStream.vue组件中,发起流式请求,并初始化主 - 子通信:
xml
<template>
<div class="doc-container">
<div id="loading" v-if="loading">加载中:{{ progress }}%</div>
<div id="markdown-container" class="markdown-content"></div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { marked } from 'marked';
import hljs from 'highlight.js';
// 引入乾坤通信工具(子应用需安装 qiankun 依赖)
import { initGlobalState } from 'qiankun';
const loading = ref(true);
const progress = ref(0);
const totalChunks = ref(10); // 后台约定的总分片数(需和后台对齐)
let currentChunkCount = ref(0);
let reader = null; // 存储流式读取器,用于卸载时销毁
let globalState = null; // 乾坤全局状态(同步进度给主应用)
// 初始化乾坤全局通信
const initQiankunState = () => {
if (window.__POWERED_BY_QIANKUN__) {
globalState = initGlobalState({
docLoadProgress: 0 // 初始进度
});
// 监听主应用的状态变更(可选,这里仅子应用向主应用发消息)
globalState.onGlobalStateChange((state) => {
console.log('主应用状态变更:', state);
});
}
};
// 发起流式请求(核心逻辑和之前一致,新增进度计算)
const streamRequest = async () => {
try {
const response = await fetch('/api/markdown-stream', { // 子应用流式接口
method: 'GET',
headers: {
'Accept': 'text/event-stream',
'Authorization': 'Bearer ' + localStorage.getItem('token'),
// 关键:告诉后台当前是乾坤子应用,避免返回格式差异
'X-Qiankun-App': 'doc-app'
},
cache: 'no-store' // 关闭缓存
});
if (!response.ok) {
throw new Error(`接口失败:${response.status}`);
}
if (!response.body) {
throw new Error('后台未返回流式数据');
}
await handleStreamData(response);
} catch (error) {
console.error('流式请求失败:', error);
document.getElementById('markdown-container').innerText = '文档加载失败,请刷新';
loading.value = false;
}
};
onMounted(() => {
initQiankunState(); // 初始化乾坤通信
streamRequest(); // 发起流式请求
});
onUnmounted(() => {
// 关键:卸载时销毁流式连接,避免主应用切换子应用后内存泄漏
if (reader) {
reader.cancel('子应用卸载,中断流式连接');
}
// 重置主应用进度
if (globalState) {
globalState.setGlobalState({ docLoadProgress: 0 });
}
});
</script>
第三步:子应用逐片读取数据,同步进度给主应用
在handleStreamData函数中,新增 "进度计算" 和 "乾坤状态同步",确保主应用能实时显示进度:
ini
// 子应用 DocStream.vue 中新增 handleStreamData 函数
const handleStreamData = async (response) => {
reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let completeMarkdown = '';
let isEnd = false;
while (!isEnd) {
try {
const { done, value } = await reader.read();
isEnd = done;
if (value) {
const currentChunk = decoder.decode(value, { stream: true });
completeMarkdown += currentChunk;
currentChunkCount.value++; // 累加分片数
// 1. 计算进度(当前分片数/总分片数*100)
progress.value = Math.floor((currentChunkCount.value / totalChunks.value) * 100);
// 2. 同步进度给主应用(仅在乾坤环境下)
if (window.__POWERED_BY_QIANKUN__ && globalState) {
globalState.setGlobalState({
docLoadProgress: progress.value
});
}
// 3. 实时渲染
renderMarkdown(completeMarkdown);
}
// 所有分片加载完成
if (isEnd) {
loading.value = false;
await reader.releaseLock();
// 同步"加载完成"状态给主应用
if (globalState) {
globalState.setGlobalState({ docLoadProgress: 100 });
}
}
} catch (error) {
console.error('读取分片失败:', error);
await reader.releaseLock();
loading.value = false;
// 同步"加载失败"状态给主应用
if (globalState) {
globalState.setGlobalState({ docLoadProgress: -1 });
}
break;
}
}
};
第四步:子应用实时渲染,处理 "乾坤样式隔离"
乾坤默认开启sandbox样式隔离,子应用的 Markdown 渲染样式会被隔离,需通过 "样式穿透" 或 "主应用共享样式" 解决:
ini
// 子应用 DocStream.vue 中 renderMarkdown 函数(新增样式适配)
const renderMarkdown = (content) => {
// 1. 补全不完整分片(和之前逻辑一致)
let renderContent = content;
if (/-\s[^(\n|$)]*$/.test(renderContent)) {
renderContent += '\n';
}
// 2. 初始化marked(适配乾坤样式隔离)
marked.setOptions({
breaks: true,
highlight: (code, lang) => {
// 关键:代码高亮样式需添加子应用前缀,避免被乾坤隔离
const highlightedCode = hljs.highlight(code, { language: lang || 'plaintext' }).value;
// 给高亮代码添加子应用专属类(doc-app-code),主应用可通过该类控制样式
return `<pre class="doc-app-code"><code>${highlightedCode}</code></pre>`;
}
});
// 3. 渲染到DOM(容器需在子应用内,避免被主应用DOM拦截)
const container = document.getElementById('markdown-container');
container.innerHTML = marked.parse(renderContent);
// 4. 样式穿透:如果子应用样式被隔离,手动添加主应用共享样式
if (window.__POWERED_BY_QIANKUN__) {
// 动态加载主应用的Markdown样式(避免子应用重复引入)
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '//localhost:8080/styles/markdown-common.css'; // 主应用共享样式
link.id = 'markdown-common-style';
// 避免重复加载
if (!document.getElementById('markdown-common-style')) {
container.appendChild(link);
}
}
};
第五步:主应用接收进度,同步更新 UI
主应用的进度条组件(比如ProgressBar.vue)通过乾坤的onGlobalStateChange接收子应用的进度:
xml
// 主应用 src/components/ProgressBar.vue
<template>
<div class="progress-bar" v-if="showProgress">
<div class="progress" :style="{ width: `${progress}%` }"></div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { initGlobalState } from 'qiankun';
const progress = ref(0);
const showProgress = ref(false);
let globalState = null;
onMounted(() => {
// 初始化全局状态监听
globalState = initGlobalState({});
globalState.onGlobalStateChange((state) => {
// 接收子应用的文档加载进度
if (state.docLoadProgress !== undefined) {
progress.value = state.docLoadProgress;
// 控制进度条显示:-1(失败)、100(完成)时隐藏
showProgress.value = progress.value >= 0 && progress.value < 100;
}
});
});
</script>
<style scoped>
.progress-bar {
height: 4px;
background: #f5f5f5;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 9999;
}
.progress {
height: 100%;
background: #1890ff;
transition: width 0.3s ease;
}
</style>
三、乾坤环境的 "特殊异常处理"
相比非微前端项目,乾坤下需额外处理三个 "流式坑点":
1. 子应用卸载时销毁流式连接
如果用户在流式加载中切换子应用(比如从文档子应用切到首页),必须在子应用beforeUnmount中销毁reader,否则会导致主应用内存泄漏:
scss
// 子应用 DocStream.vue
onUnmounted(() => {
if (reader) {
// 主动取消流式连接,通知后台停止发送分片
reader.cancel('子应用卸载,中断流式连接');
reader = null;
}
// 清空全局状态
if (globalState) {
globalState.setGlobalState({ docLoadProgress: 0 });
}
});
2. 跨域流式接口的乾坤适配
如果子应用的流式接口是跨域的(比如子应用在 8081 端口,接口在 8082 端口),需在接口响应头中添加Access-Control-Allow-Origin,且主应用的excludeAssetFilter需匹配跨域接口地址:
javascript
// 主应用 excludeAssetFilter 适配跨域接口
excludeAssetFilter: (assetUrl) => {
// 匹配跨域的流式接口(比如 http://localhost:8082/api/markdown-stream)
return assetUrl.includes('//localhost:8082/api/markdown-stream');
}
3. 乾坤沙箱的 "fetch 重写问题"
如果子应用使用axios发起流式请求,会被乾坤的沙箱拦截,需改用原生fetch,或在axios配置中关闭adapter:
arduino
// 子应用中禁用axios的adapter,避免被乾坤拦截
const axiosInstance = axios.create({
adapter: (config) => {
// 对流式接口,用原生fetch实现
if (config.url.includes('/api/markdown-stream')) {
return fetch(config.url, {
method: config.method,
headers: config.headers,
cache: 'no-store'
});
}
// 其他接口用默认adapter
return axios.defaults.adapter(config);
}
});
四、面试总结:乾坤流式处理的 "核心原则"
演示完代码后,我总结了三个关键经验,这些都是在乾坤项目中踩过的坑:
- 优先配置乾坤拦截规则:流式接口必须排除拦截,否则text/event-stream会被当作普通资源缓存,导致分片顺序错乱;
- 主 - 子通信用异步方案:进度、状态等流式数据,不能用props或vuex,必须用乾坤的initGlobalState或EventBus;
- 子应用卸载要 "彻底销毁" :不仅要释放reader,还要清空全局状态,避免主应用切换子应用后残留流式连接。
面试官点头:"之前我们的子应用就是因为没配置excludeAssetFilter,导致流式接口被缓存,分片一直重复加载。这个方案解决了核心问题。" 最后还讨论了 "大文件分片上传" 的乾坤适配,其实和流式处理原理类似,只需在主应用排除上传接口即可。