前言
通常寫倒數計時效果,用的是 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');
複製程式碼
列印順序為:
- "Script start"
- "Promise"
- "Script end"
- "Promise 1"
- "Promise 2"
- "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 執行順序
- 執行同步程式碼(巨集任務);
- 執行棧為空,查詢是否有微任務需要執行;
- 執行所有微任務;
- 必要的話渲染 UI;
- 然後開始下一輪 event loop,執行巨集任務中的非同步程式碼;
ps: 如果巨集任務中的非同步程式碼有大量的計算並且需要操作 DOM 的話,為了更快的介面響應,可把操作放微任務中。
setTimeout 在第一次執行時,會掛起到 task, 等待下一輪 event loop,而執行一次 event loop 最少需要 4ms,這就是為什麼哪怕setTimeout(()=>{...}, 0)
都會有 4ms 的延遲。
由於 JavaScript 是單執行緒,所以 setInterval / setTimeout 的誤差是無法被完全解決的。
可能是回撥中的事件,也可能是瀏覽器中的各種事件導致的。
這也是為什麼一個頁面執行久了,定時器會不準的原因。
二、專案場景
在公司專案中遇到了倒數計時的需求,但是已有前人寫過元件了,因為專案時間趕,所以直接拿來用了,但使用的過程中,發現一些 Bug:
- 在某檯安卓測試機上,手指滑動或者將要滑動的時候,毫秒數會停住,鬆開後才會繼續走;
- 去到其他頁面之後再回來,倒數計時的分秒數不正確;
- 回到原來頁面之後,重新請求資料,會導致倒數計時加快;
第一個 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 並不會執行:
傳入的 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 動畫來完成,儘可能的提高頁面的流暢度。
第一次寫技術部落格,才疏學淺,難免有遺漏之處,如果還有更好的倒數計時解決方案,歡迎各位大佬指教。
參考資料: