Web前端性能优化原理与方法
一、概述
1.1 性能对业务的影响
大部分网站的作用是:产品信息载体、用户交互工具或商品流通渠道。这就要求网站与更多用户建立联系,同时还要保持良好的用户黏性,所以网站就不能只关注自我表达,而不顾及用户是否喜欢。看看网站性能会造成哪些方面的影响:
- 用户留存:Google 营销平台曾指出,如果网站加载时间超过 3 秒,就会有 53% 的移动网站访问遭到用户抛弃;BBC 发现网站加载时长每增加 1 秒,就会有 10% 的用户流失;影响用户留存的因素不止性能这一方面,优化网站性能却是一项保证用户留存率的必要措施。
- 网站转化率:根据著名电子商务优化平台 Mobify 的调研,发现商品结账页面加载时间每减少 100 毫秒,商品购买访问的转化率就会增加 1.55%,这个比率对大型电商网站来讲,其所带来的年均收入增长将会是上千万元;根据 Google 营销平台的统计,得出加载时间在 5 秒内的网站会比 20 秒内的网站广告收入多一倍,目前大部分互联网都在实施精准化的广告营销(根据导流后产生的用户交易数据计算广告费用),网站性能不仅影响用户体验,还影响广告主以及广告商的经济利益。
- 体验与传播:用户浏览网站通常根据流量数据的字节数进行收费,从2G、3G再到4G及5G,运营商收取的流量费用单价一直在下降,网站页面所承载的内容却更加丰富以及多元化。在这样的发展趋势下,如果网站包含的资源文件过大或者冗余,用户会浪费过多的网络资费,同时过大的资源传输量也会延长请求响应时间,最终损害用户体验。性能问题引发的用户体验差,会被用户差评甚至失去这个用户,更坏的情况是因拒绝向周边朋友介绍该网站,失去更多潜在用户的可能性。
1.2 性能评估模型
Google Chrome 团队于 2015 年提出一种用于提升浏览器内的用户体验和性能的 RAIL 模型(Response、Animation、Idle 和 Load 的首字母缩写,如图1-1所示),该模型的理念是不是单纯追求技术指标(加载速度、FPS等),而是通过优化用户的实际体验(点击响应、动画流畅度等)来提升满意度。即使网站在高性能设备上运行很快,如果用户感知到卡顿或延迟,仍然会被定义为体验糟糕。
- 响应:快速确认并反馈用户的交互(比如鼠标悬停或按下按钮)非常重要,最好在 50 毫秒内完成,最长不要超过 100 毫秒。反应时间超过 100 毫秒会导致用户交互和响应之间脱节,如果响应需要超过 100 毫秒才能完成,需要提供某种形式的反馈(比如倒计时或进度条)通知用户交互已发生。
- 动画:任何低于 60fps 的动画(滚动、拖动及其他动效),尤其是不均匀或变化的帧速率,都会使页面显得卡顿。为了保证动画流畅性和视觉连续性,内容重绘应以 60fps 的速度进行,即每 16.7 毫秒一次(包括脚本执行、重排和重绘)。浏览器渲染一帧大约需要 6 毫秒,因此尽量在 10 毫秒内生成动画的每一帧。
- 空闲:浏览器是单线程的(Web Worker可以支持后台线程),这意味着用户交互、绘制和脚本执行都在同一个线程上。如果线程忙于执行复杂的 JavaScript 代码,主线程将无法对用户输入(例如按下按钮)做出反应。因此脚本要划分为可以在 50 毫秒或更短时间内执行的代码块,使得线程可以及时响应用户交互产生的任务。
- 加载:在 1 秒内向用户反馈网站请求已发出并将加载(显示页面标题以及背景色等),否则用户注意力会游离,焦点很容易离开网站。很少有网站能在 1 秒内完成加载,最好根据设备、网络等条件制定目标:中配 3G 网络手机加载网站不超过 5 秒,办公室 T1 线路加载网站不超过 1.5 秒,并以更快的速度加载后续页面。
二、前端页面生命周期
经典面试题:从浏览器地址栏输入URL后,到页面渲染出来,整个过程都发生了什么?
2.1 网络请求
2.1.1 DNS解析
DNS(Domain Name System)解析是将域名转换为 IP 地址的过程(如图 2-1 所示),其解析速度直接影响用户的网络体验:如果解析速度较慢,用户会感受到网站加载延迟,看起来“卡”一下。
提高 DNS 解析速度可以采取以下措施:
- dns-prefetch:在网页头部使用
<link>
标签指定浏览器预解析可能会被访问的域名,当用户点击链接时目标网址可能已被解析,减少用户等待时间。
<link rel="dns-prefetch" href="//dss0.bdstatic.com">
- 简化 DNS 记录:尽可能使用 A 记录(对于 IPv6 使用 AAAA 记录)直接指向 IP 地址,减少使用 CNAME 记录(CNAME 记录将一个域名解析到另一个域名),避免额外的 DNS 查询发生;定期审查和清理无用或过时的 DNS 记录,避免解析过程中非必要的查询。
➜ ~ dig A www.baidu.com;; QUESTION SECTION:
;www.baidu.com. IN A;; ANSWER SECTION:
www.baidu.com. 1200 IN CNAME www.a.shifen.com.
www.a.shifen.com. 110 IN A 220.181.111.1
www.a.shifen.com. 110 IN A 220.181.111.232
- 基建设施:选择可靠的 DNS 服务提供商,启用 DNS 缓存,定期监控 DNS 解析性能。
2.1.2 网络模型
获取到目标服务器 IP 地址后,就可以建立网络连接进行资源的请求与响应。国际标准化组织提出了 OSI 网络架构模型(Open System Interconnect,即开放式系统互连),将网络从物理层(网络设备底层)到应用层(浏览器)共划分 7 层。
OSI 层级 | 具体作用 |
应用层 | 负责给应用程序提供接口,使其可以使用网络服务,HTTP协议就位于该层。 |
表示层 | 负责数据的编码与解码、加密和解密、压缩和解压缩。 |
会话层 | 负责协调系统之间的通信过程。 |
传输层 | 负责端到端连接的建立,使报文能在端到端之间进行传输,TCP/UDP协议位于该层。 |
网络层 | 为网络设备提供逻辑地址,使位于不同地理位置的主机之间拥有可访问的连接和路径。 |
数据链路层 | 在不可靠的物理链路上,提供可靠的数据传输服务。包括组帧、物理编址、流量控制、差错控制、接入控制等。 |
物理层 | 定义网络的物理拓扑、物理设备的标准(如介质传输速率、网线或光纤的接口模型等)、比特的表示以及信号的传输模式。 |
2.2 浏览器关键渲染路径
浏览器页面的渲染是一个复杂且涉及多个步骤的过程(如图 2-1 所示),
- 解析HTML:浏览器读取 HTML 文件(从服务器或本地读取文件),然后将 HTML 标签解析为 DOM(文档对象模型)。
- 解析CSS:将外部 CSS 文件、HTML 内部 CSS 以及元素内联的样式数据解析为 CSSOM(CSS对象模型),定义页面中所有元素的样式。
- 合成渲染树:DOM 和 CSSOM 会结合生成渲染树,每个节点都对应一个
RenderObject
(保存绘制 DOM 节点所需要的各种信息);对于不可见元素,例如<script>
、<meta>
或者 display 为 none 的元素,则不会在渲染树中出现。 - 布局(Layout):布局是指渲染树创建完成后,浏览器计算每个节点的确切位置和大小的过程。布局必须考虑视口大小、元素的尺寸、盒模型等多种因素。
- 绘制(Paint):绘制是指将布局的结果输出到屏幕的过程。通常绘制会分为多个
RenderLayer
(图层)进行处理,这一步涉及填充像素、绘制文本、颜色、图片、边框和阴影等。图层存在的目的是让页面元素以正确的顺序组合,从而正确显示重叠内容、半透明元素等。如果RenderObject
满足以下条件之一,创建对应的图层,否则使用其祖先(第一个具有图层的祖先)的图层:
-
- 有明确 CSS 定位信息(relative、absolute)或者 transform 属性的节点
- 透明节点
- 有 overflow、mask-image 或者 box-reflect 属性的节点
- 有 filter 属性的节点
webgl
或者有硬件加速 2D 上下文的canvas
<video>
- 合成(Composite):获得每个图层的信息后,需要将其合并到同一个图像上,这个过程就是合成。软件渲染是按照从前到后的顺序在同一个内存空间完成每一层的绘制,实际上不需要合成。在现代浏览器中(尤其是移动端设备),使用 GPU 完成的硬件加速绘图更为常见,硬件加速绘图需要合成(合成都是使用 GPU 完成的),整个过程称之为硬件加速的合成化渲染(如图 2-2 所示),相关原理可以参考:GPU Accelerated Compositing in Chrome。对于常见的 2D 绘图操作,例如绘制文字、点、线等,使用 GPU 来绘图不一定比 CPU 在性能上有优势,原因是 CPU 使用缓存机制可以有效减少重复绘制的开销而不需要 GPU 并行性。为了节省 GPU 的显存资源,浏览器会按照一定的规则将一些图层组合在一起,形成一个有后端存储的新层,称之为
GraphicsLayer
(合成层),用于之后的合成。使用 Chrome 的 DevTools 可以方便查看页面的合成层以及创建原因(如图 2-3 所示)。如果一个图层具有以下特征之一,创建对应的合成层,否则使用其祖先(第一个具有合成层的祖先)的合成层:
-
- 使用 3D 或者透视变换的 CSS 属性
- 使用硬件加速视频解码技术的
<video>
- 使用
webgl
或者有硬件加速 2D 上下文的canvas
- 有 opacity 或者transform 属性变化的动画
- 有硬件加速 filter 属性的节点
- 后代包含一个合成层
- 有一个
z-index
比自身小的兄弟节点且这个兄弟节点为合成层
三、性能测量
3.1 Performance面板
Chrome Performance面板可以对网站运行时的性能表现进行检测与分析,推荐在无痕模式下使用,避免既有缓存和Chrome上安装的插件影响性能分析结果,如图3-1所示。
性能报告会对主线程相关活动进行记录(Main区域),由于图表长得像一团团倒立的火焰,也被称为火焰图。火焰图的横轴代表时间,纵轴代表调用堆栈,如图 3-2 所示。最上方的灰色长条表示一个由浏览器调度和执行的任务,其长度表示这个任务执行时间的跨度。有的任务部分被红色密集实线覆盖,同时右上角还有一个红色的三角形,用作标识长任务(超过 50 毫秒的部分会用这种红色密集实线覆盖)。长任务会阻塞主线程,导致页面卡顿、无法及时响应用户输入等,是需要开发者重点关注的对象。
性能报告中的核心指标,可以衡量页面加载以及渲染的性能状况。
指标 | 定义 |
DCL(DOMContentLoaded) | DOM解析完毕 |
FCP(First Contentful Paint) | 首次内容渲染时间(内容可以是文本、图片、Canvas) |
LCP(Largest Contentful Paint) | 最大内容渲染时间 |
L(Load) | 页面依赖的所有资源加载完毕 |
3.2 Lighthouse
Lighthouse可以监控和检测网站各方面的性能表现,为开发者提供用户体验和网站性能优化的指导建议。在 Chrome应用商店搜索 Lighthouse 扩展程序并进行安装,如图 3-3 所示。
Lighthouse会对性能、可访问性(对于残障用户的可用性)、最佳实践(常见安全和性能的最佳实践)和搜索引擎优化这 4 个维度进行评估得分,同时附带相关优化建议,分数越高说明该维度表现越好,如图 3-4 所示。
四、性能优化策略
4.1 构建优化
页面在渲染之前需要请求很多资源文件,如:HTML文件、CSS文件、JavaScript文件及图片等其他资源,如何更快速地请求到资源是一个非常值得关注的优化点。主要有两个思路:一方面是减少 HTTP 请求资源的大小,另一方面是减少 HTTP 的请求数量。本节以主流构建工具 Webpack 为例进行讲解。
- 压缩与合并:
文件类型 | 优化方向 | 目的 | 方案 |
HTML | 压缩 | 删除格式化字符以及注释 | 使用 |
CSS | 压缩 | 删除格式化字符以及注释、语义合并(合并选择器、简写属性值等) | 使用 |
合并 | 将 CSS 提取到一个文件中 | 使用 | |
JavaScript | 压缩混淆 | 删除格式化字符及注释、混淆变量与方法命名; | 使用 |
合并 | 多个文件打成一个 | 支持根据入口分析文件依赖 |
- 可视化分析:合并和压缩之后,如果还是觉得性能不佳,又不知道哪里出了问题,可以使用
webpack-bundle-analyzer
对打包结果进行可视化分析,如图 4-1 所示。开发人员可以快速通过直观的界面了解构建产物的组成部分,以及每个模块的大小、占比和依赖关系。识别出那些占用空间较大的模块,并采取相应的优化措施。
4.2 加载优化
4.2.1 CSS/JavaScript
浏览器向网络请求到的所有数据,并非每个字节都具有相同的优先级或重要性。所以浏览器会对所要加载的内容先进行推测,将相对重要的信息优先呈现给用户,比如:一般会先加载 CSS 文件,然后再加载 JavaScript 文件和图片。使用 Chrome 的 DevTools可以看到分配给不同资源的优先级(如图 4-2 所示),分为Highest、High、Medium、Low、Lowest这 5 个等级。
根据浏览器为资源分配下载优先级的方式,可以针对实际业务场景适当做一些调整:
- 调整顺序:根据期望的资源下载顺序放置资源标签,例如
<script>
和<link>
,具有相同优先级的资源通常按照被放置的顺序加载。 - 异步加载:
<script>
下载和执行会阻塞 HTML 解析,建议使用async
或defer
下载首屏不需要阻塞的 JavaScript 文件;如图 4-3 所示,async
完成下载后会立即执行,而defer
会在HTML解析完成之后再执行。 - 预加载:使用
<link rel="preload">
提前下载必要的资源,比如自定义字体、关键图片及样式文件等,避免页面出现 FOUC 现象(Flash of Unstyled Text:无样式内容闪烁)。
<link rel="preload" as="font" crossorigin href="https://at.alicdn.com/t/font_zck90zmlh7hf47vi.woff">
4.2.2 图片
当用户在浏览一个内容丰富的网站时,由于屏幕尺寸的限制,每次只能查看到视窗中的那部分内容,滚动页面后屏幕视窗才会依次展示出全部内容。为了提高首屏内容的加载速度,短时间内让用户对网站产生兴趣,必须对非首屏之外的图片做懒加载处理,这个优化策略在业界已经被广泛使用。
- 原生支持懒加载:从 Chrome 75 版本开始,
<img>
可以通过loading
原生支持懒加载;
<img src="https://images.pexels.com/photos/23719800/pexels-photo-23719800.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2" loading="lazy" alt="示例图片">
- 自定义懒加载:更好的做法是图片即将滚动出现在屏幕视窗之前一段距离,就提前加载图片。否则,如果存在网络延迟或图片资源过大,用户会看到占位图以及目标图片加载的全过程。可以使用
IntersectionObserver
对懒加载进行更细粒度的控制。
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Document</title><style>*{margin: 0;padding: 0;box-sizing: border-box;}ul{list-style: none;}.box{width: 80%;margin: 0 auto;display: flex;flex-wrap: wrap;}.item{width: calc( 33.33% - 20px );margin: 10px;min-height: 200px;}.item img{width: 100%;}</style>
</head>
<body><div class="box"></div>
</body>
<script>// 定义默认占位图const defaultURL = 'https://www.baidu.com/img/PCtm_d9c8750bed0b3c7d089fa7d55720d6cf.png';// 定义图片网络地址let imgList = ['https://images.pexels.com/photos/23719800/pexels-photo-23719800.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2','https://images.pexels.com/photos/20569931/pexels-photo-20569931.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2','https://images.pexels.com/photos/23105903/pexels-photo-23105903.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2','https://images.pexels.com/photos/22816073/pexels-photo-22816073.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2','https://images.pexels.com/photos/18197764/pexels-photo-18197764.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2','https://images.pexels.com/photos/24031902/pexels-photo-24031902.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2','https://images.pexels.com/photos/23914518/pexels-photo-23914518.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2','https://images.pexels.com/photos/16118941/pexels-photo-16118941.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2','https://images.pexels.com/photos/22937531/pexels-photo-22937531.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2','https://images.pexels.com/photos/21915597/pexels-photo-21915597.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2','https://images.pexels.com/photos/16703290/pexels-photo-16703290.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2','https://images.pexels.com/photos/13298586/pexels-photo-13298586.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2','https://images.pexels.com/photos/18109714/pexels-photo-18109714.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2','https://images.pexels.com/photos/22670156/pexels-photo-22670156.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2','https://images.pexels.com/photos/21852583/pexels-photo-21852583.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2','https://images.pexels.com/photos/21367366/pexels-photo-21367366.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2','https://images.pexels.com/photos/17102067/pexels-photo-17102067.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2','https://images.pexels.com/photos/24038436/pexels-photo-24038436.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2','https://images.pexels.com/photos/19473669/pexels-photo-19473669.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2','https://images.pexels.com/photos/20470948/pexels-photo-20470948.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2','https://images.pexels.com/photos/22469105/pexels-photo-22469105.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2','https://images.pexels.com/photos/13743557/pexels-photo-13743557.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2','https://images.pexels.com/photos/23230661/pexels-photo-23230661.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2',];// 定义图片列表容器let box = document.querySelector('.box');// 工具函数: 定义渲染页面结构function initImg(list){for( let i = 0; i < list.length; i++ ){let div = document.createElement('div');div.className = 'item';let img = document.createElement('img');img.src = defaultURL;img.dataset.url= list[i];div.appendChild(img);box.appendChild(div);}}// 工具函数: 懒加载图片function Observer(list){let observer = new IntersectionObserver(function(entries, self){for( let n = 0; n < entries.length; n++ ){if( entries[n].isIntersecting && entries[n].target.children[0].src.includes( "www.baidu.com" ) ){entries[n].target.children[0].src = entries[n].target.children[0].dataset.url;self.unobserve(entries[n].target);}}}, {rootMargin: '50px 0px', // 视图范围扩大 50px 触发threshold: 0.3 // 触发阈值设置为 30%})for( let i = 0; i < list.length; i++ ){observer.observe(list[i])}}// 初始化图片列表initImg(imgList); // 待页面加载完毕再进行图片懒加载window.onload = function() {let items = document.querySelectorAll('.item');Observer(items);}
</script>
</html>
4.2.3 视频
视频资源体积一般比较大,网络请求相对耗时。要防止提前加载过多视频资源阻塞页面渲染,避免对页面性能造成负面影响。
- 阻止视频预加载:将
preload
设置为 none 可以阻止视频自动预加载,尽早触发load
事件。
<video preload="none" width="600" height="400" controls><source src="http://runoob.com/try/demo_source/movie.mp4" type="video/mp4">
</video>
- 替代GIF动画:GIF动画在输出文件大小、图像色彩质量等许多方面的表现均不如视频,建议将视频代替尺寸过大的GIF动画。HTTP1.1协议规定同一域名下的最大连接数为6,如果资源请求连接超上限,多余连接会被挂起,必须控制首屏视频加载的数量。
<video width="600" height="400" autoplay muted loop playsinline><source src="http://runoob.com/try/demo_source/movie.mp4" type="video/mp4">
</video>
4.3 渲染优化
4.3.1 计算样式优化
构建渲染树时,对于每个 DOM 元素,必须在所有样式规则中查询符合的选择器,并将对应的规则进行合并。渲染树构建完成后,要经历一个布局的过程,即为每个节点提供其在屏幕上应出现的准确坐标,然后遍历渲染树并在页面绘制每个节点。
- 减少计算样式的元素数量:为了提高查询效率,浏览器使用
逆向匹配
原则(从右向左)来判断某个选择器是否匹配当前 DOM 元素,这与我们通常从左向右的书写习惯相反。
-
- 使用
类选择器
替代标签选择器
:减少从整个页面中查找标签元素的范围;
- 使用
/* 错误 */
.product-list li{}/* 正确 */
.product-list_li{}
-
- 避免使用通配符做选择器:使用通配符清除默认样式是一个很差的习惯,会导致计算开销大;
/* 错误 */
* {margin:0;padding:0;border:0;font-size: 12px;vertical-align: baseline;
}
- 降低选择器的复杂性:项目在长期迭代和维护的过程中,系统复杂性逐步变高,样式规则也会不断被扩展。
-
- 对确定元素使用单一的
类选择器
:
- 对确定元素使用单一的
/* 错误 */
.container:nth-last-child(1).content {}/* 正确 */
.final-container-content {}
-
- 推荐使用
BEM规范
对选择器进行命名:保证所有元素都被单一的类选择器
修饰;
- 推荐使用
/* 常规写法 */
.my-list {}
.my-list .item {}/* BEM写法 */
.my-list__item_big {} /* 大尺寸 */
.my-list__item_normal {} /* 中尺寸 */
.my-list__item_small {} /* 小尺寸 */
.my-list__item_size-10 {} /* 自定义 */
4.3.2 JavaScript执行优化
4.3.2.1 数据读取
- 警惕作用域链过长:变量位于作用域链中的位置越深,被 JavaScript 引擎访问到所需的时间就越长,所以要留心对作用域链的管理。
-
- 如果一个非局部变量在函数中的使用次数不止一次,最好使用局部变量进行存储,以提升其在作用域链中的查找顺序。
with
可以提供一种读取对象属性的快捷方式,会在运行时创建新的作用域。在实际开发中却不推荐:with
的动态性导致 JavaScript 引擎无法在编译时对作用域查找进行优化,代码运行变慢、性能下降;另外,在严格模式下也被禁止使用。
/** 错误 **/
function process() {const target = document.getElementById('target');const imgs = document.getElementsByClassName('img');for (let i = 0; i < imgs.length; i++) {const img = imgs[i];// 省略相关处理流程...target.appendChild(img);}
}/** 正确 **/
function process() {const doc = document; // 全局变量 document 在函数中不止使用一次: 声明为局部变量提升其在作用域链中的查找顺序const target = doc.getElementById('target');const imgs = doc.getElementsByClassName('img');const len = imgs.length; // 在 for 循环 length 属性被读取多次:从读取速度来看变量要快于对象属性for (let i = 0; i < len; i++) {const img = imgs[i];// 省略相关处理流程...target.appendChild(img);}
}
- 合理使用闭包:闭包指的是有权访问另一个函数作用域中的变量的函数,可以被理解为定义在一个函数内部的函数,内部函数可以访问到外部函数的局部变量。闭包是 JavaScript 中的一个强大特性,常用于创建私有变量和封装功能。函数执行完成后,其中局部变量所占用的空间会被释放。如果闭包本身被持续引用,就会延长外部函数中局部变量的生命周期,带来更大的内存开销及甚至内存泄漏。出于对性能的考虑,当闭包不再被需要时,将其赋值为
null
,从而确保其内存可以在适当的时机回收。
function add () {var count = 0;return function fn() {count++;console.log(count);}
}var a = add(); // 产生闭包
a(); // 1
a(); // 2
a = null; // 解除 a 与 fn 的联系: 浏览器可以回收相应内存空间
4.3.2.2 流程控制
- 条件判断:通常对于多个离散值的取值条件判断,使用
switch
会比if-else
具有更高的性能表现。关于if-else
有两种优化思路:
-
- 思路一:预估条件被匹配到的概率,按照概率降序来排列
if-else
语句,让匹配概率高的条件更快执行,在整体上降低程序花费在条件判断上的时间。 - 思路二:利用二分法的思想,当不能预估条件被匹配到的概率时,对取值区间的边界进行明确划分,降低匹配条件的执行次数;缺点是代码可读性稍低。
- 思路一:预估条件被匹配到的概率,按照概率降序来排列
/** 优化思路一:按概率降序排列 **/
if (value === 8) {// 匹配到8的概率最高
} else if (value === 7) {// 匹配到7的概率仅次于8
} else if (value === 6) {// 匹配到6的概最低
} else {// 不需要对6之外的条件进行判断
}/** 优化思路二:二分法 **/
if (value < 4) {if (value < 2) {// 值在2到下界之间取值} else {// 值在2或3之间}
} else {if (value < 6) {//值在4或5之间} else {//值在6到上界之间取值}
}
- 循环:一个循环语句的执行次数直接影响程序的时间复杂度,对程序执行性能的影响很大。如果代码中存在缺陷导致循环不能及时停止,从而造成死循环,给用户带来非常糟糕的使用体验。当循环次数非常多时,
for
的执行性能要明显强于for-in
、for-of
以及forEach
。 - 递归:递归是一种通过空间换时间的算法,内存的开销将与递归次数成正比。浏览器会限制 JavaScript 调用栈的大小,超出限制递归执行便会失败。以构造斐波那契数列为例,递归有两种优化思路,避免重复计算显著提升执行性能,且防止 JavaScript 调用栈溢出:
-
- 思路一:改用迭代方式
- 思路二:用数组对中间结果做存储(性能与迭代方式接近)
/** 不推荐:递归方式 **/
function fibonacci_Recursive(n) {count++;if (n <= 0) {return 0;} else if (n === 1) {return 1;} else {return fibonacci_Recursive(n - 1) + fibonacci_Recursive(n - 2);}
}/** 优化思路一:迭代方式 **/
function fibonacci_Iteration(n) {count++;if (n <= 0) {return 0;} else if (n === 1) {return 1;} else {let a = 0;let b = 1;let temp;for (let i = 2; i <= n; i++) {temp = a + b;a = b;b = temp;}return b;}
}/** 优化思路二:数组存储 **/
function fibonacci_Array(n) {count++;let fib = [0, 1];for (let i = 2; i <= n; i++) {fib[i] = fib[i - 1] + fib[i - 2];}return fib[n];
}
4.3.2.3 字符串处理
处理大量数据或复杂匹配模式时,草率地编写正则表达式有可能因为回溯
失控导致性能问题发生。回溯
是正则表达式引擎在尝试匹配字符串时使用的一种机制,引擎会从左到右逐步检查正则表达式的每个组件是否与字符串的相应部分匹配。如果在某个点上,后续的字符串无法匹配当前正则表达式的组件,引擎则会退回到之前的匹配点,并尝试其他可能的匹配选项。这种机制允许正则表达式处理复杂的情况,如可选元素、重复元素和条件分支等。优化正则表达式并减少不必要的回溯,可以考虑以下方法:
- 使用非捕获组:
前瞻断言
可以检查某个模式之后是否紧跟着指定的模式,而不需要引擎回溯到先前的位置;后顾断言
(ES2018中新增的功能)也同理,检查左侧是否为特定模式的文本。
let str = 'JackSprat';/** 正向前瞻断言 **/
str.match(/Jack(?=Sprat)/); // 匹配后面紧跟'Sprat'的'Jack'/** 负向前瞻断言 **/
str.match(/Jack(?!Sprat)/); // 匹配后面不紧跟'Sprat'的'Jack'/** 正向后顾断言 **/
str.match(/(?<=Jack)Sprat/); // 匹配前面有'Jack'的'Sprat'/** 负向后顾断言 **/
str.match(/(?<!Jack)Sprat/); // 匹配前面没有'Jack'的'Sprat'
- 避免嵌套重复:当字符串只有部分符合模式时,为了找到正确的匹配,引擎会不断尝试所有可能的组合,这可能导致极端的性能问题,甚至使应用程序冻结或崩溃。
let str = '11ab1111111111111111111';/** 错误 **/
str.match(/a(.+)+b/); // 耗时: 41.423 ms/** 正确 **/
str.match(/a(.+)b/); // 耗时: 0.065 ms
- 避免过于宽泛的匹配:在匹配大量文本时,避免使用如 .* 或 .+ 这类可能导致大量
回溯
的模式,应该使用更具体的字符集,对可能的匹配进行严格界定。
4.3.2.4 处理大量计算
现代浏览器架构使用沙箱模式
管理,通常为每个标签页单独分配一个渲染进程。每个渲染进程都有 JavaScript 引擎线程和 GUI 渲染线程,由于JavaScript 可以操纵 DOM 元素,为了防止渲染出现不可预期的结果,浏览器将 JavaScript 引擎线程和 GUI 渲染线程设置为互斥的关系, 当 JavaScript 引擎执行时 GUI 渲染线程会被挂起。假设 JavaScript 引擎正在进行大量计算,此时就算 GUI 有更新,也会被保存到队列中,等待 JavaScript 引擎空闲后执行。如果 JavaScript 运行时间过长,必然就会阻塞页面渲染更新,造成页面丢帧、渲染不连贯甚至严重卡顿。有两个解决思路:
- 创建 Web Worker 子线程:如果有非常耗时的工作(大量计算),可以在 Web Worker 子线程进行处理,通过
postMessage
与JavaScript 引擎线程进行消息通信(传输序列化对象)。在 Web Worker 子线程无法操作 DOM,并要求执行的代码文件与 JavaScript 引擎线程的代码文件同源。
const myWorker = new Worker('./worker.js');myWorker.onmessage = function (e) {console.log('Fibonacci result:', e.data)}myWorker.postMessage(40); // 请求计算斐波那契数列第 40 项: 102334155
self.onmessage = function (e) {const n = e.data;let a = 0, b = 1, temp;for (let i = 2; i <= n; i++) {temp = a;a = b;b = temp + b;}self.postMessage(b);
}
- 将大型任务拆分为多个小任务:如果要处理的任务必须在 JavaScript 引擎线程上完成,可以考虑将一个大型任务拆分为多个小任务,尽量让每个小任务处理的耗时在几毫秒之内,避免因执行任务导致页面不能刷新以及无法响应用户的交互操作。
Fiber
算法就使用了类似的设计思想:将reconciler
过程拆分成多个小任务,并在小任务执行后暂停执行代码,检查页面是否有需要更新的内容和响应的事件,做出相应处理后再继续执行代码,解决了React
在动画和复杂更新中的性能瓶颈,显著增强大型应用的性能和使用体验。
-
- 方法一:
requestAnimationFrame
会在下一帧重绘之前执行回调函数,回调函数的执行频率通常与显示器的刷新率相匹配。 - 方法二:
requestIdleCallback
会在当前帧空闲时执行回调函数,若当前帧没有足够的空闲时间,等待时间已超时也会立即执行回调函数;特别适合处理对时间要求不严格的低优先级任务,为了防止无限等待,强烈建议设置等待时间。
- 方法一:
// 定义100个事件的队列: 每个事件可设置限时
const todoList = [];
for (let i = 1; i <= 100; i++) {const fn = (function (index) {return mes => {console.log(`执行任务${index}: ${mes}`);};})(i);todoList.push({fn,timeout: 20, //ms});
}/** 方法一:requestAnimationFrame **/
let frameIndex = 0;
function lazyQueueRAF(queue, onComplete = () => {}) {const iterator = queue[Symbol.iterator]();let task = iterator.next();function execTask(DOMHighResTimeStamp) {frameIndex++;let taskStartTime = window.performance.now();let taskFinishTime;do {const { fn } = task.value;if (typeof fn === 'function') {fn(`第 ${frameIndex} 帧 上一帧渲染的结束时间为 ${DOMHighResTimeStamp}`);}task = iterator.next();taskFinishTime = window.performance.now();} while (taskFinishTime - taskStartTime < 3 && !task.done);if (!task.done) {requestAnimationFrame(execTask);} else {onComplete();}}requestAnimationFrame(execTask);
}
const start_RAF = Date.now();
lazyQueueRAF(todoList, () => {console.log(`所有任务完成了: 耗时${Date.now() - start_RAF}ms`);
});/** 方法二:requestIdleCallback **/
function lazyQueueRIC(queue, onComplete = () => {}) {const iterator = queue[Symbol.iterator]();let task = iterator.next();function execTask(deadline) {// 利用空闲时间执行while (deadline.timeRemaining() && !task.done) {const { fn } = task.value;if (typeof fn === 'function') {fn('空闲');}task = iterator.next();}// 超过等待时间立即执行if (deadline?.didTimeout && !task.done) {const { fn } = task.value;if (typeof fn === 'function') {fn('超时');}task = iterator.next();}if (!task.done) {const { timeout } = task.value;requestIdleCallback(execTask, {timeout,});} else {onComplete();}}requestIdleCallback(execTask, {timeout: task.value.timeout,});
}
const start_RIC = Date.now();
lazyQueueRIC(todoList, () => {console.log(`所有任务完成了: 耗时${Date.now() - start_RIC}ms`);
});
4.3.2.5 事件节流和防抖
当用户发生交互的过程中,势必会有鼠标移动、键盘输入、页面滚动及缩放的操作频繁发生,导致相应 DOM 事件的回调函数被大量计算,进而引发页面抖动甚至卡顿。采用事件节流和防抖技术,可以稀释回调函数的执行频率,更快对用户的交互进行响应、避免回调函数在短时间内高频执行引发的各种性能问题。
概念 | 定义 | 适用场景 |
节流 | 在单位时间内触发多次函数,只有一次能生效。 | 高频率事件触发:页面滚动、鼠标移动等。 |
防抖 | 当事件触发结束后,延时一段时间再执行函数。如果延时期间再次触发该事件,则重新计算延时时间。 | 连续事件触发:搜索框输入、滚动加载更多等。 |
/** 节流 **/
function throttle(func, limit) {let inThrottle;return function() { const context = this; const args = arguments;if (!inThrottle) { func.apply(context, args); inThrottle = true; setTimeout(function() { inThrottle = false; }, limit); } };
}/** 防抖 **/
function debounce(func, wait) { let timeout; return function() { const context = this; const args = arguments; clearTimeout(timeout); timeout = setTimeout(function() { func.apply(context, args); }, wait); };
}
4.3.3 重排和重绘优化
在浏览器中,重排和重绘是两个与页面渲染相关的两个概念。重排是一种较为昂贵的操作,会导致页面性能下降。重绘的开销通常比重排小,但仍然会影响性能。需要注意的是,重绘不一定需要重排,重排必然导致重绘。
概念 | 定义 | 触发操作 |
重排 | 当渲染树的一部分必须更新并且节点的尺寸发生了变化,浏览器会使渲染树中受到影响的部分失效,并重新构造渲染树。 |
|
重绘 | 元素外观被改变所触发的浏览器行为,浏览器会根据元素的新属性重新绘制,使元素呈现新的外观。 |
|
- 使用类去修改样式:逐行修改元素样式是非常糟糕的编码方式,每行都会触发一次对渲染树的更改,会导致重新计算页面布局而带来巨大的性能开销。合理的做法是将多行的样式修改合并到一个 CSS 类名中,仅在 JavaScript 代码中添加或更改类名即可。
/** 错误 **/
const div = document.getElementById('mydiv');
div.style.height = '100px';
div.style.width = '100px';
div.style.border = '2px solid blue';/** 正确 **/
const div = document.getElementById('mydiv');
mydiv.classList.add('my-div');
.my-div {height: 100px;width: 100px;border: 2px solid blue;
}
- 缓存敏感属性的计算:有些场景需要通过多次计算来获得某个元素在页面中的布局位置,赋值环节以及读取敏感属性都会触发页面布局的重新计算,这样页面的性能会非常差。合理的做法是将敏感属性通过变量的形式缓存起来,等计算完成后再统一进行赋值。
/** 错误 **/
const list = document.getElementById('list');
for (let i = 0; i < 10; i++){list.style.top = `${list.offsetTop + 10}px`;list.style.left = `${list.offsetTop + 10}px`;
}/** 正确 **/
const list = document.getElementById('list');
// 将敏感属性缓存起来
let offsetTop = list.offsetTop
let offsetLeft = list.offsetLeft;
for (let i= 0; i < 10; i++) {offsetTop += 10;offsetleft += 10;
}
// 计算完成后统一赋值触发重排
list.style.left = offsetLeft;
list.style.top = offsetTop;
4.3.4 合成处理
合成处理是将已绘制的不同图层放在一起,最终在屏幕上渲染出来的过程。将固定区域和动画区域拆分到不同图层上进行绘制,可以实现绘制区域最小化,能够降低绘制复杂度。
创建新图层的方式可以参考见 2.2 小节,同时要控制创建图层的数量,否则会为浏览器带来过多的内存分配及管理开销,导致网站性能变得更差。
4.4 数据缓存
如果网站一直重复请求服务器获取相同的数据,不仅浪费网络带宽,页面内容更新也会延迟,从而影响用户的使用体验。如果用户使用按流量计费的方式访问网站,多余的请求还会造成网络流量资费增加。因此使用缓存技术对已获取的数据进行重用,是一种提升网站性能与体验的有效策略。缓存主要分两大类:共享缓存和私有缓存。
- 共享缓存:可以被多个用户访问的缓存,这类缓存通常位于网络中的代理服务器或者
CDN
节点上,主要目的是减少服务器负载、加速内容分发,减少从源服务器到用户之间的延迟。 - 私有缓存:只供单一用户使用的缓存,这类缓存通常存在于用户的设备中,主要目的是提高单个用户的数据访问速度和使用体验。
4.4.1 HTTP缓存
强制缓存和协商缓存都是 HTTP 协议中的缓存机制,用于提高网站的性能和响应速度,同时确保用户可以看到最新内容。
- 强制缓存:服务器会将缓存相关的头部信息(cache-control 优先级高于 expires)发送给浏览器,告诉浏览器在一定时间内可以从缓存中获取文件。当浏览器判断本地缓存未过期时,状态码返回 200 并直接读取本地缓存,无须向服务器发起 HTTP 请求。适用于不经常变动的静态资源(图片、CSS 及 JavaScript 文件等)。
- 协商缓存:如果没有命中强缓存,浏览器会发送请求到服务器,并携带首次请求返回有关缓存的头部信息(Etag/If-None-Match 优先级高于 Last-Modified/If-Modified-Since),服务器判断请求资源是否被修改。如果没有修改,状态码返回 304 并告知浏览器直接从缓存获取文件;反之,状态码返回 200 并响应最新的资源内容。适用于频繁变更的资源(HTML页面、接口动态数据等)。注意:如果 HTTP 响应头中 ETag 值有改变,不一定是文件内容有更改,也可能是文件最后修改时间有变化,比如编辑文件却未更改文件内容、修改文件最后修改时间等行为。
4.4.2 浏览器存储
网站的数据存储是一个常见需求,无论是偏好设置、应用状态,或是表单提交前用户填写的数据等,都可以在浏览器进行保存。浏览器提供了多种存储方案,每种方案都有其特性和适用场景,具体异同如下:
存储方式 | 存储限制 | 数据持久性 | 是否自动发送到服务器 | 实时性 | 支持的数据类型 | 同源策略 |
LocalStorage | 5-10 MB | 永久 | 否 | 同步 | 字符串 | 是 |
SessionStorage | 5-10 MB | 会话期间 | 否 | 同步 | 字符串 | 是 |
Cookies | 单条4 KB | 可配置 | 是 | 同步 | 字符串 | 是 |
IndexedDB | 不少于250MB(甚至没有上限) | 永久 | 否 | 异步 | 所有JavaScript类型 | 是 |
- LocalStorage:提供持久化的键值对存储机制,数据没有过期时间,除非被手动清除,否则将一直存在。
- SessionStorage:类似LocalStorage,数据仅在当前会话期间有效,关闭标签页或浏览器后数据将被清除。
- Cookies:数据会在每次 HTTP 请求中自动发送到服务器,主要存储用户身份凭据、会话状态等敏感数据。
- IndexedDB:提供在浏览器存储大量结构化数据的能力,支持高性能检索数据以及事务操作,在使用时要注意数据库版本升级以及错误的兼容处理。
4.4.3 CDN缓存
若想提升首次请求资源的响应速度,除了采用资源压缩、预加载等方式,还可以借助CDN(内容分发网络)缓存技术。CDN通过缓存站点的内容在全球各地的节点(或称为边缘服务器),使得用户的请求可以就近访问到数据,从而减少延迟和提高访问速度,图 4-4 对具体原理进行了解释。
当前 CDN上托管资源非常简便,根据性能、可靠性、覆盖地区、成本等维度选择 CDN 供应商(Amazon CloudFront、Google Cloud CDN、阿里云、百度云等)。通常做法如下:
- CI/CD集成:项目构建结束后通过 CDN 供应商提供的工具将需要缓存的资源上传到 Bucket(源站),经过充分测试后在主站更新 HTML 文件对相关资源的引用。
- 域名设置:主站域名要与 CDN 服务器域名要有区分,避免静态资源请求携带不必要的 Cookie 信息,绕开浏览器对同一域名下并发请求的限制。
- 容灾处理:一旦 CDN 服务出现异常,立即将 CDN 服务器域名的 CNAME 修改为主站域名IP(保证主站服务器有资源备份),实现快速止损。
4.5 服务端渲染模式
4.5.1 CSR局限性
前端工程师在处理性能优化问题时,需要站在全栈角度去审视系统的每个细节。Vue
、React
等前端框架出现后,基于MVVM
及组件化的开发模式逐渐取代原来的MVC
,常见的客户端渲染模式(CSR)也存在以下问题:
- 首屏加载速度慢:前端框架包含的特性越多,其代码包尺寸就越大。如果网站页面依赖于框架代码加载完成后,再对相关组件进行初始化和渲染,无疑会增加用户从打开网站到看到页面内容的等待时间。等待期间网站页面一直处于空白状态,这种首屏体验是非常糟糕的。
- SEO支持度差:搜索引擎爬虫在抓取网页时,可能看不到完整的页面,会遇到内容抓取不完全或更新延迟。
- 异步请求初始化数据:首页加载完成后才会异步请求初始化数据,导致页面渲染和数据获取不同步,用户可操作时间变长。
4.5.2 SSR工作原理
服务端渲染模式(SSR)可以解决以上问题。当用户在浏览器发起某个网站请求时,服务端将需要的 HTML 文本组装好再返回给浏览器。这个 HTML 文本被浏览器解析之后,不需要再执行 JavaScript 脚本,可以直接构建 DOM 树并在页面渲染。以React
为例,具体工作流程如下:
- 脱水:服务端将组件变成 HTML 字符串,并在
window
上挂载一个变量存放store
中的数据,共同返回给浏览器。
import express from 'express';
import { renderToString } from 'react-dom/server';
import { Home } from '../component/home';
import { getStore } from '../store';const app = express();app.get('/', (req, res) => {// 读取dist文件夹中js 文件const jsFiles = fs.readdirSync(path.join(__dirname, '../dist')).filter((file) => file.endsWith('.js'));const jsScripts = jsFiles.map((file) => `<script src="${file}" defer></script>`).join('\n');const content = renderToString(<Home />);res.send(`<html><head><title>React SSR</title>${jsScripts}<script>window.INITIAL_STATE =${JSON.stringify(store.getState())}</script></head><body><div id="root">${content}</div> </body></html>`);});
- 注水:浏览器将组件逻辑连接到服务端生成的 HTML 中进行交互事件绑定(不会二次加载),并将服务端返回的数据传入
store
作为初始值。
import { hydrateRoot } from 'react-dom/client';
import { Home } from '../component/home';hydrateRoot(document.getElementById('root'), <Home />;
import { configureStore } from '@reduxjs/toolkit';
import usersReducer, { UserState } from './user-slice';export const getStore = () => {return configureStore({// reducer是必需的:它指定了应用程序的根reducerreducer: {users: usersReducer,},// 对象,它包含应用程序的初始状态preloadedState: {users: typeof window !== 'undefined' ?window.INITIAL_STATE?.users :({status: 'idle',list: [],} as UserState),},});
};
随着React
升级到18版本,服务端渲染模式增加更多新特性:
- 流式HTML:尽早发送一些HTML片段到浏览器,待其他耗时内容准备完成后再发送到客户端,更加快速、平滑地显示内容。
- 局部水合:使用代码分包,让页面就绪的内容更早开始水合,使其达到可交互状态;支持优先水合用户正在交互的内容。
五、结语
性能优化是无止境的,但时间与成本是有限的。如何取舍以达到最佳的性能体验效果,这是每位读者在日后的实际工作中,通过实践和思考来不断积累提升的能力。