React.js (以後、React) とは、Facebook 製の JavaScript 向け UI 構築ライブラリです。仮想 DOM や Virtual DOM と呼ばれる軽量な View のツリー状のデータ構造を構築し、これをブラウザが扱う本物の DOM (以後、生 DOM と呼びます) に変換することで UI を構築していきます。仮想 DOM の段階で、前回描画した仮想 DOM との比較を行い、変化があったところだけ描画するのが特徴です。

React のライセンスについて話題になったことがあります。React はもともと「特許条項付き BSD ライセンス」というライセンス体系でした。ざっくり言うと (といっても筆者の理解はこの程度)、基本的には BSD ライセンスで使えるものの、例外として Facebook と係争中の他者は React を使えないというものでした。現在は純粋な (?) MIT ライセンスに変わっているので、Facebook と係争中、あるいは係争する予定があっても、今のところは安心して使用できます (2017/11/7 現在)。

プロジェクトの準備、Hello World

まずはプロジェクトの準備をします。今回扱う React を含め、最近の (といっても数年前から) JavaScript ライブラリは AOT な変換処理を噛ませるのが一般的です。変換処理のツールを動かすために JavaScript の実行環境が必要なので、Node.js をインストールしておきます。Node.js にはパッケージ管理ツールの npm が同梱されているので、これも使います。

$ mkdir helloworld
$ cd helloworld
$ npm init  # 適当に答える
$ npm install --save react react-dom
$ npm install --save-dev babel-core babel-loader babel-preset-es2015 babel-preset-react webpack

※ちなみに React は変換処理をかまさなくても使えるようになっています。が、それはそれでより効率的な言語仕様やツールを使えないことによる不自由が伴うので、どちらがいいとは一概には言えません。

package.json は npm なプロジェクトのメタデータを保持するファイルです。Maven の pom.xml みたいなものです。先の操作により、次のような内容になりました:

{
  "name": "helloworld",
  "scripts": {
    "build-dev": "webpack -d"
  },
  "dependencies": {
    "react": "^16.0.0",
    "react-dom": "^16.0.0"
  },
  "devDependencies": {
    "babel-core": "^6.26.0",
    "babel-loader": "^7.1.2",
    "babel-preset-es2015": "^6.24.1",
    "babel-preset-react": "^6.24.1",
    "webpack": "^3.8.1"
  }
}

webpack も準備しておきます。webpack はバンドルのためのツールです。ソースツリー上の js, css ファイルに対して (外部ツールを使った) 変換処理をかけたり、依存関係を解決しながらファイルをまとめたりします。

var webpack = require('webpack');
var path = require('path');

var BUILD_DIR = path.resolve(__dirname, 'build');
var SOURCE_DIR = path.resolve(__dirname, 'src');

module.exports = {
  entry: SOURCE_DIR + '/helloworld.jsx',
  output: {
    path: BUILD_DIR,
    filename: 'hello.bundle.js'
  },
  module: {
    rules: [
      {
        test: /\.jsx?/,
        include: SOURCE_DIR,
        use: [{
          loader: 'babel-loader',
          options: {
            presets: ['es2015', 'react']
          }
        }]
      }
    ]
  }
};

この設定だと webpack でバンドルした結果は build/hello.bundle.js に出力されます。これが HTML からロードするファイルになります。

Hello World を見てみましょう。なお、サンプルコードでは ES2015, JSX といった記法を使います。ES2015 で新たに使えるようになった記法の一部は最新ブラウザで使えるようになっているものの、古いブラウザや、新しい言語仕様の一部は使えません。Babel を使って ES5 に変換して、ブラウザからロードします。

import React from 'react';
import ReactDOM from 'react-dom';

class HelloWorld extends React.Component {
  render() {
    return (
      <h1>Hello, World!</h1>
    );
  }
}

ReactDOM.render(
  <HelloWorld />,
  document.getElementById('app')
);

最後に、この JavaScript をロードして実際に実行するための HTML です。

