ReactHooksでタイプライターのように文字を表示するコンポーネント

ReactHooks学習の為、タイプライターのように文字を一つずつ表示させるコンポーネントを作りました。

# 完成図
表題のとおり、一文字ずつ表示するだけです。
コンテンツがブロックのサイズを超えた場合、合わせてスクロールします。
タイピングアニメーション中、入力欄やボタンは無効化されます。

https://beebow6.github.io/react-typewriter/dist/
![reactTipewriter.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/362737/ca2db010-379e-4d3a-f043-02d5c5be8ccf.gif)

最終成果物はこちらです。
https://github.com/BeeBow6/react-typewriter

# Typingコンポーネント
呼び出し元から下記情報を受け取ります。（※はオプション）

- タイプ表示する文字列
- タイプ表示終了後のコールバック関数
- 点滅するカーソルの表示有無 ※
- コンポーネントに適用したいCSSクラス名 ※
- 文字の表示間隔（ミリ秒）※

```jsx
const Typing = ({
  message,
  typeEnd,
  cursor = true,
  className = '',
  speed = 50
}) => {

  const [text, setText] = useState('');
  const msgEl = useRef();

  // 指定された間隔でstateを更新する
  useEffect(() => {
    // マウント時の処理
    const charItr = message[Symbol.iterator]();
    let timerId;

    (function showChar() {
      const nextChar = charItr.next();
      if (nextChar.done) {
        typeEnd();
        return;
      }
      setText(current => current + nextChar.value);
      timerId = setTimeout(showChar, speed);
    }());

    // アンマウント時に念のためタイマー解除
    return () => clearTimeout(timerId);
  }, []);

  // レンダリングのたびに表示エリアをスクロールする
  useEffect(() => {
    const el = msgEl.current;
    if (el.clientHeight < el.scrollHeight) {
      el.scrollTop = el.scrollHeight - el.clientHeight;
    }
  });

  return (
    <div
      className={className + (cursor ? ' cursor-blink' : '')}
      style={{ whiteSpace: 'pre-line' }}
      ref={msgEl}
    >
      {text}
    </div>
  );
};
```

