面试官:在项目中你是如何做前端优化?

目录
文章目录隐藏
  1. 前端优化方案
  2. 确认哪些页面需要优化
  3. 针对性优化

说到前端优化,这是一个很大的范围。可能平时更多的文章是基于某一点去深入的讲解。所以趁机整理一套前端优化的基础大纲,一方面给让自己沉淀下这些知识,另一方面也方便后续优化时有个清单可以去对照着优化。

前端优化方案

先来一道面试必考题:「聊一聊你怎么做前端优化?」 那么一个相对完善的前端优化方案应该是怎么样的呢?

在过去的经验中,我认为:首先需要知道哪些页面需要优化,需要怎么样的优化,然后才能针对性的优化。所以我会将前端优化分为以下几个步骤:

  • 确认哪些页面需要优化
    • 建立监控体系
    • 确定监控指标
    • 根据监控信息分析页面
  • 针对性优化
    • 资源优化
    • 构建优化
    • 传输优化
    • 网络优化

确认哪些页面需要优化

建立监控体系

第一步我们需要建立一套前端的性能监控体系。前端性能监控目前主要有两种方案:

  1. 第三方提供的成熟服务:例如 阿里云 ARMS、听云、监控宝等
  2. 自主搭建

这里就不赘述各类第三方平台提供的服务了,主要讲明下自主搭建前端性能监控平台大概的实现思路。

确定采集指标

在前端监控方面,一般我们关注两个方向,性能和稳定。所以我们需要采集的指标大概有一下 4 个指标。

  • RUM (Real User Monitoring) 指标:包括 FP, TTI, FCP, FMP, FID, MPFID。
  • Navigation Timing:包括 DNS, TCP, DOM 解析等阶段的指标。
  • JS Error:解析后可以细分为运行时异常、以及静态资源异常。
  • 请求异常:采集 ajax 请求异常。

那么接下来我们来逐个分析各个指标如何采集。

RUM 指标

核心 Web 指标是指适用于所有网页的 Web 指标子集,每位网站开发者都应该去测量这些指标,并且这些指标还将显示在所有 Google 工具中。每项核心 Web 指标代表用户体验的一个不同方面,能够进行实际测量,并且反映出以用户为中心的关键结果的真实体验。

核心 Web 指标的构成指标会随着时间的推移而发展 。当前的指标构成侧重于用户体验的三个方面:加载性能交互性视觉稳定性。

主要包含包括以下指标(及各指标相应的阈值):

RUM 指标

LCP 、FID、CLS

最大内容绘制 (LCP):即 Largest Contentful Paint,测量加载性能。为了提供良好的用户体验,LCP 应在页面首次开始加载后的2.5 秒内发生。

首次交互时间(FID):即 First Input Delay,记录页面加载阶段,用户首次交互操作的延时时间。FID 指标影响用户对页面交互性和响应性的第一印象。

累积布局偏移 (CLS):即 Cumulative Layout Shift,测量视觉稳定性。为了提供良好的用户体验,页面的 CLS 应保持在 0.1 或更少。

对于LCPFIDCLS我们可以直接使用web-vitals来进行采集,采集代码如下:

import { getLCP, getFID, getCLS } from 'web-vitals';

getLCP((data) => console.log('LCP', data))
getFID((data) => console.log('FID', data))
getCLS((data) => console.log('CLS', data))

直接使用 web-vitals 来进行采集
除开上述的 3 个指标,我们还可以通过以下指标进行监控:

FP、FCP

首次绘制时间( FP ) :即 First Paint,为首次渲染的时间点。

首次内容绘制时间( FCP ) :即 First Contentful Paint,为首次有内容渲染的时间点。

这两个指标看起来大同小异,但是 FP 发生的时间一定早于等于 FCPFP 指的是绘制像素,比如说页面的背景色是灰色的,那么在显示灰色背景时就记录下了 FP 指标。但是此时 DOM 内容还没开始绘制,可能需要文件下载、解析等过程,只有当 DOM 内容发生变化才会触发,比如说渲染出了一段文字,此时就会记录下 FCP 指标。因此说我们可以把这两个指标认为是和白屏时间相关的指标,所以肯定是最快越好。

FP、FCP

根据官方推荐的时间,我们应该把 FP 和 FCP 压缩到2 秒内。

采集代码如下:

window.performance.getEntriesByType('paint')

把 FP 和 FCP 压缩到 2 秒内

TTI

完全可交互时间(TTI):即 Time to interactive,记录从页面加载开始,到页面处于完全可交互状态所花费的时间。

获取 TTI 的规则如下:

  • 从首次内容绘制(FCP)开始
  • 向前搜索 5 秒,没有长任务,且不能超过 2 个 get 请求
  • 向后搜索最后一个长任务(耗时超过 50 毫秒的任务),如果没有长任务被发现就到 FCP 停止
  • TTI 的时间是最后一个长任务的时间,如果没有长任务的话,则等于 FCP 时间

Google 希望将 TTI 指标标准化,并通过 PerformanceObserver 在浏览器中公开,但目前并不支持。

目前只能通过一个 polyfill,检测目前的 TTI,适用于所有支持 Long Tasks API 的浏览器。

完全可交互时间(TTI)

根据官方推荐的时间,我们应该把 TTI 控制在到3.8 秒

采集代码如下:

import ttiPolyfill from 'tti-polyfill'

// collect the longtask
if (PerformanceLongTaskTiming) {
  window.__tti = { e: [] };
  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      // observe the longtask to get the time to interactive (TTI)
      if (entry.entryType === 'longtask') {
        window.__tti.e.concat(entry);
      }
    }
  });
  observer.observe({ entryTypes: ['longtask'] });
}

ttiPolyfill.getFirstConsistentlyInteractive().then((tti) => {
  console.log('TTI', tti)
});

Navigation Timing

这里我们关注的指标主要有

  • DNS 查询耗时
  • TCP 链接耗时
  • request 耗时
  • 解析 DOM 树耗时
  • 白屏时间
  • domready 时间
  • onload 时间

获取的方式很简单,我们可以直接通过PerformanceTiming来得到他们

window.addEventListener('load', () => {
  setTimeout(() => {
    const timing = window.performance.timing;
    console.log('DNS 查询耗时:', timing.domainLookupEnd - timing.domainLookupStart)
    console.log('TCP 链接耗时:', timing.connectEnd - timing.connectStart)
    console.log('request 耗时:', timing.responseEnd - timing.responseStart)
    console.log('解析 DOM 树耗时:', timing.domComplete - timing.domInteractive)
    console.log('白屏时间:', timing.domLoading - timing.fetchStart)
    console.log('domready 时间:', timing.domContentLoadedEventEnd - timing.fetchStart)
    console.log('onload 时间:', timing.loadEventEnd - timing.fetchStart)
  }, 0);
})

JS Error

在采集JS Error这块,我们可以采用errorunhandledrejection这两个事件来捕获 JS 错误及Promise未处理的rejection异常。

下面附上简易代码:

window.onerror = (errorMsg, url, lineNumber, columnNumber, errorObj) => {
  let errorStack = errorObj ? errorObj.stack : null;
  // 这里进行上报
  console.log(errorMsg, url, lineNumber, columnNumber, errorStack)
};

window.onunhandledrejection = (e) => {
  let errorMsg = "", errorStack = "";
  if (typeof e.reason === "object") {
    errorMsg = e.reason.message;
    errorStack = e.reason.stack;
  } else {
    errorMsg = e.reason;
  }
  // 这里进行上报
  console.log(errorMsg, errorStack)
}

请求异常

捕获请求异常,我们可以通过重写window.fetchXMLHttpRequest来实现。

根据监控信息分析页面

上面只是抛砖引玉的讲了指标的采集方法。在实际操作中,一套前端监控体系的搭建是一个很庞大的工程,不仅仅在数据采集这块,很多时候我们还需要数据分类,数据清洗,数据展示等等各个方面。举个例子:在采集信息时的因为检测数据可能会很大,那么我们需要就做本地化存储,方便进行数据压缩合并,然后上报可能采用消息队列防止上报丢失,利用 Navigator.sendBeacon 委托浏览器在页面关闭后上报数据等等。

