Micro-App 内存泄漏问题分析与解决方案
一、背景说明
问题现象
Micro-App 框架在多次进行子应用的卸载和加载时存在内存泄漏问题,表现为:
- 内存持续增长,无法正常释放
- 循环加载卸载后,内存线性累积
- 严重时导致浏览器崩溃
影响版本:1.0.0-rc.27
相关 Issue
二、项目整体架构
核心目录结构
micro-app-master/
├── src/ # 核心源码目录
│ ├── index.ts # 入口文件
│ ├── micro_app.ts # MicroApp 核心类
│ ├── create_app.ts # 子应用创建与生命周期管理
│ ├── app_manager.ts # 应用管理器
│ ├── prefetch.ts # 预加载功能
│ │
│ ├── sandbox/ # 沙箱隔离模块
│ │ ├── iframe/ # iframe 沙箱方案
│ │ ├── with/ # with 沙箱方案
│ │ ├── scoped_css.ts # CSS 作用域隔离
│ │ └── router/ # 路由沙箱
│ │
│ ├── source/ # 资源加载模块 ⚠️ 内存泄漏核心模块
│ │ ├── fetch.ts # 资源获取
│ │ ├── scripts.ts # JS 脚本处理
│ │ ├── links.ts # CSS 链接处理
│ │ ├── source_center.ts # 资源中心
│ │ └── loader/html.ts # HTML 解析器
│ │
│ └── interact/ # 应用间通信模块
│ ├── index.ts # 通信入口
│ └── event_center.ts # 事件中心
│
├── typings/global.d.ts # TypeScript 类型声明 ⚠️ 相关类型定义
├── lib/ # 构建产物
├── dev/ # 本地开发环境
└── docs/ # 文档站点
架构概览
┌──────────────────────────────────────────────────────────────┐
│ Micro-App 架构 │
├──────────────────────────────────────────────────────────────┤
│ │
│ 入口层 应用管理层 子应用实例 │
│ ┌─────────┐ ┌──────────────┐ ┌────────────────┐ │
│ │index.ts │──▶│ create_app │──────▶│ app_manager │ │
│ │micro_app│ │ prefetch │ │ │ │
│ └─────────┘ └──────────────┘ └────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ 核心功能模块 │ │
│ ├──────────────┬──────────────┬──────────────┬──────────┤ │
│ │ 沙箱隔离 │ 资源加载 │ 应用通信 │ 路由管理 │ │
│ ├──────────────┼──────────────┼──────────────┼──────────┤ │
│ │• iframe沙箱 │• HTML解析 │• 数据通信 │• history │ │
│ │• with沙箱 │• JS脚本执行 │• 事件中心 │• location│ │
│ │• CSS隔离 │• CSS处理 │• 生命周期 │• 路由事件│ │
│ │• 请求拦截 │• 资源缓存 │ │ │ │
│ └──────────────┴──────────────┴──────────────┴──────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ 基础支撑模块 │ │
│ ├──────────────┬──────────────┬──────────────┬──────────┤ │
│ │ 工具库 │ 类型声明 │ Polyfill │ 代理模块 │ │
│ └──────────────┴──────────────┴──────────────┴──────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘
三、官方资源共享方案分析
现有方案
官方提供了两种资源共享方案:
方式一:globalAssets 配置
在主应用初始化时预加载全局共享资源:
import microApp from '@micro-zoe/micro-app'
microApp.start({
globalAssets: {
js: ['https://cdn.example.com/vue.js'],
css: ['https://cdn.example.com/common.css'],
}
})
方式二:global 属性标记
在子应用 HTML 中标记共享资源:
<link rel="stylesheet" href="xx.css" global>
<script src="xx.js" global></script>
现有方案的局限性
| 问题类型 | 具体说明 |
|---|---|
| 规范依赖性强 | 需要各团队严格遵守约定,手动标记共享资源 |
| 容易遗漏 | 新增公共资源时容易忘记配置,导致重复加载 |
| 维护成本高 | 多团队协作时,globalAssets 配置需要统一管理和同步更新 |
| 未解决内存泄漏 | 仅解决资源复用,卸载后内存仍无法释放 |
典型场景示例:
场景:多个子应用都使用了 Vue 2.x
❌ 官方方案的问题:
1. 需要在 globalAssets 中手动配置 Vue CDN 地址
2. 或在每个子应用的 script 标签添加 global 属性
3. 任何遗漏都会导致 Vue 被重复加载和存储
4. 即使正确配置,卸载后内存仍无法释放
✅ 优化方案:
- 自动识别相同资源,无需手动配置
- 框架层面实现智能缓存和复用
- 卸载后自动释放内存
四、内存泄漏问题分析
关键模块定位
| 模块 | 文件 | 问题原因 |
|---|---|---|
| JS 脚本处理 | source/scripts.ts | 执行后全局引用未清理,相同脚本重复存储 |
| CSS 链接处理 | source/links.ts | CSS 字符串持久保留,卸载后未释放 |
| 类型定义 | typings/global.d.ts | ScriptSourceInfo 和 LinkSourceInfo 直接存储原始字符串 |
原有缓存机制的问题
1. JS 脚本缓存问题
原代码逻辑:
// 每个应用独立存储 JS 代码和解析后的函数
appSpaceData.parsedFunction = fn
appSpaceData.code = code
问题分析:
- 重复存储:同一份 JS 代码被多个应用加载时,每个应用都独立存储一份
- 内存累积:
code字段直接存储完整字符串,大文件占用大量内存 - 无法复用:每次都需要重新解析和创建 Function 对象
2. CSS 链接缓存问题
原代码逻辑:
// 遍历其他应用查找已解析的代码
function getExistParseCode(appName, prefix, linkInfo) {
for (const item in appSpace) {
if (item !== appName && appSpaceData.parsedCode) {
return appSpaceData.parsedCode.replace(...)
}
}
}
问题分析:
- 遍历性能差:每次查找需要遍历所有应用
- 存储冗余:每个应用独立存储
parsedCode,相同资源多次存储 - 内存泄漏核心:
linkInfo.code字段持久保留完整 CSS 字符串,卸载应用后不释放
内存泄漏的具体表现
| 操作场景 | 内存表现 |
|---|---|
| 加载子应用 A | 存储 JS/CSS 原始代码 + 解析后代码 |
| 加载子应用 B(相同资源) | 再次存储相同代码(无法复用) |
| 卸载子应用 A | linkInfo.code 未清空,继续占用内存 |
| 卸载子应用 B | 同上,内存持续累积 |
| 循环加载卸载 | 内存线性增长,最终 OOM |
根本原因
┌──────────────────────────────────────────────────────────┐
│ 原有缓存架构缺陷 │
├──────────────────────────────────────────────────────────┤
│ appSpace[app1] │
│ ├── code: "完整的JS/CSS字符串..." │
│ └── parsedCode: "解析后的代码..." │
│ │
│ appSpace[app2] (相同资源) │
│ ├── code: "完整的JS/CSS字符串..." ❌ 重复存储 │
│ └── parsedCode: "解析后的代码..." ❌ 重复存储 │
│ │
│ 核心问题: │
│ 1. 卸载应用后,linkInfo.code 仍保留完整字符串 │
│ 2. 多应用加载相同资源时,无法复用,内存翻倍 │
│ 3. 资源内容与应用实例强绑定,无法独立管理 │
└──────────────────────────────────────────────────────────┘
五、解决方案
核心思想
分离「资源存储」与「应用引用」:
- 资源内容存储在全局缓存(单例模式)
- 应用只保留资源索引(codeHash),不存储内容
- 卸载时清空内容引用,让 GC 正常回收
具体实现
1. 新增全局缓存层
JS 脚本优化 (scripts.ts):
// ✅ 全局脚本代码缓存
const GLOBAL_SCRIPT_CODE_CACHE = new Map<string, string>()
// ✅ 获取解析后的函数(带缓存逻辑)
function getParsedFunction(app, scriptInfo, parsedCode) {
const codeHash = getCodeHash(parsedCode)
const appLevelKey = `${app.name}_${codeHash}`
// 1. 优先从应用级缓存获取(避免并发竞争)
const appLevelFn = APP_LEVEL_CACHE.get(appLevelKey)
if (appLevelFn) return appLevelFn
// 2. 尝试从全局级缓存获取(跨应用资源复用)
const globalLevelFn = GLOBAL_LEVEL_CACHE.get(codeHash)
if (globalLevelFn) {
APP_LEVEL_CACHE.set(appLevelKey, globalLevelFn)
return globalLevelFn
}
// 3. 创建新函数并缓存到两级
const fn = code2Function(app, parsedCode)
APP_LEVEL_CACHE.set(appLevelKey, fn)
GLOBAL_LEVEL_CACHE.set(codeHash, fn)
return fn
}
CSS 链接优化 (links.ts):
// ✅ 全局级 CSS 代码缓存
const GLOBAL_CSS_CODE_CACHE = new Map<string, string>()
// ✅ 全局级解析后 CSS 缓存
const GLOBAL_PARSED_CSS_CACHE = new Map<string, { code: string, appName: string }>()
// ✅ 高效的字符串哈希算法
function getCodeHash(code: string): string {
let hash = 5381
const len = code.length
// 长字符串采样策略,避免全量遍历
if (len <= 1024) {
let i = len
while (i) hash = (hash * 33) ^ code.charCodeAt(--i)
} else {
// 采样头部、中间、尾部
const sampleSize = 340
// ... 采样逻辑
}
return '_' + (hash >>> 0).toString(36)
}
2. 清空冗余存储
// ✅ links.ts 关键改动
const linkInfo = sourceCenter.link.getInfo(address)!
// 计算 codeHash 并存储到全局缓存
const codeHash = getCodeHash(code)
if (!GLOBAL_CSS_CODE_CACHE.has(codeHash)) {
GLOBAL_CSS_CODE_CACHE.set(codeHash, code)
}
// 关键:清空 linkInfo.code,释放内存
linkInfo.codeHash = codeHash
linkInfo.code = '' // ✅ 释放原来保留的完整字符串
3. 类型定义更新
// typings/global.d.ts
interface LinkSourceInfo {
code: string
codeHash?: string // ✅ 新增:用于全局缓存索引
appSpace: Record<string, ...>
}
interface ScriptSourceInfo {
code: string
codeHash?: string // ✅ 新增:用于全局缓存索引
appSpace: Record<string, ...>
}
新架构对比
┌──────────────────────────────────────────────────────────┐
│ 优化后缓存架构 │
├──────────────────────────────────────────────────────────┤
│ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ GLOBAL_CSS_CODE_CACHE (全局唯一) │ │
│ │ └── codeHash → "完整的CSS字符串..." │ │
│ └────────────────────────────────────────────────────┘ │
│ ↑ 复用 │
│ ┌──────────────┬──────────────┬──────────────┐ │
│ │appSpace[app1]│appSpace[app2]│appSpace[app3]│ │
│ │ codeHash:"x" │ codeHash:"x" │ codeHash:"y" │ │
│ │ code: "" │ code: "" │ code: "" │ ✅ │
│ └──────────────┴──────────────┴──────────────┘ │
│ │
│ 核心优势: │
│ 1. 相同资源全局只存一份,内存占用大幅降低 │
│ 2. 卸载应用后清空 code 字段,释放内存 │
│ 3. 跨应用复用解析结果,性能提升 │
└──────────────────────────────────────────────────────────┘
设计模式应用
资源加载流程:
┌────────────┐ getCodeHash ┌─────────────────────┐
│ CSS/JS代码 │ ──────────────▶ │ GLOBAL_xxx_CACHE │
└────────────┘ │ (单例存储) │
└─────────────────────┘
│
▼ 存储 codeHash
┌─────────────────────┐
│ linkInfo/scriptInfo │
│ codeHash: "_abc123"│
│ code: "" │
└─────────────────────┘
资源复用流程:
┌────────────┐ codeHash 查询 ┌─────────────────────┐
│ 应用B加载 │ ──────────────▶ │ GLOBAL_xxx_CACHE │
│ 相同资源 │ │ 命中缓存,直接复用 │
└────────────┘ └─────────────────────┘
关键改动总结
| 文件 | 改动类型 | 说明 |
|---|---|---|
scripts.ts | 新增全局缓存 | GLOBAL_SCRIPT_CODE_CACHE 复用 JS 代码 |
scripts.ts | 函数复用 | getParsedFunction 实现跨应用函数复用 |
links.ts | 新增全局缓存 | GLOBAL_CSS_CODE_CACHE 复用原始 CSS |
links.ts | 新增解析缓存 | GLOBAL_PARSED_CSS_CACHE 复用解析结果 |
links.ts | 内存释放 | linkInfo.code = '' 清空字符串 |
links.ts | 哈希算法 | getCodeHash 高效计算缓存 key |
global.d.ts | 类型扩展 | 新增 codeHash 字段定义 |
六、方案对比与效果
官方方案 vs 优化方案
| 维度 | 官方方案 | 优化方案 |
|---|---|---|
| 使用方式 | 需要手动配置 | 零配置,自动识别 |
| 团队协作 | 依赖团队规范,易出错 | 框架层面自动处理 |
| 资源遗漏 | 容易遗漏共享配置 | 自动识别相同资源 |
| 内存管理 | 仅解决复用,未解决泄漏 | 同时解决复用和泄漏 |
| 代码侵入 | 需要修改 HTML/JS 配置 | 零侵入 |
| 维护成本 | 高(需同步更新配置) | 低(自动化) |
优化效果
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 相同资源存储次数 | N 份(N=应用数) | 1 份 |
| 内存占用 | 线性增长 | 稳定 |
| 解析性能 | 每次重新解析 | 命中缓存直接复用 |
| 卸载后内存释放 | ❌ 不释放 | ✅ 正常释放 |
| 配置成本 | 需要手动配置 | ✅ 零配置自动识别 |
七、总结
问题回顾
Micro-App 1.0.0-rc.27 版本存在内存泄漏问题的根本原因:
- 资源重复存储:相同 JS/CSS 被多个应用独立存储
- 内存未释放:
linkInfo.code/scriptInfo.code卸载后仍保留完整字符串 - 架构设计缺陷:资源内容与应用实例强绑定,无法独立管理
解决方案核心
- 全局缓存层:新增
GLOBAL_CSS_CODE_CACHE、GLOBAL_SCRIPT_CODE_CACHE - 哈希索引:用
codeHash替代完整字符串存储 - 内存释放:清空
code字段,只保留codeHash - 解析复用:
GLOBAL_PARSED_CSS_CACHE复用 CSS 处理结果
后续优化方向
当前优化方案已覆盖 JS 脚本 和 CSS 样式 资源,其他资源类型可参考此思路扩展:
| 资源类型 | 当前状态 | 优化建议 |
|---|---|---|
| JS 脚本 | ✅ 已优化 | - |
| CSS 样式 | ✅ 已优化 | - |
| 字体文件 | ❌ 待优化 | 新增 GLOBAL_FONT_CACHE |
| 图标文件 | ❌ 待优化 | 新增 GLOBAL_ICON_CACHE |
| 图片资源 | ❌ 待优化 | 新增 GLOBAL_IMAGE_CACHE |
扩展步骤:
- 新增对应的全局缓存 Map
- 实现资源内容的哈希计算
- 在资源加载时存入缓存,清空原始存储
- 在资源复用时从缓存读取