随着业务发展越来越复杂,网站的复杂度也变得越来越高,发展伴随而来的性能问题,是一个网站产品必然绕不过去的痛;
网站性能问题出现之后,其他相关的弊病也会暴漏出来。除了最老生常谈的用户体验一类的直接能体感到的问题,还间接性会对网站排名、搜索引擎优化产生影响,甚至于最终会对用户留存、网站的营收都会产生影响;
面对复杂的业务,不合理的代码、架构都会造成网站性能的下降;面对现代网站架构,网站的性能优化不再局限在前端,往往还需要从后端、乃至于运维一起的全栈的角度下去敬畏这个问题,最终才能做好一个高性能网站。
本文会从「网站性能影响」「常见问题及优化手段」「业界度量工具、标准」等几个方面来全方面简略谈论前端性能优化;
一、网站性能的影响
1、对「用户体验」的影响
当打开一个网站,我们通常愿意花多长时等待内容加载好? 5秒?10秒?
对于我的个人习惯,通常我会同时打开多个网站然后回过头一个一个看,那么此时如果网站还不能加载好让我看到想看的内容,我便会放弃这个网站。
Dynatrace 的研究也表明,大部分人的耐心其实只有 3 秒钟。3 秒内还没有加载出来人们可能会转而选择另一个网站。
当人们无法忍受这个时间时,就会造成以下的影响:
对转化率的影响:
以下公司曾经对网站性能与转化率进行了数据统计:
- Pinterest:2015 年上半年,Pinterest 的工程师进行了一次实验,将移动 Web 首页的页面加载性能提升了 60%,最终使得注册转化率增加了 15%,其中移动注册转化率提升了 40%。而经过这一系列的优化,最终为 Pinterest 在2016 年实现了用户数量的最大化增长。
- Furniture Village:在 2018 年,Furniture Village 通过将页面加载时间降低了 20%,移动设备转化率提高了 10%,这促使移动的收入增长也增长了 12%;
对跳出率的影响:
跳出率指访问首个页面后,就关闭页面的用户占比。显而易见,网站打开的速度,对于用户的留存与流失率就有这关键的影响。
BBC 研发发现,网页加载每增加一秒钟,他们就会失去总用户的 10%。
谷歌官方数据也显示,若页面加载时间从1秒增加到3秒,跳出率增加32%。
对营收的影响:
单纯从「转化率」和「跳出率」的数值上面看似乎体感不够强烈,但对于类似沃尔玛这种营收巨大的公司, 0.1% 的波动就会对他们业务的营收产生巨量的影响。一个电子商务网站每年产生 1000 万美元销售额,如果网站加载时间缩短一秒后转化率提高 2%(如沃尔玛案例研究所示),这表示其收入会增加 200,000 美元。
来自亚马逊的网站开发工程师 Amila Welihinda 就曾在 Twitter 表示,100ms 的延迟会让 亚马逊网站的销量下跌1%。而他们团队也正在为亚马逊网站的加载时间减半而努力。
2、对「搜索引擎排名」的影响
除了对直接对用户产生影响的体验问题,性能对网站在搜索引擎上的排名的影响也是一个不可忽视的问题。
搜索引擎对于内容的抓取来源于爬虫程序,不同的搜索引擎,会根据爬虫程序运行之后得出的一些指标,使用特定的算法来对最终抓取的内容进行排名;
这里我们主要看看对于中国最大的搜索引擎 Baidu、全球最大的搜引擎 Google 会有什么影响:
Baidu 闪电算法:
百度在 2017 年推出了闪电算法,算法的内容包含三个部分:
- 闪电算法针对的对象是移动端网站的首页;
- 以时间为算法触发维度,分别为小于等于两秒,大于两秒小于等于三秒,以及大于三秒;
- 通过移动网站打开的时间,来判定哪些网站将获得优先排名,优先展现;
上述内容可以看出,百度在针对移动端网站方面,会对打开速度进行分级,并且最终给到搜索的流量倾斜;对于那些大于三秒的网站,在百度搜索流量方面可能会遭到制裁;
Google Speed Update:
2018 年时,Google 正式宣布了 Google Speed Update,这也是一项针对于手机网站打开速度过慢时,将会降低网站的自然搜索排名;
不过值得注意的是,Google Speed Update 这项算法并不像 「Baidu 闪电算法」那么激进,此项算法只会对「最慢的网站」调降搜索排名,如果你的网站原本搜索排名或打开速度处于中上游时,就不必担心收到次影响;
总体来讲,无论是 Baidu 还是 Google,想要然我们的产品能尽早的从搜索引擎搜出来,网站性能好坏是一个比较重要的影响因素;
3、对「搜索引擎精准度」的影响
与「搜索引擎排名」一样,「搜索引擎精准度」也会受到爬虫爬取速度的影响。
响应速度快的网站一方面更容易能够被爬虫爬到更多、更完整的信息,另一方面,对于经常更新的网站,响应时间更短的网站更有利于爬虫去抓取到网站的最新数据,来保证搜索引擎中的信息时刻保持为最新,以此对用户产生较好的心智影响。
二、业界常用的性能工具、指标
在了解了性能会对用户、业务数据产生这么大的影响之后,接下来就要了解如何发现这些性能问题。
1、常用工具
Google 为前端开发者提供了两个最常用的性能检测工具:
Lighthouse(Google):
Lighthouse 是 Google 推出的一款开源自动化工具,它可以搜集多个现代网页性能指标,分析 Web 应用的性能并生成报告。
一般开发期间,会直接使用内置在 Chrome DevTools 中的:
生成的分析结果:
同样,也可以使用 Lighthouse Node 命令行进行:
// 安装
npm install -g lighthouse
# or use yarn:
# yarn global add lighthouse
// 使用
lighthouse https://www.aliyun.com/ --locale=zh-CN --preset=desktop --disable-network-throttling=true --disable-storage-reset=true
// 最后使用 chrome 打开生成的分析 html 查看即可
Google Pagespeed Insights(Google):
Google Pagespeed Insights 也是 Google 提供的一个性能分析工具,它直接提供网站页面形式的使用方式:
首次使用 GPI 的时候,比较困惑的是 GPI 看起来跟 Lighthouse 比较像,因为从具体的分析结果来看,各项的维度指标都是差不多的:
但与 Lighthouse 不同的是,PSI 同时提供页面的实验室和现场数据。实验室数据就对于调试性能问题很有用,因为它是在特定的环境中收集的,但是它可能无法捕捉到现实世界的瓶颈。而现场数据对于捕捉真实的、真实的用户体验很有用。现场数据是关于特定 URL 执行情况的历史报告,并表示来自现实世界中用户在各种设备和网络条件下的匿名性能数据。实验室数据基于单个设备上页面的模拟负载和一组固定的网络条件。因此,这些值可能会有所不同。
2、性能指标
性能工具最终的检测结果都是以各种指标数据进行定量分析,正确理解这些数据指标也是必要的学习内容之一。
还是以 Lighthourse 分析的结果为例:
从上图中可以看到一系列的度量指标,使用 Chrome 提供的 DevTools 的 performance 工具,也可以看到这几个指标出现在了时间轴上:
这里主要涉及到的核心指标有以下几种:
FCP(First Contentful Paint):首屏绘制
FCP 测量用户进入页面后,浏览器呈现第一段 DOM 内容所需的时间。 页面上的图像、非白色 <canvas> 元素和 SVG 等被视为 DOM 内容;
注意:被 iframe 加载的 DOM 并不会算在首屏绘制中。
LCP (Largest Contentful Paint):最大内容绘制
6.0 版本之后,Lighthouse 不在建议使用 First Meaningful Paint 这项指标,而是建议使用 LCP 进行度量;此项指标测量的时机为页面中最大的内容元素何时被渲染到屏幕上。 指标最终度量的时间“近似于”页面的主要内容对用户可见的时间。
犹豫是“近似于”,所以,如何确定最大内容,是一个比较争议的点。具体可以查看 Largest Contentful Paint 相关文档。
TTI(Time To Interactive):页面可交互时间
在介绍 TTI 之前,有一个概念需要了解:
Long Task:如果浏览器主线程执行的一个 task 耗时大于 50ms,那么这个 task 称为 long task。用户的交互操作也是在主线程执行的,所以当发生 Long Task 时,用户的交互操作很可能无法及时执行,这时用户就会体验到卡顿(当页面响应时间超过 100ms 时,用户可以体验到卡顿),进而影响用户体验。
满足可交互时间,需要同时满足下面三个条件:
a) FCP 之后,页面已经呈现了有用的内容;
b) 对大部分的可见页面元素而言,已经注册了事件回调;
c) 页面对用户交互的响应在 50ms 以内(这个 50ms 也就是来自于 LongTask 的值);
Google 给出的这些指标算是比较通用化的指标,同样阿里云也有一些自己定义的指标来适用于一些特定场景:
三、常见的问题及优化手段
发现问题之后,接下来就是针对问题进行一系列的优化。
现代架构下,前端已经不至于局限于前端应用工程,一些资深前端开发者会一起包掉包括后端动态服务(SSR 等)、部署、运维一系列的工作。拥有全面的技术视野,可以对我们进行前端性能优化提供更多可能性;
1、架构层面:
面对不同的业务,不同的网站类型,一个好的「网站渲染架构」对于后续的性能影响至关重要;
通常情况下,网站一般会使用两种渲染架构:
- SSR(Server Side Rendering) :较为传统的前端开发方式;由服务端把渲染的完整 HTML 吐给客户端,然后在浏览器直接渲染。此种方式的最大优势在于:
- 服务端子服务之前的相互依赖的网络环境优于客户端,内部服务器之间通信速度更快;
- 省去了客户端二次请求数据的网络传输开销;少的 HTTP 请求自然就意味着网络传输的时间会减少很多,页面的打开速度也会快很多;
- CSR(Client Side Rendering) + CDN:现代流行的前端开发方式;服务端吐给客户端的 HTML 不在拥有整个页面的所有数据和信息,而这些数据和信息的渲染依赖于前端二次加载 js 以及异步调用服务接口来拉取数据进行客户端渲染;
相比只下,一般 SSR 的速度都会比 CSR 要快,但开发成本也会高一些;因此开始时,不会为了追求性能,而所有场景都要追求 SSR,性能与开发成本的平衡是需要考虑的点;
那么我们如何对这两种渲染架构进行选择呢?
- SSR:需要一屏加载,需要支持良好的 SEO;比如:官方首页;
- CSR:不需要一屏加载,对 SEO 没有什么要求;比如:中后台系统;
除了 SSR 和 CSR 之外,还有其他的方式比如:SSG,不过大部分复杂类型的业务,页面都需要加载动态数据,这种静态的方式就不在讨论之中了。
2、前端层面:
前端层面的优化, 大部分都是针对于 CSR 场景的,大致可以归类为以下几个方向:
前端构建产物瘦身方向:
dynamic import 动态加载 - 针对非首屏的组件进行单独分离打包:
以下代码中,以动态引入方式引入 Hero 组件:
import React, { lazy, Suspense } from 'react'; // 动态引入方式 const Hero = lazy(() => import('./Hero')); // 静态引入方式 // import Hero from './Hero'; export default () => { return ( <div> <Suspense fallback={<div>Page is Loading...</div>}> <section> <Hero /> </section> </Suspense> </div> ); };
Hero 会被分片编译出一个单独的 js 文件,当执行到<Hero />
此行代码时,才会异步请求资源代码,所以通常一般非首屏的组件会使用此方式来编写代码,即不会影响到用户的使用体验又能缩小首屏资源大小:
- 优点:可以把不在首屏的东西,全部以 「dynamic import」的方式导入,以此来缩小首屏 js 资源的大小,加快页面打开速度;
- 注意的点:被 「dynamic import」引入的组件,组件内部一定要保持组建的独立性,不要再耦合过多的东西;否则可能出现一些依赖被重复打包到很多个地方;
bundle split 代码分片 - 针对复用频率较高的组件/库进行独立打包:
「bundle split」跟「dynamic import」有些类似,最终也是对一部分代码进行单独的打包,但「bundle split」强调的点是尽量把公用的代码 split
出来,以此降低一个项目构建后资源的重复资源量,最终做到对于公用的代码,最多只请求一次。
下列代码中,Page1 和 Page2 同时引入了一个比较大的 MonacoEditor
代码编辑器组件:
// Page1 import React from 'react'; import MonacoEditor from '@/components/MonacoEditor'; export default () => { return ( <div> Page1 <MonacoEditor language="json" /> </div> ); }; // Page2 import React from 'react'; import MonacoEditor from '@/components/MonacoEditor'; export default () => { return ( <div> Page2 <MonacoEditor language="json" /> </div> ); };
为了确保打包编译时,不会把 MonacoEditor
同时打入两个 page 中,可以在 Webpack 下使用 「code split」功能,对 MonacoEditor
分成单独的 split chunk,如下:
optimization: { splitChunks: { chunks: 'all', minSize: 30000, minChunks: 3, automaticNameDelimiter: '.', cacheGroups: { monacoEditor: { minChunks: 1, minSize: 30000, name: 'monacoEditor', priority: 11, reuseExistingChunk: true, // 打包时,当探测到 node_modules/monaco-editor 资源时,单独打成 monacoEditor 文件 test: /[\\/]node_modules[\\/]monaco-editor/, }, }, }, },
最终加载资源如下,可以看到访问 page1 时,优先加载了MonacoEditor
组件,当切换到 page2 时,不在重复加载 MonacoEditor
:
externals bundle 外部依赖 - 针对100%依赖的组件/包进行独立打包:
在项目中,如果我们个别库几乎每个地方都要使用,那就可以通过配置externals,将这个库作为外部依赖。
外部依赖可以避免我们将公用的类库重复的打到每一个应用,对构建出的 js bundle 瘦身有不小的作用。
注意这里与「bundle split」的区别在于,「bundle split」打包之后,公共依赖依旧会出现在你的应用的构建产物中,而 externals 强调直接不把公共依赖打入应用构建产物,不同的应用使用的 「externals」来源都是统一的地址,这样借助浏览器的缓存,即使不同的应用业务之前切换,也可以使用同一个「externals」缓存;
在 React 体系,将 react
和 react-dom
作为 externals 依赖是最常用的做法,如果你的应用在使用 antd
,也可以加入到 externals 中。对于 externals 出的库,一般会使用 <Script /> 或 <Link /> 直接加载 HTML 中作为全局依赖。
webpack 对于 externals 的配置也比较简单:
externals:{ react: 'React', 'react-dom': 'ReactDOM', antd: 'antd', moment: 'moment'' }
配置过后再打包,就会发现这些类库不在会出现在你的应用的构建产物中;
- 注意的点:一旦使用外部依赖,就表明你的所有资源都会同时依赖这些资源,那么对这些资源的升级将会存在比较大的风险;
按需加载 - 引用组件库体积优化的方式:
当你的团队需要开发一个组件库时(注意:这里说的是组件库,即组件的集合,而不是单一的组件),组件库提供按需加载功能几乎是一个必备的特性;
一方面使用者希望在写代码时,以最简答的写法引入组件,即:
import { Button } from 'antd';
另一方面,使用者又希望,如果我只使用到了 Button, 就不要给我把全部 antd 打进构建产物中,那么此时按需加载就十分必要了。当你的组件库足够庞大,且每个组件之间并没有太多耦合时,按需加载就更加必要了。
Umi 的开发者 @云谦 的代表作,babel-plugin-import 就是这个问题的一个解法。只要你的组件库按照一定的文件、命名规范,此 babel 插件就能在编译时,将 import 转换成按需加载。
import { Button } from 'antd'; ↓ ↓ ↓ ↓ ↓ ↓ var _button = require('antd/lib/button');
DCE - 引用组件库体积优化的方式:
摇树优化(Tree-Shaking)是用于消除死代码(Dead-Code Elimination)的一项技术。这主要是依赖于 ES6 模块的特性实现的。ES6 模块的依赖关系是确定的,和运行时状态无关,因此可以对代码进行可靠的静态分析,找到不被执行不被使用的代码然后消除。
如果你是用的构建工具是 webpack, 在 production 模式下,会自动进行这项优化;
代码压缩 - 构建产物收尾:
打包资源别网络压缩和精简混淆,同样也能减少不少构建产物的资源大小。
Webpack 生态中,可以使用 terser-webpack-plugin 用来压缩代码和清理无意义的代码:
const TerserPlugin = require("terser-webpack-plugin"); module.exports = { optimization: { minimize: true, minimizer: [new TerserPlugin()], }, };
前端资源加载方向:
当我们已经能把构建产物进行合理的瘦身,再往上一步就是,我们如何合理的安排这些资源的加载。
首先是 async/defer:
页面中的资源起初都是由 html 中的 script 标签引入的,script 标签为我们提供了 async 和 defer 两个关键词。
从下图中,我们可以清晰看到 defer 和 async 对于 html 在解析时到 script 时,script 的加载和执行时机。
- defer 的特性:script 加载 与 文档的解析并行进行,文档解析完成后依次执行;多个时可保证时序;
- async 的特性:script 加载 与 文档的解析并行进行,加载完成即会执行;多个时无法保证时序;
因此一些无耦合的 umd 资源,可以选择以上两种方式进行并行下载资源,来提前完成页面所有资源的加载。
其次是 preload/prefetch:
- preload:提供了一种声明式的命令,让浏览器提前加载当前页面需要用到的资源,需要执行时再执行;
- prefetch:利用浏览器的空闲时间加载将来可能用到的资源的一种机制,通常指的是下一个跳转页面的资源,以便加快后续页面的打开速度;
dns-prefetch:
DNS-prefetch (DNS 预获取) 是尝试在请求资源之前解析域名。被解析的域名可以是后面要加载的文件的域名,也可以是用户尝试打开的链接的域名。
当浏览器从(第三方)服务器请求资源时,必须先将该跨域域名解析为 IP 地址,然后浏览器才能发出请求。此过程称为 DNS 解析。DNS 缓存可以帮助减少此延迟,而 DNS 解析可以导致请求增加明显的延迟,对于本地电脑中没有 DNS 缓存的用户,预解析可能能节省 80-120ms 的延迟。
前端数据持久化:
前端数据持久化是一个比较容易被忽视的环节,最常见的优化方式是在统一网络请求层将数据缓存在浏览器提供的缓存中。
通常我们不能保证所有的接口的 RT 都是低的,那么针对一些场景下数据是不常变化的,比如:用户信息这一类,或者即使有变动,在拿到最新数据前,先用上一次的数据进行临时展示也无妨的场景,比如:邮件列表;
此时就可以在统一的网络请求库里面进行封装掉这个缓存逻辑:
- 每次请求的 callback 先返回缓存数据进行页面渲染;
- 当请求到新的数据时,再次 callback,刷新当前页面的数据;
当然这种方式也存在一定的弊端:
- 某些数据是随着当前使用者不同而不同的,那么缓存的中的索引键就需要根据用户 id 来进行生成;
- 同时为了防止数据泄露在浏览器缓存中,当用户登出、登录失效时,需要进行本地缓存的清除;
除了在业务代码层面进行数据的缓存,还有一种更加激进的方式,使用 ServiceWorker 统一拦截掉所有的请求进行选择性缓存,ServiceWorker 不但能拦截缓存 FETCH/XHR 这种请求,对于 DOC、JS、CSS 一样能够进行缓存,做到离线资源缓存的能力。
3、后端层面:
说完了前端优化,接下来就来到了后端;前后端的交接点就是页面的 HTML,这里需要注意的是:
如果你的业务可以用静态 HTML:
- 如果你的 HTML 中不需要动态渲染一些数据,最好的方式是将 HTML 放在边缘 CDN 上,CDN 节点相比于直接使用 Server,距离用户更近,网络延迟更小;
- 如果没有条件用到 CDN,必须要用 Server,那么比较好的选型是 Nginx,速度快,稳定性高;
但如果你的业务有动态 HTML 的需求:
- 尽量减少动态服务依赖:除了登录校验、权限校验、用户信息这种影响到首屏渲染的核心数据,尽量有由前端通过 Ajax 接口来交互异步实现;
4、部署运维层面:
最后一个环节就是对于前端资源部署运维方面可以做的事情;
CDN 与 缓存:
「后端层面」中,提到了静态资源可以使用 CDN 进行加速,当然不仅 HTML,所有的前端资源 js、css、img 都可以缓存到 CDN 上;当用户访问时,会选择最近的 CDN 节点,进行数据返回;同时 CDN 的服务商会提供缓存服务,当 CDN 节点存储的数据未过期时,会直接响应用户 HTTP 请求,这种情况下效率是最高的。如果过期,才会向站源(Web 服务或者 OSS 对象存储服务)发出回源请求来拉去最新的数据;
HTTP 缓存:
除了 CDN 层面的缓存,对于发起的请求,浏览器同样提供一套多样的缓存策略。
当发起请求前,浏览器会首先判断请求的 URL 有没有命中缓存,如果没有缓存才会真正的发起请求,进行后续比如去 CDN 取数据等流程。
因此一些带有版本号形式的静态资源、或者拥有唯一编码链接的静态资源(比如图片等)这类 URL 地址与内容永远保持一对一类型的资源,都可以在请求响应时设置 response header 中的 cache-control;
在使用 Nginx 或 Java/Node Web 服务器时,对于资源请求,提供 API 来自定义 response header 来设置缓存策略及缓存时间;
同样如果使用阿里云的 OSS 作为文件存储服务,在上传文件时,也可以通过 API 来设置所上传的对象被请求时的 cache-control 方式。
四、结束语
性能优化是一个非常广泛的话题,从影响面上看,它不仅会直接影响到用户的使用体验,更会间接影响到网站的业务数据。而从优化手段上讲,页面性能也不再只是前端的问题,还跟 后端部署、运维、网络之类的因素都有着不可分离的关系。
作为前端开发者,一方面不能理想的认为从前端层面做好优化,就能解决的大部分问题,另一方面,也不要把性能的提升完全寄托于框架、架构设计者,从最基本的写好一行行代码才是最重要的优化,这也是为何文中并没有从代码细节设计角度去讲如何进行前端性能优化的原因。