手把手教你寫個(gè)小程序定時(shí)器管理庫
發表時(shí)間:2021-1-5
發布人(rén):融晨科技
浏覽次數:58
背景
凹凸曼是(shì)個(gè)小程序開發者,他(tā)要(yào / yāo)在(zài)小程序實現秒殺倒計時(shí)。于(yú)是(shì)他(tā)不(bù)假思索,寫了(le/liǎo)以(yǐ)下代碼:
Page({
init: function () {
clearInterval(this.timer)
this.timer = setInterval(() => {
// 倒計時(shí)計算邏輯
console.log('setInterval')
})
},
})
可是(shì),凹凸曼發現頁面隐藏在(zài)後台時(shí),定時(shí)器還在(zài)不(bù)斷運行。于(yú)是(shì)凹凸曼優化了(le/liǎo)一下,在(zài)頁面展示的(de)時(shí)候運行,隐藏的(de)時(shí)候就(jiù)暫停。
Page({
onShow: function () {
if (this.timer) {
this.timer = setInterval(() => {
// 倒計時(shí)計算邏輯
console.log('setInterval')
})
}
},
onHide: function () {
clearInterval(this.timer)
},
init: function () {
clearInterval(this.timer)
this.timer = setInterval(() => {
// 倒計時(shí)計算邏輯
console.log('setInterval')
})
},
})
問題看起來(lái)已經解決了(le/liǎo),就(jiù)在(zài)凹凸曼開心地(dì / de)搓搓小手暗暗歡喜時(shí),突然發現小程序頁面銷毀時(shí)是(shì)不(bù)一定會調用 onHide 函數的(de),這(zhè)樣定時(shí)器不(bù)就(jiù)沒法清理了(le/liǎo)?那可是(shì)會造成内存洩漏的(de)。凹凸曼想了(le/liǎo)想,其實問題不(bù)難解決,在(zài)頁面 onUnload 的(de)時(shí)候也(yě)清理一遍定時(shí)器就(jiù)可以(yǐ)了(le/liǎo)。
Page({
...
onUnload: function () {
clearInterval(this.timer)
},
})
這(zhè)下問題都解決了(le/liǎo),但我們可以(yǐ)發現,在(zài)小程序使用定時(shí)器需要(yào / yāo)很謹慎,一不(bù)小心就(jiù)會造成内存洩漏。後台的(de)定時(shí)器積累得越多,小程序就(jiù)越卡,耗電量也(yě)越大(dà),最終導緻程序卡死甚至崩潰。特别是(shì)團隊開發的(de)項目,很難确保每個(gè)成員都正确清理了(le/liǎo)定時(shí)器。因此,寫一個(gè)定時(shí)器管理庫來(lái)管理定時(shí)器的(de)生命周期,将大(dà)有裨益。
思路整理
首先,我們先設計定時(shí)器的(de) API 規範,肯定是(shì)越接近原生 API 越好,這(zhè)樣開發者可以(yǐ)無痛替換。
function $setTimeout(fn, timeout, ...arg) {}
function $setInterval(fn, timeout, ...arg) {}
function $clearTimeout(id) {}
function $clearInterval(id) {}
接下來(lái)我們主要(yào / yāo)解決以(yǐ)下兩個(gè)問題
-
如何實現定時(shí)器暫停和(hé / huò)恢複
-
如何讓開發者無須在(zài)生命周期函數處理定時(shí)器
如何實現定時(shí)器暫停和(hé / huò)恢複
思路如下:
-
将定時(shí)器函數參數保存,恢複定時(shí)器時(shí)重新創建
-
由于(yú)重新創建定時(shí)器,定時(shí)器 ID 會不(bù)同,因此需要(yào / yāo)自定義全局唯一 ID 來(lái)标識定時(shí)器
-
隐藏時(shí)記錄定時(shí)器剩餘倒計時(shí)時(shí)間,恢複時(shí)使用剩餘時(shí)間重新創建定時(shí)器
首先我們需要(yào / yāo)定義一個(gè) Timer 類,Timer 對象會存儲定時(shí)器函數參數,代碼如下
class Timer {
static count = 0
/**
* 構造函數
* @param {Boolean} isInterval 是(shì)否是(shì) setInterval
* @param {Function} fn 回調函數
* @param {Number} timeout 定時(shí)器執行時(shí)間間隔
* @param {...any} arg 定時(shí)器其他(tā)參數
*/
constructor (isInterval = false, fn = () => {}, timeout = 0, ...arg) {
this.id = ++Timer.count // 定時(shí)器遞增 id
this.fn = fn
this.timeout = timeout
this.restTime = timeout // 定時(shí)器剩餘計時(shí)時(shí)間
this.isInterval = isInterval
this.arg = arg
}
}
// 創建定時(shí)器
function $setTimeout(fn, timeout, ...arg) {
const timer = new Timer(false, fn, timeout, arg)
return timer.id
}
接下來(lái),我們來(lái)實現定時(shí)器的(de)暫停和(hé / huò)恢複,實現思路如下:
-
啓動定時(shí)器,調用原生 API 創建定時(shí)器并記錄下開始計時(shí)時(shí)間戳。
-
暫停定時(shí)器,清除定時(shí)器并計算該周期計時(shí)剩餘時(shí)間。
-
恢複定時(shí)器,重新記錄開始計時(shí)時(shí)間戳,并使用剩餘時(shí)間創建定時(shí)器。
代碼如下:
class Timer {
constructor (isInterval = false, fn = () => {}, timeout = 0, ...arg) {
this.id = ++Timer.count // 定時(shí)器遞增 id
this.fn = fn
this.timeout = timeout
this.restTime = timeout // 定時(shí)器剩餘計時(shí)時(shí)間
this.isInterval = isInterval
this.arg = arg
}
/**
* 啓動或恢複定時(shí)器
*/
start() {
this.startTime = +new Date()
if (this.isInterval) {
/* setInterval */
const cb = (...arg) => {
this.fn(...arg)
/* timerId 爲(wéi / wèi)空表示被 clearInterval */
if (this.timerId) this.timerId = setTimeout(cb, this.timeout, ...this.arg)
}
this.timerId = setTimeout(cb, this.restTime, ...this.arg)
return
}
/* setTimeout */
const cb = (...arg) => {
this.fn(...arg)
}
this.timerId = setTimeout(cb, this.restTime, ...this.arg)
}
/* 暫停定時(shí)器 */
suspend () {
if (this.timeout > 0) {
const now = +new Date()
const nextRestTime = this.restTime - (now - this.startTime)
const intervalRestTime = nextRestTime >=0 ? nextRestTime : this.timeout - (Math.abs(nextRestTime) % this.timeout)
this.restTime = this.isInterval ? intervalRestTime : nextRestTime
}
clearTimeout(this.timerId)
}
}
其中,有幾個(gè)關鍵點需要(yào / yāo)提示一下:
-
恢複定時(shí)器時(shí),實際上(shàng)我們是(shì)重新創建了(le/liǎo)一個(gè)定時(shí)器,如果直接用 setTimeout 返回的(de) ID 返回給開發者,開發者要(yào / yāo) clearTimeout,這(zhè)時(shí)候是(shì)清除不(bù)了(le/liǎo)的(de)。因此需要(yào / yāo)在(zài)創建 Timer 對象時(shí)内部定義一個(gè)全局唯一 ID
this.id = ++Timer.count
,将該 ID 返回給 開發者。開發者 clearTimeout 時(shí),我們再根據該 ID 去查找真實的(de)定時(shí)器 ID (this.timerId)。 -
計時(shí)剩餘時(shí)間,timeout = 0 時(shí)不(bù)必計算;timeout > 0 時(shí),需要(yào / yāo)區分是(shì) setInterval 還是(shì) setTimeout,setInterval 因爲(wéi / wèi)有周期循環,因此需要(yào / yāo)對時(shí)間間隔進行取餘。
-
setInterval 通過在(zài)回調函數末尾調用 setTimeout 實現,清除定時(shí)器時(shí),要(yào / yāo)在(zài)定時(shí)器增加一個(gè)标示位(this.timeId = "")表示被清除,防止死循環。
我們通過實現 Timer 類完成了(le/liǎo)定時(shí)器的(de)暫停和(hé / huò)恢複功能,接下來(lái)我們需要(yào / yāo)将定時(shí)器的(de)暫停和(hé / huò)恢複功能跟組件或頁面的(de)生命周期結合起來(lái),最好是(shì)抽離成公共可複用的(de)代碼,讓開發者無須在(zài)生命周期函數處理定時(shí)器。翻閱小程序官方文檔,發現 Behavior 是(shì)個(gè)不(bù)錯的(de)選擇。
Behavior
behaviors 是(shì)用于(yú)組件間代碼共享的(de)特性,類似于(yú)一些編程語言中的(de) "mixins" 或 "traits"。每個(gè) behavior 可以(yǐ)包含一組屬性、數據、生命周期函數和(hé / huò)方法,組件引用它時(shí),它的(de)屬性、數據和(hé / huò)方法會被合并到(dào)組件中,生命周期函數也(yě)會在(zài)對應時(shí)機被調用。每個(gè)組件可以(yǐ)引用多個(gè) behavior,behavior 也(yě)可以(yǐ)引用其他(tā) behavior 。
// behavior.js 定義behavior
const TimerBehavior = Behavior({
pageLifetimes: {
show () { console.log('show') },
hide () { console.log('hide') }
},
created: function () { console.log('created')},
detached: function() { console.log('detached') }
})
export { TimerBehavior }
// component.js 使用 behavior
import { TimerBehavior } from '../behavior.js'
Component({
behaviors: [TimerBehavior],
created: function () {
console.log('[my-component] created')
},
attached: function () {
console.log('[my-component] attached')
}
})
如上(shàng)面的(de)例子(zǐ),組件使用 TimerBehavior 後,組件初始化過程中,會依次調用 TimerBehavior.created() => Component.created() => TimerBehavior.show()
。因此,我們隻需要(yào / yāo)在(zài) TimerBehavior 生命周期内調用 Timer 對應的(de)方法,并開放定時(shí)器的(de)創建銷毀 API 給開發者即可。思路如下:
-
組件或頁面創建時(shí),新建 Map 對象來(lái)存儲該組件或頁面的(de)定時(shí)器。
-
創建定時(shí)器時(shí),将 Timer 對象保存在(zài) Map 中。
-
定時(shí)器運行結束或清除定時(shí)器時(shí),将 Timer 對象從 Map 移除,避免内存洩漏。
-
頁面隐藏時(shí)将 Map 中的(de)定時(shí)器暫停,頁面重新展示時(shí)恢複 Map 中的(de)定時(shí)器。
const TimerBehavior = Behavior({
created: function () {
this.$store = new Map()
this.$isActive = true
},
detached: function() {
this.$store.forEach(timer => timer.suspend())
this.$isActive = false
},
pageLifetimes: {
show () {
if (this.$isActive) return
this.$isActive = true
this.$store.forEach(timer => timer.start(this.$store))
},
hide () {
this.$store.forEach(timer => timer.suspend())
this.$isActive = false
}
},
methods: {
$setTimeout (fn = () => {}, timeout = 0, ...arg) {
const timer = new Timer(false, fn, timeout, ...arg)
this.$store.set(timer.id, timer)
this.$isActive && timer.start(this.$store)
return timer.id
},
$setInterval (fn = () => {}, timeout = 0, ...arg) {
const timer = new Timer(true, fn, timeout, ...arg)
this.$store.set(timer.id, timer)
this.$isActive && timer.start(this.$store)
return timer.id
},
$clearInterval (id) {
const timer = this.$store.get(id)
if (!timer) return
clearTimeout(timer.timerId)
timer.timerId = ''
this.$store.delete(id)
},
$clearTimeout (id) {
const timer = this.$store.get(id)
if (!timer) return
clearTimeout(timer.timerId)
timer.timerId = ''
this.$store.delete(id)
},
}
})
上(shàng)面的(de)代碼有許多冗餘的(de)地(dì / de)方,我們可以(yǐ)再優化一下,單獨定義一個(gè) TimerStore 類來(lái)管理組件或頁面定時(shí)器的(de)添加、删除、恢複、暫停功能。
class TimerStore {
constructor() {
this.store = new Map()
this.isActive = true
}
addTimer(timer) {
this.store.set(timer.id, timer)
this.isActive && timer.start(this.store)
return timer.id
}
show() {
/* 沒有隐藏,不(bù)需要(yào / yāo)恢複定時(shí)器 */
if (this.isActive) return
this.isActive = true
this.store.forEach(timer => timer.start(this.store))
}
hide() {
this.store.forEach(timer => timer.suspend())
this.isActive = false
}
clear(id) {
const timer = this.store.get(id)
if (!timer) return
clearTimeout(timer.timerId)
timer.timerId = ''
this.store.delete(id)
}
}
然後再簡化一遍 TimerBehavior
const TimerBehavior = Behavior({
created: function () { this.$timerStore = new TimerStore() },
detached: function() { this.$timerStore.hide() },
pageLifetimes: {
show () { this.$timerStore.show() },
hide () { this.$timerStore.hide() }
},
methods: {
$setTimeout (fn = () => {}, timeout = 0, ...arg) {
const timer = new Timer(false, fn, timeout, ...arg)
return this.$timerStore.addTimer(timer)
},
$setInterval (fn = () => {}, timeout = 0, ...arg) {
const timer = new Timer(true, fn, timeout, ...arg)
return this.$timerStore.addTimer(timer)
},
$clearInterval (id) {
this.$timerStore.clear(id)
},
$clearTimeout (id) {
this.$timerStore.clear(id)
},
}
})
此外,setTimeout 創建的(de)定時(shí)器運行結束後,爲(wéi / wèi)了(le/liǎo)避免内存洩漏,我們需要(yào / yāo)将定時(shí)器從 Map 中移除。稍微修改下 Timer 的(de) start 函數,如下:
class Timer {
// 省略若幹代碼
start(timerStore) {
this.startTime = +new Date()
if (this.isInterval) {
/* setInterval */
const cb = (...arg) => {
this.fn(...arg)
/* timerId 爲(wéi / wèi)空表示被 clearInterval */
if (this.timerId) this.timerId = setTimeout(cb, this.timeout, ...this.arg)
}
this.timerId = setTimeout(cb, this.restTime, ...this.arg)
return
}
/* setTimeout */
const cb = (...arg) => {
this.fn(...arg)
/* 運行結束,移除定時(shí)器,避免内存洩漏 */
timerStore.delete(this.id)
}
this.timerId = setTimeout(cb, this.restTime, ...this.arg)
}
}
愉快地(dì / de)使用
從此,把清除定時(shí)器的(de)工作交給 TimerBehavior 管理,再也(yě)不(bù)用擔心小程序越來(lái)越卡。
import { TimerBehavior } from '../behavior.js'
// 在(zài)頁面中使用
Page({
behaviors: [TimerBehavior],
onReady() {
this.$setTimeout(() => {
console.log('setTimeout')
})
this.$setInterval(() => {
console.log('setTimeout')
})
}
})
// 在(zài)組件中使用
Components({
behaviors: [TimerBehavior],
ready() {
this.$setTimeout(() => {
console.log('setTimeout')
})
this.$setInterval(() => {
console.log('setTimeout')
})
}
})
npm 包支持
爲(wéi / wèi)了(le/liǎo)讓開發者更好地(dì / de)使用小程序定時(shí)器管理庫,我們整理了(le/liǎo)代碼并發布了(le/liǎo) npm 包供開發者使用,開發者可以(yǐ)通過 npm install --save timer-miniprogram
安裝小程序定時(shí)器管理庫,文檔及完整代碼詳看 https://github.com/o2team/timer-miniprogram
eslint 配置
爲(wéi / wèi)了(le/liǎo)讓團隊更好地(dì / de)遵守定時(shí)器使用規範,我們還可以(yǐ)配置 eslint 增加代碼提示,配置如下:
// .eslintrc.js
module.exports = {
'rules': {
'no-restricted-globals': ['error', {
'name': 'setTimeout',
'message': 'Please use TimerBehavior and this.$setTimeout instead. see the link: https://github.com/o2team/timer-miniprogram'
}, {
'name': 'setInterval',
'message': 'Please use TimerBehavior and this.$setInterval instead. see the link: https://github.com/o2team/timer-miniprogram'
}, {
'name': 'clearInterval',
'message': 'Please use TimerBehavior and this.$clearInterval instead. see the link: https://github.com/o2team/timer-miniprogram'
}, {
'name': 'clearTimout',
'message': 'Please use TimerBehavior and this.$clearTimout instead. see the link: https://github.com/o2team/timer-miniprogram'
}]
}
}
總結
千裏之(zhī)堤,潰于(yú)蟻穴。
管理不(bù)當的(de)定時(shí)器,将一點點榨幹小程序的(de)内存和(hé / huò)性能,最終讓程序崩潰。
重視定時(shí)器管理,遠離定時(shí)器洩露。