<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <title>react.js hello, world</title>
  </head>
  <body>
    <div id="app"></div>
    <script src="../build/hello.bundle.js"></script>
  </body>
</html>

webpack でビルドし、ブラウザで確認してみましょう。

$ npm run build-dev

> helloworld@ build-dev /home/kazuki/sources/writing/react/helloworld
> webpack -d

Hash: faf627eebf1304315e3b
Version: webpack 3.8.1
Time: 1754ms
          Asset     Size  Chunks                    Chunk Names
hello.bundle.js  2.16 MB       0  [emitted]  [big]  main
  [15] ./src/helloworld.jsx 2.56 kB {0} [built]
    + 31 hidden modules

コンポーネント, props

https://reactjs.org/docs/components-and-props.html

React ではコンポーネントを組み合わせていくことで仮想 DOM を構築していきます。コンポーネントは次の方法で定義できます:

  1. React.Component を継承したクラス
  2. props を受け取り React.Component を返す関数

1 を Class Component, 2 を Functional Component と呼んだりします。特に Functional Component は引数の props に対する (副作用を伴わない) 純粋な関数として振る舞わなければなりません。状態を持つコンポーネントを構築したい場合には Class Component を使います。状態については後述します。

props は上位のコンポーネントから受け取る引数です。JSX では属性として指定します。Read Only な値として使います。各コンポーネントは、上位から渡ってきた props と、自身が持つ状態 (state) とを組み合わせて仮想 DOM を構築します。

先の Hello World の例は Class Component です。このコンポーネントは状態をもたないので、Functional Component として定義することもできます。ついでに props を渡す例もねじ込んでみます。

function PureHelloWorld(props) {
  return (
    <h1>Pure Hello, World! {props.name}!</h1>
  );
}

ReactDOM.render(
  <PureHelloWorld name="Rambo" />,
  document.getElementById('app')
);

使い方は Class Component と同じです。

state, ライフサイクル

https://reactjs.org/docs/state-and-lifecycle.html

動的に変化する UI を構築するには、コンポーネントで状態を管理する必要があります。コンポーネントが管理する状態を、React では state という Object 型の値として保持します。1 秒ごとに時刻を更新する Timer の例を見てみましょう。

import React from 'react';
import ReactDOM from 'react-dom';

class Timer extends React.Component {
  constructor(props) {
    super(props);
    this.state = { date: new Date() };
  }

  componentDidMount() {
    this.timerId = setInterval(this.onTick.bind(this), 1000);
  }

  componentWillUnmount() {
    clearInterval(this.timerId);
  }

  onTick() {
    this.setState({ date: new Date() });
  }

  render() {
    return <Clock date={this.state.date} />;
  }
}

function Clock(props) {
  return <p>現在時刻: <time>{props.date.toLocaleString()}</time></p>;
}

ReactDOM.render(
  <Timer />,
  document.getElementById('app')
);

componentDidMount, componentWillUnmount はコンポーネントのライフサイクルに応じて React から呼び出されるコールバックです。名前の通り、コンポーネントが DOM に配置された直後と、コンポーネントが DOM から剥がされる直前に呼び出されます。他にもいくつかバリエーションがあります

状態の変更には setState を使います。setState によって状態が変わると、React はレンダリング (render 呼び出し) をスケジュールします。

可変なところは setState 経由で仮想 DOM を丸ごと構築しなおす (ように振る舞う) ので、React を使うと jQuery などを使って DOM を部分的に書き換えていく必要がありません。仮想 DOM は丸ごと作り直しますが、生 DOM の構築は仮想 DOM の差分から必要な部分だけ行われる (冒頭で述べた通り) ので、本当に DOM 全体を書き換えるほどのパフォーマンスの影響もありません。

setState による状態の更新は非同期で動く可能性があるので、前回の state に依存する更新 (インクリメントなど) では、setState に関数を渡す方式で実装しなければなりません。

this.setState(prevState => ({ count: prevState.count + 1 }))

イベントハンドラ

