1
0
mirror of https://github.com/videojs/video.js.git synced 2024-12-04 10:34:51 +02:00

feat: Add named requestAnimationFrame to prevent performance issues (#6627)

Make sure we don't create multiple rAFs particularly when in a background tab.

Fixes #5937
This commit is contained in:
Brandon Casey 2020-06-19 14:22:04 -04:00 committed by Gary Katsevman
parent 4d9e1bcccc
commit 6e7cc75aaa
9 changed files with 282 additions and 52 deletions

View File

@ -13,6 +13,8 @@ import * as Guid from './utils/guid.js';
import {toTitleCase, toLowerCase} from './utils/string-cases.js'; import {toTitleCase, toLowerCase} from './utils/string-cases.js';
import mergeOptions from './utils/merge-options.js'; import mergeOptions from './utils/merge-options.js';
import computedStyle from './utils/computed-style'; import computedStyle from './utils/computed-style';
import Map from './utils/map.js';
import Set from './utils/set.js';
/** /**
* Base class for all UI Components. * Base class for all UI Components.
@ -100,38 +102,10 @@ class Component {
this.childIndex_ = {}; this.childIndex_ = {};
this.childNameIndex_ = {}; this.childNameIndex_ = {};
let SetSham; this.setTimeoutIds_ = new Set();
this.setIntervalIds_ = new Set();
if (!window.Set) { this.rafIds_ = new Set();
SetSham = class { this.namedRafs_ = new Map();
constructor() {
this.set_ = {};
}
has(key) {
return key in this.set_;
}
delete(key) {
const has = this.has(key);
delete this.set_[key];
return has;
}
add(key) {
this.set_[key] = 1;
return this;
}
forEach(callback, thisArg) {
for (const key in this.set_) {
callback.call(thisArg, key, key, this);
}
}
};
}
this.setTimeoutIds_ = window.Set ? new Set() : new SetSham();
this.setIntervalIds_ = window.Set ? new Set() : new SetSham();
this.rafIds_ = window.Set ? new Set() : new SetSham();
this.clearingTimersOnDispose_ = false; this.clearingTimersOnDispose_ = false;
// Add any child components in options // Add any child components in options
@ -1529,6 +1503,53 @@ class Component {
return id; return id;
} }
/**
* Request an animation frame, but only one named animation
* frame will be queued. Another will never be added until
* the previous one finishes.
*
* @param {string} name
* The name to give this requestAnimationFrame
*
* @param {Component~GenericCallback} fn
* A function that will be bound to this component and executed just
* before the browser's next repaint.
*/
requestNamedAnimationFrame(name, fn) {
if (this.namedRafs_.has(name)) {
return;
}
this.clearTimersOnDispose_();
fn = Fn.bind(this, fn);
const id = this.requestAnimationFrame(() => {
fn();
if (this.namedRafs_.has(name)) {
this.namedRafs_.delete(name);
}
});
this.namedRafs_.set(name, id);
return name;
}
/**
* Cancels a current named animation frame if it exists.
*
* @param {string} name
* The name of the requestAnimationFrame to cancel.
*/
cancelNamedAnimationFrame(name) {
if (!this.namedRafs_.has(name)) {
return;
}
this.cancelAnimationFrame(this.namedRafs_.get(name));
this.namedRafs_.delete(name);
}
/** /**
* Cancels a queued callback passed to {@link Component#requestAnimationFrame} * Cancels a queued callback passed to {@link Component#requestAnimationFrame}
* (rAF). * (rAF).
@ -1578,11 +1599,15 @@ class Component {
this.clearingTimersOnDispose_ = true; this.clearingTimersOnDispose_ = true;
this.one('dispose', () => { this.one('dispose', () => {
[ [
['namedRafs_', 'cancelNamedAnimationFrame'],
['rafIds_', 'cancelAnimationFrame'], ['rafIds_', 'cancelAnimationFrame'],
['setTimeoutIds_', 'clearTimeout'], ['setTimeoutIds_', 'clearTimeout'],
['setIntervalIds_', 'clearInterval'] ['setIntervalIds_', 'clearInterval']
].forEach(([idName, cancelName]) => { ].forEach(([idName, cancelName]) => {
this[idName].forEach(this[cancelName], this); // for a `Set` key will actually be the value again
// so forEach((val, val) =>` but for maps we want to use
// the key.
this[idName].forEach((val, key) => this[cancelName](key));
}); });
this.clearingTimersOnDispose_ = false; this.clearingTimersOnDispose_ = false;

View File

@ -72,7 +72,7 @@ class LoadProgressBar extends Component {
* @listens Player#progress * @listens Player#progress
*/ */
update(event) { update(event) {
this.requestAnimationFrame(() => { this.requestNamedAnimationFrame('LoadProgressBar#update', () => {
const liveTracker = this.player_.liveTracker; const liveTracker = this.player_.liveTracker;
const buffered = this.player_.buffered(); const buffered = this.player_.buffered();
const duration = (liveTracker && liveTracker.isLive()) ? liveTracker.seekableEnd() : this.player_.duration(); const duration = (liveTracker && liveTracker.isLive()) ? liveTracker.seekableEnd() : this.player_.duration();

View File

@ -133,7 +133,7 @@ class SeekBar extends Slider {
update(event) { update(event) {
const percent = super.update(); const percent = super.update();
this.requestAnimationFrame(() => { this.requestNamedAnimationFrame('SeekBar#update', () => {
const currentTime = this.player_.ended() ? const currentTime = this.player_.ended() ?
this.player_.duration() : this.getCurrentTime_(); this.player_.duration() : this.getCurrentTime_();
const liveTracker = this.player_.liveTracker; const liveTracker = this.player_.liveTracker;

View File

@ -128,12 +128,7 @@ class TimeTooltip extends Component {
* for tooltips that need to do additional animations from the default * for tooltips that need to do additional animations from the default
*/ */
updateTime(seekBarRect, seekBarPoint, time, cb) { updateTime(seekBarRect, seekBarPoint, time, cb) {
// If there is an existing rAF ID, cancel it so we don't over-queue. this.requestNamedAnimationFrame('TimeTooltip#updateTime', () => {
if (this.rafId_) {
this.cancelAnimationFrame(this.rafId_);
}
this.rafId_ = this.requestAnimationFrame(() => {
let content; let content;
const duration = this.player_.duration(); const duration = this.player_.duration();

View File

@ -81,7 +81,7 @@ class TimeDisplay extends Component {
this.formattedTime_ = time; this.formattedTime_ = time;
this.requestAnimationFrame(() => { this.requestNamedAnimationFrame('TimeDisplay#updateTextNode_', () => {
if (!this.contentEl_) { if (!this.contentEl_) {
return; return;
} }

View File

@ -249,7 +249,7 @@ class Slider extends Component {
this.progress_ = progress; this.progress_ = progress;
this.requestAnimationFrame(() => { this.requestNamedAnimationFrame('Slider#update', () => {
// Set the new bar width or height // Set the new bar width or height
const sizeKey = this.vertical() ? 'height' : 'width'; const sizeKey = this.vertical() ? 'height' : 'width';

28
src/js/utils/map.js Normal file
View File

@ -0,0 +1,28 @@
import window from 'global/window';
class MapSham {
constructor() {
this.map_ = {};
}
has(key) {
return key in this.map_;
}
delete(key) {
const has = this.has(key);
delete this.map_[key];
return has;
}
set(key, value) {
this.set_[key] = value;
return this;
}
forEach(callback, thisArg) {
for (const key in this.map_) {
callback.call(thisArg, this.map_[key], key, this);
}
}
}
export default window.Map ? window.Map : MapSham;

28
src/js/utils/set.js Normal file
View File

@ -0,0 +1,28 @@
import window from 'global/window';
class SetSham {
constructor() {
this.set_ = {};
}
has(key) {
return key in this.set_;
}
delete(key) {
const has = this.has(key);
delete this.set_[key];
return has;
}
add(key) {
this.set_[key] = 1;
return this;
}
forEach(callback, thisArg) {
for (const key in this.set_) {
callback.call(thisArg, key, key, this);
}
}
}
export default window.Set ? window.Set : SetSham;

View File

@ -952,7 +952,7 @@ QUnit.test('should provide interval methods that automatically get cleared on co
assert.ok(intervalsFired === 5, 'Interval was cleared when component was disposed'); assert.ok(intervalsFired === 5, 'Interval was cleared when component was disposed');
}); });
QUnit.test('should provide *AnimationFrame methods that automatically get cleared on component disposal', function(assert) { QUnit.test('should provide a requestAnimationFrame method that is cleared on disposal', function(assert) {
const comp = new Component(getFakePlayer()); const comp = new Component(getFakePlayer());
const oldRAF = window.requestAnimationFrame; const oldRAF = window.requestAnimationFrame;
const oldCAF = window.cancelAnimationFrame; const oldCAF = window.cancelAnimationFrame;
@ -967,7 +967,7 @@ QUnit.test('should provide *AnimationFrame methods that automatically get cleare
const spyRAF = sinon.spy(); const spyRAF = sinon.spy();
comp.requestAnimationFrame(spyRAF); comp.requestNamedAnimationFrame('testing', spyRAF);
assert.strictEqual(spyRAF.callCount, 0, 'rAF callback was not called immediately'); assert.strictEqual(spyRAF.callCount, 0, 'rAF callback was not called immediately');
this.clock.tick(1); this.clock.tick(1);
@ -975,11 +975,11 @@ QUnit.test('should provide *AnimationFrame methods that automatically get cleare
this.clock.tick(1); this.clock.tick(1);
assert.strictEqual(spyRAF.callCount, 1, 'rAF callback was not called after a second "repaint"'); assert.strictEqual(spyRAF.callCount, 1, 'rAF callback was not called after a second "repaint"');
comp.cancelAnimationFrame(comp.requestAnimationFrame(spyRAF)); comp.cancelNamedAnimationFrame(comp.requestNamedAnimationFrame('testing', spyRAF));
this.clock.tick(1); this.clock.tick(1);
assert.strictEqual(spyRAF.callCount, 1, 'second rAF callback was not called because it was cancelled'); assert.strictEqual(spyRAF.callCount, 1, 'second rAF callback was not called because it was cancelled');
comp.requestAnimationFrame(spyRAF); comp.requestNamedAnimationFrame('testing', spyRAF);
comp.dispose(); comp.dispose();
this.clock.tick(1); this.clock.tick(1);
assert.strictEqual(spyRAF.callCount, 1, 'third rAF callback was not called because the component was disposed'); assert.strictEqual(spyRAF.callCount, 1, 'third rAF callback was not called because the component was disposed');
@ -988,7 +988,43 @@ QUnit.test('should provide *AnimationFrame methods that automatically get cleare
window.cancelAnimationFrame = oldCAF; window.cancelAnimationFrame = oldCAF;
}); });
QUnit.test('*AnimationFrame methods fall back to timers if rAF not supported', function(assert) { QUnit.test('should provide a requestNamedAnimationFrame method that is cleared on disposal', function(assert) {
const comp = new Component(getFakePlayer());
const oldRAF = window.requestAnimationFrame;
const oldCAF = window.cancelAnimationFrame;
// Stub the window.*AnimationFrame methods with window.setTimeout methods
// so we can control when the callbacks are called via sinon's timer stubs.
window.requestAnimationFrame = (fn) => window.setTimeout(fn, 1);
window.cancelAnimationFrame = (id) => window.clearTimeout(id);
// Make sure the component thinks it supports rAF.
comp.supportsRaf_ = true;
const spyRAF = sinon.spy();
comp.requestNamedAnimationFrame('testing', spyRAF);
assert.strictEqual(spyRAF.callCount, 0, 'rAF callback was not called immediately');
this.clock.tick(1);
assert.strictEqual(spyRAF.callCount, 1, 'rAF callback was called after a "repaint"');
this.clock.tick(1);
assert.strictEqual(spyRAF.callCount, 1, 'rAF callback was not called after a second "repaint"');
comp.cancelNamedAnimationFrame(comp.requestNamedAnimationFrame('testing', spyRAF));
this.clock.tick(1);
assert.strictEqual(spyRAF.callCount, 1, 'second rAF callback was not called because it was cancelled');
comp.requestNamedAnimationFrame('testing', spyRAF);
comp.dispose();
this.clock.tick(1);
assert.strictEqual(spyRAF.callCount, 1, 'third rAF callback was not called because the component was disposed');
window.requestAnimationFrame = oldRAF;
window.cancelAnimationFrame = oldCAF;
});
QUnit.test('requestAnimationFrame falls back to timers if rAF not supported', function(assert) {
const comp = new Component(getFakePlayer()); const comp = new Component(getFakePlayer());
const oldRAF = window.requestAnimationFrame; const oldRAF = window.requestAnimationFrame;
const oldCAF = window.cancelAnimationFrame; const oldCAF = window.cancelAnimationFrame;
@ -1030,7 +1066,7 @@ QUnit.test('setTimeout should remove dispose handler on trigger', function(asser
comp.dispose(); comp.dispose();
}); });
QUnit.test('requestAnimationFrame should remove dispose handler on trigger', function(assert) { QUnit.test('requestNamedAnimationFrame should remove dispose handler on trigger', function(assert) {
const comp = new Component(getFakePlayer()); const comp = new Component(getFakePlayer());
const oldRAF = window.requestAnimationFrame; const oldRAF = window.requestAnimationFrame;
const oldCAF = window.cancelAnimationFrame; const oldCAF = window.cancelAnimationFrame;
@ -1045,13 +1081,15 @@ QUnit.test('requestAnimationFrame should remove dispose handler on trigger', fun
const spyRAF = sinon.spy(); const spyRAF = sinon.spy();
comp.requestAnimationFrame(spyRAF); comp.requestNamedAnimationFrame('testFrame', spyRAF);
assert.equal(comp.rafIds_.size, 1, 'we got a new dispose handler'); assert.equal(comp.rafIds_.size, 1, 'we got a new raf dispose handler');
assert.equal(comp.namedRafs_.size, 1, 'we got a new named raf dispose handler');
this.clock.tick(1); this.clock.tick(1);
assert.equal(comp.rafIds_.size, 0, 'we removed our dispose handle'); assert.equal(comp.rafIds_.size, 0, 'we removed our raf dispose handle');
assert.equal(comp.namedRafs_.size, 0, 'we removed our named raf dispose handle');
comp.dispose(); comp.dispose();
@ -1169,6 +1207,122 @@ QUnit.test('setInterval should be canceled on dispose', function(assert) {
assert.equal(called, false, 'setInterval was never called'); assert.equal(called, false, 'setInterval was never called');
}); });
QUnit.test('requestNamedAnimationFrame should be canceled on dispose', function(assert) {
const comp = new Component(getFakePlayer());
let called = false;
let clearName;
const setName = comp.requestNamedAnimationFrame('testing', () => {
called = true;
});
const cancelNamedAnimationFrame = comp.cancelNamedAnimationFrame;
comp.cancelNamedAnimationFrame = (name) => {
clearName = name;
return cancelNamedAnimationFrame.call(comp, name);
};
assert.equal(comp.namedRafs_.size, 1, 'we added a named raf');
assert.equal(comp.rafIds_.size, 1, 'we added a raf id');
comp.dispose();
assert.equal(comp.namedRafs_.size, 0, 'we removed a named raf');
assert.equal(comp.rafIds_.size, 0, 'we removed a raf id');
assert.equal(clearName, setName, 'cancelNamedAnimationFrame was called');
this.clock.tick(1);
assert.equal(called, false, 'requestNamedAnimationFrame was never called');
});
QUnit.test('requestNamedAnimationFrame should only allow one raf of a specific name at a time', function(assert) {
const comp = new Component(getFakePlayer());
const calls = {
one: 0,
two: 0,
three: 0
};
const cancelNames = [];
const name = 'testing';
const handlerOne = () => {
assert.equal(comp.namedRafs_.size, 1, 'named raf still exists while function runs');
assert.equal(comp.rafIds_.size, 0, 'raf id does not exist during run');
calls.one++;
};
const handlerTwo = () => {
assert.equal(comp.namedRafs_.size, 1, 'named raf still exists while function runs');
assert.equal(comp.rafIds_.size, 0, 'raf id does not exist during run');
calls.two++;
};
const handlerThree = () => {
assert.equal(comp.namedRafs_.size, 1, 'named raf still exists while function runs');
assert.equal(comp.rafIds_.size, 0, 'raf id does not exist during run');
calls.three++;
};
const oldRAF = window.requestAnimationFrame;
const oldCAF = window.cancelAnimationFrame;
// Make sure the component thinks it supports rAF.
comp.supportsRaf_ = true;
// Stub the window.*AnimationFrame methods with window.setTimeout methods
// so we can control when the callbacks are called via sinon's timer stubs.
window.requestAnimationFrame = (fn) => window.setTimeout(fn, 1);
window.cancelAnimationFrame = (id) => window.clearTimeout(id);
const cancelNamedAnimationFrame = comp.cancelNamedAnimationFrame;
comp.cancelNamedAnimationFrame = (_name) => {
cancelNames.push(_name);
return cancelNamedAnimationFrame.call(comp, _name);
};
comp.requestNamedAnimationFrame(name, handlerOne);
assert.equal(comp.namedRafs_.size, 1, 'we added a named raf');
assert.equal(comp.rafIds_.size, 1, 'we added a raf id');
comp.requestNamedAnimationFrame(name, handlerTwo);
assert.deepEqual(cancelNames, [], 'no named cancels');
assert.equal(comp.namedRafs_.size, 1, 'still only one named raf');
assert.equal(comp.rafIds_.size, 1, 'still only one raf id');
this.clock.tick(1);
assert.equal(comp.namedRafs_.size, 0, 'we removed a named raf');
assert.equal(comp.rafIds_.size, 0, 'we removed a raf id');
assert.deepEqual(calls, {
one: 1,
two: 0,
three: 0
}, 'only handlerOne was called');
comp.requestNamedAnimationFrame(name, handlerOne);
comp.requestNamedAnimationFrame(name, handlerTwo);
comp.requestNamedAnimationFrame(name, handlerThree);
assert.deepEqual(cancelNames, [], 'no named cancels for testing');
assert.equal(comp.namedRafs_.size, 1, 'only added one named raf');
assert.equal(comp.rafIds_.size, 1, 'only added one named raf');
this.clock.tick(1);
assert.equal(comp.namedRafs_.size, 0, 'we removed a named raf');
assert.equal(comp.rafIds_.size, 0, 'we removed a raf id');
assert.deepEqual(calls, {
one: 2,
two: 0,
three: 0
}, 'only the handlerOne called');
window.requestAnimationFrame = oldRAF;
window.cancelAnimationFrame = oldCAF;
});
QUnit.test('$ and $$ functions', function(assert) { QUnit.test('$ and $$ functions', function(assert) {
const comp = new Component(getFakePlayer()); const comp = new Component(getFakePlayer());
const contentEl = document.createElement('div'); const contentEl = document.createElement('div');