Reactのサンプルとして連打回数測定器をつくった

Reactサンプル1

Reactの色々な機能などを使ってみようかと思い、サンプルを作ってみることにした。 基本的にできたものはCodePenに置いておこう。

今回は基本的事項の確認として、

  • statepropsでのコンポーネント間の変数の受け渡し
  • styled-componentsを利用したCSSの埋め込み
  • ボタン押下の処理
  • コンポーネントのArrayの生成

これらの例を実装してみた。まずは、動作例を載せた後にちょっとした説明を書いていく。reactの説明の後はほぼ単なる実装メモになる。

動作例

これらの要素を使って連打力測定器のようなものを作った。測定方法は改善の余地がだいぶあるが、サンプルとしてはいいだろう。

See the Pen PushPush by ikapper (@ikapper) on CodePen.

stateやpropsでのコンポーネント間の変数の受け渡し(親→子)

コンポーネントはReactでのGUI部品という感じで、htmlの要素を効率的にレンダリングしてくれるもの。

{}で挟むことで変数に格納済みの子のコンポーネントを内部に含むこともできる。親子関係にあるコンポーネント間で親から子へ変数を受け渡す場合について考える。

子→親への変数の受け渡しはあまり行わない気がするのでとりあえず割愛。どちらかというと、子→親はイベントの伝播の方が多いと思う。

親→子への変数は、propsを使って渡すのがいい。親のAppから子のBarsへと変数を渡す例:

class App extends React.Component {
  ...
  render() {
    // styled-componentsでスタイリング(後述)
    const App = window.styled.div`...`;
    const SiiyaButton = window.styled.button`...`;
    return (
      <App>
        <Bars
          countPerSec={this.state.countPerSec}
          numOfBars={NUM_OF_BARS}
          ceil={this.state.ceil}
        />
        <SiiyaButton onClick={() => this.pound()}>連打しや</SiiyaButton>
      </App>
    );
  }
  ...
}

この例では、3つの変数をBarsへと渡している。htmlの属性のように指定できる。また、親で管理する変数は、描画に使うものであれば、stateで管理するのがいいだろう。this.setState({"key": value})で効率的レンダリングが行えるため。内部的に使うものならstateにはしなくていいと思う。しても問題はないが。今回では、連打回数の算出に使うlastTimestateで管理していない。

子からのイベントの伝播は、上記のonClick={() => this.pound()}のように指定すれば十分だと思う。 ボタンの場合はonClickで十分だが、そうでないときは子でthis.props.<props名>()で実行すれば親のメソッドが実行される。

孫からの伝播でも子で同じことをすれば大丈夫:

class App extends React.Component {
  ...
  render() {
    return <Child evHandler={()=> this.somefunc()} />
  }
  somefunc() {
    alert('ハロー')
  }
}
class Child extends React.Component {
  ...
  render() {
    return <Grandchild grandEvHandler={() => this.props.evHandler()} />
  }
}
class Grandchild extends React.Component {
  ...
  render () {
    return <button onClick={() => this.props.grandEvHandler()}>おして!</button>
  }
}

これでAppsomefunc()が呼ばれることだろう。propsを使うということを忘れずにおく。 この実装サンプルも作っておきたい。

コンポーネント間の変数・イベントの受け渡しについてはこれくらいにしておく。

styled-componentsを利用したCSSの埋め込み

styled-componentsはCSSをReactコンポーネントの定義内に書くことができるモジュール。CodePenではCDNで使える。

公式ドキュメントstyled-components: Basicsにある通り:

This style of usage requires the react CDN bundles and the react-is CDN bundle to be on the page as well (before the styled-components script.)

react-isも同じページ内でロードしないといけない点に注意する必要がある。 また、window.styledからアクセスできる点も注意がいる。

使用可能にすれば、下のようにすることで簡単にCSSとコンポーネントをまとめることができる:

class App extends React.Component {
  ...
  render() {
    const App = window.styled.div`
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      height: 100vh;
    `;
    const SiiyaButton = window.styled.button`
      flex: 1 1 auto;
      width: 20em;
      background-color: #eefeba;
      font-family: "M PLUS Rounded 1c";
      margin-top: 3px;
      transition: all 1s ease;
      &:hover {
        background-color: #efabaa;
      }
    `;
    return (
      <App>
        // ...
        <SiiyaButton onClick={() => this.pound()}>連打しや</SiiyaButton>
      </App>
    );
  }
}