当我们拥有一套完整的监控体系,那么接下来可以基于上面给出的监控指标来对页面进行针对性优化。哪个页面需要优化并没有一个标准的答案,这一切都需要根据自身业务及投入产出比来看。

这里也给出一个优化目标的建议 目标:比你最快的竞争对手至少快 20%

根据用户心理学研究,如果想让用户感觉你的 web 网站比其它竞品快,那你必须比它们快 20% 以上。研究你的主要竞争对手,收集它们在移动设备和台式机上的性能指标,并设置阈值来帮助你超越竞争对手。但是,要获取准确的性能指标并制定目标,务必先通过研究分析来全面了解用户体验。然后,你可以根据 90% 的主要用户的反馈和经验来模拟测试。

针对性优化

资源优化

使用 Brotli 进行纯文本压缩

2015 年,Google 推出了Brotli,这是一种全新的开源无损数据格式,并被现在所有现代浏览器支持

使用 Brotli 进行纯文本压缩

Brotli有比GzipDeflate更高的压缩率,但是同时也需要更长的压缩时间,所以在请求的时候实时进行压缩并不是一个很好的办法。但我们可以预先对静态文件进行压缩,然后直接提供给客户端,这样我们就避免了 Brotli 压缩效率低的问题,同时使用这个方式,我们可以使用压缩质量最高的等级去压缩文件,最大程度的去减小文件的大小。

另外,由于不是所有浏览器都支持 Brotli 算法,所以在服务端我们需要同时提供两种文件,一个是经过 Brotli 压缩的文件,一个是原始文件,在浏览器不支持 Brotli 的情况下,我们可以使用 gzip 去压缩原始文件提供给客户端。

Brotli可用于任何纯文本的内容如 HTMLCSSSVGJavaScript 等。

使用最高压缩比配置的Brotli + Gzip 预压缩静态资源,并使用 Brotli 配置 3~5 级压缩比来快速压缩 HTML。确保服务器正确处理 Brotligzip 的内容协商头。

资源优化

使用响应式图像和 WebP

WebP图片是一种新的图像格式,由 Google 开发。与 pngjpg 相比,相同的视觉体验下,WebP 图像的尺寸缩小了大约30%。另外,WebP图像格式还支持有损压缩、无损压缩、透明和动画。理论上完全可以替代pngjpggif等图片格式,不过目前WebP的还没有得到全面的支持,但是我们还是能够通过兜底方案来使用它。

所以在使用图片时尽可能使用具有 srcsetsizes<picture> 元素的响应式图像。在使用它的同时,还可以通过 <picture> 元素和 JPEG 兜底来使用WebP 格式。

使用响应式图像和 WebP

但是WebP并非毫无缺点,它不支持像 JPEG 那样的渐进式渲染,这就是为什么用户使用 JPEG 可能会更快地看到实际图像!尽管 WebP 图像的网络加载速度可能会更快。使用 JPEG 我们可以用一半甚至四分之一的时间就提供给用户一个比较「像样」的体验,并在后续加载其余数据,而不是像 WebP 那样只有半空的图像。所以这就看我们想要的是什么:使用 WebP,将减少图像大小;使用 JPEG,将提高图像的可感知性。

图像是否经过适当优化?

在我们的项目中,快速加载图像非常重要,所以在使用到图片的时候需要注意:

  • 确保JPEG是渐进式渲染的,并使用mozJPEGGuetzli压缩。
  • 使用Pingo压缩png
  • 使用SVGOSVGOMGSVG进行压缩
  • 对图片或者iframe进行懒加载
  • 如果可能建议使用循环播放的video或者WebP代替 gif

使用 ffmpeg 将 gif 转成 MP4 后的性能优化

使用 ffmpeggif 转成MP4 后的性能优化。

网络字体是否经过优化?

很多时候,我们使用的字体包并不需要适配所有的文字,尤其是中文字体动不动 10M+ 大小的情况下。所以

  • 如果条件允许,我们可以对字体进行子集化(可以采用subfont来帮助我们对项目进行分析);
  • 使用preload来预加载字体;
  • 如果有必要的话,将字体缓存在Service Worker中。

构建优化

你正在使用 tree-shaking、scope hoisting 和 code-splitting 吗?

  • tree-shaking 是一种清理构建包中无用依赖的方法,它让构建结果只包含生产中实际使用的代码,并消除 Webpack 中未使用的引入。借助 WebpackRollup,我们还可以实现 scope hoisting ,这两个工具都可以检测到 import 链可以在哪个位置终止并转换为一个内联函数,而不破坏代码。借助 Webpack,我们还可以使用 JSON Tree Shaking
  • code-splitingWebpack 的另一个功能,可以把你的代码拆分为按需加载的chunk。并不是所有 JavaScript 都必须立即下载、解析和编译。一旦在代码中定义了分割点,Webpack 就可以处理依赖关系和输出文件。它可以让浏览器保持较小的初始下载量,并在应用程序请求时按需请求代码。
  • 考虑使用 preload-webpack-plugin,这个插件可以根据你代码的分隔方式,让浏览器使用 <link rel="preload"><link rel="prefetch"> 对分隔的代码chunk进行预加载。Webpack 内联指令还可以对 preload/prefetch 进行一些控制(但是请注意优先级问题。)

能否将 JavaScript 抽离到 Web Worker?

为了缩短可交互时间的耗时,最好将有繁重计算的 JavaScript 抽离到 Web Worker 中或通过 Service Worker 进行缓存。因为 DOM 操作是与 JavaScript 一起运行在主线程上。使用 Web worker 可以将这些昂贵的操作转移到后台其他线程上运行。可以通过 Web Worker 预先加载和存储一些数据,以便后续在需要时使用它。可以使用 Comlink 来简化与 Web Worker 之间的通信。

能否将频繁执行的功能抽离到 WebAssembly?

我们可以将繁重的计算任务抽离WebAssemblyWASM)执行,它是一种二进制指令格式,被设计为一种用高级语言(如 C / C ++ / Rust)编译的可移植的对象。而且大多数现代浏览器都已经支持了 WebAssembly,并且随着 JavaScript 和 WASM 之间的函数调用变得越来越快,这个方式会变得越来越可行。WebAssembly 的目的并不是替代 JavaScript,而是可以在你发现当 CPU 占用过高时作为 JavaScript 的补充JavaScript 更适合大多数 Web 应用程序,而 WebAssembly 最适合用于计算密集型 Web 应用程序,例如 Web 游戏。

下面附上 JavaScriptWebAssembly 的处理过程对比:

JavaScript 及 WebAssembly 的处理过程对比 JavaScript 及 WebAssembly 的处理过程对比

你有在 JavaScript 中使用 module/nomodule 模式吗?

我们只想通过网络发送必要的 JavaScript,但这意味着对这些资源的交付要更加专注和细致。module/nomodule 的思想是编译并提供两个单独的 JavaScript 包:“常规”构建的构建方式是,一个包含 Babel 转换和 polyfills,仅提供给实际需要它们的旧版浏览器,另一个包(相同功能)不包含 Babel 转换和 polyfills

JS module(或者称作ES moduleECMAScript module)是一个主要的新特性,或者说是一系列新特性。你可能已经使用过第三方的模块加载系统。CommonJsnodeJsAMDrequireJs 等等。这些模块加载系统都有一个共同点:它们允许你执行导入导出操作。

能够认识type=module语法的浏览器会忽略具有nomodule属性的scripts。也就是说,我们可以使用一些脚本服务于支持module语法的浏览器,同时提供一个nomodule的脚本用于哪些不支持module语法的浏览器,作为补救。

支持 module 语法的浏览器

使用 type=module 构建的文件体积优化相比常规构建的文件减少 30% ~ 50%,而且还能期待下浏览器对新语法的性能优化。

识别并删除未使用的 CSS / JS

Chrome 中的 CSS 和 JavaScript 代码覆盖率工具(Coverage) 可以让我们了解哪些代码已执行或应用,哪些未执行。我们可以启动一个覆盖率检查,然后查看覆盖率结果。一旦检测到未使用的代码,找出那些模块并使用 import() 延迟加载。然后重复代码覆盖率检查确认现在在初始化时加载代码有变少。

你可以使用 Puppeteer 来收集代码覆盖率,Puppeteer 还有许多其他用法,例如在每次构建时监视未使用的 CSS

