对 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(