おそらく、CSSを書くのはrender()の外の方がいいことだけは注意。公式ドキュメントでは、スクリプトのグローバルスコープに定義してたように認識している。サンプルということで一箇所にまとめた結果、render()内に定義となった。

細かい書き方は、先の公式ドキュメントを見るのが早い。擬似クラスの使用も楽ちん。

ボタン押下の処理

もう2回ほどサンプルを載せているが一応説明を。ボタンの場合は、html直書きと同じような感じで、onClickにメソッドを渡せばいい。(onclickではない。) thisの問題などは注意。無名関数で渡せば大体は大丈夫なイメージ:

...
  render() {
    ...
    return (
      <App>
          // ...
          <SiiyaButton onClick={() => this.pound()}>連打しや</SiiyaButton>
        </App>
      );
  }
...

このようにすれば、SiiyaButtonがクリックされた時に、Apppound()メソッドが呼ばれる。そのメソッド内で処理を行った後、this.setState()を行えば状態を更新できる。

pound()メソッドの中身は最後のコード全体の該当箇所にて。

コンポーネントのArray

ReactではコンポーネントをArrayでも保持・描画できる。<li></li>などの生成が考えられるが、今回は<div></div>のArrayを生成した。 今回はこのコンポーネントのArrayをメーターの目盛りとして使用する。

liというかArrayでコンポーネントを扱うときは、propskeyを与えた方がいい。与えないとワーニングが出ると思う。keyli(リスト中のアイテム)が一意に決定できる文字列なら何でもいい。

今回の場合のArrayの実装:

class Bars extends React.Component {
  render() {
    const bars = [];
    const delta = Math.floor(255 / this.props.numOfBars);
    const progress = Math.floor(
      (this.props.countPerSec / MAX_RENDA) * NUM_OF_BARS
    );
    // BarのArrayを生成
    for (let i = 0; i < this.props.numOfBars; i++) {
      const rr = i * delta;
      const gg = 20;
      const bb = 255 - rr;
      const alpha = i < progress ? FRONT_ALPHA : BACK_ALPHA;
      // #rrggbbの形式をキーとしておく
      const code = `#${hexColor(rr)}${hexColor(gg)}${hexColor(bb)}`;
      bars.push(<Bar key={code} rr={rr} gg={gg} bb={bb} alpha={alpha} />);
    }
    // スタイルは省略
    const Bars = window.styled.div`...`;
    const Count = window.styled.div`...`;
    return (
      <Bars>
        <Count>
          {this.props.countPerSec.toFixed(2)}回/s
          <br />
          MAX:{this.props.ceil.toFixed(2)}回/s
        </Count>
        {bars}
      </Bars>
    );
  }
}

rrbbをグラデーションにした。rrbbの合計値を255にしている。rr=0bb=255の組み合わせにならないことが起こる問題は許容した。そこまで正確でなくてもいいものだし。修正は合計の差分を均等に割り振る方法などがある。

カラーコードをkeyとしておけばArrayの中では一意性を保てる。

Countは重ねて表示して、Number.prototype.toFixed()を使って小数2桁まで表示する。

Reactはこの上まで、あとは実装のメモ

大体のReactの使用事項は済んだので、ここからは動作例の連打力測定の実装のメモをしていく。

Javascriptのコード全体はこちらにも載せておくが、実際のコード全体(html, css, javascript)は動作例のCodePenからも見れる。

Appの構造

まずはAppの全体構造から。シンプルにAppを最上位として、メーター部分(Bars)とボタン部分(SiiyaButton)に分けている。 そして、Barsはさらに子としてメーター1本ずつのBarのArrayと、現在カウントと最高カウントを表示するCountを持つ。

つまり構造は、

App
├── Bars
│   ├── Array of Bar
│   └── Count
└── SiiyaButton

このような感じになる。 CSSの観点からは、Appflexレイアウトで子を持つ。BarsBarflexレイアウトで持つ。Countposition: absolute;で重ねて表示する。