React では生 DOM にイベントハンドラを設定するのではなく、仮想 DOM に対してイベントハンドラを設定します。当然、仮想 DOM から生成される生 DOM には、仮想 DOM に設定したのと同じイベントハンドラが設定されます。イベントハンドラには JavaScript 関数を渡します。class のメソッドを使いたいときは thisbind します。this.methodthisbind されないのが ES2015 の仕様であるためです (おそらく互換性のためでしょう。JavaScript の this は実行時の文脈によって変わる)。

import React from 'react';
import ReactDOM from 'react-dom';

class ManualTimer extends React.Component {
  constructor(props) {
    super(props);
    this.state = { date: new Date() };
    this.onClick = this.onClick.bind(this);
  }

  onClick() {
    this.setState({ date: new Date() });
  }

  render() {
    return (
      <div>
        <Clock date={this.state.date}/>
        <button onClick={this.onClick}>Click me to update date</button>
      </div>
    );
  }
}

function Clock(props) {
  return <p>現在時刻: <time>{props.date.toLocaleString()}</time></p>;
}

ReactDOM.render(
  <ManualTimer />,
  document.getElementById('app')
);

コンストラクタで bind してしまうのがマニュアルでも推奨されている方法です。

仮想 DOM の構築時にイベントハンドラを指定していけばいいので、DOM の構築後に addEventListener する必要がありません。これで jQuery でやってた仕事がまた一つ減ります。

例: チャット

WebSocket ベースのチャットアプリをかんたんに作ってみます。

import React from 'react';
import ReactDOM from 'react-dom';
import uuidv4 from 'uuid/v4';

class Chat extends React.Component {
  constructor(props) {
    super(props);
    this.state = { connected: false, messages: [] };
    this.onMessageSubmit = this.onMessageSubmit.bind(this);
  }

  componentDidMount() {
    this.ws = new WebSocket(this.props.endpoint);
    this.ws.onopen = this.onWebSocketOpen.bind(this);
    this.ws.onmessage = this.onWebSocketMessage.bind(this);
    this.ws.onclose = this.onWebSocketClose.bind(this);
  }

  componentWillUnmount() {
    this.ws.close();
  }

  onWebSocketOpen() {
    this.send({ body: "Hello, I'm " + this.props.clientId });
    this.setState({ connected: true });
  }

  onWebSocketMessage(e) {
    const message = JSON.parse(e.data);
    this.setState(prev => ({
      messages: prev.messages.concat(message)
    }));
  }

  onWebSocketClose() {
    this.setState({ connected: false });
  }

  onMessageSubmit(body) {
    if (body && body.length > 0)
      this.send({ body });
  }

  send(object) {
    this.ws.send(JSON.stringify(object));
  }

  render() {
    if (this.state.connected) {
      console.log(this.state.messages);
      return (
        <div>
          <h1>Chat Room</h1>
          <h2>You are {this.props.clientId}</h2>
          <ul>
            { this.state.messages.map(msg => <Message message={msg} key={msg.id} />) }
          </ul>
          <MessageInputForm onSubmit={this.onMessageSubmit} />
        </div>
      );
    } else {
      return <p>Connecting...</p>;
    }
  }
}

function Message(props) {
  const { id, body } = props.message;
  return (
    <li>
      {body}
      <br/>
      <small style={{color: 'gray'}}>{id}</small>
    </li>
  );
}

class MessageInputForm extends React.Component {
  constructor(props) {
    super(props);
    this.state = { body: '' };
    this.onChange = this.onChange.bind(this);
    this.onSubmit = this.onSubmit.bind(this);
  }

  onChange(e) {
    this.setState({ body: e.target.value });
  }

  onSubmit(e) {
    e.preventDefault();
    this.props.onSubmit(this.state.body);
    this.setState({ body: '' });
  }

  render() {
    return (
      <form onSubmit={this.onSubmit}>
        <label>
          メッセージ:
          <input type="text" onChange={this.onChange} value={this.state.body} />
        </label>
        <input type="submit" value="送信" />
      </form>
    );
  }
}

