Firefox 前端工程师的性能最佳实践

本指南将帮助 Firefox 开发人员在编写前端代码时,尽可能地提高代码性能——不仅是代码本身的性能,还包括其对 Firefox 其他部分的影响。始终牢记更改可能产生的副作用,从阻塞其他任务到干扰其他用户界面元素。

尽可能避免使用主线程

主线程用于处理用户事件和绘制。还需要注意的是,我们的大部分 JavaScript 代码都在主线程上运行,因此脚本很容易导致事件处理或绘制延迟。这意味着,我们能够从主线程中移出越多的代码,该线程就越能够响应用户事件、绘制以及总体上对用户更具响应性。

如果需要进行一些可以在主线程之外完成的计算,可以考虑使用 Worker。如果需要比标准 Worker 更高的权限,可以考虑使用 ChromeWorker,这是一个 Firefox 独有的 API,允许创建具有更高权限的 Worker。

使用 requestIdleCallback()

如果确实无法避免在主线程上执行某些长时间运行的任务,请尝试将其分解成较小的部分,以便在浏览器有空闲时间并且用户没有进行任何操作时运行。可以使用 **requestIdleCallback()** 和 后台任务 API 的协作调度 来实现,并且仅在浏览器有空闲时间(用户可能没有进行任何操作)时才执行。

另请参阅博客文章 使用 requestIdleCallback 进行协作调度

bug 1353206 开始,您还可以使用 **Services.tm.idleDispatchToMainThread** 在非 DOM 上下文中调度空闲事件。有关更多详细信息,请参阅 **nsIThreadManager.idl** 文件。

隐藏面板

如果正在向文档添加新的 XUL <xul:popup><xul:panel>,请默认将 **hidden** 属性设置为 **true**。这样做会导致绑定按需应用而不是在加载时应用,这使得 XUL 文档的初始构建速度更快。

熟悉将像素传递到屏幕的管道

了解绘制的像素如何传递到屏幕。了解它们在浏览器引擎各个层中的路径将有助于优化代码以避免陷阱。

渲染过程会经历以下步骤

This is the pipeline that a browser uses to get pixels to the screen

以上图片根据 知识共享署名 3.0 许可使用,由 此页面(来自我们 Google 的朋友)提供,该页面本身也值得一读。

为了对管道中的样式、布局、绘制和合成步骤进行非常通俗易懂的解释,这篇 Hacks 博客文章 做了很好的解释。

为了达到 60 FPS 的帧率,以上所有操作必须在每帧 16 毫秒或更短的时间内完成。

请注意,**requestAnimationFrame()** 允许您将 JavaScript 排队,以便在 **样式刷新发生之前立即运行**。这使您可以将所有 DOM 写入(最重要的是,任何可能更改 DOM 中元素大小或位置的操作)放在管道中样式和布局步骤之前,将所有样式和布局计算合并到一个批次中,以便所有操作都一次完成,在一个帧周期内,而不是跨多个帧。

请参阅下面的 检测和避免同步重排 以获取更多信息。

这也意味着 requestAnimationFrame() **不是放置布局或样式信息查询的合适位置**。

检测和避免同步样式刷新

什么是样式刷新?

当 CSS 应用于文档(HTML 或 XUL,无关紧要)时,浏览器会进行计算以确定哪些 CSS 样式将应用于每个元素。这在页面首次加载并最初应用 CSS 时发生,但如果 JavaScript 修改了 DOM,则可能会再次发生。

例如,JavaScript 代码可能会更改 DOM 节点属性(直接或通过添加或删除元素的类),还可以添加、删除或删除 DOM 节点。因为样式通常作用于整个文档,所以执行这些样式计算的成本与文档中的 DOM 节点数(以及应用的样式数)成正比。

预计随着时间的推移,脚本将更新 DOM,需要我们重新计算样式。通常,对 DOM 的更改只会导致标准样式计算在 JavaScript 运行完成后立即在 16 毫秒窗口内进行,“样式”步骤中。这是理想情况。

