性能优化

184 阅读7分钟

优化JavaScript执行

JavaScript 是触发视觉变化的主要因素,时机不当或长时间运行的 JavaScript 可能是导致性能下降的常见原因。针对 JavaScript 的执行,下面有一些常用的优化措施。

window.requestAnimationFrame

在没有 requestAnimationFrame 方法的时候,执行动画,我们可能使用 setTimeoutsetInterval 来触发视觉变化;但是这种做法的问题是:回调函数执行的时间是不固定的,可能刚好就在末尾,或者直接就不执行了,经常会引起丢帧而导致页面卡顿。

归根到底发生上面这个问题的原因在于时机,也就是浏览器要知道何时对回调函数进行响应。setTimeoutsetInterval 是使用定时器来触发回调函数的,而定时器并无法保证能够准确无误的执行,有许多因素会影响它的运行时机,比如说:当有同步代码执行时,会先等同步代码执行完毕,异步队列中没有其他任务,才会轮到自己执行。并且,我们知道每一次重新渲染的最佳时间大约是16.6ms,如果定时器的时间间隔过短,就会造成 过度渲染,增加开销;过长又会延迟渲染,使动画不流畅。

requestAnimationFrame 方法不同与 setTimeout setInterval,它是由系统来决定回调函数的执行时机的,会请求浏览器在下一次重新渲染之前执行回调函数。无论设备的刷新率是多少,requestAnimationFrame 的时间间隔都会紧跟屏幕刷新一次所需要的时间;例如某一设备的刷新率是 75 Hz,那这时的时间间隔就是 13.3 ms(1 秒 / 75 次)。需要注意的是这个方法虽然能够保证回调函数在每一帧内只渲染一次,但是如果这一帧有太多任务执行,还是会造成卡顿的;因此它只能保证重新渲染的时间间隔最短是屏幕的刷新时间。

let offsetTop = 0;
const div = document.querySelector(".div");
const run = () => {
  div.style.transform = `translate3d(0, ${offsetTop += 10}px, 0)`;
  window.requestAnimationFrame(run);
};
run();

如果想要实现动画效果,每一次执行回调函数,必须要再次调用 requestAnimationFrame 方法;与 setTimeout 实现动画效果的方式是一样的,只不过不需要设置时间间隔。

window.requestIdleCallback

requestIdleCallback 方法只在一帧末尾有空闲的时候,才会执行回调函数;它很适合处理一些需要在浏览器空闲的时候进行处理的任务,比如:统计上传、数据预加载、模板渲染等。

以前如果需要处理复杂的逻辑,不进行分片,用户界面很可能就会出现假死状态,任何的交互操作都将无效;这时使用 setTimeout 就可以把任务拆分成多个模块,每次只处理一个模块,这样能很大程度上缓解这个问题。但是这种方式具有很强的不确定性,我们不知道这一帧是否空闲,如果已经塞满了一大堆任务,这时在处理模块就不太合适了。因此,在这种情况下,我们也可以使用 requestIdleCallback 方法来尽可能高效地利用空闲来处理分片任务。

如果一直没有空闲,requestIdleCallback就只能永远在等待状态吗?当然不是,它的参数除了回调函数之外,还有一个可选的配置对象,可以使用 timeout 属性设置超时时间;当到达这个时间,requestIdleCallback 的回调就会立即推入事件队列。来看下如何使用:

// 任务队列
const tasks = [
  () => {
    console.log("第一个任务");
  },
  () => {
    console.log("第二个任务");
  },
  () => {
    console.log("第三个任务");
  },
];

// 设置超时时间
const rIC = () => window.requestIdleCallback(runTask, {timeout: 3000})

function work() {
  tasks.shift()();
}

function runTask(deadline) {
  if (
    (
      deadline.timeRemaining() > 0 ||
      deadline.didTimeout
    ) &&
    tasks.length > 0
  ) {
    work();
  }
  if (tasks.length > 0) {
    rIC();
  }
}

rIC();

Web Worker

一个 Worker 线程是由 new 命令调用 Worker() 构造函数创建的;构造函数的参数是:包含执行任务代码的脚本文件,引入脚本文件的 URI 必须遵守同源策略。

