Skip to content

8.5 交互性能优化

前面我们介绍了一些列的性能优化手段,包含从用户输入 URL 到页面渲染的整个流程,这些都属于加载阶段的性能优化,其目的就是为了让页面更快的呈现给用户。当页面呈现后,用户不会只盯着看,而是一定会在屏幕上操作,与网页发生交互,此时就进入了交互阶段。

交互阶段主要由用户发起操作并触发页面事件,包括滚动、点击、跳转、滑动等,这个过程会触发大量的页面更新,其中就涉及到一些高成本的操作(如 DOM 操作),使用不当就会造成页面卡顿。那么如何让交互过程变得更流畅,这就是交互性能的优化的意义。

交互性能优化可以从 DOM 操作和事件循环两个方面入手。

8.5.1 防抖与节流:减少事件触发

在各种各样的浏览器事件中,有一类特殊的事件:容易过度触发的事件。它们在频繁触发频繁执行回调函数的过程中,极其容易带来性能问题,最典型的就是 scroll 事件。除此之外,还有 resize 事件、鼠标事件等等。

高频率触发回调导致的大量计算会引发页面的抖动甚至卡顿,为了避免这种情况,就需要我们手动控制触发频率。手动控制频率并不是直接减少事件触发(这是不可能的),而是通过对要执行的函数进行包装,控制这个函数的执行频率。该方案有两种主流的实现:事件防抖(debounce)和 事件节流(throttle)。

防抖和节流都是为了解决高频触发带来的问题,只不过应用场景不同,它们的主要区别如下:

  1. 防抖:最后一次说了算

防抖的核心是:事件被触发 n 秒后再执行回调,如果在这 n 秒内又被触发,则重新计时。它的特点是 n 秒内事件触发的回调都不会被执行。实际应用场景:输入搜索。

简单实现一个防抖函数,代码如下:

js
// fn是我们需要包装的事件回调, delay是每次推迟执行的等待时间
function debounce(fn, delay) {
  // 定时器
  let timer = null;

  // 将debounce处理结果当作函数返回
  return function () {
    // 保留调用时的this上下文
    let context = this;
    // 保留调用时传入的参数
    let args = arguments;
    // 每次事件被触发时,都去清除之前的旧定时器
    if (timer) {
      clearTimeout(timer);
    }
    // 设立新定时器
    timer = setTimeout(function () {
      fn.apply(context, args);
    }, delay);
  };
}
  1. 节流:第一次说了算

节流的核心原则是:事件第一次触发时执行回调,之后 n 秒内无论事件触发多少次都会被无视。n 秒之后,从新开始监听事件。节流可以使事件在相同的时间段内触发一次。实际应用场景:下拉刷新。

简单实现一个节流函数,代码如下:

js
// fn是我们需要包装的事件回调, interval是时间间隔的阈值
function throttle(fn, interval) {
  // last为上一次触发回调的时间
  let last = 0;

  // 将throttle处理结果当作函数返回
  return function () {
    // 保留调用时的this上下文
    let context = this;
    // 保留调用时传入的参数
    let args = arguments;
    // 记录本次触发回调的时间
    let now = +new Date();

    // 判断上次触发的时间和本次触发的时间差是否小于时间间隔的阈值
    if (now - last >= interval) {
      // 如果时间间隔大于我们设定的时间间隔阈值,则执行回调
      last = now;
      fn.apply(context, args);
    }
  };
}

防抖和节流函数的使用方法都一样,就是将需要执行的函数包裹起来,并返回一个新函数。调用这个新函数时,就相当于在控制频率的基础上调用了原函数。以节流函数为例,如下:

js
const onScroll = () => console.log("触发了滚动事件");
const better_scroll = throttle(onScroll, 1000);
document.addEventListener("scroll", better_scroll);

8.5.2 异步更新:减少重复渲染

主流框架 Vue 和 React 都采用了异步更新的策略,具体表现为:当我们修改一个状态时,状态不会立即更新,而是要“等待一段时间”后才会更新,这常常在开发中给我们带来困扰:明明已经修改了状态,为什么获取不到最新值呢?殊不知这正是框架保证性能的关键。

  1. 为什么需要异步更新?

我们知道,像 Vue 和 React 这类通过修改状态来更新视图的框架,它们更新页面的时机是在虚拟 DOM 对比完成后,判断出页面中哪些元素需要修改,然后操作 DOM 修改这些发生变化的元素,更新流程如下:

  • 修改数据 ——> 生成虚拟 DOM -> 对比新旧虚拟 DOM -> 操作 DOM 更新页面。