但是,脚本可能会执行某些操作,强制在 JavaScript 部分的 16 毫秒窗口内同步发生多次样式计算(或 **样式刷新**)。刷新次数越多,超出 16 毫秒帧预算的可能性就越大。如果发生这种情况,其中一些刷新将被推迟到下一帧(或者如果需要,可能是多帧),这种帧跳过称为 **卡顿**。

一般来说,在同一帧周期内 DOM 发生更改后,如果查询样式信息,就会强制进行同步样式刷新。根据 所请求的样式信息是否与大小或位置有关,您也可能会导致布局重新计算(也称为 布局刷新重排),这也是一个昂贵的步骤,请参阅下面的 检测和避免同步重排

为避免这种情况:如果可以,请避免读取样式信息。如果必须读取样式信息,请在帧的开头进行,即在自上次样式刷新以来对 DOM 进行任何更改之前。

从历史上看,还没有一种简单的方法可以做到这一点——但是,bug 1434376 已将一些 ChromeOnly 帮助程序添加到窗口绑定中,使操作更简单。

如果要将一些 JavaScript 排队,以便在下一个 自然 样式和布局刷新后运行,请尝试

// Suppose we want to get the computed "display" style of some node without
// causing a style flush. We could do it this way:
async function nodeIsDisplayNone(node) {
  let display = await window.promiseDocumentFlushed(() => {
    // Do _not_ under any circumstances write to the DOM in one of these
    // callbacks!
    return window.getComputedStyle(node).display;
  });

  return display == "none";
}

请参阅 检测和避免同步重排,了解获取布局信息然后安全地设置布局信息(而不会导致刷新)的更高级示例。

bestpractices.html#detecting-and-avoiding-synchronous-reflow

promiseDocumentFlushed 仅适用于特权脚本,应在顶级框架的内部窗口上调用。在子框架的外部窗口上调用它不受支持,并且在子框架的内部窗口中调用它可能会导致回调触发,即使仍然需要样式和布局刷新。这些问题应由 bug 1441173 解决。

目前,您作为此 API 的使用者有责任不要在 promiseDocumentFlushed 回调中意外写入 DOM。这样做可能会导致为在刷新驱动程序的同一周期内计划触发的其他 promiseDocumentFlushed 回调发生刷新。 bug 1441168 跟踪使在 promiseDocumentFlushed 回调中修改 DOM 成为不可能的工作。

编写测试以确保不会添加更多同步样式刷新

与重排不同,没有用于样式重新计算的“观察者”机制。但是,从 Firefox 49 开始,nsIDOMWindowUtils.elementsRestyled 属性记录了特定 DOM 窗口发生的样式计算次数。

应该可以编写一个测试,获取浏览器窗口的 nsIDOMWindowUtils,记录样式刷新的次数,然后 **同步调用** 要测试的函数,并在之后立即再次检查 styleFlushes 属性。如果值增加了,则代码导致同步样式刷新发生。

请注意,测试和函数 必须同步调用,才能使此测试准确。如果返回到事件循环(通过让步、等待事件等),则与代码无关的样式刷新可能会运行,并且测试将给出误报。

检测和避免同步重排

这有时也称为“同步布局”、“同步布局刷新”或“同步布局计算”。

同步重排 是一个经常被提到的术语,并且具有负面含义。工程师通常只对它有一个模糊的概念——并且只知道要避免它。本节将尝试揭开这些神秘面纱。

文档(XUL 或 HTML)首次加载时,我们会解析标记,然后应用样式。计算完样式后,我们需要计算元素在页面上的放置位置。此布局步骤可以在上面的“16 毫秒”管道图形中看到,并且在我们将元素绘制以供用户查看之前发生。

预计随着时间的推移,脚本将更新 DOM,需要我们重新计算样式,然后更新布局。但是,通常情况下,对 DOM 的更改只会导致标准样式计算在 JavaScript 运行完成后立即在 16 毫秒窗口内进行。

可中断重排

早期开始,Gecko 就有了可中断重排的概念。这是一种特殊的 **仅限内容** 重排,它在特定点检查是否应中断(通常是为了响应用户事件)。