ReactDOM.render(
  <Chat endpoint={'ws://' + location.host + '/chatsocket'} clientId={uuidv4()} />,
  document.getElementById('app')
);

個人的には、WebSocket で流れてくるストリーム (message) を画面に反映していく類のアプリは、React と非常に相性がいいように感じます。

Redux

https://redux.js.org/

Redux は状態を管理するためのライブラリです。React とセットで語られることが多いため、今回は React と Redux を一気にやっつけておこうという魂胆で取り上げます。

これまで見てきたように、React は、それ自体も state という状態を管理するための仕組みを持ちますが、貧弱です。React はあくまで View (JavaScript が担う領域自体が View とも言えるが、その中でもさらに) を担当するものであり、状態をうまくハンドリングする仕組みは提供しません。小さく単純なアプリケーションであれば React の仕組みだけでも十分やっていけるものの、ある程度の規模になると、状態管理に特化した (かつ React と親和性の高い) 仕組みがほしくなります。

そこで取り上げられる (ことが多い) のが Redux です。Redux はコンセプトも実装も非常に小さいライブラリですが、それでいて強力です。Redux のコアコンセプトを理解すると、その後の理解も早くなると思います。

コアコンセプト

https://redux.js.org/docs/introduction/CoreConcepts.html

React (のコンポーネント) が「props と state を引数にとって仮想 DOM を吐く関数」であるように、Redux は「state と action をもらって新しい state を吐く関数」であるととらえられます。これだと漠然としすぎているので、詳しく見ていきます。

まず、Redux は (React と同じように) 状態をモノリシックな Object として保持します。

{
  todos: [
    { text: 'Eat food', completed: true },
    { text: 'Exercise', completed: false}
  ],
  visibilityFilter: 'SHOW_COMPLETED'
}

Redux では React のそれと違い、state を直接書き換えることはしません。React の props のように、Read Only なものとして扱います。変更を加える際には、常に新しいオブジェクトを作ることになっています。state はただの JavaScript の Object なので (当然) ふつうに書き換えることができます。この点は、プログラマーが注意しなければなりません。

次に、Redux は state を書き換えるイベントログを扱います。Redux の世界では Action と呼びます。Action もまた、 Plain Old な JavaScript Object です。Action は type というプロパティを持っていなければなりません。type 以外のプロパティは、Action ごとに自由に付加することができます。

{ type: 'ADD_TODO', text: 'Go to swimming pool' }
{ type: 'TOGGLE_TODO', index: 1 }
{ type: 'SET_VISIBILITY_FILTER', filter: 'SHOW_ALL' }

state と action を組み合わせて新しい state を吐くのが、Reducer と呼ばれるものです。Reducer は関数として実装します。

function visibilityFilter(state = 'SHOW_ALL', action) {
  switch (action.type) {
    case 'SET_VISIBILITY_FILTER':
      return action.filter;
    default:
      return state;
  }
}

function todos(state = [], action) {
  switch (action.type) {
    case 'ADD_TODO':
      return state.concat({ text: action.text, completed: false });
    case 'TOGGLE_TODO':
      return state.map((todo, index) => action.index == index
        ? { text: todo.text, completed: !todo.completed }
        : todo);
    default:
      return state;
  }
}

function todoApps(state = {}, action) {
  return {
    todos: todos(state.todos, action),
    visibilityFilter: visibilityFilter(state.visibilityFilter, action)
  };
}

初期 state と action のストリームを Reducer に食わせて畳み込み、新たな state を作っていく。これが Redux の提供している機能です。初期 state と action ログがあれば、任意の状態を復元することが可能となっています。イメージが湧くように、Java のStream API で書くとこんな感じです:

// <U> U reduce(U identity, BiFunction<U,? super T,U> accumulator, BinaryOperator<U> combiner)
Stream<Action> actions = ...;
State newState = actions.reduce(currentState, reducer, (s1, s2) -> s2)

