当前市面上的设备频率在 60 HZ 以上。
跑如下界面 https://code.h5jun.com/pojob
通过观察上述 demo, 可以看到 100ms 以下的点击是顺畅的, 而超过 100ms 的点击就会有卡顿现象。
衡量一个网页/App 是否流畅有个比较好用的 Rail 模型, 它大概有以下几个评判标准值。
Response —— 100msAnimation —— 16.7msIdle —— 50msLoad —— 1000ms
像素管道一般由 5 个部分组成。JavaScript、样式、布局、绘制、合成。如下图所示:
function App() {useEffect(() => {setTimeout(() => {const start = performance.now()while (performance.now() - start < 1000) { }console.log('done!')}, 5000)})return (<input type="text" />);}
超过 50 ms 认为是 long task(长任务)
, long task
会阻塞 main thread
的运行。
在 chrome 74, 对 long task 进行了标明:
如下是两种解决方案。
app.js
代码如下:
import React, {useEffect} from 'react'import WorkerCode from './worker'function App() {useEffect(() => {const testWorker = new Worker(WorkerCode)setTimeout(_ => {testWorker.postMessage({})testWorker.onmessage = function(ev) {console.log(ev.data)}}, 5000)})return (<input type="text" />);}
worker.js
代码如下:
const workerCode = () => {self.onmessage = function() {const start = performance.now()while (performance.now() - start < 1000) { }postMessage('done!')}}
此时在输入框输入时没有卡顿的感觉。
下面是另外一种使页面流畅的方法 —— Time Slicing
(时间分片)。
如果某个任务超过 50ms 没有执行完, 为了避免阻塞浏览器主线程的执行, 该任务应该让出执行的控制权, 让浏览器处理完其它任务后再来执行该任务。
观察 Chrome 的 Performance, 火焰图如下,
从火焰图可以看出主线程被拆分为了多个时间分片, 所以不会造成卡顿。时间分片的代码片段如下所示:
function timeSlicing(gen) {if (typeof gen === 'function') gen = gen()if (!gen || typeof gen.next !== 'function') return(function next() {const res = gen.next() // ①if (res.done) return // ⑤setTimeout(next) // ③})()}// 调用时间分片函数timeSlicing(function* () {const start = performance.now()while (performance.now() - start < 1000) {console.log('执行逻辑')yield // ②}console.log('done') // ④})
前置知识 Generator
下面对该函数进行分析:
timeSlicing
中传入 generator
函数;performance.now() - start < 1000
则继续 ②、③, 如果 performance.now() - start >= 1000
则跳出循环执行 ④、⑤);如何说:
使用 Time Slising
会让任务的完成时间变长, 但为了让用户体验流畅, 这种取舍还是有必要的。这个也是在 schedule 中提到的目标对象更快完成渲染
与及时响应优先级更高任务
的矛盾。
假设某个任务执行时间为 100ms
, 假设被分割的任务间隔是 4ms
, 分割成 2 个 50ms
的任务和分割成 100 个 1ms
的任务分别耗时。
(50 + 4) * 2 = 108ms(1 + 4) * 100 = 500ms
针对上述函数作如下调整:
function timeSlicing(gen) {if (typeof gen === 'function') gen = gen()if (!gen || typeof gen.next !== 'function') return(function next() {const init = performance.now()let res = gen.next()while (!res.done && performance.now() - init < 25) { // 这里相当于是一个控制执行多少的阀门。res = gen.next()}if (res.done) returnsetTimeout(next)})()}
针对 long task
会阻塞 main thread
的运行的情形, 给出两种解决方案:
Web Worker
: 使用 Web Worker
提供的多线程环境来处理 long task
;Time Slicing
: 将主线程上的 long task
进行时间分片;保证 16.7ms
有新的一帧传输到界面上。除去用户的逻辑代码, 一帧内留给浏览器整合的时间大概只有 6ms
左右, 回到像素管道上来, 我们可以从这几方面进行优化:
Style 这部分的优化在 css 样式选择器的使用, css 选择器使用的层级越多, 耗费的时间越多。以下是测试 css 选择器不同层级筛选相同元素的一次测试结果。
div.box:not(:empty):last-of-type span 2.25msindex.html:85 .box--last span 0.28msindex.html:85 .box:nth-last-child(-n+1) span 2.51ms
// 先修改值el.style.witdh = '100px'// 后取值const width = el.offsetWidth
这段代码有什么问题呢?
可以看到它会造成布局重排。
应对的策略是调整它们的执行顺序,
// 先取值const width = el.offsetWidth// 后修改值el.style.witdh = '100px'
可以看到经过调换顺序后, 后执行的 el.style.width 会新开一个像素管道, 而不会在原先的像素管道进行重排。
此外不要在循环中执行如下的操作,
for (var i = 0; i < 1000; i++) {const newWidth = container.offsetWidth; // ①boxes[i].style.width = newWidth + 'px'; // ②}
可以在火焰图中看到它发生了重绘的警告,
执行顺序是 ①②①②①②①..., 假若我们在第一个 ① 后面插入一条竖线后 ①|②①②①②①, 其就变成先修改值后取值的情景, 所以也就发生了重绘!
正确的使用姿势应该如下:
const newWidth = container.offsetWidth;for (var i = 0; i < 1000; i++) {boxes[i].style.width = newWidth + 'px';}
创建 Layers(图层) 可以避免重绘,
{transform: translateZ(0);}