记一次 Vue 移动端活动倒计时优化

7,904 阅读10分钟

前言

通常写倒计时效果,用的是 setInterval,但这会引发一些问题,最常见的问题就是定时器不准。

如果只是普通的动画效果,倒也无所谓,但倒计时这种需要精确到毫秒级别的,就不行了,否则活动都结束了,用户的界面上倒计时还在走,但是又参加不了活动,会被投诉的╮(╯▽╰)╭

一、 知识铺垫

1. setInterval 定时器

先说本文的主角 setInterval,MDN web doc 对其的解释是:

setInterval() 方法重复调用一个函数或执行一个代码段,在每次调用之间具有固定的时间延迟。

返回一个 intervalID。(可用于清除定时器)

语法: let intervalID = window.setInterval(func, delay[, param1, param2, ...]);
例:

值得注意的是,在 setInterval 里面使用 this 的话,this 指向的是 window 对象,可以通过 call、apply 等方法改变 this 指向。

setTimeout 与 setInterval 类似,只不过延迟 n 毫秒执行函数一次,且不需要手动清除。

至于 setTimeout 和 setInterval 的运行原理,就要牵扯到另一个概念: event loop (事件循环)。

2. 浏览器的 Event Loop

JavaScript 在执行的过程中会产生执行环境,这些执行环境会被顺序的加入到执行栈中,若遇到异步的代码,会被挂起并加入到 task (有多种 task) 队列中。

一旦执行栈为空, event loop 就会从 task 队列中拿出需要执行的代码并放入执行栈中执行。

有了 event loop,使得 JavaScript 具备了异步编程的能力。(但本质上,还是同步行为)

先看一道经典的面试题:

console.log('Script start');

setTimeout(() => {
  console.log('setTimeout');
}, 0);

new Promise((resolve, reject) => {
  console.log('Promise');
  resolve()
}).then(() => {
  console.log('Promise 1');
}).then(() => {
  console.log('Promise 2');
});

console.log('Scritp end');

打印顺序为:

  1. "Script start"
  2. "Promise"
  3. "Script end"
  4. "Promise 1"
  5. "Promise 2"
  6. "setTimeout"

至于为什么 setTimeout 设置为 0,却在最后被打印,这就涉及到 event loop 中的微任务和宏任务了。

2.1 宏任务和微任务

不同的任务源会被分配到不同的 task 队列中,任务源可分为微任务( microtask )和宏任务( macrotask ).

在 ES6 中:

  • microtask 称为 Job
  • macrotask 称为 Task

macro-task(Task): 一个 event loop 有一个或者多个 task 队列。task 任务源非常宽泛,比如 ajax 的 onload,click 事件,基本上我们经常绑定的各种事件都是 task 任务源,还有数据库操作(IndexedDB ),需要注意的 是setTimeout、setInterval、setImmediate 也是 task 任务源。总结来说 task 任务源:

  • setTimeout
  • setInterval
  • setImmediate
  • I/O
  • UI rendering

micro-task(Job): microtask 队列和 task 队列有些相似,都是先进先出的队列,由指定的任务源去提供任务,不同的是一个 event loop 里只有一个 microtask 队列。另外 microtask 执行时机和 macrotasks 也有所差异

  • process.nextTick
  • promises
  • Object.observe
  • MutationObserver

ps: 微任务并不快于宏任务

2.2 Event Loop 执行顺序

  1. 执行同步代码(宏任务);
  2. 执行栈为空,查询是否有微任务需要执行;
  3. 执行所有微任务;
  4. 必要的话渲染 UI;
  5. 然后开始下一轮 event loop,执行宏任务中的异步代码;

ps: 如果宏任务中的异步代码有大量的计算并且需要操作 DOM 的话,为了更快的界面响应,可把操作放微任务中。

setTimeout 在第一次执行时,会挂起到 task, 等待下一轮 event loop,而执行一次 event loop 最少需要 4ms,这就是为什么哪怕setTimeout(()=>{...}, 0)都会有 4ms 的延迟。

由于 JavaScript 是单线程,所以 setInterval / setTimeout 的误差是无法被完全解决的。

可能是回调中的事件,也可能是浏览器中的各种事件导致的。

这也是为什么一个页面运行久了,定时器会不准的原因。

二、项目场景

在公司项目中遇到了倒计时的需求,但是已有前人写过组件了,因为项目时间赶,所以直接拿来用了,但使用的过程中,发现一些 Bug:

  1. 在某台安卓测试机上,手指滑动或者将要滑动的时候,毫秒数会停住,松开后才会继续走;
  2. 去到其他页面之后再回来,倒计时的分秒数不正确;
  3. 回到原来页面之后,重新请求数据,会导致倒计时加快;

