浏览器前端优化

3,315 阅读24分钟
原文链接: zcfy.cc

优化全都是与速度和满意度有关。

  • 从用户体验的角度,我们希望前端提供可以快速加载和执行的网页。

  • 而从开发者体验的角度,我们希望前端是快速、简单而规范的。

这不仅会给我们带来快乐的用户和快乐的开发者,而且由于 Google 偏向于优化,SEO 排名也会显著提高。

如果你已经花费了大量时间来改善你网站的 Google Pagespeed Insights分数,那么这将有助于揭示这一切实际上意味着什么,以及我们必须为优化前端所采取的大量策略。

背景

最近我的整个团队有机会花一些时间加快把我们提出的升级变为代码库,可能是用 React。这确实让我思考起了我们该如何创建前端。很快,我意识到浏览器将是我们的方法中的一个重要因素,同时也是我们知识中的大瓶颈。

方法

首先

我们不能控制浏览器或者改变它的行为方式,但是我们可以理解它的工作原理,这样就可以优化我们提供的负载。

幸运的是,浏览器行为的基础原理是相当稳定而且文档齐全的,并且在相当长一段时间内肯定不会发生显著变化。

所以这至少给了我们一个目标。

其次

另一方面,代码、技术栈、架构和模式是我们可以控制的东西。它们更灵活,变化的更快,并给我们这一边提供了更多选择。

因此

我决定从外向内着手,搞清楚我们代码的最终结果应该是什么样的,然后形成编写代码的意见。在这第一篇博文中,我们打算专注于对于浏览器来说我们需要了解的一切。

浏览器都做了什么

下面我们来建立一些知识。如下是我们希望浏览器要运行的一些简单 HTML:

<!DOCTYPE html>
<html>
  <head>
    <title>The "Click the button" page</title>
    <meta charset="UTF-8">
    <link rel="stylesheet" href="styles.css" />
  </head>

  <body>
    <h1>
      Click the button.
    </h1>

    <button type="button">Click me</button>

    <script>
      var button = document.querySelector("button");
      button.style.fontWeight = "bold";
      button.addEventListener("click", function () {
        alert("Well done.");
      });
    </script>
  </body>
</html>

浏览器如何渲染网页

当浏览器接收到 HTML 时,就会解析它,将其分解为浏览器所能理解的词汇,而这个词汇由于HTML5 DOM 规范定义,在所有浏览器中是保持一致的。然后浏览器通过一系列步骤来构造和渲染页面。如下是一个很高层次的概述:

  1. 使用 HTML 创建文档对象模型(DOM)。

  2. 使用 CSS 创建 CSS 对象模型(CSSOM)。

  3. 基于 DOM 和 CSSOM 执行脚本(Script)

  4. 合并 DOM 和 CSSOM 形成渲染树(Render Tree)。

  5. 使用渲染树布局(Layout)所有元素的大小和位置。

  6. 绘制(Paint)所有元素。

步骤一 — HTML

浏览器开始从上到下读取标记,并且通过将它分解成节点,来创建 DOM 。

HTML 加载优化策略

  • 样式在顶部,脚本在底部

虽然这个规则有例外和细微差别,不过总体思路是尽可能早地加载样式,尽可能晚地加载脚本。原因是脚本执行之前,需要 HTML 和 CSS 解析完成,因此,样式尽可能的往顶部放,这样在底部脚本开始编译和执行之前,样式有足够的时间完成计算。

下面我们进一步研究如何在优化的同时做细微调整。

  • 最小化和压缩

这适用于我们提交的所有内容,包括 HTML、CSS、JavaScript、图片和其它资源。

最小化是移除所有多余的字符,包括空格、注释、多余的分号等等。

压缩(比如 GZip)是将代码或者资源中重复的数据替换为一个指向原始实例的指针,大大压缩下载文件的大小,并且是在客户端解压文件。

双管齐下的话,可以潜在将负载降低 80% 到 90%。比如:光 bootstrap 就节省了 87% 的负载

  • 可访问性

虽然可访问性不会让页面的下载变得更快,但是会大大提高残障人士的满意度。要确保是给所有人提供的!给元素加上 aria 标签,给图片提供 alt 文本,以及所有其它好东西

使用像 WAVE 这样的工具确认在哪些地方可以改善可访问性。

