開発環境構築からのReact

React基礎

開発環境構築(Webpack)

Reactの開発をするにあたって、まずは開発環境の構築を進めていきたいと思います。利用するビルドツールですがよく使われていると思うのでWebpackを使っていきます。

以下の記事では最近のビルドツールについて説明されています。
https://tech-blog.rakus.co.jp/entry/20210930/frontend

そもそもWebpackとは公式ページのコンセプト に説明がありますが、ビルド時にエントリーのjsファイルを起点に依存関係を読み取って一つにまとめたりします。Webpackが出た当初はES5, ES6で書かれたjsがブラウザ上でそのまま動かなかったのでWebpack上でBabel(トランスパイラ)を利用してES5,ES6に対応できていないブラウザでも動くようにしてきました。 ただ、現在のブラウザの対応状況を見てみると主要なブラウザでは末尾再帰最適化以外の機能に対応できていることが分かります。
http://kangax.github.io/compat-table/es6/

Webpackではエントリーポイントのjsを起点にバンドルして一つにまとめるという特徴がありますが、後発のビルドツールであるviteではESModuleといって別のjsファイルをimportする機能がブラウザ側にある前提でバンドルを行わずビルド時間の面で改善されているようです。
https://ja.vitejs.dev/guide/why.html#%E5%95%8F%E9%A1%8C%E7%82%B9

プロジェクト作成

ビルドまで

では公式のページを参考にプロジェクト作成から進めていきます。以下のコマンドを実行しpackage.jsonを作成します。

mkdir react-tutorial
cd react-tutorial
npm init -y
npm install webpack webpack-cli --save-dev

npm install webpack webpack-cli --save-devにて対象のプロジェクトのnode_modules以下のディレクトリにwebpackの依存が追加されています、package.jsonにも開発用ライブラリとしてdevDependenciesに追加されていることが確認できます。gitでnpmプロジェクトをクローンしてきた時はプロジェクトの依存を解決するためにnpm installを実行しpackage.jsonに記述された依存をダウンロードしてきます。

次に src/index.js を作成します。既存はlodashを使用していましたがES6だとArray.prototype.join()が使えて不要なので書き換えます。

function component() {
  const element = document.createElement('div');
  element.innerHTML = ['Hello', 'webpack'].join(' ');
  return element;
}

document.body.appendChild(component());

それからtemplateのhtmlも追加しておきたいので、以下のindex.htmlも追加しておきます。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>webpack App</title>
  </head>
  <body>
  </body>
</html>

Webpack上でhtml用のプラグインを使いたいので以下のコマンドを実行します。

npm i -D html-webpack-plugin

それからwebpackの設定ファイルを追加したいので以下のwebpack.config.jsを追加します。

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
 mode: 'development',
 entry: {
   index: './src/index.js',
 },
 plugins: [
    new HtmlWebpackPlugin({
      template: 'index.html',
      filename: '[name].html'
    }),
  ],
  output: {
   filename: '[name].bundle.js',
   path: path.resolve(__dirname, 'dist'),
   clean: true,
  },
};

entryにて起点となるjsを指定しています、それからhtml-webpack-pluginでテンプレートのhtmlを指定し、outputで出力先の設定をしています。

これでビルドの準備が整ったのでnpx webpack --config webpack.config.jsを実行しビルドします、configはwebpack.config.jsがデフォルトなので省略しても問題ありません。distのindex.htmlをブラウザで開いたらビルドできていることが確認できるかと思います。

[arimura@MacMini]$ npx webpack --config webpack.config.js
asset index.bundle.js 1.37 KiB [emitted] (name: index)
asset index.html 173 bytes [emitted]
./src/index.js 186 bytes [built] [code generated]
webpack 5.73.0 compiled successfully in 57 ms

npxはnpmのスクリプトを直接実行するためのコマンドなのですがいpackage.jsonに以下のようにbuildを追加しnpm run buildを実行するのと同じになります。

~省略~
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "webpack --config webpack.config.js"
  },
~省略~

webpack-dev-server導入

開発時の効率アップのためwebpack-dev-serverを利用します、以下のコマンドでwebpack-dev-serverの依存を追加します。

npm install -D webpack-dev-server

それからwebpack.config.jsにdevServerを追加します。