第一个 Bug 是因为滑动阻塞了主线程,导致 macrotask 没有正常的执行。

第二个 Bug 是因为切换页面后,浏览器为了降低性能的消耗,会自动的延长之前页面定时器的间隔,导致误差越来越大。

第三个 Bug 是因为调用方法之前,没有清除定时器,导致监听时间戳的时候,又新增了定时器。

前两个 Bug 才是本文要解决的地方。

查了很多文章,大致解决方案有以下两种:

1. requestAnimationFrame()

MDN web doc 的解释如下:

window.requestAnimationFrame() 告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行

注意: 若你想在浏览器下次重绘之前继续更新下一帧动画,那么回调函数自身必须再次调用window.requestAnimationFrame()

requestAnimationFrame() 的执行频率取决于浏览器屏幕的刷新率,通常的屏幕都是 60Hz 或 75Hz,也就是每秒最多只能重绘60次或75次,requestAnimationFrame 的基本思想就是与这个刷新频率保持同步,利用这个刷新频率进行页面重绘。此外,使用这个API,一旦页面不处于浏览器的当前标签,就会自动停止刷新。这就节省了CPU、GPU和电力。

不过要注意:requestAnimationFrame 是在主线程上完成。这意味着,如果主线程非常繁忙,requestAnimationFrame 的动画效果会大打折扣。

利用 requestAnimationFrame 可以在一定程度上替代 setInterval,不过时间间隔需要计算,按 60Hz 的屏幕刷新率( fps )来算的话,1000 / 60 = 16.6666667(ms),也就是每16.7ms执行一次,但 fps 并不是固定的,有玩过 FPS(第一人称射击游戏)的玩家会深有体会。不过相对于之前不做任何优化的 setInterval 来说,误差要比原来的小得多。

我的解决方案是,设置一个变量 then,在执行动画函数之后,记录当前时间戳,再下一次进入动画函数的时候,用 [当前时间戳] 减去 [then] ,得到时间间隔,然后让 [倒计时时间戳] 减去 [间隔],并在离开页面时记录离开时间,进一步减小误差。

<script>
export default {
  name: "countdown",
  props: {
    timestamp: {
      type: Number,
      default: 0
    }
  },
  data() {
    return {
      remainTimestamp: 0
      then: 0
    };
  },
  activated () {
    window.requestAnimationFrame(this.animation);
  },
  deactivated() {
    this.then = Date.now();
  },
  methods: {
    animation(tms) {
      if (this.remainTimestamp > 0 && this.then) {
        this.remainTimestamp -= (tms - this.then); // 减去当前与上一次执行的间隔
        this.then = tms; // 记录本次执行的时间
        window.requestAnimationFrame(this.animation);
      }
    }
  },
  watch: {
    timestamp(val) {
      this.remainTimestamp = val;
      this.then = Date.now();
      window.requestAnimationFrame(this.animation);
    }
  }
};
</script>

requestAnimationFrame 在使用过程中和 setInterval 还是有区别的,最大的区别就是不能自定义间隔时间。

如果倒计时只需要精确到秒,那么 1000ms 内执行 16.7 次对性能有点过于浪费了。而如果要模拟 setInterval ,还需要额外的变量去处理间隔,也降低了代码的可读性。

因此就继续尝试第二种方案: Web Worker。

2. Web Worker

Web Worker 是 JavaScript 实现多线程的黑科技,在阮一峰博客的解释如下:

JavaScript 语言采用的是单线程模型,也就是说,所有任务只能在一个线程上完成,一次只能做一件事。前面的任务没做完,后面的任务只能等着。随着电脑计算能力的增强,尤其是多核 CPU 的出现,单线程带来很大的不便,无法充分发挥计算机的计算能力。
Web Worker 的作用,就是为 JavaScript 创造多线程环境,允许主线程创建 Worker 线程,将一些任务分配给后者运行。在主线程运行的同时,Worker 线程在后台运行,两者互不干扰。等到 Worker 线程完成计算任务,再把结果返回给主线程。这样的好处是,一些计算密集型或高延迟的任务,被 Worker 线程负担了,主线程(通常负责 UI 交互)就会很流畅,不会被阻塞或拖慢。
Worker 线程一旦新建成功,就会始终运行,不会被主线程上的活动(比如用户点击按钮、提交表单)打断。这样有利于随时响应主线程的通信。但是,这也造成了 Worker 比较耗费资源,不应该过度使用,而且一旦使用完毕,就应该关闭。

