加速JavaScript - draft-js emoji plugin

我通过一则非常有趣的问题,涉及一个网站在某些情况下卡顿了大约2-3秒。该网站在某些输入框中使用了 draft-js 富文本编辑器,他能够将问题追溯到 draft-js 的 emoji 插件。因此,我们决定一起进行调试。

通过使用 Chrome 的性能分析器快速录制,确认了最初的猜测。emoji 插件出了些问题。我们可以在底部看到许多频繁的函数调用,占用了大部分时间。不幸的是,Chrome 的性能分析器没有像 speedscope 那样的 “左重” 可视化。这使得更难看出哪个函数值得调查。”left-heavy” 在这方面更好,因为它将相似的调用栈合并为一个。

每个单独的调用似乎都没问题,并且最终总是调用到正则表达式引擎。但是调用次数过多足以引起担忧。Chrome 的性能分析器的一个很酷的功能是,它可以使用采样跟踪注释源代码行。这为您提供了每行执行所需时间的近似值。由于前端项目中涉及大量转译,它并不百分之百准确,但足以得出一些初步结论。

耗费大部分时间的两个方法涉及正则表达式。或许很容易得出结论是正则表达式本身有问题,但我有一种感觉,那只是一个更深层次问题的症状。我们首先检查的是这个函数被调用的频率。这可以通过递增一个简单的计数器或直接使用 console.count() 来完成。

1
2
3
4
5
6
7
8
9
10
11
  ns.escapeRegExp = function(string) {
+ console.count("escapeRegExp");
return string.replace(/[-[\]{}()*+?.,;:&\\^$#\s]/g, "\\$&");
};

ns.replaceAll = function(string, find) {
+ console.count("replaceAll");
var escapedFind = ns.escapeRegExp(find);
var search = new RegExp("<object[^>]*>.*?<\/object>|<span[^>]*>.*?<\/span>|<(?:object|embed|svg|img|div|span|p|a)[^>]*>|("+escapedFind+")", "gi");
// ...
};

原来每次加载页面时都会调用 replaceAll 方法7318次。接下来,我们检查了escapeRegExp被调用时使用的参数。理论是也许它总是被相同的参数调用。

一分钟后,这个假设被证明是正确的,因为它一遍又一遍地转义相同的字符串。我们知道这个方法是从 replaceAll 被调用的,所以让我们检查一下我们是否总是传递相同的参数。确实如此,第一个字符串参数接收了两个不同的值,但第二个 find 参数总是相同的。这是稍后传递给 escapeRegExp 的参数。

立即弹出的问题是:“是谁在调用 replaceAll,为什么他们总是传递相同的参数?”

再次深入分析性能分析,我们观察到所有对 replaceAll 的调用都有toShort作为共同的祖先。

这里发生的非常有趣的事情是,传递给 replaceAll 的第二个参数与传递给 toShort 的参数无关。跟踪 unicodeCharRegex 的路径,我们更清楚地了解了这里代码的目的。就像在 Slack 等流行的聊天应用中一样,draft-js 的插件允许你输入文本 :smile:,然后自动转换为适当的表情符号 "😀"。但反过来也是需要的,这就是我们在这里看到的。
找到解决方案
了解更多关于这段代码目的的信息后,我们注意到正则表达式是通过迭代包含 Unicode 字符及其元数据的大型数据结构来构建的。然后,该正则表达式被应用于传入的文本,以匹配表情符号并用简码替换它们。每次调用toShort时,都会从头开始构建正则表达式。这成为一个性能问题,因为 Unicode 标准中有大量的表情符号,包括肤色变体和其他元素。因此,不难理解为什么最终的正则表达式会非常庞大。

Chrome 的控制台显示,仅生成正则表达式的字符串就超过了42KB。

与大多数昂贵计算的情况一样,我们可以通过缓存先前的结果来避免大部分工作。这样我们就不需要一遍又一遍地重新计算相同的事情。这是一个最不侵入的修复方法,不需要对插件的架构进行较大的更改。我们发起了一个 PR,并将阻塞时间从2-3秒降低到了总共小于200毫秒。虽然这仍然比我们想要的多很多,但在用户体验方面已经是天翻地覆的改进。

我们在这个阶段结束了研究,但这让我想知道如果我们不局限于保持当前的架构,还能做些什么。

结论

如果我们退后一步,总是在加载该模块时构建那个正则表达式似乎是一种浪费。结果总是相同的,因此一种优化方法是存储转换后的结果,并从一开始在插件中使用它。这可以在发布新版本插件时构建。

追求的另一个潜在想法是使用手工制作的函数来匹配表情符号。它可以让您非常快速地缩小潜在匹配的搜索空间,但仍然需要验证这种方法是否在这种情况下比正则表达式更快。我认为值得一试。

Fabio Spampinato 在 Twitter 上分享了一个更好的想法。与其构建一个 >40kB 的正则表达式,我们可以利用最近的Unicode增强。这包括特殊的 Unicode 属性转义,如 Emoji_Presentation,允许您直接匹配所有表情符号(例如:/\p{Emoji_Presentation}/gu)。有了这个,我们可以完全摆脱正则表达式生成代码。在 MDN 上了解更多信息。

总的来说,这个特定的问题提醒我们时不时地对代码进行性能分析。这是一个提醒,即即使是看起来无害的函数也可能对性能产生巨大的影响。

原文:https://marvinh.dev/blog/speeding-up-javascript-ecosystem-part-5/