前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版

关键路径渲染优化 preload 与资源预加载全解析

首页2020-01-28 11:24:08Front-End
性能优化关键渲染路径preload

有个现象你大概率也遇过:Network 面板里所有请求加起来才两百多 KB,接口也快,可白屏就是要一秒多。打开 Performance 一看,主线程前面一大段是空的,浏览器在等一个谁都没在意的 CSS 文件。这类问题不是「资源太大」造成的,是关键渲染路径上的顺序出了问题。这篇把浏览器从拿到 HTML 到把像素画上屏幕的五步拆开,再逐个讲关键 CSS、preload、prefetch、dns-prefetch、preconnect、prerender 分别卡在哪一步、解决什么、有什么坑。

在本篇文章中,我们将从浅入深,和大家一起学习以下知识:

  • 浏览器从 HTML 到首屏像素的五个步骤,以及谁会阻塞谁
  • 优化关键渲染路径要盯住的三个变量,资源数、路径长度、字节数
  • 关键 CSS 怎么拆,为什么内联首屏样式有效
  • preload 和 prefetch 的根本区别,以及 preload 和 defer 的差异
  • 用 preload 加速样式表、脚本、字体,字体那个 crossorigin 坑要重点看
  • dns-prefetch、preconnect、prerender 各自省掉了链路上的哪一段

# 一、浏览器从 HTML 到像素的五步

浏览器从获取 HTML 到最终在屏幕上显示内容,需要完成这几步:

  • 处理 HTML 标记并构建 DOM 树
  • 处理 CSS 标记并构建 CSSOM 树
  • 将 DOM 与 CSSOM 合并成一个 render tree
  • 根据渲染树来布局,计算每个节点的几何信息
  • 将各个节点绘制到屏幕上

走完这一整套,我们才能看见屏幕上出现渲染的内容。优化关键渲染路径,指的就是最大限度缩短执行上述第 1 步到第 5 步耗费的总时间,让用户最快看到首次渲染的内容。

这五步里最要命的是它们之间的阻塞关系。CSSOM 的构建会阻塞 HTML 的渲染,也会阻塞 JS 的执行;而 JS 的下载与执行(不管是内联的还是外部文件)会阻塞 HTML 的解析,解析停了后面的一切自然也就停了。

所以性能问题往往不是「东西太多」,而是「东西挡在路上」。

顺着这个思路想,一个 200 KB 的 JS 放在 </body> 前面和放在 <head> 里,对首屏的影响是天差地别的。前者只是多花点带宽,后者是直接把解析线程摁住。

# 二、要盯住的三个变量

为了尽快完成首次渲染,我们需要最大限度减小三种可变因素:

  • 关键资源的数量:可能阻止网页首次渲染的资源
  • 关键路径长度:获取所有关键资源所需的往返次数或总时间
  • 关键字节的数量:实现网页首次渲染所需的总字节数,是所有关键资源传送文件大小的总和

拿最简单的例子理解一下。一个只包含单个 HTML 页面的站点,关键资源就一项(HTML 文档本身),关键路径长度等于一次网络往返(假设文件较小),总关键字节数正好是 HTML 文档的传送大小。每往里加一个阻塞渲染的 CSS 或 JS,这三个数字就一起涨。

优化关键渲染路径的常规步骤也是围绕这三个数字来的:

  • 先对关键路径做分析和特性描述,把资源数、字节数、长度量出来
  • 最大限度减少关键资源的数量,删掉它们、延迟它们的下载、或者标记为异步
  • 优化关键字节数以缩短下载时间,也就是减少往返次数
  • 优化其余关键资源的加载顺序,尽早下载所有关键资产,缩短关键路径长度

顺序很重要,先量再改。没量过就动手,很容易花一天时间优化了一个根本不在关键路径上的资源。

# 三、关键 CSS

样式表会阻塞渲染,在加载完毕之前页面是不会显示的。既然是这样,那就把「用户第一眼要看到的那部分样式」和「剩下的样式」拆开。

