首页 / 文章 / 查找JavaScript内存泄漏:可达性、引用关系与清理机制

查找JavaScript内存泄漏:可达性、引用关系与清理机制

了解为何使用垃圾回收的JavaScript仍会出现内存泄漏,哪些常见模式会导致内存滞留,以及如何通过堆快照和引用链找到问题根源。

4420 词

一款在上午9点时响应迅速,但到了下午5点就变得迟缓的网页应用,正是内存泄漏最常见的症状之一:点击操作会有延迟反应,滚动不再流畅,动画出现卡顿,内存使用量会超过1GB,而重新加载页面后一切又恢复正常。这类泄漏不会引发任何异常,也不会导致测试失败,能够顺利通过持续集成流程,因为只有当用户长时间保持应用打开时才会显现问题。本指南将解释为何采用垃圾回收机制的语言仍会出现泄漏,分析导致实际环境中大多数泄漏的常见模式,并提供一套可重复使用的Chrome DevTools工作流程,帮助你找到让内存持续占用的具体引用对象。

垃圾回收释放的是无法访问的内存,而非未被使用的内存

由于 JavaScript 从不要求你调用 malloc()free(),人们很容易误以为运行时完全负责内存管理。但这种想法只对了一半。当你的代码不再使用某个对象时,垃圾回收器并不会立即回收它;只有当没有任何路径可以访问到该对象时,才会被回收。如果还有被遗忘的引用指向该对象,引擎就无法判断它已变成无用之物。从运行时的角度来看,任何仍可访问的对象都可能还有用。

“不再使用”与“无法再访问”之间的这种差距,正是现代网页开发中一些最棘手的性能问题的根源。

一个案例:每天下午都会变慢的控制面板

想象一下,工作人员在整个轮班期间都保持打开的运营监控面板。在测试环境中,它的运行速度很快:初始加载迅速,API调用高效,Lighthouse评分也很高。但一旦进入生产环境就会出现问题。五六个小时之后,切换标签页会变得迟缓,图表重绘也很慢,就连一个简单的模态框也需要相当长的时间才能打开。

人们通常的第一反应是归咎于后端。团队调整了数据库查询,确保API响应时间保持在100毫秒以内,并发现服务器的CPU使用率很低。但这些都无法解释为何问题会随着时间的推移而愈发严重。

这一突破源自 Chrome 的任务管理器。该标签页在早上时的内存占用约为 150 MB,到下午晚些时候则上升至接近 1.4 GB。每一次导航、每一个对话框、每一次小部件刷新都会留下少量内存占用。单次内存分配量虽小,但数量庞大时问题就显现了。渲染性能、网络延迟以及执行速度本身都正常,只是该应用程序始终没有释放那些已不再需要的对象。

引擎如何决定哪些对象应保留

在 JavaScript 中创建对象根本无需任何复杂步骤:

const user = {
  id: 101,
  name: "Emma"
};

当没有其他代码再引用 user 时,引擎就可以回收它了。有趣的是引擎是如何决定这一点的。V8(Chrome和Node.js)、SpiderMonkey(Firefox)以及JavaScriptCore(Safari)都会追踪由引用相连的对象图。全局对象之类的根节点位于最顶层,应用程序创建的所有内容都依附于这些根节点:应用实例、路由器、状态存储、组件树以及各种全局变量。

垃圾回收过程从这些根节点开始,沿着所有可能的引用路径进行追踪。能够被追踪到的对象会保留下来,而无法被追踪到的对象则会被标记为待清理。这里的关键词是eligible,关键属性则是unreachable。一个对象并非因为陈旧、未被使用或被遗忘而被回收,而仅仅是因为没有任何引用路径通向它。

假设你加载了一个包含大量记录的列表:

const employees = fetchEmployees();

过一段时间后,用户界面不再显示这些数据,但仍有另一个对象指向它们:

cache.employees = employees;

即便再也没有代码读取 cache.employees,收集器也无法就此断定。由于引用仍然存在,整个数组就会保留在内存中。引擎的行为完全符合设计要求;内存泄漏源自应用程序代码。

以引用而非对象来思考问题

一旦停止关注对象本身,转而考虑指向它们的引用,内存泄漏的问题就会变得容易理解得多。以一个创建对象并返回它的函数为例:

