48
42

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

React #2Advent Calendar 2019

Day 19

C#エンジニアに贈るReact入門

Last updated at Posted at 2019-12-18

この記事は React #2 Advent Calendar 2019 19日目の記事です。

はじめに

本職は、C#でWindows Formを使ったレガシーなアプリケーションを作っています。

ただ、レガシーなアプリを作っていると、Webフロントエンドは、進化が早くて、活発で、
なんかキラキラしている感じがして、うらやましくなってきます。

Reactは、趣味だけなのでまだまだですが、そんなC#エンジニア(以降、C#er)から見たReact入門を解説したいと思います。

同僚がReactを勉強したいと言い出したので、そのための資料でもあります。

React の Advent Calendar ということで他の方がReactのTipsを公開している中、
一人だけ空気を読まなくて申し訳ないです・・・。

なお、Webフロントエンド界隈は進化や世代交代が早いので、
この記事の賞味期限もせいぜい半年~1年くらいだと思っています。

(2020-09-02 Modified)
予想通りライブラリやツールのバージョンアップによっていろいろ変更なようです。
この記事は自分でも使っていて不便なので最低限問題ないように修正しました。
(もしかしたら抜けがあるかも?)

(2020-10-04 Modified)
上の対応ではだめだったので、さらに追記しました。

(2020-12-31 Modified)
なんかAdvent Calendar 2020の影響でPVが増えたのかストックされる方が増えたので
いろいろ動かないところを最低限アップデートしました。

  • MobX ver.6.0.0 以降に対応
  • .envファイルは不要になった
  • Visual Studio Codeでnode_modules以下のTypeScriptを使用する設定

要約

  • C#エンジニアがReactを始めるときは、Typescript、MobX、Material-UI、Class Componentを使うといいよ。
  • 解説をつけながらハンズオン的な感じでブログっぽいページを作るよ

想定読者

  • WindowsでC#を使ってWindows Formなアプリを作ってきた方
  • および、Webアプリは、CGIとかAjaxあたりで止まっている方

React基礎知識

そもそもReactって?

Webアプリのフロントエンドを構築するためのJavascriptライブラリです。
最近の流行として、htmlのページ遷移無しで、1ページ内で完結させようという流行(SPA、Single Page Application)があり、ReactはSPAを実現するためのフレームワークの1つです。
主な役割はhtmlのレンダリングであり、C#でいうと、WPFにおけるXAMLや、WindowsFormsの*.Designer.csに相当します。

Reactって何がうれしいの?

レガシーなWebサイトの場合、htmlでコンテンツを管理して、 見た目はCSS、何か動かすならJavascriptという感じだったと思います。

ただ、htmlで使えるのは決まったタグだけで、 部品化ができず、凝ったことをしようとすると作りこみが必要なわりに、それを他のページに展開するにはコピペが必要になってしまいます。

Reactを使うと、独自のHTMLタグを増やすことができるようになります。
さらに、単なるhtmlの部品ではなく、Javascriptもセットになっているので、
部品化によってリッチなUIを簡単に作ることができるようになります。

C#でいうなら、既定のButtonやLabelしか使えない状況から、UserControlが使えるようになったものです。

公開されている部品を組み合わせてもいいですし、もちろん自分で部品を作ることもできます。

例えば、次のようなブログのトップページをイメージしてもらうと、

Sample-Blog-Top.jpg

1つの記事のサマリーがAbstractContentという部品になっていれば、 それを並べればTopページが作れます。

<div className="TopPage">
	<AbstractContent title={title1} .... />
	<AbstractContent title={title2} .... />
	<AbstractContent title={title3} .... />
</div>

さらにAbstractContentも何かの部品を組み合わせているので 次のようなツリー構造になります。

Blog-Component-Tree.jpg

部品の例としては、material-uiのサイトを眺めるとこの部品化がイメージしやすいと思います。

Reactで部品はどうやって作るのか?

Reactの部品はこんな感じで作ります。

Reactコンポーネントの例
import React from "react";

interface BlogPartsProps {
  id: number;
  title: string;
  date: string;
  abstract: string;
}

class BlogParts extends React.Component<BlogPartsProps > {
  public render(): JSX.Element {
    return (
      <div>
      	<a href={`/contents/${this.props.id}`}><p>{this.props.title} {this.props.date}</p></a>
      	<p>{this.props.abstract}</p>
      </div>
    );
  }
}

export default BlogParts;

細かい説明は、他サイトを参考にしてもらったらよいと思いますが、
ポイントは次の2つです。

  • render関数では、JavascriptとHtmlが混ざった書き方をする。C#でいうと、RazorやT4テンプレートみたいなもの。
  • BlogPartsProps interfaceに定義されているフィールドが、外から渡されるパラメータ

BlogPartsPropsうんぬんは、C#のWindows Formで例えると、こんな感じで、外からセットされるプロパティに相当します。

C#でのPropsの例
class BlogParts : UserControl
{
  public int id {get;set;}
  public string title {get;set;}
  public string date {get;set;}
  public string abstractContent {get;set;}
  
  private void BlogParts_Load(object sender, EventArgs e)
  {
      _titleLabel.Text = this.Title;
      ・・・
  }
}

なお、Reactは、classで部品を作るパターン(Class Component)と、関数で部品を作るパターン(Functional Component)がありますが、C#erとしては、前者のほうがイメージしやすいです。
ただし、今の流行や推奨は後者のFunctional Componentです。React Hooksとかもこちらのようです。

