宣言的と命令的の対比
React の特徴のひとつとして、UI を宣言的に記述する、というものが挙げられる。
これは、UI の最終結果だけを記述する、ということである。
データと UI が一対一で、このデータのときはこのような UI になる、という書き方をする。
これと対照的で、よく引き合いに出されるのが、命令的な記述。
これは、既存の UI に対して命令を繰り返すことで UI を作っていく。そのため、既存の UI の状態に依存し、それを考慮しながら記述しなければならない。
これが、宣言的な記述との大きな違い。宣言的な記述は作るべき成果物だけを意識すればいいが、命令的な記述はそれだけではなく、作るべき UI と既存の UI との差分を考慮し、それをコードに落とし込まないといけない。
宣言的に記述することで、経時的な変化を意識せずにコードを書けるようになり、コードが単純になり、保守性や可読性が高まる。
なぜ React を使うと宣言的に UI を記述できるのかというと、「作るべき UI と既存の UI との差分の考慮」という部分を React に丸投げできるからである。
開発者は、作るべき UI についてのみ記述すればよく、あとは React が差分検出アルゴリズムを使って、効率的に UI を書き換えてくれる。
もちろん常に最適な更新を行ってくれるわけではないし、パフォーマンスを高めるためには React の使い方に習熟する必要もある。だがそれはあくまで、「作るべき UI をより効率的に生成するにはどうすればいいのか」というのが論点であり、「時間軸」や「既存の UI との差分」といった概念からは解放される。
「React による UI の効率的な更新」を例示する。
これは、3
秒毎にオンライン状態であるユーザーのリストを受け取り、それをid
の順にソートした上でリスト形式で表示するアプリ。
getOnlineUsers
とApp
は本質ではないので流し読みでよい。重要なのはOnlineUsers
。
// React v16.10.2 で動作確認している。この記事の他の React アプリのコードも同様 import React, {useEffect, useState} from 'react'; // ランダムな user のリストを返す const getOnlineUsers = () => { const users = [ {id: 1, name: 'Alice'}, {id: 2, name: 'Bob'}, {id: 3, name: 'Carol'}, {id: 4, name: 'Dave'}, {id: 5, name: 'Ellen'}, ]; const list = []; for (let i = 0; i <= 3; i += 1) { const index = Math.floor(Math.random() * users.length); if (!list.includes(index)) list.push(index); } return list.map(i => users[i]); }; // 3秒毎に getOnlineUsers を実行し、その返り値を users に格納する // users が更新される度に、それを OnlineUsers に渡している const App = () => { const [users, setUsers] = useState([]); useEffect(() => { const timerId = setInterval(() => { setUsers(getOnlineUsers()); }, 3000); return () => clearInterval(timerId); }, []); return ( <div> <OnlineUsers users={users} /> </div> ); }; // 受け取った users を昇順でソートし、それをリスト形式で表示する const OnlineUsers = ({users}) => { return ( <ul> {users .sort((a, b) => a.id - b.id) .map(user => ( <li key={user.id}>{`${user.id}: ${user.name}`}</li> ))} </ul> ); }; export default App;
OnlineUsers
には、受け取ったusers
をソートして、そしてそれをリスト形式で表示するという、「どのような UI を作るのか」という情報しか記述されていない。前回の UI(この例では3
秒前の UI)がどのような状態であるか、については一切考慮していない。
にも関わらず、リストを丸ごと更新するのではなく、変更があった要素についてのみ更新が行われ、UI が効率的に再構築されている。
同様のことを React のようなライブラリなしでやろうとすると、命令的な記述にならざるを得ず、コードが肥大化、複雑化していく。
単に行数が長くなるだけではなく、コードそのものが処理の内容を時系列で記述していくような形になりがちで、コードから UI の完成物を推察しづらくなってしまう。
次のコードはそのことを例示するためのサンプルに過ぎないので、興味がなければ読み飛ばして構わない。
// getOnlineUsers は先程と同じなので省略 setInterval(() => { const nextUsers = new Map( getOnlineUsers() .sort((a, b) => a.id - b.id) .map(user => [user.id, user]) ); const nextIds = Array.from(nextUsers.keys()); const ulElement = document.querySelector('#online-users'); const liElements = ulElement.querySelectorAll('li'); const prevIds = []; liElements.forEach(item => { prevIds.push(Number(item.id.slice(item.id.length - 1))); }); const appendIds = []; nextIds.forEach(id => { if(!prevIds.includes(id)) appendIds.push(id); }); const removeIds = []; prevIds.forEach(id => { if(!nextIds.includes(id)) removeIds.push(id); }); removeIds.forEach(id => { const elem = ulElement.querySelector(`#user-${id}`); elem.parentNode.removeChild(elem); }); const existElementIds = []; nextIds.forEach(id => { if (!appendIds.includes(id)) { existElementIds.push(id); return; } const elem = document.createElement('li'); elem.setAttribute('id', `user-${id}`) elem.textContent = `${id}: ${nextUsers.get(id).name}`; if (existElementIds.length === 0) { ulElement.insertBefore(elem, ulElement.firstChild); existElementIds.push(id); return; } const target = ulElement.querySelectorAll('li')[existElementIds.length - 1]; target.parentNode.insertBefore(elem, target.nextSibling); existElementIds.push(id); }); }, 3000);
このような面倒な処理をライブラリに丸投げしてしまうことで、開発者がより本質的な作業に集中することでき、コードも簡潔になるのが、宣言的 UI の大きなメリットである。
データと UI の完全なシンクロ
ここまでは「見た目」の話をしてきたが、ウェブアプリ(だけではないが)における UI には、イベントハンドラも含まれる。
そしてイベントハンドラについても、宣言的に記述できる。
先程のOnlineUsers
ではprops
の内容に基づいて UI を作っていたので、今度はstate
を使ってみる。
import React, {useState, Fragment} from 'react'; // `ms`ミリ秒だけ処理を停止させる関数 const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); const candidates = ['Alice', 'Bob', 'Carol']; const App = () => { const [target, setTarget] = useState('Alice'); const selectTarget = e => { setTarget(e.currentTarget.value); }; // 2秒後にログを表示する const vote = async () => { await sleep(2000); console.log(`${target} に投票しました`); }; return ( <div> {candidates.map(item => ( <Fragment key={item}> <input type="radio" value={item} checked={item === target} onChange={selectTarget} /> {`${item} `} </Fragment> ))} <br /> <button type="button" onClick={vote}> 投票する </button> </div> ); }; export default App;
投票したい人をチェックすると、target
という名前のstate
にその人の名前が格納される。
「投票する」ボタンを押すと、ボタンを押したときにチェックしていた人に、投票される。
投票が行われるのはボタンを押した2
秒後だが、その間に他の人にチェックしても、投票は正しく行われる。
データと UI が一対一であり、両者がシンクロしているからこそ、このように挙動する。
この例でいうと、target
がAlice
のときはそれに対応した UI が作られ、target
がBob
のときはそれに対応した UI がまた新たに作られる。
イベントハンドラも UI の一部なので、vote
関数はそれぞれ、次のような内容になる。
// target が Alice のとき const vote = async () => { await sleep(2000); console.log(`Alice に投票しました`); }; // target が Bob のとき const vote = async () => { await sleep(2000); console.log(`Bob に投票しました`); };
「投票する」ボタンを押したときのtarget
はAlice
なので、ボタンのイベントハンドラに設定されているvote
は、2
秒後にAlice に投票しました
と表示される。
このように、データ毎に UI が作られ、データが変わればまたそれに応じた UI が作られる。
そして新しい UI の内容で DOM を差し替えるのだが、既に見たようにそれは React が自動的かつ効率的に行なってくれる。
面倒なことは React がやってくれるので、難しいことを考えなくてもprops
やstate
などのデータと UI は常に一対一になっており、シンクロしている。UI は必ずデータに基づいて構築され、その結びつきが破壊されることはない。
React 開発チームの一人である Dan Abramov 氏が、上手く表現している。
よく人は「すべては過程だ。結果ではない」と言います。ですが、 React の場合は逆です。全ては結果であり、過程ではありません。 これが jQuery の $.addClass と $.removeClass(過程)などの呼び出しと React であるべき CSS クラスを定義する行為(結果)の違いです。
React は現在の props と state に応じて DOM をシンクロします。 render 時は mount や update に区別はありません。
(中略)
初期 render か否かで違う挙動をするエフェクトを書こうとしてる場合は、React の流れに逆らっています! もし、結果が過程に頼ってしまっている場合は、シンクロに失敗しています。
props A, B, と C と順に render しようが C でいきなり render しようが関係ないはずです。
これが React の原則であり、それによってシンプルさと一貫性が生まれ、開発が非常に楽になる。
しかし React は内部に、この原則を崩しデータと UI のシンクロを破壊しかねない存在を抱えている。それが、クラスコンポーネントのthis
である。
ミュータブルな this
props
やstate
が更新されれば UI も作り直される、というのが React の原則だが、クラスコンポーネントはその原則を破る。
一つの UI のなかで、props
やstate
が移り変わってしまう可能性がある。
先程の「投票ページ」をクラスコンポーネントに書き換えて、確認してみる。
import React, {Fragment} from 'react'; const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); const candidates = ['Alice', 'Bob', 'Carol']; class App extends React.Component { constructor() { super(); this.state = {target: 'Alice'}; this.selectTarget = this.selectTarget.bind(this); this.vote = this.vote.bind(this); } selectTarget(e) { this.setState({ target: e.currentTarget.value, }); } async vote() { await sleep(2000); console.log(`${this.state.target} に投票しました`); } render() { return ( <div> {candidates.map(item => ( <Fragment key={item}> <input type="radio" value={item} checked={item === this.state.target} onChange={this.selectTarget} /> {`${item} `} </Fragment> ))} <br /> <button type="button" onClick={this.vote}> 投票する </button> </div> ); } } export default App;
Alice
にチェックした状態で「投票する」ボタンを押下し、2秒以内にBob
にチェックすると……。
Bob
に投票してしまう。
なぜこのようなことが起きるのかというと、this
がミュータブルであり、そしてクラスコンポーネントにおいてはprops
やstate
もthis
の一部だからである。
一つの UI のなかでもthis
は変わり得るのであり、それゆえにthis.props
やthis.state
も変わり得る。
今回はthis.state.target
がAlice
からBob
に変わってしまった。
クラスコンポーネントのprops
やstate
が変化してしまうということを、今度は関数コンポーネントとの比較で見てみる。
次の例では、onClick
というイベントハンドラを実行している途中で、this.props
が変化してしまう。
import React, {useEffect, useState} from 'react'; const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); const App = () => { const [state, setState] = useState(0); useEffect(() => { const timerId = setInterval(() => { setState(s => s + 1); }, 1000); return () => clearInterval(timerId); }, []); return ( <div> <h1>{state}</h1> <FunctionChild count={state} /> <ClassChild count={state} /> </div> ); }; const FunctionChild = props => { const onClick = async () => { console.log('==FUNCTION=='); console.log('start count', props.count); await sleep(2000); console.log('finish count', props.count); console.log('==FUNCTION=='); }; return ( <button type="button" onClick={onClick}> <b>FUNCTION</b> </button> ); }; class ClassChild extends React.Component { constructor(props) { super(props); this.onClick = this.onClick.bind(this); } async onClick() { console.log('==CLASS=='); console.log('start count', this.props.count); await sleep(2000); console.log('finish count', this.props.count); console.log('==CLASS=='); } render() { return ( <button type="button" onClick={this.onClick}> <b>CLASS</b> </button> ); } } export default App;
関数コンポーネントではonClick
のなかのprops.count
はイミュータブルだが、クラスコンポーネントのthis.props.count
はonClick
の処理の途中で値が変わってしまう。
// count が 3 のとき const onClick = async () => { console.log('==FUNCTION=='); console.log('start count', 3); // props.count は 3 を指す await sleep(2000); console.log('finish count', 3); // props.count は 3 を指す console.log('==FUNCTION=='); }; async onClick() { console.log('==CLASS=='); console.log('start count', 3); // this.props.count は 3 を指す await sleep(2000); console.log('finish count', 5); // this.props.count は 3 を...指さない! console.log('==CLASS=='); }
このため、クラスコンポーネントを使うと UI とデータのシンクロが崩れ、シンプルさや一貫性が失われ、this
の挙動を意識しながらコードを書かなければならなくなる。時に原則から逸脱することも必要かもしれないが、それはあくまでも開発者が自らの意思で逸脱するべきであり、知らぬ間に原則を壊しかねないthis
の振る舞いは望ましくない。
まとめ
Hooks の利点や魅力については既に多くの言説が存在するが、個人的には、クラスコンポーネントを使う必要性がほとんど無くなりthis
を扱わずに済むようになる、というのも大きいと思っている。
this
というミュータブルな値が入り込むと、イミュータブルなデータの内容に基づきそれに対応する UI を記述していけばよい、という宣言的 UI のメリットが薄れてしまう。
そして、JavaScript のthis
はミュータブル性の他にも問題を抱えており、触らずに済むのならそれに越したことはない。
公式ドキュメントでも、「クラスは人間と機械の両方を混乱させる」として、クラスやthis
が学習の障壁になっていると指摘、それが Hooks 導入の動機の一つでもあると述べている。
同時に「クラスコンポーネントは今後もサポートし続ける」とも述べており、無理にクラスコンポーネントを関数コンポーネントに置き換える必要はない。ただ、今後新しく作るコンポーネントについては、極力、関数コンポーネントにしたほうがよいのではないだろうか。
明日は、this
が抱えているもう一つの厄介な問題である「入れ子になった関数のなかだと値が変わってしまう」問題とその対処法について扱う。