function createUser() {
  const user = {
    name: "Alice"
  };
return user;
}
const employee = createUser();

该函数执行完毕后,只有一个引用将变量与对象关联起来:

employee
   │
   ▼
{ name: "Alice" }

如果随后清除这个引用,该对象就不再有外部关联,下一次收集时就可以将其移除:

employee = null;

如果你自己尝试实现的话,有一个需要修正的地方:在之前的代码片段中,employee是用const声明的,因此重新赋值会引发TypeError。如果你打算之后不再使用该变量,就应该用let来声明它。无论如何,关键点都是:只有移除最后一个引用,对象才能被垃圾回收。

现在稍微修改一下示例,让函数将创建的对象存储在模块级别的数组中:

const users = [];
function createUser() {
  const user = {
    name: "Alice"
  };  users.push(user);
}

一旦createUser()返回,每个用户对象仍然会被users数组引用,而且该数组本身也可以从顶层访问:

Window
 │
 ▼
users
 │
 ├── User 1
 ├── User 2
 ├── User 3
 └── User 4

只要 users 可以被访问,其中的每个元素也同样可以访问。这就是内存泄漏通常会逐渐加剧的原因:没有某个单个对象占用大量内存,而是成千上万个小对象在数小时或数天内不断累积。

内存泄漏是逐次交互积累的

“内存泄漏”这个词往往会让人联想到某个巨大的对象占用了数百兆字节的内存。但实际上,其表现形式几乎总是由大量小型、重复出现的问题造成的。

试想,关闭一个设置对话框会留下大约20 KB的流量。这一数值本身微不足道。但如果一名高级用户在工作日里反复打开和关闭该对话框500次,就会泄露约10 MB的流量。再加上五个每次交互都会产生类似少量泄露的组件、持续8小时的会话、同时打开的多个标签页,以及每隔几秒就传来的实时更新,这些原本微不足道的数值就会累积成数百兆字节。

这样的计算也解释了为什么开发者很少察觉到这些问题。在开发过程中,人们每隔几分钟就会重新加载页面,这样就能把之前的数据清除干净。而用户不会重新加载页面,他们会继续使用软件。

为什么单页应用受影响最严重

传统的多页面网站其实有一个意外的安全机制:每次导航都会加载一份新的文档,并丢弃整个JavaScript堆内存,包括其中那些已泄露的对象。

使用 React、Angular、Vue、Svelte 或类似框架构建的单页应用无需完全重新加载即可运行数小时。这不仅提升了用户体验,也恰恰说明了内存管理的重要性。每次路由切换、模态框出现、提示信息显示、WebSocket 消息接收以及图表刷新都会创建新的对象;如果这些对象没有得到妥善释放,它们就会与页面一同存在。

这里存在一个矛盾:用户体验越好,用户停留的时间就越长,细微的内存泄漏也就有更多机会逐渐累积。

内存回收机制很复杂,但并非能预知未来

现代引擎采用增量回收、分代回收、并发标记、压缩处理以及空闲时间回收等技术,这些方法能让内存管理更加高效且不干扰应用运行,但它们无法修复逻辑错误。

想象一下把一本书借给别人后却再也不去要回它。对方无从知晓你已经忘记了这件事,所以在他们看来你依然希望有一天能拿回那本书。引用机制的原理也是如此。只要你的应用程序还持有某个对象的引用,引擎就会认为该对象很重要,无论你的代码是否还会再次使用它。引擎无法理解人的意图,只能沿着图中的连接关系进行追踪。

这种思维方式的转变会影响你的调试方式。你不必再去疑惑为什么内存回收器没有释放内存,而应该提出一个更有成效的问题:还有什么在持有对这个对象的引用?几乎所有错误都出在这一点上。

大多数现实世界中的内存泄漏背后的模式

收集器本身并未损坏;泄漏现象出现的原因是代码仍保留着本应释放的引用。这些引用往往看起来并无异常,它们源自普通且看似合理的代码,而非复杂的算法或浏览器漏洞。以下模式涵盖了在实际生产环境中最常遇到的泄漏情况。

从未被移除的事件监听器

事件监听器是导致泄漏的最常见原因之一,尤其是在单页应用中。其设置通常很正常:获取一个元素并为其添加处理函数。

const button = document.getElementById("save");
button.addEventListener("click", saveDocument);