今回は、C#er向けということでClass Componentでいきます。

Typescript

Typescriptは、Javascriptに型を導入した言語です。
ビルド(トランスクリプトというらしい)するとjavascriptになります。
ビルド結果のjavascriptは人が読むことは想定されていないようなので
ビルドするとバイナリになるC#に近い使用感です。
生のJavascriptは型がなく、なんとなくで実装できてしまいますが、
C#をやっている身からすると型が無いのが恐ろしいです。

ということで、Typescript、おすすめです。

言語的にはC#に近い言語っぽいけど、Javascriptの制約を引きずっているのと、独自進化しているようで、
知らない仕様がけっこうあります。

簡単なクラスを例にするとこんな感じです。

Typescriptの例
export class AppStore
{
	_id:number|undefined;  // 型はメンバーの後ろ、また、型に"|"を使ってORを表現できる。
	
	// メソッドはラムダ式を代入したメンバー。C#のメソッドっぽくも書けるけどこっちのほうがいいみたい
	public changeId = (id:number):void => {  
		this._id = id;
	}
	public getId = ():number => {
		if(this._id !== undefined)  //undefinedでないことをチェックしないとビルドが通らない
		{
			return this._id;
		}
		return 0;
	}
}

Windowsアプリでも問題になるけど内部状態はどう管理したらいいの?

React界隈でも問題になったようで、Flaxというアーキテクチャが生み出されています。

flux.jpg

参考: https://facebook.github.io/flux/

簡単に解説すると、(1)内部状態(Store)は1か所に集約する、
(2)GoFのCommandパターンで状態を更新する(ActionとDispatcher)、
(3)Viewは内部状態をただ表現するだけ、のようです。
この発想自体は、Windowsアプリケーションでも参考になりそうですね。

ただ、理念は良さそうですが、Flax実装の一つで一番人気のReduxは肌に合わなかったです。
単にReduxの実装がよくないだけかもしれませんし、
Commandパターンにありがちな、無駄に実装量が増えるあたりかもしれません。

代替策を調べた結果、もう少し簡易的な状態管理ライブラリである、MobXが良さそうに思えました。
MobXは、上の(1)~(3)の中では、(2)はあきらめて、
DIとGoFのObserverパターンを追加した感じです。
WPFにMVVMパターンがありますが、それに似ています。

すなわち、いろいろなViewModelは1か所で管理されて、Viewの定義に従ってDIされる、
ViewModelで状態が変わるとView側(React)に通知が来て、再描画される、という感じです。

MobX_Abstract.jpg

C#でMobXに似たような実装を書くとこんな感じ。

C#でのMobXっぽい実装イメージ
public class App: UserControl
{
	ViewModel _viewModel;
	
	[Inject("viewModel")]  //この属性を目印にDIされる
	public void SetViewModel(ViewModel viewModel)
	{
		_viewModel = viewModel;
		_viewModel.PropertiesUpdated += _viewModel_PropertiesUpdated;
	}
	
	_textBox1_TextChanged()
	{
		_viewModel.Text = _textBox1.Text;  //コントロールの変更はViewModelに伝える
	}
	
	_viewModel_PropertiesUpdated()  // ViewModelの変更から画面を更新する
	{
		// 描画更新
	}
}

public class ViewModel
{
	private string _text;
	public string Text {
		get { return _text; }
		set { _text = value; PropertiesUpdated?.Invoke();}  //プロパティの変更を通知する
	}
	public event Action PropertiesUpdated;
}

参考: https://mobx.js.org/README.html

Reactをとにかく使ってみたい

さて、基礎知識はここまでとして、実際に手を動かしてみましょう。
思ったより長くなったので、興味ない方は、以降は読み飛ばして、まとめに行ってもらってもいいです。

事前に必要なもの

環境は、C#er前提なのでWindowsです(ほかの環境でも動きそうですが確認していません)。
また、次のアプリをインストールしておく必要があります。

なお、React本体やTypescriptコンパイラは開発用フォルダにダウンロードされますので 上記以外ではOSを汚しません。(・・・はず)

開発環境

まずは開発環境を作ります。 次のコマンドで、create-react-appというReact開発環境のテンプレートを生成するツールをダウンロードします。なお、npmはC#でいうところのNugetです。

commandline
cd (適当な空フォルダー)
npm install create-react-app

ダウンロードしたモジュールはnode_modulesフォルダに入っており、実行モジュールはnode_modules/.binにあります。
それを使って、開発環境を生成します。
第一引数はフォルダ名(およびプロジェクト名)を、第二引数の--typescriptはJavascriptではなくTypescriptを使うことを指定しています。

commandline
node_modules\.bin\create-react-app test --typescript

出来上がった開発環境のフォルダに移動して、実行してみましょう。

commandline
cd test
npm start

<<2020-12-31 Deleted>>
この.envファイルは、最新のReactで不要になったようです。
一応、古いバージョンを使うこともあるので、記事はこのまま残しておきます。

<<2020-10-04 Added>>
npm start で開発用サーバーとブラウザが立ち上がるはずですが・・・
次のようなエラーが出ることがあります。
eslintのバージョンが高すぎると出るようですが、
reactにeslintが含まれているのでエラーになってしまいます。

> npm start
There might be a problem with the project dependency tree.
It is likely not a bug in Create React App, but something you need to fix locally.

The react-scripts package provided by Create React App requires a dependency:

  "eslint": "^6.6.0"