抽出来的这部分样式单独放在一个样式表里,或者干脆内联在页面中,它就叫关键样式。内容上它可以是页面的骨架屏,也可以是用户刚加载进页面时看到的首屏内容。

<!doctype html>
<head>
  <style> /* inlined critical CSS */ </style>
  <script> loadCSS('non-critical.css'); </script>
</head>
<body>
  ...body goes here
</body>
</html>
@前端进阶之旅: 代码已经复制到剪贴板

上面这段的思路是,首屏样式直接内联进 <style>,省掉一次网络往返,浏览器解析到这里就能立刻构建 CSSOM;剩下的样式交给 loadCSS 异步去拿,拿到了再应用。

内联的量要控制。关键 CSS 是塞进 HTML 里的,它会让 HTML 文档变大,而 HTML 本身也在关键路径上。一般控制在十几 KB 以内比较合适,超过这个量收益就开始被文档体积吃掉了。

# 四、preload 和 prefetch 分别在解决什么

这两个名字长得像,做的事完全相反。

preload 会提升资源的优先级,因为它标明这个资源是本页肯定会用到的,属于本页优先。prefetch 会降低这个资源的优先级,因为它标明这个资源是下一页可能用到的,属于为下一页提前加载。

preload 最大的作用,是把下载与执行分离,并且把下载的优先级提到很高的位置,然后由我们自己去控制资源执行的位置。

记住这一句就够了:preload 管本页,prefetch 管下一页。

用 preload 有个必填项容易漏,就是 as。不写 as 的话浏览器不知道这是脚本还是样式还是字体,既定不了优先级,也可能重复下载一次。控制台通常会给你一条警告,别忽略它。

# 五、用 preload 加速样式表

样式表是阻塞页面呈现的,注意是呈现,不是解析。正常通过 link 加载的外部样式表,要等下载完、构建完 CSSOM,页面才会呈现。而 preload 能让样式表的下载和呈现分离。

试想你在页面的 head 中写了这两个样式表:

<link href="critical.css" rel="stylesheet" />
<link href="non-critical.css" rel="stylesheet" />
@前端进阶之旅: 代码已经复制到剪贴板

第一个是关键 CSS,第二个不是。页面解析了这两个 link 标签之后同时开始下载,但即使 critical.css 下载解析完毕,页面也不会呈现,因为还要等 non-critical.css。

那把第二个改成 preload 呢?原文给的写法是这样:

<link href="critical.css" rel="stylesheet" />
<link rel="preload" href="non-critical.css" as="style" />
<link href="non-critical.css" rel="stylesheet" />
@前端进阶之旅: 代码已经复制到剪贴板

这里要说清楚一件事,很多人(包括我自己当时)会以为加了 preload 那一行,后面那个 rel="stylesheet" 就不阻塞了。实际上不是。preload 只负责把资源以高优先级提前拉进缓存,紧跟着的 <link rel="stylesheet"> 该阻塞还是阻塞,它只是因为命中了缓存所以等待时间变短了。

要真正做到非阻塞,得让这个 link 在加载期间不是 stylesheet,加载完了再变成 stylesheet:

<link rel="preload" href="non-critical.css" as="style" onload="this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="non-critical.css"></noscript>
@前端进阶之旅: 代码已经复制到剪贴板

还有一种等效写法是用 media 骗过浏览器,media="print" 时它不参与屏幕渲染所以不阻塞,加载完再把 media 改回 all:

<link rel="stylesheet" href="non-critical.css" media="print" onload="this.media='all'">
@前端进阶之旅: 代码已经复制到剪贴板

这样一来页面在解析完 critical.css 之后就会呈现(暂不考虑脚本),non-critical.css 也在同时下载,但不阻塞,直到它下载和解析完毕才会应用到页面上。