步骤二 — CSS

当浏览器发现任何与节点相关的样式时(即外部样式表、内部样式表或行内样式),就立即停止渲染 DOM ,并用这些节点来创建 CSSOM。这就是人们称 CSS 阻塞渲染的原因。这里是不同类型样式的一些优缺点

//外部样式
<link rel="stylesheet" href="styles.css">

// 内部样式
<style>
  h1 {
    font-size: 18px;
  }
</style>

// 行内样式
<button style="background-color: blue;">Click me</button>

CSSOM 节点的创建与 DOM 节点的创建类似,随后,两者会被合并。这就是现在它们的样子:

CSSOM 的构建会阻塞页面的渲染,因此我们想在树中尽可能早地加载样式,让它们尽可能轻便,并且在有效的地方延迟加载它们。

CSS 加载优化策略

media 属性指定要加载样式必须满足的条件,比如:是最大还是最小分辨率?是面向屏幕阅读器吗?

桌面是很强大,但是移动设备不是,所以我们想给移动设备尽可能最轻的负载。我们可以假设先只提供移动端样式,然后对桌面端样式放一个媒体条件。虽然这样做不会阻止桌面端样式下载,不过会阻止它阻塞页面加载和使用宝贵的资源。

// 这个 css 在所有情况都会下载,并阻塞页面的渲染。
// media="all" 是默认值,并且与不声明任何 media 属性一样。
<link rel="stylesheet" href="mobile-styles.css" media="all">

// 在移动端,这个 css 会在后台下载,而且不会中断页面加载。
<link rel="stylesheet" href="desktop-styles.css" media="min-width: 590px">

// CSS 中只为打印视图计算的媒体查询
<style>
  @media print {
    img {
      display: none;
    }
  }
</style>
  • 延迟加载 CSS

如果我们有一些样式可以等到首屏有价值的内容渲染完成后,再加载和计算,比如出现在首屏以下的,或者页面变成响应式之后不那么重要的东西。我们可以把样式加载写在脚本中,用脚本等待页面加载完成,然后再插入样式。

<html>
  <head>
    <link rel="stylesheet" href="main.css">
  </head>

  <body>
    <div class="main">
      折叠内容之上的重要部分。
    </div>

    <div class="secondary">
      折叠内容之下。页面加载之后,向下滚动才会看到的东西。
    </div>

    <script>
      window.onload = function () {
        // 加载 secondary.css
      }
    </script>
  </body>
</html>

这里有一个如何实现这个的例子,还有另一个例子

  • 较小的特殊性

链在一起的元素越多,自然要传输的数据就越多,因而会增大 CSS 文件,这是一个明显的缺点。不过这样做还有一个客户端计算的损耗,即会把样式计算为较高的特殊性。

// 更具体的选择器 == 糟糕
.header .nav .menu .link a.navItem {
  font-size: 18px;
}

// 较不具体的选择器 == 好
a.navItem {
  font-size: 18px;
}
  • 只加载需要的样式

这听起来可能有点愚蠢或者装模作样,不过如果你已经从事前端工作多年的话,就会知道 CSS 的一个最大问题是删除东西的不可预测性。设计的时候它就是被下了不断增长这样的诅咒。

要尽可能多削减 CSS ,可以使用类似uncss)包这样的工具,或者如果想有一个网上的替代品,那就到处找找,还是有挺多选择的。

步骤三 — JavaScript

然后,浏览器会不断创建 DOM / CSSOM 节点,直到发现任何 JavaScript 节点,即外部或者行内的脚本。

// 外部脚本
<script src="app.js"></script>

// 内部脚本
<script>
  alert("Oh, hello");
</script>

由于脚本可能需要访问或操作之前的 HTML 或样式,我们必须等待它们构建完成。

因此浏览器必须停止解析节点,完成创建 CSSOM,执行脚本,然后再继续。这就是人们称 JavaScript 阻塞解析器的原因。

浏览器有种称为'预加载扫描器'的东西,它会扫描 DOM 的脚本,并开始预加载脚本,不过脚本只会在先前的 CSS 节点已经构建完成后,才会依次执行。

假如这就是我们的脚本:

var button = document.querySelector("button");
button.style.fontWeight = "bold";
button.addEventListener("click", function () {
  alert("Well done.");
});