然而在某一次操作中,我们可能会同时修改多个状态,或多次修改同一个状态。如果每次修改状态之后都要走一遍上述的流程,那么就会重复触发多次虚拟 DOM 对比和真实 DOM 操作。举一个例子:

js
var status = {
  value: 0,
  action: null,
};
const changeStatus = () => {
  status.value = "1";
  status.action = "change";
  status.value = "2";
};

代码中的 changeStatus() 函数内一共修改了三次状态。如果按照同步更新的原则,每次修改状态后都要更新页面,那么就会发生三次虚拟 DOM 对比和三次 DOM 操作,这显然是不合理的,实际上我们只需要更新一次而已。

那么如何做到只更新一次呢?其实也很简单,就是让“更新”这个操作变成一个标记,而不是真实的修改状态。当一次操作执行完成后,我们通过标记判断出哪些状态应该发生变化,然后一次性更新,这样更新流程就会只执行一次。将上面的例子优化后如下:

js
var status = {
  value: 0,
  action: null,
};
const changeStatus = () => {
  let old_status = { ...status };
  old_status.value = "1";
  old_status.action = "change";
  old_status.value = "2";
  status = { ...old_status };
};

上述代码中,修改 old_status 的属性就好比我们在 Vue 中修改状态 ——— 实际上它并不是真正的修改状态,只是标记了哪些状态会被修改,修改后的值是什么。当然 Vue 中的异步更新一定不是这样实现,但原理差不多,就是在所有操作完成之后才会批量更新真实的状态。

  1. 异步更新方案

那么 Vue 和 React 是如何实现异步更新呢?答案就是事件循环。

本书第 3 章我们介绍过,事件循环是浏览器执行异步任务的方式和原理。事件循环会把异步任务分成两类:宏任务和微任务,并将两类任务分别存储在两个队列中。当代码运行时,script 脚本会当作第一个宏任务执行,此过程如果产生的异步任务,则会被加入相应的任务对列中,等待下一次执行。

事件循环的整个过程,简单描述有 3 个步骤:

  1. 宏任务执行:从宏任务队列中出列一个任务并执行。
  2. 微任务执行:从微任务队列中取出所有任务,依次执行。
  3. 页面渲染:微任务执行完成之后,浏览器进行页面渲染,更新 DOM。

上面三个步骤执行完成后,如果任务队列中还有任务,则重新执行这三个步骤,直到任务队列全部清空。

对照上述事件循环的流程,当 Vue 和 React 更新状态时,其本质是创建了一个异步微任务,加入到微任务队列,等到同步代码执行完成之后,才从队列中出列并依次执行,执行完成之后页面开始渲染,这样一次渲染就搞定了页面更新,而不是修改一次执行一次渲染。

8.5.3 减少 DOM 操作

众所周知 DOM 操作耗费性能,但更新页面的本质就是更新 DOM,即便如 Vue 这样的数据驱动视图框架也不能避免 DOM 操作。因此如何在实现相同功能的同时更少地操作 DOM,成为了探索性能优化的关键方向。

在介绍如何减少 DOM 操作之前,我们先回顾一下页面更新时 DOM 构建的原理,也就是我们在第 8 章介绍过的重绘和重排的概念,如下。

在页面初始化完成后,我们还可能会通过 CSS、JS 来对页面中的元素进行修改,这些修改会重新触发一部分页面渲染的生命周期。重走页面生命周期的这个过程,有两种主要的形式 ——— 重排与重绘。

重绘与重排都发生在页面的交互阶段。总的来说,当修改元素的几何属性(宽高、大小、边距)时会触发重排,而仅修改元素的样式(颜色、背景色)时会触发重绘。重排会影响其他元素的位置,因此需要重新计算布局,性能损耗更大。相对来说重绘只影响当前元素,性能损耗更小些。

但不管重绘还是重排,对页面来说都是性能损耗的来源。我们应该尽可能少地执行重绘和重排,这是提高交互性能的根本逻辑。

  1. 用 CSS 代替 JS 动画

前端发生交互最多的部分往往是动画,比如一些页面切换动画、滚动动画、循环执行的动画等,早期我们都是通过修改元素的属性来实现元素动画效果,这会不可避免地频繁操作 DOM,动画性能很低。

在现代浏览器中,CSS 动画可以实现了我们大部分的动画需求,包括一些计算功能。一些强大的 CSS 属性会利用 GPU 渲染,保证流畅的同时又兼顾了性能,这是直接操作 DOM 所不能比的。

最重要的是,如果修改一些不会引起重排的属性,渲染引擎将跳过布局和绘制,在一个单独的线程上执行动画,这个过程叫做合成。合成动画避免了对主线程的占用,相对于重绘和重排,合成能大大提升绘制效率。

