perf: render timer less frequently

This commit is contained in:
ayo 2026-07-02 20:46:46 +02:00
parent bf26d3e80a
commit 6e6cf2c4f8
3 changed files with 103 additions and 15 deletions

2
.gitignore vendored
View file

@ -1,5 +1,7 @@
node_modules/ node_modules/
dist/ dist/
.claude
*.*~ *.*~
*.*swp *.*swp

View file

@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { TimerService } from '../utils/timer/timer.js' import { TimerService } from '../utils/timer/timer.js'
describe('TimerService.pretty', () => { describe('TimerService.pretty', () => {
@ -31,3 +31,65 @@ describe('TimerService.clean', () => {
expect(timer.clean('05', ':')).toBe('05:') expect(timer.clean('05', ':')).toBe('05:')
}) })
}) })
describe('TimerService rendering', () => {
let display
beforeEach(() => {
vi.useFakeTimers()
display = document.createElement('span')
})
afterEach(() => {
vi.useRealTimers()
})
it('shows 0 before the timer starts', () => {
const timer = new TimerService()
timer.initialize(display)
expect(display.innerHTML).toBe('0')
})
it('drives updates with requestAnimationFrame, not a 1ms interval', () => {
const raf = vi.spyOn(window, 'requestAnimationFrame')
const interval = vi.spyOn(window, 'setInterval')
const timer = new TimerService()
timer.initialize(display)
timer.start()
expect(raf).toHaveBeenCalled()
expect(interval).not.toHaveBeenCalled()
})
it('only touches the DOM when the displayed value changes', () => {
const timer = new TimerService()
timer.initialize(display)
timer.start()
let writes = 0
const span = { set innerHTML(_v) { writes++ } }
timer.display = span
// Same tenth-of-a-second value across frames -> a single DOM write.
timer.time = 1500
timer.render()
timer.render()
timer.render()
expect(writes).toBe(1)
// A new value -> one more write.
timer.time = 1600
timer.render()
expect(writes).toBe(2)
})
it('returns the elapsed time in milliseconds from stop()', () => {
const timer = new TimerService()
timer.initialize(display)
timer.start()
vi.advanceTimersByTime(2500)
timer.tick()
expect(timer.stop()).toBe(2500)
})
})

View file

@ -1,11 +1,11 @@
import { LoggerService } from '../logger/logger' import { LoggerService } from '../logger/logger'
const INTERVAL = 1
export class TimerService { export class TimerService {
constructor() { constructor() {
this.loggerService = new LoggerService() this.loggerService = new LoggerService()
this.time = 0
this.rendered = undefined
} }
initialize(el) { initialize(el) {
@ -13,33 +13,58 @@ export class TimerService {
this.display = el this.display = el
this.startTime = undefined this.startTime = undefined
if (this.id) { if (this.id !== undefined) {
this.stop() this.stop()
} }
this.updateDisplay() this.time = 0
this.render()
} }
start() { start() {
if (this.running || !this.display) return if (this.running || !this.display) return
this.running = true this.running = true
this.startTime = new Date().getTime() this.startTime = Date.now()
this.id = window.setInterval(() => this.updateDisplay(), INTERVAL) this.tick()
this.loggerService.debug(`started timer id: ${this.id}`) this.loggerService.debug('started timer')
} }
stop() { stop() {
this.running = false this.running = false
clearInterval(this.id) if (this.id !== undefined) {
this.loggerService.debug(`stopped timer id: ${this.id}`) window.cancelAnimationFrame(this.id)
}
this.id = undefined this.id = undefined
this.loggerService.debug('stopped timer')
return this.time return this.time
} }
updateDisplay() { /**
let currentTime = new Date().getTime() - this.startTime * Recompute the elapsed time and schedule the next frame.
this.time = Math.floor(currentTime / INTERVAL) * Driven by requestAnimationFrame so it aligns with the browser's paint
this.display.innerHTML = this.pretty(this.time) || '0' * cadence and pauses automatically when the tab is hidden instead of the
* old fixed 1ms interval that fired ~1000 times a second.
*/
tick() {
this.time = Date.now() - this.startTime
this.render()
if (this.running) {
this.id = window.requestAnimationFrame(() => this.tick())
}
}
/**
* Write to the DOM only when the visible value actually changes. The display
* has 100ms (tenths-of-a-second) resolution, so most frames are a no-op and
* cost no reflow.
*/
render() {
if (!this.display) return
const text = this.pretty(this.time) || '0'
if (text !== this.rendered) {
this.display.innerHTML = text
this.rendered = text
}
} }
pretty(duration) { pretty(duration) {
@ -60,4 +85,3 @@ export class TimerService {
return (str === '00') ? '' : `${str}${separator}` return (str === '00') ? '' : `${str}${separator}`
} }
} }