因为 **可中断重排只能在布局内容时中断,而不能在布局 chrome UI 时中断**,所以本节的其余部分仅作为背景信息提供。

当可中断重排被中断时,真正发生的事情是某些布局操作可以被跳过,以便更快地绘制和处理用户事件。

当可中断重排被中断时,最佳情况是所有布局都被跳过,并且布局操作结束。

最坏的情况是,尽管被中断,但没有一个布局可以跳过,并且整个布局计算都发生。

由 16 毫秒周期“自然”触发的重排都被视为可中断的。尽管在布局 chrome UI 时实际上不可中断,但追求可中断布局始终是一个好习惯,因为不可中断布局的潜在问题要严重得多(请参阅下一节)。

重申一遍,只有 Web 内容中的可中断重排才能被中断。

不可中断重排

不可中断重排是我们需要 **不惜一切代价避免** 的。当某个 DOM 节点的样式发生更改,需要更新文档中一个或多个节点的大小或位置,并且 **JavaScript 请求任何元素的大小或位置** 时,就会发生不可中断重排。由于所有操作都等待重排,因此无法获得答案,因此所有操作都会停滞,直到重排完成并且脚本可以获得答案。刷新布局也意味着必须刷新样式以计算最新状态,因此这是一个双重打击。

以下是一个简单的示例,摘自 Paul Rouget 的这篇博文

div1.style.margin = "200px";        // Line 1
var height1 = div1.clientHeight;    // Line 2
div2.classList.add("foobar");       // Line 3
var height2 = div2.clientHeight;    // Line 4
doSomething(height1, height2);      // Line 5

在第 1 行,我们正在为某个 DOM 节点设置一些样式信息,这将导致重排——但是(仅在第 1 行)没关系,因为该重排将在样式计算之后发生。

但是请注意第 2 行——我们正在请求某个 DOM 节点的高度。这意味着 Gecko 需要使用不可中断重排同步计算布局(和样式),以回答 JavaScript 提出的问题(“div1clientHeight 是多少?”)。

我们的示例可以通过将第 2 行和第 4 行移到第 1 行上方来避免这种同步的、不可中断的重排。假设第 1 行上方没有任何需要重新计算大小或位置的样式更改,则clientHeight信息应该会被缓存,因为它是从上次重排后缓存的,并且不会导致新的布局计算。

如果可以避免在 JavaScript 中查询元素的大小或位置,这是最安全的选择——尤其是在 JavaScript 执行的当前这一轮中,某些更早的代码可能在您不知情的情况下更改了 DOM 的样式。

请注意,对于 Chrome UI 文档的相同 DOM 更改,单个同步的不可中断重排在计算量上并不比 16 毫秒周期触发的可中断重排更昂贵。但是,最好努力让重排只发生在一个地方(16 毫秒周期的布局步骤),而不是在 16 毫秒周期内多次发生(这有更高的概率会超出 16 毫秒的预算)。

如何避免触发不可中断的重排?

这是一个JavaScript 可以请求导致不可中断重排的操作列表,以帮助您思考这个问题。请注意,列表中的一些项目可能是特定于浏览器的或可能随时更改,并且列表中未明确出现的项目并不意味着它不会导致重排。例如,在撰写本文时,访问event.rangeOffset在 Gecko 中会触发重排,而在前面的链接中则不会。如果您不确定某项操作是否会导致重排,请检查!

请注意第一个列表中属性的丰富程度。这意味着当枚举 DOM 对象(例如元素/节点、事件、窗口等)上的属性时,访问每个枚举属性的值几乎肯定会(意外地)导致不可中断的重排,因为许多 DOM 对象都拥有一个或多个这样的属性。

如果您需要大小或位置信息,您可以选择以下几种方法。

bug 1434376 在窗口绑定中添加了一个辅助函数,以便特权代码更容易在知道 DOM 未被修改且大小、位置和样式信息查询成本较低时排队执行 JavaScript。

以下是一个示例