Store, Dispatcher, Action, Reducer

Redux を語るときによく出てくる単語です。

Store は State と Reducer を束ねるオブジェクトです。内部に state と reducer を抱え込みます。Store には action を受け入れる dispatch という操作が用意されています。

Dispatcher は Store が提供する dispatch 関数のことです。

Action, Reducer はすでに説明したので省略します。

いきなりこの 4 単語が出てくると「意味が分からない」となる(なった)のが、コアコンセプトを理解しておけば、なんと言うことはないことが分かります。

これらの単語の意味を理解すると、Redux で状態を管理するとき、どのようにデータが流れていくのかが分かるようになります:

  1. ユーザー操作、あるいは何かしらのイベントを端として Store.dispatch(action) する
  2. Store は dispatch された action と、自身が持つ state を reducer に食わせる
  3. reducer は与えられた state と action から、自身のロジックにもとづいて新しい state を構築する
  4. Store は reducer が出力した state を保持する

Redux を使ってみよう

コアコンセプトの説明で例に挙げた TODO アプリを作ってみます。ついでに非同期処理の例として、Web サーバーへの保存機能も付け足してみます。ちなみに、本来ならファイルを分けたり、React と Redux の連携のためのライブラリ react-redux を使ったりするところを、この例では説明のためあえてこれらを採用していません。

import React from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import { createStore, combineReducers, applyMiddleware } from 'redux';
import logger from 'redux-logger';
import axios from 'axios';

// --------------
// http
function save({ todos }) {
  return axios.post('/todos', { todos }).then(r => r.data);
}

// --------------
// actions
const ADD_TODO = 'ADD_TODO';
const TOGGLE_TODO = 'TOGGLE_TODO';
const SET_VISIBILITY_FILTER = 'SET_VISIBILITY_FILTER';
const REQUEST_SAVE = 'REQUEST_SAVE';
const DONE_SAVE = 'DONE_SAVE';

let nextTodoId = 0;
function addTodo(text) { return { type: ADD_TODO, text, id: ++nextTodoId }; }
function toggleTodo(id) { return { type: TOGGLE_TODO, id }; }
function setVisibilityFilter(filter) { return { type: SET_VISIBILITY_FILTER, filter }; }
function requestSave() { return { type: REQUEST_SAVE }; }
function doneSave() { return { type: DONE_SAVE }; }

// --------------
// reducers
function visibilityFilter(state = 'SHOW_ALL', action) {
  switch (action.type) {
    case SET_VISIBILITY_FILTER:
      return action.filter;
    default:
      return state;
  }
}

function items(state = [], action) {
  switch (action.type) {
    case ADD_TODO:
      return state.concat({ id: action.id, text: action.text, completed: false });
    case TOGGLE_TODO:
      return state.map(todo => action.id === todo.id
                           ? Object.assign({}, todo, { completed: !todo.completed })
                           : todo);
    default:
      return state;
  }
}

function saving(state = false, action) {
  switch (action.type) {
    case REQUEST_SAVE:
      return true;
    case DONE_SAVE:
      return false;
    default:
      return state;
  }
}

// --------------
// state = { todos: {items,saving}, visibilityFilter }
const todos = combineReducers({ items, saving });
const reducer = combineReducers({ visibilityFilter, todos });

// --------------
// store
function configureStore() {
  return createStore(reducer, applyMiddleware(logger));
}

// --------------
// component
function nop() { }

function TodoItem({ item, onClick, saving }) {
  const BALLOT_BOX = '\u2610';
  const BALLOT_BOX_WITH_CHECK = '\u2611';
  const check = item.completed ? BALLOT_BOX_WITH_CHECK : BALLOT_BOX;
  return (
    <li onClick={saving ? nop : () => onClick(item.id)}>{check} {item.text}</li>
  );
}

function TodoList({ items, onTodoClick, saving }) {
  return (
    <ul>
      {items.map(item => 
        <TodoItem key={item.id} item={item} onClick={onTodoClick} saving={saving}/>
      )}
    </ul>
  );
}

