LoginSignup
13

More than 3 years have passed since last update.

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

Posted at

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

完成図

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

https://beebow6.github.io/react-typewriter/dist/
reactTipewriter.gif

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

Typingコンポーネント

呼び出し元から下記情報を受け取ります。(※はオプション)

  • タイプ表示する文字列
  • タイプ表示終了後のコールバック関数
  • 点滅するカーソルの表示有無 ※
  • コンポーネントに適用したいCSSクラス名 ※
  • 文字の表示間隔(ミリ秒)※
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にてpropsで受け取った文字列を、一文字ずつローカルStateのtextに追加しています。
useEffectの第二引数に空の配列を渡しているため、マウント時にしか実行されません。
また、戻り値の関数はアンマウント時に実行されます。

マウント時、受け取った文字列オブジェクトのSymbol.iteratorメソッドを使用して、イテレーターを取得しています。
そのイテレータが最後にたどり着くまで、setTimeoutを繰り返しています。

表示エリアのスクロール

二つ目のuseEffectは、レンダリングのたびに実行されます。
useRefを利用して、DOM要素を直接参照し、コンテンツがはみ出している場合に下方へスクロールします。

Typingコンポーネントを操作する

カスタムHook

Typingコンポーネント単体でも一応使えますが、操作用のカスタムHookも用意しました。
下記の値を返します。

  • typeStart(newMessage): タイプ表示開始
  • typeEnd(): タイプ表示終了
  • inputRock: タイプ表示中trueになる。
  • key: タイプ表示開始毎に発行する識別値(後述)
  • message: typeStartで取得した文字列
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の戻り値の内、typeStartinputRockはテキスト入力用コンポーネントとリセットボタンに渡します。
残りはTypingコンポーネントに渡します。

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は、主にリスト項目などで兄弟関係のコンポーネントを識別する為に使用されますが、ここではコンポーネントをリセットする目的で使用しています。
key値が変更されると、Reactコンポーネントは再レンダリングされるのではなく、一度破棄されてまた再構築します。
下記の様に、useEffectの実行タイミングをmessageの変化を基にした場合、同一文字列を再び受け取った場合は変化なしと判断されて実行されません。

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

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

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

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でメモ化させてから渡さないと、他所のレンダリングに巻き込まれてタイピングを開始してしまいます。

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

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13