~省略~
module.exports = {
  mode: 'development',
  devServer: {
    hot: true,
    compress: true,
    port: 9000,
    open: true,
  },
  entry: {
    index: './src/index.js',
  },
~省略~

これでwebpack-dev-serverの確認ができるのでnpx webpack serve --config webpack.config.jsを実行し、ブラウザが開いてビルド結果がみれたかと思います。hot: trueでHot Module Replacementに対応しているので、index.jsを修正することで自動でブラウザの表示も切り替わります。

React

hello world

Reactのプロジェクト作成にはCreate React Appが使われているかと思うのですが、今回は自前でビルド環境を作っていきたいと思います。

まずReactの依存をpackage.jsonに追加するので以下のコマンドを実行します、ビルドの依存ではなくアプリの依存になるので--save または -Sで依存を追加します。

npm install -S react react-dom

それからjsxのビルドのためにbabelも追加します、@babel/preset-reactを追加するとその依存で@babel/plugin-transfrom-jsxも入るのでこれでjsxもビルドできるようになります。

npm install -D babel-loader @babel/core @babel/preset-env @babel/preset-react

そもそもjsxとは何かですが、Javascript側で利用できるDom定義のシンタックスシュガーで素のJSでDocument.createElement()を直接呼び出したり、ReactのReact.createElement()ではDomの全体的な構造が把握しずらいのでJSXにて通常のhtmlのように記述することができます。例えばjsxを利用すると以下のようにdomの定義を行うことができます。

const element = (
  <div>
    <h1>Hello!</h1>
    <h2>Good to see you here.</h2>
  </div>
);

webpack上でbabelを利用できるように以下のようにmoduleにbabel-loaderを追加します、それからエントリーのjsを./src/index.jsxに変更しておきます。

~省略~
  resolve: { extensions: ["*", ".js", ".jsx"] },
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        exclude: /node_modules/,
        loader: "babel-loader",
      },
    ]
  },

  entry: {
    index: './src/index.jsx',
  },
~省略~

それから@babel/preset-reactを利用できるように以下の.babelrcを追加します。

{
    "presets": [
        "@babel/preset-env",
        "@babel/preset-react"
    ]
}

次にReactのhello worldを書いていこうと思います、今回はReact18が依存に追加されていたのでsrc/index.jsxに以下のファイルを追加します。

import * as React from 'react';
import * as ReactDOMClient from 'react-dom/client';

const root = ReactDOMClient.createRoot(document.getElementById('root'));
root.render(<h1>Hello, world!</h1>);

const root = ReactDOMClient.createRoot(document.getElementById('root'));の部分はReactのルートのエレメントを作成し、html上にあるdocument.getElementById('root')で取得できるエレメントに対してマウントする動きになります。それからroot.render(<h1>Hello, world!</h1>);でマウント先に書き込みを行なっています。templateのhtmlにはrootのdomがなかったので以下のようにindex.htmlを修正します。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>react App</title>
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

これで npx webpack serveを実行するとブラウザ上にHello, world!が表示されるかと思います。

Reactで開発しやすくするようにSourceMapを追加します、これでビルド後に開発者ツールのソースで確認した時にソースがそのまま表示されるようになるかと思います。

~省略~
module.exports = {
  mode: 'development',
  devtool: 'source-map',
  devServer: {
~省略~

チュートリアル

Reactのhello worldまでできたので次はチュートリアルのコードを動かしたいと思います。 https://ja.reactjs.org/tutorial/tutorial.html

先にスタイルを読み込めるようにしたいと思うので、以下のコマンドでcss-loaderを依存に追加しておきます。

npm install --save-dev style-loader css-loader

それからwebpack.config.jsを修正しcss-loader, style-loaderを追加します。

~省略~
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        exclude: /node_modules/,
        loader: "babel-loader",
      },
      {
        test: /\.css$/i,
        use: ["style-loader", "css-loader"],
      },
    ]
  },
~省略~

次に読み込み対象のcssをmain.cssで追加します。

body {
    font: 14px "Century Gothic", Futura, sans-serif;
    margin: 20px;
  }
  
  ol, ul {
    padding-left: 30px;
  }
  
  .board-row:after {
    clear: both;
    content: "";
    display: table;
  }
  
  .status {
    margin-bottom: 10px;
  }
  
  .square {
    background: #fff;
    border: 1px solid #999;
    float: left;
    font-size: 24px;
    font-weight: bold;
    line-height: 34px;
    height: 34px;
    margin-right: -1px;
    margin-top: -1px;
    padding: 0;
    text-align: center;
    width: 34px;
  }
  
  .square:focus {
    outline: none;
  }
  
  .kbd-navigation .square:focus {
    background: #ddd;
  }
  
  .game {
    display: flex;
    flex-direction: row;
  }
  
  .game-info {
    margin-left: 20px;
  }

