30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

宣言的な React と、ミュータブルな this

宣言的と命令的の対比

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の順にソートした上でリスト形式で表示するアプリ。
getOnlineUsersAppは本質ではないので流し読みでよい。重要なのは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 が効率的に再構築されている。

f:id:numb_86:20191123181845g:plain

同様のことを 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秒後だが、その間に他の人にチェックしても、投票は正しく行われる。

f:id:numb_86:20191123181959g:plain

データと UI が一対一であり、両者がシンクロしているからこそ、このように挙動する。
この例でいうと、targetAliceのときはそれに対応した UI が作られ、targetBobのときはそれに対応した 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 に投票しました`);
  };

「投票する」ボタンを押したときのtargetAliceなので、ボタンのイベントハンドラに設定されているvoteは、2秒後にAlice に投票しましたと表示される。
このように、データ毎に UI が作られ、データが変わればまたそれに応じた UI が作られる。
そして新しい UI の内容で DOM を差し替えるのだが、既に見たようにそれは React が自動的かつ効率的に行なってくれる。
面倒なことは React がやってくれるので、難しいことを考えなくてもpropsstateなどのデータと 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

propsstateが更新されれば UI も作り直される、というのが React の原則だが、クラスコンポーネントはその原則を破る。
一つの UI のなかで、propsstateが移り変わってしまう可能性がある。
先程の「投票ページ」をクラスコンポーネントに書き換えて、確認してみる。

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にチェックすると……。

f:id:numb_86:20191123182027g:plain

Bobに投票してしまう。

なぜこのようなことが起きるのかというと、thisがミュータブルであり、そしてクラスコンポーネントにおいてはpropsstatethisの一部だからである。
一つの UI のなかでもthisは変わり得るのであり、それゆえにthis.propsthis.stateも変わり得る。
今回はthis.state.targetAliceからBobに変わってしまった。

クラスコンポーネントのpropsstateが変化してしまうということを、今度は関数コンポーネントとの比較で見てみる。
次の例では、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;

f:id:numb_86:20191123182052g:plain

関数コンポーネントではonClickのなかのprops.countはイミュータブルだが、クラスコンポーネントのthis.props.countonClickの処理の途中で値が変わってしまう。

  // 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が抱えているもう一つの厄介な問題である「入れ子になった関数のなかだと値が変わってしまう」問題とその対処法について扱う。

参考資料