async function matchWidth(elem, otherElem) {
  let width = await window.promiseDocumentFlushed(() => {
    // Do _not_ under any circumstances write to the DOM in one of these
    // callbacks!
    return elem.clientWidth;
  });

  requestAnimationFrame(() => {
    otherElem.style.width = `${width}px`;
  });
}

有关如何使用此 API 的更多信息,请参阅检测和避免同步样式刷新中关于promiseDocumentFlushed的部分。

请注意,只有当 DOM 被写入时,大小和位置信息的查询才会很昂贵。否则,我们只需廉价地查找缓存的信息。如果我们努力将所有 DOM 写入操作都移到requestAnimationFrame()中,那么我们可以确保所有大小和位置查询都是廉价的。

也可以(尽管不如promiseDocumentFlushed可靠)将 JavaScript 排队在框架绘制后立即运行,此时 DOM 很可能尚未被写入,布局和样式信息的查询仍然很廉价。例如,这可以通过使用setTimeout或在requestAnimationFrame回调中分派一个可运行对象来完成。

requestAnimationFrame(() => {
  setTimeout(() => {
    // This code will be run ASAP after Style and Layout information have
    // been calculated and the paint has occurred. Unless something else
    // has dirtied the DOM very early, querying for style and layout information
    // here should be cheap.
  }, 0);
});

// Or, if you are running in privileged JavaScript and want to avoid the timer overhead,
// you could also use:

requestAnimationFrame(() => {
  Services.tm.dispatchToMainThread(() => {
    // Same-ish as above.
  });
});

这也意味着在requestAnimationFrame()查询大小和位置信息很可能会导致同步重排。

其他有用的方法

下面您将找到一些其他方法的建议,当您需要执行操作而无需造成同步重排时,这些方法可能会派上用场。这些方法通常会返回请求值的最新计算值,这意味着该值可能不再是最新的,但可能仍然“足够接近”您的需求。除非您需要精确的信息,否则它们可以成为您性能工具箱中的宝贵工具。

nsIDOMWindowUtils.getBoundsWithoutFlushing()

getBoundsWithoutFlushing()的功能与其名称完全一致:它允许您获取窗口中包含的 DOM 节点的边界矩形,而无需刷新布局。这意味着您获得的信息可能是过时的,但允许您避免同步重排。如果您能够使用可能不是最新的信息,这将非常有用。

nsIDOMWindowUtils.getRootBounds()

getBoundsWithoutFlushing()类似,getRootBounds()允许您获取窗口的尺寸,而无需冒同步重排的风险。

nsIDOMWindowUtils.getScrollXY()

返回窗口的滚动偏移量,而不会冒导致同步重排的风险。

编写测试以确保您不会添加更多意外的重排

接口nsIReflowObserver允许我们检测可中断和不可中断的重排。已经编写了许多测试来测试浏览器的各种功能打开标签页打开窗口,并确保在这些操作发生时,我们不会意外地添加新的不可中断重排。

如果您碰巧正在修改 DOM,则应该为您的功能添加类似的测试。

检测过度绘制

总的来说,绘制比样式计算和布局计算都便宜;但是,您能避免的越多越好。一般来说,需要重新绘制的区域越大,花费的时间就越长。同样,需要重新绘制的事物越多,花费的时间就越长。

如果配置文件显示大量时间花费在绘制或显示列表构建上,并且您不确定原因,请考虑与我们始终乐于助人的图形团队在gfx 聊天室(位于Matrix上)联系,他们可能会为您提供建议。

请注意,图形团队的大多数成员都位于美国东部时区(夏令时期间为 UTC-5 或 UTC-4),因此在您在gfx 聊天室中提问时,请参考此信息安排时间。

使用 DocumentFragments 添加节点

有时您需要将多个 DOM 节点添加为现有 DOM 树的一部分。例如,当使用 XUL <xul:menupopup>s时,您通常会使用动态插入<xul:menuitem>s的脚本。将项目插入 DOM 会产生一定的成本。如果您在循环中将多个子节点添加到 DOM 节点,则通常通过创建一个DocumentFragment,将新节点添加到其中,然后将DocumentFragment作为所需节点的子节点插入,可以更有效地批量插入它们。

