フロントエンドエンジニアじゃないけれど React を書きたい人のための実践入門(後編) #Zaim
Zaim でサーバーサイドやフロントエンドを担当している takeshy です。先日の note の続きです。
後編は React でUIの開発に活用できるカタログツールである「Storybook」と、コンポーネントの記述で使う Javascript を拡張した構文「JSX」について掘り下げてみます。
Storybook
Presentational Component は再利用性が高いため、Presentational Component を一覧で管理できる「Storybook」という Web アプリフレームワークが便利です。Storybook を使うことで、Component をカタログ化でき、索引性を高めたりデザイナーの方がデザイン調整をしやすくなったりします。
実行手順
前編で紹介した単語変換のサンプルアプリが動作する環境であることが、前提です。ターミナルで以下を実行します。
cd sample2
npx -p @storybook/cli sb init --type react
stories/index.stories.js を下記に書き換え、
import React from "react";
import { storiesOf } from "@storybook/react";
import WordList from "../src/WordList";
storiesOf("WordList", module).add("with text", () => (
<WordList
words={[{ japanese: "おやすみ", english: "Good Night" }]}
update={() => {}}
register={e => {
e.preventDefault();
}}
japanese={"こんにちは"}
english={"Hello"}
/>
));
ターミナルで Storybook を起動します。
npm run storybook
JSX
概要
render の処理内の、HTML のようなタグを使ったコンポーネントの作成の記述方法が「JSX」です。実際に使用する前に Babel や TypeScript のコンパイラによって、React.createElement を使った通常の Javascript のメソッドに変換されます。
コンパイル前
ReactDOM.render(
<h1 id="my-heading">
<span>Hello</span>
world!
</h1>,
document.getElementById("root")
);
コンパイル後
ReactDOM.render(
React.createElement(
"h1",
{ id: "my-heading" },
React.createElement("span", null, "Hello"),
"world!"
),
document.getElementById("root")
);
上の変換後のコードを見ると、初心者の方がやりがちな
ReactDOM.render(<h1>Hello</h1><h2>World<h2>)
のような、トップレベルに複数のタグを並べてしまうコードがエラーになる理由が分かると思います。
Flagment タグ
これが NG であるとなると、tr, td のように、間にタグを挟めない関係だと困ることがあります。
その場合は「Fragment」というタグを使います。Fragment タグは React ツリー上には存在しますが、ホストの DOM ツリー上には表示されないコンポーネントです。
Fragment タグは <Fragment> と書いてもいいですし、省略して <> と空タグでも表現できます。ただし Fragment タグで id などの属性を使いたい場合は
<Fragment key=“1”></Fragment>
のように明示的に Flagment の記載が必要です。
import React from "react";
import ReactDOM from "react-dom";
ReactDOM.render(
<>
Hello
<span>world!</span>
</>,
document.getElementById("root")
);
配列
return で返すコンポーネントは一つですが、配列であっても構いません。ただし、後で説明するように配列内でユニークになる key の指定が必要です。
import React from "react";
export default props => {
return [<h1 key="first">Hello<h1>, <h2 key="last">World</h2>];
}
{} を使った式
JSX 内は {} 内で Javascript の式を使って評価した結果を使用できます。{} 内も render の際と同様、トップコンポーネントは配列も可能ですが、一つである必要があります。
import React from "react";
export default props => {
<h1>
HELLO, {"World".toUpperCase()}!
</h1>
);
HTML と書き方の違い
通常の HTML の場合、イベントハンドラはクラス名や ID 名で Node を指定し、その Node に対して AddEventHandler を呼ぶことが多かったのではないでしょうか。
一方、React の場合は、React コンポーネントに対して直接 onClick や onChange の属性で指定する必要があります。作成した React コンポーネントに対して外部からは極力、操作しないようにするためです。
JSX でも基本的に HTML と同じタグや属性が使えますが、例えば以下のように一部の属性は名称が変わっています。
class → className
for → htmlFor
なお input や image の閉じタグは必須です。また、textarea はタグの中身ではなく、textarea の value 属性が値になります。
CSS の記法もキャメルケースにする必要があります。css-modules を有効にして、CSS を module として import すると、指定した画像のパスやクラス名がユニークになるよう webpack が変換してくれるので、CSS 上で単純なクラス名でも安心して使えるようになります。
index.js
import React from "react";
//css moduleを使う場合はcss名をmodule.cssにする
import styles from "./styles.module.css";
import arrow from "./arrow.svg";
const imgStyle = {
width: "30px",
marginLeft: "40px" //cssではmargin-left
};
export default props => {
return (
<div>
<span className={styles.title}>矢印</span>
<img style={imgStyle} src={arrow} alt="point" />
</div>
);
};
styles.module.css
.title {
font-size: 1.2rem;
}
仮想 DOM ツリー
概要
React のコンポーネントは呼び出されると、子の React コンポーネントを返し続け、最終的に React DOM で定義されたコンポーネントのみの構成になるまで呼び出しを繰り返すことで、ツリーを形成します。こうしてできあがったツリーを「仮想 DOM ツリー」と呼びます。
各コンポーネントは軽量な Javascript のオブジェクトのため、DOM に比べると生成コストは低く済みます。
React は、仮想 DOM ツリーをホストツリー(実際の DOM ツリー)にマッピングすることで描画します。マッピング初回の場合は、先頭からすべて反映しますが、以降は前に生成した仮想 DOM ツリーと、新たに生成した仮想 DOM ツリーを比較して、変更があった箇所のみをホストツリーに反映します。
仮想 DOM ツリーの更新
仮想 DOM の初回呼び出しでツリー全体が生成された以降は、いずれかのコンポーネントの state もしくは props に変更があると、そのコンポーネントおよび配下のコンポーネントだけが render されます。
上記の例でいうと、state の変更により NewsList の render が呼ばれ、state を参照して News1, News2, News3 のコンポーネントを新たに作成します。ただし News1, News2 は前回と同じ内容のため、ホストへのマッピングの際に、既存のノードには更新がかかりません。
ホストツリーへのマッピング
ホストツリーの DOM を更新するか差し替えるかは、子要素を順番に見ていき判断します。
前回とコンポーネントの種類が同じ場合:更新
前回とコンポーネントの種類が一致しない場合:削除して作成
コンポーネントの評価が false の場合は、空のコンポーネントを表します。DOM が「あり」の状態から「なし」の状態に変更する場合は、評価を false にすることで、子のコンポーネントの並びを維持します。
OK 例
import React from "react";
class App extends React.Component {
constructor(props) {
super(props);
this.state = { checked: false };
this.check = this.check.bind(this);
}
check(e) {
this.setState({ checked: true });
}
render() {
return (
<>
{!this.state.checked && (
<h2 onClick={this.check}>コーヒーがはいりました</h2>
)}
<h3>お知らせをクリックすることで消すことができます</h3>
</>
);
}
}
export default App
NG 例
import React from "react";
class App extends React.Component {
constructor(props) {
super(props);
this.state = { checked: false };
this.check = this.check.bind(this);
}
check(e) {
this.setState({ checked: true });
}
render() {
if (!this.state.checked) {
return (
<div>
<h2 onClick={this.check}>コーヒーがはいりました</h2>
<h3>お知らせをクリックすることで消すことができます</h3>
</div>
);
}
return (
<div>
<h3>お知らせをクリックすることで消すことができます</h3>
</div>
);
}
}
export default App;
OK 例の場合は、確認ボタンを押して h2 のお知らせを消した後もその位置に false が来て、その DOM がなくなったことを伝えられます。
しかし、NG 例のように単に該当部分を削除すると、h2 の位置だった場所に h3 の DOM がきてしまいます。「h2 の要素が h3 に変わって元々の h3 が削除された」と見なされ、ホストツリーでは h2 が削除・h3 が作成・すでにあった h3 が削除、というような状況となります。
配列の React コンポーネントを返す場合
先の例に見たように、基本的に React コンポーネントの子要素の数は、変わらないようにすることが必要です。
一方で配列の場合は、削除・フィルタリングなどで React コンポーネントの数が変わってしまいます。
配列の先頭を削除すると、以降の要素が繰り上がり、すべての配列のコンポーネントが更新の対象となります。それを避けるために、React ではコンポーネントの配列には、key という属性でユニークな値を付与します。
OK 例
import React from "react";
class App extends React.Component {
constructor(props) {
super(props);
this.state = {
notices: [
{ id: 1, message: "コーヒーができました" },
{ id: 2, message: "お茶がわきました" },
{ id: 3, message: "紅茶ができました" }
]
};
this.check = this.check.bind(this);
}
check(e) {
this.setState({
notices: this.state.notices.filter(
s => s.id !== Number(e.currentTarget.attributes["id"].value)
)
});
}
render() {
return (
<div>
{this.state.notices.map(s => (
<h2 id={s.id} key={s.id} onClick={this.check}>
{s.message}
</h2>
))}
</div>
);
}
}
export default App;
NG 例
import React from "react";
class App extends React.Component {
constructor(props) {
super(props);
this.state = {
notices: [
{ id: 1, message: "コーヒーができました" },
{ id: 2, message: "お茶がわきました" },
{ id: 3, message: "紅茶ができました" }
]
};
this.check = this.check.bind(this);
}
check(e) {
this.setState({
notices: this.state.notices.filter(
s => s.id !== Number(e.currentTarget.attributes["id"].value)
)
});
}
render() {
return (
<div>
{this.state.notices.map((s, idx) => (
<h2 id={s.id} key={idx} onClick={this.check}>
{s.message}
</h2>
))}
</div>
);
}
}
export default App;
OK 例の場合は、先頭のお知らせを削除しても、key=“1” のレコードがなくなっただけと認識できるので、ホストツリー上での削除対象のノードが消されるだけで済みます。
一方で NG 例のように、key に配列の index を指定すると、先頭のお知らせを削除すると、index が繰り上がります。このため、同じ key で内容が違うコンポーネントができてしまいます。結果、ホストツリーのすべてのお知らせのノードが更新の対象になり、最後のレコードが削除されることになります。
まとめ
最近、いろいろな所で「宣言的であること」が重要視されています。
インフラでよく使われる Docker も、Dockerfile を使ってイチからイメージを作成すれば、更新にまつわる冪等性の複雑さを考える必要がありません。また、Docker が裏側でキャッシュしているため、次回以降は変更した部分以外はキャッシュを使えます。これにより、新規でも更新と変わらないくらい素早くイメージを作成できます。
React も、コードを書いているときは更新を意識せず、与えらえたデータを使って React コンポーネントを返すようにすれば、React 側がデータが変更があった部分の render 処理だけ呼び出してくれます。さらに、前回のツリーと変更があった箇所だけをホストツリーに反映するため、描画の更新が遅くなることがありません。
ただ React の場合、ユーザーによるイベントや API の呼び出しなどは、そのまま書くと命令的になってしまう処理も存在します。それは Redux を使うことで回避できるのですが、これはまた別の機会に書きたいと思います。
React や Redux を使いイチからフロントエンドやサーバーサイドの開発をしたいエンジニアの方、「まずは会ってみたい」をお待ちしています!