记一次 Vue 移动端活动倒计时优化
前言
通常寫倒計時效果,用的是 setInterval,但這會引發(fā)一些問題,最常見的問題就是定時器不準(zhǔn)。
如果只是普通的動畫效果,倒也無所謂,但倒計時這種需要精確到毫秒級別的,就不行了,否則活動都結(jié)束了,用戶的界面上倒計時還在走,但是又參加不了活動,會被投訴的╮(╯▽╰)╭
一、 知識鋪墊
1. setInterval 定時器
先說本文的主角 setInterval,MDN web doc 對其的解釋是:
setInterval() 方法重復(fù)調(diào)用一個函數(shù)或執(zhí)行一個代碼段,在每次調(diào)用之間具有固定的時間延遲。
返回一個 intervalID。(可用于清除定時器)
語法: let intervalID = window.setInterval(func, delay[, param1, param2, ...]);
例:
值得注意的是,在 setInterval 里面使用 this 的話,this 指向的是 window 對象,可以通過 call、apply 等方法改變 this 指向。
setTimeout 與 setInterval 類似,只不過延遲 n 毫秒執(zhí)行函數(shù)一次,且不需要手動清除。
至于 setTimeout 和 setInterval 的運(yùn)行原理,就要牽扯到另一個概念: event loop (事件循環(huán))。
2. 瀏覽器的 Event Loop
JavaScript 在執(zhí)行的過程中會產(chǎn)生執(zhí)行環(huán)境,這些執(zhí)行環(huán)境會被順序的加入到執(zhí)行棧中,若遇到異步的代碼,會被掛起并加入到 task (有多種 task) 隊列中。
一旦執(zhí)行棧為空, event loop 就會從 task 隊列中拿出需要執(zhí)行的代碼并放入執(zhí)行棧中執(zhí)行。
有了 event loop,使得 JavaScript 具備了異步編程的能力。(但本質(zhì)上,還是同步行為)
先看一道經(jīng)典的面試題:
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'); 復(fù)制代碼打印順序為:
至于為什么 setTimeout 設(shè)置為 0,卻在最后被打印,這就涉及到 event loop 中的微任務(wù)和宏任務(wù)了。
2.1 宏任務(wù)和微任務(wù)
不同的任務(wù)源會被分配到不同的 task 隊列中,任務(wù)源可分為微任務(wù)( microtask )和宏任務(wù)( macrotask ).
在 ES6 中:
- microtask 稱為 Job
- macrotask 稱為 Task
macro-task(Task): 一個 event loop 有一個或者多個 task 隊列。task 任務(wù)源非常寬泛,比如 ajax 的 onload,click 事件,基本上我們經(jīng)常綁定的各種事件都是 task 任務(wù)源,還有數(shù)據(jù)庫操作(IndexedDB ),需要注意的 是setTimeout、setInterval、setImmediate 也是 task 任務(wù)源。總結(jié)來說 task 任務(wù)源:
- setTimeout
- setInterval
- setImmediate
- I/O
- UI rendering
micro-task(Job): microtask 隊列和 task 隊列有些相似,都是先進(jìn)先出的隊列,由指定的任務(wù)源去提供任務(wù),不同的是一個 event loop 里只有一個 microtask 隊列。另外 microtask 執(zhí)行時機(jī)和 macrotasks 也有所差異
- process.nextTick
- promises
- Object.observe
- MutationObserver
ps: 微任務(wù)并不快于宏任務(wù)
2.2 Event Loop 執(zhí)行順序
ps: 如果宏任務(wù)中的異步代碼有大量的計算并且需要操作 DOM 的話,為了更快的界面響應(yīng),可把操作放微任務(wù)中。
setTimeout 在第一次執(zhí)行時,會掛起到 task, 等待下一輪 event loop,而執(zhí)行一次 event loop 最少需要 4ms,這就是為什么哪怕setTimeout(()=>{...}, 0)都會有 4ms 的延遲。
由于 JavaScript 是單線程,所以 setInterval / setTimeout 的誤差是無法被完全解決的。
可能是回調(diào)中的事件,也可能是瀏覽器中的各種事件導(dǎo)致的。
這也是為什么一個頁面運(yùn)行久了,定時器會不準(zhǔn)的原因。
二、項目場景
在公司項目中遇到了倒計時的需求,但是已有前人寫過組件了,因為項目時間趕,所以直接拿來用了,但使用的過程中,發(fā)現(xiàn)一些 Bug:
第一個 Bug 是因為滑動阻塞了主線程,導(dǎo)致 macrotask 沒有正常的執(zhí)行。
第二個 Bug 是因為切換頁面后,瀏覽器為了降低性能的消耗,會自動的延長之前頁面定時器的間隔,導(dǎo)致誤差越來越大。
第三個 Bug 是因為調(diào)用方法之前,沒有清除定時器,導(dǎo)致監(jiān)聽時間戳的時候,又新增了定時器。
前兩個 Bug 才是本文要解決的地方。
查了很多文章,大致解決方案有以下兩種:
1. requestAnimationFrame()
MDN web doc 的解釋如下:
window.requestAnimationFrame() 告訴瀏覽器——你希望執(zhí)行一個動畫,并且要求瀏覽器在下次重繪之前調(diào)用指定的回調(diào)函數(shù)更新動畫。該方法需要傳入一個回調(diào)函數(shù)作為參數(shù),該回調(diào)函數(shù)會在瀏覽器下一次重繪之前執(zhí)行
注意: 若你想在瀏覽器下次重繪之前繼續(xù)更新下一幀動畫,那么回調(diào)函數(shù)自身必須再次調(diào)用window.requestAnimationFrame()
requestAnimationFrame() 的執(zhí)行頻率取決于瀏覽器屏幕的刷新率,通常的屏幕都是 60Hz 或 75Hz,也就是每秒最多只能重繪60次或75次,requestAnimationFrame 的基本思想就是與這個刷新頻率保持同步,利用這個刷新頻率進(jìn)行頁面重繪。此外,使用這個API,一旦頁面不處于瀏覽器的當(dāng)前標(biāo)簽,就會自動停止刷新。這就節(jié)省了CPU、GPU和電力。
不過要注意:requestAnimationFrame 是在主線程上完成。這意味著,如果主線程非常繁忙,requestAnimationFrame 的動畫效果會大打折扣。
利用 requestAnimationFrame 可以在一定程度上替代 setInterval,不過時間間隔需要計算,按 60Hz 的屏幕刷新率( fps )來算的話,1000 / 60 = 16.6666667(ms),也就是每16.7ms執(zhí)行一次,但 fps 并不是固定的,有玩過 FPS(第一人稱射擊游戲)的玩家會深有體會。不過相對于之前不做任何優(yōu)化的 setInterval 來說,誤差要比原來的小得多。
我的解決方案是,設(shè)置一個變量 then,在執(zhí)行動畫函數(shù)之后,記錄當(dāng)前時間戳,再下一次進(jìn)入動畫函數(shù)的時候,用 [當(dāng)前時間戳] 減去 [then] ,得到時間間隔,然后讓 [倒計時時間戳] 減去 [間隔],并在離開頁面時記錄離開時間,進(jìn)一步減小誤差。
<script> export default {name: "countdown",props: {timestamp: {type: Number,default: 0}},data() {return {remainTimestamp: 0then: 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); // 減去當(dāng)前與上一次執(zhí)行的間隔this.then = tms; // 記錄本次執(zhí)行的時間window.requestAnimationFrame(this.animation);}}},watch: {timestamp(val) {this.remainTimestamp = val;this.then = Date.now();window.requestAnimationFrame(this.animation);}} }; </script> 復(fù)制代碼requestAnimationFrame 在使用過程中和 setInterval 還是有區(qū)別的,最大的區(qū)別就是不能自定義間隔時間。
如果倒計時只需要精確到秒,那么 1000ms 內(nèi)執(zhí)行 16.7 次對性能有點(diǎn)過于浪費(fèi)了。而如果要模擬 setInterval ,還需要額外的變量去處理間隔,也降低了代碼的可讀性。
因此就繼續(xù)嘗試第二種方案: Web Worker。
2. Web Worker
Web Worker 是 JavaScript 實現(xiàn)多線程的黑科技,在阮一峰博客的解釋如下:
JavaScript 語言采用的是單線程模型,也就是說,所有任務(wù)只能在一個線程上完成,一次只能做一件事。前面的任務(wù)沒做完,后面的任務(wù)只能等著。隨著電腦計算能力的增強(qiáng),尤其是多核 CPU 的出現(xiàn),單線程帶來很大的不便,無法充分發(fā)揮計算機(jī)的計算能力。
Web Worker 的作用,就是為 JavaScript 創(chuàng)造多線程環(huán)境,允許主線程創(chuàng)建 Worker 線程,將一些任務(wù)分配給后者運(yùn)行。在主線程運(yùn)行的同時,Worker 線程在后臺運(yùn)行,兩者互不干擾。等到 Worker 線程完成計算任務(wù),再把結(jié)果返回給主線程。這樣的好處是,一些計算密集型或高延遲的任務(wù),被 Worker 線程負(fù)擔(dān)了,主線程(通常負(fù)責(zé) UI 交互)就會很流暢,不會被阻塞或拖慢。
Worker 線程一旦新建成功,就會始終運(yùn)行,不會被主線程上的活動(比如用戶點(diǎn)擊按鈕、提交表單)打斷。這樣有利于隨時響應(yīng)主線程的通信。但是,這也造成了 Worker 比較耗費(fèi)資源,不應(yīng)該過度使用,而且一旦使用完畢,就應(yīng)該關(guān)閉。
具體教程可以看 阮一峰的博客 和 MDN - 使用 Web Workers ,不再贅述。
但是要在 Vue 項目中使用 Web Worker 的話,還是需要一番折騰的。
首先是文件載入,官方的例子是這樣的:
var myWorker = new Worker('worker.js');
由于 Worker 不能讀取本地文件,所以這個腳本必須來自網(wǎng)絡(luò)。如果下載沒有成功(比如404錯誤),Worker 就會默默地失敗。
因此,我們就不能直接用 import 引入,否則會找不到文件,遂 Google 之,發(fā)現(xiàn)有兩種解決方案;
2.1 vue-worker
這是 simple-web-worker 的作者針對 Vue 項目編寫的插件,它可以通過像 Promise 那樣調(diào)用函數(shù)。
Github地址: vue-worker
但是在使用過程中發(fā)現(xiàn)一些問題,那就是 setInterval 并不會執(zhí)行:
傳入的 val 是倒計時剩余的時間戳,但是運(yùn)行發(fā)現(xiàn),return 出去的 val 并沒有改變,也就是 setInterval 并沒有執(zhí)行。理論上 Web Worker 會保留 setInterval 的。(可能是我的姿勢有問題?去提了 issues,現(xiàn)在還是沒有人答復(fù),有大佬指教嗎?)
倒計時最核心的 setInterval 無法執(zhí)行,因此棄用此插件,執(zhí)行 Plan B。
2.2 worker-loader
這是和 babel-loader 類似的 JavaScript 文件轉(zhuǎn)義插件,具體使用已經(jīng)有大神總結(jié)了,就不再贅述:
怎么在 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) }; 復(fù)制代碼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> 復(fù)制代碼這里出現(xiàn)了一個小插曲,本地運(yùn)行的時候沒問題,但是打包的時候報錯,排查原因是把 worker-loader 的 rules 寫在了 babel-loader 的后面,結(jié)果先匹配的 .js 文件,直接把 .worker.js 用 babel-loader 處理了,導(dǎo)致 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 內(nèi)聯(lián)為一個 BLOBfallback: false, // 禁用 chunkname: '[name]:[hash:8].js'}},{test: /\.js$/,loader: 'babel-loader',include: [utils.resolve('src'), utils.resolve('test')]},// ...]}, 復(fù)制代碼三、總結(jié)
經(jīng)過一番折騰,對瀏覽器的 event loop 又加深了理解,不只是 setInterval 這樣的定時器任務(wù) ,其他高密集的計算也可以利用多線程去處理,不過要注意處理完畢后關(guān)閉線程,否則會嚴(yán)重消耗資源。 不過普通的動畫還是盡量用 requestAnimationFrame 或者 CSS 動畫來完成,盡可能的提高頁面的流暢度。
第一次寫技術(shù)博客,才疏學(xué)淺,難免有遺漏之處,如果還有更好的倒計時解決方案,歡迎各位大佬指教。
參考資料:
轉(zhuǎn)載于:https://juejin.im/post/5cb5858ce51d456e51614a81
總結(jié)
以上是生活随笔為你收集整理的记一次 Vue 移动端活动倒计时优化的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 网易 UU 加速器推出 Steam De
- 下一篇: html5倒计时秒杀怎么做,vue 设