【JavaScript】スクロール量取得専用クラスのインスタンスを一つだけ作成して複数の別のクラスのメソッドに値を渡す

# この記事を書いた背景
こちらのZennの記事「[【JavaScript】アニメーションの処理負荷を軽減する](https://zenn.dev/sakata_kazuma/articles/83997ac6644c5e)」（現在は削除されています）を拝見し、そこに記載のあったrequestAnimationFrameの処理の間引き方法や参考URLに記載のあった[createScrollManager.js](https://gist.github.com/tylerchilds/6ce0bb6e200b3cb797c843dc4ade425b)によるスクロール連動の処理の軽減方法を読んで「クラス化して、インスタンスを作成してインスタンス自体を他のクラスにimportすれば一つのスクロール取得メソッドで複数のクラスに値をわたせるのでは？」と思いテストをしました。

:::note info
使用しているうちに、同じ名前のCallback関数を複数登録して使いたい場合にremoveがうまくいかないケースが発生しました。
そこでCallbackを配列にて登録からObjectにて登録し、add関数でCallback関数を登録する際にIDキーをreturnして、削除する際にはそのIDキーを使って削除する方法を追加しました。

[同じ名前のcallback関数を複数登録すると上手くremoveできない](#同じ名前のcallback関数を複数登録すると上手くremoveできない)
:::


# 概要及び設定内容

ざっとこんな感じで組み込んでいきます。
1. [createScrollManager.js](https://gist.github.com/tylerchilds/6ce0bb6e200b3cb797c843dc4ade425b)をクラス化
1. 「[【JavaScript】アニメーションの処理負荷を軽減する - requestAnimationFrame()のフレームレート制限](https://zenn.dev/sakata_kazuma/articles/83997ac6644c5e#requestanimationframe()%E3%81%AE%E3%83%95%E3%83%AC%E3%83%BC%E3%83%A0%E3%83%AC%E3%83%BC%E3%83%88%E5%88%B6%E9%99%90)」記載のフレームレート制限を1. に組み込む
1. 上記クラスのインスタンスを作成してそれをexport defaultに指定する
1. モジュールバンドラとしてWebpack5を使用
1. スクロール量を知りたい別クラスに通常のimportにてインスタンスをimportして使用してみる
1. 別クラスにてDynamic importで取り込んで使用してみる

とこんな感じで試してみました。



# スクロール監視＆スクロール量取得クラスの作成

ざっとこんな感じです。
[元記事](https://zenn.dev/sakata_kazuma/articles/83997ac6644c5e)にあるようにスクロールしているときはrequestAnimationFrameを繰り返し走らせて処理を実行しています。
そうするとScrollイベントでのリスナー時よりも若干メモリ使用量が少なくなります。
止まっているときはrequestAnimationFrameを繰り返して実行しているとメモリ使用量が微増するのでそのときは　Scrollイベントをリスナーに登録して処理します。
そうすることでメモリ使用量がグッと抑えられます。

その他それほど難し処理はしていないのでコメントを見ていただければすぐにわかると思います。

```javascript
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つのクラスにてテストしました。

```javascript
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つのクラスにてテストしました。

```javascript
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);
```

:::note info
インスタンスscrollObserverInstanceをexport default指定しているので、上記では```modules.default.add〜```としていますが、直接```modules.scrollObserverInstance.add〜```でもいけると思います。
:::
:::note info
また、全く別のスクロール監視インスタンスを作りたい場合（FPSを違う値にしたものなど）は、
```js
    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](https://github.com/uuidjs/uuid)をインストールして使用しています（要nodejs環境）。

```bash
# モジュールをインストールしておきます
$ npm install uuid
```

```diff_JavaScript
+ //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;

```
### 使い方
```JavaSCript
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関数を以下のようにした場合との比較
```JavaScript:scrollObserverInstance2の一部分
    //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インスタンスの方がメモリ使用量が跳ね上がるときがあるのですが、平均すると大差はない感です。
もっと高負荷なテストをすると違いが出てくるかもしれません。


![スクリーンショット 2023-09-03 11.47.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/34746/8211923b-ee5d-6717-5695-23aa8d460580.png)
青い線がscrollObserverInstanceインスタンスでのテスト
赤紫の線がscrollObserverInstance2インスタンスでのテスト




【参考URL】
1. [【JavaScript】アニメーションの処理負荷を軽減する](https://zenn.dev/sakata_kazuma/articles/83997ac6644c5e)
1. [createScrollManager.js](https://gist.github.com/tylerchilds/6ce0bb6e200b3cb797c843dc4ade425b)
1. [JavaScriptのモジュールは変数をエクスポートする](https://qiita.com/uhyo/items/6b111815ef5495f4e490)
1. [全モダンブラウザで使えるJavaScriptのdynamic import（動的読み込み）](https://qiita.com/tonkotsuboy_com/items/f672de5fdd402be6f065)