此外,purgecssUnCSSHelium 可以帮助你从 CSS 中删除未使用的样式。

识别并删除未使用的 CSS / JS

修剪 JavaScript 包大小

将依赖包审核添加到日常的工作流程中。使用更小巧轻便的库替换你可能在几年前添加的一些大型的库,例如使用 Day.js 替换 Moment.js

使用 Bundlephobia 之类的工具可以帮助你了解添加一个 npm 包的代价。size-limit 不仅会包检查大小,还会展示 JavaScript 的执行时长。

修剪 JavaScript 包大小

使用针对目标 JavaScript 引擎的优化。

看下哪些 JavaScript 引擎在你的用户群中占主导地位,然后探索对其进行优化的方法。例如,当针对 Blink 浏览器、Node.js 运行时和 Electron 中使用的 V8 进行优化时,可以使用脚本流来处理整体脚本。

脚本流优化了 JavaScript 文件的解析。以前版本的 Chrome 会用一种简单的方法,在开始解析脚本之前完整的下载脚本,但在下载完成前并没有充分利用 CPU。从 41 版本开始,Chrome 会在下载开始后立即在单独的线程上解析异步和延迟脚本。这意味着解析可以在下载完成后的几毫秒内完成,并使页面加载速度提高最高 10%。这对于大型脚本和慢速网络连接特别有效。

下载开始后,脚本流允许 asyncdefer scripts 在单独的后台线程上进行解析,因此在某些情况下,页面加载时间最多可缩短 10%。而且,在 header 中使用 script defer,可以使浏览器更早的发现资源,然后在后台线程解析它。

警告Opera Mini 不支持脚本延迟,因此,如果你的主要用户是使用 Opera Minidefer 则将被忽略,从而导致渲染被阻塞,直到脚本执行完毕。

客户端渲染还是服务器端渲染?

使用客户端渲染还是服务端渲染?这都得由应用程序的性能来决定。最好的方法是设置某种渐进式引导:使用服务端渲染来快速获得第一个有意义的图形(FCP),同时包括一些最小体积的必需的 JavaScript,尽量让可交互时间(TTI)紧挨着第一个有意义的图形的绘制。如果 JavaScript 执行在 FCP 之后太晚,浏览器会在解析、编译和执行后来执行的 JavaScript锁定主线程,从而削弱了网站或应用程序的交互性

为了避免这种情况,我们务必将函数的执行分解为单独的异步任务,并尽可能使用 requestIdleCallback。使用 WebPack 的动态import()支持,延迟加载部分 UI,避免在用户真正需要它们之前因为加载、解析和编译造成的成本消耗。

进入可交互状态后,我们可以按需或在时间允许的情况下启动应用程序的非必需部分。不过框架通常没有面向开发者提供简单的优先级概念,因此,对于大多数库和框架而言,实现逐步启动并不容易。

下面我们来分析下目前的几种渲染机制:

(1)完全客户端渲染 (CSR)

所有的逻辑、渲染、启动均在客户端上完成。结果通常是 TTIFCP 之间的间隔加大。由于整个应用程序必须在客户端上启动才能呈现任何内容,因此应用程序感觉很比较呆滞。通常来说SSR 比 CSR 快。但是对于许多应用程序来说,CSR 是最常见的实现方式。附上传统 CSR 的链路图:传统 CSR 的链路图

(2)完全服务器端渲染(SSR)

服务器呈现响应于导航,为服务器上的页面生成完整的 HTML。这样可以避免在客户端进行数据获取和模板化的其他往返过程,因为它是在浏览器获得响应之前进行处理的。

FPFCP 的差距通常很小,在服务器上运行页面逻辑和呈现可以避免向客户端发送大量 JavaScript,这有助于实现快速的可交互时间(TTI)。而且可以将 HTML 以流式传输到浏览器并立即呈现页面。不过,我们需要花费更长的时间去做解析,导致第一个字节到达(TTFB)浏览器的时间加长,并且我们没有利用现代应用程序的响应式功能和其他丰富的功能。

(3)静态站点生成(SSG)

静态网站生成类似于服务器端渲染,不过是在构建时而不是在请求时渲染页面。与服务器渲染不同,由于不必动态生成页面的 HTML,因此它还可以保持始终如一的快速到第一字节的时间(TTFB)。通常,静态呈现意味着提前为每个 URL 生成单独的 HTML 文件。借助预先生成的 HTML 响应,可以将静态渲染器部署到多个 CDN,以利用边缘缓存的优势。因此,我们可以快速显示页面,然后为后续页面提前获取 SPA 框架。但是这种方法只适用于页面生成不依赖于用户输入的场景。

静态站点生成(SSG)

(4)带有 (Re)Hydration 的服务端渲染(SSR + CSR)

Hydration 译为 水合。是不是一脸懵逼!说人话,对曾经渲染过的 HTML 进行重新渲染的过程称为水合。

导航请求(例如整页加载或重新加载)由服务器处理,服务器将应用程序呈现为 HTML,然后将 JavaScript 和用于呈现的数据嵌入到生成的文档中。理想状态下,就像服务端渲染一样可以得到快速的 FCP ,然后通过使用称为 (Re)Hydration 的技术在客户端上再次渲染来修补

借助 React,我们可以在 Node 上使用 ReactDOMServer 模块,然后调用 renderToString 方法将顶级组件生成为静态 HTML 字符串。使用 Vue 的话,我们可以使用 vue-server-renderer ,调用 renderToString 方法来将 Vue 实例渲染为 HTML

该方法也有其缺点,我们确实保留了客户端的全部灵活性,同时提供了更快的服务器端渲染,但是 FCPTTI 之间的间隔也越来越大,并且 FID 也增加了。Hydration 非常昂贵,带有水合的 SSR 页面通常看起来具有欺骗性,并且具有交互性,但是在执行客户端 JS 并附加事件处理程序之前,实际上无法响应输入

带有 (Re)Hydration 的服务端渲染(SSR + CSR)

注意 bundle.js 仍然是全量的 CSR 代码,这些代码执行完毕页面才真正可交互。因此,这种模式下,FP(First Paint) 虽然有所提升,但 TTI(Time To Interactive) 可能会变慢,因为在客户端二次渲染完成之前,页面无法响应用户输入(被 JS 代码执行阻塞了)

对于二次渲染造成交互无法响应的问题,可能的优化方向是增量渲染(例如 React Fiber),以及渐进式渲染/部分渲染

(5)使用渐进 (Re)Hydration 进行流式服务端渲染(SSR + CSR)

为了最大程度地缩短 TTIFCP 之间的间隔,我们可以发起多个请求,并在生成内容时分批发送内容(返回的响应体是)。因此,在将内容发送到浏览器之前,我们不必等待完整的 HTML 字符串,还可以缩短第一个字节的时间(TTFB)。

React 中,我们可以使用 renderToNodeStream 而不是 renderToString 来通过管道返回响应并将 HTML 分块发送。在 Vue 中,我们可以使用 renderToStream 来实现管道和流传输。随着 React Suspense 的到来,我们也可以使用异步渲染来达到相同目的。

在客户端,我们不是一下启动整个应用程序,而是逐步启动组件。首先将应用程序的各个部分分解功能放到独立脚本中,然后逐步 “激活” (按优先级顺序)。我们可以先将关键组件激活,而其余的则随后激活。然后,可以针对每个组件定义为客户端还是服务器端渲染。然后,我们还可以延迟某些组件的激活,直到它们出现在可视区域或用户交互需要或浏览器处于空闲状态时。

对于 Vue,当在用户交互时使用 hydration 或使用 vue-lazy-hydration(可以定义组件可见的时机或特定用户交互时激活组件)可以减少 SSR 应用程序的交互时间。你也可以使用 Preact 和 Next.js 实现部分 hydration

理想中的流式服务端渲染流程如下:

  1. 请求 HTML,服务器先返回骨架屏的 HTML,之后再返回所需数据,或者带有数据的 HTML,最后关闭请求。
  2. 请求 jsjs 返回并执行后就可以交互了。

理想中的流式服务端渲染流程
(6)客户端预渲染

与服务端预渲染相似,但不是在服务器上动态渲染页面,而是在构建时就将应用程序渲染为静态 HTML

