記一次 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
相關文章
相關標籤/搜索