【优化】让页面显示更流畅

2022/02/25 16:19:56

页面绘制

刷新率

显示器刷新频率最低为60hz,如果页面每秒往屏幕传输 60 个页面,即60FPS,用户就会觉得这个网页是流畅的,(1s/1000ms) / 60 帧 = 16.7 ms/帧,即动画每16.7ms传输一帧到屏幕上

像素管道

在页面上触发一些视觉变化时(用js修改了颜色宽度等)浏览器会做央视计算、布局、绘制、合并图层等,这个过程叫像素管道

像素管道

像素管道有五步:JavaScript->样式计算->布局->绘制->合成

提高页面绘制效率

需要保障页面绘制(像素管道)在16.7ms之内完成,这就需要js的执行时间小于10ms,给浏览器预留出6.7ms

更快的样式计算

  • 选择器匹配

    计算某元素的样式时,有 50%的时间是用于选择器匹配

    选择器越简单越快,

布局抖动

像素管道中的每一步都是异步的,js 改了样式,其实它是异步的去计算样式,布局,绘制,图层合并,但是一些操作会导致强制同步布局:

el.style.width = "100px";
const width = el.offsetWidth;

第一行代码设置元素宽度,第二行代码获取元素宽度;这样操作在第一行代码执行时浏览器还在异步的进行计算样式等操作,执行第二行代码时还没有进行布局,此时浏览器为了获取元素宽度需要立即做一次布局,这个时候的布局就变成了同步的。两个没有任何关联的元素间也会存在这个问题,比如设置 a 元素宽度后再读取其他不相干元素的宽度。在循环中这样操作会造成大量的性能损耗

解决方案就是把触发布局的操作放在上面/放到循环外,这时只读一次宽度,并且由于之前并没有改变元素的属性,所以浏览器不需要做同步的布局。

图层与绘制

  • 图层 浏览器和 PhotoShop 一样,都有图层的概念,图层有一个最大的特点就是如果图层的位置变了,浏览器只需要重新去合成,就可以得到一张新的图,这个过程是不需要绘制(Paint)的。

  • 绘制 同一个图层里的内容改变会触发绘制(Paint),通过重新绘制图层,才能让图层里面的内容发生变化

  • 添加图层可以取消 Paint 可以使用 CSS 的 will-change 来创建图层,在 will-change 不兼容的情况下,可以用 transform: translateZ(0); 来代替

    浏览器做图层管理也是需要消耗的,不要大量使用

避免丢帧

    |----paint---|            |----paint---|
|----16ms----|----16ms----|----16ms----|----16ms----|

如上图,Timer 不能保证函数在每一帧最开始执行,保证不了函数的执行频率,会导致图像每一帧的传输出现间隔

使用 requestAnimationFrame 可以让函数在每一帧的最开始执行,这时保证页面绘制时间在 16.7ms 内,就可以保证不丢帧的情况下达到 60FPS

|----paint---|----paint---|----paint---|----paint---|
|----16ms----|----16ms----|----16ms----|----16ms----|

响应时间

用户的主动交互行为响应时间超过100ms就会感觉到卡顿,把响应时间控制在50ms内,即使在最坏的情况下(我点击这个按钮的一瞬间,有其他的任务在执行),总共的响应时间也不会超过100ms

处理长任务

从对用户的影响来看超过50ms的任务都可看做长任务

Web Worker

把耗时较长的任务交给worker去做,可以避免该任务阻塞主线程,缺点是worker无法操作DOM

Time Slicing 时间切片

如果任务不能在 50 毫秒 内执行完,那么为了不阻塞主线程,这个任务应该让出主线程的控制权,使浏览器可以处理其他任务,缺点是任务运行的总时间变长了,这是因为它每处理完一个小任务后,主线程会空闲出来,并且在下一个小任务开始处理之前有一小段延迟(4ms)。

对长任务拆分后可以显示进度指示

// 长任务修改为generator函数
function* programes() {
  let time = 1000;
  while (time--) {
    console.log(11);
    yield; // 使用yield表达式来暂停函数
  }
}

// 执行长任务的工具函数
function timeSlice(gen) {
  if (typeof gen === "function") gen = gen();
  if (!gen || typeof gen.next !== "function") return;

  (function next() {
    const res = gen.next(); // 让Generator继续执行
    if (res.done) return; // Generator的done为true时表示执行完毕
    setTimeout(next);
  })();
}

console.log("pre"); // 长任务之前的任务

timeSlice(programes);

console.log("next"); // 会被长任务阻断的任务

用定时器分解循环

如果是循环操作造成执行时间过长,并且这个循环操作可以异步执行且不必按顺序处理,就可以用定时器来分解。

定时器会在指定时间之后将任务推入执行队列中,每次循环都使用定时器执行任务就不会阻塞 UI线程

缺点是会延长整体执行时间。

function process(item) {
  console.log(item);
}

function processArray(items, process, callback) {
  var todo = items.slice(); //create a clone of the original
  setTimeout(function () {
    process(todo.shift());
    if (todo.length > 0) {
      setTimeout(arguments.callee, 25);
    } else {
      callback(items);
    }
  }, 25);
}

processArray(list, process, () => {
  console.log("end");
});

参考

高性能 JavaScript 编程