在构建过程中使用 renderToStaticMarkup 方法而不是 renderToString 方法,生成一个没有 data-reactid 之类属性的静态页面,这个页面的主 JS 和后续可能会用到的路由会做预加载。也就是说,当初打包时页面是怎么样,那么预渲染就是什么样。等到 JS 下载并完成执行,如果页面上有数据更新,那么页面会再次渲染。这时会造成一种数据延迟的错觉。

结果是 TTFB(第一字节到达时间)FCP 时间变少,并且缩短了 TTIFCP 之间的间隔。如果预期内容会发生很大变化,那么就无法使用该方法。另外,必须提前知道所有 URL 才能生成所有页面。

客户端预渲染
(7)三方同构渲染

如果可以使用 Service Worker,三方同构渲染也可能派上用场。这个技术是指:利用流式服务器渲染初始页面,等 Service Worker 加载后,接管 HTML 的渲染工作。这样可以让缓存的组件和模板保持最新,还可以启用像单页应用一样的导航用以在同一会话中预渲染新视图。当可以在服务器、客户端页面和 Service Worker 之间共享相同模板和路由代码时,此方法最有效。

三方同构渲染

三方同构渲染,在三个位置使用相同的代码渲染:在服务器上,在 DOM 中或在 service worker 中。

三方同构渲染

服务端渲染到客户端渲染的技术频谱。

至于如何选择, 这里也给出一些不成熟的建议:

  1. SEO 要求不高,同时对操作需求比较多的项目,比如一些管理后台系统,建议使用 CSR。因为只有在执行完 bundle 之后, 页面才能交互,单纯能看到元素,却不能交互, 意义不大,而且 SSR 会带来额外的开发和维护成本。
  2. 如果页面无数据,或者是纯静态页面,建议使用 SSG。 因为这是一种通过预览打包的方式构建页面,也不会增加服务器负担。
  3. SEO 有比较大需求同时页面数据请求多的情况,建议使用 SSR

正确设置 HTTP 缓存报文头

仔细检查 expiresmax-agecache-control 和其他 HTTP 缓存报文头是否已正确设置。一般来说,资源可以在很短的时间内或无限期缓存,并且可以在需要时通过 URL 中更改其版本。

使用Cache-control: immutable,用于表示响应正文不会随时间变化。资源如果未过期,在服务器上不会改变,因此浏览器不会发送缓存验证(例如:If-None-MatchIf-Modified-Since)来检查更新,即便是用户刷新了页面。

我们可以使用Cache-Control响应头指定了缓存时间,例如 Cache-Control: max-age=60。经过 60 秒后,浏览器就会重新去获取资源,但是这会导致页面加载速度变慢。所以我们可以通过使用 stale-while-revalidate 来避免这种问题;例如:Cache-Control: max-age=60, stale-while-revalidate=3600 是说,这个缓存在 60 秒内是「新鲜」的,从 60 秒到 3660 秒的这一个小时内,虽然缓存是过期了,但仍可以直接使用这个过期缓存,同时进行异步更新,在 3660 秒之后,就是完全过期了,那么需要进行传统的同步资源获取了。

在 2019 年 6 月至 7 月,ChromeFirefox 开始对 HTTP Cache-Control stale-while-revalidate 支持,由于过期的资产不会再堵塞渲染,所以它可以提高后续的页面加载速度。效果:对于重连的视图,RTT 为零

RTT:即是 Round Trip Time 的缩写,通俗点说,就是通信一来一回的时间。

此外,确保没有发送不必要的报头(例如 x-powered-bypragmax-ua-compatibleexpires等)和确保报文中包含有用的安全和性能相关报文头(如 Content-Security-PolicyX-XSS-ProtectionX-Content-Type-Options 等)。最后,请注意单页应用程序中 CORS 请求的性能成本

传输优化

是否对 JavaScript 库进行了异步加载?

当用户请求一个页面时,浏览器获取 HTML 构造 DOM,获取 CSS 构造 CSSOM,然后通过匹配 DOMCSSOM 生成一个渲染树。但是只要需要解析 JavaScript 时,浏览器就会延迟渲染页面的时间。所以作为开发人员,我们必须明确地告诉浏览器立即开始渲染页面。可以通过给脚本添加 HTML 中的 deferasync 属性。

然而,我们应该选用 defer,慎用 async。使用 async 的脚本一旦获取到,就会立即执行。假如这个 async 脚本获取的非常快,当脚本处于缓存就绪状态时,它实际上会阻塞 HTML 渲染。使用 defer,浏览器在解析 HTML 之前不会执行脚本。因此,除非在开始渲染之前需要执行 JavaScript,否则最好都使用 defer

另外,要限制第三方库和脚本的影响,特别要注意:例如社交分享按钮 SDKiframe 标签(如地图)的使用。可以使用 size-limit 库防止 JavaScript 库膨胀:如果不小心添加了一个大的依赖项,这个工具将通知你并抛出一个错误。

使用 IntersectionObserver 和图片懒加载

一般来说,我们应该把所有耗性能的组件都做延迟加载,比如大的 JavaScript、视频、iframe、小组件和潜在的要加载的图片。例如:Native lazy-loading 可以帮助我们延迟加载图片和 iframe

Native lazy-loading 就是浏览器的 img 标签和 iframe 标签支持原生懒加载特性,使用loading="lazy"语法标记即可。

根据测试:需要在 img 标签中显形设置 widthheight 才会支持延迟加载。

使用 IntersectionObserver 和图片懒加载

延迟加载脚本的最有效方式是使用 Intersection Observer API,这个 API 可以异步观察目标元素与祖先元素或文档的 viewport 之间交集的变化。我们需要创建一个 IntersectionObserver 对象,它接收一个回调函数和相应的参数,然后我们添加一个观察目标。如下:

let options = {
  root: document.querySelector('#scrollArea'),
  rootMargin: '0px',
  threshold: 1.0
}

let observer = new IntersectionObserver(callback, options);

let target = document.querySelector('#listItem');
observer.observe(target);

// threshold = 1.0 意味着 target 元素完全出现在 root 选项指定的元素中可见时,回调函数将会被执行。

当目标变成可见或不可见时,回调函数就会执行,所以当它和 viewport 相交时,我们可以在元素变得可见之前执行一些操作。所以,我们可以通过 rootMargin(围绕根的边距)和 threshold (一个数字或一组数字,表示目标的可见性的百分比)对何时调用观察者的回调进行细粒度控制。

渐进加载图片

我们可以通过在页面中使用渐进式图片加载将延迟加载效果提升到新的高度。与 FacebookPinterestMedium 类似,我们可以先加载低质量甚至模糊的图片,然后随着页面继续加载,使用 LQIP(低质量图片占位符)技术将它们替换为高质量的完整版本。

配合懒加载,我们可以使用现成的库:lozad.js

给个最简单的演示代码:

<img data-src="https://assets.imgix.net/unsplash/jellyfish.jpg?w=800&h=400&fit=crop&crop=entropy"
          src="https://assets.imgix.net/unsplash/jellyfish.jpg?w=800&h=400&fit=crop&crop=entropy&px=16&blur=200&fm=webp"
>
<script>
    function init() {
        var imgDefer = document.getElementsByTagName('img');
        for (var i=0; i<imgDefer.length; i++) {
            if(imgDefer[i].getAttribute('data-src')) {
                imgDefer[i].setAttribute('src',imgDefer[i].getAttribute('data-src'));
            }
        }
    }
    window.onload = init;
</script>

你发送了关键 CSS 吗?

为了确保浏览器尽快开始渲染页面,只包含首屏渲染可见部分所需的所有 CSS 称为”关键 CSS“。将它内联在页面的 <head> 标签中,从而减少往返请求传输。由于在慢启动阶段 TCP 交换的包的大小有限,所以关键 CSS 的大小应该不超过14KB。(这个特定的限制不适用于 TCP BBR,但是优先处理关键资源并尽早的去加载它们总是不会错的)。如果超出这个限制范围,浏览器将需要额外的传输往返用于获取更多样式。

这里简单介绍下上面说道的慢启动TCP BBR ,顺带简述下 TCP 的几种避免拥塞的算法