因此在实现动画效果时,应该尽可能地用 CSS 动画来替代 DOM 操作。比如要实现一个元素向右移动 50px 的动画,传统的做法是通过 position + left 实现,如下:

html
<div class="dom" style="position:relative">元素</div>
<script>
  var px = 0;
  const dom = document.querySelector(".dom");
  var timmer = setInterval(() => {
    if (px == 50) {
      clearInterval(timmer);
    }
    dom.style.left = px + "px";
    px++;
  }, 10);
</script>

这种方式借助定时器不断修改元素的 left 属性从而达到“缓慢移动”的动画效果。显然这种方式不够友好,对 DOM 操作过于频繁。我们看如何用 CSS 来简单实现:

html
<div class="dom">元素</div>
<script>
  const dom = document.querySelector(".dom");
  dom.style.transition = "all 0.3s";
  setTimeout(() => {
    dom.style.transform = "translate(50px)";
  }, 0);
</script>

上面代码中通过 CSS 的 transition 属性(过渡效果)和 transform 属性来设置元素位移,写法很简单,并且满足在单独线程中执行动画的条件。transform 不光可以设置位移,还支持缩放、旋转、3D 操作等,使用这个属性可以让页面动画的性能达到最优。

animation 是 CSS 的另一个动画属性,比 transition 属性更加灵活。对于一些更复杂的非线形动画,我们可以用 animation 属性代替 JavaScript 实现以保证性能。具体该属性的用法请查阅文档,这里不赘述。

  1. DocumentFragment

尽管我们可以最大程度地避免 DOM 操作,但是对于一些确实需要操作 DOM 才能实现的功能,我们应该如何优化性能呢?这时候就需要一个新成员 ——— DocumentFragment。

在 JavaScript 中操作 DOM,需要 JS 引擎不断地与渲染引擎通信,这是大量操作 DOM 消耗性能的主要原因。两个引擎“通信”的成本很昂贵,那么就要想办法减少这些通信,让工作任务尽可能地在 JS 引擎中执行,于是乎 DocumentFragment 诞生了。

DocumentFragment 表示文档片段,是一个轻量版的 Document 对象,与 Document 有着相同的 API。不同的是它不是真实 DOM 树的一部分,使用该对象创建元素时只会在 JS 引擎中执行,因此不会触发页面的重绘和重排,也就不会对性能产生影响。

最常用的方法是使用 DocumentFragment 创建和组合一个子节点树,最后将其插入到真实 DOM 中。这种方式的好处是创建子节点的过程中不需要执行 DOM 操作,只会在子节点创建之后被插入到文档中时进行一次重渲染,显然这会大大提升渲染性能。

举例如下,我们先看如何用 Document 对象创建子节点并插入元素:

js
var ul = document.querySelector("ul");
for (let i = 1; i < 200; i++) {
  let li = document.createElement("li");
  li.innerText = `子元素${i}`;
  ul.appendChild(li);
}
console.log(ul);

上述代码中,在循环体内每次都要修改 ul 元素,一共修改了 200 次。而每一次更改 DOM 都有可能引发重排和重绘,这会带来大量不必要的渲染。我们用 DocumentFragment 来优化这段代码:

js
var ul = document.querySelector("ul");
var fragment = new DocumentFragment();

for (let i = 1; i < 200; i++) {
  let li = document.createElement("li");
  li.innerText = `子元素${i}`;
  fragment.appendChild(li);
}

ul.appendChild(fragment);

代码中使用 DocumentFragment 构造函数创建了文档片段,在循环体中创建的 200 个子元素都添加到了该文档片段中,这个过程不涉及更改真实 DOM。最后一步,使用 appendChild() 方法将文档片段添加到真实元素中,实际相当于将文档片段的所有子元素一次性添加到真实元素,此时该文档片段会变成空片段,可供我们后续使用。

本章小节

性能优化是一个庞大而注重实践的专题,本章我们从网络加载和浏览器渲染的原理出发,从宏观层面帮助大家了解了性能优化是什么。具体到实践,我们在网络层面介绍了如何通过拆包/压缩减少资源体积,让浏览器可以更快地下载;在渲染层面介绍了如何通过异步加载、缓存、减少渲染次数等方式,让页面更快地呈现,并且保证交互流畅。

文中介绍的性能优化方案并不是“阅读即可吸收”,一定要在项目中亲自实践,才能从数据和体验上感觉到性能提升带来的美妙。同时不要忘记性能检测,现在不妨打开开发者工具,检测一下当前网站的性能,然后开启优化之旅吧。