稍后用户离开页面,按钮就会从页面上消失。但从 DOM 中移除一个元素并不会自动断开所有相关的 JavaScript 引用。如果事件处理函数仍被注册,且还有其他因素使该元素或处理函数可访问,那么监听器及其所引用的对象就会一直保留在内存中。当监听器被绑定到像 windowdocument 这样长期存在的对象上时,内存泄漏问题会最为严重,因为这些对象永远不会消失。解决方法是显式地取消注册该事件处理函数:

button.removeEventListener("click", saveDocument);

在 React、Angular 或 Vue 中,应在组件生命周期的卸载或销毁阶段执行此操作。一个良好的习惯是将每次 addEventListener() 调用视为一项承诺:添加监听器时,就要明确知道何时以及在哪里移除它。将 AbortController 信号传递给多个监听器,并在组件销毁时统一取消该信号,是批量履行此类承诺的便捷方法。

生命周期超过屏幕显示时间的定时器

定时器也会以同样隐蔽的方式造成资源泄漏。一个每五秒查询一次新数据的控制面板可能如下所示:

const timer = setInterval(() => {
    loadLatestData();
}, 5000);

如果用户离开页面且该定时器未被清除,回调函数会持续在后台触发。它会保持其闭包存活,而该闭包所引用的任何函数、变量甚至整个组件实例也会随之存在。几个被遗忘的定时器占用的内存可能远超预期。当相关操作不再进行时,应立即清除这些定时器:

clearInterval(timer);

同样的原则也适用于 setTimeout()(通过 clearTimeout())和 requestAnimationFrame()(通过 cancelAnimationFrame())。

已分离的 DOM 节点

已分离的节点是指不再属于文档结构但仍在 JavaScript 中被引用的元素。通过获取模态框并将其移除,即可创建这样的节点:

const modal = document.getElementById("modal");
modal.remove();

它看似已消失,但如果仍有任何变量、数组、闭包或状态对象指向该元素,那么它及其子节点就无法被回收。那些动态创建模态框、工具提示、下拉菜单或通知面板的应用尤其容易出现这种情况。虽然每个节点的体积很小,但经过数百次交互后,这些被分离出的子树可能会占用相当大的内存。

捕获了过多信息的闭包

闭包是 JavaScript 最强大的特性之一,但同时也容易导致意外地占用内存。以一个在返回函数之前先分配一个大数组的“工厂函数”为例:

function createLogger() {
    const largeData = new Array(100000).fill("data");
    return function () {
        console.log("Logging...");
    };
}

返回的函数从未访问过largeData。该数组是否仍然存在取决于引擎如何表示其所在的作用域。实际上,V8只会保留该作用域中某些闭包实际引用的变量,因此这段代码通常不会保留该数组。当在同一作用域中创建的另一个闭包使用了largeData时,就会出现风险:这些闭包共享同一个上下文对象,从而导致生命周期较长的日志记录器也会使该大型数组保持存活状态。在作用域内使用eval也会迫使引擎保留所有内容。

这些因素并不会使闭包变坏;现代 JavaScript 正是依赖它们的。关键在于要谨慎控制长期存在的函数能够访问的范围。如果它只需要一个值,那就直接传递或复制该值,而非创建包含整个对象或数据集的闭包。对作用域进行适度调整就能显著降低内存占用。

没有淘汰策略的缓存

缓存可以避免重复工作,但只會不断扩大的缓存只不过是出于好意却导致内存泄漏的工具。以下是一个最简单的记忆化查询示例:

const cache = {};
function getUser(id) {
    if (!cache[id]) {
        cache[id] = fetchUser(id);
    }    return cache[id];
}