Don't try to install it manually: your package manager does it automatically.
However, a different version of eslint was detected higher up in the tree:

  c:\work\trials-create-react-app\node_modules\eslint (version: 7.7.0)

Manually installing incompatible versions is known to cause hard-to-debug issues.

If you would prefer to ignore this check, add SKIP_PREFLIGHT_CHECK=true to an .env file in your project.
That will permanently disable this message but you might encounter other issues.

対処方法は、エラー出力に書いてある通りで、一度eslintを消すのが正解ですが、
ここでは面倒なので、もう一つの方法であるエラー無視の方向にします。

次のようなファイルを作ります。

.env
SKIP_PREFLIGHT_CHECK=true

これで、 npm start でエラーが出なくなります。

<<2020-10-04 Added終わり>>
<<2020-12-31 Deleted終わり>>

<<2020-12-31 Added>>
Visual Studio CodeでApp.tsxを開いたときに次のようなエラーになることがあります。

VSCode-Error-JSXUnlessJsxFlag.jpg

Cannot use JSX unless the '--jsx' flag is provided.

エラーメッセージだけだと意味不明ですが、
Visual Studio Codeが使用するTypescriptのバージョンが古いと発生するようです。
Visual Studio CodeのステータスバーにTypeScriptのバージョンが表示されていますので、
それをクリックし、[Select TypeScript Version] → [Use Workspace Version]で
node_modules以下のTypeScriptを使用するように設定できます。

VSCode-UseTypescriptVersion.jpg

上記の設定をすると、settings.jsonに以下のように記録されますので、直接settings.jsonを編集しても良いようです。

.vscode\settings.json
{
  "typescript.tsdk": "node_modules\\typescript\\lib",
}

<<2020-12-31 Added終わり>>

出来上がった開発環境のフォルダに移動して、実行してみましょう。

commandline
cd test
npm start

初期の画面が表示されます。 npm start は、Visual Studioのデバッグの開始のようなもので、
自動的にhttpサーバーが起動し、ソースがビルドされて、Webページが表示されます。

React-init-screen.jpg

この状態で、ソースコードを一部変更してみます。
Visual Studio Code等のテキストエディタでsrc/App.tsxを編集します。

src/App.tsx
- Edit <code>src/App.tsx</code> and save to reload.
+ Hello World

ソースコードを変更すると自動でビルドされ、Webページも更新されます。

React-init-screen-Helloworld.jpg

終了は、コマンドプロンプトでCtrl+Cです。

開発環境のファイル構成はこんな感じです。
よく使うファイルは説明を付けました。

├ build         ・・・ビルド結果が入る。C#でいうとbin/Release
├ node_modules  ・・・ダウンロードしたパッケージがあります。C#でいうとpackages
├ public        ・・・この中身がビルド時にコピーされる。index.htmlとか入っている
├ src           ・・・ソースコードフォルダ
│  ├ App.css
│  ├ App.test.tsx ・・・App.tsxのユニットテスト。
│  ├ App.tsx      ・・・Web画面。ここをメインで編集します。C#でいうとForm1.Designer.cs
│  ├ index.css
│  ├ index.tsx    ・・・エントリーポイントです。C#でいうとProgram.csのmain関数
│  ├ logo.svg
│  ├ react-app-env.d.ts
│  ├ serviceWorker.ts
│  └ setupTests.ts
├ .env        ・・・環境変数の定義 (2020-10-04 Added)
├ .gitignore
├ package.json      ・・・このプロジェクトの情報が記載されている。C#でいうと*.csproj
├ package-lock.json ・・・依存パッケージの情報。package.jsonにもあるけど、こっちがマスター?
├ README.md
└ tsconfig.json     ・・・Typescriptの設定。

開発環境を作りこむ

このままでも開発可能ですが、コードチェックの設定を行って、楽をしましょう。
C#でいうと、Visual Studioのコード分析か、Resharperですね。

必要なパッケージをダウンロードします。

(2020-09-02 Modified)
reactをバージョンアップしたら、このやり方だとエラーが出るようになりました。eslintはすでに導入されているため追加でインストールしてはいけないようです。
参考: https://qiita.com/arakappa/items/9781556a8a67b6779a0e

commandline
npm install --save-dev @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint-config-prettier eslint-plugin-prettier prettier

↓は古いやり方
npm install --save-dev @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint eslint-config-prettier eslint-plugin-prettier prettier

コードチェックのルール用ファイルを作成します。

.eslintrc.json
{
    "extends": [
      "react-app",
      "eslint:recommended",
      "plugin:@typescript-eslint/recommended",
      "plugin:@typescript-eslint/eslint-recommended",
      "plugin:prettier/recommended",
      "prettier/@typescript-eslint"
    ],
    "plugins": [
      "@typescript-eslint"
    ],
    "parser": "@typescript-eslint/parser",
    "env": { "browser": true, "node": true, "es6": true },
    "parserOptions": {
      "sourceType": "module"
    },
    "rules": {
      
    }
  }

次のコマンドでソースコードのコードチェックが行われます。

commandline
node_modules/.bin/eslint src --ext .js,.jsx,.ts,.tsx
コードチェック結果
(WorkDir)\src\App.test.tsx
  1:19  error  Replace `'react'` with `"react"`                                        prettier/prettier
  2:24  error  Replace `'@testing-library/react'` with `"@testing-library/react"`      prettier/prettier
  3:17  error  Replace `'./App'` with `"./App"`                                        prettier/prettier

・・・

✖ 39 problems (33 errors, 6 warnings)
  33 errors and 0 warnings potentially fixable with the `--fix` option.

デフォルト状態では、大量にエラーが表示されます。

次に、Visual Studio Codeでの警告表示と保存時の自動Fixを設定します。
Visual Studio Codeの設定ファイルに以下を追加します。ファイルやフォルダがなければ新規作成してください。

(2020-09-02 Modified)
Visual Studio Codeをバージョンアップしたら動作しなくなりました。
新しいeslintプラグインの書き方に変更する必要があるようです。
参考: https://qiita.com/mysticatea/items/3f306470e8262e50bb70

.vscode\settings.json
{
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true
  },
  "eslint.validate": [
    "javascript",
    "javascriptreact",
    "typescript",
    "typescriptreact"
  ],
  "typescript.tsdk": "node_modules\\typescript\\lib",  // (2020-12-31 Added) Visual Studio Codeが使用するTypeScriptの設定
}
.vscode\settings.json
(これは古いeslintプラグインでの書き方です。)
{
    "eslint.autoFixOnSave": true,
    "eslint.validate": [
        "javascript",
        "javascriptreact",
        {"language": "typescript", "autoFix": true },
        {"language": "typescriptreact", "autoFix": true }
      ]
}

これで、Visual Studio CodeでJavascriptやTypescriptのファイルを開いた場合に以下のように警告が表示され、
上書き保存時に自動修正できる箇所は修正されます。

VSCode-Warning.jpg

eslintの出力に記載があるように--fixをつけると自動Fixできますが、ファイル数も少ないので、
Visual Studio Codeで1つ1つファイルを開いては上書き保存していきます。

もう一度eslintを実行すると、自動解決できない警告だけになっています。
ここでは、気にしない方向で無視します。
なお、@typescript-eslint/explicit-function-return-typeを無視するようにeslintrc.jsonに記載するか、serviceWorkerの関数の戻り値をちゃんと宣言すれば、警告を消せます。

commandline
node_modules/.bin/eslint src --ext .js,.jsx,.ts,.tsx
コードチェック結果
(WorkDir)\src\serviceWorker.ts
   28:8   warning  Missing return type on function  @typescript-eslint/explicit-function-return-type
   62:1   warning  Missing return type on function  @typescript-eslint/explicit-function-return-type
   66:36  warning  Missing return type on function  @typescript-eslint/explicit-function-return-type
   71:42  warning  Missing return type on function  @typescript-eslint/explicit-function-return-type
  106:1   warning  Missing return type on function  @typescript-eslint/explicit-function-return-type
  136:8   warning  Missing return type on function  @typescript-eslint/explicit-function-return-type

✖ 6 problems (0 errors, 6 warnings)

毎回、上記のeslintコマンドを入れるのは面倒なので、mpn scriptにします。 (なおnode_modules/.bin のパスは、npmコマンドで実行すると解決されるので不要です)

package.json
-   "eject": "react-scripts eject"
+   "eject": "react-scripts eject", 
+   "lint": "eslint src --ext .js,.jsx,.ts,.tsx"

これで、npm run lintで実行できるようになります。

commandline
npm run lint
コードチェック結果
(WorkDir)\src\serviceWorker.ts
   28:8   warning  Missing return type on function  @typescript-eslint/explicit-function-return-type
   62:1   warning  Missing return type on function  @typescript-eslint/explicit-function-return-type
   66:36  warning  Missing return type on function  @typescript-eslint/explicit-function-return-type
   71:42  warning  Missing return type on function  @typescript-eslint/explicit-function-return-type
  106:1   warning  Missing return type on function  @typescript-eslint/explicit-function-return-type
  136:8   warning  Missing return type on function  @typescript-eslint/explicit-function-return-type

✖ 6 problems (0 errors, 6 warnings)

参考

Class Componentにする

App.tsxのデフォルトは、Functional Componentです。
上で話した通り、C#er的にはClass Componentのほうがわかりやすいので、Class Componentに変更します。

といっても、わりと機械的な作業で、 React.Componentを継承したclassを作って、
デフォルトで実装されていたApp関数をrender関数にします。

src/App.tsx(変更前)
const App: React.FC = () => {
  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>Hello World</p>
        <a
          className="App-link"
          href="https://reactjs.org"
          target="_blank"
          rel="noopener noreferrer"
        >
          Learn React
        </a>
      </header>
    </div>
  );
};
src/App.tsx(変更後)
class App extends React.Component {
  public render = (): JSX.Element => {
    return (
      <div className="App">
        <header className="App-header">
          <img src={logo} className="App-logo" alt="logo" />
          <p>Hello World</p>
          <a
            className="App-link"
            href="https://reactjs.org"
            target="_blank"
            rel="noopener noreferrer"
          >
            Learn React
          </a>
        </header>
      </div>
    );
  };
}

ポイントは以下です。

  • React.Componentを継承したclassを作ります。なお、Typescriptの継承はextendsです。
  • reander関数の戻り値はhtmlっぽい何かで、型的にはJSX.Elementになります。
    • classclassName にする(javascriptの構文と名前が被るので)
    • Htmlの属性は文字列以外は{}で囲ってJavascriptまたはTypescriptで記載する
    • styleは {{width:"50%"}} のように{}で囲われたJsonで書く

npm startしてみると、当然ですが、見た目は変わりません。

React-init-screen-Helloworld.jpg

参考: https://qiita.com/daikoncl/items/a3806d8a8bf35f086487