DocumentFragment在 DOM 本身之外的内存中维护,因此更改不会导致重排。API 非常简单

  1. 通过调用Document.createDocumentFragment()创建DocumentFragment

  2. 创建每个子元素(例如,通过调用Document.createElement()),并通过调用DocumentFragment.appendChild()将每个子元素添加到片段中。

  3. 片段填充后,通过在新元素的父元素上调用appendChild()将片段附加到 DOM。

此示例摘自davidwalsh 的博客文章

// Create the fragment

var frag = document.createDocumentFragment();

// Create numerous list items, add to fragment

for(var x = 0; x < 10; x++) {
    var li = document.createElement("li");
    li.innerHTML = "List item " + x;
    frag.appendChild(li);
}

// Mass-add the fragment nodes to the list

listNode.appendChild(frag);

上述方法严格来说比单独将每个节点添加到 DOM 更便宜。

Gecko 性能分析器插件是您的朋友

在诊断性能问题和查找瓶颈时,Gecko 性能分析器是您最好的朋友。MDN 上有大量关于 Gecko 性能分析器的优秀文档

不要猜测——要测量。

如果您正在进行性能改进,这一点应该不言而喻:通过在改进前后进行测量,确保您关注的内容确实得到了改善。

发布推测性的性能增强与发布推测性的错误修复相同——这些都需要进行测试。即使这意味着使用Date.now()在函数入口处记录时间,并在出口处使用另一个Date.now()来测量处理时间变化。

通过在改进前后进行测量,向自己证明您确实改进了某些内容。

使用 Performance API

Performance API非常适合进行高分辨率测量。这通常比使用您自己编写的计时器来测量事物花费的时间要好得多。您可以通过Window.performance访问此 API。

此外,Gecko 性能分析器后端正在修改以公开诸如标记(来自window.performance.mark())之类的内容。

使用合成器进行动画

在主线程上执行动画应该被视为已弃用。避免这样做。相反,请使用Element.animate()进行动画。有关如何执行此操作的更多信息,请参阅文章像您不在乎一样进行动画

明确定义动画的起始和结束值

Gecko 动画代码中的一些优化是基于这样的预期:from(0%)和to(100%)值将在@keyframes定义中明确定义。即使这些值可以通过使用初始值或级联来推断,但屏幕外动画优化也依赖于显式定义。有关更多信息,请参阅此注释以及该错误上的一些先前注释。

使用 IndexedDB 进行存储

AppCacheLocalStorage是同步存储 API,当您使用它们时会阻塞主线程。无论如何都要避免使用它们!

IndexedDB是首选,因为 API 是异步的(所有磁盘操作都在主线程之外进行),并且可以从 Web 工作线程访问。

IndexedDB 也可能比从文件中存储和检索 JSON 更有效——特别是如果 JSON 编码或解码发生在主线程上。IndexedDB 将使用结构化克隆算法为您执行 JavaScript 对象序列化和反序列化,这意味着您可以存储诸如映射、集合、日期、Blob 等内容,而无需进行 JSON 兼容性的转换。

Chrome 代码可以使用一个基于 Promise 的 IndexedDB 包装器IndexedDB.sys.mjs

在弱硬件上进行测试

对于那些负责开发 Firefox 的人员来说,我们往往拥有非常强大的开发硬件。这很好,因为它可以减少构建时间,并且意味着我们可以更快地完成工作。

我们应该提醒自己,大多数用户不太可能拥有类似的硬件。查看Firefox 硬件报告,以了解我们用户的硬件情况。在较慢的机器上进行测试,以便您更清楚地了解您编写的代码是否会影响浏览器的性能。

考虑使用子脚本加载器异步加载脚本

如果您曾经使用过子脚本加载器,您可能不知道它可以异步加载脚本,并在脚本加载完成后返回一个 Promise。例如

Services.scriptloader.loadSubScriptWithOptions(myScriptURL, { async: true }).then(() => {
  console.log("Script at " + myScriptURL + " loaded asynchronously!");
});