对 rehype-highlight (v7.0.0) 内存泄漏问题的分析
内存泄漏的整体分析在这个 issue comment 里面进行了概括,但是其实说明不是很完全。
咱再来手动说明一下吧。rehype-highlight 的上游库 lowlight
使用 highlight.js 进行代码高亮,使用 hljs 提供的 newInstance
方法创建多个 HLJS 实例。理论上来说这些实例在不使用之后应当被垃圾回收,但是事实上这么美好的愿景并没有实现,原因就是 newInstance
方法的实现偷懒了,导致每次都会执行 window.addEventListener('DOMContentLoaded', boot, false);
,创建了对 boot 函数的引用(而 boot 函数又引用了 highlightAll()
),导致 HLJS 这个闭包里的东西无法被垃圾回收。
我是如何分析的呢?
内存泄漏是很容易观察到的。差不多在 #791 被提出的第二天,我观察到了这个问题。于是便排查,但是由于当时水平太低,干了三天,没能排查出来。
转机出现在 2024 年 7 月 31 日,当时看到了 Chrome 提供的 queryObjects() / getEventListeners()
方法,瞬间感觉有思路了。于是次日早上便翻开 issue 继续排查(这东西这么久还没人排查出来吗 QwQ)。这次排查十分顺利。首先是 @zzzgydi 的一条 comment,然后我就去看有什么东西引用了这些看起来是表示语言的对象。(访问的是 https://remarkjs.github.io/react-markdown/)
然后注意到这个地方有一个 V8EventListener
。但是鉴于一开始对这个不是很熟悉,最后绕了弯路。不过最后还是回到了这个 EventListener
。但是我需要知道这个 listener 在哪里,用 getEventListeners(someNode)
,写了下面一段代码:
function traverseDOM(node, depth = 0) {
const l = getEventListeners(node);
if (Object.keys(l).length !== 0) console.log(node, getEventListeners(node))
node.childNodes.forEach(child => {
traverseDOM(child, depth + 1);
});
}
traverseDOM(document);
结果没发现这个 w 函数。想起来还有全局对象,所以:
getEventListeners(window)
看到这个结果给我激动半天。就说明,我们赢了!(x
二次确认一下:
getEventListeners(window).DOMContentLoaded.forEach(x => window.removeEventListener("DOMContentLoaded", x.listener))
发现内存占用果然骤减。
接下来只需要知道哪个地方添加了这个 listener。直接对着 index.module.js
搜索,发现这玩意就在 w()
函数的定义正下方。而且,全文只有他一个 DOMContentLoaded
的监听器。
function w() {
b && f()
}
typeof window < "u" && window.addEventListener && window.addEventListener("DOMContentLoaded", w, !1);
翻译过来:
function w() { /* ... */ }
if (window !== undefined && window.addEventListener) window.addEventListener("DOMContentLoaded", w, false);
接着一路翻上游库,rehype-highlight
没有,lowlight
没有,highlight.js
有。于是就有了情况说明的这些东西。确认了情况之后就赶紧给 highlight.js
开了个 issue,然后引用到这个仓库的对应 issue。
发现问题原因之后就等着上游修复吧~(x
这个故事告诉我们什么?构造函数里面不要塞奇怪的东西,尤其是有这种全局操作的!
找到问题根源还是很高兴的www(