それからindex.jsx側でcssを読み込ませたら際ビルドでスタイルが適用されたことが確認できるかと思います。

import * as React from 'react';
import * as ReactDOMClient from 'react-dom/client';

import "../main.css";

const root = ReactDOMClient.createRoot(document.getElementById('root'));
root.render(<h1>Hello, world!</h1>);

次にindex.jsxを以下のように書き換えることでチュートリアルのコードが動きます。

import * as React from 'react';
import * as ReactDOMClient from 'react-dom/client';

import "../main.css";

function Square(props) {
    return (
        <button className="square" onClick={props.onClick}>
            {props.value}
        </button>
    );
}

class Board extends React.Component {
    renderSquare(i) {
        return (
            <Square
                value={this.props.squares[i]}
                onClick={() => this.props.onClick(i)}
            />
        );
    }

    render() {
        return (
            <div>
                <div className="board-row">
                    {this.renderSquare(0)}
                    {this.renderSquare(1)}
                    {this.renderSquare(2)}
                </div>
                <div className="board-row">
                    {this.renderSquare(3)}
                    {this.renderSquare(4)}
                    {this.renderSquare(5)}
                </div>
                <div className="board-row">
                    {this.renderSquare(6)}
                    {this.renderSquare(7)}
                    {this.renderSquare(8)}
                </div>
            </div>
        );
    }
}

class Game extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            history: [
                {
                    squares: Array(9).fill(null)
                }
            ],
            stepNumber: 0,
            xIsNext: true
        };
    }

    handleClick(i) {
        const history = this.state.history.slice(0, this.state.stepNumber + 1);
        const current = history[history.length - 1];
        const squares = current.squares.slice();
        if (calculateWinner(squares) || squares[i]) {
            return;
        }
        squares[i] = this.state.xIsNext ? "X" : "O";
        this.setState({
            history: history.concat([
                {
                    squares: squares
                }
            ]),
            stepNumber: history.length,
            xIsNext: !this.state.xIsNext
        });
    }

    jumpTo(step) {
        this.setState({
            stepNumber: step,
            xIsNext: (step % 2) === 0
        });
    }

    render() {
        const history = this.state.history;
        const current = history[this.state.stepNumber];
        const winner = calculateWinner(current.squares);

        const moves = history.map((step, move) => {
            const desc = move ?
                'Go to move #' + move :
                'Go to game start';
            return (
                <li key={move}>
                    <button onClick={() => this.jumpTo(move)}>{desc}</button>
                </li>
            );
        });

        let status;
        if (winner) {
            status = "Winner: " + winner;
        } else {
            status = "Next player: " + (this.state.xIsNext ? "X" : "O");
        }

        return (
            <div className="game">
                <div className="game-board">
                    <Board
                        squares={current.squares}
                        onClick={i => this.handleClick(i)}
                    />
                </div>
                <div className="game-info">
                    <div>{status}</div>
                    <ol>{moves}</ol>
                </div>
            </div>
        );
    }
}

// ========================================

const root = ReactDOMClient.createRoot(document.getElementById('root'));
root.render(<Game />);

function calculateWinner(squares) {
    const lines = [
        [0, 1, 2],
        [3, 4, 5],
        [6, 7, 8],
        [0, 3, 6],
        [1, 4, 7],
        [2, 5, 8],
        [0, 4, 8],
        [2, 4, 6]
    ];
    for (let i = 0; i < lines.length; i++) {
        const [a, b, c] = lines[i];
        if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
            return squares[a];
        }
    }
    return null;
}

Chromeでの開発では以下を使うことでコンポーネントのpropsやstateが確認しやすくなります。 https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi?hl=ja

開発者ツールで確認すると以下のようなコンポーネントの構造だというのが分かるかと思います。

  • Game. ← state管理
    • Board. ← propsとして表示
      • Squrare. ← propsとして表示
      • Squrare. ← propsとして表示
      • ~省略~