Material-UIを導入する

Reactのデフォルトでは、標準のhtmlタグしか使えません。 デザインセンス無しでそこそこイケてるWeb画面を作るために、既存の部品を使います。
ここでは、上で紹介した、Material-UIです。

まずパッケージをインストールします。

commandline
npm install @material-ui/core @material-ui/icons --save

そして、material-uiを使ったUIにApp.tsxをマルっと書き換えます。
Cardを使って、ブログのサマリーページ的な感じにしました。
Cardの使い方は、Material-UIの公式を参考に。
https://material-ui.com/components/cards/

src/App.tsx
import React from "react";
import Card from "@material-ui/core/Card";
import CardHeader from "@material-ui/core/CardHeader";
import CardContent from "@material-ui/core/CardContent";
import IconButton from "@material-ui/core/IconButton";
import MoreVertIcon from "@material-ui/icons/MoreVert";
import Typography from "@material-ui/core/Typography";
import "./App.css";

class App extends React.Component {
  render(): JSX.Element {
    return (
      <div className="App">
        <Card style={{ width: 300, margin: 2 }}>
          <CardHeader
            action={
              <IconButton aria-label="settings">
                <MoreVertIcon />
              </IconButton>
            }
            title="タイトル"
            subheader="2019-12-17"
          />
          <CardContent>
            <Typography color="textSecondary" gutterBottom>
              アブストラクトです。
            </Typography>
          </CardContent>
        </Card>
        <Card style={{ width: 300, margin: 2 }}>
          <CardHeader
            action={
              <IconButton aria-label="settings">
                <MoreVertIcon />
              </IconButton>
            }
            title="タイトルその2"
            subheader="2019-12-17"
          />
          <CardContent>
            <Typography color="textSecondary" gutterBottom>
              アブストラクトその2です。
            </Typography>
          </CardContent>
        </Card>
      </div>
    );
  }
}

export default App;

npm startしてみると次のようになります。
いろいろスタイルは手抜きですが、影とかアイコンとかそれっぽくなりました。

React-blog-top.jpg

参考: https://material-ui.com/

データをサーバーから取得

現状、コンテンツはApp.tsxに埋め込まれています。
普通のサイトを考えるとJsonをサーバーから取得すると思います。

ざっくりJsonを想定するとこんな感じでしょうか。

[
  {
    "id": 1
    "title": "タイトル",
    "date": "2019-12-17",
    "abstract": "アブストラクトです。"
  },
  {
    "id": 2
    "title": "タイトルその2",
    "date": "2019-12-17",
    "abstract": "アブストラクトその2です。"
  }
]

Webページの組み込みは、いったん後回しとして、 サーバーからJsonを取得して、このデータをWebページに渡すViewModelを作り、 単体テストで動作確認します。

まずは、サーバーがないのでテスト時にHTTPリクエストを代替するパッケージfetch-mockを入れます。

commandline
npm install fetch-mock @types/fetch-mock node-fetch --save-dev

そして、ViewModelとユニットテスト用に2つのファイルを追加します。

src/AppStore.ts (ViewModelです)
export interface BlogSummary {
  id: number;
  title: string;
  date: string;
  abstract: string;
}

export class AppStore {
  data: BlogSummary[] = [];
  public fetchData = async (): Promise<void> => {
    const json = (await (
      await fetch("/api/summaries/")
    ).json()) as BlogSummary[];
    this.data = json;
  };
}
src/AppStore.test.ts (ユニットテストです)
import { AppStore } from "./AppStore";
import fetchMock from "fetch-mock";

const dummyData = [
  {
    id: 1,
    title: "タイトル",
    date: "2019-12-17",
    abstract: "アブストラクトです。"
  },
  {
    id: 2,
    title: "タイトルその2",
    date: "2019-12-17",
    abstract: "アブストラクトその2です。"
  }
];
fetchMock.get("/api/summaries/", dummyData);

test("fetchData test", () => {
  const target = new AppStore();
  target.fetchData().then(() => {
    expect(target.data).toEqual(dummyData);
  });
});

ついでに、上の変更ですでに壊れているので src/App.test.tsx を削除します。
(個人的にViewのテストは難しいわりにリターンが少ないので、あまりしません。
Typescriptで型はチェックできていますし。)

ユニットテストを実行してみましょう。

commandline
npm run test

成功しました。良さそうです。

ユニットテスト結果
 PASS  src/AppStore.test.ts
  √ fetchData test (5ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        2.573s
Ran all test suites related to changed files.

Watch Usage
 › Press a to run all tests.
 › Press f to run only failed tests.
 › Press q to quit watch mode.
 › Press p to filter by a filename regex pattern.
 › Press t to filter by a test name regex pattern.
 › Press Enter to trigger a test run.

参考: http://www.wheresrhys.co.uk/fetch-mock/

MobXを使ってViewModelとViewを結合する

ViewModel AppStore はルートである Index.tsxstores で管理し、App にインジェクトします。

Object-Diagram1.jpg

まずは必要なパッケージをインストールします。

commandline
npm install mobx mobx-react --save

そして、MobXはデコレーターを使用するのでTypescriptのオプションを変更します。

tsconfig.json
-    "jsx": "react"
+    "jsx": "react",
+    "experimentalDecorators": true
  },

index.tsxでストア(stores)を作り、 状態を管理する<Provider></Provider>Appを囲い、
Providerにはstoresを渡します。
これにより、Appの要求に従って、Providerから必要なViewModelを渡されます。