Appのすること

Appはコントローラ的な役割を果たす。なのでするべきことは他より少し多い。

  • ボタンクリックのイベントハンドリング
  • 1秒ごとの連打回数の管理
  • 1秒ごとの最大連打回数の管理

管理はstateを利用する。他のコンポーネントでは状態管理しないのでstateは使用しない。

Barsのすること

Barspropsとして、1秒ごとの連打回数・最大連打回数、バーの本数を受け取る。 そして、連打回数と表示上の限界連打回数(MAX_RENDA)から、現在の連打回数のMAX_RENDAに対する達成度合いを算出する。 未到達のバーは透明度を下げて視覚的に限界と、現状の値をわかるようにしている。

一方で、バーの本数からバーの色のグラデーションの間隔を決定する。今回は255を等分して変化をつけることにした。

アニメーションについて

フェードのアニメーションを使っている。これは、styled-componentskeyframesを使う。 window.styled.keyframesで利用できる。

使い方は簡単で:

// keyframesを定義して
changeAlpha = window.styled.keyframes`
  from {
    background-color: rgba(${this.props.rr}, ${this.props.gg}, ${this.props.bb}, ${this.props.alpha});
  }
  to {
    background-color: rgba(${this.props.rr}, ${this.props.gg}, ${this.props.bb}, ${BACK_ALPHA});
  }
`;
// 要素に埋め込む
const Bar = window.styled.div`
  flex: 1 1 auto;
  background-color: rgba(${this.props.rr}, ${this.props.gg}, ${this.props.bb}, ${BACK_ALPHA});
  animation: ${changeAlpha} 1s linear;
`;

のようにkeyframesを定義して、styled-componentsの定義内で埋め込めばいい。

カラーコード文字列の生成

rrなどの単体で渡して#rrggbbの1色分の16進数を作成するヘルパー関数。 MDNにあったと思うが、URLのメモを忘れていた…。 少しコードを追加して、1桁の場合は0埋めして2桁になるようにした。

function hexColor(c) {
  if (c < 256) {
    if (c < 16) {
      return "0" + Math.abs(c).toString(16);
    }
    return Math.abs(c).toString(16);
  }
  return 0;
}
// 16進数のカラーコードが生成できる
const code = `#${hexColor(rr)}${hexColor(gg)}${hexColor(bb)}`;

連打回数の算出方法

自然な感じに見せるのは結構難しい。一定時間連打し続けて、その後に連打した回数を一定時間で割れば1秒あたりの回数は出るが、何となくそういう方法にはしたくなかった。

結局は、1回のクリックから次のクリックまでかかった時間の逆数を1秒あたりの連打回数にすることにした。この方法の欠点は、描画間隔がクリックの仕方次第になってしまうこと。連打すればするほど描画回数が多くなり、処理落ちなどが発生すると連打回数が正しく計算できず、あっという間に30回/秒をオーバーしてしまうこともあるようだ。(下図参照) さらに、クリックをやめても直近の回数は残ってしまう。これの対処は簡単そうだが。

壊れた例:

1秒に42回もクリックできるわけないだろ😅 気持ちいい算出方法は、上記2つのハイブリッド的な感じだろうか。最初は後者で測定して、安定してきたらn秒ごとの回数をとる、というようなイメージ。

遭遇したちょっとしたスタイリングの問題

今回styled-componentsは使う予定がなかった。

しかし、スタイリングをrender()の中で変数を作って埋め込む形で行う、つまり、下のようなコードでエラーが起きるようなのでstyled-componentsを使用することにした。

  render() {
    // なぜかエラーに。reactのminifyの問題?
    const _style = `background-color: ${this.props.colorCode};`
    return (
      <div style={_style}>{this.props.colorCode}</div>
    )
  }

コメントアウトにもあるように、おそらくminifyの問題だと思う。変数が展開されるためか?と思われる。

こういうわけでstyled-componentsを使うことになったが、結局はコードのまとまりがよくなった気がする。個人的には見やすい。 クラス名などを考える必要がないし、CSSのファイルを見る必要も関連付けを考える必要もないのは楽だ。

