性能优化
一、前言
为什么要做性能优化?
好的性能优化能大大提高用户的留存率、转化率。
且由于性能优化的点很多, 想通过记录、补充、梳理的方式系统学习
二、对性能优化的解释
性能优化一般分为两个大类:
- 加载时优化
- 运行时优化
1、加载性能
加载时优化主要解决的点是提高网站的加载速度, 比如压缩文件, 用CDN加速等。一般用白屏时间
和首屏加载时间
来衡量。
测算白屏时间
将代码放在</head>
前面就能获取白屏时间
<script>
new Date().getTime() - performance.timing.navigationStart
</script>
测算首屏时间 在window.onload
时间中执行代码
new Date().getTime() - performance.timing.navigationStart
2、运行性能
是指页面运行时的性能表现, 而不是加载时的性能。
通过chrome控制台里的性能
tab查看, 具体方法会单独整理成一篇文章
三、加载时的性能优化
我们知道浏览器如果输入的是一个网址, 首先要 交给DNS域名解析 -> 找到对应的IP地址 -> 然后进行TCP连接 -> 浏览器发送HTTP请求 -> 服务器接收请求 -> 服务器处理请求并返回HTTP报文 -> 以及浏览器接收并解析渲染页面
。从这一过程中, 其实就可以挖出优化点, 缩短请求的时间, 从而去加快网站的访问速度, 提升性能。
这个过程中可以提升性能的优化的点:
- DNS解析优化, 浏览器访问DNS的时间就可以缩短
- 使用HTTP2
- 减少HTTP请求数量
- 减少http请求大小
- 服务器端渲染
- 静态资源使用CDN
- 资源缓存, 不重复加载相同的资源
从上面几个优化点出发, 有以下几种实现性能优化的方式。
1、DNS 预解析
DNS解析
在介绍DNS预解析前, 需要先简单了解下什么是DNS解析
大多数人是通过域名访问网站, 当浏览器从(第三方)服务器请求资源时, 必须先将该域名解析为 IP地址, 然后浏览器才能向该域名发出请求, 域名到IP这一过程称为 DNS解析。
当你的网站第一次请求某个跨域域名时, 需要先解析该域名(例如页面访问cdn资源, 第一次访问需要先解析cdn)。可以在请求的Timing上看到有一个DNS Lookup阶段, 而在这个请求之后的其他该域名的请求都没有这项时间支出。
DNS解析时间可能导致大量用户感知延迟(在移动端可能比较明显), DNS解析所需的时间差异非常大, 延迟范围可以从0ms(本地缓存结果)到几秒钟时间(网络极差)。
DNS预解析
DNS 预解析是一项让浏览器主动去执行域名解析的功能。
浏览器试图在用户访问链接之前解析域名, 其范围包括文档的所有链接, 无论是图片的, CSS 的, 还是 JavaScript 等其他用户能够点击的 URL。
因为预读取会在后台执行, 所以DNS很可能在链接对应的东西出现之前就已经解析完毕, 这能够减少用户点击链接时的延迟。
DNS预解析的实现
用meta信息来告知浏览器, 当前页面要做DNS预解析:
<meta http-equiv="x-dns-prefetch-control" content="on"/>
在页面header中使用link标签来强制对DNS预解析:
<link rel="dns-prefetch" href="http://Saraph1nes.github.io"/>
举个例子
场景:点击按钮切换图片, 图片是cdn资源
不使用DNS预解析:点击后请求cdn上的资源, DNS查询解析成ip, 用ip完成后续连接
使用DNS预解析:点击后请求cdn上的资源, 直接用预解析的ip完成后续连接
注意
dns-prefetch需慎用, 多页面重复DNS预解析会增加重复DNS查询次数。
http页面下所有的a标签的href都会自动去启用DNS Prefetch, 也就是说, 你网页的a标签href带的域名, 是不需要在head里面加上link手动设置的。
https页面需要使用meta标签强制开启:
html<meta http-equiv="x-dns-prefetch-control" content="on">
dns-prefetch适用于网页引用了大量其他域名的资源, 例如淘宝。
2、http2
http2.0是一种安全高效的下一代http传输协议。安全是因为http2.0建立在https协议的基础上, 高效是因为它是通过二进制分帧来进行数据传输。 正因为这些特性, http2.0协议也在被越来越多的网站支持。据统计, 截止至2018年8月, 已经有27.9%的网站支持http2.0。
二进制分帧(Binary Format)- http2.0的基石
几个概念:
帧:HTTP/2 数据通信的最小单位消息:指 HTTP/2 中逻辑上的 HTTP 消息。例如请求和响应等, 消息由一个或多个帧组成。
流:存在于连接中的一个虚拟通道。流可以承载双向消息, 每个流都有一个唯一的整数ID。
HTTP/2 采用二进制格式传输数据, 而非 HTTP 1.x 的文本格式, 二进制协议解析起来更高效。 HTTP / 1 的请求和响应报文, 都是由起始行, 首部和实体正文(可选)组成, 各部分之间以文本换行符分隔。 HTTP/2 将请求和响应数据分割为更小的帧, 并且它们采用二进制编码。
HTTP/2 中, 同域名下
所有通信都在单个连接
上完成, 该连接可以承载任意数量的双向数据流。
每个数据流都以消息的形式发送, 而消息又由一个或多个帧组成。多个帧之间可以乱序发送, 根据帧首部的流标识可以重新组装。
多路复用 (Multiplexing) / 连接共享
多路复用, 代替原来的序列和阻塞机制。所有就是请求的都是通过一个 TCP连接并发完成。
HTTP 1.x 中, 如果想并发多个请求, 必须使用多个 TCP 链接, 且浏览器为了控制资源, 还会对单个域名有 6-8个的TCP链接请求限制。
在 HTTP/2 中, 有了二进制分帧之后, HTTP /2 不再依赖 TCP 链接去实现多流并行了, 在 HTTP/2中:
- 同个域名只需要占用一个 TCP 连接, 消除了因多个 TCP 连接而带来的延时和内存消耗。
- 单个连接上可以并行交错的请求和响应, 之间互不干扰。
- 在HTTP/2中, 每个请求都可以带一个31bit的优先值, 0表示最高优先级, 数值越大优先级越低。有了这个优先值, 客户端和服务器就可以在处理不同的流时采取不同的策略, 以最优的方式发送流、消息和帧。
服务端推送(Server Push)
服务端可以在发送页面HTML时主动推送其它资源, 而不用等到浏览器解析到相应位置, 发起请求再响应。例如服务端可以主动把JS和CSS文件推送给客户端, 而不需要客户端解析HTML时再发送这些请求。
服务端可以主动推送, 客户端也有权利选择是否接收。如果服务端推送的资源已经被浏览器缓存过, 浏览器可以通过发送RST_STREAM帧来拒收。主动推送也遵守同源策略, 服务器不会随便推送第三方资源给客户端。
头部压缩(Header Compression)
http1.x的头带有大量信息, 而且每次都要重复发送。 http/2使用encoder来减少需要传输的header大小, 通讯双方各自缓存一份头部字段表, 既避免了重复header的传输, 又减小了需要传输的大小。
对于相同的数据, 不再通过每次请求和响应发送, 通信期间几乎不会改变通用键-值对(用户代理、可接受的媒体类型, 等等)只需发送一次。
事实上,如果请求中不包含头部(例如对同一资源的轮询请求), 那么, 头部开销就是零字节, 此时所有头部都自动使用之前请求发送的头部。
如果头部发生了变化, 则只需将变化的部分加入到header帧中, 改变的部分会加入到头部字段表中, 头部表在 http 2.0 的连接存续期内始终存在, 由客户端和服务器共同渐进地更新。
需要注意的是, http 2.0关注的是头部压缩, 而我们常用的gzip等是报文内容(body)的压缩, 二者不仅不冲突, 且能够一起达到更好的压缩效果。
请求优先级(Request Priorities)
把http消息分为很多独立帧之后, 就可以通过优化这些帧的交错和传输顺序进一步优化性能。 每个流都可以带有一个31比特的优先值:0 表示最高优先级;2的31次方-1 表示最低优先级。
服务器可以根据流的优先级, 控制资源分配(CPU、内存、带宽), 而在响应数据准备好之后, 优先将最高优先级的帧发送给客户端。高优先级的流都应该优先发送, 但又不会绝对的。绝对地准守, 可能又会引入首队阻塞的问题:高优先级的请求慢导致阻塞其他资源交付。
分配处理资源和客户端与服务器间的带宽, 不同优先级的混合也是必须的。客户端会指定哪个流是最重要的, 有一些依赖参数, 这样一个流可以依赖另外一个流。优先级别可以在运行时动态改变, 当用户滚动页面时, 可以告诉浏览器哪个图像是最重要的, 你也可以在一组流中进行优先筛选, 能够突然抓住重点流。
优先级最高:主要的html
优先级高:CSS文件
优先级中:js文件
优先级低:图片
http2.0性能瓶颈
启用http2.0后会给性能带来很大的提升, 但同时也会带来新的性能瓶颈。因为现在所有的压力集中在底层一个TCP连接之上, TCP很可能就是下一个性能瓶颈, 比如TCP分组的队首阻塞问题, 单个TCP packet丢失导致整个连接阻塞, 无法逃避, 此时所有消息都会受到影响。未来, 服务器端针对http 2.0下的TCP配置优化至关重要。
3、减少HTTP请求数
HTTP请求建立和释放需要时间。
HTTP请求从建立到关闭一共经过以下步骤:
- 客户端连接到Web服务器
- 发送HTTP请求
- 服务器接受请求并返回HTTP响应
- 释放连接TCP链接
这些步骤都是需要花费时间的, 在网络情况差的情况下, 花费的时间更长。 如果页面的资源非常碎片化, 每个HTTP请求只带回来几K甚至不到1K的数据(比如各种小图标)那性能是非常浪费的。
4、压缩、合并文件
压缩文件: 减少HTTP请求大小,可以减少请求时间
文件合并: 减少HTTP请求数量。
压缩文件
我们可以对html、css、js以及图片资源进行压缩处理, 现在可以很方便的使用 webpack 实现文件的压缩:
- js压缩:UglifyPlugin
- CSS压缩:MiniCssExtractPlugin
- HTML压缩:HtmlWebpackPlugin
- 图片压缩:image-webpack-loader
合并文件
提取公共代码( 参考webpack的 代码分离 )
合并文件虽然能减少HTTP请求数量, 但是并不是文件合并越多越好, 还可以考虑按需加载方式。 一般来说, 将项目中多次使用到的公共代码进行提取, 打包成公共模块。
5、采用svg图片或者字体图标
因为字体图标或者SVG是矢量图, 代码编写出来的, 放大不会失真, 而且渲染速度快。
字体图标使用时就跟字体一样, 可以设置属性, 例如 font-size、color 等等, 非常方便, 还有一个优点是生成的文件特别小。
6、按需加载代码, 减少冗余代码
按需加载
在开发SPA项目时, 项目中经常存在十几个甚至更多的路由页面, 如果将这些页面都打包进一个JS文件, 虽然减少了HTTP请求数量, 但是会导致文件比较大, 同时加载了大量首页不需要的代码, 有些得不偿失, 这时候就可以使用按需加载, 将每个路由页面单独打包为一个文件, 当然不仅仅是路由可以按需加载。
根据文件内容生成文件名,结合 import 动态引入组件实现按需加载。
减少冗余代码
核心思想: 考虑代码的可读性的前提下, 能合并的合并,能提取的提取
7、服务端渲染(Server Side Render)
客户端渲染: 页面上的内容是我们加载的js文件渲染出来的,js文件运行在浏览器上面,服务端只返回一个html模板。
服务端渲染:页面上的内容是通过服务端渲染生成的,浏览器直接显示服务端返回的html就可以了。
优点:首屏渲染快,SEO 好。
缺点:配置麻烦,增加了服务器的计算压力。
8、使用 Defer 加载JS
尽量将 CSS 放在文件头部,JavaScript 文件放在底部
script
浏览器在解析 HTML 的时候,如果遇到一个没有任何属性的 script 标签,就会暂停解析,先发送网络请求获取该 JS 脚本的代码内容,然后让 JS 引擎执行该代码,当代码执行完毕后恢复解析。整个过程如下图所示:
async script
当浏览器遇到带有 async 属性的 script 时,请求该脚本的网络请求是异步的,不会阻塞浏览器解析 HTML,一旦网络请求回来之后,如果此时 HTML 还没有解析完,浏览器会暂停解析,先让 JS 引擎执行代码,执行完毕后再进行解析
defer script
当浏览器遇到带有 defer 属性的 script 时,获取该脚本的网络请求也是异步的,不会阻塞浏览器解析 HTML,一旦网络请求回来之后,如果此时 HTML 还没有解析完,浏览器不会暂停解析并执行 JS 代码,而是等待 HTML 解析完毕再执行 JS 代码
9、静态资源使用 CDN (Content Delivery Network 内容分发网络)
具体步骤:
- 当用户点击APP上的内容,APP会根据URL地址去本地DNS(域名解析系统)寻求IP地址解析。
- 本地DNS系统会将域名的解析权交给CDN专用DNS服务器。
- CDN专用DNS服务器,将CDN的全局负载均衡设备IP地址返回用户。
- 用户向CDN的负载均衡设备发起内容URL访问请求。
- CDN负载均衡设备根据用户IP地址,以及用户请求的内容URL,选择一台用户所属区域的缓存服务器。
- 负载均衡设备告诉用户这台缓存服务器的IP地址,让用户向所选择的缓存服务器发起请求。
- 用户向缓存服务器发起请求,缓存服务器响应用户请求,将用户所需内容传送到用户终端。
- 如果这台缓存服务器上并没有用户想要的内容,那么这台缓存服务器就要网站的源服务器请求内容。
- 源服务器返回内容给缓存服务器,缓存服务器发给用户,并根据用户自定义的缓存策略,判断要不要把内容缓存到缓存服务器上。
CDN的好处
采用CDN技术,最大的好处,就是加速了网站的访问——用户与内容之间的物理距离缩短,用户的等待时间也得以缩短。
而且,分发至不同线路的缓存服务器,也让跨运营商之间的访问得以加速。
例如中国移动手机用户访问中国电信网络的内容源,可以通过在中国移动假设CDN服务器,进行加速。效果是非常明显的。
此外,CDN还有安全方面的好处。内容进行分发后,源服务器的IP被隐藏,受到攻击的概率会大幅下降。而且,当某个服务器故障时,系统会调用临近的健康服务器,进行服务,避免对用户造成影响。
正因为CDN的好处很多,所以,目前所有主流的互联网服务提供商,都采用了CDN技术。所有的云服务提供商,也都提供了CDN服务(价格也不算贵,按流量计费)。
10、图片优化
雪碧图
在网站上通常会有很多小的图标,一般来说,最直接的方式就是将这些小图标保存为一个个独立的图片文件,然后通过 CSS 将对应元素的背景图片设置为对应的图标图片。
这么做的一个重要问题在于,页面加载时可能会同时请求非常多的小图标图片,这就会受到浏览器并发 HTTP 请求数的限制。
雪碧图的核心原理在于设置不同的背景偏移量,大致包含两点:
不同的图标元素都会将
background-url
设置为合并后的雪碧图的 uri不同的图标通过设置对应的
background-position
来展示大图中对应的图标部分。你可以用 Photoshop 这类工具自己制作雪碧图。当然比较推荐的还是将雪碧图的生成集成到前端自动化构建工具中,例如在webpack
中使用webpack-spritesmith
,或者在gulp
中使用gulp.spritesmith
。它们两者都是基于spritesmith
这个库。
图片懒加载
图片的懒加载就是在页面打开的时候,不要一次性全部显示页面所有的图片,而是只显示当前视口内的图片,一般在移动端使用(PC端主要是前端分页或者后端分页)。
懒加载实现原理
由于浏览器会自动对页面中的img标签的src属性发送请求并下载图片。因此,通过html5自定义属性 data-xxx
先暂存src的值,然后在需要显示的时候,再将 data-xxx
的值重新赋值到img的src属性即可。
CSS中图片懒加载
除了对于 <img>
元素的图片进行来加载,在 CSS 中使用的图片一样可以懒加载,最常见的场景就是 background-url
。
background-url: url(/static/img/example.png);
对于上面这个样式规则,如果不应用到具体的元素,浏览器不会去下载该图片。所以你可以通过切换 className
的方式,放心得进行 CSS 中图片的懒加载。
四、运行时的性能优化
减少重绘与重排(回流)
浏览器渲染过程:
- 解析HTML生成DOM树
- 解析CSS生成CSSOM规则树
- 将DOM树与CSSOM规则树合并生成Render(渲染)树
- 遍历Render(渲染)树开始布局, 计算每一个节点的位置大小信息
- 将渲染树每个节点绘制到屏幕上
需要明白,这五个步骤并不一定一次性顺序完成。
如果 DOM 或 CSSOM 被修改,以上过程需要重复执行,这样才能计算出哪些像素需要在屏幕上进行重新渲染。
实际页面中,CSS 与 JavaScript 往往会多次修改 DOM 和 CSSOM。
重排
当改变DOM元素位置或者大小时, 会导致浏览器重新生成Render树, 这个过程叫重排
重绘
当重新生成渲染树后, 将要将渲染树每个节点绘制到屏幕, 这个过程叫重绘。
重排触发时机
重排发生后的根本原理就是元素的几何属性发生改变, 所以从能够改变几何属性的角度入手:
- 添加|删除可见的DOM元素
- 元素位置发生改变
- 元素大小发生改变
- 内容变化
- 页面渲染器初始化
- 浏览器窗口大小发生改变
重绘不一定需要重排,重排必然会导致重绘
样式设置
降低 CSS 选择器的复杂性
浏览器读取选择器,遵循的原则是从选择器的右边到左边读取
避免使用层级较深的选择器,或其他一些复杂的选择器,以提高CSS渲染效率
js#block .text p { color: red; }
查找所有 P 元素
查找结果 1 中的元素是否有类名为 text 的父元素
查找结果 2 中的元素是否有 id 为 block 的父元素
CSS 选择器优先级
js内联 > ID选择器 > 类选择器 > 标签选择器
根据以上两个信息可以得出结论。
- 选择器越短越好。
- 尽量使用高优先级的选择器,例如 ID 和类选择器。
- 避免使用通配符 *。
最后要说一句,据我查找的资料所得,CSS 选择器没有优化的必要,因为最慢和慢快的选择器性能差别非常小。
避免使用CSS表达式,CSS表达式是动态设置CSS属性的强大但危险方法,它的问题就在于计算频率很快。不仅仅是在页面显示和缩放时,就是在页面滚动、乃至移动鼠标时都会要重新计算一次
元素适当地定义高度或最小高度,否则元素的动态内容载入时,会出现页面元素的晃动或位置,造成回流
给图片设置尺寸。如果图片不设置尺寸,首次载入时,占据空间会从0到完全出现,上下左右都可能位移,发生回流
要使用table布局,因为一个小改动可能会造成整个table重新布局。而且table渲染通常要3倍于同等元素时间
能够使用CSS实现的效果,尽量使用CSS而不使用JS实现
DOM优化
- 缓存DOM
const div = document.getElementById('div')
减少DOM深度及DOM数量。
HTML 中标签元素越多,标签的层级越深,浏览器解析DOM并绘制到浏览器中所花的时间就越长,所以应尽可能保持 DOM 元素简洁和层级较少。
批量操作DOM
由于DOM操作比较耗时,且可能会造成回流,因此要避免频繁操作DOM,可以批量操作DOM,先用字符串拼接完毕,再用innerHTML更新DOM
批量操作CSS样式
通过切换class或者使用元素的style.csstext属性去批量操作元素样式
js// 三次重排 div.style.left = '10px'; div.style.top = '10px'; div.style.width = '20px'; // 一次重排 el.style.cssText = 'left: 10px;top: 10px; width: 20px';
在内存中操作DOM
使用DocumentFragment对象,让DOM操作发生在内存中,而不是页面上
DOM元素离线更新
对DOM进行相关操作时,appendChild可以使用Document Fragment对象进行离线操作,带元素“组装”完成后再一次插入页面,或者使用display:none 对元素隐藏,在元素“消失”后进行相关操作
科普:
DocumentFragment
,文档片段接口,一个没有父对象的最小文档对象。它被作为一个轻量版的Document
使用,就像标准的document一样,存储由节点(nodes)组成的文档结构。与document相比,最大的区别是DocumentFragment 不是真实 DOM 树的一部分,它的变化不会触发 DOM 树的重新渲染,且不会导致性能等问题。最常用的方法是使用文档片段作为参数(例如,任何
Node
接口类似Node.appendChild
和Node.insertBefore
的方法),这种情况下被添加(append)或被插入(inserted)的是片段的所有子节点, 而非片段本身。因为所有的节点会被一次插入到文档中,而这个操作仅发生一个重渲染的操作,而不是每个节点分别被插入到文档中,因为后者会发生多次重渲染的操作。DOM读写分离
浏览器具有惰性渲染机制,连接多次修改DOM可能只触发浏览器的一次渲染。而如果修改DOM后,立即读取DOM
为了保证读取到正确的DOM值,会触发浏览器的一次渲染
因此,修改DOM的操作要与访问DOM分开进行
// bad 强制刷新 触发四次重排+重绘 性能不好
div.style.left = div.offsetLeft + 1 + 'px';
div.style.top = div.offsetTop + 1 + 'px';
div.style.right = div.offsetRight + 1 + 'px';
div.style.bottom = div.offsetBottom + 1 + 'px';
// good 缓存布局信息 相当于读写分离 触发一次重排+重绘 优化性能
var curLeft = div.offsetLeft;
var curTop = div.offsetTop;
var curRight = div.offsetRight;
var curBottom = div.offsetBottom;
div.style.left = curLeft + 1 + 'px';
div.style.top = curTop + 1 + 'px';
div.style.right = curRight + 1 + 'px';
div.style.bottom = curBottom + 1 + 'px';
事件代理
事件代理是指将事件监听器注册在父级元素上,由于子元素的事件会通过事件冒泡的方式向上传播到父节点,因此,可以由父节点的监听函数统一处理多个子元素的事件
利用事件代理,可以减少内存使用,提高性能及降低代码复杂度
防抖和节流
使用函数节流(throttle)或函数去抖(debounce),限制某一个方法的频繁触发
及时清理环境
及时消除对象引用,清除定时器,清除事件监听器,创建最小作用域变量,可以及时回收内存
刷新率
FPS 表示的是每秒钟画面更新次数,当今大多数设备的屏幕刷新率都是60次/秒,在两次硬件刷新之间浏览器进行两次重绘是没有意义的只会消耗性能,那么浏览器渲染动画或页面的每一帧的速率,也需要跟设备的刷新率保持一致。
也就是说,浏览器对每一帧画面的渲染工作需要在16ms(1000ms/60)之内完成,也就是说每一次渲染都要在 16ms才不会掉帧。
在这16ms 内浏览器要完成的工作有如下图,这一系列操作通常被称为像素管道(The pixel pipeline)
- 脚本执行(JavaScript):脚本造成了需要重绘的改动,比如增删 DOM、请求动画等
- 样式计算(CSS Object Model):级联地生成每个节点的生效样式。
- 布局(Layout):计算布局,执行渲染算法
- 重绘(Paint):各层分别进行绘制(比如 3D 动画)
- 合成(Composite):将位图发送给合成线程。
渲染时的每一帧都会经过管道的各部分进行处理,但并不意味着所有的部分都会执行。实际上,在实现视觉变化效果时,管道针对指定帧通常有三种方式:
- JS / CSS > 样式 > 布局 > 绘制 > 合成
如果修改一个 DOM 元素的 Layout 属性,也就是改变了元素的样式(比如 width、height 或者 position 等),那么浏览器会检查哪些元素需要重新布局,然后对页面激发一个 reflow(重排)过程完成重新布局。被 reflow(重排)的元素,接下来也会激发绘制过程,最后激发渲染层合并过程,生成最后的画面。
- JS / CSS > 样式 > 绘制 > 合成
如果你修改一个 DOM 元素的 Paint Only 属性,比如背景图片、文字颜色或阴影等,这些属性不会影响页面的布局,因此浏览器会在完成样式计算之后,跳过布局过程,只会绘制和渲染层合并过程。
JS / CSS > 样式 > 合成
如果你修改一个非样式且非绘制的 CSS 属性,那么浏览器会在完成样式计算之后,跳过布局和绘制的过程,直接做渲染层合并。这种方式在性能上是最理想的,对于动画和滚动这种负荷很重的渲染,我们要争取使用第三种渲染过程。
影响 Layout、Paint 和 Composite 的属性都可以通过 CSS Triggers 网站查阅。
使用 requestAnimationFrame
使用js创建动画时,用 requestAnimationFrame 替代setTimeout或
setInterval来实现视觉变化,使用 requestAnimationFrame
从而保证 JavaScript 在帧开始时运行。
如果采取 setTimeout
或 setInterval
来实现动画的话,回调函数将在帧中的某个时点运行,可能刚好在末尾,而这可能经常会使我们丢失帧,导致卡顿。