那么这就是我们的 DOM 和 CSSOM 的效果:

JavaScript 加载优化策略

优化脚本是我们可以做的最重要的事情之一,同时也是大多数网站做得最糟糕的事情之一。

  • 异步加载脚本

通过在脚本上使用 async 属性,可以通知浏览器继续,用另一个低优先级的线程下载这个脚本,而不要阻塞其余页面的加载。一旦脚本下载完成,就会执行。

<script src="async-script.js" async></script>

这意味着这段脚本可以随时执行,这就导致了两个明显的问题。首先,它可以在页面加载后执行很长时间,所以如果依靠它为用户体验做一些事情,那么可能会给用户一个不是最佳的体验。其次,如果它刚好在页面完成加载之前执行,我们就没法预测它会访问正确的 DOM/CSSOM 元素,并且可能会中断执行。

async 适用于不影响 DOM 或 CSSOM 的脚本,而且尤其适用于与 HTML 和 CSS 代码无关,对用户体验无影响的外部脚本,比如分析或者跟踪脚本。不过如果你发现了任何好的使用案例,那就用它。

  • 延迟加载脚本

deferasync 非常相似,不会阻塞页面加载,但会等到 HTML 完成解析后再执行,并且会按出现的次序执行。

这对于会作用于渲染树上的脚本来说,确实是一个好的选择。不过对于加载折叠内容之上的页面,或者需要之前的脚本已经运行的脚本来说,并不重要。

<script src="defer-script.js" defer></script>

这里是使用 defer 策略的另一个好选择,或者也可以使用 addEventListener。如果想了解更多,请从这里开始阅读。

// 所有对象都在 DOM 中,并且所有图像、脚本、link 和子帧都完成了加载。
window.onload = function () {
};

// 在 DOM 准备好时调用,在图像和其它外部内容准备好之前
document.onload = function () {
};

// JQuery 的方式
$(document).ready(function () {
});

不幸的是 asyncdefer 对于行内脚本不起作用,因为只要有行内脚本,浏览器默认就会编译和执行它们。当脚本内嵌在 HTML 中时,就会立即运行,通过在外部资源上使用上述两个属性,我们只是把脚本抽取出来,或者延迟把脚本发布到 DOM/CSSOM。

  • 操作之前克隆节点

当且仅当对 DOM 执行多次修改时看到了不必要的行为时,就试试这招。

先克隆整个 DOM 节点,对克隆的节点做修改,然后用它来替换原始节点,这样可能效率更高。因为这样就避免了多次重画,降低了 CPU 和内存消耗。这样做还可以避免更改页面时的抖动和无样式内容的闪烁(Flash of Unstyled Content,FOUC)。

// 通过克隆,高效操作一个节点

var element = document.querySelector(".my-node");
var elementClone = element.cloneNode(true); // (true) 也克隆子节点, (false) 不会

elementClone.textContent = "I've been manipulated...";
elementClone.children[0].textContent = "...efficiently!";
elementClone.style.backgroundColor = "green";

element.parentNode.replaceChild(elementClone, element);

请注意,克隆的时候并没有克隆事件监听器。有时这实际上刚好是我们想要的。过去我们已经用过这种方法来重置不调用命名函数时的事件监听器,而且那时也没有 jQuery 的 .on().off() 方法可用。

  • Preload/Prefetch/Prerender/Preconnect

这些属性基本上也实现了它们所做的承诺,而且都棒极了。不过,这些属性都是相当新,还没被浏览器普遍支持,也就是说对我们大多数人来说它们实际上不是真正的竞争者。不过,如果你有空的话,可以看看这里这里

步骤四 — 渲染树

一旦所有节点已被读取,DOM 和 CSSOM 准备好合并,浏览器就会构建渲染树。如果我们把节点当成单词,把对象模型当成句子,那么渲染树便是整个页面。现在浏览器已经有了渲染页面所需的所有东西。

步骤五 — 布局

然后我们进入布局阶段,确定页面上所有元素的大小和位置。

步骤六 — 绘制

最终我们进入绘制阶段,真正地光栅化屏幕上的像素,为用户绘制页面。

整个过程通常会在几秒或者十分之一秒内发生。我们的任务是让它做得更快。

