記一次 Vue 移動端活動倒數計時優化

布拉德特皮發表於2019-04-17

前言

通常寫倒數計時效果,用的是 setInterval,但這會引發一些問題,最常見的問題就是定時器不準。

如果只是普通的動畫效果,倒也無所謂,但倒數計時這種需要精確到毫秒級別的,就不行了,否則活動都結束了,使用者的介面上倒數計時還在走,但是又參加不了活動,會被投訴的╮(╯▽╰)╭

一、 知識鋪墊

1. setInterval 定時器

先說本文的主角 setInterval,MDN web doc 對其的解釋是:

setInterval() 方法重複呼叫一個函式或執行一個程式碼段,在每次呼叫之間具有固定的時間延遲。

返回一個 intervalID。(可用於清除定時器)

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

記一次 Vue 移動端活動倒數計時優化

值得注意的是,在 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); // 減去當前與上一次執行的間隔
        window.requestAnimationFrame(this.animation);
        this.then = tms; // 記錄執行完後的時間
      }
    }
  },
  watch: {
    timestamp(val) {
      this.remainTimestamp = val;
      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 並不會執行:

記一次 Vue 移動端活動倒數計時優化

傳入的 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

相關文章