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

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

React の関数コンポーネントが再呼び出しされる条件

マウントされた関数コンポーネントが再び実行されるのは、どのようなケースか。
stateが更新されたら再実行されるんでしょ、くらいの曖昧な理解だったので、検証して整理した。

reactreact-domのバージョンは16.10.2
動作確認にReact Developer Toolsも使用したが、そのバージョンは4.2.0

確認方法

コードの全体像は改めて載せるが、関数コンポーネント内にconsole.log('called');と記述する。
これで、関数コンポーネントが呼ばれる度にログにcalledと流れる。

また、React Developer ToolsHighlight updates when components render.を有効にすることで、コンポーネントが再レンダリングされる度にハイライトされるようにしておく。

f:id:numb_86:20191019105056p:plain

state が更新されると呼び出される

useStateuseReducerstateが更新されると、コンポーネント関数が再呼び出しされる。
当該stateを表示に使っているか否かは、関係ない。

下記の例では、ボタンが押す度にstateがインクリメントされる。
stateは定義しただけでどこでも使っておらず、DOM構造にも影響はない。それでも、関数は呼び出される。

import React, {useState} from 'react';

// ボタンを押す度にこの関数が実行される
const App = () => {
  const [state, setState] = useState(0);

  console.log('called');

  const onClick = () => {
    setState(s => s + 1);
  };

  return (
    <div style={{backgroundColor: 'lightcyan'}}>
      App
      <br />
      <button type="button" onClick={onClick}>
        click
      </button>
    </div>
  );
};
export default App;

f:id:numb_86:20191019110341g:plain

onClickを書き換えて、stateに常に0がセットされるようにする。
つまりstateが初期値から変わらず、更新が発生しない。こうすると、関数の再呼び出しは発生しない。

  const onClick = () => {
    setState(0);
  };

f:id:numb_86:20191019110757g:plain

SameValue アルゴリズムによる更新判定

stateが更新されたら関数コンポーネントを再呼び出しする」ということが分かったが、その「更新」が行われたかどうかは、どのように判定しているのか。

SameValueというアルゴリズムを使って判定している。ちなみに、PureComponentuseEffectdepsなどでも、同じロジックを使っているらしい。

github.com

ES2015 で定義されたObject.isは、このアルゴリズムに基づいて動く。
React でもこのメソッド、及びそのポリフィルを実装して、利用している。

react/objectIs.js at master · facebook/react

このアルゴリズムの挙動は===とほぼ同じ。違うのはNaN-0の扱い。
これについてはコードを見たほうが早い。このなかで出てくる+00と同じものと思ってよい。

console.log(Object.is(NaN, NaN)); // true
console.log(NaN === NaN); // false

console.log(Object.is(+0, -0)); // false
console.log(+0 === -0); // true

console.log(Object.is(+0, 0)); // true
console.log(+0 === 0); // true

stateの更新判定にSameValueを使っていることを確認するため、ボタンを押す度にstate0-0を交互に渡してみる。
stateが変わる度に関数コンポーネントが再呼び出しされていることを確認できる。

  const onClick = () => {
    setState(s => {
      const currentState = s;
      const nextState = 1 / currentState === Infinity ? -0 : 0;
      console.log(currentState);
      console.log(nextState);
      console.log(currentState === nextState); // === では常に true になる
      return nextState;
    });
  };

f:id:numb_86:20191019115831g:plain

オブジェクトの扱いは===と同じなので、値ではなく参照が同じかどうかを見る。

const a = {foo: 1};
const b = {foo: 1};
const c = a;

console.log(Object.is(a, b)); // false
console.log(Object.is(a, c)); // true

以下の例では、ボタンを押下する度に新しいオブジェクトをstateにセットしている。
値は同じだがオブジェクトとしては別物なので、stateが更新されたと見做される。

import React, {useReducer} from 'react';

const reducer = (state, action) => {
  switch (action.type) {
    case 'update':
      return {
        id: 1,
        status: 'Online',
      };
    default:
      throw new Error();
  }
};