Worker 线程与主线程不在同一个全局上下文中,因此会有一些需要注意的地方:

  • 两者不能直接通信,必须通过消息机制来传递数据;并且,数据在这一过程中会被复制,而不是通过 Worker 创建的实例共享。详细介绍可以查阅 worker中数据的接收与发送:详细介绍。
  • 不能使用 DOM、window 和 parent 这些对象,但是可以使用与主线程全局上下文无关的东西,例如 WebScoket、indexedDB 和 navigator 这些对象,更多能够使用的对象可以查看Web Workers可以使用的函数和类。

使用方式 Web Worker 规范中定义了两种不同类型的线程;一个是 Dedicated Worker(专用线程),它的全局上下文是 DedicatedWorkerGlobalScope 对象;另一个是 Shared Worker(共享线程),它的全局上下文是 SharedWorkerGlobalScope 对象。其中,Dedicated Worker 只能在一个页面使用,而 Shared Worker 则可以被多个页面共享。

专用线程

下面代码最重要的部分在于两个线程之间怎么发送和接收消息,它们都是使用 postMessage 方法发送消息,使用 onmessage 事件进行监听。区别是:在主线程中,onmessage 事件和 postMessage 方法必须挂载在 Worker 的实例上;而在 Worker 线程,Worker 的实例方法本身就是挂载在全局上下文上的。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Web Worker 专用线程</title>
</head>
<body>
  <input type="text" name="" id="number1">
  <span>+</span>
  <input type="text" name="" id="number2">
  <button id="button">确定</button>
  <p id="result"></p>

  <script src="./main.js"></script>
</body>
</html>
// main.js

const number1 = document.querySelector("#number1");
const number2 = document.querySelector("#number2");
const button = document.querySelector("#button");
const result = document.querySelector("#result");

// 1. 指定脚本文件,创建 Worker 的实例
const worker = new Worker("./worker.js");

button.addEventListener("click", () => {
  // 2. 点击按钮,把两个数字发送给 Worker 线程
  worker.postMessage([number1.value, number2.value]);
});

// 5. 监听 Worker 线程返回的消息
// 我们知道事件有两种绑定方式,使用 addEventListener 方法和直接挂载到相应的实例
worker.addEventListener("message", e => {
  result.textContent = e.data;
  console.log("执行完毕");
})
// worker.js

// 3. 监听主线程发送过来的消息
onmessage = e => {
  console.log("开始后台任务");
  const result= +e.data[0]+ +e.data[1];
  console.log("计算结束");

  // 4. 返回计算结果到主线程
  postMessage(result);
}
共享线程

共享线程虽然可以在多个页面共享,但是必须遵守同源策略,也就是说只能在相同协议、主机和端口号的网页使用。

示例基本上与专用线程的类似,区别是:

  • 创建实例的构造器不同。
  • 主线程与共享线程通信,必须通过一个确切打开的端口对象;在传递消息之前,两者都需要通过 onmessage 事件或者显式调用 start 方法打开端口连接。而在专用线程中这一部分是自动执行的。
// main.js

const number1 = document.querySelector("#number1");
const number2 = document.querySelector("#number2");
const button = document.querySelector("#button");
const result = document.querySelector("#result");

// 1. 创建共享实例
const worker = new SharedWorker("./worker.js");

// 2. 通过端口对象的 start 方法显式打开端口连接,因为下文没有使用 onmessage 事件
worker.port.start();

button.addEventListener("click", () => {
  // 3. 通过端口对象发送消息
  worker.port.postMessage([number1.value, number2.value]);
});

// 8. 监听共享线程返回的结果
worker.port.addEventListener("message", e => {
  result.textContent = e.data;
  console.log("执行完毕");
});
// worker.js

// 4. 通过 onconnect 事件监听端口连接
onconnect = function (e) {
    // 5. 使用事件对象的 ports 属性,获取端口
    const port = e.ports[0];
  // 6. 通过端口对象的 onmessage 事件监听主线程发送过来的消息,并隐式打开端口连接
    port.onmessage = function (e) {
        console.log("开始后台任务");
        const result= e.data[0] * e.data[1];
        console.log("计算结束");
        console.log(this);
        // 7. 通过端口对象返回结果到主线程
        port.postMessage(result);
    }
}

参考:

晨风明悟 网页渲染性能优化