src/index.tsx
- ReactDOM.render(<App />, document.getElementById("root"));
+ import { AppStore } from "./AppStore";
+ import { Provider } from "mobx-react";
+ 
+ const stores = {
+   appStore: new AppStore()
+ };
+ 
+ ReactDOM.render(
+   <Provider {...stores}>
+     <App />
+   </Provider>,
+   document.getElementById("root")
+ );

ストアの appStore を受け取るように App で宣言します。

src/App.tsx(変更前)
class App extends React.Component {
src/App.tsx(変更後)
import { AppStore } from "./AppStore";
import { inject, observer } from "mobx-react";

interface AppProps {
  appStore?: AppStore;
}

@inject("appStore")
@observer
class App extends React.Component<AppProps> {

ポイントは以下です。

  • React部品で外からパラメータを受け取るにはPropsの仕組みを使います。
    • React.Componentのジェネリック型に、受け取るパラメータを宣言したインターフェイスAppPropsを指定します。
    • 今回はstoresで管理されているAppStoreを受け取るので、AppStore型のappStoreを宣言します。
  • @injectの引数およびAppPropsのプロパティには、受け取りたいstores内のプロパティ名appStoreを指定します。

これで、index.tsxstores内のappStoreAppに渡されます。

なお、当然ですが、見た目は変わりません。

React-blog-top.jpg

ViewModelを使ってWebページを更新する

上までで、AppにViewModelであるAppStoreが渡されたので、AppStoreの状態を使ってWebページを更新するようにします。 App.tsxをマルっと書き換えます。

src/App.tsx
@inject("appStore")
@observer
class App extends React.Component<AppProps> {
  // コンポーネントが構築される直前に呼ばれる
  public componentDidMount = (): void => {
    if (this.props.appStore === undefined) {
      return;
    }
    this.props.appStore.fetchData();
  }

  render(): JSX.Element {
    // appStoreはundefindedである場合があるので、その時は何も表示しない
    if (this.props.appStore === undefined) {
      return <div />;
    }

    // this.props.appStore.dataに従ってWebページを構築する
    return (
      <div className="App">
        {this.props.appStore.data.map(data => {
          return (
            <Card key={data.id} style={{ width: 300, margin: 2 }}>
              <CardHeader
                action={
                  <IconButton aria-label="settings">
                    <MoreVertIcon />
                  </IconButton>
                }
                title={data.title}
                subheader={data.date}
              />
              <CardContent>
                <Typography color="textSecondary" gutterBottom>
                  {data.abstract}
                </Typography>
              </CardContent>
            </Card>
          );
        })}
      </div>
    );
  }
}

ポイント

  • componentDidMount() はこのコンポーネントが構築される前に呼ばれます。
    • C#でいうところの、Form.Loadイベントです。
    • ここでは、サーバーからデータを取得するため、 appStore.fetchData() を呼び出します。
  • render() では、 this.props.appStore.data に従ってWebページを構築します。
    • {}で囲うとTypescriptを記述でき、returnでJSX.Elementを返します。

MobXは、プロパティが変更されたことを検知して、Viewを再描画させる機能を持っています。
これを使って、データが取得されたときにViewを更新します。
そのために AppStore のメソッドとプロパティに属性を付けます。

(2020-12-31 Add)
MobX ver.6.0.0以降では、属性を使用しない仕組みに変わったようです。
コンストラクタで makeAutoObservable(this); を呼ぶと
自動でメンバーに @observable を付けたことになるようです。
@observable は不要になったようですが、私はなんとなく毎回付けてしまっています。
参考: https://mobx.js.org/migrating-from-4-or-5.html#upgrading-classes-to-use-makeobservable

src/AppStore.ts
+ import { action, makeAutoObservable, observable } from "mobx";


 export class AppStore {
+  constructor() {
+    makeAutoObservable(this);
+  }
+  @observable
   data: BlogSummary[] = [];
+  @action
   public fetchData = async (): Promise<void> => {
 }

これでサーバーデータに応じた画面を作ることができましたが、
このままでは、サーバーは存在しないのでエラーになります。
ユニットテスト用のfetch-mockを流用してダミーデータを使うようにします。

src/index.tsx
+ import fetchMock from "fetch-mock";
+ 
+ const dummyData = [
+   {
+     id: 1,
+     title: "[サーバー]タイトル",
+     date: "2019-12-17",
+     abstract: "アブストラクトです。"
+   },
+   {
+     id: 2,
+     title: "[サーバー]タイトルその2",
+     date: "2019-12-17",
+     abstract: "アブストラクトその2です。"
+   }
+ ];
+ fetchMock.get("/api/summaries/", dummyData);
  
