作ったもの:mqtsuo02/lemon-with-react
最近ブログを書いていなかったせいで、書き方を忘れてしまった。
適当に伝えたいことを書いていく。
概要
いわゆるADV風なメッセージボックス。
それをReact上に実装して、文学に浸ってみた。一応、檸檬全部読める。
普通にstateを使うと再描画が走りまくってやばかったので、Reactに内緒でごにょごにょした。
仕様
何かしら馴染みが深いUIだと思うが、簡単に書き起こすと以下。
- 文字が流れる
- 全文表示後に文末で ▼ が点滅する
- クリックすると次の文章が表示される
- 文が流れる途中でクリックすると全文表示される
実装
今回の主要な仕様である「文字が流れる」を実現するためには、一定時間ごとに文字列を結合していく実装イメージが浮かぶ。
Reactではstateを中心にUIを再描画していくのが基本原則であるので、とりあえずstateでやろうとしたのだが、ライフサイクル制御の都合上、理想的なコードにならなかった。
なので、Reactの観測しない場所(つまりstateではないところ)で文字列を結合し、直接DOMを書き換えるということをした。
普通にJSでかいた処理をReact上で不整合が起きないように調整していった感じ。
import React from "react";
class StreamMessage extends React.Component {
constructor(props) {
super(props);
this.messageRef = React.createRef();
}
componentDidMount() {
this.InitializeMessage();
this.streamMessage();
}
componentDidUpdate() {
this.InitializeMessage();
this.streamMessage();
}
shouldComponentUpdate(nextProps) {
return nextProps.message === this.props.message ? false : true;
}
InitializeMessage() {
this.messageChars = this.props.message.split("");
this.charCount = 0;
this.message = "";
}
streamMessage() {
if (this.props.isStreamFinished) {
this.messageRef.current.innerText = this.props.message;
return;
}
if (this.messageChars.length > this.charCount) {
setTimeout(() => {
this.message += this.messageChars[this.charCount];
this.charCount++;
this.messageRef.current.innerText = this.message;
this.streamMessage();
}, 50);
return;
}
this.props.onFinish();
}
render() {
return <span ref={this.messageRef} />;
}
}
export default StreamMessage;
DOMを直接操作するためには、refを取得する必要があるので、constructorでReact.createRefを実行する
親コンポーネントで文字列の配列を持っていて、クリックする度に子である本コンポーネントに文字列(message)がpropsで渡ってくるイメージ。
componentDidMountとcomponentDidUpdateでは同じ処理を行う。
メッセージの初期化(initializeMessage)では、stateではなくインスタンスのプロパティとして3つ宣言する。
- messageChars: 受け取ったメッセージを一文字ずつの配列にしたもの
- charCount: インデックス
- message: messageCharsを一文字ずつ結合する対象
文字を流す処理(streamMessage)は、setTimeoutを用いて再帰的に実行する。isStreamFinishedは親コンポーネントのstateで、onClick時の制御とメッセージ出力後の点滅▼を表示するフラグ。
親からはpropsとしてmessageの他に、前述のisStreamFinishedとそれを更新するonFinishを渡していて、shouldComponentUpdateを実装することでmessageの変更時のみ再描画されるように制御している。
本当はReact.memoとReact.useCallbackを使用して、関数コンポーネントで書きたかったのだが、isStreamFinishedがあるためにclassコンポーネントで書かざるを得なかった。memoにareEqual関数を渡してやってみたが、どうやら再帰処理の最中にisStreamFinishedの変更を感知できなかったようだった。
親コンポーネントはこんな感じ。
/** @jsx jsx */
import { useState } from "react";
import { jsx, css } from "@emotion/core";
import StreamMessage from "./StreamMessage";
import NextSymbol from "./NextSymbol";
const MessageBox = ({ messages }) => {
const [messageIndex, setMessageIndex] = useState(0);
const [isStreamFinished, setIsStreamFinished] = useState(false);
const handleFinish = () => {
setIsStreamFinished(true);
};
const handleClick = () => {
if (!isStreamFinished) {
setIsStreamFinished(true);
return;
}
if (messages.length > messageIndex + 1) {
setIsStreamFinished(false);
setMessageIndex(messageIndex + 1);
return;
}
console.log("the end");
};
return (
<div>
<div css={boxStyle} onClick={handleClick}>
<StreamMessage
message={messages[messageIndex]}
isStreamFinished={isStreamFinished}
onFinish={handleFinish}
/>
{isStreamFinished && <NextSymbol />}
</div>
</div>
);
};
const boxStyle = css`
color: white;
background-color: black;
cursor: pointer;
`;
export default MessageBox;
stylingにはEmotionを使用している。
点滅の▼はEmotionでkeyframesを定義してアニメーションさせている。すごく便利。
/** @jsx jsx */
import { jsx, css, keyframes } from "@emotion/core";
const NextSymbol = () => <span css={nextStyle}>▼</span>;
const blink = keyframes`
0% {
visibility: hidden;
}
50% {
visibility: hidden;
}
100% {
visibility: visible;
`;
const nextStyle = css`
animation: 1s linear infinite ${blink};
`;
export default NextSymbol;
檸檬の本文は青空文庫から拝借していて、ブラウザ上からコピペして文字列としてjsファイルにした。そのためルビが入ったままである。
App.jsで適当にフォーマット&配列化してぶちこんだ(粗仕事)
import React from "react";
import MessageBox from "./Components/MessageBox";
import lemonText from "./lemonText";
const formatText = text => text.replace(/\r?\n/g, "").replace(/ /g, "");
const splitText = text =>
text
.replace(/。/g, "。,")
.replace(/」/g, "」,")
.split(",");
const App = () => (
<div>
<h1>檸檬 / 梶井基次郎</h1>
<MessageBox messages={splitText(formatText(lemonText))} />
</div>
);
export default App;
本来ならばWebAPIから取得するイメージ。なので適当にやった。
以上
stateを使わずに、DOMを直接いじるためにしたことをまとめた。
今回紹介したプログラムは ReactLT会 @Informetis で発表するために、普段個人で作っているゲームから抽出したもの。
以上、ありがとうございました。