最初的 TCP 的实现方式是,在连接建立成功后便会向网络中发送大尺寸的数据包,假如网络出现问题,很多这样的大包会积攒在路由器上,很容易导致网络中路由器缓存空间耗尽,从而发生拥塞。因此现在的 TCP 协议规定了,新建立的连接不能够一开始就发送大尺寸的数据包,而只能从一个小尺寸的包开始发送,在发送和数据被对方确认的过程中去计算对方的接收速度,来逐步增加每次发送的数据量(最后到达一个稳定的值,进入高速传输阶段。相应的,慢启动过程中,TCP 通道处在低速传输阶段),以避免上述现象的发生。这个策略就是慢启动

1. 慢启动算法

慢启动算法的思想是为发送方增加了一个拥塞窗口(Congestion Window),记为 cwnd

拥塞窗口指的是在收到对端的 ACK 时还能发送的最大 MSS(最大报文段大小)数。拥塞窗口是发送端维护的一个值,不会像接收方窗口(rwnd)那样通告给对端,发送方窗口的大小是 cwnd 和 rwnd 的最小值。目前的 linux 的拥塞窗口初始值为 10 个 MSS。

慢启动算法,每经过一个 RTT,cwnd 变为之前的两倍。发送方开始时发送 initcwnd 个报文段(假设接收方窗口没有限制)然后等待 ACK(即是确认字符,在数据通信中,接收站发给发送站的一种传输类控制字符。表示发来的数据已确认接收无误。)。当收到该 ACK 时,拥塞窗口扩大为initcwnd*2,即可以发送initcwnd*2个报文段。当收到这发出报文段的 ACK 时,拥塞窗口继续扩大为initcwnd*4,这是一种指数增加的关系。

慢启动算法
2. 拥塞避免算法

拥塞避免算法和慢启动算法是两个不同的算法,但是他们都是为了解决拥塞,在实际中这两个算法通常是在一起实现的。相比于慢启动算法,拥塞避免算法多维护了一个慢启动阈值 ssthresh

当 cwnd < ssthresh 时,拥塞窗口使用慢启动算法,按指数级增长。 当 cwnd > ssthresh 时,拥塞窗口使用拥塞避免算法,按线性增长。

拥塞避免算法每经过一个 RTT,拥塞窗口增加initcwnd

当发生拥塞的时候(超时或者收到重复 ack),RFC5681 认为此时 ssthresh 需要置为没有被确认包的一半,但是不小于两个 MSS。此外,如果是超时引起的拥塞,则 cwnd 被置为 initcwnd

超时重传对传输性能有严重影响。原因:

  1. 在 RTO(重传超时时间,即从数据发送时刻算起,超过这个时间便执行重传。)阶段不能传数据,相当于浪费了一段时间;
  2. 拥塞窗口的急剧减小,相当于接下来传得慢多了。

拥塞避免算法
3. 快重传算法

有时候拥塞比较轻微,只有少量包丢失,后续的包能够正常到达。当后续的包到达接收方时,接收方会发现其 Seq 号比期望的大,所以它每收到一个包就 Ack 一次期望的 Seq 号,以此提醒发送方重传。当发送方收到3 个或以上重复确认(Dup Ack)时,就意识到相应的包已经丢了,从而立即重传它。这个过程称为快速重传。

为什么要规定凑满 3 个呢?这是因为网络包有时会乱序,乱序的包一样会触发重复的 Ack,但是为了乱序而重传没有必要。由于一般乱序的距离不会相差太大,比如 2 号包也许会跑到 4 号包后面,但不太可能跑到 6 号包后面,所以限定成 3 个或以上可以在很大程度上避免因乱序而触发快速重传。

快重传算法
另外还有一个问题,如果我们 2 和 3 号包都丢了,但是后面 4、5、6、7 号都正常收到了,并触发了三次 ACK 2。这时候发送端端如果收到多个重复的 ACK,认为发生丢包,TCP 会重传最后确认的包开始的后续包。这样原先已经正确传输的包可能会重复发送,降低了 TCP 性能。为改善这种情况,发展出 SACK(选择性确认)技术,使用 SACK 选项可以告知发包方收到了哪些数据,发包方收到这些信息后就会知道哪些数据丢失,然后立即重传丢失的部分。

快重传算法

4. 快恢复算法

如果在拥塞阶段发生了快速重传就没有必要像超时重传那样处理拥塞窗口了,考虑到如果网络出现拥塞的话就不会收到好几个重复的确认,所以发送方现在认为网络可能没有出现拥塞。所以此时不执行慢开始算法,而是将 cwnd 设置为 ssthresh 减半后的值,然后执行拥塞避免算法,使 cwnd 缓慢增大。这个过程被称为快恢复

快恢复算法

总结:

  • 超时重传对性能的影响最大,因为在 RTO 期间不能传输任何数据,而且拥塞窗口会急剧减小。所以应该尽量避免超时重传。
  • 丢包对极小文件的影响比大文件严重,因为小文件可能不能触发三次重复的 Ack,导致无法快速重传。
  • 在采用快恢复算法时,慢开始算法只是在 TCP 连接建立时和网络出现超时时才使用。

TCP BBR 是由 Google 发表的新的 TCP 拥塞控制算法,目前已经在 Google 内部大范围使用并且随着 linux 4.9 版本正式发布。

BBR 的名称实际上是 bottleneck bandwith and round-trip propagation time (瓶颈带宽和往返传播时间) 的首字母缩写,表明了 BBR 的主要运行机制:通过检测带宽和 RTT 这两个指标来进行拥塞控制。 BBR 算法的主要特点有以下几个:

  1. BBR 不考虑丢包,因为丢包(在现在这个时代)并不一定是网络出现拥塞的标志了
  1. BBR 依赖实时检测的带宽和 RTT 来决定拥塞窗口的大小:窗口大小 = 带宽 * RTT

尝试重新组合你的 CSS 规则

根据CSS and Network Performance的研究,按照媒体查询条件把 CSS 文件进行拆分可能对我们的页面性能有一定提升。这样,浏览器会使用高优先级检索关键 CSS,使用低优先级处理其他的所有内容。

例如:

<link rel="stylesheet" href="all.css" />

们把所有的 css 放在一个文件中,浏览器会这样处理他:

尝试重新组合你的 CSS 规则

<link rel="stylesheet" href="all.css" media="all" />
<link rel="stylesheet" href="small.css" media="(min-width: 20em)" />
<link rel="stylesheet" href="medium.css" media="(min-width: 64em)" />
<link rel="stylesheet" href="large.css" media="(min-width: 90em)" />
<link rel="stylesheet" href="extra-large.css" media="(min-width: 120em)" />
<link rel="stylesheet" href="print.css" media="print" />

当我们将其拆分成按 media 查询的时候,浏览器会这样:

按 media 查询
避免在 CSS 文件中使用 @import,因为它的工作原理,会影响浏览器的并行下载。不过目前我们更多使用的是 scssless他们会将 @import 的文件直接包含在 CSS 中,并不会产生额外的 HTTP 请求。

另外,不要将 <link rel="stylesheet"> 放在 async 代码段之前。如果 JavaScript 脚本不依赖于样式,可以考虑将异步脚本置于样式之上。如果存在依赖,可以将 JavaScript 分成两部分,将它们分别放到 CSS 的两边来加载。

动态样式也可能会有很高的代价,虽然因为 React 的性能很好,所以通常只会发生在大量组合组件并行渲染时才会出现这种情况。根据 The unseen performance costs of modern CSS-in-JS libraries in React apps 的研究,在 production 模式开启时,通过 CSS-in-JS 创建的组件可能会比常规的 React 组件多花一倍的渲染时间。所以在应用 CSS-in-JS 时,可以采用以下方案来提升你的程序性能:

  1. 不要过度的组合嵌套样式组件:这可以让 React 需要管理的组件更少,可以更快的完成渲染工作
  2. 优先使用“静态”组件:一些 CSS-in-JS 库会在你的 CSS 没有依赖主题或 props 的情况下优化其执行。你的标签模板越是 「静态」,你的 CSS-in-JS 运行时就越有可能执行得更快。
  3. 避免无效的 React 重新渲染:确保只在需要的时候才渲染,这样可以避免 ReactCSS-in-JS 库的运行时工作。
  4. 零运行时的 CSS-in-JS 库是否能适用于你的项目:有时我们会选择在 JS 中编写 CSS,因为它确实提供了一些很好的开发者体验,同时我们又不需要访问额外的JS API。如果你的应用程序不需要对主题的支持,也不需要使用大量复杂的 CSS props,那么零运行时的 CSS-in-JS 库可能是一个很好的选择。使用零运行时的库,你能从你的 bundle 文件中减少 12KB,因为大多数 CSS-in-JS 库的大小在 10KB-15KB 之间,而零运行时的库(如 linaria)小于 1KB。