// ボタンを押す度にこの関数が実行される
const App = () => {
  const [state, dispatch] = useReducer(reducer, {id: 1, status: 'Online'});

  console.log('called');

  const onClick = () => {
    dispatch({type: 'update'});
  };

  return (
    <div style={{backgroundColor: 'lightcyan'}}>
      id: {state.id}
      <br />
      status: {state.status}
      <br />
      <button type="button" onClick={onClick}>
        status update
      </button>
    </div>
  );
};
export default App;

f:id:numb_86:20191019130401g:plain

reducerを以下のように書き換えると先程とは逆になり、中身は変わっているがオブジェクトは同一なので、stateの更新はないと判定される。
そのため関数コンポーネントの再呼び出しは行われず、当然、表示にも反映されない。

const reducer = (state, action) => {
  switch (action.type) {
    case 'update':
      state.status = 'Offline';
      return state;
    // 以下が正しい
    // return {...state, status: 'Offline'};
    default:
      throw new Error();
  }
};

f:id:numb_86:20191019130657g:plain

コメントアウトした部分のように新しいオブジェクトを作って返すと、stateの更新を検知し、関数の再呼び出しが行われるようになる。

一度のイベントで state が複数回セットされる場合

stateの更新による関数の再呼び出しは即時で行われるのではなく、同期処理(逐次処理)が終わったあとに一度だけ行われる。

以下の例ではボタンを押下するとsetStateが1000回呼ばれる。
だがその都度stateが更新されるのではなく、まずは処理が全て終わるのを待ち、その後に一度だけstateを更新する。
そのため、まずdoneがログに流れ、その後にstateをセットし、それによりAppコンポーネントが再呼び出しされてcalledがログに流れる、という挙動になる。

import React, {useState} from 'react';

// ボタンを押す度に一度だけ App が実行される
const App = () => {
  const [state, setState] = useState(0);

  console.log('called');

  const onClick = () => {
    [...Array(1000)].forEach(() => {
      setState(s => s + 1);
    });
    console.log('done');
  };

  return (
    <div style={{backgroundColor: 'lightcyan'}}>
      {state}
      <br />
      <button type="button" onClick={onClick}>
        click
      </button>
    </div>
  );
};
export default App;

f:id:numb_86:20191019164414g:plain

以下のようにonClickを書き換えて「一度setStateに現在値と違う値を渡し、その後に現在値をsetStateに渡す」と、再呼び出しはするがレンダリングはしない、という挙動になる。

  // App が呼び出されるが、レンダリングはされない
  const onClick = () => {
    setState(1);
    setState(0);
  };

f:id:numb_86:20191019165055g:plain

関数は呼び出されているのだが、ハイライトしていない。

プロファイラで確認しても、Appは灰色なので、やはりレンダリングされていない。

f:id:numb_86:20191019175017g:plain

どういうロジックでこうなっているのかは不明。React の実装を読むしかないと思う。

非同期処理のなかで setState を実行した場合

同期処理のなかでsetStateを実行している場合、まずそれによるstateの更新を行う。
その後、非同期処理のなかで実行したsetStateに基づき、stateを更新する。

そのため以下の例では、一度stateを更新して関数を呼び出した後、その1秒後に再びstateの更新とそれによる関数呼び出しが行われる。

import React, {useState} from 'react';

const timeout = ms => new Promise(resolve => setTimeout(resolve, ms));

// ボタンを押す度に App が2回実行される
const App = () => {
  const [state, setState] = useState(0);

  console.log('called');

  const onClick = () => {
    setState(s => s + 1);
    timeout(1000).then(() => setState(s => s + 1));
  };

  return (
    <div style={{backgroundColor: 'lightcyan'}}>
      {state}
      <br />
      <button type="button" onClick={onClick}>
        click
      </button>
    </div>
  );
};
export default App;

f:id:numb_86:20191019170639g:plain

非同期処理のなかで行われるsetStateは即時で実行される。そのため以下のような実装にすると、関数呼び出しが5回発生する。

  const onClick = async () => {
    setState(s => s + 1);
    await timeout(1000);
    setState(s => s + 1);
    setState(s => s + 1);
    await timeout(1000);
    setState(s => s + 1);
    setState(s => s + 1);
  };