javascriptコード全体

最後に、縦長になってしまうが、実装したjavascriptというかjsx全体のコードを載せておく。

const NUM_OF_BARS = 10;
const MAX_RENDA = 18;

const BACK_ALPHA = 0.2;
const FRONT_ALPHA = 0.8;

class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      countPerSec: 0,
      ceil: 0
    };
  }
  render() {
    const App = window.styled.div`
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      height: 100vh;
    `;
    const SiiyaButton = window.styled.button`
      flex: 1 1 auto;
      width: 20em;
      background-color: #eefeba;
      font-family: "M PLUS Rounded 1c";
      margin-top: 3px;
      transition: all 1s ease;
      &:hover {
        background-color: #efabaa;
      }
    `;
    return (
      <App>
        <Bars
          countPerSec={this.state.countPerSec}
          numOfBars={NUM_OF_BARS}
          ceil={this.state.ceil}
        />
        <SiiyaButton onClick={() => this.pound()}>連打しや</SiiyaButton>
      </App>
    );
  }
  pound() {
    const currentTime = performance.now();
    if (this.lastTime == null) {
      this.lastTime = currentTime;
    } else {
      const delta = currentTime - this.lastTime;
      this.lastTime = currentTime;
      // calc hz
      const cPerSec = (1 / delta) * 1000;
      if (cPerSec > this.state.ceil) {
        this.setState({
          countPerSec: cPerSec,
          ceil: cPerSec
        });
      } else {
        this.setState({
          countPerSec: cPerSec
        });
      }
    }
  }
}

class Bars extends React.Component {
  render() {
    const bars = [];
    const delta = Math.floor(255 / this.props.numOfBars);
    const progress = Math.floor(
      (this.props.countPerSec / MAX_RENDA) * NUM_OF_BARS
    );
    for (let i = 0; i < this.props.numOfBars; i++) {
      const rr = i * delta;
      const gg = 20;
      const bb = 255 - rr;
      const alpha = i < progress ? FRONT_ALPHA : BACK_ALPHA;
      const code = `#${hexColor(rr)}${hexColor(gg)}${hexColor(bb)}`;
      bars.push(<Bar key={code} rr={rr} gg={gg} bb={bb} alpha={alpha} />);
    }
    const Bars = window.styled.div`
      display: flex;
      flex-direction: column-reverse;
      flex: 3 1 auto;
      width: 20em;
    `;
    const Count = window.styled.div`
      position: absolute;
      top: 14px;
      left: 0;
      right: 0;
      text-align: center;
      font-size: 2em;
      font-family: "Nico Moji", "M PLUS Rounded 1c";
    `;
    return (
      <Bars>
        <Count>
          {this.props.countPerSec.toFixed(2)}回/s
          <br />
          MAX:{this.props.ceil.toFixed(2)}回/s
        </Count>
        {bars}
      </Bars>
    );
  }
}

class Bar extends React.Component {
  render() {
    let changeAlpha = null;
    if (this.props.alpha >= 0.5) {
      changeAlpha = window.styled.keyframes`
        from {
          background-color: rgba(${this.props.rr}, ${this.props.gg}, ${this.props.bb}, ${this.props.alpha});
        }
        to {
          background-color: rgba(${this.props.rr}, ${this.props.gg}, ${this.props.bb}, ${BACK_ALPHA});
        }
      `;
    }
    const Bar = window.styled.div`
      flex: 1 1 auto;
      background-color: rgba(${this.props.rr}, ${this.props.gg}, ${this.props.bb}, ${BACK_ALPHA});
      animation: ${changeAlpha} 1s linear;
    `;
    return <Bar></Bar>;
  }
}

function hexColor(c) {
  if (c < 256) {
    if (c < 16) {
      return "0" + Math.abs(c).toString(16);
    }
    return Math.abs(c).toString(16);
  }
  return 0;
}

ReactDOM.render(<App />, document.getElementById("app"));

おわり

だいぶ長くなってしまった。サンプルとしては動作例のCodePenを見れば十分だと思う。 Reactの基本の一部なので使いこなしておきたい。

以上です。


広告

Bitly
タイトルとURLをコピーしました