如果 JavaScript 事件改变了页面的某个部分,就会导致渲染树的重绘,并且迫使我们再次经历布局和绘制。现在浏览器足够智能,会仅进行部分重画。不过我们不能指望靠这就能高效或者高性能。

话虽如此,不过很显然 JavaScript 主要是在客户端基于事件,而且我们想让它来操作 DOM,所以它就得做到这一点。我们只是必须限制它的不良影响。

至此你已经足够认识到要感谢 Tali Garsiel 的演讲。这是 2012 年的演讲,但是信息依然是真实的。她在此主题上的综合论文可以在这里读到

如果你喜欢迄今为止所读过的内容,但仍然渴望知道更多的底层技术性的东西,那么所有知识的权威就是HTML5 规范

我们差不多搞定了,不过请和我多待段时间!现在我们来探讨为什么需要知道上面的所有知识。

浏览器如何发起网络请求

本节中,我们将理解如何才能高效地把渲染页面所需的数据传输给浏览器。

当浏览器请求一个 URL 时,服务器会响应一些 HTML。我们将从超级小的开始,慢慢增加复杂性。

假如这就是我们页面的 HTML:

<!DOCTYPE html>
<html>
  <head>
    <title>The "Click the button" page</title>
  </head>

  <body>
    <h1>
      Button under construction...
    </h1>
  </body>
</html>

我们需要学习一个新术语,关键渲染路径(Critical Rendering Path,CRP),就是浏览器渲染页面所需的步数。如下就是现在我们的 CRP 示意图看起来的样子:

浏览器发起一个 GET 请求,在我们页面(现在还没有 CSS 及 JavaScript)所需的 1kb HTML 响应回来之前,它一直是空闲的。接收到响应之后,它就能创建 DOM,并渲染页面。

关键路径长度

三个 CRP 指标的第一个是路径长度。我们想让这个指标尽可能低。

浏览器用一次往返来获取渲染页面所需的 HTML,而这就是它所需的一切。因此我们的关键路径长度是 1,很完美。

下面我们上一个档次,加点内部样式和内部 JavaScript。

<!DOCTYPE html>
<html>
  <head>
    <title>The "Click the button" page</title>
    <style>
      button {
        color: white;
        background-color: blue;
      }
    </style>
  </head>

  <body>
    <button type="button">Click me</button>

    <script>
      var button = document.querySelector("button");
      button.addEventListener("click", function () {
        alert("Well done.");
      });
    </script>
  </body>
</html>

如果我们检查一下 CRP 示意图,应该能看到有两点变化。

新增了两步,创建 CSSOM执行脚本。这是因为我们的 HTML 有内部样式和内部脚本需要计算。不过,由于没有发起外部请求,关键路径长度没变。

但是注意,渲染没那么快了。而且我们的 HTML 大小增加到了 2kb,所以某些地方还是受了影响。

关键字节数

那就是三个指标之二,关键字节数出现的地方。这个指标用来衡量渲染页面需要传送多少字节数。并非页面会下载的所有字节,而是只是实际渲染页面,并把它响应给用户所需的字节。

不用说,我们也想减少这个数。

如果你认为这就不错了,谁还需要外部资源啊,那就大错特错了。虽然这看起来很诱人,但是它在规模上是不可行的。在现实中,如果我的团队要通过内部或者行内方式给页面之一提供所需的一切,页面就会变得很大。而浏览器不是为了处理这样的负载而创建的。

看看这篇关于像 React 推荐的那样内联所有样式时,页面加载效果的有趣文章。DOM 变大了四倍,挂载花了两倍的时间,到可以响应多花了 50% 的时间。相当不能接受。

还要考虑一个事实,就是外部资源是可以被缓存的,因此在回访页面,或者访问用相同资源(比如 my-global.css)的其它页面时,浏览器就用发起网络请求,而是用其缓存的版本,从而为我们赢得更大的胜利。

所以下面我们更进一步,对样式和脚本使用外部资源。注意这里我们有一个外部 CSS 文件、一个外部 JavaScript 文件和一个外部 asyncJavaScript 文件。

<!DOCTYPE html>
<html>
  <head>
    <title>The "Click the button" page</title>
    <link rel="stylesheet" href="styles.css" media="all">
    <script type="text/javascript" src="analytics.js" async></script>  // async
  </head>

  <body>
    <button type="button">Click me</button>

    <script type="text/javascript" src="app.js"></script>
  </body>