f:id:numb_86:20191019171313g:plain

再呼び出しを行うロジックは、よく分からなかった。

  // ボタンを押しても App は呼び出されない
  const onClick = () => {
    setState(0);
    timeout(1000).then(() => setState(0));
  };

これは分かる。同期処理、非同期処理共に、stateが現在値である0と同じだから、コンポーネント関数は実行されない。

  // App を1回呼び出す
  const onClick = () => {
    setState(0);
    timeout(1000).then(() => setState(1));
  };

これも分かる。同期処理では更新は行われていないが、非同期処理で更新されているから。

  // App を2回呼び出す
  const onClick = () => {
    setState(1);
    timeout(1000).then(() => setState(0));
  };

これも分かる。まず同期処理で0から1に更新される。そして1秒後に今度は1から0に更新される。

  // App を2回呼び出す
  const onClick = () => {
    setState(1);
    timeout(1000).then(() => setState(1));
  };

問題はこれ。
同期処理は分かる。0 → 1だから、このときにAppが実行されるのは分かる。
だがなぜ、非同期処理のsetStateでもAppが再呼び出しされるのだろうか。1 → 1だからstateは更新されていないはずだが。

これも React の実装を読むしかなさそう。

親が再呼び出しされれば、子も無条件で再呼び出しされる

propsの受け渡しの有無等は関係なく、親である関数コンポーネントが再呼び出しされれば、子にあたる関数コンポーネントも再呼び出しされる。

import React, {useState} from 'react';

const Child = () => {
  console.log('Child called');
  return (
    <div style={{backgroundColor: 'lightblue', margin: '10px'}}>Child</div>
  );
};

// ボタンを押す度に App と Child が再呼び出しされる
const App = () => {
  const [state, setState] = useState(0);

  console.log('App called');

  const onClick = () => {
    setState(s => s + 1);
  };

  return (
    <div style={{backgroundColor: 'lightcyan'}}>
      App
      <br />
      <button type="button" onClick={onClick}>
        click
      </button>
      <Child />
    </div>
  );
};
export default App;

f:id:numb_86:20191019173152g:plain

親のコンポーネントが再呼び出しされないなら、子もされない。

  const onClick = () => {
    setState(0);
  };

f:id:numb_86:20191019173334g:plain

参考資料

ターミナルで直前に実行したコマンドをクリップボードにコピーする

bash のバージョンは5.0.11(1)-release (x86_64-apple-darwin18.6.0)

動機

Git のコミットメッセージに、その差分を生み出したコマンドを書くことがよくある。
例えば以下のように。

$ yarn run lint:fix
$ git commit -am '$ yarn run lint:fix'

コミットメッセージに何を書くべきかについてはいろんな意見があるが、何らかのコマンドを実行した結果をコミットする場合は、そのコマンドを書いておくのは悪くないと思う。何をした結果としてその差分が生まれたのかが明確で、デバッグや調査のときに役に立つ。
そして、その時に直近のコマンドをコピペするわけだが、これが面倒くさい。よく行う作業だけに、もっと効率化したい。

ワンライナーで書ける

ただ単にタイトルの内容を実現するだけなら、ワンライナーで書ける。

$ history -a && tail -n 2 ~/.bash_history | head -n 1 | tr -d '\n' | pbcopy

これで出来る。

history -a
実行したコマンドの履歴は~/.bash_historyというファイルに書かれている。
だが随時更新されているわけではない。そのため、何も考えずに~/.bash_historyの内容を取りに行くと、意図とは違う結果になる可能性がある。
任意のタイミングで~/.bash_historyを更新するにはhistory -aというコマンドを実行すればよい。

tail -n 2 ~/.bash_history | head -n 1
~/.bash_historyへの書き込みが終わったら、tail -n 2 ~/.bash_historyを実行して~/.bash_historyの末尾2行を取得する。
なぜ2行なのかというと、最後の1行は今実行したコマンド、つまりhistory -a && tail -n 2 ~/.bash_history | head -n 1 | tr -d '\n' | pbcopyになっているから。そのため、目的のコマンドはその1つ前のものということになる。
そして、取得した2行の1行目をhead -n 1で取得することで、目的のコマンドを取得できる。