浏览器对 preload 的支持不是全都有,兼容性兜底可以用 loadCSS 这个库做 polyfill。它的实现思路其实跟上面第二种写法是一回事:遍历所有带 preload 和 as 的标签,把标签的 media 改成不匹配任何条件并开始下载,下载完毕后再还原这个 link 原来的 media 把它应用上。

# 六、用 preload 加速脚本,以及它和 defer 的差别

preload 把脚本的加载及执行分离了。给 <link> 加上 preload 的作用是把脚本提到高优先级尽快完成下载,但并不执行:

<link rel="preload" href="//cdn.staticfile.org/jquery/3.2.1/jquery.min.js" as="script" />
@前端进阶之旅: 代码已经复制到剪贴板

还需要在你想让它执行的地方,引入一个正常的 <script> 标签来执行这个脚本:

<script src="//cdn.staticfile.org/jquery/3.2.1/jquery.min.js"></script>
@前端进阶之旅: 代码已经复制到剪贴板

只 preload 不执行会怎样?Chrome 大约会在三秒后报一个 warning 提醒你这个资源被浪费了,完全没有被使用到。这个警告挺有用的,它能帮你发现那些「加了 preload 但其实页面根本没用到」的僵尸配置。

preload 听起来很像被 defer 的脚本,但两者有几处不同:

对比项 defer preload
执行时机 无法控制,在 DOMContentLoaded 触发前执行 由你插入 <script> 的位置决定
是否阻塞 DOMContentLoaded 会阻塞 不参与,取决于配套的 script
是否阻塞 onload 会阻塞 原文结论是不阻塞
下载优先级 low high

关于 onload 那一行我要说句实话,这条我没有在最新版本的 Chrome 上重新量过。preload 的资源是否计入 load 事件,各浏览器的实现历史上有过调整,如果你的监控指标恰好卡在 load 上,建议自己在目标浏览器里跑一遍再下结论,别直接信表格。

# 七、用 preload 加速字体,以及 crossorigin 那个坑

自定义字体在加载完成之前会有 FOIT(Flash of Invisible Text)现象,也就是文字先隐形一段时间。我们可以用 webFont 一类的库来控制字体的闪现、添加钩子函数,但最根本的解法还是让字体加载得尽可能快。

用 preload 就能做到。在 head 里直接声明字体的 preload,比等浏览器先下载样式表、再从里面读到 @font-face 的 src、再去发起字体请求要快得多,直接省掉一层串行依赖:

<link rel="preload" as="font" href="https://at.alicdn.com/t/font_zck90zmlh7hf47vi.woff">
@前端进阶之旅: 代码已经复制到剪贴板

写成上面这样,你会发现字体被下载了两次。

原因在于跨域模式对不上。preload 不带 crossorigin 时,默认情况下 CORS 根本不会启用,HTTP 的 request header 里就不会有 Origin,走的是非跨域请求;而 @font-face 加载字体默认就是跨域请求(匿名模式)。两次请求的 request header 不一样,缓存命中不了,于是重复请求一次。

这条规则容易被忽略的地方是:即使字体文件跟页面在同一个域名下,也要加 crossorigin。很多人以为同域就不用管,结果照样下两次。

解决办法就是带上 crossorigin:

fe
  • 一、浏览器从 HTML 到像素的五步
  • 二、要盯住的三个变量
  • 三、关键 CSS
  • 四、preload 和 prefetch 分别在解决什么
  • 五、用 preload 加速样式表
  • 六、用 preload 加速脚本,以及它和 defer 的差别
  • 七、用 preload 加速字体,以及 crossorigin 那个坑
  • 八、preload 还能拿来做什么
  • 九、dns-prefetch,先把域名解析掉
  • 十、preconnect,把 DNS 加 TCP 加 TLS 一起提前
  • 十一、prerender,提前渲染整个下一页
  • 总结
  • 参考

← 前端调试实战指南 从 Chrome DevTools 到真机与 NodeJenkins 自动部署前端项目 从装机到 git 提交自动构建 →