</html>

如下是现在 CRP 示意图看起来的样子:

浏览器得到页面,创建 DOM,一发现任何外部资源,预加载扫描器就开始介入。继续,开始下载 HTML 中所找到的所有外部资源。CSS 和 JavaScript 有较高的优先级,其它资源次之。

它挑出我们的 styles.cssapp.js,开辟另一条关键路径去获取它们。不过不会挑出 analytics.js,因为我们给它加了 async属性。浏览器依然会用另一个低优先级的线程下载它,不过因为它不会阻塞页面渲染,所以也与关键路径无关。这正是 Google 自己的优化算法对网站进行排名的方式。

关键文件

最后,是我们最后一个 CRP 指标,关键文件,也就是浏览器渲染页面需要下载的文件总数。在例三中,HTML 文件本身、CSS 和 JavaScript 文件都是关键文件。async 的脚本不算。当然,文件越少越好。

回到关键路径长度

现在你可能会认为这肯定就是最长的关键路径吧?我的意思是说要渲染页面,我们只需要下载 HTML、CSS 和 JavaScript,而且只需要两个往返就可以搞定。

HTTP1 文件限制

不过,生活依然没那么简单。拜 HTTP1 协议所赐,我们的浏览器一次从一个域名并发下载的最大文件数是有限制的。范围从 2(很老的浏览器)到 10(Edge)或者 6(Chrome)。

你可以从这里查看用户浏览器请求你的域名时的最大并发文件数。

你可以并且应该通过把一些资源放在影子域名上,来绕开这个限制,从而最大限度地提高优化潜力。

警告:不要把关键的 CSS 放到根域名之外的其他地方,DNS 查找和延迟都会抵消这样做时所带来的任何可能的好处。

HTTP2

如果网站是 HTTP2,并且用户的浏览器也是兼容的,那么你就可以完全避开这个限制。不过,这种好事并不常见。

可以在这里测试你网站的 HTTP2。

TCP 往返限制

另一个敌人逼近了!

任何一次往返可传输的最大数据量是 14kb,对于包括所有 HTML、CSS 和脚本在内的所有网络请求都是如此。这来自于防止网络拥堵和丢包的一个 TCP 规范

如果一次请求中,我们的 HTML 或者任何累积的资源超过了 14kb,那么就需要多做一次往返来获取它们。所以,是的,这些大的资源确实会给 CRP 添加很多路径。

大招

现在将我们的大网页倾巢而出。

<!DOCTYPE html>
<html>
  <head>
    <title>The "Click the button" page</title>
    <link rel="stylesheet" href="styles.css">     // 14kb
    <link rel="stylesheet" href="main.css">       // 2kb
    <link rel="stylesheet" href="secondary.css">  // 2kb
    <link rel="stylesheet" href="framework.css">  // 2kb
    <script type="text/javascript" src="app.js"></script>  // 2kb
  </head>

  <body>
    <button type="button">Click me</button>

    <script type="text/javascript" src="modules.js"></script> // 2kb
    <script type="text/javascript" src="analytics.js"></script> // 2kb
    <script type="text/javascript" src="modernizr.js"></script>  // 2kb
  </body>
</html>

现在我知道一个按钮就有很多 CSS 和 JavaScript,但是它是一个很重要的按钮,它对我们来说意义重大。所以就不要评判,好吗?

整个页面被很好地最小化和压缩到 2kb,远低于 14kb 的限制,所以我们又回到正好一次 CRP 往返了,而浏览器开始忠实地用一个关键文件,即我们的 HTML,来创建 DOM。

CRP 指标:长度 1,文件数 1,字节数 2kb

浏览器发现了一个 CSS 文件,而预加载扫描器识别出所有外部资源(CSS 和 JavaScript),并发送一个请求开始下载它们。但是等一等,第一个 CSS 文件是 14kb,超出了一次往返的最大负载,所以它本身就是一个 CRP。

CRP 指标:长度 2,文件数 2,字节数 16kb

然后它继续下载资源。余下的资源低于 14kb,所以可以在一次往返中搞定。不过由于总共有 7 个资源,而且我们的网站还没启用 HTTP2,而且用的是 Chrome,所以这次往返只能下载 6 个文件。

CRP 指标:长度 3,文件数 8,字节数 28kb