起初它的性能表现不错。但在投入生产六个月后,其中可能存储着数十万条再也不会被查询的记录。与其让缓存无限制地扩大,不如考虑:

  • 设置最大容量
  • 为过时条目设定基于时间的过期时间
  • 采用最近最少使用(LRU)的淘汰策略
  • 当缓存键为那些其生命周期应决定对应条目生命周期的对象时,可使用 WeakMap
  • 请注意,WeakMap仅接受对象(或未注册的符号)作为键,因此它无法替代上述那种以数字ID为键的缓存的LRU机制。如需了解弱引用的工作原理,可参阅我们关于符号、WeakMap、代理和生成器的概述。没有删除策略的缓存实际上并非真正的缓存,而只是永久存储。

    永远存在的全局变量

    任何附加在全局作用域中的内容都会与应用程序一同存在。这既方便又充满风险。像这样的模块级集合:

    let allUsers = [];
    

    每次添加新数据时都会不断增长:

    allUsers.push(...newUsers);
    

    除非有代码明确对其进行清理,否则数组只会不断变大。在数月的开发过程中,大型全局对象往往会变成数据堆积的场所。当调查性能问题时,全局状态是首先需要检查的地方之一。

    WebSockets及其他长连接

    实时功能通常依赖于WebSockets,而创建一个WebSocket只需一行代码:

    const socket = new WebSocket(url);
    

    错误在于没有关闭它。即使用户已离开,处于打开状态的套接字仍会持续接收消息、触发回调并保留应用程序状态。当创建这些连接的功能不再使用时,应立即关闭连接:

    socket.close();
    

    同样的规则也适用于可观察对象、数据流、自定义事件发射器以及任何可能比其使用者存活更久的订阅机制。

    共同点

    这些例子表面上看各不相同,但它们都有一个根本原因:总有某种东西持续保留着对本应无法访问的对象的引用。这类事物通常包括:

    • 已注册的监听器
    • 尚未到期的定时任务或超时设置
    • 闭包作用域
    • 不断扩大的缓存
    • 模块级或全局变量
    • 未关闭的套接字或其他订阅项

    一旦用引用而非对象的角度来思考问题,查找内存泄漏就会变得更为直观。不必追问为什么内存持续增长,只需找出仍在保留该对象的因素即可。这个问题往往能直接帮助找到泄漏点。

    在用户发现之前找到内存泄漏

    了解原因固然有用,但在实际的代码库中,关键问题在于内存泄漏究竟发生在何处。一个大型应用可能包含数千个组件和数百个监听器,且每秒都会创建新的对象。靠猜测几乎无法找到问题所在。浏览器工具非常出色,关键在于以一致且规范的方式使用它们,而非试图掌握开发工具的所有功能。

    确认确实存在内存泄漏

    内存持续上升并不一定意味着存在泄漏。浏览器引擎会在应用运行时分配内存,同时在垃圾回收时释放内存,因此正常运行的应用会呈现锯齿状的内存使用曲线:

    Memory
     ^
     |        /\      /\       /\
     |       /  \    /  \     /  \
     |______/____\__/____\___/____\____ Time
    

    在应用活跃使用时内存使用量会上升,每次垃圾回收后又会下降。而存在内存泄漏的应用则表现不同:

    Memory
     ^
     |          /\        /\
     |         /  \      /  \
     |        /    \    /    \
     |_______/______\__/______\________
     |              /
     |             /
     |            /
     |___________/________________ Time
    

    那些小的下降幅度表明垃圾回收器正在运行,但每次的最低点都比前一次更高。内存始终无法回到之前的基准值,这正是对象被保留下来的第一个明显迹象。在下结论之前,请手动触发垃圾回收(在“内存”和“性能”面板中的垃圾桶图标),因为仅因尚未进行垃圾回收而导致基准值偏高的情况并不属于内存泄漏。

    步骤1:打开“内存”面板

    Chrome提供了多种内存分析工具,但无需同时使用所有工具。打开开发者工具并切换到内存面板。根据Chrome的版本不同,您会看到堆快照、时间轴上的内存分配记录以及内存分配采样等分析类型;不同版本的具体名称和选项会有所差异,如果与当前版本不同,请查阅最新的开发者工具文档。

    对于大多数调查而言,堆快照是合适的起点,因为它能显示当前占用内存的内容。

    步骤2:记录基准状态

    在测试可疑功能之前,先对应用程序的初始状态进行快照拍摄,即获取堆的内存结构图像。之后反复执行你怀疑会引发问题的操作。例如:

    • 打开并关闭模态窗口十次
    • 在页面之间来回切换
    • 执行文件上传操作
    • 对大数据表应用筛选条件
    • 反复切换控制面板中的标签页

    操作完成后,再拍摄一张快照。这样你就有了两个状态可供对比。

    步骤3:比较快照

    这才是调查真正开始的地方。如果清理机制有效,交互过程中创建的临时对象在收集后应该会消失。若无效,则某些类型的对象数量会持续增加。常见的嫌疑对象包括:

    • 已分离的 DOM 元素
    • 大型数组
    • 事件监听器
    • 你自己的应用程序类
    • 本应已被销毁的框架组件

    你无需了解堆内存中的每一个对象。使用对比视图,找出每次重复相同操作时其数量都会以固定幅度上升的对象类型。这种一致性通常是最重要的线索。

    已分离的 DOM 节点是最容易发现的证据

    已断开的节点是最容易识别的泄漏类型之一。打开并关闭一个模态框二十次;每次关闭后,该模态框应该会消失。如果快照中仍然存在二十个模态框元素,那就说明有某些东西仍在保留它们。你可以在快照的类过滤器中输入“Detached”来快速列出这些元素。

    DOM本身很少是真正的问题所在,实际原因通常出在其他地方:

    • 元素上或某个长期存在的目标对象上仍注册有处理程序
    • 存在回调函数中提到了该节点的间隔函数或超时函数
    • 有捕获了该元素的闭包
    • 有存储了指向该节点的指针的存储空间、组件字段或数组

    应将已断开的节点视为一种症状:它表明有其他引用阻止了清理操作。

    追踪保留链

    一旦发现某个显然不应再存在的对象,接下来的问题就是是谁在维持它的存在。堆快照中的Retainers部分正好可以回答这个问题。选中该对象后,Chrome会显示将其与垃圾回收根节点相连的引用链。从概念上来说,其结构可能如下所示:

    Window
       │
    Application
       │
    UserService
       │
    cachedUsers
       │
    User Object
    

    此时的调查就变得简单多了。不必再困惑于某个用户对象为何会持续存在,因为可以看到它被UserService中的cachedUsers集合所引用。找到被保留的对象固然有帮助,但真正解决漏洞的关键在于确定是什么在保留它

    使用性能监控工具查看实时计数器

    快照非常适合进行详细分析,但并非唯一的工具。Chrome的性能监控工具可以显示包括以下内容在内的实时指标:

    • JS 堆内存大小
    • DOM 节点数量
    • JS 事件监听器数量
    • 文档与框架数量

    如果在重复执行相同操作时 DOM 节点或监听器数量持续上升,说明没有进行必要的清理。这样做的好处是能更快发现问题:无需等到应用变得缓慢,可疑的趋势往往在几分钟内就会显现出来。

    选取一个小型且可重复的场景

    常见的错误是试图一次性调查整个应用。应将其缩小为单一交互场景:

    • 显示一个模态框,将其关闭,连续执行二十次
    • 或者,在相同的两个页面之间切换五十次

    像这样的极端情况要量化起来容易得多。当某个重复操作在每次执行时都能带来性能提升时,搜索范围就已经大幅缩小,责任代码通常也容易从中找到。

    刻意测试长时间运行场景

    开发者往往只让应用运行十到十五分钟就停止测试。而内部控制面板、交易平台、监控工具或支持门户的真正用户可能会一整天都保持应用处于打开状态。在内存测试中应包含长时间运行的场景:让应用持续运行,定期与其交互,并观察内存使用情况的变化。许多内存泄漏问题要经过数百次或数千次交互后才会显现出来。

    避免凭猜测的调试流程

    在各种性能分析工具之间来回切换只会浪费时间。遵循固定的步骤往往更有效:

    1. 确认内存使用量在多次操作后持续上升。
  • 通过一次可重复的操作来重现增长现象。
  • 获取操作前后的堆快照。
  • 比较两次快照中的对象数量。
  • 找出被保留的对象。
  • 追踪引用链,确定谁持有该引用。
  • 修复代码后再次运行完全相同的测试。
  • 这样就能避免凭猜测行事。不必假设某个特定组件是问题所在,而是让证据指引你找到它。

    在问题影响生产环境之前加以预防

    诊断只是问题的一半;更省钱的办法是从源头避免内存泄漏。大多数内存泄漏并非开发者误解了 JavaScript 所致,而是因为现代应用程序生命周期长、交互性高且持续进行内存分配,在这样的环境中很容易忘记所有创建的对象最终都需要被销毁。那些很少遇到内存问题的团队并不一定是在编写更智能的代码,只是他们有一些习惯使得内存泄漏不太可能发生。

    为每个资源设定明确的生命周期

    每当代码创建了会长期存在的对象时,都要问自己一个问题:它何时会被销毁?这一原则不仅适用于原始内存:

    • 元素、windowdocument 上的监听器
    • 定时器、超时处理以及动画帧
    • 套接字及其他网络连接
  • 可观察对象、存储及其他订阅机制
  • 已保存的 DOM 元素引用
  • 缓存的响应结果与计算值
  • Web 工作线程及类似的后台任务
  • 设置这些功能通常很简单,但清理工作却是应用程序的薄弱环节。有一条简单的原则可以概括这一点:如果代码有启动过程,那就必须有停止机制。只要秉持这种思维,就能避免不少内存泄漏问题。

    让组件能够自动清理自身

    基于组件的框架鼓励开发自包含的单元,而自包含性应包括销毁功能。启动了计时器的组件在卸载时应停止计时器,注册了监听器的组件也应移除这些监听器。一旦组件离开屏幕,它所启动的任何操作都不应继续运行。

    想象一下退房时的情景:你不会离开时还让灯光亮着、电视开着或水龙头滴个不停。行为良好的组件会保持事物原有的状态。在 React 中,这意味着每个进行订阅、调度或连接的 useEffect 都应返回一个清理函数。

    围绕移除而非仅插入来设计缓存

    缓存的初衷是避免重复调用 API 或进行昂贵的计算。但几个月后,它们可能存储着数千个数周无人查看的对象。在设计缓存时,要像考虑数据如何进入一样仔细地思考数据如何离开:

    • 一条记录应在内存中保留多久?
    • 最大容量是多少?
    • 记录是否应自动过期?
    • 很少使用的记录能否被移除?

    如果这些问题没有答案,缓存几乎肯定会随着时间不断膨胀。

    仅保留真正需要的数据

    另一个常见问题是,即使只使用了对象的一小部分,却仍保留整个对象。如果只是为了显示用户名而获取了庞大的用户资料,就没有理由无限期地保存完整响应;只需存储界面所需的字段即可。较小的对象占用的内存更少,更易于理解,也不太可能被意外保留下来。有时候,解决之道并非编写更多代码,而是减少存储的数据量。

    重视微小的数据泄漏

    人们往往会忽视几千字节级别的数据泄漏。问题在于,用户很少只执行某项操作一次。全天候运行的仪表板、数百名员工共享的内部管理工具,或是从未被重新加载的监控界面,都会让相同的代码路径被执行数千次。今天还几乎无法察觉的数据泄漏,在数周的正常使用后可能会演变成严重的问题。

    在日常开发中加入内存检查

    性能优化通常关注加载时间和API延迟,但内存问题同样不容忽视。在开发新功能时,多花几分钟检查以下内容:

    • 使用该功能后内存是否恢复到初始水平?
    • 监听器数量是否有异常增长?
    • 移除组件后DOM节点是否消失?
    • 重复执行相同操作是否会持续增加内存占用?

    这些检查成本很低,却能节省日后数小时的调试时间。

    代码审查检查清单

    在合并代码之前,先快速过一遍以下要点:

    • 所有新增的事件监听器是否都已移除?
    • 不再需要的定时器是否已清除?
    • 订阅项是否已正确释放?
    • 该缓存是否会无限制地增长?
  • 引用是否被保留过长时间?
  • 组件是否会清理它创建的所有对象?
  • 无需对每一行代码都进行此类检查,但将其纳入审查流程能显著降低出现漏洞的风险。

    关键要点

    • 漏洞通常不会在首次使用时就导致程序崩溃;它们会悄悄积累,首先影响那些最活跃的用户,这正是其危险所在。
    • 漏洞也是可预测的:一个对象之所以存在,只是因为还有其他地方在引用它,因此每个漏洞都可以追溯到本应被移除的引用。
    • 当应用程序在数小时内变慢时,不要急于归咎于浏览器或引擎。应先获取堆快照,找出被保留的对象,并追踪其引用链。
    • 常见的答案都很平常:被遗忘的监听器、未清除的计时器、无限制的缓存,或是始终未能完成清理工作的组件。
    • 快速的 JavaScript 不仅关乎执行速度,还涉及对所分配资源生命周期的管理,这样才能确保无论用户使用应用的时间是五分钟还是一整个工作日,应用都能保持响应灵敏。

    相关阅读