function Link({ active, children, onClick }) {
  if (active) {
    return <span>{children}</span>;
  } else {
    return (
      <a href="#" onClick={e => { e.preventDefault(); onClick(); }}>
        {children}
      </a>
    );
  }
}

function Footer(props) {
  return (
    <p>
      Show:
      {' '}
      <FilterLink filter="SHOW_ALL" {...props}>SHOW ALL</FilterLink>
      {', '}
      <FilterLink filter="SHOW_ACTIVE" {...props}>SHOW ACTIVE</FilterLink>
      {', '}
      <FilterLink filter="SHOW_DONE" {...props}>SHOW DONE</FilterLink>
    </p>
  );
}

// --------------
// container
class AddTodo extends React.Component {
  constructor(props) {
    super(props);
    this.onSubmit = this.onSubmit.bind(this);
    this.onChange = this.onChange.bind(this);
    this.state = { valid: false, value: '' };
  }

  onChange(e) {
    let value = e.target.value;
    this.setState({ valid: value.length > 0, value });
  }

  onSubmit(e) {
    e.preventDefault();
    this.props._dispatch(addTodo(this.input.value));
    this.setState({ value: '', valid: false });
    this.input.focus();
  }

  render() {
    let { saving } = this.props.todos;
    return (
      <form onSubmit={this.onSubmit}>
        <input type="text" onChange={this.onChange} value={this.state.value}
               ref={node => this.input = node}/>
        <input type="submit" disabled={!this.state.valid || saving}/>
      </form>
    );
  }
}

class Save extends React.Component {
  constructor(props) {
    super(props);
    this.onClick = this.onClick.bind(this);
  }

  onClick(e) {
    e.preventDefault();
    let { items } = this.props.todos;
    this.props._dispatch(requestSave());
    save({ todos: items }).then(() => this.props._dispatch(doneSave()));
  }

  render() {
    let { saving } = this.props.todos;
    return (
      <button onClick={this.onClick} disabled={saving}>Save</button>
    );
  }
}

function filterItems(filter, todos) {
  switch (filter) {
    case 'SHOW_ALL':
      return todos;
    case 'SHOW_ACTIVE':
      return todos.filter(todo => !todo.completed);
    case 'SHOW_DONE':
      return todos.filter(todo => todo.completed);
  }
}

class VisibleTodoList extends React.Component {
  constructor(props) {
    super(props);
    this.onTodoClick = this.onTodoClick.bind(this);
  }

  onTodoClick(id) {
    this.props._dispatch(toggleTodo(id));
  }

  render() {
    let { visibilityFilter, todos } = this.props;
    let items = filterItems(visibilityFilter, todos.items);
    return (
      <TodoList items={items} saving={todos.saving} onTodoClick={this.onTodoClick}/>
    );
  }
}

class FilterLink extends React.Component {
  constructor(props) {
    super(props);
    this.onClick = this.onClick.bind(this);
  }

  onClick() {
    this.props._dispatch(setVisibilityFilter(this.props.filter));
  }

  render() {
    let { visibilityFilter } = this.props;
    let active = visibilityFilter === this.props.filter;
    return (
      <Link active={active} onClick={this.onClick}>
        {this.props.children}
      </Link>
    );
  }
}

const dummyState = {};
class App extends React.Component {
  componentDidMount() {
    this.unsubscribe = this.props.store.subscribe(() => this.setState(dummyState));
  }

  componentWillUnmount() {
    this.unsubscribe();
  }

  render() {
    let { store } = this.props;
    let props = Object.assign({}, store.getState(), { _dispatch: store.dispatch.bind(store) });
    console.log('hoge', props);
    return (
      <div>
        <AddTodo {...props}/>
        <Save {...props}/>
        <VisibleTodoList {...props}/>
        <Footer {...props}/>
      </div>
    );
  }
}

// --------------
// render
let store = configureStore();
ReactDOM.render(
  <App store={store}/>,
  document.getElementById('app')
);