预热连接用于加速传输

使用 资源提示(resource hint) 节省时间:

dns-prefetch: 提示浏览器该资源需要在用户点击链接之前进行 DNS 查询和协议握手。

preconnect: 向浏览器提供提示,建议浏览器提前打开与链接网站的连接,而不会泄露任何私人信息或下载任何内容,以便在跟随链接时可以更快地获取链接内容。

prefetch: 提示浏览器,用户未来的浏览有可能需要加载目标资源,所以浏览器有可能通过事先获取和缓存对应资源,优化用户体验。

preload: 告诉浏览器下载资源,因为在当前导航期间稍后将需要该资源。

然后发现这里是不是还少了个 prerender?(建议浏览器事先获取链接的资源,并建议将预取的内容显示在屏幕外,以便在需要时可以将其快速呈现给用户。)

但是在实际中使用是它确很困难。因为它会使浏览器除了去获取资源,还可能会预处理该资源,而该 HTML 页面依赖的其他资源,像<script><style> 等页面所需资源也可能会被处理。预处理会由于浏览器或当前机器、网络情况的不同而被不同程度地推迟。例如,会根据 CPU、GPU 和内存的使用情况,以及请求操作的幂等性而选择不同的策略或阻止该操作。

所以,不出所料,它被弃用了。但 Chrome 团队基于此提出了 NoState Prefetch 机制。在 Chrome 63 版本以上,Chrome 已经把 prerender 当作了一个 NoState Prefetch,它的表现就像 prerender 一样,NoState Prefetch 会提前获取资源;但与 prerender 不同的是,它不执行 JavaScript,也不提前渲染页面的任何部分。NoState Prefetch 只使用约 45MiB 的内存,且子资源处理的优先级只是 空闲(IDLE) 级别。从 Chrome 69 开始,NoState Prefetch 就在所有的请求中加入了请求头 Purpose: Prefetch,以便它们区别于普通的浏览。

实际上,我们至少会使用 preconnectdns-prefetch,会谨慎使用 prefetch, preloadprerender。需要注意的是:只有在你确认用户下一步将需要什么资源(例如:购买流程,注册流程)时,才应该使用 prerender

即使使用了 preconnectdns-prefetch,浏览器也会限制并行连接的主机数量,所以,根据优先级对它们进行排序会更好。

使用 resource hint 可能是提高性能的最简单的方法,而且它确实能给你带来性能提升。

下面附上各个阶段资源的请求优先级对照表:

各个阶段资源的请求优先级对照表由于字体文件一般是页面上的重要资源,有时通过 preload 提示浏览器下载重要字体是一个比较好的方法。但是,需要仔细看下这样做是否真的能提升性能,因为在预加载字体时存在一个优先级的难题:由于预加载被视为非常重要,它可以跳过甚至更关键的资源,如关键 CSS。

您还可以动态加载 JavaScript,有效地延迟加载执行。此外,由于 <link rel="preload"> 接受 media 属性,所以可以根据 @media 查询规则选择性的对资源进行优先级排序

最后,有几个需要注意的问题

preload 有助于将资源的开始下载时间提前到更接近初始请求的时间,但是预加载资源会占用与页面的内存缓存。

preload 可以很好地处理 HTTP 缓存:如果资源已经在 HTTP 缓存中,则永远不会发送网络请求。

因此,preload 对于后续触发加载的资源,如 background-image 加载的图片、内联关键的 CSS(或 JavaScript)并预加载其余的 CSS(或 JavaScript)非常有用。此外,只有在浏览器从服务器接收到 HTML 并且解析器找到 preload 标记之后,preload 标记才能初始化预加载。

另外还有一些新的提案,例如:Early Hint 可以在 HTML 的响应头之前,就可以启用 preloadPriority Hints 为开发人员提供了一种向浏览器表明资源优先级的方法,允许我们更好的控制资源加载的顺序。

注意:如果你正在使用 preload,必须定义 as,否则不会加载;不带 crossorigin 属性的预加载字体会被重复获取。

<link rel="preload" href="style.css" as="style">
<link rel="preload" href="main.js" as="script">

优化渲染性能

使用 CSS 的 will-change 通知浏览器哪些元素和属性将会改变。

will-change: auto
will-change: scroll-position
will-change: contents
will-change: transform
will-change: opacity
will-change: left, top

will-change: unset
will-change: initial
will-change: inherit

CSS 大部分样式是通过 CPU 来计算的,但 CSS 中也有一些 3D 的样式和动画的样式,计算这些样式会有有很多重复且大量的计算任务,可以交给 GPU 来跑。

浏览器在处理下面的 CSS 的时候,会使用 GPU 渲染:

  • transform
  • opacity
  • filter
  • will-change

这里要注意的是 GPU 硬件加速是需要新建图层的,而把该元素移动到新图层是个耗时操作,界面可能会闪一下,所以最好提前做。will-change 就是提前告诉浏览器在一开始就把元素放到新的图层,方便后面用 GPU 渲染的时候,不需要做图层的新建。

你是否了解如何避免回流和重绘?

说道回流重绘,我们先来回顾下浏览器的渲染流程:

  1. 构建 DOM 树
    • HMTL 词法语法分析,转成对应的 AST
  2. 样式计算
    • 格式化样式属性,例如:rem -> pxwhite -> #FFFFFF
    • 计算每个节点样式属性:根据 CSS 选择器与 DOM 树共同构建 render 树
  3. 生成布局树
    • 这里去除一些 dispy:none 等隐藏样式的元素,因为它们不在 render 树中
  4. 建立图层树
    • 主要分为「显式合成」和「隐式合成」
      • 当重绘时就只需要重绘当前图层
  5. 生成绘制列表
    • 将图层树转换成绘制的指令列表
  6. 生成图块和位图
    • 绘制列表交付给合成线程,进行图层分块。
    • 渲染进程中专门维护了一个栅格化线程池,专门负责把图块交由 GPU 渲染
    • GPU 渲染后将位图信息传递给合成线程,合成线程将位图信息在显示器显示
  7. 显示器显示内容显示器显示内容

其中第四步建立图层树很重要,我们再着重的讲一下。浏览器从 DOM 树画质到屏幕图形上,需要做树结构到层结构的转化。这里介绍 4 个点:

  1. 渲染对象(RenderObject)一个 DOM 节点对应了一个渲染对象,渲染对象维持着 DOM 树的树形结构。渲染对象知道怎么去绘制 DOM 节点的内容,它通过向一个绘图上下文(GraphicsContext)发出必要的绘制指令来绘制 DOM 节点。
  2. 渲染层(RenderLayer)浏览器渲染时第一个构建的层模型,位于同一个层级坐标空间的渲染对象都会被归并到同一个渲染层中,所以根据层叠上下文,不同层级坐标空间的的渲染对象将会形成多个渲染层,以此来体现它们之间的层叠关系。所以,对于满足形成层叠上下文条件的渲染对象,浏览器会自动为其创建新的渲染层。通常以下几种常见情况会让浏览器为其创建新的渲染层:
    • document 元素
    • position: relative | fixed | sticky | absolute
    • opacity < 1
    • will-change | fliter | mask | transform != none | overflow != visible
  3. 图形层(GraphicsLayer)图形层是一个负责生成最终准备呈现出来的内容图形的层模型,它拥有一个图形上下文(GraphicsContext),图形上下文会负责输出该层的位图。存储在共享内存中的位图将作为纹理(可以把它想象成一个从主存储器移动到图像存储器的位图图像)上传到 GPU,最后由 GPU 将多个位图进行合成,然后绘制到屏幕上,此时,我们的页面也就展现到了屏幕上。所以图形层是一个重要的渲染载体和工具,但它并不直接处理渲染层,而是处理合成层。
  4. 合成层(CompositingLayer)满足某些特殊条件的渲染层,会被浏览器自动提升为合成层。合成层拥有单独的图形层,而其他不是合成层的渲染层,则会和第一个拥有图形层的父层共用一个。那么一个渲染层满足哪些特殊条件时,才能被提升为合成层呢?这里也列举一些常见情况:
    • 3D transforms
    • videocanvasiframe
    • opacity 动画转换
    • position: fixed
    • will-change
    • animationtransition 设置了opacitytransformfliterbackdropfilter