## 文字列の更新
一つ目の[useEffect](https://ja.reactjs.org/docs/hooks-reference.html#useeffect)にて`props`で受け取った文字列を、一文字ずつローカルStateの`text`に追加しています。
`useEffect`の第二引数に空の配列を渡しているため、[マウント](https://ja.reactjs.org/docs/react-component.html#mounting)時にしか実行されません。
また、戻り値の関数は[アンマウント](https://ja.reactjs.org/docs/react-component.html#unmounting)時に実行されます。

マウント時、受け取った文字列オブジェクトの[Symbol.iterator](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Symbol/iterator)メソッドを使用して、[イテレーター](https://developer.mozilla.org/ja/docs/Web/JavaScript/Guide/Iterators_and_Generators)を取得しています。
そのイテレータが最後にたどり着くまで、`setTimeout`を繰り返しています。

## 表示エリアのスクロール
二つ目の`useEffect`は、レンダリングのたびに実行されます。
[useRef](https://ja.reactjs.org/docs/hooks-reference.html#useref)を利用して、DOM要素を直接参照し、コンテンツがはみ出している場合に下方へスクロールします。

# Typingコンポーネントを操作する
## カスタムHook
Typingコンポーネント単体でも一応使えますが、操作用の[カスタムHook](https://ja.reactjs.org/docs/hooks-custom.html)も用意しました。
下記の値を返します。

- `typeStart(newMessage)`: タイプ表示開始
- `typeEnd()`: タイプ表示終了
- `inputRock`: タイプ表示中`true`になる。
- `key`: タイプ表示開始毎に発行する識別値（後述）
- `message`: `typeStart`で取得した文字列

```js
const useTyping = () => {
  const [message, setMessage] = useState('');
  const [key, setKey] = useState(0);
  const [inputRock, setRock] = useState(false);

  const typeStart = (text = '') => {
    setKey(state => state + 1);
    setRock(true);
    setMessage(text);
  };
  const typeEnd = () => {
    setRock(false);
  };

  return {
    typeStart,
    typeEnd,
    inputRock,
    key,
    message,
  };
};
```

## useTypingの利用
`useTyping`の戻り値の内、`typeStart`と`inputRock`はテキスト入力用コンポーネントとリセットボタンに渡します。
残りはTypingコンポーネントに渡します。

```jsx
import Input from './input';
import {
  Typing,
  useTyping
} from './typing';

const App = () => {
  const {
    typeStart,
    inputRock,
    ...params
  } = useTyping();

  return (
    <div className="wrp">
      <Input
        onSubmit={typeStart}
        rock={inputRock}
      />
      <Typing
        className="msg-box"
        speed={80}
        {...params}
      />
      <button
        type="button"
        className="btn"
        disabled={inputRock || !params.message}
        onClick={() => typeStart('')}
      >
        Reset
        </button>
    </div>
  );
};
```

### Key値によるコンポーネント管理
[Key](https://ja.reactjs.org/docs/lists-and-keys.html#keys)は、主にリスト項目などで兄弟関係のコンポーネントを識別する為に使用されますが、ここではコンポーネントをリセットする目的で使用しています。
`key`値が変更されると、Reactコンポーネントは再レンダリングされるのではなく、一度破棄されてまた再構築します。
下記の様に、`useEffect`の実行タイミングを`message`の変化を基にした場合、同一文字列を再び受け取った場合は変化なしと判断されて実行されません。

```js
const Typing = ({
  message,
  typeEnd,
  cursor = true,
  className = '',
  speed = 50
}) => {

  useEffect(() => {
    const charItr = message[Symbol.iterator]();
    // ...
    (function showChar() {
      // ...
    }());
    return () => clearTimeout(timerId);
  }, [message]);
```

表示文字列とは別に、`typeStart()`が呼び出される毎に更新される値を基にすることで、必ずタイピングアニメーションが開始するようにしています。
`key`値ではなく、`id`として下記の様にコンポーネントに渡して再レンダリングの範囲で対応することも考えました。

```js
const Typing = ({
  id,
  message,
  typeEnd,
  cursor = true,
  className = '',
  speed = 50
}) => {

  useEffect(() => {
    const charItr = message[Symbol.iterator]();
    setText('');
    // ...
    (function showChar() {
      // ...
    }());
    return () => clearTimeout(timerId);
  }, [
    id, 
    message
  ]);
```

上記の場合`id`,`messsage`以外の`props`の変更には対応できなくなります。
しかし、依存リストに他のpropsを含めた場合、それらが不用意に変化しないことを確実にする必要があります。
例えば、タイピング終了時に任意の処理を実行する関数を渡す場合は、予め[useCallback](https://ja.reactjs.org/docs/hooks-reference.html#usecallback)でメモ化させてから渡さないと、他所のレンダリングに巻き込まれてタイピングを開始してしまいます。

```js
const App = () => {
  const {
    typeStart,
    inputRock,
    typeEnd,
    ...params
  } = useTyping();

  // Typingに渡すコールバック関数は、同一性を保つ必要が出てくる...
  const doSomething = useCallback(() => {
    // Do hogehoge...
    typeEnd();
  }, []);

  return (
    <div className="wrp">
      <Input />
      <Typing
        className="msg-box"
        speed={80}
        typeEnd={doSomething}
        {...params}
      />
```

そしてこの`useCallback`も、依存する値がある場合は変化について気を払う必要が出てくるので、面倒だから毎回破棄にした方が管理が楽そうだな…という理由で、`Key`を利用することにしました。
公式サイト上でも紹介されている方法で、パフォーマンスの心配はあまり必要ないそうです。

[Recommendation: Fully uncontrolled component with a key | React](https://ja.reactjs.org/blog/2018/06/07/you-probably-dont-need-derived-state.html#recommendation-fully-uncontrolled-component-with-a-key)