具体教程可以看 阮一峰的博客MDN - 使用 Web Workers ,不再赘述。

但是要在 Vue 项目中使用 Web Worker 的话,还是需要一番折腾的。

首先是文件载入,官方的例子是这样的:

var myWorker = new Worker('worker.js');

由于 Worker 不能读取本地文件,所以这个脚本必须来自网络。如果下载没有成功(比如404错误),Worker 就会默默地失败。

因此,我们就不能直接用 import 引入,否则会找不到文件,遂 Google 之,发现有两种解决方案;

2.1 vue-worker

这是 simple-web-worker 的作者针对 Vue 项目编写的插件,它可以通过像 Promise 那样调用函数。

Github地址: vue-worker

但是在使用过程中发现一些问题,那就是 setInterval 并不会执行:

传入的 val 是倒计时剩余的时间戳,但是运行发现,return 出去的 val 并没有改变,也就是 setInterval 并没有执行。理论上 Web Worker 会保留 setInterval 的。(可能是我的姿势有问题?去提了 issues,现在还是没有人答复,有大佬指教吗?)

倒计时最核心的 setInterval 无法执行,因此弃用此插件,执行 Plan B。

2.2 worker-loader

这是和 babel-loader 类似的 JavaScript 文件转义插件,具体使用已经有大神总结了,就不再赘述:

怎么在 ES6+Webpack 下使用 Web Worker

直接贴代码:

timer.worker.js:

self.onmessage = function(e) {
  let time = e.data.value;
  const timer = setInterval(() => {
    time -= 71;
    if(time > 0) {
      self.postMessage({
        value: time
      });
    } else {
      clearInterval(timer);
      self.postMessage({
        value: 0
      });
      self.close();
    }
  }, 71)
};

countdown.vue:

<script>
import Worker from './timer.worker.js'
export default {
  name: "countdown",
  props: {
    timestamp: {
      type: Number,
      default: 0
    }
  },
  data() {
    return {
      remainTimestamp: 0
    };
  },
  beforeDestroy () {
    this.worker = null;
  },
  methods: {
    setTimer(val) {
      this.worker = new Worker();
      this.worker.postMessage({
        value: val
      });
      const that = this;
      this.worker.onmessage = function(e) {
        that.remainTimestamp = e.data.value;
      }
    }
  },
  watch: {
    timestamp(val) {
      this.worker = null;
      this.setTimer(val);
    }
  }
};
</script>

这里出现了一个小插曲,本地运行的时候没问题,但是打包的时候报错,排查原因是把 worker-loader 的 rules 写在了 babel-loader 的后面,结果先匹配的 .js 文件,直接把 .worker.js 用 babel-loader 处理了,导致 worker 没能引入成功,打包报错:

webpack.base.conf.js (公司项目比较老,没有使用 webpack 4.0+ 的配置方式,不过原理是一样的)

  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader',
        options: {
          vueLoaderConfig,
          postcss: [
            require('autoprefixer')({
              browsers: ['last 10 Chrome versions', 'last 5 Firefox versions', 'Safari >= 6', 'ie > 8']
            })
          ]
        }
      },
      {
        // 匹配的需要写在前面,否则会打包报错
        test: /\.worker\.js$/,
        loader: 'worker-loader',
        include: resolve('src'),
        options: {
          inline: true,    // 将 worker 内联为一个 BLOB
          fallback: false, // 禁用 chunk
          name: '[name]:[hash:8].js'
        }
      },
      {
        test: /\.js$/,
        loader: 'babel-loader',
        include: [utils.resolve('src'), utils.resolve('test')]
      },
      // ...
    ]
  },

三、总结

经过一番折腾,对浏览器的 event loop 又加深了理解,不只是 setInterval 这样的定时器任务 ,其他高密集的计算也可以利用多线程去处理,不过要注意处理完毕后关闭线程,否则会严重消耗资源。 不过普通的动画还是尽量用 requestAnimationFrame 或者 CSS 动画来完成,尽可能的提高页面的流畅度。

第一次写技术博客,才疏学浅,难免有遗漏之处,如果还有更好的倒计时解决方案,欢迎各位大佬指教。

参考资料:

  1. 浏览器事件循环机制
  2. Web Worker 使用教程 - 阮一峰
  3. worker-loader 官方文档
  4. 怎么在 ES6+Webpack 下使用 Web Worker