コンポーネントにはstateとpropsという2種類のデータがあるのですが、stateは生成したコンポーネントが管理対象とするデータでpropsは親のコンポーネントから受け取った表示対象のデータになります。今回のマルバツの選択やステップの情報は一番親のGameコンポーネントで生成して管理し、それから更新があれば子コンポーネントの描画に反映されます。もう少し具体的に言うとGameコンポーネント内でsetStateでステートを更新するとGameコンポーネントのrenderが呼び出され、さらに更新したstateを子コンポーネントはpropsとして受けとって再描画が行われます。なのでrender関数内で不要にオブジェクトを生成しないようにする方がパフォーマンスは良くなるらしいです。(render内の処理でarrow関数使うと楽ですが、パフォーマンスを最適化したいなら コンポーネントに関数を渡す – React使わない方が良いらしいです。) パフォーマンスについてはいかにもまとめられています。

ステートレスなコンポーネントによるReactのパフォーマンス最適化 – WPJ.
Reactのパフォーマンスチューニングの歴史をまとめてみた | blog.ojisan.io

state とライフサイクル – Reactにあるように、データの流れは親から子になるので、コンポーネント毎に管理対象の決めておいて、そこでstateとして生成する必要があります。今回であれば一番親のGameコンポーネントでstateを生成し、子コンポーネント側はstate更新の操作が検知できたら親コンポーネントから受け取ったアクションを呼び出し、親側でstateを更新し親から子へ再描画が伝搬しています。パフォーマンス最適化であったようにコンポーネントが増えてくると親に変更があっても、再描画が必要でない場合がありそう言う場合はPureComponentを使ったり、shouldComponentUpdateを書いたりして無駄な再描画は減らせるらしいです。

statelessにして再利用性を高くする

チュートリアルのコードでもstateの管理は親コンポーネントで行い、子コンポーネントをpropsとして表示でしたが、以下の公式ではstatelessにすることで再利用性が高くなると言うのが確認しやすいのでみてみます。 state のリフトアップ – React

実装自体は以下のようになります。

const scaleNames = {
    c: 'Celsius',
    f: 'Fahrenheit'
};

function toCelsius(fahrenheit) {
    return (fahrenheit - 32) * 5 / 9;
}

function toFahrenheit(celsius) {
    return (celsius * 9 / 5) + 32;
}

function tryConvert(temperature, convert) {
    const input = parseFloat(temperature);
    if (Number.isNaN(input)) {
        return '';
    }
    const output = convert(input);
    const rounded = Math.round(output * 1000) / 1000;
    return rounded.toString();
}

function BoilingVerdict(props) {
    if (props.celsius >= 100) {
        return <p>The water would boil.</p>;
    }
    return <p>The water would not boil.</p>;
}

class TemperatureInput extends React.Component {
    constructor(props) {
        super(props);
    }

    handleChange = (e) => {
        this.props.onTemperatureChange(e.target.value);
    }

    render() {
        const temperature = this.props.temperature;
        const scale = this.props.scale;
        return (
            <fieldset>
                <legend>Enter temperature in {scaleNames[scale]}:</legend>
                <input value={temperature}
                    onChange={this.handleChange} />
            </fieldset>
        );
    }
}

class Calculator extends React.Component {
    constructor(props) {
        super(props);
        this.state = { temperature: '', scale: 'c' };
    }

    handleCelsiusChange = (temperature) => {
        this.setState({ scale: 'c', temperature });
    }

    handleFahrenheitChange = (temperature) => {
        this.setState({ scale: 'f', temperature });
    }

    render() {
        const scale = this.state.scale;
        const temperature = this.state.temperature;
        const celsius = scale === 'f' ? tryConvert(temperature, toCelsius) : temperature;
        const fahrenheit = scale === 'c' ? tryConvert(temperature, toFahrenheit) : temperature;

        return (
            <div>
                <TemperatureInput
                    scale="c"
                    temperature={celsius}
                    onTemperatureChange={this.handleCelsiusChange} />
                <TemperatureInput
                    scale="f"
                    temperature={fahrenheit}
                    onTemperatureChange={this.handleFahrenheitChange} />
                <BoilingVerdict
                    celsius={parseFloat(celsius)} />
            </div>
        );
    }
}

ここではTemperatureInputが単純にテキストの表示のみでデータの管理自体は親に任せることで摂氏、華氏用のテキストインプットとして利用できています。性能改善の