Intersection Observer APIを使ってスクロールに応じてデータを取得する方法

## はじめに
42TokyoでWebアプリケーションを作る課題に取り組んでいる中で学んだ実装方法を残しておくためにまとめておこうと思います。
webアプリやモバイルアプリの個人開発は過去にしたことがありましたが、当時は訳も分からずネットの情報をコピペして作っていたため事実上全くの初心者として課題に取り組んでいます。
そのため今回の課題では普段自分が使っているアプリで気になっていた動作を実装してみるつもりでいます。
今回の記事はその中の一つとなります。
※あくまで自分用なのであまり説明は記載していません。

## 実装したかったもの
Xやinstagramなどコンテンツが大量にあるアプリケーションで必ずと言っていいほどみる「スクロールに応じてデータを取得し表示する」動作がどのように実装されているのか気になっていました。
今回はモバイルアプリではないためブラウザのAPIを使用しますが、後述するサーバー側の処理方法は概ね同じであると思うので、モバイルでも大した差はないと思います。

## コード内で使用している技術スタック
- Fastify + Node.js
- TailWindCSS
- Prisma

## フロントエンド

本課題ではフレンド機能を実装するという要件があるため、アカウントのプロフィール画面に表示するフレンドリストは題材に実装しました。
まずは表示するリストをHTMLに以下の様に記述します。
```html
<ul id="friend-list" style="max-height: 150px; overflow-y: scroll;"></ul>
```

次に上記のリストを動的に表示するためのスクリプトを用意します。
ここで使用するのが「*Intersection Observer API*」です。
https://developer.mozilla.org/ja/docs/Web/API/Intersection_Observer_API

```typescript
type Friend = {
	friendshipId: number;
	friend: {
		id: number;
		name: string;
		email: string;
		imageId: string;
	};
};

let friends: Friend[] = [];
let cursor: number | null = null;
let hasMoreFriend: boolean = true;

const listElement = document.getElementById('friend-list');

await loadFriends();

const options = {
	root: null,
	rootMargin: '0px',
	threshold: 0.25
};

const observer = new IntersectionObserver(intersect, options);

observeLastFriend();

function observeLastFriend() {
	if (!hasMoreFriend || friends.length === 0) return;
	const items = listElement?.querySelectorAll('li');
	const lastItem = items?.[items.length - 1];
	if (lastItem) observer.observe(lastItem);
}

function intersect(entries: IntersectionObserverEntry[]) {
	for (const entry of entries) {
		if (entry.isIntersecting) {
			observer.unobserve(entry.target);
			loadFriends().then(() => {
				observeLastFriend();
			});
		}
	}
}

async function loadFriends() {
	if (!hasMoreFriend) return;

	const res = await fetch(`/profile/friends?cursor=${cursor ?? ''}`, {
		headers: {Authorization: `Bearer ${token}`}
	});
	const data = await res.json();

	const newFriends: Friend[] = data.friends;
	hasMoreFriend = data.hasMore;
	cursor = data.cursor;

	friends = [...friends, ...newFriends];

	newFriends.forEach((f) => {
		const li = document.createElement('li');
		li.textContent = f.friend.name;
		listElement?.appendChild(li);
	});
}
```

## バックエンド
次にサーバー側の処理で主要な関数を記載します。
本来であれば引数のlimitに応じたレコード数を取得するクエリを書きたかったのですが、勉強不足で書けなかったため全て取得してから、配列をスライスしています。
UIで表示するためだけであればFriend[]を保存しておく必要はありませんが、選択して何かしらのアクションを用意する予定なので読み込んだデータは配列で保存しておきます。

```typescript
import {decodeJwt} from './decodeJwt.js';

type Friend = {
	friendshipId: number;
	friend: {
		id: number;
		name: string;
		email: string;
		imageId: string;
	};
};

type ProfileState = {
	friends: Friend[];
	cursor: number | null;
	hasMoreFriend: boolean;
	listElement: HTMLElement | null;
	observer: IntersectionObserver | null;
	userName: string;
	userId: number;
	imageId: string;
};

export async function renderProfile() {
	let state: ProfileState = {
		friends: [],
		cursor: 0,
		hasMoreFriend: true,
		listElement: document.getElementById('friend-list'),
		observer: null,
		userName: decodeJwt()?.userName,
		userId: decodeJwt()?.userId,
		imageId: decodeJwt()?.imageId
	};
	// const {userName, imageId} = token;

	await loadFriends(state);

	state.observer = createFriendListObserver(state);
	observeLastFriend(state);
}

function observeLastFriend(state: ProfileState) {
	if (!state.hasMoreFriend || state.friends.length === 0) return;
	const items = state.listElement?.querySelectorAll('li');
	const lastItem = items?.[items.length - 1];
	if (lastItem) state.observer?.observe(lastItem);
}

function createFriendListObserver(state: ProfileState) {

	const options = {
		root: null,
		rootMargin: '0px',
		threshold: 0.25
	};
	
	const intersect =
		(entries: IntersectionObserverEntry[],
			observer: IntersectionObserver
		) => {
			for (const entry of entries) {
				if (entry.isIntersecting) {
					observer.unobserve(entry.target); // 一旦解除
					loadFriends(state).then(() => {
						observeLastFriend(state); // 新しい末尾に再度observerつける
					});
				}
			}
		};
	return new IntersectionObserver(intersect, options);
}


async function loadFriends(state: ProfileState) {
	if (!state.hasMoreFriend) return;

	const res = await fetch(`/profile/friends?cursor=${state.cursor ?? ''}`, {
		headers: {Authorization: `Bearer ${state.cursor}`}
	});
	const data = await res.json();

	const newFriends: Friend[] = data.friends;
	state.hasMoreFriend = data.hasMore;
	state.cursor = data.cursor;

	state.friends = [...state.friends, ...newFriends];

	newFriends.forEach((f) => {
		const li = document.createElement('li');
		li.className =
			'flex items-center gap-3 py-2 border-b border-[color:var(--color-surface)]';

		const img = document.createElement('img');
		img.src = `/images/${f.friend.imageId}`;
		img.alt = `${f.friend.name}のプロフィール画像`;
		img.className = 'w-10 h-10 rounded-full object-cover';

		const span = document.createElement('span');
		span.textContent = f.friend.name;
		span.className =
			'text-base text-[color:var(--color-white-strong)] font-medium';

		li.appendChild(img);
		li.appendChild(span);
		state.listElement?.appendChild(li);
	});
}

```

## 実際の動作
まだフレンドリストの作成ができていないので簡単なリストでの動作確認になってしまいますが、実際にはこの様な動作になります。
今回はデモ用のデータを用意するのが手間だったためlimitを2にしてfetchする回数を増やしています。
![demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3823284/86c723e4-8160-4d3c-80a4-caa0faf9e943.gif)




## 最後に
初めてのTypescriptなので綺麗なコードではないですが、ずっと気になっていた動作が実装できて満足しています。
他にもいくつか興味のある機能があるので都度まとめていこうと思います。