一般寫倒計時效果,用的是 setInterval,但這會引起一些問題,最多見的問題就是定時器不許。css
若是隻是普通的動畫效果,倒也無所謂,但倒計時這種須要精確到毫秒級別的,就不行了,不然活動都結束了,用戶的界面上倒計時還在走,可是又參加不了活動,會被投訴的╮(╯▽╰)╭html
先說本文的主角 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
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');
複製代碼
打印順序爲:
至於爲何 setTimeout 設置爲 0,卻在最後被打印,這就涉及到 event loop 中的微任務和宏任務了。
不一樣的任務源會被分配到不一樣的 task 隊列中,任務源可分爲微任務( microtask )和宏任務( macrotask ).
在 ES6 中:
macro-task(Task): 一個 event loop 有一個或者多個 task 隊列。task 任務源很是寬泛,好比 ajax 的 onload,click 事件,基本上咱們常常綁定的各類事件都是 task 任務源,還有數據庫操做(IndexedDB ),須要注意的 是setTimeout、setInterval、setImmediate 也是 task 任務源。總結來講 task 任務源:
micro-task(Job): microtask 隊列和 task 隊列有些類似,都是先進先出的隊列,由指定的任務源去提供任務,不一樣的是一個 event loop 裏只有一個 microtask 隊列。另外 microtask 執行時機和 macrotasks 也有所差別
ps: 微任務並不快於宏任務
ps: 若是宏任務中的異步代碼有大量的計算而且須要操做 DOM 的話,爲了更快的界面響應,可把操做放微任務中。
setTimeout 在第一次執行時,會掛起到 task, 等待下一輪 event loop,而執行一次 event loop 最少須要 4ms,這就是爲何哪怕setTimeout(()=>{...}, 0)
都會有 4ms 的延遲。
因爲 JavaScript 是單線程,因此 setInterval / setTimeout 的偏差是沒法被徹底解決的。
多是回調中的事件,也多是瀏覽器中的各類事件致使的。
這也是爲何一個頁面運行久了,定時器會不許的緣由。
在公司項目中遇到了倒計時的需求,可是已有前人寫過組件了,由於項目時間趕,因此直接拿來用了,但使用的過程當中,發現一些 Bug:
第一個 Bug 是由於滑動阻塞了主線程,致使 macrotask 沒有正常的執行。
第二個 Bug 是由於切換頁面後,瀏覽器爲了下降性能的消耗,會自動的延長以前頁面定時器的間隔,致使偏差愈來愈大。
第三個 Bug 是由於調用方法以前,沒有清除定時器,致使監聽時間戳的時候,又新增了定時器。
前兩個 Bug 纔是本文要解決的地方。
查了不少文章,大體解決方案有如下兩種:
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。
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 之,發現有兩種解決方案;
這是 simple-web-worker 的做者針對 Vue 項目編寫的插件,它能夠經過像 Promise 那樣調用函數。
Github地址: vue-worker
可是在使用過程當中發現一些問題,那就是 setInterval 並不會執行:
傳入的 val 是倒計時剩餘的時間戳,可是運行發現,return 出去的 val 並無改變,也就是 setInterval 並無執行。理論上 Web Worker 會保留 setInterval 的。(多是個人姿式有問題?去提了 issues,如今仍是沒有人答覆,有大佬指教嗎?)
倒計時最核心的 setInterval 沒法執行,所以棄用此插件,執行 Plan B。
這是和 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 動畫來完成,儘量的提升頁面的流暢度。
第一次寫技術博客,才疏學淺,不免有遺漏之處,若是還有更好的倒計時解決方案,歡迎各位大佬指教。
參考資料: