ReactHooks学習の為、タイプライターのように文字を一つずつ表示させるコンポーネントを作りました。
完成図
表題のとおり、一文字ずつ表示するだけです。
コンテンツがブロックのサイズを超えた場合、合わせてスクロールします。
タイピングアニメーション中、入力欄やボタンは無効化されます。
https://beebow6.github.io/react-typewriter/dist/
最終成果物はこちらです。
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
の戻り値の内、typeStart
とinputRock
はテキスト入力用コンポーネントとリセットボタンに渡します。
残りは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