Micro-App 内存泄漏问题分析与解决方案

48 阅读8分钟

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.tsCSS 字符串持久保留,卸载后未释放
类型定义typings/global.d.tsScriptSourceInfoLinkSourceInfo 直接存储原始字符串

原有缓存机制的问题

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(相同资源)再次存储相同代码(无法复用)
卸载子应用 AlinkInfo.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 版本存在内存泄漏问题的根本原因:

  1. 资源重复存储:相同 JS/CSS 被多个应用独立存储
  2. 内存未释放linkInfo.code / scriptInfo.code 卸载后仍保留完整字符串
  3. 架构设计缺陷:资源内容与应用实例强绑定,无法独立管理

解决方案核心

  1. 全局缓存层:新增 GLOBAL_CSS_CODE_CACHEGLOBAL_SCRIPT_CODE_CACHE
  2. 哈希索引:用 codeHash 替代完整字符串存储
  3. 内存释放:清空 code 字段,只保留 codeHash
  4. 解析复用GLOBAL_PARSED_CSS_CACHE 复用 CSS 处理结果

后续优化方向

当前优化方案已覆盖 JS 脚本CSS 样式 资源,其他资源类型可参考此思路扩展:

资源类型当前状态优化建议
JS 脚本✅ 已优化-
CSS 样式✅ 已优化-
字体文件❌ 待优化新增 GLOBAL_FONT_CACHE
图标文件❌ 待优化新增 GLOBAL_ICON_CACHE
图片资源❌ 待优化新增 GLOBAL_IMAGE_CACHE

扩展步骤

  1. 新增对应的全局缓存 Map
  2. 实现资源内容的哈希计算
  3. 在资源加载时存入缓存,清空原始存储
  4. 在资源复用时从缓存读取