上面提到满足一些特殊条件的渲染层最终会被浏览器提升了合成层,称为显式合成。除此之外,浏览器在合成阶段还存在一种隐式合成。下面我们通过举例来看下:

  • 假设,我们有两个 absolute 定位的 div 在屏幕上交叠了,根据 z-index 的关系,其中一个 div 就会”盖在“了另外一个上边。隐式合成
  • 这时候,如果我们给 z-index: 3 设置 transform: translateZ(0) ,让浏览器将其提升为合成层。提升后 z-index: 3 这个合成层就会在 docuemnt 上方,那么按理来说 z-index: 3 就会在 z-index: 5 上面,我们设置的 z-index 就会出现交叠关系错乱的情况。隐式合成
  • 为了纠正这种错误的交叠顺序,浏览器必须让原本应该”盖在“它上边的渲染层也同时提升为合成层。这称为隐式合成

渲染层提升为合成层之后,会给我们带来不少好处:

  1. 合成层的位图,会交由 GPU 合成,比 CPU 处理要快得多;
  2. 当需要重绘时,只需要重绘本身,不会影响到其他的层;
  3. 元素提升为合成层后,transformopacity 才不会触发重绘,如果不是合成层,则其依然会触发重绘。

当然了,任何东西滥用都是会有副作用,例如:

  1. 绘制的图层必须传输到 GPU,这些层的数量和大小达到一定量级后,可能会导致传输非常慢,进而导致一些低端和中端设备上出现闪烁;
  2. 隐式合成容易产生过量的合成层,每个合成层都占用额外的内存,形成层爆炸。占用 GPU 和大量的内存资源,严重损耗页面性能。而内存是移动设备上的宝贵资源,过多使用内存可能会导致浏览器崩溃,让性能优化适得其反。

OK,大概知道了浏览器的渲染原理后,我们来看下如何在实际中去减少回流和重绘:

  1. 始终在图像上设置宽度和高度属性:浏览器会在默认情况下会分配框并保留空间,后续图片资源加载完成后不需要回流。
  2. 避免多次修改:例如我们需要修改一个 DOMheight/width/margin 三个属性,这时候我们可以通过 cssText 去修改,而不是通过 dom.style.height 去修改。
  3. 批量修改 DOM:将 DOM 隐藏或者克隆出来修改后再替换,不过现在浏览器会用队列来存储多次修改,进行优化。 这个是适用范围已经不是那么广了。
  4. 脱离文档流:对于一些类似动画之类的频繁变更的 DOM.可以使用绝对定位将其脱离文档流,避免父元素频繁回流。

网络优化

启用 OCSP stapling 了吗?

通过在服务器上启用 OCSP stapling,可以加快 TLS 握手的速度。「在线证书状态协议」(OCSP)是「证书吊销列表」(CRL)协议的替代品。两种协议都用于检查 SSL 证书是否已被吊销。但是,OCSP 协议不需要浏览器花时间下载和搜索证书信息列表,因此减少了握手所需的时间。

  • 证书吊销列表(CRL): CRL 分布在公共可用的存储库中,浏览器可以在验证证书时获取并查阅 CA 的最新 CRL。 该方法的一个缺陷是吊销列表的时间粒度受限于 CRL 发布期。只有在 CA 厂商更新所有当前发布的 CRL 之后,才会通知浏览器撤销。 各家签名 CA 厂商的策略不一样,有的是几小时,有的是几天,甚至几周。
  • 在线证书状态协议(OCSP):为了解决单个文件大,延迟性高等问题,迎来了新的解决方案 OCSP。浏览器从在线 OCSP 服务器(也称为 OCSP Response Server)请求证书的撤销状态,OCSP Server 予以响应。这种方法避免 CRL 更新延迟问题。该方法的缺点是:
    1. 浏览器的每次 HTTPS 请求创建,都需要连接 CA OCSP Server 进行验证,有的浏览器所在 IP 与 CA OCSP Server 的网络并不是通畅的。而且,OCSP 的验证有网络 IO,花费了很长的时间,严重影响了浏览器访问服务器的用户体验。
    2. 在浏览器发送服务器 HTTPS 证书序号到 CA OCSP Server 时,也将暴露了用户的隐私,将用户访问的网址透漏给了 CA OCSP Server。
  • OCSP 装订(OCSP Stapling):OCSP Stapling 解决了 CRL、OCSP 的缺点,将调用 OCSP Server 获取证书吊销状况的过程交给 Web 服务器来做,Web 服务器不光可以直接查询 OCSP 信息,规避网络访问限制OCSP 服务器离用户的物理距离较远等问题,还可以将查询响应缓存起来,给其他浏览器使用。由于 OCSP 的响应也是具备 CA RSA 私钥签名的,所以不用担心伪造问题。
    1. 解决了访问慢的问题
    2. 解决了用户隐私泄露的问题

是否针对 HTTP 协议做了正确的优化?

随着 HTTPSHTTP/2 的流行,很多 HTTP/1.1 时代的优化策略已经不奏效了,甚至还有反优化的作用。

