| title | コンポーネントを純粋に保つ |
|---|
JavaScript 関数の中には、純関数 (pure function) と呼ばれるものがあります。純関数とは計算だけを行い、他には何もしない関数のことです。コンポーネントを常に厳密に純関数として書くことで、コードベースが成長するにつれて起きがちな、あらゆる種類の不可解なバグ、予測不可能な挙動を回避することができます。ただし、このようなメリットを得るためには、従わなければならないルールがいくつか存在します。
- 「純粋」であるとは何か、それによりなぜバグが減らせるのか
- 変更をレンダーの外で行い、コンポーネントを純粋に保つ方法
- Strict Mode を使用してコンポーネントの間違いを見つける方法
コンピュータサイエンス(特に関数型プログラミングの世界)では、純関数 (pure function) とは、以下のような特徴を持つ関数のことを指します。
- 自分の仕事に集中する。呼び出される前に存在していたオブジェクトや変数を変更しない。
- 同じ入力には同じ出力。同じ入力を与えると、純関数は常に同じ結果を返す。
皆さんは純関数の例をひとつ、すでにご存知のはずです。数学における関数です。
この数式を考えてみてください:y = 2x。
もし x = 2 ならば y = 4。常にです。
もし x = 3 ならば y = 6。常にです。
もし x = 3 ならば、y が現在時刻や株式市況に影響されてたまに 9 や –1 や 2.5 になったりはしません。
もし y = 2x かつ x = 3 なら、y はどんな場合でも常に 6 になるのです。
この式を JavaScript 関数で書くとすると、次のようになります:
function double(number) {
return 2 * number;
}上記の例では、double 関数は純関数です。もし 3 を渡すと、6 を返しますね。常にです。
React はこのような概念に基づいて設計されています。React は、あなたが書くすべてのコンポーネントが純関数であると仮定しています。つまり、あなたが書く React コンポーネントは、与えられた入力が同じであれば、常に同じ JSX を返す必要があります。
function Recipe({ drinkers }) {
return (
<ol>
<li>Boil {drinkers} cups of water.</li>
<li>Add {drinkers} spoons of tea and {0.5 * drinkers} spoons of spice.</li>
<li>Add {0.5 * drinkers} cups of milk to boil and sugar to taste.</li>
</ol>
);
}
export default function App() {
return (
<section>
<h1>Spiced Chai Recipe</h1>
<h2>For two</h2>
<Recipe drinkers={2} />
<h2>For a gathering</h2>
<Recipe drinkers={4} />
</section>
);
}drinkers={2} を Recipe に渡すと、2 cups of water を含む JSX が返されます。常にです。
drinkers={4} を渡すと、4 cups of water を含む JSX が返されます。常にです。
そう、まるで数式のように、です。
コンポーネントとはレシピのようなものだと考えることもできるでしょう。調理途中で新しい食材を加えたりせず、レシピに従っておけば、常に同じ料理を得ることができます。その「料理」とは、コンポーネントが React に提供する JSX のことであり、それを React が表示します。
React のレンダープロセスは常に純粋である必要があります。コンポーネントは JSX を返すだけであり、レンダー前に存在していたオブジェクトや変数を書き換えしないようにしなければなりません。さもなくばコンポーネントは不純 (impure) になってしまいます!
以下は、この規則を守っていないコンポーネントの例です。
let guest = 0;
function Cup() {
// Bad: changing a preexisting variable!
guest = guest + 1;
return <h2>Tea cup for guest #{guest}</h2>;
}
export default function TeaSet() {
return (
<>
<Cup />
<Cup />
<Cup />
</>
);
}このコンポーネントは、外部で宣言された guest 変数を読み書きしています。つまり、このコンポーネントを複数回呼び出すと、異なる JSX が生成されます! さらに悪いことに、ほかのコンポーネントも guest を読み取る場合、それらもレンダーされたタイミングによって異なる JSX を生成することになります! これでは予測不可能です。
数式 y = 2x の例に戻ると、これは x = 2 であっても y = 4 であることが保証されない、というようなことです。テストは失敗し、ユーザは当惑し、飛行機も空から墜落しかねません。こんなことをするとなぜ混乱するバグが引き起こされるのか、もうおわかりですね。
props を使って guest を渡すように、このコンポーネントを修正できます。
function Cup({ guest }) {
return <h2>Tea cup for guest #{guest}</h2>;
}
export default function TeaSet() {
return (
<>
<Cup guest={1} />
<Cup guest={2} />
<Cup guest={3} />
</>
);
}これでこのコンポーネントは純粋になります。返す JSX が props である guest のみに依存しているからです。
一般に、特定の順序でコンポーネントがレンダーされることを期待してはいけません。y = 2x と y = 5x のどちらを先に呼ぶかなど問題にしてはいけないのです。これらの数式は互いに無関係に計算されるべきです。同じように、各コンポーネントは「自分のことだけを考える」べきであり、レンダーの最中に他のコンポーネントに依存したり他のコンポーネントと協調したりすることはありません。レンダーとは学校の試験のようなものです。各コンポーネントはそれぞれ、自分の力だけで JSX を計算する必要があるのです!
まだ全部を使ったことはないかもしれませんが、React には props、state、そしてコンテクストという、レンダー中に読み取ることができる 3 種類の入力値があります。これらの入力値は、常に読み取り専用として扱うようにしてください。
ユーザ入力に応じて何かを変更したい場合は、変数に書き込む代わりに、state を設定することが適切です。要素のレンダー中に既存の変数やオブジェクトを書き換えることは絶対にやってはいけません。
React には "Strict Mode" という機能があり、開発中には各コンポーネント関数を 2 回呼び出します。関数呼び出しを 2 回行うことで、Strict Mode はこれらのルールに反するコンポーネントを見つけるのに役立ちます。
元の例では "Guest #1"、"Guest #2"、"Guest #3" と表示される代わりに "Guest #2"、"Guest #4"、"Guest #6" と表示されてしまっていましたね。元の関数が純粋でなかったため、2 回呼び出すと壊れていたわけです。修正された純粋なバージョンは、毎回 2 回呼び出されても問題ありません。純関数は計算をするだけなので、2 回呼び出しても何も変わりません。double(2) を 2 回呼び出しても返り値が変わることはなく、y = 2x を 2 回解いても y が変わることがないのと全く同じです。入力が同じならば、出力も同じにしてください。常にそうしてください。
Strict Mode は本番環境では影響を与えないため、ユーザが使うアプリを遅くすることはありません。Strict Mode を有効にするには、ルートコンポーネントを <React.StrictMode> でラップします。一部のフレームワークでは、これがデフォルトで行われます。
上記の例では、問題はコンポーネントがレンダーの最中に既存の変数を変更していた点にありました。このような変更は、少し恐ろしい言い方では "ミューテーション(変異; mutation)" と呼ばれます。純関数は、関数のスコープ外の変数や、呼び出し前に作成されたオブジェクトをミューテートしません。そういうことをしてしまった関数は「不純」になってしまいます!
しかし、レンダー中にその場で作成した変数やオブジェクトであれば、書き換えることは全く問題ありません。この例では、[] 配列を作成して cups 変数に代入し、それに 12 個のカップを push しています:
function Cup({ guest }) {
return <h2>Tea cup for guest #{guest}</h2>;
}
export default function TeaGathering() {
const cups = [];
for (let i = 1; i <= 12; i++) {
cups.push(<Cup key={i} guest={i} />);
}
return cups;
}cups 変数または [] 配列が TeaGathering 関数の外で作成されたものだった場合、これは大きな問題になることでしょう! 既存のオブジェクトを変更してしまうことになるからです。
しかし、cups 変数と [] 配列は、TeaGathering 内で同一のレンダー中に作成されたものであるため、問題はありません。TeaGathering 以外のコードは、これが起こったことすら知るすべがありません。これは "ローカルミューテーション (local mutation)" と呼ばれます。あなたのコンポーネント内のちょっとした秘密のようなものです。
関数型プログラミングには純粋性が重要であるとはいえ、いつか、どこかの場所で、何らかのものが変化しなければなりません。むしろそれがプログラミングをする意味というものでしょう。これらの変化(スクリーンの更新、アニメーションの開始、データの変更など)は 副作用 (side effect) と呼ばれます。レンダーの最中には発生しない、「付随的」なものです。
React では、副作用は通常、イベントハンドラの中に属します。イベントハンドラは、ボタンがクリックされたといった何らかのアクションが実行されたときに React が実行する関数です。イベントハンドラは、コンポーネントの「内側」で定義されているものではありますが、レンダーの「最中」に実行されるわけではありません! つまり、イベントハンドラは純粋である必要はありません。
いろいろ探してもあなたの副作用を書くのに適切なイベントハンドラがどうしても見つからない場合は、コンポーネントから返された JSX に useEffect 呼び出しを付加することで副作用を付随させることも可能です。これにより React に、その関数をレンダーの後(その時点なら副作用が許されます)で呼ぶように指示できます。ただしこれは最終手段であるべきです。
可能な限り、ロジックをレンダーのみで表現してみてください。これだけでどれだけのことができるのか、驚くことでしょう!
純関数を書くことには、多少の習慣化と訓練が必要です。しかし、それは素晴らしいチャンスをもたらすものでもあります。
- コンポーネントが異なる環境、例えばサーバ上でも実行できるようになります! 入力値が同じなら同じ結果を返すので、ひとつのコンポーネントが多数のユーザリクエストを処理できます。
- 入力値が変化しない場合、レンダーをスキップすることでパフォーマンスを向上できます。これが問題ないのは、純関数は常に同じ出力を返すため安全にキャッシュできるからです。
- 深いコンポーネントツリーのレンダーの途中でデータが変化した場合、React は既に古くなったレンダー処理を最後まで終わらせるような無駄を省き、新しいレンダーを開始できます。純粋性のおかげで、いつ計算を中断しても問題ありません。
我々が開発する React の新たな機能は常に、関数の純粋性を活用しています。データ取得からアニメーション、パフォーマンスの向上に到るまで、React パラダイムの威力はコンポーネントを純関数に保つことによって発揮されるのです。
- コンポーネントは純粋である必要がある。すなわち:
- コンポーネントは自分の仕事に集中する。レンダー前に存在していたオブジェクトや変数を書き換えない。
- 入力が同じなら出力も同じ。同じ入力に対しては、常に同じ JSX を返すようにする。
- レンダーはいつでも起こる可能性があるため、コンポーネントは相互の呼び出し順に依存してはいけない。
- コンポーネントがレンダーに使用する入力値を書き換えない。これには props、state、コンテクストが含まれる。画面を更新するためには既存のオブジェクトを書き換えるのではなく、代わりに state をセットする。
- コンポーネントのロジックはできるだけコンポーネントが返す JSX の中で表現する。何かを「変える」必要がある場合、通常はイベントハンドラで行う。最終手段として
useEffectを使用する。 - 純関数を書くことには訓練が必要だが、それにより React パラダイムの威力が発揮される。
このコンポーネントは、深夜 0 時から朝 6 時までの間は <h1> の CSS クラスを "night" に、その他の時間帯は "day" に設定しようとしています。ですが失敗してしまっています。このコンポーネントを修正してみてください。
あなたの回答が機能しているかを確認するには、一時的にコンピュータのタイムゾーンを変更することで確認できます。現在の時刻が午前 0 時から 6 時までの場合、時計の色が反転するはずです!
レンダーは計算のみを行います。そこで何かを「行おう」としてはいけません。同じ意味を表現する別の方法はありますか?
export default function Clock({ time }) {
const hours = time.getHours();
if (hours >= 0 && hours <= 6) {
document.getElementById('time').className = 'night';
} else {
document.getElementById('time').className = 'day';
}
return (
<h1 id="time">
{time.toLocaleTimeString()}
</h1>
);
}import { useState, useEffect } from 'react';
import Clock from './Clock.js';
function useTime() {
const [time, setTime] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => {
setTime(new Date());
}, 1000);
return () => clearInterval(id);
}, []);
return time;
}
export default function App() {
const time = useTime();
return (
<Clock time={time} />
);
}body > * {
width: 100%;
height: 100%;
}
.day {
background: #fff;
color: #222;
}
.night {
background: #222;
color: #fff;
}className を計算してレンダーの出力に含めるようにすれば、このコンポーネントを修正できます。
export default function Clock({ time }) {
const hours = time.getHours();
let className;
if (hours >= 0 && hours <= 6) {
className = 'night';
} else {
className = 'day';
}
return (
<h1 className={className}>
{time.toLocaleTimeString()}
</h1>
);
}import { useState, useEffect } from 'react';
import Clock from './Clock.js';
function useTime() {
const [time, setTime] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => {
setTime(new Date());
}, 1000);
return () => clearInterval(id);
}, []);
return time;
}
export default function App() {
const time = useTime();
return (
<Clock time={time} />
);
}body > * {
width: 100%;
height: 100%;
}
.day {
background: #fff;
color: #222;
}
.night {
background: #222;
color: #fff;
}この例では、副作用(DOM の変更)は全く必要ではありませんでした。単に JSX を返すだけで十分です。
異なるデータを持つ 2 つの Profile コンポーネントが並んで表示されています。最初のプロフィールを折りたたみ (Collapse) してから展開 (Expand) してみてください。両方のプロフィールが同じ人物を表示することがわかります。これはバグです。
バグの原因を探し、修正してください。
バグがあるコードは Profile.js にあります。一番上から一番下まで読み通してください。
import Panel from './Panel.js';
import { getImageUrl } from './utils.js';
let currentPerson;
export default function Profile({ person }) {
currentPerson = person;
return (
<Panel>
<Header />
<Avatar />
</Panel>
)
}
function Header() {
return <h1>{currentPerson.name}</h1>;
}
function Avatar() {
return (
<img
className="avatar"
src={getImageUrl(currentPerson)}
alt={currentPerson.name}
width={50}
height={50}
/>
);
}import { useState } from 'react';
export default function Panel({ children }) {
const [open, setOpen] = useState(true);
return (
<section className="panel">
<button onClick={() => setOpen(!open)}>
{open ? 'Collapse' : 'Expand'}
</button>
{open && children}
</section>
);
}import Profile from './Profile.js';
export default function App() {
return (
<>
<Profile person={{
imageId: 'lrWQx8l',
name: 'Subrahmanyan Chandrasekhar',
}} />
<Profile person={{
imageId: 'MK3eW3A',
name: 'Creola Katherine Johnson',
}} />
</>
)
}export function getImageUrl(person, size = 's') {
return (
'https://i.imgur.com/' +
person.imageId +
size +
'.jpg'
);
}.avatar { margin: 5px; border-radius: 50%; }
.panel {
border: 1px solid #aaa;
border-radius: 6px;
margin-top: 20px;
padding: 10px;
width: 200px;
}
h1 { margin: 5px; font-size: 18px; }問題は、Profile コンポーネントが既存の currentPerson 変数に書き込み、Header と Avatar コンポーネントがそれを読み取っていることです。これにより、これら 3 つのコンポーネントすべてが不純なものとなり、予測困難となってしまっています。
バグを修正するには、currentPerson 変数を削除します。代わりに、Header と Avatar にすべての情報を props で伝えます。両コンポーネントに props として person を追加し、それを渡す必要があります。
import Panel from './Panel.js';
import { getImageUrl } from './utils.js';
export default function Profile({ person }) {
return (
<Panel>
<Header person={person} />
<Avatar person={person} />
</Panel>
)
}
function Header({ person }) {
return <h1>{person.name}</h1>;
}
function Avatar({ person }) {
return (
<img
className="avatar"
src={getImageUrl(person)}
alt={person.name}
width={50}
height={50}
/>
);
}import { useState } from 'react';
export default function Panel({ children }) {
const [open, setOpen] = useState(true);
return (
<section className="panel">
<button onClick={() => setOpen(!open)}>
{open ? 'Collapse' : 'Expand'}
</button>
{open && children}
</section>
);
}import Profile from './Profile.js';
export default function App() {
return (
<>
<Profile person={{
imageId: 'lrWQx8l',
name: 'Subrahmanyan Chandrasekhar',
}} />
<Profile person={{
imageId: 'MK3eW3A',
name: 'Creola Katherine Johnson',
}} />
</>
);
}export function getImageUrl(person, size = 's') {
return (
'https://i.imgur.com/' +
person.imageId +
size +
'.jpg'
);
}.avatar { margin: 5px; border-radius: 50%; }
.panel {
border: 1px solid #aaa;
border-radius: 6px;
margin-top: 20px;
padding: 10px;
width: 200px;
}
h1 { margin: 5px; font-size: 18px; }React ではコンポーネント関数が何らかの特定の順序で実行されることは保証されていないため、変数を設定してコンポーネント間でデータをやりとりすることはできない、ということを覚えておきましょう。すべての通信は props を介して行う必要があります。
あなたの会社の CEO に、オンライン時計アプリに「ストーリー」を追加するよう要求され、ノーと言えない状況です。あなたは StoryTray コンポーネントを作成して、受け取った stories のリストを表示させ、末尾に "Create Story" というプレースホルダを表示することにしました。
"Create Story" プレースホルダは、受け取った stories 配列にさらにフェイクのストーリーを 1 つ追加することで実装しました。しかし、どういうわけか "Create Story" が何度も表示されてしまっています。この問題を修正してください。
export default function StoryTray({ stories }) {
stories.push({
id: 'create',
label: 'Create Story'
});
return (
<ul>
{stories.map(story => (
<li key={story.id}>
{story.label}
</li>
))}
</ul>
);
}import { useState, useEffect } from 'react';
import StoryTray from './StoryTray.js';
const initialStories = [
{id: 0, label: "Ankit's Story" },
{id: 1, label: "Taylor's Story" },
];
export default function App() {
const [stories, setStories] = useState([...initialStories])
const time = useTime();
// HACK: Prevent the memory from growing forever while you read docs.
// We're breaking our own rules here.
if (stories.length > 100) {
stories.length = 100;
}
return (
<div
style={{
width: '100%',
height: '100%',
textAlign: 'center',
}}
>
<h2>It is {time.toLocaleTimeString()} now.</h2>
<StoryTray stories={stories} />
</div>
);
}
function useTime() {
const [time, setTime] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => {
setTime(new Date());
}, 1000);
return () => clearInterval(id);
}, []);
return time;
}ul {
margin: 0;
list-style-type: none;
}
li {
border: 1px solid #aaa;
border-radius: 6px;
float: left;
margin: 5px;
margin-bottom: 20px;
padding: 5px;
width: 70px;
height: 100px;
}{
"hardReloadOnChange": true
}時計が更新されるたびに、"Create Story" が 2 回追加されることに気づくと、レンダー中にミューテーションが発生していることがわかるでしょう。Strict Mode は、このような問題をより目立たせるために、コンポーネントを 2 回呼び出します。
問題は StoryTray 関数が純粋でないことです。受け取った stories 配列(props の一部です)に push を呼び出すことで、StoryTray がレンダーし始める前に作成されたオブジェクトをミューテートしてしまっています。これにより、バグや予測困難な動作につながります。
最も単純な修正方法は、受け取った配列を一切いじらず、"Create Story" を別にレンダーすることです。
export default function StoryTray({ stories }) {
return (
<ul>
{stories.map(story => (
<li key={story.id}>
{story.label}
</li>
))}
<li>Create Story</li>
</ul>
);
}import { useState, useEffect } from 'react';
import StoryTray from './StoryTray.js';
const initialStories = [
{id: 0, label: "Ankit's Story" },
{id: 1, label: "Taylor's Story" },
];
export default function App() {
const [stories, setStories] = useState([...initialStories])
const time = useTime();
// HACK: Prevent the memory from growing forever while you read docs.
// We're breaking our own rules here.
if (stories.length > 100) {
stories.length = 100;
}
return (
<div
style={{
width: '100%',
height: '100%',
textAlign: 'center',
}}
>
<h2>It is {time.toLocaleTimeString()} now.</h2>
<StoryTray stories={stories} />
</div>
);
}
function useTime() {
const [time, setTime] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => {
setTime(new Date());
}, 1000);
return () => clearInterval(id);
}, []);
return time;
}ul {
margin: 0;
list-style-type: none;
}
li {
border: 1px solid #aaa;
border-radius: 6px;
float: left;
margin: 5px;
margin-bottom: 20px;
padding: 5px;
width: 70px;
height: 100px;
}あるいは、アイテムを追加する前に、(すでに存在する配列をコピーすることで)新しい配列を生成しても構いません。
export default function StoryTray({ stories }) {
// Copy the array!
const storiesToDisplay = stories.slice();
// Does not affect the original array:
storiesToDisplay.push({
id: 'create',
label: 'Create Story'
});
return (
<ul>
{storiesToDisplay.map(story => (
<li key={story.id}>
{story.label}
</li>
))}
</ul>
);
}import { useState, useEffect } from 'react';
import StoryTray from './StoryTray.js';
const initialStories = [
{id: 0, label: "Ankit's Story" },
{id: 1, label: "Taylor's Story" },
];
export default function App() {
const [stories, setStories] = useState([...initialStories])
const time = useTime();
// HACK: Prevent the memory from growing forever while you read docs.
// We're breaking our own rules here.
if (stories.length > 100) {
stories.length = 100;
}
return (
<div
style={{
width: '100%',
height: '100%',
textAlign: 'center',
}}
>
<h2>It is {time.toLocaleTimeString()} now.</h2>
<StoryTray stories={stories} />
</div>
);
}
function useTime() {
const [time, setTime] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => {
setTime(new Date());
}, 1000);
return () => clearInterval(id);
}, []);
return time;
}ul {
margin: 0;
list-style-type: none;
}
li {
border: 1px solid #aaa;
border-radius: 6px;
float: left;
margin: 5px;
margin-bottom: 20px;
padding: 5px;
width: 70px;
height: 100px;
}これにより、あなたのミューテーションはローカルなものとなり、レンダー関数が純粋に保たれます。ただしまだ注意が必要です。たとえば、配列の既存のアイテムを変更したい場合、そのアイテム自体も複製する必要があるでしょう。
配列に対する操作のうちどれが配列の書き換えを伴うもので、どれが伴わないものなのか、覚えておくことが有用です。例えば push、pop、reverse、sort は元の配列を書き換えてしまいますが、slice、filter、map は新しい配列を作成します。