この記事を書いた背景
こちらのZennの記事「【JavaScript】アニメーションの処理負荷を軽減する」(現在は削除されています)を拝見し、そこに記載のあったrequestAnimationFrameの処理の間引き方法や参考URLに記載のあったcreateScrollManager.jsによるスクロール連動の処理の軽減方法を読んで「クラス化して、インスタンスを作成してインスタンス自体を他のクラスにimportすれば一つのスクロール取得メソッドで複数のクラスに値をわたせるのでは?」と思いテストをしました。
使用しているうちに、同じ名前のCallback関数を複数登録して使いたい場合にremoveがうまくいかないケースが発生しました。
そこでCallbackを配列にて登録からObjectにて登録し、add関数でCallback関数を登録する際にIDキーをreturnして、削除する際にはそのIDキーを使って削除する方法を追加しました。
概要及び設定内容
ざっとこんな感じで組み込んでいきます。
- createScrollManager.jsをクラス化
- 「【JavaScript】アニメーションの処理負荷を軽減する - requestAnimationFrame()のフレームレート制限」記載のフレームレート制限を1. に組み込む
- 上記クラスのインスタンスを作成してそれをexport defaultに指定する
- モジュールバンドラとしてWebpack5を使用
- スクロール量を知りたい別クラスに通常のimportにてインスタンスをimportして使用してみる
- 別クラスにてDynamic importで取り込んで使用してみる
とこんな感じで試してみました。
スクロール監視&スクロール量取得クラスの作成
ざっとこんな感じです。
元記事にあるようにスクロールしているときはrequestAnimationFrameを繰り返し走らせて処理を実行しています。
そうするとScrollイベントでのリスナー時よりも若干メモリ使用量が少なくなります。
止まっているときはrequestAnimationFrameを繰り返して実行しているとメモリ使用量が微増するのでそのときは Scrollイベントをリスナーに登録して処理します。
そうすることでメモリ使用量がグッと抑えられます。
その他それほど難し処理はしていないのでコメントを見ていただければすぐにわかると思います。
export class ScrollObserver {
//監視の間引き初期設定は60にしています
constructor(fps = 60) {
this.callbacks = [];//複数のコールバック関数/メソッドを格納するため
this.scrollPosition = -1;
this.animatedKilled = false;//監視を完全に止めるためのフラグ
//間引きのための設定値各種
this.interval = Math.floor(1000 / fps);
this.startTime = performance.now();
this.previousTime = this.startTime;
this.currentTime = 0;
this.elapsed = 0;
//スクロール監視を実行
this.animate();
}
//スクロール監視を実行するメソッド郡
animate = () => {
requestAnimationFrame((timestamp) => {
this.onScroll(timestamp);
}
);
}
onScroll = (timestamp) =>{
if (this.animatedKilled) return;
if (this.scrollPosition !== window.pageYOffset) {
//スクロールしている時の処理
window.removeEventListener('scroll', this.animate);
//fpsを調整する処理
this.currentTime = timestamp;
this.elapsed = this.currentTime - this.previousTime;
if (this.elapsed < this.interval) {
this.animate();
return;
}
this.previousTime = this.currentTime - (this.elapsed % this.interval);
// end
this.scrollPosition = window.pageYOffset;
//登録された各関数/メソッドにスクロール値を引数として渡す
this.callbacks.forEach(cb => cb(this.scrollPosition));
this.animate();
} else {
//スクロールしていない時の処理
window.addEventListener('scroll', this.animate);
}
}
//スクロール値を渡したい外部のメソッド、関数をCallbackに追加処理
add = (cb) => {
this.callbacks = [...this.callbacks,cb];
}
//外部のメソッド、関数をCallbackから削除する処理
remove = (cb) => {
this.callbacks = this.callbacks.filter((val) => val != cb);
}
//スクロール監視自体を破棄
destroy = () => {
this.animatedKilled = true;
window.removeEventListener('scroll', this.animate);
}
}
// class end
export const scrollObserverInstance = new ScrollObserver();
//インスタンスをdefault exportにする
export default scrollObserverInstance;
通常のimportでの使用
普通にimportして、2つのクラスに別々の処理をさせてみます。
簡単なスクロール量に小数点の数値を乗算してconsole.logに表示させるという2つのクラスにてテストしました。
import scrollObserverInstance from './modules/scrollobserver';
class ScrollCheck_1 {
constructor(ratio) {
this.ratio = ratio;
//メソッドはbindを使用してthis固定させ第一引数を渡しています。
scrollObserverInstance.add(this.myCalc.bind(this, this.ratio));
}
myCalc(ratio,scrolled) {
const val = scrolled * ratio;
console.log(`クラス名 ${this.constructor.name}の計算値は ${val.toFixed(2)}` )
}
}
const chk1 = new ScrollCheck_1(0.6);
class ScrollCheck_2 {
constructor(ratio) {
this.ratio = ratio;
scrollObserverInstance.add(this.myCalc.bind(this, this.ratio));
}
myCalc(ratio,scrolled) {
const val = scrolled * ratio;
console.log(`クラス名 ${this.constructor.name}の計算値は ${val.toFixed(2)}` )
}
}
const chk2 = new ScrollCheck_2(0.1);
//console.logに以下のような表示が出て別々のクラスで
//クラス名 ScrollCheck_1の計算値は 530.40
//クラス名 ScrollCheck_2の計算値は 88.40
Dynamic importでのテスト
2つのクラスに別々の処理をさせてみます。
簡単なスクロール量に小数点の数値を乗算してconsole.logに表示させるという2つのクラスにてテストしました。
class ScrollCheck_1 {
constructor(ratio) {
this.ratio = ratio;
this.setting();
}
setting = () => {
//Dynamic import
import('./modules/scrollobserver')
.then((modules) => {
//exprot defaultで指定しているので modules.defaultで取得できます。
//メソッドはbindを使用してthis固定させ第一引数を渡しています。
modules.default.add(this.myCalc.bind(this, this.ratio));
});
}
myCalc(ratio,scrolled) {
const val = scrolled * ratio;
console.log(`クラス名 ${this.constructor.name}の計算値は ${val.toFixed(2)}` )
}
}
const chk1 = new ScrollCheck_1(0.6);
class ScrollCheck_2 {
constructor(ratio) {
this.ratio = ratio;
this.setting();
}
setting = () => {
//Dynamic import
import('./modules/scrollobserver')
.then((modules) => {
//exprot defaultで指定しているので modules.defaultで取得できます。
modules.default.add(this.myCalc.bind(this, this.ratio));
});
}
myCalc(ratio,scrolled) {
const val = scrolled * ratio;
console.log(`クラス名 ${this.constructor.name}の計算値は ${val.toFixed(2)}` )
}
}
const chk2 = new ScrollCheck_2(0.1);
インスタンスscrollObserverInstanceをexport default指定しているので、上記ではmodules.default.add〜
としていますが、直接modules.scrollObserverInstance.add〜
でもいけると思います。
また、全く別のスクロール監視インスタンスを作りたい場合(FPSを違う値にしたものなど)は、
import('./modules/scrollobserver')
.then((modules) => {
const myInstance = new modules.ScrollObserver(30);
myInstance.add(this.myCalc.bind(this, this.ratio));
});
などとすれば作成できます。
* その場合は別々のインスタンスでの監視になります
一つのクラスのインスタンスを使い回すことで多少はメモリの消費を押させられるかなと思いそういったクラスを作ってみました。
使い回しができそうな汎用クラスができてよかったです。
同じ名前のCallback関数を複数登録すると上手くremoveできない
同じ関数名のものを複数登録すると削除できなくなるので、Objectでの登録/削除という方法に変更しました。
キーをUUIDを使って作成して重複しないようにして、キーから削除できるように変更しました。
Callback登録を配列からObjectへ変更
上記の方法だと同じ名前の関数を複数登録するとremoveがおかしな動きになってしまいます。
そこで、以下のようにObjectを使うように修正しました。
またその際にkeyには重複しないようにUUIDを使うためにnpm経由でuuidをインストールして使用しています(要nodejs環境)。
# モジュールをインストールしておきます
$ npm install uuid
+ //CallbackをObject形式で登録する際のキーにUUIDを使用するため
+ //便利なモジュールを追加
+ import { v4 as uuidv4 } from 'uuid';
export class ScrollObserver {
constructor(fps = 60) {
this.callbackObj = {};
this.scrollPosition = -1;
this.animatedKilled = false;
this.interval = Math.floor(1000 / fps);
this.startTime = performance.now();
this.previousTime = this.startTime;
this.currentTime = 0;
this.elapsed = 0;
//スクロール監視を実行
this.animate();
}
+ //スクロール量取得する関数を追加
+ getScrollY() {
+ const scrollY = window.scrollY || document.body.scrollTop || window.pageYOffset;
+ return scrollY;
+ }
//スクロール監視を実行するメソッド郡
animate = () => {
requestAnimationFrame((timestamp) => {
this.onScroll(timestamp);
}
);
}
onScroll = (timestamp) => {
if (this.animatedKilled) return;
//保存された前のスクロール値と今のスクロール値が違う(動いている)場合
if (this.scrollPosition !== this.getScrollY()) {
//一旦イベントリスナー解除
window.removeEventListener('scroll', this.animate);
//fpsを調整
this.currentTime = timestamp;
this.elapsed = this.currentTime - this.previousTime;
if (this.elapsed < this.interval) {
this.animate();
return;
}
//
this.previousTime = this.currentTime - (this.elapsed % this.interval);
this.scrollPosition = this.getScrollY();
Object.entries(this.callbackObj)
.forEach(([ID, cb]) => {
cb(this.scrollPosition);
})
this.animate();
} else {
+ //{passive: true}を追記(event.preventDefault()がないことをブラウザに通知)
+ window.addEventListener('scroll', this.animate, {passive:true});
}
}
//スクロール値を渡したい外部のメソッド、関数をCallbackに追加処理
add = (cb) => {
//配列への登録方法は削除
- this.callbacks = [...this.callbacks,cb];
//キーはユニークなIDを作成して設定新しいCallback用のObjectを作成する処理を追加
+ const uuid = `ID_${uuidv4()}`;
+ const newCallback = {
+ [uuid]: cb,
+ };
+ //
+ Object.assign(this.callbackObj, newCallback);
+ return uuid;
}
//外部のメソッド、関数をCallbackから削除する処理
remove = (uuid) => {
//配列での削除方法は削除
- this.callbacks = this.callbacks.filter((val) => val != cb);
//delete で該当キーのCallbackを削除
+ delete this.callbackObj[uuid];
}
//スクロール監視自体を破棄
destroy = () => {
this.animatedKilled = true;
window.removeEventListener('scroll', this.animate);
}
}
// class end
export const scrollObserverInstance = new ScrollObserver();
//インスタンスをdefault exportにする
export default scrollObserverInstance;
使い方
import scrollObserverInstance from './modules/scrollobserver';
let callbackID;
const myFnc = (v,scrolled) {
//スクロールの値をコンソールへ表示
console.log(scrolled);
};
//Callback登録
callbackID = scrollObserverInstance.add(myFunc.bind(null,'第一引数へ渡したい値'));
//...色々な処理
//Callback削除
scrollObserverInstance.remove(callbackID);
よくあるスクロールイベントを間引く方法とのパフォーマンスの違いのチェック
よくあるaddEventListener('scroll',~)をrequestAnimationFrameで間引く方法でのメモリ消費量などをチェックしてみました。
onScroll関数を以下のようにした場合との比較
//onScrollメソッドを以下のように変更
onScroll = () => {
if (this.animatedKilled) return;
window.addEventListener('scroll', () => {
//メモリー使用量チェック
memoryCheck();
//追加処理
//さらにfpsを調整したい場合にコメントを外す ↓
/*
const fps = 60;
const scrollInterval = Math.floor(1000 / fps);
*/
//間引き処理のためのフラグ判定 falseなら実行
if (!this.scrollTicking) {
requestAnimationFrame((timestamp) => {
//requestAnimationFrameが実行されたらフラグをfalseに
this.scrollTicking = false;
//追加処理
//さらにfpsを調整したい場合にコメントを外す ↓
/*
this.currentTime = timestamp;
this.elapsed = this.currentTime - this.previousTime;
if (this.elapsed < scrollInterval) {
return;
}
this.previousTime = this.currentTime - (this.elapsed % scrollInterval);
*/
//追加フレームレートを調整する場合設定 end
//Callback関数にスクロール量を渡す
Object.entries(this.callbackObj)
.forEach(([ID, cb]) => {
cb(this.getScrollY());
});
});
//間引き処理のためのフラグをtrueにして次のスクロール時での処理を一旦停止させる
this.scrollTicking = true;
}
}, { passive: true });
}
//省略...
//scrollObserverInstance2としてインスタンスをdefault exportにする
export default scrollObserverInstance2;
2つのソースをChatGPTにどちらがメモリ消費量が少ないか質問したところ、scrollObserverInstance2インスタンスの方がメモリ消費量が少ないとのこと。
そこで気になり実際に再度チェックをしたところ、両者ともそれほどメモリ消費量の決定的な差は出なかったです。
たまにscrollObserverInstanceインスタンスの方がメモリ使用量が跳ね上がるときがあるのですが、平均すると大差はない感です。
もっと高負荷なテストをすると違いが出てくるかもしれません。
青い線がscrollObserverInstanceインスタンスでのテスト
赤紫の線がscrollObserverInstance2インスタンスでのテスト
【参考URL】