这里我们顺带把各个版本的 HTTP 协议做一下简单的分析:

  • HTTP/1.0
    • 浏览器与服务器只保持短暂的连接,浏览器的每次请求都需要与服务器建立一个 TCP 连接(TCP 连接的新建成本很高,因为需要客户端和服务器三次握手),服务器完成请求处理后立即断开 TCP 连接,服务器不跟踪每个客户也不记录过去的请求
  • HTTP/1.1
    • 管道化(Pipelining):提出管道化方案解决连接延迟,服务端可设置 Keep-Alive 来让连接延迟关闭时间,但因为浏览器自身的 Max-Connection 最大连接限制,同一个域名下的请求连接限制(同域下谷歌浏览器是一次限制最多 6 个连接),只能通过多开域名来实现,这也就是我们的静态资源选择放到 CDN 上或其它域名下,来提高资源加载速度。管道化方案需要前后端支持,但绝大部分的HTTP代理器对管道化的支持并不友好。
    • 只支持 GET/HEAD:管道化只支持 GET / HEAD方式传送数据,不支持 POST 等其它方式传输。
    • 头部信息冗余HTTP 是无状态的,客户端/服务端只能通过 HEAD 的数据维护获取状态信息,这样就造成每次连接请求时都会携带大量冗余的头部信息,头部信息包括 COOKIE 信息等。
    • 超文本协议HTTP/1.X 是超文本协议传输。超文本协议传输,发送请求时会找出数据的开头和结尾帧的位置,并去除多余空格,选择最优方式传输。如果使用了 HTTPS,那么还会对数据进行加密处理,一定程度上会造成传输速度上的损耗。
    • 队头阻塞:管道化通过延迟连接关闭的方案,虽然可同时发起对服务端的多个请求,但服务端的 response 依旧遵循FIFO(先进先出)规则依次返回。举个例子客户端发送了 1、2、3、4 四个请求,如果 1 没返回给客户端,那么 2,3,4 也不会返回。这就是所谓的「队头阻塞」。高并发高延迟的场景下阻塞明显。
  • HTTP/2.0
    • 多路复用:一个域只要一个 TCP 连接,实现真正的并发请求,降低延时,提高了带宽的利用率。
    • 头部压缩:客户端/服务端进行渐进更新维护,采用 HPACK 压缩,节省了报文头占用流量。
      1. 相同的头部信息不会通过请求发送,延用之前请求携带的头部信息。
      2. 新增/修改的头部信息会被加入到 HEAD 中,两端渐进更新。
    • 请求优先级:每个流都有自己的优先级别,客户端可指定优先级。并可以做流量控制。
    • 服务端推送:例如我们加载 index.html, 我们可能还需要 index.js, index.css 等文件。传统的请求只有当拿到 index.html,解析 html 中对 index.js/index.css 的引入才会再请求资源加载,但是通过服务端数据,可以提前将资源推送给客户端,这样客户端要用到的时候直接调用即可,不用再发送请求。
    • 二进制协议:采用二进制协议,区别 与 HTTP/1.X 的 超文本协议。客户(服务)端发送(接收)数据时,会将数据打散乱序发送,接收数据时接收一端再通过 streamID 标识来将数据合并。二进制协议解析起来更高效、“线上”更紧凑,更重要的是错误更少。

    这里再补充一下 HTTP2 相对于 HTTP1.1 并不全是优点:因为 HTTP2 将多个 HTTP 流放在同一个 TCP 连接中,遵循同一个流量状态控制。只要第一个 HTTP 流遇到阻塞,那么后面的 HTTP 流压根没办法发出去,这就是「行头阻塞」

  • HTTP/3.0采用 QUIC 协议,基于 UDP 协议,避免了 TCP 协议的一些缺点,采用 TLS1.3HTTPS 所需的 RTT 降至最少为 0。
    • TCP 协议的不足
        • TCP 可能会间歇性地挂起数据传输:如果一个序列号较低的数据段还没有接收到,即使其他序列号较高的段已经接收到,TCP 的接收机滑动窗口也不会继续处理。这将导致TCP 流瞬间挂起,在更糟糕的情况下,即使所有的段中有一个没有收到,也会导致关闭连接。这个问题被称为 TCP 流的行头阻塞(HoL)TCP 可能会间歇性地挂起数据传输
        • TCP 不支持流级复:虽然 TCP 确实允许在应用层之间建立多个逻辑连接,但它不允许在一个 TCP 流中复用数据包。使用 HTTP/2 时,浏览器只能与服务器打开一个 TCP 连接,并使用同一个连接来请求多个对象,如 CSSJavaScript 等文件。在接收这些对象的同时,TCP 会将所有对象序列化在同一个流中。因此,它不知道 TCP段的对象级分区。
        • TCP 会产生冗余通信TCP 连接握手会有冗余的消息交换序列,即使是与已知主机建立的连接也是如此。TCP 会产生冗余通信
    • QUIC 协议的优势
      • 选择 UDP 作为底层传输层协议:在 TCP 之上建立新的传输机制,将继承 TCP 的上述所有缺点。因此,UDP 是一个明智的选择。此外,QUIC 是在用户层构建的,所以不需要每次协议升级时进行内核修改。
      • 流复用和流控QUIC 引入了连接上的多路流复用的概念。 QUIC 通过设计实现了单独的、针对每个流的流控,解决了整个连接的行头阻塞问题。
      • 灵活的拥塞控制机制TCP 的拥塞控制机制是刚性的。该协议每次检测到拥塞时,都会将拥塞窗口大小减少一半。相比之下,QUIC 的拥塞控制设计得更加灵活,可以更有效地利用可用的网络带宽,从而获得更好的吞吐量。
      • 更好的错误处理能力QUIC 使用增强的丢失恢复机制和转发纠错功能,以更好地处理错误数据包。该功能对于那些只能通过缓慢的无线网络访问互联网的用户来说是一个福音,因为这些网络用户在传输过程中经常出现高错误率。
      • 更快的握手QUIC 使用相同的 TLS 模块进行安全连接。然而,与 TCP 不同的是,QUIC 的握手机制经过优化,避免了每次两个已知的对等者之间建立通信时的冗余协议交换。更快的握手

这里大概给两条公式看下 HTTP/3 在结合 HTTPS 下跟 HTTP/2 的对比,给大家一个比较直观的感受,具体细节就不再简述了。毕竟本篇的主题不在这,如果有兴趣的话后面可以再详细讲。

HTTP/2 下:HTTPS 通信时间总和 = TCP 连接时间 + TLS 连接时间 + HTTP 交易时间 = 1.5 RTT + 1.5 RTT + 1RTT = 4 RTT

HTTP/3 下:首次链接时,QUIC 采用 TLS1.3,需要 1RTT,一次 HTTP 数据请求,共 2RTT。重连时直接使用 Session ID,不需要再次进行 TLS 验证,所以只需要 1RTT

OK,大概了解的 HTTP 协议的版本特点后,我们来看在目前主流 HTTP2 + HTTPS 的时代下,哪些优化策略已经过时了甚至是反优化呢

  1. 减少请求数HTTP/1.1 因为存在「队头阻塞」,所以我们通常会采用合并资源,捆绑文件(雪碧图等)等方式来减少请求数。但在 HTTP/2 中我们更需要注重网站的缓存调优,传输轻量、细粒度的资源,方便独立缓存和并行传输。
  2. 多域名存储HTTP/1.1 因为浏览器有最大连接数限制,所以我们会将资源分发到不同的域名下存放以此来增大最大连接数。但在 HTTP/2 中一个域只有一个链接,所以我们不需要去分多个域名存储,多域名存储甚至还会造成额外的 TLS 消耗。

减小请求头的大小

减小请求头的大小,常见的情况是 Cookie 。例如我们的主站中(如:www.test.com ) 存储了很多的 Cookie,我们的 CDN 域名(cdn.test.com)与我们主域一样,此时我们去请求时会附带上 .test.com域下的 Cookie。而且这些 Cookie 对于 CDN 毫无用处,会增大我们请求的包大小。所以我们可以将 CDN 域名与主域区分开,例如:淘宝(https://www.taobao.com)的 CDN 域名为 https://img.alicdn.com

总结

优化工具

上面我们长篇大论的简述了许多前端优化的点,这里再推荐 PageSpeed InsightsWebPageTest 这两个工具。

  1. PageSpeed Insights 可以帮助我们查看网站的各项 RUM 数据,同时会提供网站中的优化不足点,同时提供优化建议等。
  2. WebPageTest 免费提供了全球多个地点进行网站速度测试。还可以依据测试结果提供丰富的诊断信息,包括资源加载瀑布图,页面速度优化检查和改进建议,会给每一项内容一个最终的评级。

速成方案

这个清单很庞大,如果要完成所有的优化可能需要很长时间。所以,如果你只有很有限的时间来进行优化,你会怎么做呢?让我们把它浓缩成15 个比较容易实现的点

  1. 根据实际经验制定合适的目标。一个比较合理的目标是:可视区域渲染 < 1s,页面渲染 < 3s,弱网 3G 的可操作时间 < 5s,重复访问的可交互时间(TTI) < 2s。
  2. 为首屏准备关键 CSS,并将其内联在页面。对于 CSS / JS,关键文件大小控制在最大为压缩后 170KB 内。
  3. 抽离、优化、延迟加载尽可能多的脚本,选轻量级替代方案(如用 DayJs 代替 MomentJs),并限制第三方脚本的影响。
  4. 仅向具有 <script type="module">module/nomodule 模式的旧版本浏览器提供旧版本代码。
  5. 尝试重新组合 CSS 规则。
  6. 添加资源提示(resource hints)以提升页面加载速度,例如 dns-prefetchpreconnectprefetchpreloadprerender 等。
  7. 设置 Web 字体子集并异步加载,并利用 CSS 中的 font-display 实现快速的首次呈现。
  8. 使用mozjpegguetzlipingoSVGOMG优化图像,并考虑使用图像 CDNWebP 服务。
  9. 检查 HTTP 缓存头和安全头是否设置正确。
  10. 在服务器上启用 Brotli 压缩。(如果不行,那么请启用 Gzip 压缩。)
  11. 只要服务器运行在 Linux 内核版本 4.9+上,就启用 TCP BBR 拥塞。
  12. 如果可能,启用 OCSP stapling
  13. 如果 HTTP/2 可用,则启用 HPACK 压缩。如果激进一点可以尝试启用 HTTP/3
  14. Service worker 中缓存字体、样式、JavaScript 和图像等资源。
  15. 尝试使用渐进式 hydration 和流服务器渲染你的单页应用。

「点点赞赏,手留余香」

2

给作者打赏,鼓励TA抓紧创作!

微信微信 支付宝支付宝

还没有人赞赏,快来当第一个赞赏的人吧!

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。
码云笔记 » 面试官:在项目中你是如何做前端优化?

发表回复