Reactにナイショで文学作品「檸檬」を読んでみた


|![lemon.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/178904/48fbff89-caaa-db95-dd2b-7d2ce87c0b0b.gif)|
|:-:|

作ったもの：[mqtsuo02/lemon-with-react](https://github.com/mqtsuo02/lemon-with-react)


最近ブログを書いていなかったせいで、書き方を忘れてしまった。

適当に伝えたいことを書いていく。


## 概要

いわゆるADV風なメッセージボックス。

それをReact上に実装して、文学に浸ってみた。一応、檸檬全部読める。

普通にstateを使うと再描画が走りまくってやばかったので、Reactに内緒でごにょごにょした。


## 仕様

何かしら馴染みが深いUIだと思うが、簡単に書き起こすと以下。

- 文字が流れる
- 全文表示後に文末で ▼ が点滅する
- クリックすると次の文章が表示される
- 文が流れる途中でクリックすると全文表示される


## 実装

今回の主要な仕様である「文字が流れる」を実現するためには、一定時間ごとに文字列を結合していく実装イメージが浮かぶ。

Reactではstateを中心にUIを再描画していくのが基本原則であるので、とりあえずstateでやろうとしたのだが、ライフサイクル制御の都合上、理想的なコードにならなかった。

なので、Reactの観測しない場所(つまりstateではないところ)で文字列を結合し、直接DOMを書き換えるということをした。

普通にJSでかいた処理をReact上で不整合が起きないように調整していった感じ。

```StreamMessage.js
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の変更を感知できなかったようだった。

親コンポーネントはこんな感じ。

```MessageBox.js
/** @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を定義してアニメーションさせている。すごく便利。

```NextSymbol.js
/** @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で適当にフォーマット&配列化してぶちこんだ(粗仕事)


```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](https://informetis.connpass.com/event/133541/) で発表するために、普段個人で作っているゲームから抽出したもの。

以上、ありがとうございました。