Web性能优化:FOUC

背景

FOUC,也就是 flash of unstyled content,指的是网页渲染时,外部样式还没加载好,就以浏览器默认样式短暂地展示了部分内容,等到外部样式加载完成,又恢复正常的这个页面闪烁的过程。

看到网上有的文章说现代浏览器已经不需要关注 FOUC 的问题了,这其实是不对的,虽然现代浏览器针对首次绘制做了一些优化,但是代码上的不合理依然可以导致 FOUC 出现。

在这个 SPA 盛行的时代,大部分情况下 FOUC 都不那么容易引起重视,但是有些时候,FOUC 带来的影响仍然是不可忽视的。 举个例子,想象一下,你在黑暗的环境下使用了一个黑暗的主题,打开了一个深色主题的页面,本来是很和谐的,却因为 FOUC 每次都会先在默认的白色背景下闪烁一下,非常影响体验。

原因分析

要了解 FOUC 的原因,首先我们要了解一下浏览器渲染的原理。这篇文章给出了非常详细的论述,这里就不再赘述,只讨论一下我们关注的重点。

浏览器的渲染流程

Parsing HTML
Render tree construction
Layout
Paint

值得注意的是,整个渲染过程是同步进行的。也就是说,浏览器一边解析HTML,一边构建渲染树,构建一部分,就会把当前已有的元素渲染出来。如果这个时候外部样式并没有加载完成,渲染出来的就是浏览器默认样式了。

脚本和样式的执行顺序

  • JavaScript 会阻塞解析(parser blocking)

    浏览器中的 JavaScript 是在一个线程中执行的,所有的 <script> 都是依次同步执行的。当浏览器解析到一个 <script> 并开始执行时,就会阻塞后面所有的 DOM 构建和渲染。

    一般来说,现代浏览器在阻塞渲染的时候,都会提前加载所需的静态资源,如 CSS 和 JavaScript 脚本,但是此时并不会执行。

  • CSS 会阻塞渲染(render blocking)

    当一个 CSS 尚未加载完成时,浏览器会继续解析和构建 DOM,但是并不会渲染,因为渲染需要的渲染树是由 DOM 和 CSSOM 共同构建而成的。因此,这个时候页面的渲染会被阻塞,直到 CSS 加载完成。

性能指标

  • 首次绘制(FP,First paint),表示浏览器渲染任何在视觉上不同于导航前屏幕内容之内容的时间点(这句话有点拗口,但是应该没有语法错误),即屏幕内容第一次发生变化的时间点。
  • 首次内容绘制(FCP,First contentful paint),表示浏览器开始渲染 DOM 内容的时间点。

一般来说,如果 FPFCP 同时发生,页面就不会出现闪烁。当然也有例外,如果 FCP 发生的时候,所需的样式依然没有加载完成,那么 FOUC 依然会出现,这种情况一般发生于,CSS 不是通过 <link> 标签加载的,而是使用 JavaScript 动态插入的。

FPFCP 发生的时机可以通过 Chrome 的 performance 来观察:

Performance tool

这里的 DCLDOMContentLoaded 事件,其他的节点这里就不详细展开了。

绘制的时机

前面已经说过,浏览器的解析和渲染是同步进行的,只要有合适的 DOM 和 CSSOM 构建成了渲染树,就会渲染出来,触发浏览器绘制。这个过程都是在一个线程中进行,为了优化性能,同步的操作会被合并,只有当所有的同步操作完成后,构建的渲染树才会被渲染。

  1. 一个简单的例子:

    对于一个简单 HTML 页面,当 CSS 加载完成,且所有的 DOM 都同步解析完成,才会触发第一次渲染。也就是说,FP 紧跟在 DCL 后发生。

    <html>
    <head>
      <link rel="stylesheet" href="style.css">
    </head>
    <body>
      <div>hello, world</div>
    </body>
    </html>

    fp 1

    当 JavaScript 加入之后,就变得不一样了。

  2. 当浏览器开始执行一个 <script> 时,DOM 的构建会停下来,因为我们的脚本很可能对当前的 DOM 进行查询和操作。所以这个时候,就会将已经构建好的渲染树先渲染出来。

    <html>
    <head>
      <link rel="stylesheet" href="style.css">
    </head>
    <body>
      <div>hello, world</div>
      <script src="app.js"></script></body>
    </html>

    fp 2

    值得一提的是,如果 DOM 树的内容为空,浏览器会直接跳过本次渲染。

    所以对于 SPA,更好的做法是在脚本中去动态创建顶层的容器,而不是写到 HTML 中。如果是在 HTML 先写一个 loading 动画提升体验就另说了。

  3. 如果 JavaScript 触发了强制 paint / reflow,就会产生更多的绘制,即使 <script> 之前的 DOM 树为空,也有可能使 FP 提前。

    举个例子:

    div.offsetHeight; // oops
    
    // oops * paragraphs.length
    for (let i = 0; i < paragraphs.length; i++) {
      paragraphs[i].style.width = box.offsetWidth + 'px';
    }
  4. 多个 <script> 标签放在 <body> 中,会多次触发 paint 。原因和上面说过的一样,每次执行一个 <script> 的时候,浏览器都会暂停 DOM 树的构建,先把当前的渲染树渲染出来。所以如果前面的 <script> 创建了 DOM 元素,后面的 <script> 执行前一定会先触发 paint,如果这时发生样式的变化,就会出现 FOUC。

    <html>
    <head>
      <link rel="stylesheet" href="style.css">
    </head>
    <body>
      <div>hello, world</div>
      <script src="a.js"></script>  <script src="b.js"></script>  <script src="c.js"></script></body>
    </html>

    fp 3

    可以看到,这里的三个 <script> 标签导致了额外的三次 reflow / paint。

    这个问题不容忽视,因为有时候费了很大劲做的优化,一次 Webpack 打包就可以让你前功尽弃。比如使用了 svg-sprite-loader 之后,把SVG图标资源打包到 vendor.js 中,会得到:

    <body>
      <script src="vendor.js"></script>
      <script src="app.js"></script>
    </body>

    vendor.js 执行的时候,svg-sprite-loader 会向 <body> 上插入一个大的 <svg>。再到 app.js 执行的时候,就会闪烁了。

解决方案

了解了浏览器绘制的时机,FOUC 的问题就可以迎刃而解了。这里主要针对 SPA 页面,毕竟对 SSR 的页面来说,FOUC 或许不是一个大问题。

  1. 将 JavaScript 资源尽量放到 <head> 中,只保留最后一个包含主逻辑的脚本在 <body> 中,因为它很可能要往 <body> 上挂载元素。这可以解决上面提到的 <script> 标签导致的多次渲染问题。
  2. 第一次渲染,不论是 Vue、React 还是 VanillaJS,一定要同步放到主逻辑中,确保发生在 DCL 之前。
  3. 避免对 DOM 进行不必要的读操作,因为他们会带来的额外的绘制。

最后得到一个这样的页面,我就成功了:

<html>
<head>
  <link rel="stylesheet" href="style.css">
  <script src="common.js"></script>
  <script src="vendor.js"></script>
</head>
<body>
  <script src="app.js"></script>
</body>
</html>

参考资料


© 2020