はじめに
初めてのweb開発でTypeScriptを使っています。
今回はSPAのページを書いてる途中で状態を保存するオブジェクトをそれぞれの関数への渡し方について学びがあったのでまとめておきます。
分割代入
分割代入とはオブジェクトのプロパティ名と同じ名前の変数を左辺におき、右辺にオブジェクトをおくとそのプロパティの値を変数に代入してくれるJavaScriptの構文です。
参考
const obj = { a, b, c };
const { a, b, c } = obj;
// 次の文と同等
// const a = obj.a, b = obj.b, c = obj.c;
const obj = { prop1: x, prop2: y, prop3: z };
const { prop1: x, prop2: y, prop3: z } = obj;
// 次の文と同等
// const x = obj.prop1, y = obj.prop2, z = obj.prop3;
ちなみに分割代入はJavaScriptの構文であり、TypeScript固有のものではありません。
ケーススタディ
まず私が分割代入を忘れて書いていたコードは以下の通りです。
開発途中ですので、関数内の処理に関しては目を瞑ってください。
exportしているrenderProfile()を軸として読むのがいいかと思います。
問題点はFriendStateとMatchStateを全ての関数の引数に設定しており、関数内でどのプロパティを使用しているかが関数の処理を読まないと理解できない点です。
import type {
Team,
Tournament
} from '../../backend/prisma/generated/prisma/index.js';
type Friend = {
friendshipId: number;
friend: {
id: number;
name: string;
email: string;
imageId: string;
};
};
type FriendState = {
friends: Friend[];
cursor: number | null;
hasMore: boolean;
listElement: HTMLElement | null;
observer: IntersectionObserver | null;
};
type Match = {
matchId: number;
homeTeam: Team;
awayTeam: Team;
winner: Team;
tournament: Tournament;
startAt: Date;
endAt: Date;
homeScore: number;
awayScore: number;
};
type MatchState = {
matches: Match[];
cursor: number | null;
hasMore: boolean;
listElement: HTMLElement | null;
observer: IntersectionObserver | null;
};
export async function renderProfile() {
const friendState: FriendState = {
friends: [],
cursor: 0,
hasMore: true,
listElement: document.getElementById('friend-list'),
observer: null
};
const matchState: MatchState = {
matches: [],
cursor: 0,
hasMore: true,
listElement: document.getElementById('match-list'),
observer: null
};
// const token = localStorage.getItem('authToken');
// if (!token) {
// // トークンがない場合、ログイン画面にリダイレクトなど
// return;
// }
// const {userName, imageId} = decodeJwt(token);
const userName = 'demo';
const imageId = 'demo';
createPage(userName, imageId);
await loadFriends(friendState);
await loadMatches(matchState);
friendState.observer = createFriendListObserver(friendState);
observeLastFriend(friendState);
matchState.observer = createMatchListObserver(matchState);
observeLastMatch(matchState);
}
function observeLastFriend(state: FriendState) {
if (!state.hasMore || 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: FriendState) {
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);
});
}
}
};
return new IntersectionObserver(intersect, options);
}
async function loadFriends(state: FriendState) {
if (!state.hasMore) return;
const res = await fetch(`/profile/friends?cursor=${state.cursor ?? ''}`, {
// headers: {Authorization: `Bearer ${token}`}
});
const data = await res.json();
const newFriends: Friend[] = data.friends;
state.hasMore = data.hasMore;
state.cursor = data.cursor;
state.friends = [...state.friends, ...newFriends];
newFriends.forEach((f) => {
const li = document.createElement('li');
// ・
// ・
// ・
li.appendChild(img);
li.appendChild(span);
state.listElement?.appendChild(li);
});
}
async function loadMatches(state: MatchState) {
if (!state.hasMore) return;
const res = await fetch(`/profile/matches?cursor=${state.cursor ?? ''}`, {
// headers: {Authorization: `Bearer ${token}`, }
});
const data = await res.json();
const newMatches: Match[] = data.matches;
state.hasMore = data.hasMore;
state.cursor = data.cursor;
state.matches = [...state.matches, ...newMatches];
newMatches.forEach((f) => {
const li = document.createElement('li');
// ・
// ・
// ・
li.appendChild(span);
state.listElement?.appendChild(li);
});
}
function observeLastMatch(state: MatchState) {
if (!state.hasMore || state.matches.length === 0) return;
const items = state.listElement?.querySelectorAll('li');
const lastItem = items?.[items.length - 1];
if (lastItem) state.observer?.observe(lastItem);
}
function createMatchListObserver(state: MatchState) {
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);
loadMatches(state).then(() => {
observeLastMatch(state);
});
}
}
};
return new IntersectionObserver(intersect, options);
}
修正例
以下のコードではobserveLastFrined()の引数を分割代入に変更しています。
こうすることで呼び出し元はFriendStateのインスタンスを引数に渡すだけで良く、observeLastFriend()はFriendStateの中で使用しているプロパティが明確になります。
function observeLastFriend({hasMore, friends, listElement, observer}: FriendState) {
if (!hasMore || friends.length === 0) return;
const items = listElement?.querySelectorAll('li');
const lastItem = items?.[items.length - 1];
if (lastItem) observer?.observe(lastItem);
}
function createFriendListObserver(state: FriendState) {
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);
});
}
}
};
return new IntersectionObserver(intersect, options);
}
分割代入の注意点
さて引き続き分割代入に変更していきます。
次はloadFriends()を修正していたのですが、ここで分割代入の罠にはまりました。
一度コードを読んで問題点を探してみてください。
async function loadFriends({hasMore, cursor, friends, listElement}: FriendState) {
if (!hasMore) return;
const res = await fetch(`/profile/friends?cursor=${cursor ?? ''}`, {
// headers: {Authorization: `Bearer ${token}`}
});
const data = await res.json();
const newFriends: Friend[] = data.friends;
hasMore = data.hasMore;
cursor = data.cursor;
friends = [...friends, ...newFriends];
newFriends.forEach((f) => {
const li = document.createElement('li');
//・
//・
//・
li.appendChild(img);
li.appendChild(span);
listElement?.appendChild(li);
});
}
一見問題無い様に見えますが、問題なのは以下の箇所です。
hasMore = data.hasMore;
cursor = data.cursor;
問題の原因は変数代入時のコピー方法にあります。
JavaScriptの代入ではプリミティブ型は値渡し、オブジェクトは参照渡しになるのです。
以下に例を挙げます。
// 値渡し
let preA = 10;
let preB = 0;
preB = preA;
preB++;
console.log(`preA=${preA} preB=${preB}`);
// 参照渡し
let objA = {num: 10};
let objB = objA;
objB.num++;
console.log(`objA.num=${objA.num} objB.num=${objB.num}`);
出力
preA=10 preB=11
objA.num=11 objB.num=11
プリミティブ型の場合はコピー元が変更されず、オブジェクトはコピー元も変更されていることが分かります。
これは分割代入でも同じのため注意が必要です。
そのため、以下の2行の代入は呼び出し元のオブジェクトには影響しないため期待している動作にはなりません。
hasMore = data.hasMore;
cursor = data.cursor;
これに対する銀の弾丸的なものはなく、関数の戻り値で変更する値を返すなどが必要になります。それはそれで嬉しくないので、今回はプロパティを変更する関数には変更を加えないことにしました。
他にいい解決策を発見したらまた記事にしようと思います。
終わりに
今回はJavaScriptの分割代入を自身のコードをリファクタリングするという目線で学びました。C/C++では常に意識していた代入の際のコピーを改めて意識するとてもいい機会になりました。
本当はcreateMatchListObserver()とcreateFriendListObserver()などは共通化したいので時間があるのときに取り組んでみます。