  const stores = {
    appStore: new AppStore()
  };

これで、 npm start すると以下のようになります。

React-blog-top-json.jpg

dummyDataをいろいろ変えてみるとWebページが変わると思います。

部品を作る

Reactの部品化の練習のためにTOPページのサマリー要素部分を部品化してみます。

Object-Diagram2.jpg

上と同じようにReactコンポーネントのclassを作って、
renderでは、App.tsxで部品化したい部分を返すようにします。
必要なパラメータはPropsで上から渡してもらいます。
なお、Storesで状態管理するのはページごとで、部品ではViewModelは使わないほうが良いようです。

src/SummaryItem.tsx(新規作成)
import React from "react";
import Card from "@material-ui/core/Card";
import CardHeader from "@material-ui/core/CardHeader";
import CardContent from "@material-ui/core/CardContent";
import IconButton from "@material-ui/core/IconButton";
import MoreVertIcon from "@material-ui/icons/MoreVert";
import Typography from "@material-ui/core/Typography";
import { Link } from "react-router-dom";

interface SummaryItemProps {
  title: string;
  date: string;
  abstract: string;
  url: string;
}

class SummaryItem extends React.Component<SummaryItemProps> {
  render(): JSX.Element {
    return (
      <Card style={{ width: 300, margin: 2 }}>
        <CardHeader
          action={
            <IconButton aria-label="settings">
              <MoreVertIcon />
            </IconButton>
          }
          title={<Link to={this.props.url}>{this.props.title}</Link>}
          subheader={this.props.date}
        />
        <CardContent>
          <Typography color="textSecondary" gutterBottom>
            {this.props.abstract}
          </Typography>
        </CardContent>
      </Card>
    );
  }
}

export default SummaryItem;

App.tsxのほうは、上で作った部品を使うように書き換えます。
なお、部品の参照は、importです。
また、不要になったimportも消しておきましょう。

src/App.tsx
- import Card from "@material-ui/core/Card";
- import CardHeader from "@material-ui/core/CardHeader";
- import CardContent from "@material-ui/core/CardContent";
- import IconButton from "@material-ui/core/IconButton";
- import MoreVertIcon from "@material-ui/icons/MoreVert";
- import Typography from "@material-ui/core/Typography";
+ import SummaryItem from "./SummaryItem";


-            <Card key={data.id} style={{ width: 300, margin: 2 }}>
-              <CardHeader
-                action={
-                  <IconButton aria-label="settings">
-                    <MoreVertIcon />
-                  </IconButton>
-                }
-                title={data.title}
-                subheader={data.date}
-              />
-              <CardContent>
-                <Typography color="textSecondary" gutterBottom>
-                  {data.abstract}
-                </Typography>
-              </CardContent>
-            </Card>
+            <SummaryItem
+              key={data.id}
+              title={data.title}
+              date={data.date}
+              abstract={data.abstract}
+            />

相変わらず見た目は変わりませんが、
サマリー部分の部品化ができました。

React-blog-top-json.jpg

なお、この部品は再利用しないので、部品化はあまり意味はありません。

ページ遷移を作る

SPAといえども、普通のHtmlのようにページ遷移がしたいことが多いです。
概要←→詳細 で行き来することもありますし、1ページいろいろ押し込むと使いにくいですし。

ここでは、React-Routerを使ってページ遷移を作ります。

まずページ遷移があるということはURLを設計しないといけません。
簡単ですが、以下のようにします。

  • http://~/         → トップページ(サマリーのページ)
  • http://~/contents/(Id)  → 詳細ページ(Idは数字)

では作ってみましょう。

まず、必要なパッケージを取得します。

commandline
npm install react-router-dom @types/react-router-dom  --save

次にTOPページから詳細ページへのリンクを作ります。
React-Routerでのリンクは<Link to="url">ラベル</Link>を使いますので、
タイトル部分をリンクに置き換えます。
URLはPropsに追加して、親ページから渡してもらうことにします。

src/SummaryItem.tsx
  import Typography from "@material-ui/core/Typography";
+ import { Link } from "react-router-dom";
 
  interface SummaryItemProps {
    title: string;
    date: string;
    abstract: string;
+   url: string;
  }
 
             </IconButton>
           }
-          title={this.props.title}
+          title={<Link to={this.props.url}>{this.props.title}</Link>}

親ページでは、URLを渡すように設定します。

src/App.tsx
              <SummaryItem
                key={data.id}
+               url={`./contents/${data.id}`}
                title={data.title}
                date={data.date}
                abstract={data.abstract}

これでトップページからのリンクはできました。
では、詳細ページを作っていきます。

作り方は、App.tsxと同じでページ(ContentPage.tsx)とViewModel(ContentPageStore.ts)を作って
MobXで連携させます。

Object-Diagram3.jpg

src/ContentPage.tsx (新規作成)
import React from "react";
import { RouteComponentProps } from "react-router-dom";
import Card from "@material-ui/core/Card";
import CardHeader from "@material-ui/core/CardHeader";
import CardContent from "@material-ui/core/CardContent";
import IconButton from "@material-ui/core/IconButton";
import MoreVertIcon from "@material-ui/icons/MoreVert";
import Typography from "@material-ui/core/Typography";
import { ContentPageStore } from "./ContentPageStore";
import { inject, observer } from "mobx-react";

interface ContentPageProps extends RouteComponentProps<{ id: string }> {
  contentPageStore?: ContentPageStore;
}

@inject("contentPageStore")
@observer
class ContentPage extends React.Component<ContentPageProps> {
  public componentDidMount = (): void => {
    if (this.props.contentPageStore === undefined) {
      return;
    }
    const id = parseInt(this.props.match.params.id, 10);
    this.props.contentPageStore.fetchData(id);
  };

  render(): JSX.Element {
    if (this.props.contentPageStore === undefined) {
      return <div />;
    }
    const store = this.props.contentPageStore;
    return (
      <div className="App">
        <Card style={{ width: 300, margin: 2 }}>
          <CardHeader title={store.data.title} subheader={store.data.date} />
          <CardContent>
            <Typography color="textSecondary" gutterBottom>
              {store.data.content}
            </Typography>
          </CardContent>
        </Card>
      </div>
    );
  }
}

export default ContentPage;
src/ContentPageStore.ts (新規作成)
import { action, makeAutoObservable, observable } from "mobx";

interface BlogContent {
  id: number;
  title: string;
  date: string;
  content: string;
}

export class ContentPageStore {
  constructor() {
    makeAutoObservable(this);
  }
  @observable
  data: BlogContent = {
    id: 0,
    title: "",
    date: "",
    content: ""
  };

