この記事は 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が使えるようになったものです。
公開されている部品を組み合わせてもいいですし、もちろん自分で部品を作ることもできます。
例えば、次のようなブログのトップページをイメージしてもらうと、
1つの記事のサマリーがAbstractContent
という部品になっていれば、 それを並べればTopページが作れます。
<div className="TopPage">
<AbstractContent title={title1} .... />
<AbstractContent title={title2} .... />
<AbstractContent title={title3} .... />
</div>
さらにAbstractContent
も何かの部品を組み合わせているので 次のようなツリー構造になります。
部品の例としては、material-uiのサイトを眺めるとこの部品化がイメージしやすいと思います。
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で例えると、こんな感じで、外からセットされるプロパティに相当します。
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の制約を引きずっているのと、独自進化しているようで、
知らない仕様がけっこうあります。
簡単なクラスを例にするとこんな感じです。
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というアーキテクチャが生み出されています。
参考: 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)に通知が来て、再描画される、という感じです。
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です(ほかの環境でも動きそうですが確認していません)。
また、次のアプリをインストールしておく必要があります。
- Node.js
-
Google Chrome
- [React Developer Tools](https://chrome.google.com/webstore/detail/react-developer-
tools/fmkadmapgofadopljbjfkapdkoienihi?hl=ja)( Reactの開発にあると便利ですが、今回は使いません)
- [React Developer Tools](https://chrome.google.com/webstore/detail/react-developer-
- Visual Studio Code
なお、React本体やTypescriptコンパイラは開発用フォルダにダウンロードされますので 上記以外ではOSを汚しません。(・・・はず)
開発環境
まずは開発環境を作ります。 次のコマンドで、create-react-app
というReact開発環境のテンプレートを生成するツールをダウンロードします。なお、npmはC#でいうところのNugetです。
cd (適当な空フォルダー)
npm install create-react-app
ダウンロードしたモジュールはnode_modulesフォルダに入っており、実行モジュールはnode_modules/.bin
にあります。
それを使って、開発環境を生成します。
第一引数はフォルダ名(およびプロジェクト名)を、第二引数の--typescript
はJavascriptではなくTypescriptを使うことを指定しています。
node_modules\.bin\create-react-app test --typescript
出来上がった開発環境のフォルダに移動して、実行してみましょう。
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を消すのが正解ですが、
ここでは面倒なので、もう一つの方法であるエラー無視の方向にします。
次のようなファイルを作ります。
SKIP_PREFLIGHT_CHECK=true
これで、 npm start
でエラーが出なくなります。
<<2020-10-04 Added終わり>>
<<2020-12-31 Deleted終わり>>
<<2020-12-31 Added>>
Visual Studio CodeでApp.tsxを開いたときに次のようなエラーになることがあります。
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を使用するように設定できます。
上記の設定をすると、settings.jsonに以下のように記録されますので、直接settings.jsonを編集しても良いようです。
{
"typescript.tsdk": "node_modules\\typescript\\lib",
}
<<2020-12-31 Added終わり>>
出来上がった開発環境のフォルダに移動して、実行してみましょう。
cd test
npm start
初期の画面が表示されます。 npm start
は、Visual Studioのデバッグの開始のようなもので、
自動的にhttpサーバーが起動し、ソースがビルドされて、Webページが表示されます。
この状態で、ソースコードを一部変更してみます。
Visual Studio Code等のテキストエディタでsrc/App.tsx
を編集します。
- Edit <code>src/App.tsx</code> and save to reload.
+ Hello World
ソースコードを変更すると自動でビルドされ、Webページも更新されます。
終了は、コマンドプロンプトで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
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
コードチェックのルール用ファイルを作成します。
{
"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": {
}
}
次のコマンドでソースコードのコードチェックが行われます。
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
{
"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の設定
}
(これは古いeslintプラグインでの書き方です。)
{
"eslint.autoFixOnSave": true,
"eslint.validate": [
"javascript",
"javascriptreact",
{"language": "typescript", "autoFix": true },
{"language": "typescriptreact", "autoFix": true }
]
}
これで、Visual Studio CodeでJavascriptやTypescriptのファイルを開いた場合に以下のように警告が表示され、
上書き保存時に自動修正できる箇所は修正されます。
eslintの出力に記載があるように--fix
をつけると自動Fixできますが、ファイル数も少ないので、
Visual Studio Codeで1つ1つファイルを開いては上書き保存していきます。
もう一度eslintを実行すると、自動解決できない警告だけになっています。
ここでは、気にしない方向で無視します。
なお、@typescript-eslint/explicit-function-return-type
を無視するようにeslintrc.json
に記載するか、serviceWorker
の関数の戻り値をちゃんと宣言すれば、警告を消せます。
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コマンドで実行すると解決されるので不要です)
- "eject": "react-scripts eject"
+ "eject": "react-scripts eject",
+ "lint": "eslint src --ext .js,.jsx,.ts,.tsx"
これで、npm run lint
で実行できるようになります。
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)
参考
- https://qiita.com/madono/items/a134e904e891c5cb1d20
- https://ginpen.com/2019/08/06/eslint-for-react-in-typescript/
Class Componentにする
App.tsx
のデフォルトは、Functional Componentです。
上で話した通り、C#er的にはClass Componentのほうがわかりやすいので、Class Componentに変更します。
といっても、わりと機械的な作業で、 React.Component
を継承したclassを作って、
デフォルトで実装されていたApp
関数をrender
関数にします。
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>
);
};
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
になります。-
class
はclassName
にする(javascriptの構文と名前が被るので) - Htmlの属性は文字列以外は{}で囲ってJavascriptまたはTypescriptで記載する
- styleは
{{width:"50%"}}
のように{}
で囲われたJsonで書く
-
npm start
してみると、当然ですが、見た目は変わりません。
参考: https://qiita.com/daikoncl/items/a3806d8a8bf35f086487
Material-UIを導入する
Reactのデフォルトでは、標準のhtmlタグしか使えません。 デザインセンス無しでそこそこイケてるWeb画面を作るために、既存の部品を使います。
ここでは、上で紹介した、Material-UIです。
まずパッケージをインストールします。
npm install @material-ui/core @material-ui/icons --save
そして、material-uiを使ったUIにApp.tsx
をマルっと書き換えます。
Card
を使って、ブログのサマリーページ的な感じにしました。
Card
の使い方は、Material-UIの公式を参考に。
https://material-ui.com/components/cards/
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
してみると次のようになります。
いろいろスタイルは手抜きですが、影とかアイコンとかそれっぽくなりました。
データをサーバーから取得
現状、コンテンツは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
を入れます。
npm install fetch-mock @types/fetch-mock node-fetch --save-dev
そして、ViewModelとユニットテスト用に2つのファイルを追加します。
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;
};
}
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で型はチェックできていますし。)
ユニットテストを実行してみましょう。
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.tsx
の stores
で管理し、App
にインジェクトします。
まずは必要なパッケージをインストールします。
npm install mobx mobx-react --save
そして、MobXはデコレーターを使用するのでTypescriptのオプションを変更します。
- "jsx": "react"
+ "jsx": "react",
+ "experimentalDecorators": true
},
index.tsxでストア(stores)を作り、 状態を管理する<Provider></Provider>
でApp
を囲い、
Provider
にはstores
を渡します。
これにより、App
の要求に従って、Provider
から必要なViewModelを渡されます。
- 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
で宣言します。
class App extends React.Component {
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.tsx
のstores
内のappStore
がApp
に渡されます。
なお、当然ですが、見た目は変わりません。
ViewModelを使ってWebページを更新する
上までで、App
にViewModelであるAppStore
が渡されたので、AppStore
の状態を使ってWebページを更新するようにします。 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()
を呼び出します。
- C#でいうところの、
-
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
+ import { action, makeAutoObservable, observable } from "mobx";
export class AppStore {
+ constructor() {
+ makeAutoObservable(this);
+ }
+ @observable
data: BlogSummary[] = [];
+ @action
public fetchData = async (): Promise<void> => {
}
これでサーバーデータに応じた画面を作ることができましたが、
このままでは、サーバーは存在しないのでエラーになります。
ユニットテスト用のfetch-mock
を流用してダミーデータを使うようにします。
+ 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
すると以下のようになります。
dummyData
をいろいろ変えてみるとWebページが変わると思います。
部品を作る
Reactの部品化の練習のためにTOPページのサマリー要素部分を部品化してみます。
上と同じようにReactコンポーネントのclassを作って、
renderでは、App.tsxで部品化したい部分を返すようにします。
必要なパラメータはPropsで上から渡してもらいます。
なお、Storesで状態管理するのはページごとで、部品ではViewModelは使わないほうが良いようです。
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
も消しておきましょう。
- 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}
+ />
相変わらず見た目は変わりませんが、
サマリー部分の部品化ができました。
なお、この部品は再利用しないので、部品化はあまり意味はありません。
ページ遷移を作る
SPAといえども、普通のHtmlのようにページ遷移がしたいことが多いです。
概要←→詳細 で行き来することもありますし、1ページいろいろ押し込むと使いにくいですし。
ここでは、React-Router
を使ってページ遷移を作ります。
まずページ遷移があるということはURLを設計しないといけません。
簡単ですが、以下のようにします。
-
http://~/
→ トップページ(サマリーのページ) -
http://~/contents/(Id)
→ 詳細ページ(Idは数字)
では作ってみましょう。
まず、必要なパッケージを取得します。
npm install react-router-dom @types/react-router-dom --save
次にTOPページから詳細ページへのリンクを作ります。
React-Routerでのリンクは<Link to="url">ラベル</Link>
を使いますので、
タイトル部分をリンクに置き換えます。
URLはPropsに追加して、親ページから渡してもらうことにします。
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を渡すように設定します。
<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で連携させます。
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;
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に追加します。
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を返すように設定するようです。
一応これで完成なのですが、相変わらずバックエンドがないので
ダミーデータを返すようにします。
- 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を押しても元ページに戻ったりしません。
ビルドしてデプロイ可能にする
ある程度アプリができたら、ビルドしてデプロイできるようにします。
次のコマンドを実行すると、buildフォルダに成果物が出来上がります。
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
自分で打ち込むのが面倒な方はそちらを見てもらってもよいと思います。
個人的には写経はおすすめですが。