📖
良いコード悪いコードを読んでフロントエンドの実装を改善してみた!
はじめに
- この記事では、近年売れている「良いコード悪いコードで学ぶ設計入門(本)」からフロントエンドにおける良い実装とは何か?を考え、特に改善できそうな部分をピックアップして紹介したいと思います。
- ぜひ、この記事を読んでコードの書き方を改めるきっかけにしてもらえたらと思います。
関心の分離
- 第10章「名前設計」(p.206)にて紹介されています。
- 関心事は別々に分離して管理しようという考え方です。
- 色々な処理を1つの場所にまとめて書くのではなく、関心単位(機能、ビジネスロジック単位)で切り出して管理することが重要です。
- 以下でReactにおける関心の分離を考えてみました。
UIとロジックを分離
- フロントエンドでの大きな関心事は「UI」と「ロジック」があります。
- 短いコードであればUIとロジックをまとめて記述してもいいと思いますが、ロジックが長くなる場合はカスタムフック等へ移動させ、責務を分けて管理した方がスッキリすると思います。
コンポーネントとデータ取得を分離
- API呼び出し処理とコンポーネントを分離することで、コンポーネントがAPIのことを気にする必要がなくなります。
- 具体的には、
api/ディレクトリ等でAPIの処理を定義し、コンポーネント側で呼び出すようにするのがよくある例かと思います。
機能やビジネスロジックごとに分離
- 例えば、Package by featureデザインパターンを使用することで、feature(機能)単位で関心事を分けることができます。
Package by featureの例:
src/
features/
todo/ (機能)
components/
hooks/
services/
user/ (機能)
components/
hooks/
services/
横断的関心事
- 第5章「低凝集」(p.68)にて紹介されています。
- あるロジックが複数の機能において活用できる場合に、ビジネスロジックから切り離し、共通ロジックとして管理することを横断的関心事と良い、アスペクト指向プログラミング(AOP)の考え方です。
共通ロジックはutils等の階層で管理する
- ビジネスロジックに依存せず、どこでも共通して使われるようなロジックは共通ロジックとして管理することが望ましいです。
- 例えば日付フォーマット関数はビジネスロジックに依存しないかつ汎用的であるため、共通ロジックとして管理できます。
// utils/format.ts
/**
* 日付をフォーマットする関数
*/
export const formatDate = (date: Date): string => {
const yyyy = date.getFullYear();
const mm = String(date.getMonth() + 1).padStart(2, '0');
const dd = String(date.getDate()).padStart(2, '0');
return `${yyyy}/${mm}/${dd}`;
};
密結合と疎結合
- 第8章「密結合 ー絡まって解きほぐせない構造ー」(p.142)にて紹介されています。
- 密結合と疎結合について簡単に説明すると、
- 密結合:モジュールやコンポーネント同士が互いに高く依存している状態
- 疎結合:モジュールやコンポーネント同士が互いに依存していない状態
- 一般的には「疎結合」を目指すことが望ましいとされています。
フロントエンドにおける密結合とは
- フロントにおける密結合で意識すべきところはコンポーネントだと思います。
- コンポーネントは自己完結した再利用可能な部品として機能させる、つまり疎結合にすることが望ましいです。
-
改善前:密結合とされるコンポーネントの作り
-
ProfileCardはUserAvatar,UserBioに強く依存している形になっています。
-
export default function ProfileCard({ user }) {
return (
<div className="card">
{/* 直接具体的なコンポーネントを呼んでいる */}
<UserAvatar imageUrl={user.avatarUrl} />
<UserBio bio={user.bio} />
</div>
);
}
- 改善後:引数からコンポーネントを受け取るようにして疎結合を実現する
export default function ProfileCard({ user, AvatarComponent, BioComponent }) {
return (
<div className="card">
{/* どのコンポーネントを使うかは外部から指定 */}
<AvatarComponent imageUrl={user.avatarUrl} />
<BioComponent bio={user.bio} />
</div>
);
}
- その他様々な結合の種類があるみたいです。
参照透過性
-
第4章「不変の活用 ー安定動作を構築するー」の4.2.5 「不変にして予期せぬ動作を防ぐ」(p.49)で小さく紹介されています。
-
参照透過性とは同じ引数で呼び出した関数は常に同じ結果を返却するという性質のことです。
-
当書ではオブジェクト指向をベースとしていますが、一部宣言型プログラミングでも活用されている概念も取り入れて説明されています。
-
Reactではコンポーネントは純粋(参照透過)であることが望ましいとされています。
-
つまり、同じ
propsを渡したら同じレンダリング結果を返すと言うことです。 -
ReactではStrict Modeで開発時にはレンダリングが2回実行されるようになっており、2回とも同じ結果が返るかをチェックする仕組みがあります。
- 副作用性のある処理が存在する場合はuseEffectとクリーンアップ関数を使って適切に管理することができます。
useEffect(() => {
const connection = createConnection();
connection.connect();
return () => {
connection.disconnect(); // アンマウント時実行
};
}, []);
- 4.2.4「関数の影響範囲を限定する」(p.49)では、副作用を排除できない場合でも関数の影響範囲を小さくするように工夫することが重要であると言われています。
副作用をコンポーネントの外に出す
「引数で状態を受け取り、状態を変更せず、値を返すだけの関数が理想です。」
4.2.4「関数の影響範囲を限定する」(p.49)から
- 例えば、URLのクエリパラメータはグローバル変数であると捉えることができ、それを変更することは副作用が発生し、純粋性は失われます。
- 以下の例は、タブ切り替えの状態をクエリパラメータで持つように書かれています。
-
Tabsコンポーネント内でパラメータの変更まで行った方がすっきりするように見えますが、コンポーネント自体に副作用を持ってしまうため純粋性が失われます。 - また、他の開発者がコードを読むときに副作用がどこで発生したのかを気付けない可能性があります。
- 改善前:コンポーネント自体に副作用を持つ
// Tabs.tsx
export default function Tabs() {
const handleTabChange =
(id: string) => {
const newParams = new URLSearchParams(searchParams.toString());
newParams.set('id', id);
// ここで副作用が発生する
router.replace(`?${newParams.toString()}`);
};
return (
<div className="flex gap-2">
<button onClick={() => handleTabChange(1)}>タブ1</button>
<button onClick={() => handleTabChange(2)}>タブ2</button>
</div>
)
}
- 改善後:副作用を外に出す
// page.tsx
export default function Page() {
const handleTabChange =
(id: string) => {
const newParams = new URLSearchParams(searchParams.toString());
newParams.set('id', id);
router.replace(`?${newParams.toString()}`);
};
return (
<Tabs
activeTabId={activeTabId}
onChange={handleTabChange} // onChangeで状態の変更を渡す
/>
);
}
switchの代わりにmapを使う
- 6.6「フラグ引数」(p.127)でswitch文の代替として、Mapインターフェースを用いた実装が紹介されています。
- switch文はコードの行数が長くなるほどコードが読みにくくなっていきます。
- それにより、条件式がわかりにくくなったり、同じ条件式を書いてしまったりする可能性が高くなります。
- 以下の例ではswitch文で認証状態の分岐を行っているコードです。
- この程度のコード量でも条件式が見にくいと感じてしまいます。
- 改善前:
function handleEvent(eventType, data) {
switch (eventType) {
case 'login':
const username = data.username;
const time = new Date();
console.log(`[LOGIN] ${username} at ${time.toISOString()}`);
break;
case 'logout':
const logoutTime = new Date();
console.log(`[LOGOUT] User ${data.username} logged out at ${logoutTime.toISOString()}`);
break;
case 'error':
console.error(`[ERROR] Code: ${data.code}, Message: ${data.message}`);
break;
default:
console.warn('Unknown event type:', eventType);
}
}
- JavaScriptではMapオブジェクトがあるので、switchからMapに置き換えてみます。
- 処理がまとまっていて見やすくなったと思います。
- 改善後:
const handleLogin = (data) => {
const username = data.username;
const time = new Date();
console.log(`[LOGIN] ${username} at ${time.toISOString()}`);
}
const handleLogout = (data) => {
const logoutTime = new Date();
console.log(`[LOGOUT] User ${data.username} logged out at ${logoutTime.toISOString()}`);
}
const handleError = (data) => {
console.error(`[ERROR] Code: ${data.code}, Message: ${data.message}`);
}
const handlers = new Map([
['login', handleLogin],
['logout', handleLogout],
['error', handleError]
]);
const handleEvent(eventType, data) => {
const handler = handlers.get(eventType);
if (handler) {
handler(data);
} else {
console.warn('Unknown event type:', eventType);
}
}
Mapを使うことのメリット
- case内の処理が長くなると可読性が下がるが、関数として切り出すことで解消できます。
- caseはスコープ範囲がわかりにくくなります。
- switchを排除することで循環的複雑度(CC)を下げられます。
以下を参考にしました。
例外の握り潰し
- 9.7「例外の握り潰し」(p.191)にて紹介されています。
- 例外が発生した時にcatch句で何も処理しないと、外部から検出できないのでとても厄介です。
- 例外時に
console.logでログ出力だけで終わっているのも過去にありました。 - 対策としては、判別可能なユニオン型 (discriminated union)を使って正常系または異常系を返すようにする方法があります。
Result型
- 明示的に例外を発生させる場合は
throwではなくResult型を定義します。 - これにより、エラーハンドリングの実装漏れを型エラーで教えてくれるようになるのと、呼び出し側のtry-catchを無くし簡潔に書くことができます。
type Result<T> = {
ok: true,
value: T
} | {
ok: false,
error: string,
}
neverthrow
- neverthrowというtry-catchを使わないエラーハンドリングに使える専用のライブラリがあるみたいです。
- ですが、TypeScriptでのResult型の使用は賛否分かれるようで、使わない方がいいという意見もあります。個人的には使ってみたいですが。
デッドコード
- コードを書いていると、不要なimportや変数を残していたり、どの条件でも到達できない到達不可能コードを書いてしまうことがあります。
- これをデッドコードと呼び、その場で気付いて修正すれば問題ないですが、そのまま放置されるとコードが読みにくくなったり、思わぬバグの原因になったりします。
- Knip という静的解析ツールを使うと、TypeScript や JavaScript のプロジェクト内で未使用の型、関数、クラス、依存関係などを解析し、削除すべきものをリストアップしてくれます。
- 到達不可能コードも検出してくれるかはわかりません。
使う時になったら実装する(YAGNI原則)
- 第9章「デッドコード」では、使われない処理は可読性を下げたり、後にバグを引き起こしたりするため書くべきではないと説明されています。
- コンポーネントを作る際など、柔軟に使えるように汎用的な実装に時間を使ってしまいがちですが、現状の必要な実装がクリアできればそれで良いのだと思います。
- 今後拡張が必要ならその時になったら実装するという具合です。
Point-Free Style
- 当書には書かれていないですが、当書をきっかけに知ることができた設計手法を紹介します。
- Point-Free Styleは関数合成などで使われる概念で、引数(point)を省略する書き方です。
- xなど意味のない引数や変数を使う必要がなくなりコードがスッキリします。
- よくあるやつだと
filter(Boolean)がありますね。 -
filter((x) ⇒ !!x)のような書き方よりずっとシンプルでわかりやすいと思います。 - その他キャスト系:
map(Number)map(String)map(Boolean)
- Point-Free Styleを意識するとシンプルにわかりやすくコードが書けそうです。
- また、ヘルパー関数等はPoint-Free Styleに適用しやすいように合成可能にしておくことも大事なのかなと思いました。
感想
- この本を読んでから、レビュー観点がより綿密に見えるようになったり、なんとなくお作法的に書くべきだと思っていたことを体系化することができスッキリしました。
- なんとなく当たり前に書いている書き方には、実は名前があったり原則に当てはめることができたりと、自分の中で整理することができました。
これから読む方に向けて
- 当書の内容は基本的にオブジェクト指向ベースで書かれています。そのため読んだままにフロントエンドに活用できるものは少ないです。
- 当書の設計改善アプローチで出てきた手法やキーワードなどを調べて、様々な記事を読んでみることをおすすめします。そうするととても面白いと思います。
- また、当書の内容は意見が分かれる部分も多々あるようなので、他の記事や本も比較しながら読み進めました。
Discussion