LoginSignup
3
1

More than 3 years have passed since last update.

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

Posted at
lemon.gif

作ったもの: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 で発表するために、普段個人で作っているゲームから抽出したもの。

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

3
1
0

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
3
1