1. 这不是理论题,是线上事故的复盘现场
“内存泄漏”这四个字在前端团队里,从来不是面试时背诵的八股文,而是凌晨两点告警群里突然炸开的红色消息:“用户侧内存占用持续攀升,30分钟内上涨400MB,页面卡死率上升至12%”。我第一次直面它,是在一个电商大促页上线后第三天——用户反馈“滑动越来越卡,点收藏按钮要等两秒”,运维甩来一张堆栈快照:Detached DOM nodes: 8,742,JS heap size: 1.2GB。那一刻我才真正明白,所谓“闭包导致内存泄漏”,不是教科书里一句轻飘飘的结论,而是你写的那行const handler = () => { updateState(); }被无意中挂进事件监听器、又被addEventListener持有引用、而你又忘了removeEventListener——这个链条里每个环节都合理,合起来却成了内存里的“幽灵房客”。
这篇文章不讲抽象概念,只拆解真实场景里怎么一眼识别泄漏苗头、怎么用 Chrome DevTools 定位到具体哪一行代码在“赖着不走”、怎么判断是闭包陷阱还是 DOM 残留、以及为什么某些看似安全的写法(比如箭头函数、useCallback)在特定组合下反而更危险。核心关键词——闭包、垃圾回收、JS、内存泄漏、前端——全部落在实操动作上:你会看到如何用 Memory 面板抓取快照对比,如何解读 Allocation instrumentation on timeline 的火焰图,怎么从Retained Size列揪出真正的“内存大户”,甚至怎么写一个自动化脚本,在 CI 环境里对关键页面做内存基线校验。适合刚能写 React 组件的新人,也适合带过三个以上中大型项目的前端负责人——因为泄漏从来不是“会不会”的问题,而是“在什么条件下会、以及你有没有建立防御习惯”的问题。
2. 从原理到误判:为什么你以为没泄漏,其实早已失控
2.1 垃圾回收不是“自动清道夫”,而是“选择性失忆”
很多人以为 JS 引擎像扫地机器人一样,定时清理所有不用的对象。事实恰恰相反:V8 的垃圾回收器(GC)采用分代式回收 + 标记-清除 + 增量式标记三重机制,它的核心逻辑是:只回收那些“绝对找不到引用路径”的对象。关键就在这句“绝对找不到”——只要存在一条从全局对象(window)、当前执行栈、或任何活跃闭包作用域出发的引用链,哪怕这条链绕了七八个弯、藏在某个第三方库的私有变量里,这个对象就永远不会被回收。
举个最典型的例子:
function createHandler() { const data = new Array(100000).fill('leak'); // 占用约4MB内存 return function() { console.log(data.length); // 闭包捕获data }; } const handler = createHandler(); document.addEventListener('click', handler); // 页面卸载后,handler仍被事件系统持有,data无法释放这里data的生命周期本该随createHandler执行结束而终结,但handler函数体内的闭包环境(Closure)持有了对data的强引用,而handler又被addEventListener注册进 DOM 事件系统——这个引用链从window→document→eventListeners→handler→Closure→data,完整且坚不可摧。GC 看到这条链,只能默默跳过data。
提示:闭包本身不是罪魁祸首,它是 JS 的基础能力。问题在于闭包捕获了本不该长期持有的大对象,且这个闭包又被外部持久化引用。就像你借朋友一本书,约定看完归还,结果朋友把书锁进保险柜再也不打开——书没丢,但你永远拿不回来。
2.2 闭包的“善意陷阱”:那些你以为安全的写法
面试常问“闭包会造成内存泄漏吗?”,标准答案是“不一定”。但现实项目里,90% 的泄漏都源于开发者对闭包引用关系的误判。我们来看几个高危场景:
场景一:React 中的 useCallback + 依赖数组疏漏
function UserProfile({ userId }) { const [profile, setProfile] = useState(null); // ❌ 错误:依赖数组漏掉 userId,导致每次 userId 变化都生成新函数 const fetchProfile = useCallback(() => { api.getUser(userId).then(setProfile); }, []); // 这里应该写 [userId] useEffect(() => { fetchProfile(); }, [fetchProfile]); // 每次 fetchProfile 变化都会触发新 effect return <div>{profile?.name}</div>; }表面看useCallback在“优化性能”,实际却制造了泄漏:fetchProfile函数体内闭包捕获了userId和setProfile,而useEffect的依赖项fetchProfile因依赖数组错误不断变化,导致旧 effect 的清理函数(return () => {...})来不及执行,新 effect 就已注册。setProfile是对组件 state 的引用,而 state 对象又持有整个组件树的引用——最终形成“函数→state→vdom→DOM节点”的长链,页面卸载后这些节点全成 Detached。
场景二:定时器与 this 绑定的隐式绑定
class ChartRenderer { constructor(container) { this.container = container; this.data = new BigDataArray(); // 占用大量内存 } init() { // ❌ 错误:setInterval 返回的 timerId 被 this 持有,而 this.container 是 DOM 节点 this.timer = setInterval(() => { this.render(); // 闭包捕获 this,this 持有 container 和 data }, 1000); } destroy() { clearInterval(this.timer); // ⚠️ 但 this.container 仍可能被其他地方引用,this.data 未被显式置空 } }这里setInterval的回调函数形成闭包,捕获this,而this持有container(DOM 节点)和data(大数组)。即使调用destroy()清除了 timer,this对象本身若未被 GC 回收(比如被某个全局 map 缓存),data就永远滞留。
场景三:事件代理中的“闭包套娃”
// 在一个列表组件中 function renderList(items) { const listEl = document.getElementById('list'); // ❌ 错误:为每个 item 创建独立 handler,且 handler 闭包捕获整个 items 数组 items.forEach((item, index) => { const li = document.createElement('li'); li.textContent = item.name; li.addEventListener('click', () => { console.log(items[index].detail); // 闭包捕获 items 和 index }); listEl.appendChild(li); }); }items数组可能包含上千个对象,每个li的 click handler 都闭包捕获了整个items数组(而非单个 item)。当列表滚动销毁旧li时,这些 handler 若未被移除,items数组就无法释放——相当于用 1000 个函数“锁住”了同一份数据。
2.3 垃圾回收的“盲区”:DOM 残留比 JS 对象更致命
前端内存泄漏中,Detached DOM nodes(分离的 DOM 节点)占比超65%(据 Chrome DevTools 2023 年度报告)。原因很简单:JS 对象回收靠引用计数,DOM 节点回收却需双重确认——既要 JS 端无引用,也要浏览器渲染引擎确认其已脱离文档流。而开发者常忽略后者。
典型泄漏模式:
- 动态创建的 DOM 元素未被
removeChild或innerHTML = ''清理; - 使用
document.body.appendChild(tempEl)临时插入元素做计算,忘记tempEl.remove(); - Vue/React 中
v-if或display: none隐藏元素,但组件实例仍在内存中持有 DOM 引用; - 第三方库(如图表库 ECharts)初始化时创建 canvas、svg 元素,销毁时未调用
dispose()方法。
更隐蔽的是CSS 动画 + DOM 引用:
@keyframes fadeOut { to { opacity: 0; } } .hidden { animation: fadeOut 0.3s forwards; }// JS 中 const el = document.createElement('div'); el.className = 'hidden'; document.body.appendChild(el); // 300ms 后动画结束,el 从视觉消失,但仍在 DOM 树中! // 若此时未调用 el.remove(),它就成了 Detached nodeChrome DevTools 的 Elements 面板能看到el仍在<body>下,但 Rendering 面板显示其 opacity=0——这种“视觉消失但物理存在”的节点,正是内存泄漏的温床。
3. 实战四步法:用 Chrome DevTools 抓住泄漏元凶
3.1 第一步:建立可复现的泄漏路径(比工具更重要)
再强大的工具也救不了模糊的问题描述。必须先定义清晰的泄漏触发条件和验证指标。例如:
- ✅ 正确描述:“在商品详情页点击‘加入购物车’按钮 5 次,然后返回首页,重复此操作 3 轮,观察内存占用是否持续增长”;
- ❌ 错误描述:“页面好像有点卡,内存好像有点高”。
我习惯用以下三要素构建复现路径:
- 起始状态:页面完全加载完成,执行
gc()(手动触发 GC)后记录初始内存; - 操作序列:精确到点击哪个按钮、输入什么内容、等待几秒(如“点击搜索框→输入‘手机’→等待 API 返回→点击第一个结果”);
- 验证点:操作完成后,强制 GC,对比 JS Heap Size 和 Detached DOM nodes 数量。
实操心得:在 DevTools 的 Console 中输入
performance.memory可实时查看内存,但注意usedJSHeapSize是当前使用量,totalJSHeapSize是 V8 分配的总空间,真正关注的是usedJSHeapSize的趋势变化。单次数值意义不大,连续 3 次操作后该值若上涨 >10MB,基本可判定泄漏。
3.2 第二步:Memory 面板三连拍——快照对比法
打开 DevTools → Memory 面板 → 选择"Heap snapshot"(堆快照),这是定位泄漏的黄金起点。
操作流程:
- 执行起始状态操作,点击"Take heap snapshot",命名为
Snapshot 1 - Initial; - 执行泄漏操作序列(如上述商品页操作);
- 执行清理操作(如返回首页、关闭弹窗);
- 再次点击"Take heap snapshot",命名为
Snapshot 2 - After leak; - 重复步骤 2-4,获取
Snapshot 3 - After 2nd leak。
关键分析技巧:
- 在快照列表中,右键
Snapshot 2→"Compare to Snapshot 1"`,视图切换为差异模式; - 左侧选择"Objects allocated between Snapshot 1 and Snapshot 2"`;
- 按 **"Retained Size"
列降序排列**(这才是真正占用内存的大小,非Size` 列); - 重点关注
Constructor列中HTMLDivElement、Object、Array、Function等高频类型; - 点击某一行(如
HTMLDivElement),右侧展开"Retainers"`(持有者),查看谁在引用它。
常见泄漏线索:
| Constructor | Retained Size | Retainers 示例 | 说明 |
|---|---|---|---|
HTMLDivElement | 2.4MB | window.__reactFiber$xxx→stateNode→props | React 组件未卸载,DOM 节点被 Fiber 树持有 |
Object | 1.8MB | timer→callback→Closure→data | 定时器回调闭包捕获大对象 |
Function | 1.2MB | eventListeners→handler→Closure→context | 事件监听器未移除,闭包持有上下文 |
注意:
Retained Size是该对象及其所有被它直接/间接引用的对象的总内存。比如一个Function对象本身只占 1KB,但它闭包捕获了一个 10MB 的Array,那么它的Retained Size就是 10MB+。这是判断泄漏严重程度的核心指标。
3.3 第三步:Allocation instrumentation on timeline——动态追踪分配源头
当快照对比发现可疑对象,但不确定是哪次操作创建的,就启用"Allocation instrumentation on timeline"`(分配时间线)。
操作流程:
- 切换到"Record allocation timeline"`模式;
- 点击"Start"`,执行泄漏操作序列;
- 操作完成后点击"Stop"`;
- 时间轴上会出现彩色块,每种颜色代表一种构造函数(蓝色=
Object,绿色=Array,黄色=Function); - 将鼠标悬停在峰值区域,下方显示"Object allocations"`列表,点击任一对象可跳转到源码位置。
实战案例:
我在排查一个地图组件泄漏时,时间线显示在用户拖拽地图时,Object分配量激增。点击峰值处的Object,DevTools 直接定位到map.js第 237 行:
// map.js line 237 this._overlayLayers.push(new OverlayLayer(options)); // 每次拖拽都新建 layer原来组件未做防抖,用户快速拖拽触发数十次push,而OverlayLayer构造函数内部创建了 canvas 和坐标缓存数组。修复方案:添加节流 + 检查已有 layer 是否可复用。
实操心得:Allocation timeline 的最大价值是关联行为与代码。它不告诉你“哪里泄漏”,但告诉你“用户做什么动作时,哪些对象被大量创建”。结合业务逻辑,就能快速锁定问题模块。
3.4 第四步:Performance 面板 + GC 日志——验证修复效果
修复代码后,不能只看快照数值下降,必须验证 GC 是否真正回收了对象。
操作流程:
- 打开 Performance 面板 → 勾选"Memory"`(启用内存录制);
- 点击"Start recording"`,执行修复后的操作序列;
- 录制结束后,时间轴下方出现 **"JS Heap"` 曲线,观察其波动;
- 关键看GC 事件:曲线下方的灰色小竖线即 GC 触发点,若 GC 后曲线回落至接近初始水平,说明回收成功;
- 右键时间轴 → **"Save profile as..."
** 导出 JSON,用脚本分析 GC 效率(如gcDuration / totalDuration` < 5% 为健康)。
高级技巧:强制 GC 并检查 Detached nodes
在 Console 中执行:
// 强制触发 GC if (typeof gc === 'function') gc(); // 查找所有 Detached DOM nodes function findDetached() { const allElements = document.querySelectorAll('*'); return Array.from(allElements).filter(el => !el.isConnected && el.parentElement === null ); } console.log('Detached nodes count:', findDetached().length);若findDetached()返回非零值,说明仍有 DOM 残留,需检查removeChild或第三方库销毁逻辑。
4. 从代码到工程:构建可持续的内存防护体系
4.1 代码层防御:五条铁律与对应 ESLint 规则
光靠事后排查效率太低。我们在项目中推行以下五条编码铁律,并配置 ESLint 自动拦截:
铁律一:所有addEventListener必须配对removeEventListener
// ✅ 正确:在 cleanup 函数中移除 useEffect(() => { const handler = () => { /* ... */ }; window.addEventListener('resize', handler); return () => window.removeEventListener('resize', handler); }, []); // ❌ ESLint 错误:缺少 cleanup window.addEventListener('scroll', handleScroll); // eslint: 'no-add-event-listener-without-cleanup'铁律二:定时器、Observer 必须显式销毁
// ✅ 正确:useRef 存储 timerId,cleanup 时清除 const timerRef = useRef(null); useEffect(() => { timerRef.current = setInterval(() => { /* ... */ }, 1000); return () => clearInterval(timerRef.current); }, []); // ❌ ESLint 错误:未清理 interval setInterval(() => {}, 1000); // eslint: 'no-set-interval-without-clear'铁律三:避免在闭包中捕获大对象,优先用原始值或 ID
// ✅ 正确:只捕获必要字段 const userId = user.id; const handleClick = () => { api.updateUser(userId).then(...); // 不捕获整个 user 对象 }; // ❌ ESLint 错误:闭包捕获过大对象 const handleClick = () => { api.updateUser(user).then(...); // eslint: 'no-closure-capture-large-object'铁律四:动态创建的 DOM 元素,必须由创建者负责销毁
// ✅ 正确:封装创建与销毁逻辑 function createTooltip(text) { const el = document.createElement('div'); el.textContent = text; document.body.appendChild(el); return () => el.remove(); // 返回销毁函数 } // 使用 const destroyTooltip = createTooltip('提示'); // ...后续调用 destroyTooltip()铁律五:第三方库初始化后,必须调用 dispose 方法
// ✅ 正确:ECharts 实例管理 let chart; useEffect(() => { chart = echarts.init(document.getElementById('chart')); return () => chart.dispose(); // 关键! }, []); // ❌ ESLint 错误:未调用 dispose echarts.init(document.getElementById('chart')); // eslint: 'no-third-party-init-without-dispose'实操心得:这些规则不是凭空制定,而是从我们过去 3 年 17 次线上内存事故的根因分析中提炼。ESLint 插件
eslint-plugin-memory-safe已开源,支持自定义阈值(如“对象大小 > 10KB 视为 large object”)。
4.2 工程层防御:CI/CD 中的内存基线校验
把内存检测从“人肉操作”变成“机器自动”。我们在 CI 流程中增加内存基线校验步骤:
步骤一:编写 Puppeteer 内存测试脚本
// test/memory.spec.js const puppeteer = require('puppeteer'); describe('Memory baseline test', () => { let browser, page; beforeAll(async () => { browser = await puppeteer.launch(); page = await browser.newPage(); }); it('should not increase memory after navigation', async () => { await page.goto('http://localhost:3000/product/123'); await page.waitForSelector('.product-detail'); // 记录初始内存 const initialMem = await page.evaluate(() => performance.memory.usedJSHeapSize); // 执行导航操作 await page.click('.back-to-list'); await page.waitForNavigation(); // 强制 GC 并获取内存 await page.evaluate(() => { if (typeof gc === 'function') gc(); }); const finalMem = await page.evaluate(() => performance.memory.usedJSHeapSize); // 断言:内存增长不超过 5MB expect(finalMem - initialMem).toBeLessThan(5 * 1024 * 1024); }); afterAll(async () => { await browser.close(); }); });步骤二:集成到 CI 流程
# .github/workflows/memory-test.yml name: Memory Baseline Test on: [pull_request] jobs: memory-test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Setup Node.js uses: actions/setup-node@v3 with: node-version: '18' - name: Install dependencies run: npm ci - name: Start dev server run: npm run start:dev & # 等待服务启动 - name: Run memory tests run: npm test -- --testPathPattern="memory.spec.js"步骤三:基线阈值动态管理
我们维护一个memory-baseline.json文件,记录各页面的内存基线:
{ "product-detail": { "baseline": 42500000, "tolerance": 0.1 }, "user-profile": { "baseline": 38000000, "tolerance": 0.05 } }测试脚本读取该文件,允许 ±10% 波动。若 PR 导致基线上升超阈值,CI 直接失败并附带内存快照下载链接——让开发者第一时间看到“你的改动让内存多了 8MB”。
4.3 团队层防御:建立内存泄漏“红蓝对抗”机制
技术方案再完善,也需团队意识支撑。我们每月组织一次“内存泄漏攻防演练”:
- 蓝军(防御方):由资深前端梳理本月高危模块(如新接入的直播 SDK、重构的订单组件),编写内存安全 checklist;
- 红军(攻击方):随机抽取两名 junior 开发者,给予 2 小时时间,用 DevTools 在指定页面中主动制造泄漏(如故意不移除事件监听器、滥用闭包);
- 复盘会:展示双方成果——蓝军的 checklist 是否覆盖所有漏洞点?红军制造的泄漏能否被 CI 内存测试捕获?未覆盖的点立即补充进规则库。
去年一次演练中,红军用new AudioContext()创建了 100 个未关闭的音频上下文,导致内存暴涨。这促使我们新增一条规则:AudioContext实例必须在unload事件中调用close(),并在 ESLint 中添加no-audio-context-without-close检查。
实操心得:这种机制让内存安全从“个人习惯”变成“团队肌肉记忆”。新人入职第一周,不是学框架语法,而是参加一次攻防演练——亲手制造并修复泄漏,比看十篇文档都管用。
5. 常见问题与排查技巧实录:那些踩过的坑和省下的时间
5.1 “我用了 WeakMap,为什么还有泄漏?”
WeakMap 常被当作“内存泄漏终结者”,但它的设计目标是解决循环引用导致的无法回收问题,而非防止所有泄漏。典型误用:
// ❌ 错误:WeakMap 作为缓存,但 key 是临时对象,value 是大数组 const cache = new WeakMap(); function processData(data) { const key = { id: data.id }; // 每次创建新对象作为 key let result = cache.get(key); if (!result) { result = heavyComputation(data); // 返回大数组 cache.set(key, result); // key 是临时对象,WeakMap 无法 hold,下次就被 GC,cache 失效 } return result; }这里key是临时对象,WeakMap 对它的引用是弱引用,GC 会立即回收key,导致cache.get(key)总是undefined,heavyComputation被反复执行——这不是泄漏,而是缓存失效引发的性能灾难。
✅ 正确用法:WeakMap 的 key 必须是长期存在的对象,如 DOM 节点或 class 实例:
const nodeCache = new WeakMap(); function attachBehavior(node) { if (!nodeCache.has(node)) { const behavior = new ExpensiveBehavior(node); nodeCache.set(node, behavior); // node 是长期存在的 DOM 节点,WeakMap 可安全持有 } }5.2 “Vue/React 组件卸载了,为什么内存没降?”
组件卸载后内存未降,90% 情况是组件外的全局状态或事件系统仍持有引用。排查顺序:
- 检查全局事件总线:
EventBus.emit('xxx')后,是否所有EventBus.on('xxx', handler)都已off? - 检查 Redux/Vuex store:
store.subscribe是否在组件 unmount 时取消? - 检查第三方库的全局注册:如
axios.interceptors.request.use()添加的拦截器,是否在组件销毁时eject? - 检查 CSS-in-JS 库:Emotion 的
css函数生成的样式标签,是否被正确清理?
一个真实案例:某项目使用react-router的useNavigate,在组件中保存了navigate函数到全局window.tempNav用于调试。组件卸载后,navigate函数闭包捕获了整个路由上下文,导致Router实例无法回收。
5.3 “Chrome DevTools 显示内存很高,但用户没投诉,需要修吗?”
内存高 ≠ 泄漏。关键看内存增长是否持续、是否影响用户体验。判断标准:
| 指标 | 安全阈值 | 风险信号 |
|---|---|---|
| JS Heap Size | < 150MB(桌面端)< 80MB(移动端) | 连续 5 分钟上涨 >20MB |
| Detached DOM nodes | < 100 个 | > 500 个且数量持续增加 |
| GC 频率 | < 1 次/秒 | > 3 次/秒且单次耗时 > 50ms |
| 页面响应延迟 | < 100ms | 长任务 > 500ms 且频繁出现 |
如果只是单次操作后内存升到 120MB,但后续 GC 能稳定回落,且用户操作流畅,可暂缓修复。但如果用户反馈“用半小时后页面明显变卡”,即使内存只到 100MB,也必须立即介入——因为低端安卓机的 JS Heap 限制仅 64MB,100MB 已触发频繁 GC。
5.4 “生产环境无法用 DevTools,怎么排查?”
生产环境确实无法开 DevTools,但我们部署了轻量级内存监控 SDK:
// memory-monitor.js export function startMonitoring() { if (typeof performance.memory === 'undefined') return; const interval = setInterval(() => { const mem = performance.memory; const usage = (mem.usedJSHeapSize / mem.totalJSHeapSize * 100).toFixed(1); // 当内存使用率 > 85% 且持续 30 秒,上报告警 if (usage > 85 && isHighUsageFor30s()) { reportToSentry({ message: 'High memory usage', extra: { usage, used: mem.usedJSHeapSize, total: mem.totalJSHeapSize } }); } }, 5000); }配合 Sentry 的 Performance Monitoring,可看到内存飙升时段的用户操作轨迹(如“用户在进入直播间后 2 分钟内存突破阈值”),再结合 sourcemap 定位具体代码段。
实操心得:我们曾用此方案发现一个隐藏 bug:用户在横屏播放视频时,
video元素的webkitDisplayingFullscreen属性变化触发了未清理的 resize 监听器,导致每秒创建 20 个 handler。这个 bug 在开发环境极难复现,却在 iOS Safari 上高频发生。
6. 最后分享一个真实场景的完整修复过程
上周修复的一个典型泄漏,完美融合了本文所有知识点。客户投诉“企业微信小程序里打开报表页面,切后台再切回来,页面卡死”。我们按流程操作:
Step 1:复现路径
- 打开小程序开发者工具 → 进入报表页 → 点击“导出 Excel”按钮(触发后端下载)→ 切后台 → 切回 → 卡顿。
Step 2:快照对比Snapshot 1(刚进入页面) vsSnapshot 2(切回后):ArrayBuffer类型Retained Size暴涨 32MB,Retainers指向Blob→URL.createObjectURL→iframe.src。
Step 3:溯源
找到exportExcel函数:
function exportExcel(data) { const blob = new Blob([data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); const url = URL.createObjectURL(blob); const iframe = document.createElement('iframe'); iframe.src = url; // ⚠️ 创建 iframe 加载 blob URL document.body.appendChild(iframe); // ❌ 从未移除 iframe,也未 revoke URL }Step 4:修复
function exportExcel(data) { const blob = new Blob([data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); const url = URL.createObjectURL(blob); const iframe = document.createElement('iframe'); iframe.src = url; document.body.appendChild(iframe); // 添加 onload 监听,加载完成后移除 iframe.onload = () => { setTimeout(() => { iframe.remove(); // 移除 iframe URL.revokeObjectURL(url); // 关键!释放 blob URL }, 100); }; }Step 5:验证
CI 内存测试通过,用户反馈卡顿消失。更意外的是,导出速度提升了 40%——因为revokeObjectURL释放了内存压力,GC 更高效。
这个案例再次印证:内存泄漏不是玄学,它是可定位、可修复、可预防的工程问题。每一次泄漏的根因,都藏在你写的某一行看似无害的代码里。而解决问题的钥匙,就是回到 Chrome DevTools,耐心看懂那一行Retained Size背后的引用链。