もっとシンプルに無限スクロールをネイティブで実装する

JavaScript の Intersection Observer API を使用し、コンテンツを遅延読み込みして div を描画するような、無限スクロールを実装します。

Twitter のタイムラインを下に辿っていくような表示になります (上にスクロールして新しいコンテンツを読むような機能は、ここでは実装しません) 。

# 1. はじめに

自分が WEB 上で調べてみたところ、下記の記事が最初に出てきたのですが、サンプルコードにバグを含んでいて、解説通りの動作をしませんでした…。

また、古い JavaScript の書き方をしていたり、本質的ではないコードが大半をしめていたり、then の中で then するような書き方をしていました。

参考「[jQueryにはもう頼らない！無限スクロールをネイティブで実装する最新テクニック – WPJ](https://www.webprofessional.jp/intersectionobserver-api/)」

本記事では、なるべく本質的な部分だけを記述し、シンプルに分かりやすく実装したいと思います。

# 2. サンプル

## 2.1. コード

画面下に到達するたびに、新しいコンテンツを読み込みます。

ここでは、30 件まで読み込んだらそれ以上読み込みません。

```html:index.html
<meta charset="UTF-8">

<div id="contents"></div>

<script src="./fetch-dummy.js"></script>
<script src="./main.js"></script>
```

```js:main.js
(() => {

	const contents = document.getElementById('contents');

	// 
	const infiniteScrollObserver = new IntersectionObserver(entries => {
		entries.forEach(entry => {
			if ( ! entry.isIntersecting ) return;

			infiniteScrollObserver.unobserve(entry.target);
			loadContent();

		});
	});

	// 
	let i = 0;
	const max = 30;

	const loadContent = async () => {

		const response = await fetchDummy('https://example.com/load?i=' + i);

		contents.insertAdjacentHTML('beforeend',
				'<div>' +
				'#' + (i + 1) + '<br>' +
				await response.text() +
				'</div>');

		// 
		i++;

		if ( i < max ) infiniteScrollObserver.observe(contents.lastElementChild);

	};

	// 
	loadContent();

})();
```

```js:fetch-dummy.js
(() => {

	window.fetchDummy = url => new Promise(resolve => {
		setTimeout(() => {
			const content = ('Content '.repeat(6) + '<br>').repeat(3) + url + '<br><br>';
			resolve({ text: async () => content });
		}, 100)
	});

})();
```

※ `fetchDummy()` は疑似的に `fetch()` するための関数で、実用的に使用するコードではありません。 

## 2.2. 動作確認環境

- Chrome 81
- Firefox 77
- Edge Legacy 44

IE で動作させるには、トランスコンパイルや Polyfill が必要です。

未確認ですが、IE 以外ならおおよそ動作すると思われます。

# 3. 説明

## 3.1. おおまかな流れ

以下の 1, 2 と 3, 4 を繰り返します。

1. 新しいコンテンツを読み込む
2. そのコンテンツを監視する
3. 画面上にあったら監視をやめる
4. 次のコンテンツの読み込みを開始する

## 3.2. `IntersectionObserver` を作る

上記の「おおまかな流れ」の 3, 4 に相当する部分を実装します。

以下は、`IntersectionObserver` のテンプレートのような記述です。

```js
    const infiniteScrollObserver = new IntersectionObserver(entries => {
        entries.forEach(entry => {
            if ( ! entry.isIntersecting ) return;

            // ...

        });
    });
```

ここでは、本質的なコードは以下の 2 行のみです。

```js
            infiniteScrollObserver.unobserve(entry.target);
            loadContent();
```

監視対象である `entry.target` を unobserve し、次に `loadContent()` 関数で非同期にコンテンツの読み込みを開始しています。

(ちなみに、あらかじめコンテンツのデータをすべて読み込んでいるなど、非同期に読み込む必要がない場合も同様のコードで記述できます。)

参考「[Intersection Observer API - Web API | MDN](https://developer.mozilla.org/ja/docs/Web/API/Intersection_Observer_API)」

## 3.3. `loadContent()` の実装

上記の「おおまかな流れ」の 1, 2 に相当する部分を実装します。

コンテンツのデータを読み込み、DOM に要素を追加し、その要素を observe します。

コンテンツの終端の判定は場合により異なると思いますが、ここでは単に `max = 30` 件まで読み込んだらもう observe しないようにしました。

※ `fetchDummy()` は疑似的に `fetch()` するための関数で、実用的に使用するコードではありません。 

## 3.4 おまけ: 読み込み中などの表示

`loadContent()` 内で要素にクラスを付け外し、CSS で表示を切り替えれば、コードをあまり複雑にすることなく、スマートに実装できます。

```js:loadContent()
	const loadContent = async () => {

		contents.insertAdjacentHTML('beforeend', '<div class="loading"></div>');

		const content = contents.lastElementChild;

		// 
		const response = await fetchDummy('https://example.com/load?i=' + i);

		content.classList.remove('loading');

		content.insertAdjacentHTML('beforeend',
				'#' + (i + 1) + '<br>' +
				await response.text());
```

```css:CSS
.loading:before {
	content: 'Loading ...';
}
```

## 3.5. おまけ: 非同期ジェネレータを使用して拡張しやすくする

コンテンツの合間に見出しや広告を挿入するなどを考えると、コンテンツの読み込みを非同期ジェネレータで定義したほうが拡張しやすくなります。

※ Edge Legacy では非同期イテレータ・非同期ジェネレータはサポートされていません。

```js:loadContent()
	// 
	const loadContent = async () => {

		const nextContent = await contentsIterable.next();

		if ( ! nextContent.done )
			infiniteScrollObserver.observe(nextContent.value);

	};

	const contentsIterable = (async function*() {

		for (let i = 0; i < 30; i++) {

			// 
			const response = await fetchDummy('https://example.com/load?i=' + i);

			contents.insertAdjacentHTML('beforeend',
					'<div>' +
					'#' + (i + 1) + '<br>' +
					await response.text() +
					'</div>');

			yield contents.lastElementChild;

		}

	})();
```

# 4. その他

レスポンシブ対応や、一度に読み込む件数の調整などは、必要に応じて機能を追加してください。

そもそも無限スクロールにすべきかどうかも場合によりますので、何も考えずに使用するのは避けるべきです。

個人的に、Twitter の非公式クライアントなどを作成する場合に無限スクロールを使用すると、良いと思います。