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

前言

一般写倒计时效果,用的是 setInterval,但这会引起一些问题,最多见的问题就是定时器不许。css

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

1、 知识铺垫

1. setInterval 定时器

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

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

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

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

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

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

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

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 的偏差是没法被彻底解决的。

多是回调中的事件,也多是浏览器中的各类事件致使的。

这也是为何一个页面运行久了,定时器会不许的缘由。

2、项目场景

在公司项目中遇到了倒计时的需求,可是已有前人写过组件了,由于项目时间赶,因此直接拿来用了,但使用的过程当中,发现一些 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')]
      },
      // ...
    ]
  },
复制代码

3、总结

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

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

参考资料:

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