现在我们终于可以下载完最终文件,并开始渲染 DOM了。

CRP 指标:长度 4,文件数 9,字节数 30kb

我们的 CRP 总共有 30kb 的关键资源,在 9 个关键文件和 4 个关键路径中。有了这个信息,以及一些关于连接中延迟的知识,我们实际上就可以开始对给定用户的页面性能进行真正准确的估计了。

浏览器网络优化策略

  • Pagespeed Insights

使用Insights 来确定性能问题。Chrome DevTools 中还有个 audit标签。

  • 善用 Chrome 开发者工具

DevTools 如此惊人。我们为它单独写一整本书,不过这里已经有不少资源可以帮助你。这里)有一篇开始解释网络资源的文章值得一读。

  • 在好的环境中开发,在糟糕的环境中测试

你当然想在你的 1tb SSD、32G 内存的 Macbook Pro 上开发,不过对于性能测试,应该转到 Chrome 中的 network 标签下,模拟低带宽、节流 CPU 连接,从而真正得到一些有用的信息。

  • 合并资源/文件

在上面的 CRP 示意图中,我省略了一些你不需要知道的东西。不过基本上,每接收到一个外部 CSS 和 JavaScript 文件后,浏览器都会构建 CSSOM,并执行脚本。所以,尽管你可以在一次往返中传递几个文件,它们每个也都会让浏览器浪费宝贵的时间和资源,所以最好还是将文件合并在一起,消除不必要的加载。

  • 在 head 部分为首屏设置内部样式

是让 CSS 和 JavaScript 内部化或者内联,以防止获取外部资源,还是相反,让资源变成外部资源,这样就可以缓存,从而让 DOM 保持轻量,二者并非非黑即白。

但是有一个很好的观点是对首屏关键内容设置内部样式,可以避免在首次有意义的渲染时获取资源。

  • 最小化/压缩图片

这很简单、基础,有很多选择可以这样做,选一个你最喜欢的即可。

  • 延迟加载图片直到页面加载后

用一些简单的原生 JavaScript,你就可以延迟加载出现在折叠部分之下或者对首次用户响应状态不重要的图片。这里有一些不错的策略。

  • 异步加载字体

字体加载的代价非常高,如果可以的话,你应该使用带回退的 web 字体,然后逐步渲染字体和图标。这看起来可能不咋样,不过另一个选择是如果字体还没有加载,页面加载时就完全没有文字,这被称为不可见文本的闪烁(Flash Of Invisible Text,FOIT)。

  • 是否真正需要 JavaScript/CSS?

你需要吗?请回答我!是否有原生 HTML 元素可以产生用脚本一样的行为?是否可以用行内样式或图标而不是内部/外部资源?比如,内联一个 SVG

  • CDN

内容分发网络(CDN)可用于为用户提供物理上更近和更低延迟的位置,从而降低加载时间。


现在你开心惨了,已经知道了足够多的东西,可以从这里走出去,自己探索有关这个主题的更多东西了。我推荐参加这个免费的 Udacity 课程,并且阅读Google 自己的 优化文档

如果你渴望更底层的知识,那么这本免费电子书《高性能浏览器网络》是个开始的好地方。

总结

关键渲染路径是最重要的,它让网站优化有规律可循。需要关注的 3 个指标是:

1 — 关键字节数

2 — 关键文件数

3 — 关键路径数

这里我所写的应该足以让你掌握基础知识,并帮助你解释 Google Pagespeed Insights对你的性能有什么看法。

最佳实践的应用将伴随着良好的 DOM 结构、网络优化和可用于减少 CRP 指标的各种策略的结合。让用户更高兴,让 Google 的搜索引擎更高兴。

在任何企业级网站中,这将是一项艰巨的任务,但是你必须迟早做到这一点,所以不要再承担更多的技术性债务,并开始投资于坚实的性能优化策略。

感谢你阅读至此,如果你真的做到了。衷心希望能帮到你,有任何反馈或者纠正,请给我发消息。

在本博客的下一部分中,我希望给出一个真实世界的例子,说明如何在我自己的团队的大量代码库中实现所有这些原则。我们在一个域上拥有超过 2000 个可管理内容的页面,并且每天为数十万个页面浏览量提供服务,因此这将很有趣。

不过这可能还需要段时间,我现在需要休息一下。