| tr -d '\n'
これで目的のコマンドの文字列を取得できたが、この文字列の末尾には改行がついている。このままだとターミナル上でペーストするのに不都合が生じる。
trは文字列操作のためのコマンドで、対象の文字列 | tr -d '削除する文字列'とすると、対象の文字列から削除する文字列を除去した文字列を出力する。
これを使って改行文字(\n)を取り除く。

| pbcopy
最後にpbcopyで、クリップボードにコピーする。

試しに以下の操作をすると、クリップボードにecho 1がコピーされているのを確認できる。

$ echo 1
1
$ history -a && tail -n 2 ~/.bash_history | head -n 1 | tr -d '\n' | pbcopy

エイリアスへの設定

いちいちhistory -a && tail -n 2 ~/.bash_history | head -n 1 | tr -d '\n' | pbcopyを実行するのはそれこそ手間なので、エイリアスに登録する。

エイリアスに登録するためにはまず、~/.bashrcに登録する。このファイルがなければ作る。
以下の内容を書き込んで保存する。clcというのは CopyLastCommand の略なので、これは好きな名前を使えばよい。

alias clc=`history -a && tail -n 2 ~/.bash_history | head -n 1 | tr -d '\n' | pbcopy`

次に、~/.bash_profile~/.bashrcを読み込むようにする。source ~/.bashrcという内容を書き込めばいいので、以下を実行する。

$ echo 'source ~/.bashrc' >> ~/.bash_profile

これで、次に bash にログインしたときからclcを使えるようになる。すぐに使いたい場合は$ source ~/.bash_profileを実行する。

だがここで問題が発生した。エイリアスに登録すると、なぜか改行の除去が上手くいかない。このままだと不便だが、シェルスクリプトにすると意図通りに動いたので、やむを得ずシェルスクリプトにすることにした。

シェルスクリプト化

copy_last_bash_historyという名前で、以下の内容のファイルを作る。

#!/bin/bash

tail -n 2 ~/.bash_history | head -n 1 | tr -d '\n' | pbcopy

ファイルに実行権限を付与する。

$ chmod +x ファイルのパス

既にシェルスクリプト用のディレクトリがある場合はそこに置く。
まだない場合は、ディレクトリの作成と、パスを通す作業が必要になる。

ディレクトリの作成
今回は~/bin/というディレクトリを作り、そこに自作のシェルスクリプトを置くようにした。

パスを通す
~/bin/をコマンド検索パスに含めるための作業。
以下の内容を実行する。

$ echo 'export PATH="$HOME/bin:$PATH"' >> ~/.bash_profile
$ source ~/.bash_profile

これで、copy_last_bash_historyというコマンドを実行できるようになった。

エイリアスの設定を、以下に書き換える。

alias clc='history -a && copy_last_bash_history'

これで、$ clcを実行すると直前のコマンドがクリップボードにコピーされるようになった。

改良

せっかくシェルスクリプトにしたので、もう少し改良してみた。

#!/bin/bash

argument=$(($1))

if [ $argument = 0 ]; then
  argument=1
fi

tail -n $(($argument+1)) ~/.bash_history | head -n 1 | tr -d '\n' | pbcopy
pbpaste
echo ''

まず、直前のコマンド以外もコピーできるようにした。対象を引数で指定する。
clcあるいはclc 1で直前のコマンドをコピー、clc 2で2つ前のコマンドをコピー、clc 3で3つ前、という具合。

引数を受け取れるように、エイリアスの末尾に$1を追加する。

alias clc='history -a && copy_last_bash_history $1'

~/.bashrcを更新したときは$ source ~/.bash_profileか再ログインをしないと変更が反映されないので、忘れずに。

また、どのコマンドをコピーしたか分かるように、コピーしたコマンドをpbpasteを使って出力するようにした。最後にecho ''で改行を出力する。

例えば以下のように動作する。

$ echo foo
foo
$ echo bar
bar
$ clc 2
echo foo

このとき、クリップボードにはecho fooという文字列が格納されている。

参考資料