  @action
  public fetchData = async (id: number): Promise<void> => {
    const json = (await (
      await fetch(`/api/contents/${id}`)
    ).json()) as BlogContent;
    this.data = json;
  };
}

React-Routerの本番はここからです。
今までindex.tsx<App/>と直接Reactコンポーネントを指定していたところを、
<BrowserRouter>で囲って、<Route/>を並べていきます。
<Route>component=にReactコンポーネントを、path=にURLを指定します。

また、ViewModelもStoresに追加します。

src/index.tsx
  import fetchMock from "fetch-mock";
+ import ContentPage from "./ContentPage";
+ import { ContentPageStore } from "./ContentPageStore";
+ import { BrowserRouter, Route } from "react-router-dom";


  const stores = {
-   appStore: new AppStore()
+   appStore: new AppStore(),
+   contentPageStore: new ContentPageStore()
  };
  ReactDOM.render(
    <Provider {...stores}>
-     <App />
+     <BrowserRouter basename={"/"}>
+       <Route exact={true} path="/" component={App} />
+       <Route exact={true} path="/contents/:id" component={ContentPage} />
+     </BrowserRouter>
    </Provider>,
    document.getElementById("root")
  );

これで、/の場合はAppのページが、/contents/(id)の場合は、ContentPageのページが表示されるようになります。
URLのルートURLは、<BrowserRouter basename=に指定します。

昔ながらのCGIをやっていた方は、URLが違うなら別のhtmlが返ってくるのでは?という気がしますが、
SPAの場合は、バックエンド側で同じindex.htmlを返すように設定するようです。

一応これで完成なのですが、相変わらずバックエンドがないので
ダミーデータを返すようにします。

src/index.tsx
-     abstract: "アブストラクトです。"
+     abstract: "アブストラクトです。",
+     content: "これは本文です。"
    },
    {
      id: 2,
      title: "[サーバー]タイトルその2",
      date: "2019-12-17",
-     abstract: "アブストラクトその2です。"
+     abstract: "アブストラクトその2です。",
+     content: "これは本文です。"
    }
  ];
  fetchMock.get("/api/summaries/", dummyData);
+ fetchMock.get("/api/contents/1", dummyData[0]);
+ fetchMock.get("/api/contents/2", dummyData[1]);

以上で、簡単ですがブログっぽいページができました。
TOPページからリンクをたどると、詳細ページに飛びます。
URLも変化していますので、F5を押しても元ページに戻ったりしません。

React-blog-top-withLink.jpg

React-blog-content.jpg

ビルドしてデプロイ可能にする

ある程度アプリができたら、ビルドしてデプロイできるようにします。
次のコマンドを実行すると、buildフォルダに成果物が出来上がります。

commandline
npm run build

その他細かいTips

多言語対応は?

Javascript用にいくつかライブラリがあるようです。

基本的には、言語ごとにJsonを用意して、キーとなる文字列を指定すると、
実行時には言語に応じたJsonから文字列が取れるようです。

リソースの定義
{
  "buttons":{
      "new":"新規投稿"
      "edit":"編集"
  }
}
多言語リソースを使う例
   <Button label={i18next.t('buttons.new')} />

C#のResources.resxと比較すると、型がないので、インテリセンスが使えない分、ちょっと面倒です。

以前やったときに、Typescript+Jsonスキーマを使うといい感じに改善できそうだったので、
また記事を書くかもしれません。

サーバーサイドはどうする?

Node.jsもいいですが、C#erなら、過去のC#の資産を使えるASP.NET Coreを使いたいです。

Visual Studioで[プロジェクトの新規作成]-[ASP.NET Core Webアプリケーション]→[React.js]で
テンプレートを作って、ClientApp以下を上と同じ手順で作ったReact開発環境で置き換えれば、
いいようです。

普通にF5実行で、Reactのフロントエンド+C#のバックエンドで起動しました。
なお、初回はビルドが走るのか重いようです。

問題は、ASP.NET Coreを動かすサーバーを用意できるかどうか。

ReactとWindows Formで開発速度はどれくらい違う?

htmlが不慣れとかいろいろ要素がありますが、
私の場合、Windows Formで作るほうが3倍くらい速いです。

C# ←→ Typescriptの変換も面倒ですし、
画面レイアウトの作りこみも大変です。
というか、C#を振り返ると、Visual StudioのWindows Formのデザイナーはよくできているなぁと思います。
(壊れやすいのが難点ですが)

まとめ

割とがっつりReactの入門記事になってしまいましたが、
普段C#を使っている方の視点で、解説したつもりです。
Reactを使ってSPAを始めるための一通りを説明したつもりなので、
あとは応用を学んでいけば、そこそこのプロダクトが作れるのではないでしょうか。

なお、上で作ったコードは、一応GitHubに公開しておきます。
(この記事の賞味期限が有効なあたりまでは残しておきます)
https://github.com/banban525/react-example-2019

自分で打ち込むのが面倒な方はそちらを見てもらってもよいと思います。
個人的には写経はおすすめですが。

48
42
2

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
48
42

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?