20200324のReactに関する記事は8件です。

C/C++を使っているRustのコンソールアプリのReact SPA化

Emscriptenを使って、C/C++に依存しているRustのコンソールアプリをReact SPA化した時のメモ。

環境構築

emcc

emccというEmscriptenコンパイラのフロントエンドが必要で、そのためにemsdkのインストールが必要。

emsdkのインストールと有効化

https://emscripten.org/docs/getting_started/downloads.html
に従って、emsdkのインストールとアクティベイトをした上で、ビルドを実行するshell上で、emsdk_envを実行して、emccを使えるようにする必要がある。

# shellを開くたびに実行が必要
$ . ./emsdk/emsdk_env.sh

ビルド

targetにwasm32-unknown-emscriptenを指定して、cargoでビルド。CARGO_BUILD_RUSTFLAGSを使って、emccのオプションを指定。

$ CARGO_BUILD_RUSTFLAGS="内容は後述" cargo build --target=wasm32-unknown-emscripten --release

ビルドが成功すると、Cargo.tomlで指定したパッケージ名で、2つのファイルが作られる

ファイル名 説明
[パッケージ名].js JavaScriptからWebAssemblyを使うためのグルーコード
[バッケージ名].wasm WebAssembly本体

emccオプションの指定方法

-C link-arg=...の形で、CARGO_BUILD_RUSTFLAGSの中で指定する。空白がある場合にはバラして別々にする必要があるので注意が必要。
例えば、-s ASYNCIFYを指定したい場合は、-C link-arg=-s -C link-arg=ASYNCIFYとなる。

C/C++部分のビルド

C/C++部分については事前に、emconfigureemmakeで、configuremakeをラップして実行して、別途ソースからLLVMのbitcodeにビルドしておく必要がある。em...は元のスクリプトの環境変数を書き換え等を行ってbitcodeを出力するスクリプトに変換するためのツール。.libsディレクトリにbitcodeを含んだファイルが出力される。

下の様に実行する。詳細はこちら、https://emscripten.org/docs/compiling/Building-Projects.html#building-projects

$ ./emconfigure ./configure
$ ./emmake make

更に、そのbitcodeをリンクできるように、bitcodeがあるディレクトリへのパスをemccオプションで指定する。

-L native=[レポジトリへのパス]/.libs

うまく行かない場合

結構な割合で依存ライブラリのビルドに失敗したので、エラーメッセージを見でケースバイケースで対処した。大きく分けて下の2つの方法で問題が解決できた。

configureスクリプトのパラメーターの調整

configureスクリプトに然るべきパラメーターを与えることでうまく行く場合があった。例えば以下のようなもの。

  • assemblyはサポートされないので、使わないようにする。
  • アーキテクチャーはgenericなものを選択する
  • c++のサポートをオンにする
ソースコードの修正(C/C++もRustも)

ソースコードの一部がなんらかの理由でWebAssemblyにできない場合があったので、以下のような形で解消した。

  • 使われていないコードを消す
  • スレッドを使っているコードを使わないように書き直す。C/C++の場合は、USE_PTHREADS=1というオプションがあるので、これで動かせる場合もありそうだが、自分のケースではうまく行かなかった。まだ実装が不十分なので。。という内容の開発者の書き込みもあった。詳細はこちら、https://emscripten.org/docs/porting/pthreads.html
  • JavaScriptでコードを置き換えた
  • 戻り値が省略されている等、一致しないシグネチャーを合わせた

などなど

修正した依存ライブラリの組み込み

直接依存していないものも含めて、修正済みの依存ライブラリをパッケージに組み込むには、Cargo.tomlで以下のような形で、patch指定をする必要が有る。

creates-ioから取得しているライブラリ
[patch.crates-io]
some-library = { path = "./dependency/some-library" }
github等から取得しているライブラリ
[patch."https://github.com/foo/some-library"]
some-library = { version = "0.5.0", path = "./dependency/some-library" }

その他

  • Cargo.tomlにedition = "2018"がないとビルドできなかった。

JavaScriptとRustの相互呼び出し

RustからJavaScriptの関数を呼び出す

グローバルな名前空間で定義したJavaScriptの関数を、Rust側から呼び出せる。
Rustからは、emscripten_run_script*にJavaScriptを書いた文字列を渡すと、JavaScript上でevalしてくれる。結果を返すタイプの関数であれば、その結果も返してくれる。

関数 戻り値
emscripten_run_script_string *const std::os::raw::c_char
emscripten_run_script_int std::os::raw::c_int
emscripten_run_script なし

他に試していない関数がいろいろとここに、https://emscripten.org/docs/api_reference/emscripten.h.html

以下は例。

JavaScript

function foo(s: string) {
  console.log(s)
}

window.foo = foo // global関数に

Rust

use std::ffi::CString;

extern "C" {
  pub fn emscripten_run_script(s: *const std::os::raw::c_char);
}

pub fn set_status(s: &str) {
  unsafe {
    let script = CString::new(format!("foo({:?})", s)).unwrap();
    emscripten_run_script(script.as_ptr());
  }
}

JavaScriptからRustの関数を呼び出す

emccのオプションに、-s EXTRA_EXPORTED_RUNTIME_METHODS=['cwrap']の追加して、
グルーコードが提供するcwrapという関数を使って、関数をJavaScript側で定義する。
cwrapの最後の引数に{ async: true }を指定するとPromise<戻り値の型>を返すようになる。省略すると戻り値の型を返す。戻り値をvoidにしたい場合はnullでOK。

const foo = Module.cwrap("foo" /* 関数名*/, "string" /* 戻り値の型 */, ["string", "number"] /* 引数の型 */, { async: true })`

Promise化のためには、emccオプションに-s ASYNCIFYの追加が必要。

更に、emccのオプションのEXPORTED_FUNCTIONSのリストに、-s EXPORTED_FUNCTIONS=['_foo']のように、関数名の先頭にアンダースコアーをつけたものを指定する必要が有る。これがないと最適化で呼び出す関数が消されるらしい。

Rust側

use std::os::raw::{c_char, c_int};

#[no_mangle]
pub extern fn foo(name: *const c_char, age: c_int) -> *const c_char {
  ...
}
c_charポインタからStringへの変換

let s = CStr::from_ptr(some_c_char_ptr).to_str().unwrap();

相互に渡せる型

JavaScript Rust
number c_int
string *const c_char
byte array *const c_int (メモにないので未確認。 バイト列へのポインタだったと記憶)

React SPAへのwasmの組み込み

以下、webpackを使っている想定。必要なファイルは、wasm本体とグルーJSのみ。

必要なemccオプション

以下のビルドオプションを追加。

オプション 説明
-s MODULARIZE=1 モジュールの形でグルーJSを出力
-s ENVIRONMENT=web ブラウザがサポートしないコードを出力しない

wasmのロード

タイミング

ルートコンポーネントのcomponetDidMountあたり。

方法

グルーJSでdefault exportされている関数を呼び出す。その戻り値を使うと、wasmとやり取りすることが出来るようになる。

仕組み

グルーJSは、自身でwasmをロードする。デフォルトではローカルファイルシステムにwasmがある前提でロードするので、locateFileフックを使って、wasmファイルへのパスをURLに変換した上で、webpackのfile-loaderで、そのURLにあたる場所にwasmファイルを、内容変えずに出力する必要がある。そうすると、グルーJSはwasmをネットワーク経由でロードする。

Webpackの設定

module: {
    rules: [
      ...
      {
        test: /\.wasm$/,
        type: "javascript/auto",  // これがないと必要なヘッダがないとエラーになる
        loader: "file-loader",
        options: {
          name: '[name]-[hash].[ext]'  // ブラウザにキャッシュされないようにhashを含める
        }
      }
    ]

wasmをロードするコード

const glue = require("[グルーJSへの相対パス]/foo.js")
const wasm = require("[wasm本体への相対パス]/foo.wasm")

    componentDidMount() {
      const module = glue({
        locateFile(path: string) {  // convert from source wasm file name to asset url
          if (path === "foo.wasm") {
            return `${ファイル名部分を除いたwasmファイルのURL}/${wasm.default}` // wasm.defaultにはhashが含まれた実際にwebpackが出力したファイル名が入る 
          }
          return path
        }
      })
      module.onRuntimeInitialized = () => {
        // wasmが使用可能になると、この関数が呼び出される。
      }
    }

アプリのレスポンスの改善

wasm側のコードが動いている間は、ブラウザ側のイベントループが止まってしまうので、実行に時間がかかるコードを動かす場合は、画面が固まってしまうが、

extern "C" {
  pub fn emscripten_sleep(i: std::os::raw::c_int);
}

で、emscripten_sleepへのbindingを作って、

emscripten_sleep(1);

をRust側で呼び出すと、その時点で、一時的にブラウザ側に処理を戻すことができる。
なお、emccオプションに-s ASYNCIFYの追加が必要。

非同期イベントループ

wasm側で非同期のイベントループを作って、1ループごとに処理をブラウザに戻すこともできる。
Rustのコードを1から作るのなら、この形で作るのが良さそう。以下の様に1ループ分の処理を実行する関数を定義して、emscripten_set_main_loopに渡せば良い。

fn f() {
  // 1ループ分の処理
}

fn main() {
  ...
  emscripten_set_main_loop(f, 60 /* fps */, 1 /* 1 for infinite loop */);
  ...
}

細かい説明はここに、
https://emscripten.org/docs/porting/emscripten-runtime-environment.html

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Typescript】Union型のType Guardは分割代入するとうまく動かない件

TypescriptのType Guardはまあまあ優秀ですが、動かない時もある、というお話です。

うまく動くコード

interface StringValue {
  isString: true;
  value: string;
}

interface NumberValue {
  isString: false;
  value: number;
}

type ValueObject = StringValue | NumberValue;

function getString(obj: ValueObject): string {
  if (obj.isString) {
    return obj.value; // obj.valueの型はstring
  }
  return (obj.value + 10).toString(); // obj.valueの型はnumber
}

うまく動かないコード

分割代入します

interface StringValue {
  isString: true;
  value: string;
}

interface NumberValue {
  isString: false;
  value: number;
}

type ValueObject = StringValue | NumberValue;

function getString(obj: ValueObject): string {
  const { isString, value } = obj;

  if (isString) {
    return value; // obj.valueの型はstring | number
    // Type 'number' is not assignable to type 'string'.
  }
  return (value + 10).toString(); // obj.valueの型はstring | number
  // Operator '+' cannot be applied to types 'string | number' and 'number'.
}

分割代入は大変便利ですが、Union型を利用する場合は気をつけましょうということでした。

なぜ?

この件に関するIssue (3年以上前ですが)に詳細が有りました。これはバグではなく仕様です。

once an object is destructed, the compiler can no longer make any assumptions about the relationships between the parts. Doing so requires data-flow analysis and alias tracking which is not trivial tasks.

一度オブジェクトが分割されると、コンパイラーはその分割された部分の関係についていかなる仮定もすることができなくなる。そのようにするには、データフローの分析とaliasの追跡が必要となり、それは簡単なタスクでは無い。

(おまけ)ESLint Plugin Reactのreact/destructuring-assignmentについて

私が使っていたESLintの設定の一つで、Reactのpropsは利用前に必ず分割代入されなければならないというものでした。

const Comp: React.FC<{value: string}> = ({
  value
}) => (
  <>
    {value}
  </>
);


const Comp1: React.FC<{ value: string }> = ({ value }) => <>{value}</>; // OK
const Comp2: React.FC<{ value: string }> = props => <>{props.value}</>; 
// Must use destructuring props assignment

普段使う分にはこのルールは問題無いのですが、propsの型がUnion型の時に上記のような問題が発生する場合があります。したがって、個人的にはOFFにすることをお勧めします。(plugin:react/recommendedを利用している方は含まれていないので心配無用です)

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

AWS Amplify 初めてみる編

AWS Apmlifyは、モバイルアプリケーションとウェブアプリケーションを構築するためのAWSがOSSで公開する開発プラットフォームです。
簡単に言えば、AWSでもFirebaseみたいにバックエンドはお任せでフロントだけ開発できるようにしてくれるフレームワークみたいなやつです。
あくまでもプラットフォームなので、AWS Amplify == Firebaseではないです。

↓AWSのページ
https://aws.amazon.com/jp/amplify/

↓公式ドキュメント
https://aws-amplify.github.io/docs/

今回は、Apmlifyの概要とプロジェクトの作成からデプロイまで軽く触ってみます。

AWS Apmlifyとは

AWSのサービスを用いた、Web・モバイルアプリを最速でリリースするための開発プラットフォームです。
React, Vue, AngularなどでのSPAや、Gatsby, Hugoなどの静的サイトジェネレーターを用いたWebアプリケーションを最速で開発するためのプラットフォームと紹介されています。
アプリケーション開発者はフロントエンドの開発に集中でき、バックエンドとインフラはAWSにサーバーレスでお任せできます。
さらに、CI/CD環境の構築も自動で構築してくれます。このあたりはFirebaseとよく似ています。
AWSのサービスを駆使して同等な環境を構築できますが、175個もあるサービスから選定してアーキテクトするのはかなり時間がかかります。
そのコストをAmplifyが解決してくるということです。

AWS Amplifyの以下のような特徴があります。

  • 最速でアプリを開発できる
  • スケールするアプリを開発できる
  • 簡単にアプリを開発できる

サービスの立ち上げからグロースまでを行うことができることが特徴です。

構成

AWS AmplifyはAWSの様々なサービスやツールで構成されています。
そのため、AWS Amplifyで提供されているサービスやツールをまとめてAmplifyファミリーと呼ばれています。
Amplifyファミリーは以下の構成になっています。

  • Framework
  • CLI
  • Developer Tools

Amplify Framework

ライブラリ、UIコンポーネント、CLIを含むOSSのクライアントフレームワーク。SDKとコンポーネントが一緒になったみたいなものです。
Amplify Framreworkは、クライアントがAWSのバックエンドと連携するための処理を数行で実装できるようにするフレームワークです。
以下のようなサービスが提供されています。

  • クライアントライブラリ(JavaScript・iOS・Android)
  • UIコンポーネント
  • Amplify DataStore
  • Amplify CLI
クライアントライブラリ

Amplify for JavaScript / iOS / Andoridとして各クラインとごとにライブラリを提供しています。(iOS/Androidはプレビュー版)
特徴としては、

  • AWSバックエンドと簡単に統合できるクライアントライブラリ
  • React / Vue / AngularといったWebフレームワークやiOS / Androidに対応
  • カテゴリベースで直感的な実装ができるインターフェイス

があげられます。SDKのラッパーのような役割だと思ってもらえれば。

UIコンポーネント

各フレームワーク向けにUIコンポーネントライブラリを提供しています。React, React Native, Angular, Ionic, Vue向けに提供されています。

例えば、ユーザー認証用のUIを追加する場合、UIコンポーネントがあると予め用意された認証UIをさくっと実装できますが、ない場合は全て自らUIとその機能を実装する必要があります。
この後で少し触りますが、一瞬で認証機能を構築することができます。

AWS DataSotre

マルチプラットフォームなクライアント向けのストレージエンジを提供します。
AWS上にバックエンドを用意しなくても、GraphQL経由で自動的にアプリケーション・バックエンドのデータを同期することができます。
Amplifyでのバックエンドの実装は、AWS AppSyncを使用したGraphQL APIがデフォルトの選択肢になっています。
API GateawayとAWS Lambdaを使用したREST APIに変更することもできます。
DataStoreはオフラインに対応しています。

Amplify CLI

AWSでサーバーレスなバックエンドを構築・管理するためのCLIツール。コマンドを実行して対話的に質問に回答するだけで、サーバーレスなバックエンドを構築することができます。
バックエンドとの接続に必要な設定ファイルやソースコードの一部を自動で生成することができます。

Amplify CLIがない場合、

  • AWSのバックエンドをGUIから手動で構築しないといけない
  • AWS CloudFormationやAWS SAMなどで、バックエンドをモデル化し自動で構築する

といった「やりたいこと」の実現手法を知っていれば構築できるやり方です。
しかし、GUIで構築する場合は手順書を用意する必要があり、AWSのGUIが変更されると手順書も更新しなければなりません。また、手順書がなければAWSが初めての方は調べながら構築する必要があり時間がかなりかかります。
また、CloudFormation、AWS SAM、以前紹介したServerless Frameworkなどを使用する場合は、手動で構築する問題を解決できますが、CloudFormationの記法を知らなければ、どのようなバックエンド構成かを理解するのに時間がかかります。

一方のAmplify CLIは「やりたいこと」から実現する手法を構築してくれます。
例えば認証機能を追加した場合は、amplify add authとするだけでAWSの各サービスを組み合わせて認証機能のバックエンドを構築することができます。

Developer Tools

フロントエンドとバックエンドに対してビルド、テスト、デプロイ、ホスティングを容易に実現できるAWSサービス群のことです。

以下のサービスで構成されています。

  • AWS Amplify Console
  • AWS Device Farm

AWS Amplify Consoleは、SPAとSSGによるフルスタックなWebアプリ向けCI/CD環境構築とホスティングを行うことができます。
Gitリポジトリを接続するだけで、WebアプリケーションのためのCI/CDパイプラインを簡単に構築でき、さらにホスティングまでできちゃう、といったところでしょうか。ここはまだあまり理解できていません。

Amplifyの紹介は以上です。
ではAmplifyファミリーのFrameworkを使用して、認証機能とGraphQLでのバックグラウンドを構築していこうと思います。

AWS Amplify実践

AWS Amplifyのインストール

では早速Amplify CLIをインストールしていきます。

$ npm install -g @aws-amplify/cli
$ amplify -v
4.16.1
$ amplify configure

amplify configureを起動すると、AWSのログイン画面がブラウザで表示されます。この辺はFirebaseとほぼ一緒ですね。
ルートユーザーでも、IAMユーザーでもログインできます。
ログインしたらコンソールに戻って、エンターキーを押してください。

ログインすると、Amplifyで使用するリージョンを聞かれます。使用するリージョンを決定してください。

Specify the AWS Region
? region:  ap-northeast-1

次に、ユーザー名を聞かれます。これは、IAMユーザーを聞かれていますので、作成していなければそのまま実行します。

Specify the username of the new IAM user:
? user name:  

すると、AWS IAMのユーザー作成の画面が表示されます。ここからは、普通にIAMユーザーを作成していきます。

  • プログラムによるアクセスのみ許可
  • アクセス権限はとりあえずAdministratorAccess
  • タグはお好みで追加

ユーザーを作成すると、アクセスキーとシークレットアクセスキーを取得することができます。念のためCSVファイルをダウンロードして大切に保管してださい。

ユーザーを作成できたら、先ほど取得したアクセスキーとシークレットアクセスキーを設定します。

Enter the access key of the newly created user:
? accessKeyId:  (<YOUR_ACCESS_KEY_ID>)
? secretAccessKey:  (<YOUR_SECRET_ACCESS_KEY>)

次にプロファイル情報の名前を聞かれますが、任意の名前で大丈夫です。すでにAWS CLIなどがインストールされていて、プロファイル情報がある場合は上書きされます。

This would update/create the AWS Profile in your local machine
? Profile Name:  (default)

以上で初期設定は環境です、

Successfully set up the new user.

Reactアプリケーションの作成

検証用アプリケーションはReactで実装していきます。まずはReactアプリケーションを作成します。

$ npx create-react-app amplify-todo
$ cd amplify-todo

このタイミングで、いらないファイルは消しておきましょう。

Amplifyプロジェクトの構築

Amplifyのプロジェクトを立ち上げて初期化します。プロジェクトを初期化するにはamplify initコマンドを実行します。

$ amplify init
? Enter a name for the project amplify-todo
? Enter a name for the environment dev
? Choose your default editor: Visual Studio Code
? Choose the type of app that you're building javascript
Please tell us about your project
? What javascript framework are you using react
? Source Directory Path:  src
? Distribution Directory Path: build
? Build Command:  npm run build
? Start Command: npm start
Using default provider  awscloudformation

For more information on AWS Profiles, see:
https://docs.aws.amazon.com/cli/latest/userguide/cli-multiple-profiles.html

? Do you want to use an AWS profile? Yes
? Please choose the profile you want to use default

上記のように必要な情報を聞かれるので、プロジェクトに沿った回答をします。
コマンドの実行が完了すると、amplifyディレクトリとsrc/aws-exports.jsファイルが作成されます。

// WARNING: DO NOT EDIT. This file is automatically generated by AWS Amplify. It will be overwritten.

const awsmobile = {
    "aws_project_region": "ap-northeast-1"
};


export default awsmobile;

Amplify CLIの初期設定で設定したリージョンが指定されています。プロジェクトの初期化は以上で完了です。

認証機能の追加

Amplifyプロジェクトの初期化ができたので、次は認証機能を追加します。

まずは、Amplifyの認証機能を追加するコマンドを実行します。Apmlifyに機能を追加する場合は、amplify add <機能>コマンドで追加します。
今回はメールアドレスで認証を行うため、ログイン方法にEmailを選択します。

$ amplify add auth
Using service: Cognito, provided by: awscloudformation

 The current configured provider is Amazon Cognito. 

 Do you want to use the default authentication and security configuration? Default configuration
 Warning: you will not be able to edit these selections. 
 How do you want users to be able to sign in? Email
 Do you want to configure advanced settings? No, I am done.

設定が成功したら、amplify pushコマンドで認証機能をクラウドに反映します。

$ amplify push
Current Environment: dev

| Category | Resource name       | Operation | Provider plugin   |
| -------- | ------------------- | --------- | ----------------- |
| Auth     | amplifyuser751c3c76 | Create    | awscloudformation |
? Are you sure you want to continue? Yes

コマンドの実行が完了するとS3のバケットに最新のCloudfrontのテンプレートファイルなどがアップデートされます。

s3-1.png

次に、ReactのアプリケーションにAmplifyライブラリをインストールします。

$ yarn add aws-amplify aws-amplify-react

src/App.jsにAmplifyライブラリの設定を追加します。
signUpConfigには、ユーザーを登録する際に必要な情報を指定することができます。
詳しい設定方法は公式ドキュメントを参照してください。

https://aws-amplify.github.io/docs/js/react#signup-configuration

src/App.js
import React from 'react';
import Amplify, { Auth } from "aws-amplify";
import awsmobile from "./aws-exports";
import { withAuthenticator } from "aws-amplify-react";

// Amplifyの設定を行う
Amplify.configure(awsmobile)

// SingUp時に、メールアドレスとパスワードを要求する
const signUpConfig = {
    header: 'Sign Up',
    hideAllDefaults: true,
    defaultCountyCode: '1',
    signUpFields: [
        {
            label: 'User Name',
            key: 'username',
            required: true,
            displayOrder: 1,
            type: 'string'
        },
        {
            label: 'Email',
            key: 'email',
            required: true,
            displayOrder: 2,
            type: 'string'
        },
        {
            label: 'Password',
            key: 'password',
            required: true,
            displayOrder: 3,
            type: 'password'
        }
    ]
}

// SingOut
function signOut(){
  Auth.signOut()
  .then()
  .catch();
}

function App() {
  return (
    <React.Fragment>
      <button onClick={signOut}>Sign out</button>
      <div>
        Hello World
      </div>
    </React.Fragment>
  );
}

// Appコンポーネントをラップする
export default withAuthenticator(App,{signUpConfig});

これで、コンポーネントを開く前にユーザーのメールアドレスでログイン画面が表示されます。ローカルで確認してみます。

$ npm start

http://localhost:3000/にアクセスすると、次の画面が表示されます。

auth1.png

Create accountをクリックしアカウント作成画面でアカウントを作成し、ログインしてみます。作成したユーザーは、Cognitoに作成されています。

  • 画面

auth2.png

  • Cognito

cogunite.png

Hello Worldの画面が表示されます。

helloworld.png

サイトを公開する

apmlify add hostingでS3での静的ウェブホスティングを有効にします。バケット名には、何も指定しない場合デフォルトでユニークなバケットを作成してくれます。

$ amplify add hosting
? Select the plugin module to execute Amazon CloudFront and S3
? Select the environment setup: PROD (S3 with CloudFront using HTTPS)
? hosting bucket name 
Static webhosting is disabled for the hosting bucket when CloudFront Distribution is enabled.

You can now publish your app using the following command:
Command: amplify publish

ホスティングを有効した後に、amplify publishを実行し、アプリケーションをビルドしデプロイします。

$ amplify publish
✔ Successfully pulled backend environment dev from the cloud.

Current Environment: dev

| Category | Resource name       | Operation | Provider plugin   |
| -------- | ------------------- | --------- | ----------------- |
| Auth     | amplifyuser751c3c76 | No Change | awscloudformation |
| Hosting  | S3AndCloudFront     | No Change | awscloudformation |
? Are you sure you want to continue? Yes
~ 省略 ~
✔ Uploaded files successfully.
Your app is published successfully.
https://×××××××××××××.cloudfront.net

最後に表示されるURLにアクセスしてみてください。先ほど作成した画面が、CloudFrontで公開されています。
これで、プロジェクトの作成からデプロイまでが完了です。

バックエンドのGraphQLを追加する

認証機能を設定できたので、データベースにデータを永続化し、バックエンドAPIを追加します。

まずは、amplify addでGraphQLを選択肢API機能を追加します。
認証形式にAmazon Cognito User Poolを選択すると、先ほど作成したユーザー情報を用いた認証を使用することができます。

$ amplify add api
? Please select from one of the below mentioned services: GraphQL
? Provide API name: amplifytodo
? Choose the default authorization type for the API Amazon Cognito User Pool
Use a Cognito user pool configured as a part of this project.
? Do you want to configure advanced settings for the GraphQL API No, I am done.
? Do you have an annotated GraphQL schema? No
? Do you want a guided schema creation? No
? Provide a custom type name Todo

amplify statusで確認します。

$ amplify status
amplify status                                                                                                                                                                             11:12:55

Current Environment: dev

| Category | Resource name       | Operation | Provider plugin   |
| -------- | ------------------- | --------- | ----------------- |
| Api      | AmplifyUserAPI      | Create    | awscloudformation |
| Auth     | amplifyuser751c3c76 | No Change | awscloudformation |
| Hosting  | S3AndCloudFront     | No Change | awscloudformation |

APIが追加されていることがわかります。

@modelの作成

@modelが付与されたオブジェクトは、エンティティとしてAmazon DynamoDBに保存されます。@modeの単位でテーブルが作成されるイメージです。
TODOアプリ用に、モデルを作成していきます。
amplify/backend/api/amplifytodoディレクトリにschema.graphqlファイルが作成されています。
それを以下のように書き換えます。

amplify/backend/api/amplifytodo/schema.graphql
type Todo @model {
    id: ID!
    title: String!
    detail: String!
    createdAt: AWSDateTime
    updatedAt: AWSDateTime
}

この状態で、amplify pushをしてみます。一旦全てデフォルトの値で設定します。

$ amplify push
? Do you want to generate code for your newly created GraphQL API Yes
? Choose the code generation language target javascript
? Enter the file name pattern of graphql queries, mutations and subscriptions src/graphql/**/*.js
? Do you want to generate/update all possible GraphQL operations - queries, mutations and subscriptions Yes
? Enter maximum statement depth [increase from default if your schema is deeply nested] 2
~省略~
✔ Generated GraphQL operations successfully and saved at src/graphql
✔ All resources are updated in the cloud

GraphQL endpoint: https://×××××××××××××.appsync-api.ap-northeast-1.amazonaws.com/graphql

GraphQL Endpointが表示されれば成功です。
AppSyncとDynamoDBのページでリソースが作成されているか確認してみてください。

  • AppSync

appsync.png

  • DynamoDB

dynamo.png

ではTODOを追加、確認できるようにしていきます。
まずはライブラリを追加します。

$ npm install @aws-amplify/api @aws-amplify/pubsub 

src/App.jsを以下のように書き換えます。

import React, {useState, useEffect, useReducer } from 'react';
import Amplify, { Auth } from 'aws-amplify';
import API, { graphqlOperation } from '@aws-amplify/api';
import { withAuthenticator } from 'aws-amplify-react'
import { createTodo } from './graphql/mutations';
import { listTodos } from './graphql/queries';
import { onCreateTodo } from './graphql/subscriptions';

import awsconfig from './aws-exports';

Amplify.configure(awsconfig);

const GET = 'GET';
const CREATE = 'CREATE';

const initialState = {
  todos: [],
};

const reducer = (state, action) => {
  switch (action.type) {
    case GET:
      return {...state, todos: action.todos};
    case CREATE:
      return {...state, todos:[...state.todos, action.todo]}
    default:
      return state;
  }
};

function signOut(){
  Auth.signOut()
  .then()
  .catch();
}

function App() {
  const [state, dispatch] = useReducer(reducer, initialState);
  const [user, setUser] = useState(null);
  const [title, setTitle] = useState(null);
  const [detail, setDetail] = useState(null);

  function onChange(e){
    if(e.target.id === 'title'){
      setTitle(e.target.value);
    }
    if(e.target.id === 'detail'){
      setDetail(e.target.value);
    }
  }

  async function create(e) {
    e.preventDefault();
    setTitle('')
    setDetail('')
    const todo = { title:title, detail:detail };
    await API.graphql(graphqlOperation(createTodo, { input: todo }));
  }

  useEffect(() => {

    async function getUser(){
      const user = await Auth.currentUserInfo();
      setUser(user);
      return user
    }

    getUser();

    async function getData() {
      const todoData = await API.graphql(graphqlOperation(listTodos));
      dispatch({ type: GET, todos: todoData.data.listTodos.items });
    }

    getData();

    const subscription = API.graphql(graphqlOperation(onCreateTodo)).subscribe({
      next: (eventData) => {
        const todo = eventData.value.data.onCreateTodo;
        dispatch({ type: CREATE, todo });
      }
    });

    return () => subscription.unsubscribe();
  }, []);

  return (
    <div className="App">
      <p>user: {user!= null && user.username}</p>
      <button onClick={signOut}>Sign out</button>
      <div>
        <table border="1" style={{'border-collapse': 'collapse'}}>
          <tr>
            <th>No</th>
            <th>Title</th>
            <th>Detail</th>
            <th></th>
          </tr>
          <tr>
            <td></td>
            <td><input id='title' type='text' onChange={onChange} value={title}/></td>
            <td><input id='detail' type='text' onChange={onChange} value={detail}/></td>
            <th><button onClick={create}>New</button></th>
          </tr>
          {state.todos && state.todos.map((todo,index) => {
            return(
              <tr key={todo.id}>
                <td>{index + 1}</td>
                <td>{todo.title}</td>
                <td>{todo.detail}</td>
                <td>{todo.createdAt}</td>
              </tr>
            )
          })}
        </table>
      </div>
    </div>
  );
}

const signUpConfig = {
    header: 'Sign Up',
    hideAllDefaults: true,
    defaultCountyCode: '1',
    signUpFields: [
        {
            label: 'User Name',
            key: 'username',
            required: true,
            displayOrder: 1,
            type: 'string'
        },
        {
            label: 'Email',
            key: 'email',
            required: true,
            displayOrder: 2,
            type: 'string'
        },
        {
            label: 'Password',
            key: 'password',
            required: true,
            displayOrder: 3,
            type: 'password'
        }
    ]
}

export default withAuthenticator(App, {
  signUpConfig: signUpConfig
});

ローカルで実行してみます。

$ npm start

ブラウザでサイトを立ち上げて確認してみます。

video1.gif

TODOが追加できていることがわかります。DynamoDBのコンソールでも確認してみてください。
作成したTODOが追加されています。

簡単ですが、GraphQLのバックグラウンドを追加してデータの追加をしてみました。

削除

amplify deleteinitで作成した環境を全て削除できます。何かとお金がかかるので不安な方は実行してください。

まとめ

AWS Amplifyの紹介と実際に少し触ってみました。
まだまだAmplifyの一部分しか触れていませんが、一から構築するのと比べものにならないぐらい爆速でアプリを立ち上げることができます。
Amplify Frameworkの基本的なフローは、amplify addで機能を追加しamplify pushでCloudFrontのテンプレートファイルを更新し、amplify publishで静的サイトをデプロイする、の流れになります。
Amplifyを使ってAWSを簡単に導入して、少しずつ各サービスを理解していき、最終的には同等の環境をAmplifyなしで構築できればベストかなと感じました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

AWS Amplify はじめてみる編

AWS Apmlifyは、モバイルアプリケーションとウェブアプリケーションを構築するためのAWSがOSSで公開する開発プラットフォームです。
簡単に言えば、AWSでもFirebaseみたいにバックエンドはお任せでフロントだけ開発できるようにしてくれるフレームワークみたいなやつです。
あくまでもプラットフォームなので、AWS Amplify == Firebaseではないです。

↓AWSのページ
https://aws.amazon.com/jp/amplify/

↓公式ドキュメント
https://aws-amplify.github.io/docs/

今回は、Apmlifyの概要とプロジェクトの作成からデプロイまで軽く触ってみます。

AWS Apmlifyとは

AWSのサービスを用いた、Web・モバイルアプリを最速でリリースするための開発プラットフォームです。
React, Vue, AngularなどでのSPAや、Gatsby, Hugoなどの静的サイトジェネレーターを用いたWebアプリケーションを最速で開発するためのプラットフォームと紹介されています。
アプリケーション開発者はフロントエンドの開発に集中でき、バックエンドとインフラはAWSにサーバーレスでお任せできます。
さらに、CI/CD環境の構築も自動で構築してくれます。このあたりはFirebaseとよく似ています。
AWSのサービスを駆使して同等な環境を構築できますが、175個もあるサービスから選定してアーキテクトするのはかなり時間がかかります。
そのコストをAmplifyが解決してくるということです。

AWS Amplifyの以下のような特徴があります。

  • 最速でアプリを開発できる
  • スケールするアプリを開発できる
  • 簡単にアプリを開発できる

サービスの立ち上げからグロースまでを行うことができることが特徴です。

構成

AWS AmplifyはAWSの様々なサービスやツールで構成されています。
そのため、AWS Amplifyで提供されているサービスやツールをまとめてAmplifyファミリーと呼ばれています。
Amplifyファミリーは以下の構成になっています。

  • Framework
  • CLI
  • Developer Tools

Amplify Framework

ライブラリ、UIコンポーネント、CLIを含むOSSのクライアントフレームワーク。SDKとコンポーネントが一緒になったみたいなものです。
Amplify Framreworkは、クライアントがAWSのバックエンドと連携するための処理を数行で実装できるようにするフレームワークです。
以下のようなサービスが提供されています。

  • クライアントライブラリ(JavaScript・iOS・Android)
  • UIコンポーネント
  • Amplify DataStore
  • Amplify CLI
クライアントライブラリ

Amplify for JavaScript / iOS / Andoridとして各クラインとごとにライブラリを提供しています。(iOS/Androidはプレビュー版)
特徴としては、

  • AWSバックエンドと簡単に統合できるクライアントライブラリ
  • React / Vue / AngularといったWebフレームワークやiOS / Androidに対応
  • カテゴリベースで直感的な実装ができるインターフェイス

があげられます。SDKのラッパーのような役割だと思ってもらえれば。

UIコンポーネント

各フレームワーク向けにUIコンポーネントライブラリを提供しています。React, React Native, Angular, Ionic, Vue向けに提供されています。

例えば、ユーザー認証用のUIを追加する場合、UIコンポーネントがあると予め用意された認証UIをさくっと実装できますが、ない場合は全て自らUIとその機能を実装する必要があります。
この後で少し触りますが、一瞬で認証機能を構築することができます。

AWS DataSotre

マルチプラットフォームなクライアント向けのストレージエンジを提供します。
AWS上にバックエンドを用意しなくても、GraphQL経由で自動的にアプリケーション・バックエンドのデータを同期することができます。
Amplifyでのバックエンドの実装は、AWS AppSyncを使用したGraphQL APIがデフォルトの選択肢になっています。
API GateawayとAWS Lambdaを使用したREST APIに変更することもできます。
DataStoreはオフラインに対応しています。

Amplify CLI

AWSでサーバーレスなバックエンドを構築・管理するためのCLIツール。コマンドを実行して対話的に質問に回答するだけで、サーバーレスなバックエンドを構築することができます。
バックエンドとの接続に必要な設定ファイルやソースコードの一部を自動で生成することができます。

Amplify CLIがない場合、

  • AWSのバックエンドをGUIから手動で構築しないといけない
  • AWS CloudFormationやAWS SAMなどで、バックエンドをモデル化し自動で構築する

といった「やりたいこと」の実現手法を知っていれば構築できるやり方です。
しかし、GUIで構築する場合は手順書を用意する必要があり、AWSのGUIが変更されると手順書も更新しなければなりません。また、手順書がなければAWSが初めての方は調べながら構築する必要があり時間がかなりかかります。
また、CloudFormation、AWS SAM、以前紹介したServerless Frameworkなどを使用する場合は、手動で構築する問題を解決できますが、CloudFormationの記法を知らなければ、どのようなバックエンド構成かを理解するのに時間がかかります。

一方のAmplify CLIは「やりたいこと」から実現する手法を構築してくれます。
例えば認証機能を追加した場合は、amplify add authとするだけでAWSの各サービスを組み合わせて認証機能のバックエンドを構築することができます。

Developer Tools

フロントエンドとバックエンドに対してビルド、テスト、デプロイ、ホスティングを容易に実現できるAWSサービス群のことです。

以下のサービスで構成されています。

  • AWS Amplify Console
  • AWS Device Farm

AWS Amplify Consoleは、SPAとSSGによるフルスタックなWebアプリ向けCI/CD環境構築とホスティングを行うことができます。
Gitリポジトリを接続するだけで、WebアプリケーションのためのCI/CDパイプラインを簡単に構築でき、さらにホスティングまでできちゃう、といったところでしょうか。ここはまだあまり理解できていません。

Amplifyの紹介は以上です。
ではAmplifyファミリーのFrameworkを使用して、認証機能とGraphQLでのバックグラウンドを構築していこうと思います。

AWS Amplify実践

AWS Amplifyのインストール

では早速Amplify CLIをインストールしていきます。

$ npm install -g @aws-amplify/cli
$ amplify -v
4.16.1
$ amplify configure

amplify configureを起動すると、AWSのログイン画面がブラウザで表示されます。この辺はFirebaseとほぼ一緒ですね。
ルートユーザーでも、IAMユーザーでもログインできます。
ログインしたらコンソールに戻って、エンターキーを押してください。

ログインすると、Amplifyで使用するリージョンを聞かれます。使用するリージョンを決定してください。

Specify the AWS Region
? region:  ap-northeast-1

次に、ユーザー名を聞かれます。これは、IAMユーザーを聞かれていますので、作成していなければそのまま実行します。

Specify the username of the new IAM user:
? user name:  

すると、AWS IAMのユーザー作成の画面が表示されます。ここからは、普通にIAMユーザーを作成していきます。

  • プログラムによるアクセスのみ許可
  • アクセス権限はとりあえずAdministratorAccess
  • タグはお好みで追加

ユーザーを作成すると、アクセスキーとシークレットアクセスキーを取得することができます。念のためCSVファイルをダウンロードして大切に保管してださい。

ユーザーを作成できたら、先ほど取得したアクセスキーとシークレットアクセスキーを設定します。

Enter the access key of the newly created user:
? accessKeyId:  (<YOUR_ACCESS_KEY_ID>)
? secretAccessKey:  (<YOUR_SECRET_ACCESS_KEY>)

次にプロファイル情報の名前を聞かれますが、任意の名前で大丈夫です。すでにAWS CLIなどがインストールされていて、プロファイル情報がある場合は上書きされます。

This would update/create the AWS Profile in your local machine
? Profile Name:  (default)

以上で初期設定は環境です、

Successfully set up the new user.

Reactアプリケーションの作成

検証用アプリケーションはReactで実装していきます。まずはReactアプリケーションを作成します。

$ npx create-react-app amplify-todo
$ cd amplify-todo

このタイミングで、いらないファイルは消しておきましょう。

Amplifyプロジェクトの構築

Amplifyのプロジェクトを立ち上げて初期化します。プロジェクトを初期化するにはamplify initコマンドを実行します。

$ amplify init
? Enter a name for the project amplify-todo
? Enter a name for the environment dev
? Choose your default editor: Visual Studio Code
? Choose the type of app that you're building javascript
Please tell us about your project
? What javascript framework are you using react
? Source Directory Path:  src
? Distribution Directory Path: build
? Build Command:  npm run build
? Start Command: npm start
Using default provider  awscloudformation

For more information on AWS Profiles, see:
https://docs.aws.amazon.com/cli/latest/userguide/cli-multiple-profiles.html

? Do you want to use an AWS profile? Yes
? Please choose the profile you want to use default

上記のように必要な情報を聞かれるので、プロジェクトに沿った回答をします。
コマンドの実行が完了すると、amplifyディレクトリとsrc/aws-exports.jsファイルが作成されます。

// WARNING: DO NOT EDIT. This file is automatically generated by AWS Amplify. It will be overwritten.

const awsmobile = {
    "aws_project_region": "ap-northeast-1"
};


export default awsmobile;

Amplify CLIの初期設定で設定したリージョンが指定されています。プロジェクトの初期化は以上で完了です。

認証機能の追加

Amplifyプロジェクトの初期化ができたので、次は認証機能を追加します。

まずは、Amplifyの認証機能を追加するコマンドを実行します。Apmlifyに機能を追加する場合は、amplify add <機能>コマンドで追加します。
今回はメールアドレスで認証を行うため、ログイン方法にEmailを選択します。

$ amplify add auth
Using service: Cognito, provided by: awscloudformation

 The current configured provider is Amazon Cognito. 

 Do you want to use the default authentication and security configuration? Default configuration
 Warning: you will not be able to edit these selections. 
 How do you want users to be able to sign in? Email
 Do you want to configure advanced settings? No, I am done.

設定が成功したら、amplify pushコマンドで認証機能をクラウドに反映します。

$ amplify push
Current Environment: dev

| Category | Resource name       | Operation | Provider plugin   |
| -------- | ------------------- | --------- | ----------------- |
| Auth     | amplifyuser751c3c76 | Create    | awscloudformation |
? Are you sure you want to continue? Yes

コマンドの実行が完了するとS3のバケットに最新のCloudfrontのテンプレートファイルなどがアップデートされます。

s3-1.png

次に、ReactのアプリケーションにAmplifyライブラリをインストールします。

$ yarn add aws-amplify aws-amplify-react

src/App.jsにAmplifyライブラリの設定を追加します。
signUpConfigには、ユーザーを登録する際に必要な情報を指定することができます。
詳しい設定方法は公式ドキュメントを参照してください。

https://aws-amplify.github.io/docs/js/react#signup-configuration

src/App.js
import React from 'react';
import Amplify, { Auth } from "aws-amplify";
import awsmobile from "./aws-exports";
import { withAuthenticator } from "aws-amplify-react";

// Amplifyの設定を行う
Amplify.configure(awsmobile)

// SingUp時に、メールアドレスとパスワードを要求する
const signUpConfig = {
    header: 'Sign Up',
    hideAllDefaults: true,
    defaultCountyCode: '1',
    signUpFields: [
        {
            label: 'User Name',
            key: 'username',
            required: true,
            displayOrder: 1,
            type: 'string'
        },
        {
            label: 'Email',
            key: 'email',
            required: true,
            displayOrder: 2,
            type: 'string'
        },
        {
            label: 'Password',
            key: 'password',
            required: true,
            displayOrder: 3,
            type: 'password'
        }
    ]
}

// SingOut
function signOut(){
  Auth.signOut()
  .then()
  .catch();
}

function App() {
  return (
    <React.Fragment>
      <button onClick={signOut}>Sign out</button>
      <div>
        Hello World
      </div>
    </React.Fragment>
  );
}

// Appコンポーネントをラップする
export default withAuthenticator(App,{signUpConfig});

これで、コンポーネントを開く前にユーザーのメールアドレスでログイン画面が表示されます。ローカルで確認してみます。

$ npm start

http://localhost:3000/にアクセスすると、次の画面が表示されます。

auth1.png

Create accountをクリックしアカウント作成画面でアカウントを作成し、ログインしてみます。作成したユーザーは、Cognitoに作成されています。

  • 画面

auth2.png

  • Cognito

cogunite.png

Hello Worldの画面が表示されます。

helloworld.png

サイトを公開する

apmlify add hostingでS3での静的ウェブホスティングを有効にします。バケット名には、何も指定しない場合デフォルトでユニークなバケットを作成してくれます。

$ amplify add hosting
? Select the plugin module to execute Amazon CloudFront and S3
? Select the environment setup: PROD (S3 with CloudFront using HTTPS)
? hosting bucket name 
Static webhosting is disabled for the hosting bucket when CloudFront Distribution is enabled.

You can now publish your app using the following command:
Command: amplify publish

ホスティングを有効した後に、amplify publishを実行し、アプリケーションをビルドしデプロイします。

$ amplify publish
✔ Successfully pulled backend environment dev from the cloud.

Current Environment: dev

| Category | Resource name       | Operation | Provider plugin   |
| -------- | ------------------- | --------- | ----------------- |
| Auth     | amplifyuser751c3c76 | No Change | awscloudformation |
| Hosting  | S3AndCloudFront     | No Change | awscloudformation |
? Are you sure you want to continue? Yes
~ 省略 ~
✔ Uploaded files successfully.
Your app is published successfully.
https://×××××××××××××.cloudfront.net

最後に表示されるURLにアクセスしてみてください。先ほど作成した画面が、CloudFrontで公開されています。
これで、プロジェクトの作成からデプロイまでが完了です。

バックエンドのGraphQLを追加する

認証機能を設定できたので、データベースにデータを永続化し、バックエンドAPIを追加します。

まずは、amplify addでGraphQLを選択肢API機能を追加します。
認証形式にAmazon Cognito User Poolを選択すると、先ほど作成したユーザー情報を用いた認証を使用することができます。

$ amplify add api
? Please select from one of the below mentioned services: GraphQL
? Provide API name: amplifytodo
? Choose the default authorization type for the API Amazon Cognito User Pool
Use a Cognito user pool configured as a part of this project.
? Do you want to configure advanced settings for the GraphQL API No, I am done.
? Do you have an annotated GraphQL schema? No
? Do you want a guided schema creation? No
? Provide a custom type name Todo

amplify statusで確認します。

$ amplify status
amplify status                                                                                                                                                                             11:12:55

Current Environment: dev

| Category | Resource name       | Operation | Provider plugin   |
| -------- | ------------------- | --------- | ----------------- |
| Api      | AmplifyUserAPI      | Create    | awscloudformation |
| Auth     | amplifyuser751c3c76 | No Change | awscloudformation |
| Hosting  | S3AndCloudFront     | No Change | awscloudformation |

APIが追加されていることがわかります。

@modelの作成

@modelが付与されたオブジェクトは、エンティティとしてAmazon DynamoDBに保存されます。@modeの単位でテーブルが作成されるイメージです。
TODOアプリ用に、モデルを作成していきます。
amplify/backend/api/amplifytodoディレクトリにschema.graphqlファイルが作成されています。
それを以下のように書き換えます。

amplify/backend/api/amplifytodo/schema.graphql
type Todo @model {
    id: ID!
    title: String!
    detail: String!
    createdAt: AWSDateTime
    updatedAt: AWSDateTime
}

この状態で、amplify pushをしてみます。一旦全てデフォルトの値で設定します。

$ amplify push
? Do you want to generate code for your newly created GraphQL API Yes
? Choose the code generation language target javascript
? Enter the file name pattern of graphql queries, mutations and subscriptions src/graphql/**/*.js
? Do you want to generate/update all possible GraphQL operations - queries, mutations and subscriptions Yes
? Enter maximum statement depth [increase from default if your schema is deeply nested] 2
~省略~
✔ Generated GraphQL operations successfully and saved at src/graphql
✔ All resources are updated in the cloud

GraphQL endpoint: https://×××××××××××××.appsync-api.ap-northeast-1.amazonaws.com/graphql

GraphQL Endpointが表示されれば成功です。
AppSyncとDynamoDBのページでリソースが作成されているか確認してみてください。

  • AppSync

appsync.png

  • DynamoDB

dynamo.png

ではTODOを追加、確認できるようにしていきます。
まずはライブラリを追加します。

$ npm install @aws-amplify/api @aws-amplify/pubsub 

src/App.jsを以下のように書き換えます。

import React, {useState, useEffect, useReducer } from 'react';
import Amplify, { Auth } from 'aws-amplify';
import API, { graphqlOperation } from '@aws-amplify/api';
import { withAuthenticator } from 'aws-amplify-react'
import { createTodo } from './graphql/mutations';
import { listTodos } from './graphql/queries';
import { onCreateTodo } from './graphql/subscriptions';

import awsconfig from './aws-exports';

Amplify.configure(awsconfig);

const GET = 'GET';
const CREATE = 'CREATE';

const initialState = {
  todos: [],
};

const reducer = (state, action) => {
  switch (action.type) {
    case GET:
      return {...state, todos: action.todos};
    case CREATE:
      return {...state, todos:[...state.todos, action.todo]}
    default:
      return state;
  }
};

function signOut(){
  Auth.signOut()
  .then()
  .catch();
}

function App() {
  const [state, dispatch] = useReducer(reducer, initialState);
  const [user, setUser] = useState(null);
  const [title, setTitle] = useState(null);
  const [detail, setDetail] = useState(null);

  function onChange(e){
    if(e.target.id === 'title'){
      setTitle(e.target.value);
    }
    if(e.target.id === 'detail'){
      setDetail(e.target.value);
    }
  }

  async function create(e) {
    e.preventDefault();
    setTitle('')
    setDetail('')
    const todo = { title:title, detail:detail };
    await API.graphql(graphqlOperation(createTodo, { input: todo }));
  }

  useEffect(() => {

    async function getUser(){
      const user = await Auth.currentUserInfo();
      setUser(user);
      return user
    }

    getUser();

    async function getData() {
      const todoData = await API.graphql(graphqlOperation(listTodos));
      dispatch({ type: GET, todos: todoData.data.listTodos.items });
    }

    getData();

    const subscription = API.graphql(graphqlOperation(onCreateTodo)).subscribe({
      next: (eventData) => {
        const todo = eventData.value.data.onCreateTodo;
        dispatch({ type: CREATE, todo });
      }
    });

    return () => subscription.unsubscribe();
  }, []);

  return (
    <div className="App">
      <p>user: {user!= null && user.username}</p>
      <button onClick={signOut}>Sign out</button>
      <div>
        <table border="1" style={{'border-collapse': 'collapse'}}>
          <tr>
            <th>No</th>
            <th>Title</th>
            <th>Detail</th>
            <th></th>
          </tr>
          <tr>
            <td></td>
            <td><input id='title' type='text' onChange={onChange} value={title}/></td>
            <td><input id='detail' type='text' onChange={onChange} value={detail}/></td>
            <th><button onClick={create}>New</button></th>
          </tr>
          {state.todos && state.todos.map((todo,index) => {
            return(
              <tr key={todo.id}>
                <td>{index + 1}</td>
                <td>{todo.title}</td>
                <td>{todo.detail}</td>
                <td>{todo.createdAt}</td>
              </tr>
            )
          })}
        </table>
      </div>
    </div>
  );
}

const signUpConfig = {
    header: 'Sign Up',
    hideAllDefaults: true,
    defaultCountyCode: '1',
    signUpFields: [
        {
            label: 'User Name',
            key: 'username',
            required: true,
            displayOrder: 1,
            type: 'string'
        },
        {
            label: 'Email',
            key: 'email',
            required: true,
            displayOrder: 2,
            type: 'string'
        },
        {
            label: 'Password',
            key: 'password',
            required: true,
            displayOrder: 3,
            type: 'password'
        }
    ]
}

export default withAuthenticator(App, {
  signUpConfig: signUpConfig
});

ローカルで実行してみます。

$ npm start

ブラウザでサイトを立ち上げて確認してみます。

video1.gif

TODOが追加できていることがわかります。DynamoDBのコンソールでも確認してみてください。
作成したTODOが追加されています。

簡単ですが、GraphQLのバックグラウンドを追加してデータの追加をしてみました。

削除

amplify deleteinitで作成した環境を全て削除できます。何かとお金がかかるので不安な方は実行してください。

まとめ

AWS Amplifyの紹介と実際に少し触ってみました。
まだまだAmplifyの一部分しか触れていませんが、一から構築するのと比べものにならないぐらい爆速でアプリを立ち上げることができます。
Amplify Frameworkの基本的なフローは、amplify addで機能を追加しamplify pushでCloudFrontのテンプレートファイルを更新し、amplify publishで静的サイトをデプロイする、の流れになります。
Amplifyを使ってAWSを簡単に導入して、少しずつ各サービスを理解していき、最終的には同等の環境をAmplifyなしで構築できればベストかなと感じました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

開発コンテナで快適!<del>ひきこもり生活</del>フロントエンド開発

どうも、よこけんです。
Web アプリ開発の現場から離れて10年くらい経つのですが、思うところあって最近のフロントエンド開発についてプライベートで勉強しました。今日はその成果をアウトプットしようと思います。

本記事では主に、VSCode の Remote-Container を使ったフロントエンド (React) 開発を行うための環境構築方法を解説します。
オールインワンのためかなり長い記事になってしまいましたが、大半の作業はファイルのコピペとコマンドのコピペなので作業自体はシンプルです。ただし、各要素の理解こそが重要なので、この記事をきっかけに各要素の理解を深めていっていただければと思います。

本記事では特に下記の要素を押さえています。

  • 開発環境のコンテナ化
    • 常にコンテナの中で 生活 開発していきます。
    • 開発者間での開発環境の統一ができます。
    • 開発環境のリセットが容易です。
    • 本番環境に (構成の面で) 近い環境を使用して開発できます。
  • 本番環境のコンテナ化
    • Docker in Docker によって、開発コンテナからも気軽に起動できます。
    • 本番環境と (構成の面で) 同一の環境を使用して動作確認できます。
  • React 開発を始めるにあたって重要になってくる (手堅い) 周辺技術
    • 技術選定を簡略化もしくは省略できます。
    • create-react-app を使わないので、各技術がブラックボックス化されず制御しやすくなります。
    • 導入時に手を焼くであろうポイントを回避できます。
    • 概略を押さえることで各要素の学習のハードルが下がり、スムーズに進めやすくなります。
  • デバッグ方法
    • 取っつきにくくややこしい設定に手を焼くことなく、容易にデバッグを行えます。

反対に、下記については本記事では扱いません。

  • 言語について
  • React そのものの詳細な開発テクニック・テストテクニック
  • バージョン管理 (Git) の詳細
  • ひきこもりの是非
  • アトミックデザインなどのコンポーネント設計手法
  • マテリアルデザインなどの Web デザイン手法
  • Cloud などへのデプロイメント

余談ですが、個人的にはアトミックデザインについてはやや懐疑的です。
コンポーネントの再利用性を高めること自体は重要と思いますが、ボトムアップなアプローチは過剰設計を招きがちです。
フロントエンド開発においても、トップダウンなアプローチで必要に応じてコンポーネントの再利用性を高めていく進化的設計が望ましいと考えています。

まぁそれはさておき、本題に入りましょう。
完成品は GitHub にあげてありますのでご活用ください。

開発環境構成

まずはこの記事で構築する開発環境の構成を見ていきます。

ホスト構成

  • OS
    • Windows 10 Pro (※)
  • IDE
    • VSCode
      • Remote Development (Remote-Container)
      • Docker
      • EditorConfig
      • Git Lens (この記事では扱いませんがお勧めです)
      • Git Graph (この記事では扱いませんお勧めです)
  • バージョン管理システム
    • Git
  • コンテナツール
    • Docker Desktop

※ 私の環境が Windows 10 Pro なので、Windows 10 Home や Mac だとどうなるのかはよくわかりません。特に Windows 10 Home は Hyper-V 非対応のため Docker Desktop が使えないかと思います。(Virtual Box + Docker Toolbox や WSL2 + Docker Desktop で Docker が動かせるという噂ですが。)

開発用コンテナ構成

  • OS
    • Debian
  • ランタイム
    • Node.js
  • パッケージマネージャー
    • npm
  • コンテナツール
    • Docker CE CLI
    • Docker Compose
  • IDE
    • VSCode (ホスト環境からリモート接続)
      • language-stylus
      • Debugger for Chrome
      • Debugger for Edge
      • ESLint
      • Stylint
      • Manta's Stylus Supremacy
      • Jest Runner
      • EditorConfig 1
      • Git Lens 1
      • Git Graph 1
  • バージョン管理システム
    • Git 1

コンテナ化した本番環境を Docker in Docker で起動できるよう、コンテナツールもインストールしておきます。 (基本的には本番環境は起動せず開発環境内で直接アプリを実行しますが。)

開発用パッケージ構成

  • クライアントサイド
    • 言語
      • HTML
      • TypeScript
      • Stylus
    • Lint
      • ESLint
        • ESLint Plugin React
      • Stylint
    • フレームワーク
      • React
        • React DOM
        • React Hooks
        • React Router DOM
        • React Hot Loader
    • モジュールバンドラ
      • WebPack
        • WebPack CLI
        • WebPack Merge
        • TS Loader
        • CSS Loader
        • Style Loader
        • Stylus Loader
        • URL Loader
        • File Loader
        • Clean WebPack Plugin
        • HTML WebPack Plugin
    • テストフレームワーク
      • Jest
      • Jest CSS Modules
      • Fetch Mock
      • React Testing Library
    • その他
      • concurrently
  • サーバーサイド
    • ランタイム
      • ts-node
      • ts-node-dev
    • 言語
      • TypeScript
    • Lint
      • ESLint
    • Web サーバー
      • Express
      • WebPack Dev Server
    • ロギング
      • log4js
    • リバースプロキシ
      • node-fetch

多過ぎ…

うん。多いですね。
これだけ見ると、フロントエンド開発をこれから学ぼうとしている方は尻込みしてしまうかもしれません。
ただ、これらは大きなものから小さなものまで全て洗い出して記載しており、主要技術としては太字で記載したものに限定されます。
下記は主要技術を抜き出したリストとなります。

  • IDE
    • VSCode
      • Remote Development (Remote-Container)
  • バージョン管理システム
    • Git
  • ランタイム
    • Node.js
  • パッケージマネージャー
    • npm
  • コンテナツール
    • docker-ce-cli
    • docker-compose
  • 言語
    • TypeScript
    • Stylus
  • Lint
    • ESLint
    • Stylint
  • Web サーバー
    • Express
    • WebPack Dev Server
  • フレームワーク
    • React
  • モジュールバンドラ
    • WebPack
  • テストフレームワーク
    • Jest
    • React Testing Library
  • ロギング
    • log4js

まぁ、それでも多いんですが。
だからこそ、まとめて押さえられるようにこの記事を書こうと思い立ったわけです。

といっても、この記事だけで全て完璧に習得できる、なんてことは全くありません。特にバージョン管理、言語、フレームワーク、テストフレームワーク、そして CSS フレームワークについては、確実に追加学習が必要となります。

何を学べば良いかがある程度固まって、その取っ掛かりになるくらいの情報は得られる、というのがこの記事の目指すところです。

本番環境構成

続いて本番環境です。

本番用コンテナ構成

  • OS
    • Debian
  • ランタイム
    • Node.js
  • パッケージマネージャー
    • npm

本番用パッケージ構成

  • クライアントサイド
    • 言語
      • (HTML)
      • (JavaScript)
      • (CSS)
  • サーバーサイド
    • ランタイム
      • ts-node
    • 言語
      • TypeScript
    • Web サーバー
      • Express
    • ロギング
      • log4js
    • リバースプロキシ
      • node-fetch

クライアントサイドはブラウザ上で実行されますので HTML + JavaScript + CSS になっています。TypeScript や Stylus がビルド時に自動的にこれらにトランスパイル (変換) されます。
React などのライブラリ群は、ビルド時にモジュールバンドラによってまとめられるため本番環境へのインストールは不要です。

事前準備

では、ここからは実際に作業を行っていきます。

VSCode のインストール

VSCode をダウンロードしてインストールします。

インストールしたら起動して、拡張機能をインストールします。

image.png

以下の拡張機能をそれぞれ名前で検索して [Install] ボタンでインストールしてください。

  • Remote Development (Remote-Container)
  • Docker
  • EditorConfig
  • Git Lens (この記事では扱いませんがお勧めです)
  • Git Graph (この記事では扱いませんお勧めです)

Git のインストール

Git をダウンロードしてインストールします。

重要な設定は改めて行うので、インストール時に行う設定はとりあえず適当で良いです。よくわからない項目はそのままで。

インストールが完了したら Git Bash を開き、下記のように設定を行います。
{User Name}{Mail Address} は自分の情報で置き換えてください。

git config --global user.name "{User Name}"
git config --global user.email "{Mail Address}"
git config --global push.default "simple"
git config --global core.autocrlf "false"
git config --global core.ignorecase "false"
git config --global core.quotepath "false"

Hyper-V の設定確認

デフォルトで有効化されていると思いますが、一応確認しておいてください、

仮想マシンを自分で作成する必要はありません。

Docker Desktop のインストール

Docker Desktop (Community Edition) をダウンロードしてインストールします。インストーラのオプション設定は変更せずにインストールします。

インストールが完了すると自動で起動します。Docker アカウントのログイン画面が表示されますが、アカウント登録やログインは不要ですので閉じてしまってください。

ワークスペース

VSCode にはマルチルートワークスペースという機能があります。これは一つのワークスペースに、関連する複数のプロジェクトフォルダーをまとめる機能です。
この記事では一つのプロジェクトフォルダーしか作成しませんが、下記の理由からこのマルチルートワークスペースを採用します。

  • 常にマルチルートワークスペースで構築することで、一貫して同じ操作方法・動作となる。
    • プロジェクトフォルダー を直接開く場合はプロジェクトフォルダー = ワークスペースだが、マルチルートワークスペースではプロジェクトフォルダー ≠ ワークスペースとなり、一部の操作方法や動作に違いがある。
    • フロントエンドは Node.js、バックエンドは Python や C# というように、プロジェクトフォルダーを分けることは多い。後からプロジェクトフォルダーが増えることもよくある。

ということで、ワークスペースを作成しましょう。

マルチルートワークスペースの作成

  1. Windows Explorer で任意の場所にワークスペースフォルダー (本記事ではC:\Workspaces\MoroMoro.Sample) を作成します。
  2. VSCode を起動します。
  3. メニューバーの [File] - [Close Folder] が無効化されていることを確認します。有効化されていたり [Close Workspace] が表示されている場合はそれを押してフォルダー(もしくはワークスペース) を必ず閉じてください。
  4. メニューバーの [File] - [Save Workspace As...] を押します。
  5. ファイル保存ダイアログが開かれるので、ワークスペースフォルダーに移動し、ファイル名にワークスペースフォルダーと同じ名前 (本記事では MoroMoro.Sample) を入力して [Save] ボタンで保存します。

<注意>
本記事ではワークスペース名やプロジェクト名を MoroMoro.Sample.Frontend のようにアッパーキャメルケースで命名していますが、Node.js パッケージ (後述) の命名規則に合わせて moromoro.sample.frontend のように全て小文字で命名しても構いません。(本記事でも Node.js パッケージ名については全て小文字で命名します。)

Git Init

バージョン管理はワークスペースレベルで行います。
Git Bash を開きワークスペースに移動してから次のコマンドを実行します。

git init

<注意>
「使い捨てだからバージョン管理しなくていいや」という人も必ず行ってください。
非常に厄介なことに、Git Init の有無で Remote-Container の挙動の一部が大きく変わってしまうためです。(後述)

プロジェクト

続いて、ワークスペースにフロントエンドのプロジェクトを作成します。

プロジェクトフォルダーの追加

  1. Windows Explorer でワークスペースフォルダーにプロジェクトフォルダー (本記事では MoroMoro.Sample.Frontend) を作成します。
  2. VSCode のサイドメニューバーから image.png (Explorer) を開き、[Add Folder] ボタンを押します。
  3. フォルダー選択ダイアログが開かれるので、プロジェクトフォルダーを選択して [Add] ボタンで追加します。

EditorConfig 設定

EditorConfig はファイルの文字コードや改行コード、インデントなどのエディタ設定をファイルにまとめる仕組みです。設定ファイルをソースコードと一緒に管理することで、メンバー間でのエディタ設定を統一することができます。
VSCode では直接はこの仕組みをサポートしていませんが、事前準備でインストールした EditorConfig 拡張機能によってこの仕組みが利用できるようになります。
VSCode の settings.json などでも同様のことを実現できるのですが、下記の理由から EditorConfig を採用することにします。

  • EditorConfig をサポートする別のエディタを併用できる
  • 適用対象ファイルをパターンで指定することができる
  • フォルダ単位で設定ファイルを用意することができる (基本的にはルートフォルダーで全体設定するだけで事足りますが)

では、プロジェクトフォルダーに .editorconfig というファイルを作成し、下記の内容で保存してください。 (必要に応じて独自にカスタマイズしてください。)

root = true

[*]
end_of_line = lf
charset = utf-8
indent_size = 4
indent_style = space
trim_trailing_whitespace = true
insert_final_newline = true

[*.md]
trim_trailing_whitespace = false

一行目の root = true という記述は特別な設定です。これによって「この設定ファイルはルートフォルダーに配置されている」ということが宣言され、これより上位のフォルダーに EditorConfig 設定ファイルがあったとしても無視されるようになります。
あとはシンプルでわかりやすいと思うので説明は省きます。

なお、VSCode 独自の設定や拡張機能の設定は EditorConfig では設定できませんので settings.json で管理します。(後述)

Git Ignore の設定

Git Ignore は下記の gitignore.io という Web サイトで作成するのが手っ取り早いです。

今回は Node, react, Linux, VisualStudioCode を入力して作成しました。 (Stylus を含めると *.css が登録されてしまうのであえて除外)

では、プロジェクトフォルダーに .gitignore というファイルを作成し、下記の内容で保存してください。 (必要に応じて独自にカスタマイズしてください。)

内容
# Created by https://www.gitignore.io/api/node,react,linux,visualstudiocode
# Edit at https://www.gitignore.io/?templates=node,react,linux,visualstudiocode

### Linux ###
*~

# temporary files which can be created if a process still has a handle open of a deleted file
.fuse_hidden*

# KDE directory preferences
.directory

# Linux trash folder which might appear on any partition or disk
.Trash-*

# .nfs files are created when an open file is removed but is still being accessed
.nfs*

### Node ###
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*

# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

# Runtime data
pids
*.pid
*.seed
*.pid.lock

# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov

# Coverage directory used by tools like istanbul
coverage
*.lcov

# nyc test coverage
.nyc_output

# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt

# Bower dependency directory (https://bower.io/)
bower_components

# node-waf configuration
.lock-wscript

# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release

# Dependency directories
node_modules/
jspm_packages/

# TypeScript v1 declaration files
typings/

# TypeScript cache
*.tsbuildinfo

# Optional npm cache directory
.npm

# Optional eslint cache
.eslintcache

# Optional REPL history
.node_repl_history

# Output of 'npm pack'
*.tgz

# Yarn Integrity file
.yarn-integrity

# dotenv environment variables file
.env
.env.test

# parcel-bundler cache (https://parceljs.org/)
.cache

# next.js build output
.next

# nuxt.js build output
.nuxt

# rollup.js default build output
dist/

# Uncomment the public line if your project uses Gatsby
# https://nextjs.org/blog/next-9-1#public-directory-support
# https://create-react-app.dev/docs/using-the-public-folder/#docsNav
# public

# Storybook build outputs
.out
.storybook-out

# vuepress build output
.vuepress/dist

# Serverless directories
.serverless/

# FuseBox cache
.fusebox/

# DynamoDB Local files
.dynamodb/

# Temporary folders
tmp/
temp/

### react ###
.DS_*
**/*.backup.*
**/*.back.*

node_modules

*.sublime*

psd
thumb
sketch

### VisualStudioCode ###
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json

### VisualStudioCode Patch ###
# Ignore all local history of files
.history

# End of https://www.gitignore.io/api/node,react,linux,visualstudiocode

開発コンテナ

さあ、お待ちかねの コンテナハウス 開発コンテナです。
快適な環境を整えていきましょう。

開発コンテナの作成

開発コンテナは Remote-Container 拡張機能が用意しているコマンドで手っ取り早く作成することもできるのですが、下記の理由から本記事では手作業で作成します。

  • ベースイメージは本番環境と揃えたい
  • 拡張機能が用意する Node.js 開発用 Dockerfile と Docker in Docker 用 Dockerfile の2つの良いとこどりをしたい
  • 拡張機能が用意する Node.js 開発用 Dockerfile では、今回使用する一部の Node.js モジュールとの相性が悪い
  • 拡張機能が用意する Docker in Docker 用 Dockerfile では、イメージサイズが無駄に大きくなる

やはり 生活空間 開発環境はこだわらないと。
まずはプロジェクトフォルダーに .devcontainer フォルダーを作成してください。
大事なことなので画像貼っておきます。
image.png
このフォルダーの中に Dockerfiledevcontainer.json を作成します。
image.png

Dockerfile

Dockerfile は下記の内容で保存してください。

FROM node:12.16-buster-slim
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
    #
    # Verify git, process tools installed
    && apt-get -y install --no-install-recommends git openssh-client iproute2 procps \
    #
    #####
    # https://github.com/Microsoft/vscode-dev-containers/tree/master/containers/docker-in-docker#how-it-works--adapting-your-existing-dev-container-config
    # Note that no recommended packages are required, except for gnupg-agent.
    #
    # Install Docker CE CLI
    && apt-get install -y --no-install-recommends apt-transport-https ca-certificates curl software-properties-common lsb-release jq \
    && apt-get install -y gnupg-agent \
    && curl -fsSL https://download.docker.com/linux/$(lsb_release -is | tr '[:upper:]' '[:lower:]')/gpg | apt-key add - 2>/dev/null \
    && add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/$(lsb_release -is | tr '[:upper:]' '[:lower:]') $(lsb_release -cs) stable" \
    && apt-get update \
    && apt-get install -y --no-install-recommends docker-ce-cli \
    #
    # Install Docker Compose
    && curl -sSL "https://github.com/docker/compose/releases/download/$(curl https://api.github.com/repos/docker/compose/releases/latest | jq .name -r)/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose \
    && chmod +x /usr/local/bin/docker-compose \
    #####
    #
    # Clean up
    && apt-get autoremove -y \
    && apt-get clean -y \
    && rm -rf /var/lib/apt/lists/*
ENV DEBIAN_FRONTEND=dialog

これが開発コンテナの素です。
Docker がこの定義に従ってイメージを構築し、コンテナとして立ち上げてくれるのです。

ベースイメージは node:12.16-buster-slim です。後述の本番環境のベースイメージと揃えています。buster は Debian 10 のことで、Docker イメージ向けにスリムになったものが buster-slim です。これに Node.js 12.16 がインストールされています。

開発コンテナには更に、Git など開発に必要となるツールと Docker CE CLI、Docker Compose を追加インストールします。これらの追加ツールは、製品コードに直接影響しない補助ツールなのでバージョン指定を行っていません。Docker Compose についても、下記の記事を参考に最新版が自動で選択されるよう細工しています。

devcontainer.json

devcontainer.json は下記の内容で、{Workspace Name} 1箇所と {Project Name} 2箇所を適切に置き換えた上で保存してください。この際、大文字・小文字もしっかり合わせる必要があります。
本記事の場合、{Workspace Name}MoroMoro.Sample に、{Project Name}MoroMoro.Sample.Frontend になります。

// For format details, see https://aka.ms/vscode-remote/devcontainer.json or this file's README at:
// https://github.com/microsoft/vscode-dev-containers/tree/v0.101.1/containers/javascript-node-12
{
    "name": "Node.js 12",
    "dockerFile": "Dockerfile",
    // Set *default* container specific settings.json values on container create.
    "settings": {
        "terminal.integrated.shell.linux": "/bin/bash"
    },
    // Add the IDs of extensions you want installed when the container is created.
    "extensions": [
        "editorconfig.editorconfig",
        "mhutchie.git-graph",
        "eamodio.gitlens",
        "sysoev.language-stylus",
        "msjsdiag.debugger-for-chrome",
        "msjsdiag.debugger-for-edge",
        "dbaeumer.vscode-eslint",
        "haaleo.vscode-stylint",
        "thisismanta.stylus-supremacy",
        "firsttris.vscode-jest-runner"
    ],
    // Use 'forwardPorts' to make a list of ports inside the container available locally.
    "forwardPorts": [
        3000, // Main server
        8080, // HMR server
    ],
    "mounts": [
        // Use the Docker CLI from inside the container. See https://aka.ms/vscode-remote/samples/docker-in-docker.
        "source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind",
        // https://code.visualstudio.com/docs/remote/containers-advanced#_use-a-targeted-named-volume
        "source={Project Name}-node_modules,target=/workspaces/{Workspace Name}/{Project Name}/node_modules,type=volume"
    ],
    // Use 'postCreateCommand' to run commands after the container is created.
    "postCreateCommand": "npm install"
}

重要な設定項目についてだけ詳しく説明しておきます。

extensions

インストールする VSCode の拡張機能です。
コンテナイメージ作成時に自動でインストールしてくれます。
ホスト側にはインストールされませんので開発コンテナ内でのみ使用可能です。
拡張機能は ID で指定する必要がありますが、拡張機能サイドバーで拡張機能を右クリックして Copy Extension Id を実行すれば簡単に ID をコピーできます。

forwardPorts

ポートフォワーディング設定です。
コンテナ内でポート3000を使用してサーバーを立ち上げますので、ホスト側のブラウザからアクセスできるようにこの設定が必要となります。また、直接アクセスすることはありませんが、後述の HMR 用補助サーバーがポート8080を使用してこっそりブラウザとやり取りしますのでこちらも設定が必要です。

mounts

マウント設定です。二つ設定しています。

一つ目のマウント設定
Docker in Docker を実現するために必要です。
これにより、開発コンテナ内の Docker CLI がホストの Docker Desktop と接続され、正常に動作するようになります。

二つ目のマウント設定
node_modules フォルダー (Node モジュールが大量にインストールされるフォルダ) を Docker Desktop の名前付きボリュームという特別な領域にマウントするための設定です。
通常、ワークスペース内のファイルはホストとコンテナの間で自動的に共有されるのですが、ファイルアクセス速度はやや遅めです。基本的には全く問題無いレベルの遅延なのですが、node_modules フォルダーには大量のファイル作成が行われるため、この遅延によるパフォーマンス低下が顕著に現れてしまいます。そこで、node_modules フォルダーだけは例外的にホストではなく名前付きボリュームにマウントしてしまうわけです。

名前付きボリュームはコンテナの外の領域なので、コンテナを再作成しても削除されません。その代わり、別コンテナからも同じ名前付きボリュームにマウントすることができてしまいます。なので、名前が衝突しないよう MoroMoro.Sample.Frontend-node_modules というように面倒な名前付けを行う必要があります。

ちなみに、マウント先のパスの先頭 (パーティション名) は workspaces 固定です。workspaceFolder 設定及び workspaceMount 設定で変更することもできますが、非常にややこしいことになるのでやらないでください。本記事の、『マルチルートワークスペース』+『ワークスペース丸ごと Git 管理』+『コンテナ内から Git 操作』という構成は恐らく成立しなくなります。
ホスト側のワークスペース直下で Git Init を行い、workspaceFolder 設定及び workspaceMount 設定を変更していない2場合に限り、ホストのプロジェクトフォルダではなくワークスペースフォルダがマウントされ、コンテナ内から Git 操作できるようになります。

postCreateCommand

コンテナイメージ作成後に自動実行されるコマンドです。コンテナイメージはコンテナを初めて開くときに作成されます。
npm install というコマンドは package.json に従って Node モジュールのインストールを行うコマンドです。この後コンテナを開きますが、その時点では package.json がないのでこのコマンドはあまり意味がありません。しかし、package.json 作成完了後であれば、チームメンバーが Git からソースコード一式を手に入れて開発コンテナを開くとそれだけで最初から開発環境が完璧に揃って提供されることになります。

開発コンテナを開く

ついに開発コンテナが用意できましたね…。早速開きましょう。
開き方はいくつかあるのですが、本記事では Remote Explorer から開く方法をお勧めします。他の方法より手数が多い方法なのですが、Remote Explorer に慣れておけば後々便利です。
まだ一度も開いていないコンテナを開くには、Remote Explorer の右上の [+] ボタンを押し、Open Folder in Container... を選択します。
image.png
フォルダー選択ダイアログが開きますのでプロジェクトフォルダーを選択して開きます。
すると右下にメッセージが表示されコンテナイメージの作成が開始します。
image.png
作成が完了するとメッセージが消えます。
問題無ければこの時点でコンテナの中に入れているはずです。コンテナの中にいる時はステータスバーの左端に Dev Container: Node.js 12 と表示されます。
image.png

コンテナの中でプロジェクトフォルダーを開いたことにより、コンテナ内ではプロジェクトフォルダーがワークスペースそのものとなります。
本記事では、コンテナ内でのワークスペースのことをコンテナワークスペースと呼ぶことにします。

Docker ソケットのマウント確認

ターミナルから次のコマンドを実行し、正常にイメージ一覧を取得できることを確認してください。

docker images

エラーが発生してしまう場合は開発コンテナの準備に不備があったということですので設定を見直してください。
修正したら、開発コンテナをリビルドします。

node_modules のマウント確認

コンテナワークスペースに空の node_modules フォルダーが作成されていることを確認してください。
node_modules フォルダが作成されていない場合は開発コンテナの準備に不備があったということですので設定 (特にワークスペース名やプロジェクト名の誤字脱字、workspacesWorkspacesworkspace になっていないかなど) を見直してください。 (或いは前述の Git Init を行っていない場合にもマウントが正常に行えません。Git Init を行っていないと、コンテナワークスペースが /workspaces/{Workspace Name}/{Project Name} ではなく /workspaces/{Project Name} に配置されてしまいます。使い捨てで Git 管理しない場合でも Git Init だけは必ず行ってください。)
修正したら、開発コンテナをリビルドします。

なお、前述の通りこの node_modules フォルダーはホスト側にはファイルを一切作成しません。逆にホスト側で node_modules フォルダー内にファイルを作成しても、コンテナ側には共有されません。ただし、node_modules フォルダーそのものの削除を行ってしまうとお互い連動して削除されてしまいます。ホスト側では中身が無いからといって、くれぐれもフォルダーそのものを削除しないよう注意しましょう。

開発コンテナのリビルド

開発コンテナの準備に不備があった場合には、修正してリビルドを行ってください。
リビルドする方法もいくつかあるのですが、やはり Remote Explorer で行う方法をお勧めします。
Remote Explorer にコンテナとフォルダーが登録されていますので、コンテナを右クリックして Rebuild Container を実行するとリビルドが行われます。完了後は自動でコンテナが開きなおされます。
image.png
コンテナのリビルドはコンテナ内にいる時にしか実行できません。
コンテナ外にいるときは単純にコンテナを削除してしまえば、次回コンテナを開くときにコンテナが自動で作成されます。
image.png

開発コンテナを閉じる

VSCode を終了すればコンテナは停止されます。(ただし、devcontainer.json で "shutdownAction": "none" を設定している場合は停止されません。)
次回 VSCode 起動時には自動で開発コンテナが開かれます。

VSCode を終了させずにコンテナを閉じる時は、メニューバーの [File] - [Close Remote Connection] を実行します。(Connection といいつつ接続だけでなくコンテナも停止します。)
image.png
再び開発コンテナを開くには、1回目と同じ方法でも開くことができるのですが、もっと楽な手順で開くこともできます。Remote Explorer にコンテナとフォルダーが登録されていますので、フォルダーの右端の image.png (Open Folder in Container) ボタンを押せば一発で開けます。
image.png

無事開発コンテナを開けたら

ここからはもう、ずっと、最後まで、とことん、開発コンテナにひきこもります。

パッケージの作成

さて、まずはプロジェクトのパッケージ化を行いましょう。
パッケージ化すると、パッケージのビルドや実行などのスクリプトを登録できたり、パッケージが依存する Node モジュールを簡単に管理できたりします。

package.json の作成

コンテナワークスペースに package.json ファイルを作成し、下記の内容で {project name} 2箇所と {Your Name} 1箇所を適切に置き換えた上で保存してください。{project name} では大文字が禁止されているので全て小文字で記述します。
本記事の場合、{project name}moromoro.sample.frontend に、{Your Name}Kenji Yokoyama になります。
(npm init は今回使いません。下記を貼り付けて置換した方が手っ取り早いので。)

{
    "name": "{project name}",
    "version": "1.0.0",
    "description": "{project name}",
    "scripts": {
        "start": "     export NODE_ENV=production  && ts-node ./src/server/server.ts",
        "build": "     export NODE_ENV=production  && webpack --config ./webpack.config.ts",
        "build:dev": " export NODE_ENV=development && webpack --config ./webpack.config.ts",
        "run": "       export NODE_ENV=production  && npm run build     && npm start",
        "run:dev": "   export NODE_ENV=development && npm run build:dev && ts-node-dev --nolazy --inspect=9229 ./src/server/server.ts",
        "run:hmr": "   export NODE_ENV=development && export HMR=true   && concurrently \"ts-node-dev --nolazy --inspect=9229 ./src/server/server.ts\" \"webpack-dev-server --config ./webpack.config.hmr.ts\"",
        "test": "      export NODE_ENV=development && jest --coverage"
    },
    "author": "{Your Name}",
    "license": "UNLICENSED",
    "private": true
}

フロントエンド開発のための非公開パッケージですので、"license": "UNLICENSED""private": true を設定しておきます。
scripts に登録した各スクリプトについては後ほど適宜説明していきます。

Node モジュールのインストール

続いて Node モジュールのインストールです。VSCode のターミナルにて、モジュール名を指定して npm install コマンドを実行します。依存モジュールがある場合は、基本的に全て自動で追加インストールされます。

まずは本番環境用モジュールをインストールします。

本番環境用モジュールのインストールには -S オプションを使用します。

npm install -S typescript ts-node express @types/express log4js node-fetch @types/node-fetch

次は開発環境用モジュールのインストールです。

開発環境用モジュールのインストールには -D オプションを使用します。
実行環境用にインストールしたモジュールを改めてインストールする必要はありません。

npm install -D ts-node-dev stylus @types/stylus eslint eslint-plugin-react @typescript-eslint/eslint-plugin @typescript-eslint/parser stylint react @types/react react-dom @types/react-dom react-router-dom @types/react-router-dom react-hot-loader @hot-loader/react-dom webpack @types/webpack webpack-cli webpack-merge @types/webpack-merge webpack-dev-server @types/webpack-dev-server ts-loader style-loader css-loader stylus-loader url-loader file-loader @types/file-loader clean-webpack-plugin html-webpack-plugin @types/html-webpack-plugin concurrently @types/concurrently jest @types/jest ts-jest fetch-mock @types/fetch-mock @testing-library/react @testing-library/react-hooks @testing-library/jest-dom jest-css-modules

手動インストールが必要なモジュール (代替モジュールがあるもの) がいくつか報告されましたので追加でインストールします。

npm install -D react-test-renderer @types/react-test-renderer canvas bufferutil utf-8-validate

インストールは node_modules フォルダーに対して行われます。
コンテナワークスペースの node_modules フォルダーに大量のモジュールフォルダーが追加されていることを確認しておきましょう。
この時、ホスト側の node_modules には一切ファイルが追加されていないことが重要です。せっかくひきこもったのですが、念のため 偵察ドローンを飛ばして外の様子を Windows Explorer でホスト側の node_modules フォルダーが空であることを確認しておいてください。

なお、インストールに成功するとインストールされたモジュールのバージョン情報が package.json の dependenciesdevDependencies に記録されます。
更に、追加でインストールされた間接的な依存モジュールも含む全ての依存モジュールの情報が package-lock.json に記録されます。
package.json と package-lock.json によって依存モジュールのバージョンが固定されるので、モジュールの再インストールをしても全く同じ環境を復元することができます。

コンテナワークスペース用 VSCode 設定

コンテナワークスペースに .vscode フォルダを作成し、下記の内容の settings.json ファイルを作成してください。

{
    "editor.formatOnSave": true,
    "editor.formatOnPaste": true,
    "editor.formatOnType": true,
    "[stylus]": {
        "editor.formatOnType": false
    },
    "files.associations": {
        "*.stylintrc": "jsonc"
    },
    "eslint.format.enable": true,
    "jestrunner.debugOptions": {
        "skipFiles": [
            "<node_internals>/**/*.js",
            "node_modules/"
        ]
    }
}

上から3つの設定はフォーマッタの基本設定です。セーブ時、ペースト時、タイピング時 (主に改行時) に自動フォーマットが行われるように設定しています。
残りの設定は ESLint、Stylint、Jest の 設定となりますが、これらについては後述します。

TypeScript の設定

TypeScript の設定を行っておきます。
コンテナワークスペースに tsconfig.json を作成し、下記の内容で保存してください。

{
    "compilerOptions": {
        "baseUrl": "src",
        "jsx": "react",
        "moduleResolution": "node",
        "noEmitOnError": true,
        "noFallthroughCasesInSwitch": true,
        "noImplicitReturns": true,
        "noUnusedLocals": true,
        "noUnusedParameters": false, // Resolving with an underscore when a parameter cannot be removed, may leave the one when the parameter is used again.
        "removeComments": true,
        "resolveJsonModule": true,
        "strict": true,
        /* Applied only to client scripts. */
        "module": "CommonJS",
        "target": "es5",
        "sourceMap": true
    },
    "exclude": [
        "node_modules"
    ]
}

本記事では各項目の説明は省略させていただきますが、ソースコードチェックができるだけしっかり行われるよう設定してあります。例えば使われていないローカル変数があったり型の指定がされていなかったりしたらコンパイルエラーになります。
ただし、引数については使われていないものがあってもエラーにならないようにしてあります。引数は削除できない場合が多々あるからです。(その場合、引数名にアンダースコアを付けることでエラー回避できるのですが、後から引数を使うようになった時にアンダースコアを削除するようメンバーに徹底しきれなかったりするので。)

Lint の設定

Lint は設定した独自のコーディングルールに基づいてソースコードの詳細なチェックを行ってくれるツールです。命名規則や空白の使い方、1ファイルあたりの最大行数など、様々なルールを設定できます。
本記事では私が考えるコーディングルールを設定していますが、これを叩き台に適宜ルールを変更していただければと思います。

ESLint

コンテナワークスペースに .eslintrc ファイルを作成し、下記の内容で保存してください。

内容
{
    "env": {
        "browser": true,
        "es6": true,
        "node": true
    },
    "extends": [
        "eslint:recommended",
        "plugin:react/recommended",
        "plugin:@typescript-eslint/eslint-recommended"
    ],
    "globals": {
        "Atomics": "readonly",
        "SharedArrayBuffer": "readonly"
    },
    "parser": "@typescript-eslint/parser",
    "parserOptions": {
        "ecmaFeatures": {
            "jsx": true
        },
        "sourceType": "module"
    },
    "plugins": [
        "react",
        "@typescript-eslint"
    ],
    "rules": {
        "max-len": [
            "off",
            80
        ],
        "max-lines": [
            "warn",
            300
        ],
        "max-statements": [
            "warn",
            30
        ],
        "max-params": [
            "warn",
            5
        ],
        "max-depth": [
            "warn",
            4
        ],
        "max-nested-callbacks": [
            "warn",
            {
                "max": 5
            }
        ],
        "require-jsdoc": [
            "warn",
            {
                "require": {
                    "FunctionDeclaration": true,
                    "MethodDefinition": true,
                    "ClassDeclaration": true,
                    "ArrowFunctionExpression": false,
                    "FunctionExpression": false
                }
            }
        ],
        "indent": [
            "error",
            4,
            {
                "SwitchCase": 1
            }
        ],
        "quotes": [
            "error",
            "double",
            {
                "avoidEscape": true
            }
        ],
        "semi": [
            "error",
            "always"
        ],
        "no-multiple-empty-lines": [
            "error",
            {
                "max": 2,
                "maxBOF": 0,
                "maxEOF": 0 // It allows one empty line.
            }
        ],
        "brace-style": [
            "error",
            "1tbs",
            {
                "allowSingleLine": false
            }
        ],
        "max-statements-per-line": [
            "error",
            {
                "max": 1
            }
        ],
        "one-var": [
            "error",
            "never"
        ],
        "one-var-declaration-per-line": [
            "error",
            "always"
        ],
        "comma-style": [
            "error",
            "last"
        ],
        "dot-location": [
            "error",
            "property"
        ],
        "no-useless-computed-key": [
            "error",
            {
                "enforceForClassMembers": true
            }
        ],
        "object-property-newline": [
            "error",
            {
                "allowAllPropertiesOnSameLine": true
            }
        ],
        "padded-blocks": [
            "error",
            "never"
        ],
        "wrap-iife": [
            "error",
            "inside"
        ],
        "camelcase": "error",
        "no-unused-vars": "off",
        "yoda": "error",
        "curly": "error",
        "arrow-spacing": "error",
        "arrow-parens": [
            "error",
            "as-needed",
            {
                "requireForBlockBody": true
            }
        ],
        "prefer-arrow-callback": "error",
        "object-curly-spacing": [
            "error",
            "always"
        ],
        "rest-spread-spacing": [
            "error",
            "never"
        ],
        "template-curly-spacing": "error",
        "block-spacing": "error",
        "array-bracket-spacing": "error",
        "semi-spacing": "error",
        "space-before-blocks": "error",
        "space-in-parens": "error",
        "key-spacing": "error",
        "keyword-spacing": "error",
        "space-infix-ops": "error",
        "comma-spacing": "error",
        "func-call-spacing": "error",
        "space-unary-ops": "error",
        "spaced-comment": "error",
        "use-isnan": "error",
        "new-parens": "error",
        "constructor-super": "off", // It is not needed, because VSCode already has the checker.
        "no-fallthrough": "error",
        "no-iterator": "error",
        "no-new-wrappers": "error",
        "no-path-concat": "error",
        "no-self-compare": "error",
        "no-throw-literal": "error",
        "no-undef-init": "error",
        "no-unreachable": "error",
        "no-unsafe-finally": "error",
        "no-unsafe-negation": "error",
        "no-useless-call": "error",
        "no-whitespace-before-property": "error",
        "eqeqeq": "error"
    }
}

設定できるルールについては下記のドキュメントから確認できます。

ESLint はソースコードのチェックだけでなく、一部のルールに対する自動修正機能を含んでいます。
コンテナワークスペース用 VSCode 拡張機能の設定で行った下記の設定によって、ファイル保存時や改行時などに ESLint による自動修正が行われるようになります。

    "eslint.format.enable": true,

Stylint

コンテナワークスペースに .stylintrc ファイルを作成し、下記の内容で保存してください。

内容
{
    "blocks": false,
    "brackets": "never",
    "colons": "always",
    "colors": "always",
    "commaSpace": "always",
    "commentSpace": "always",
    "cssLiteral": "never",
    "customProperties": [],
    "depthLimit": false,
    "duplicates": true,
    "efficient": false,
    "exclude": [],
    "extendPref": "@extends",
    "globalDupe": false,
    "groupOutputByFile": true,
    "indentPref": 4,
    "leadingZero": "always",
    "maxErrors": false,
    "maxWarnings": false,
    "mixed": true,
    "mixins": [],
    "namingConvention": "camelCase",
    "namingConventionStrict": true,
    "none": "never",
    "noImportant": true,
    "parenSpace": "never",
    "placeholders": false,
    "prefixVarsWithDollar": "always",
    "quotePref": "double",
    "reporterOptions": {
        "columns": [
            "lineData",
            "severity",
            "description",
            "rule"
        ],
        "columnSplitter": "  ",
        "showHeaders": false,
        "truncate": true
    },
    "semicolons": "never",
    "sortOrder": false,
    "stackedProperties": "never",
    "trailingWhitespace": "never",
    "universal": false,
    "valid": true,
    "zeroUnits": false,
    "zIndexNormalize": false
}

設定できるルールについては下記のドキュメントから確認できます。

Stylint のルールチェックは Stylint 拡張機能、自動フォーマットは Manta's Stylus Supremacy 拡張機能が行ってくれます。ただ、自動フォーマットはやや強力すぎる (ルール違反を一瞬たりとも許さず、単に次の項目を入力するために改行しただけでも消し去られます) ので、コンテナワークスペース用 VSCode 拡張機能の設定で行った下記の設定によってタイピング時の自動フォーマットを無効化しています。

    "[stylus]": {
        "editor.formatOnType": false
    },

また、.stylintrc ファイルは、そのままでは VSCode が JSON ファイルとして認識してくれないため、コンテナワークスペース用 VSCode 拡張機能の設定で行った下記の設定によって言語モードに JSON with Comments を設定しています。

    "files.associations": {
        "*.stylintrc": "jsonc"
    },

<注意>
stylint とよく似た名前の stylelint という Lint 系ツールがありますが、これは Stylus ではなく CSS や Sass の Lint ツールです。本記事の構成では Stylus しか使用しないため不要です。

WebPack の設定

WebPack はモジュールバンドラです。
開発したソースコードや画像などのファイル群をリリース用にバンドルしてくれます。
特にソースコードについては、TypeScript からコンパクトな JavaScript へのトランスパイルや、トランスパイルされた JavaScript を呼び出すコードを HTML に埋め込んでくれたりします。
更に、画像ファイルなども全て JavaScript コード化してひとまとめにすることができます。(ひとまとめにせず独立したファイルのまま含めることもできます。)

設定に使用できるファイルフォーマットは複数ありますが、本記事では強力なコード補完機能の恩恵を受けられる TypeScript にて記述します。

基本設定

コンテナワークスペースに webpack.config.ts ファイルを作成し、下記の内容で保存してください。

import * as webpack from "webpack";
import { CleanWebpackPlugin } from "clean-webpack-plugin";
import * as HtmlWebpackPlugin from "html-webpack-plugin";
import * as path from "path";

const IS_DEV = (process.env.NODE_ENV === "development");

const config: webpack.Configuration = {
    mode: !IS_DEV ? "production" : "development",
    devtool: !IS_DEV ? false : "source-map",
    entry: [
        "./src/client/index.tsx"
    ],
    output: {
        filename: "bundle.js",
        path: path.resolve(__dirname, "dist")
    },
    resolve: {
        extensions: [".js", ".ts", ".tsx", ".styl"],
        modules: [
            path.resolve(__dirname, "src/client"),
            path.resolve(__dirname, "node_modules")
        ],
        alias: {
            "react-dom": "@hot-loader/react-dom",
        },
    },
    module: {
        rules: [
            {
                test: /\.tsx?$/,
                use: [
                    "react-hot-loader/webpack",
                    "ts-loader"
                ]
            },
            {
                test: /\.styl$/,
                use: [
                    "style-loader",
                    {
                        loader: "css-loader",
                        options: {
                            importLoaders: 1,
                            sourceMap: IS_DEV,
                            modules: {
                                localIdentName: !IS_DEV ? "[hash:base32]" : "[path][name]__[local]",
                            }
                        }
                    },
                    "stylus-loader"
                ]
            },
            {
                test: {
                    not: [
                        /\.html?$/,
                        /\.jsx?$/,
                        /\.tsx?$/,
                        /\.styl$/
                    ]
                },
                use: {
                    loader: "url-loader",
                    options: {
                        /* Every file exceeding the size limit is deployed as a file with a name of the indicated rule. */
                        limit: 51200,
                        name: !IS_DEV ? "[hash:base32].[ext]" : "[path][name].[ext]"
                    }
                }
            },
            {
                test: /favicon\.ico$/,
                use: "file-loader?name=[name].[ext]"
            },
        ],
    },
    plugins: [
        new CleanWebpackPlugin({
        }),
        new HtmlWebpackPlugin({
            template: "./src/client/index.html",
            filename: "./index.html"
        })
    ]
};

export default config;

細かく説明すると非常に長くなってしまうので、要点をまとめておきます。

  • ビルド時にセットされる NODE_ENV 環境変数 (productiondevelopment のいずれか) に従い、設定を切り替える
  • development モード時はデバッグ補助用にソースマップ (トランスパイル前後のソースコードの紐付け情報) を生成する
  • バンドル前のエントリーポイントは ./src/client/index.tsx
  • バンドル後のエントリーポイントは bundle.js
  • 出力フォルダは dist
  • バンドルするソースコードが配置されているフォルダーは src/clientnode_modules
  • react-dom モジュールをインポートしようとすると代わりに @hot-loader/react-dom モジュールがインポートされる (後述の HMR で必要となる)
  • TypeScript ファイル (.ts or .tsx) は次のローダーを使ってバンドルを行う
    • ts-loader : TS から JS へのトランスパイル
    • react-hot-loader/webpack : 後述の HMR で必要となる
  • Stylus ファイル (.styl) は次のローダーを使ってバンドルを行う
    • stylus-loader : Stylus から CSS へのトランスパイル
    • css-loader : CSS のクラス名を衝突回避のためユニークな名前に変換する (後述の CSS Modules)
    • style-loader : CSS を JS で動的に出力する
  • HTML、JavaScript、TypeScript、Stylus 以外のファイルは、50KB 以下なら JS に直接埋め込み、50KB 以上ならファイル名を一意に変更した上で独立ファイルとしてバンドルする
  • favicon.ico はブラウザが名指しで直接取得しにくるので、ファイル名を維持して独立ファイルとしてバンドルする
  • CleanWebpackPlugin を使用し、バンドル処理開始時に前回の出力結果を全て削除する
  • HtmlWebpackPlugin を使用し、バンドル後の JS ファイル (bundle.js) を呼び出すコードを index.html に埋め込む

HMR 用補助サーバーの設定

HMR (Hot Module Replacement) というのは、開発時、ブラウザで Web アプリの動作を確認している時にソースコードを変更しても、サーバーの再起動もブラウザのリロードも行うことなく変更内容がブラウザに自動反映されるという機能です。(複雑な変更は追従しきれない場合があり、その場合は手動でリロードするようブラウザに表示されます。)

HMR の実現を補助する開発用サーバーが WebPack に用意されていますので、ここではそのサーバー設定を行います。HMR 利用時以外は不要なサーバーなので、基本設定とは別のファイルにします。(webpack-merge を使用して基本設定を HMR 用設定にマージします。)

コンテナワークスペースに webpack.config.hmr.ts ファイルを作成し、下記の内容で保存してください。

import * as webpack from "webpack";
import * as merge from "webpack-merge";
import config from "./webpack.config";
import "webpack-dev-server";

const hmrConfig: webpack.Configuration = merge(config, {
    devServer: {
        host: "localhost",
        port: 8080,
        contentBase: "src/client",
        historyApiFallback: true,
        inline: true,
        hot: true,
        open: false
    }
});

export default hmrConfig;

CSS Modules や画像ファイルを TypeScript で利用可能にする

TypeScript では型定義のないモジュールをインポートして使用するとエラーになってしまいます。
解決方法はいくつかありますが、本記事では手っ取り早く下記の定義を追加します。

  • 全ての Stylus ファイルに対して、string 配列がエクスポートされたモジュールとして型定義を追加
  • 全てのファイル (型定義が見つからなかった場合に限る) に対して、Any 型の値がデフォルトエクスポートされたモジュールとして型定義を追加

コンテナワークスペースに modules.d.ts ファイルを作成し、下記の内容で保存してください。

declare module "*.styl" {
    const classNames: {
        [className: string]: string
    };
    export = classNames;
}

declare module "*" {
    const value: any;
    export default value;
}

Jest の設定

Jest はテスティングフレームワークです。
本記事では Jest と React Testing Framework を組み合わせることで React コンポーネントのユニットテストを行います。

コンテナワークスペースに jest.ts ファイルを作成し、下記の内容で保存してください。

{
    "preset": "ts-jest",
    "moduleNameMapper": {
        "\\.(css|styl)$": "<rootDir>/node_modules/jest-css-modules"
    }
}

Jest で TypeScript をテストできるようにするため presetts-jest を設定します。
また、本来 WebPack (CSS Loader) を通さなければ処理できない CSS Modules (後述) という特殊なインポート方法を、WebPack を介さない Jest でも最低限エラー発生を回避して処理できるよう、moduleNameMapperjest-css-modules を設定しています。

デバッグ設定

次の5種類のデバッグを行えるよう設定を行います。

  • Chrome 上で動作しているクライアントサイドコードのデバッグ
  • Edge 上で動作しているクライアントサイドコードのデバッグ
  • サーバーサイドコードのデバッグ (既に起動しているサーバープロセスにアタッチしてデバッグ)
  • サーバーサイドコードのデバッグ (サーバープロセスを起動してデバッグ)
  • Jest でテスト実行しながらデバッグ

コンテナワークスペースに .vscode フォルダを作成し、下記の内容の launch.json ファイルを作成してください。

{
    // Use IntelliSense to learn about possible attributes.
    // Hover to view descriptions of existing attributes.
    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Debug Client (Chrome)",
            "type": "chrome",
            "request": "launch",
            "trace": true,
            "sourceMaps": true,
            "url": "http://localhost:3000",
            "webRoot": "${workspaceFolder}",
            "sourceMapPathOverrides": {
                "webpack:///*": "${workspaceFolder}/*"
            },
            "skipFiles": [
                "<node_internals>/**/*.js",
                "node_modules"
            ]
        },
        {
            "name": "Debug Client (Edge)",
            "type": "edge",
            "request": "launch",
            "trace": true,
            "sourceMaps": true,
            "url": "http://localhost:3000",
            "webRoot": "${workspaceFolder}",
            "sourceMapPathOverrides": {
                "webpack:///*": "${workspaceFolder}/*"
            },
            "skipFiles": [
                "<node_internals>/**/*.js",
                "node_modules"
            ]
        },
        {
            "name": "Debug Server (Attach)",
            "type": "node",
            "request": "attach",
            "cwd": "${workspaceFolder}",
            "port": 9229,
            "protocol": "inspector",
            "internalConsoleOptions": "openOnSessionStart",
            "skipFiles": [
                "<node_internals>/**/*.js",
                "node_modules"
            ]
        },
        {
            "name": "Debug Server (Launch)",
            "type": "node",
            "request": "launch",
            // "preLaunchTask": "npm: build:dev",
            "runtimeArgs": [
                "--nolazy",
                "-r",
                "ts-node/register"
            ],
            "args": [
                "${workspaceFolder}/src/server/server.ts"
            ],
            "cwd": "${workspaceFolder}",
            "protocol": "inspector",
            "internalConsoleOptions": "openOnSessionStart",
            "env": {
                "TS_NODE_IGNORE": "false"
            },
            "skipFiles": [
                "<node_internals>/**/*.js",
                "node_modules"
            ]
        }
    ]
}

Jest のデバッグ実行は Jest Runner 拡張機能を使用して行うため launch.json では設定できません。代わりにコンテナワークスペース用 VSCode 拡張機能の設定で行った下記の設定によって Jest のデバッグ設定を行っています。

    "jestrunner.debugOptions": {
        "skipFiles": [
            "<node_internals>/**/*.js",
            "node_modules/"
        ]
    },

実装

ようやくここまで辿り着きました。(本記事を書き始めて1週間)
ここからは実装を行っていきます。

本記事では、ハードコーディングされた "Hello World." というメッセージを表示する Home ページと、サーバーに実装した hello API から取得した "Hello World!" というメッセージを動的に表示する Home Work ページを用意し、これらをメインページ内でタブ切り替えのように行き来できるようにします。二つのページには異なる URL を割り当てますので、URL 直打ちで最初から Home Work ページを表示させることもできます。
ezgif-1-7ea4b3d7020e.gif
Home Work ページではメッセージを動的に取得していることがわかりやすいよう、待機中は "Loading..." と表示しています。
あとデザインがクソダサいですが気にしないように。

ソースコードフォルダーの作成

コンテナワークスペースに下記のフォルダー構造を作成しておきます。

  • コンテナワークスペース
    • src
      • client
      • server

本記事ではこれ以上深いフォルダーはあえて作成しませんが、実開発ではフォルダー構成は重要です。
基本的な考え方として下記のページが参考になるかと思います。(後半を読み飛ばさないように)

サーバーサイド:loggers.ts

log4js を使用してシステムログ用のロガーとアクセスログ用のロガーを実装します。 (クライアントサイドはブラウザ上で実行されるためログは取れません。)
log4js の公式ドキュメントは下記にあります。

src/server フォルダーに loggers.ts ファイルを作成し、下記の内容で保存してください。

import * as log4js from "log4js";

const IS_DEV = process.env.NODE_ENV === "development";

log4js.configure({
    appenders: {
        "system_console": {
            type: "console",
            layout: {
                type: "pattern",
                pattern: "%[[%d] [%p]%] %c - %m [%f:%l:%o]"
            },
        },
        "system_file": {
            type: "file",
            filename: "logs/system/system.log",
            maxLogSize: 5 * 1024 * 1024,
            backups: 5,
            compress: true,
            layout: {
                type: "pattern",
                pattern: "[%d] [%p] %c - %m [%f:%l:%o]"
            },
        },
        "access_console": {
            type: "console",
        },
        "access_file": {
            type: "dateFile",
            filename: "logs/access/access.log",
            pattern: "yyyy-MM-dd",
            alwaysIncludePattern: true,
            keepFileExt: true,
            compress: true,
            daysToKeep: 5,
        }
    },
    categories: {
        "default": {
            appenders: ["system_console"],
            level: !IS_DEV ? "info" : "all",
            enableCallStack: true,
        },
        "system": {
            appenders: ["system_console", "system_file"],
            level: !IS_DEV ? "info" : "all",
            enableCallStack: true,
        },
        "access": {
            appenders: !IS_DEV ? ["access_file"] : ["access_console", "access_file"],
            level: !IS_DEV ? "info" : "all",
        }
    }
});

export const defaultLogger = log4js.getLogger();
export const systemLogger = log4js.getLogger("system");
export const accessLogger = log4js.getLogger("access");
export const accessLogConnector = log4js.connectLogger(accessLogger, { level: "auto" });

システムログは logs/system フォルダーに保存されます。
ログサイズが 5MB を超えたらログを圧縮してローテーションを行うように設定しています。

アクセスログは logs/access フォルダーに保存されます。
毎日ログを圧縮してローテーションを行うように設定しています。

また、NODE_ENV 環境変数が development の時 (開発時) は全てのレベルのログを出力し、production の時 (本番) は fatalerrorwarninfo のログを出力します。

サーバーサイド:server.ts

Express を使用してサーバーを実装します。
Express の公式ドキュメントは下記にあります。

src/server フォルダーに server.ts ファイルを作成し、下記の内容で保存してください。

import * as express from "express";
import * as process from "process";
import * as path from "path";
import fetch from "node-fetch";
import { systemLogger as logger, accessLogConnector } from "./loggers";

const clientRootPath = "dist";
const clientRootAbsolutePath = path.join(process.cwd(), clientRootPath);

const server = express();

server.use(accessLogConnector);
server.use(express.static(clientRootPath));

server.get("/api/hello", (req, res) => {
    res.send({ message: "Hello World!" });
});

server.get("*", (req, res) => {
    if (process.env.HMR === "true") {
        fetch(
            `http://localhost:8080${req.originalUrl}`,
            {
                method: req.method,
                headers: req.headers as { [key: string]: string }
            }
        ).then(innerRes => new Promise((resolve, reject) => {
            innerRes.body.pipe(res);
            res.on("close", resolve);
            res.on("error", reject);
        }));
        return;
    }
    res.sendFile("index.html", { root: clientRootAbsolutePath });
});

server.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
    logger.error(err);
    next(err);
});

server.listen(3000, () => {
    logger.info("server running");
});

dist フォルダー内の静的ファイルへのアクセスについてはそのまま該当のファイルを返します。

サンプル実装として、/api/hello という URL にアクセスされたら { message: "Hello World!" } という JSON データを返すようにしています。実際の開発では、リバースプロキシ化してバックエンドサービスに処理を委譲することが多いかと思います。

上記のいずれにも当てはまらない場合、通常は index.html を返します。ここまで特に触れませんでしたが、本記事で構築するのは SPA (Single Page Application) と呼ばれる形式のアプリケーションで、クライアント内で完結するルーティングを行えるため、サーバーはとにかく index.html を返してあげる必要があります。
ただし、HMR 環境変数が true の場合には HMR 用補助サーバーへの簡易リバースプロキシとして動作します。HMR 用補助サーバーが静的コンテンツをホスティングするためです。

また、エラーハンドラを追加してエラーをシステムログに記録するようにしてあります。

そして最後にポート番号 3000 を使用してサーバーを起動しています。

クライアントサイド:index.html

コンテンツは React で実装していきますので index.html は非常にコンパクトです。

src/client フォルダーに index.html ファイルを作成し、下記の内容で保存してください。

<!DOCTYPE html>
<html>

<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Sample</title>
</head>

<body>
    <div id="root"></div>
</body>

</html>

body には id="root" を設定した div 要素を配置するのみです。
React がこの div 要素に対して動的にコンポーネントをレンダリングします。

クライアントサイド:index.tsx

index.tsx はエントリーポイントです。ここからクライアントサイドの処理が開始されます。

src/client フォルダーに index.tsx ファイルを作成し、下記の内容で保存してください。

import { hot } from "react-hot-loader/root"; // Must be imported before "react" and "react-dom".
import * as React from "react";
import * as ReactDOM from "react-dom";
import { BrowserRouter } from "react-router-dom";
import App from "./App";

const Root = () => {
    return (
        <BrowserRouter>
            <App />
        </BrowserRouter>
    );
};

ReactDOM.render(<Root />, document.getElementById("root"));

export default hot(Root);

エントリーポイントでは、HMR に対応するための細工と、React Router (後述) に対応するための細工を行います。具体的なコンテンツの実装は、次に実装する App コンポーネントから行っていきます。
具体的には、React Router DOM の BrowserRouter という特殊なコンポーネントで App コンポーネントをラップし Root コンポーネントとして定義し、更にその Root コンポーネントを React Hot Loader の hot 関数でラップしたものを、先ほど index.html に配置した div 要素に対してレンダリングしています。

なお、NODE_ENV 環境変数が production の時には hot 関数は何も行わず引数で受け取ったコンポーネントをそのまま返しますので、hot 関数は除去せずそのままリリースして大丈夫です。

<補足>
TypeScript の中に HTML のタグのような記述が混ざっていることに戸惑う人もいるかと思います。これは React の JSX という機能で、HTML のようなタグ構文を使用してオブジェクト (仮想 DOM コンポーネント) の生成を行うことができます。(トランスパイルすると React.createElement() を呼び出す普通の JavaScript コードに変換されます。その関係で、import * as React from "react"; を必ず記述しておく必要があります。)
上述の Root 関数の場合、App コンポーネントを生成し、さらにそれを子要素として渡して BrowserRouter コンポーネントを生成しています。この Root 関数も仮想 DOM を生成して返す関数ですので、ReactDOM.render() の引数部分のように JSX 構文で Root コンポーネントを生成することができます。

クライアントサイド:App.tsx

ここからが UI を作りこんでいくメインプログラミングとなります。

App コンポーネント (メインページ) は、 自コンポーネント内に Home コンポーネント (Home ページ) を表示する "Home" リンクと、同じく自コンポーネント内に HomeWork コンポーネント (Home Work ページ) を表示する "Home Work" リンクを持ちます。リンクをクリックしても App コンポーネント自体は消えたり再読み込みされたりせず、Home コンポーネントと HomeWork コンポーネントの切り替えだけが行われます。どちらのコンポーネントも JavaScript コード自体は最初からブラウザに読み込まれていますので、切り替え時にサーバー通信は発生しません。(HomeWork コンポーネントの実装がサーバーの API を叩くのでそれに関しての通信は発生しますが。)

また、Home は "/" に、HomeWork は "/homework" にルーティングしています。これによりユーザーが URL をブックマークに登録してショートカット表示するということが可能になります。

src/client フォルダーに App.tsx ファイルを作成し、下記の内容で保存してください。

import * as React from "react";
import { Switch, Route, Link } from "react-router-dom";

import "./favicon.ico";
import * as styles from "./App.styl";
import Home from "./Home";
import HomeWork from "./HomeWork";

const App = () => {
    return (
        <>
            <Link className={styles.menuButton} to="/">Home</Link>
            <Link className={styles.menuButton} to="/homework">Home Work</Link>
            <Switch>
                <Route exact path="/" component={Home} />
                <Route exact path="/homework" component={HomeWork} />
            </Switch>
        </>
    );
};

export default App;

コンポーネントのルーティングや切り替えには React Router DOM を使用しています。

favicon.icoApp.stylus がインポートできるのは WebPack のおかげです。
favicon.ico はブラウザが決め打ちでアクセスしてくるのでインポートするだけで良いですが、例えば img タグで読み込ませたい場合は import favicon from "/favicon.ico"; として <img src={favicon} /> というように指定します。
App.stylus もインポートするだけでスタイルが適用されるのですが、クラスセレクタはそのままでは適用されませんので、className={styles.menuButton} というようにして CSS クラス名をコンポーネントにセットする必要があります。CSS クラス名はバンドル時に、衝突回避のため一意な名前に変換されます。このように、CSS ファイルや Stylus ファイルを JavaScript/TypeScript モジュールのように扱う機能のことを CSS Modules と言います。

<補足>
JSX 構文で仮想 DOM を作成する際には、必ず単一の親要素を用意する必要があります。
今回のように特に親要素に該当するコンポーネントや HTML 要素が無い場合には、Fragment コンポーネント (<> または <Fragment>) を使用します。

クライアントサイド:favicon.ico

src/client フォルダーに favicon.ico ファイルを作成してください。
用意しないとビルドエラーが発生しますので中身は空でも良いので作成しておいてください。

クライアントサイド:App.styl

src/client フォルダーに App.styl ファイルを作成し、下記の内容で保存してください。

$basicForegroundColor = #0000A0
$basicBackgroundColor = #A0A0FF

body
    color: $basicForegroundColor
    background-color: $basicBackgroundColor

.menuButton
    margin-right: 16px

<注意>
スタイルシートのファイル間の依存方向が、コンポーネントの依存方向と逆行しないよう気を付けてください。
本記事では App.styl しか用意しませんが、例えば Home.styl や HomeWork.styl を用意する場合、Home.styl や HomeWork.styl から App.styl を参照 (インポート) してはいけません。base.styl を用意してそちらを参照させるなど、適切に設計しましょう。

クライアントサイド:Home.tsx

単純に "Hello World." と表示するだけのページです。

src/client フォルダーに Home.tsx ファイルを作成し、下記の内容で保存してください。

import * as React from "react";

const Home = () => {
    return (
        <h1>Hello World.</h1>
    );
};

export default Home;

クライアントサイド:HomeWork.tsx

サーバーに実装した hello API から取得した "Hello World!" というメッセージを動的に表示するページです。

src/client フォルダーに HomeWork.tsx ファイルを作成し、下記の内容で保存してください。

import * as React from "react";
import { useState, useEffect } from "react";

const HomeWork = () => {
    const [message, setMessage] = useState("Loading...");
    useEffect(() => {
        fetch("/api/hello")
            .then(response => response.json())
            .then(json => setMessage(json.message));
    }, []);
    return (
        <h1>{message}</h1>
    );
};

export default HomeWork;

hello API との通信にはfetch 関数 (Web 通信を行う非同期関数) を使っていますが、取得したデータを動的にコンテンツに埋め込むには React Hooks (useStateuseEffect) を利用する必要があります。

HomeWork 関数は h1 タグに message 変数の中身を埋め込んで返すようになっていて、この message 変数は通常の変数ではなく useState によって用意されたステート変数という特殊な変数です。
"Loading..." を初期値として与えていますので、HomeWork ページ表示直後はこの "Loading..." が表示され、その後 hello API からデータが取得されると、setMessage 関数を通じて message ステート変数が書き換えられ、"Hello World!" と表示されます。
このように、動的な書き換えを行うには必ずステート変数を利用する必要があります。

そしてここからが少しややこしいのですが、実は HomeWork 関数は初期化時に1回だけ呼ばれるわけではなく、レンダー時 (ステート変数書き換え時) に毎回呼びなおされます。
ですので hello API との通信を HomeWork 関数にべた書きしてしまうと、ステート変数書き換え後に再び hello API 呼び出しが行われ、またステート変数が書き換えられ…と無限ループしてしまいます。
このように、DOM のレンダーとは独立して動作するべき処理は副作用と呼ばれ、useEffect 関数を通じてレンダー後に処理する必要があります。
といっても、単に処理のタイミングをレンダー後に移動しただけでは、ループすることに変わりありません。今回の場合は特に useEffect 関数の第二引数が重要です。ここには、値が更新されるたびに副作用を再実行する必要のあるステート変数のリストを渡すようになっています。そしてここに空のリストを渡すと、副作用の実行を初回レンダー後の1回のみに制限することができます。ちなみに、第二引数を省略した場合はレンダー毎に副作用が実行されてしまうので、空のリストをしっかりと渡す必要があります。

React Hooks のより詳しい解説については下記のドキュメントを参照してください。

実行

一通りの実装が完了しましたので実行させてみましょう。
Explorer サイドバーの NPM Scripts に、package.jsonscripts で定義したスクリプトの一覧が並んでいますので、run:hmr を実行します。
image.png
実行するとサーバー起動とビルドが行われます。
ezgif-1-98b27c5cd798.gif
Terminal に [1] ℹ 「wdm」: Compiled successfully. と出力されたら成功です。
ブラウザを立ち上げて http://localhost:3000 にアクセスしてみてください。
ezgif-1-7ea4b3d7020e.gif
Home と HomeWork がうまく切り替わったでしょうか。

クライアントサイドコードの変更

HMR 用補助サーバーの設定で説明しましたとおり、クライアントサイドコードの変更は HMR 機能によってブラウザをリロードすることなく即座に反映されます。
試しに Home.tsx の "Hello World." を "Hello HMR." に変えてみたり、App.styl をいじってみてください。
ezgif-1-832e51b56c30.gif
ちなみにこの動画では、撮影の都合上 Browser Preview という拡張機能を使用して VSCode 内にブラウザを表示させていますが、もちろん普通のブラウザでも HMR はちゃんと動作します。

サーバーサイドコードの変更

サーバーサイドコードの変更は ts-node-dev によって検出され、サーバーが自動で再起動されます。

サーバーの停止

基本的にサーバーは起動しっぱなしで良いのですが、ターミナル上で Ctrl + C を押せばサーバーが停止します。

デバッグ

特にバグがあるわけでもないのですが、次はデバッグをしてみます。

クライアントサイドコードのデバッグ

HomeWork 関数をデバッグしてみます。

  • 準備
    1. サーバーを起動しておきます。
    2. Run サイドバーの上部にあるドロップダウンリストから launch.json で定義したデバッグ設定を選べますので、Debug Client (Chrome) もしくは Debug Client (Edge) を選択しておきます。
    3. HomeWrok.tsx の5行目にカーソルを移動し、F9 キーにてブレークポイントを設置します。
  • デバッグ
    1. F5 キーにてデバッグを開始します。
    2. 自動起動されたブラウザにて HomeWork ページを表示すると、ブレークポイント (HomeWork.tsx の5行目) でブレーク (一時停止) します。

ブレーク後は下記の操作が行えます。

操作 キー
ステップオーバー3 F10
ステップイン4 F11
再開 F5
停止5 6 Shift + F5

他にも、変数にカーソルをあてて変数の中身を確認したり、DEBUG CONSOLE から変数の中身を書き換えたりすることも可能です。

サーバーサイドコードのデバッグ

hello API をデバッグしてみます。

  • 準備
    1. サーバーを起動しておきます。
    2. Run サイドバーの上部にあるドロップダウンリストから、Debug Server (Attach) を選択しておきます。
    3. server.ts の16行目にカーソルを移動し、F9 キーにてブレークポイントを設置します。
  • デバッグ
    1. F5 キーにてデバッグを開始します。
    2. ブラウザ (自動起動はしません) にて HomeWork ページを表示すると、ブレークポイント (Server.ts の16行目) でブレーク (一時停止) します。

ブレーク後は、クライアントサイドコードのデバッグ時と同じ操作が可能です。

なお、サーバーの起動時の処理をデバッグしたい場合には、サーバーを停止し Debug Server (Launch) を使用してデバッグを開始してください。

ユニットテスト

次はユニットテストを用意して実行してみます。本当はテスト駆動開発で実装より先にテストを用意したかったのですが、記事の構成の都合から諦めて後回しにしました。

各テストケースの基本的な流れは次のようになります。

  1. testing-library の render 関数でテスト対象コンポーネントを疑似的にレンダーする
  2. テストしたいシナリオを fireEvent でエミュレートする
  3. expect でコンポーネントの状態を検証する

ユニットテストフレームワークの詳細は下記を参照してください。

App コンポーネントのテスト

App コンポーネントに対して次の3つのテストケースを用意します。

  • 最初に Home が表示されること
  • メニューから Home を表示できること
  • メニューから Home Work を表示できること

src/client フォルダーに App.spec.tsx ファイルを作成し、下記の内容で保存してください。

import * as React from "react";
import { MemoryRouter } from "react-router-dom";
import { render, cleanup, fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect";

const homeMock = jest.fn(() => <></>);
jest.mock("./Home", () => ({ __esModule: true, default: homeMock }));

const homeworkMock = jest.fn(() => <></>);
jest.mock("./HomeWork", () => ({ __esModule: true, default: homeworkMock }));

import App from "./App";

afterEach(cleanup);
afterEach(jest.clearAllMocks);
afterAll(() => {
    jest.unmock("./Home");
    jest.unmock("./HomeWork");
});

describe("App", () => {
    it("最初に Home が表示されること", () => {
        render(<MemoryRouter><App /></MemoryRouter>);
        expect(homeMock).toBeCalled();
    });

    it("メニューから Home を表示できること", () => {
        const app = render(<MemoryRouter><App /></MemoryRouter>);
        expect(homeMock).toBeCalled();
        homeMock.mockClear();

        const menuHomeButton = app.getByText("Home", { exact: true });
        fireEvent.click(menuHomeButton);

        expect(homeMock).toBeCalled();
    });

    it("メニューから Home Work を表示できること", () => {
        const app = render(<MemoryRouter><App /></MemoryRouter>);
        expect(homeworkMock).not.toBeCalled();

        const menuHomeWorkButton = app.getByText("Home Work", { exact: true });
        fireEvent.click(menuHomeWorkButton);

        expect(homeworkMock).toBeCalled();
    });
});

MemoryRouter コンポーネントについて
App コンポーネントは React Router DOM を使用しているため、Router コンポーネントでラップする必要があります。
実行時は index.tsx にて BrowserRouter コンポーネントでラップしていますが、テスト時は MemoryRouter コンポーネントでラップします。

コンポーネントのモック化について
App コンポーネントのテストに専念するため、App.tsx をインポートする前に Home コンポーネントと HomeWork コンポーネントを下記のようにしてモック化しています。

const homeMock = jest.fn(() => <></>);
jest.mock("./Home", () => ({ __esModule: true, default: homeMock }));

const homeworkMock = jest.fn(() => <></>);
jest.mock("./HomeWork", () => ({ __esModule: true, default: homeworkMock }));

これは Home.tsx や HomeWork.tsx を下記のようなコードで一時的に上書きしているかのような効果を持ちます。

export default () => {
    return (
        <></>
    );
};

モックは更に、自分が呼び出しされたかどうか等を調べられるようになっています。

expect(homeMock).toBeCalled();

これにより、App コンポーネントが Home コンポーネントや HomeWork コンポーネントを適切なタイミングで呼び出しているかどうかをテストできるわけです。

モック化が Home.spec.tsx 以外のテストケースに影響しないよう、クリーンアップ処理の登録も忘れずに行います。

afterAll(() => {
    jest.unmock("./Home");
    jest.unmock("./HomeWork");
});

Home コンポーネントのテスト

Home コンポーネントに対して次の1つのテストケースを用意します。

  • メッセージが表示されること

src/client フォルダーに Home.spec.tsx ファイルを作成し、下記の内容で保存してください。

import * as React from "react";
import { render, cleanup } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect";

import Home from "./Home";

afterEach(cleanup);
afterEach(jest.clearAllMocks);

describe("HomeWork", () => {
    it("メッセージが表示されること", () => {
        const app = render(<Home />);
        const heading = app.getByRole("heading");
        expect(heading).toHaveTextContent("Hello World.");
    });
});

HomeWork コンポーネントのテスト

HomeWork コンポーネントに対して次の1つのテストケースを用意します。

  • サーバーから取得したメッセージが表示されること

src/client フォルダーに HomeWork.spec.tsx ファイルを作成し、下記の内容で保存してください。

import * as React from "react";
import { render, cleanup, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect";
import * as fetchMock from "fetch-mock";

import HomeWork from "./HomeWork";

afterEach(cleanup);
afterEach(jest.clearAllMocks);
afterEach(fetchMock.restore);

describe("HomeWork", () => {
    it("サーバーから取得したメッセージが表示されること", async () => {
        fetchMock.get("/api/hello", (url, opts) => ({
            status: 200,
            body: {
                message: "Hello Mock."
            }
        }));
        const app = render(<HomeWork />);
        const heading = app.getByRole("heading");
        expect(heading).toHaveTextContent("Loading...");
        await waitFor(() => {
            expect(heading).toHaveTextContent("Hello Mock.");
        });
    });
});

fetch 関数のモック化について
HomeWork コンポーネントのテストに専念するため、fetch 関数 (hello API への通信) を下記のようにしてモック化しています。

        fetchMock.get("/api/hello", (url, opts) => ({
            status: 200,
            body: {
                message: "Hello Mock."
            }
        }));

今回のモック化は各テストケースがテストシナリオに合わせて用意している形ですので、他のテストケースに影響しないようテストケース実行のたびにクリーンアップを行います。

afterEach(fetchMock.restore);

waitFor 関数について
HomeWork コンポーネントはレンダー後に副作用として hellow API 呼び出しとメッセージ更新を行います。副作用はレンダースレッドがフリーになってから、つまりテストケースの実行が完了してからでないと本来実行されません。そこで、テストケースの中で waitFor 関数を使用し、スレッドを一旦フリーにしてあげる必要があります。
waitFor 関数はレンダーされた仮想 DOM の変更を一定 (50ms) 間隔で監視しながら待機する関数です。仮想 DOM が更新されると、引数に指定されたコールバック関数を実行して waitFor 関数が完了します。

<注意>
現行の Testing Library では、型定義ファイルに不備があるため waitFor 関数のインポートが行えません。近いうちに Testing Library のアップデートで修正されると思いますが、それまでの間は、node_modules/@types/testing-library__dom/index.d.ts に下記の型定義を追加して保存しておくことで回避することができます。

export function waitFor<T>(
    callback: () => void,
    options?: {
        container?: HTMLElement;
        timeout?: number;
        interval?: number;
        mutationObserverOptions?: MutationObserverInit;
    },
): Promise<T>;

テストの個別実行とデバッグ

テストを個別に実行するには、テストコードを右クリックして Run Jest を実行します。
ezgif-1-b04e1c98b6b7.gif
Run Jest ではなく Debug Jest を実行することでデバッグも可能です。
ezgif-1-bb2c8e5571b7.gif

テストの全体実行とカバレッジ

テスト全体を実行するには、Explorer サイドバーの NPM Scripts から、test を実行します。
image.png
結果と共にカバレッジも出力するようにしてあります。
image.png
カバレッジの詳細レポートはコンテナワークスペースの coverage フォルダーに出力されています。coverage/lcov-report/index.html をブラウザで開けば、どの行が何回実行されたかといった情報も確認できます。
image.png

本番用コンテナ

本記事もいよいよ大詰めです。
本番用コンテナは Dockerfile + docker-compose.yml で作成します。

Dockerfile

コンテナワークスペースに Dockerfile を作成し、下記の内容で保存してください。

FROM node:12.16-buster-slim AS base
WORKDIR /app
COPY ["package.json", "package-lock.json", "tsconfig.json", "./"]
RUN npm install --production --silent

FROM base AS build
WORKDIR /app
RUN npm install --silent
COPY . .
RUN npm run build

FROM base AS product
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/src/server ./src/server
EXPOSE 3000

このコードでは、マルチステージビルドで次の3つのイメージを作成しています。

イメージ名 概要
base ビルド用イメージと本番イメージのベースイメージ。
build ビルド用の中間イメージ。開発環境用の Node モジュールのインストール、プロジェクトフォルダ内の全てのファイルのコピーを行い、package.json に定義した build スクリプトを使用してビルドを行う。
product 本番イメージ。build イメージで生成したビルド成果物が配置される。開発環境用の Node モジュールはインストールされない。

大元のベースイメージには node の 12.16-buster-slim を使用しています。これは、現時点での Node.js の安定バージョンと Debian の最新バージョンのスリム版の組み合わせです。
node の Docker イメージは下記ページから確認できます。

.dockerignore

コンテナワークスペースに .dockerignore を作成し、下記の内容で保存してください。

**/coverage
**/dist
**/logs
**/node_modules

ビルド時や実行時に生成されるディレクトリがイメージ内にコピーされないようにしています。
他にもビルドに不要なファイルはありますが、特別大きなファイルであったりしない限り、そこまで厳密にリストアップする必要はありません。

docker-compose.yml

docker-compose.yml は下記の内容で、{project name} 1箇所を適切に置き換えた上で保存してください。{project name}package.json に合わせて小文字で記述します。

version: '2.1'

services:
    app:
        image: {project name}
        build: .
        ports:
            - 3001:3000
        command: npm start

コンテナ起動設定を app というサービス名で登録しています。
開発コンテナで既にポート3000をそのまま使用しているため、こちらはポート3001にフォワーディングしておきます。
また、コンテナ起動時にサーバーが起動するよう npm start コマンドを設定しておきます。

イメージビルドとコンテナ起動

ターミナルで下記のコマンドを実行すると、イメージがビルドされ、コンテナが起動します。(ビルド済みのイメージをそのまま起動する場合は --build オプションを付けずに実行します。)

docker-compose up --build app

ブラウザを立ち上げて http://localhost:3001/ にアクセスできることを確認してください。

コンテナにシェルで接続

コンテナ起動後、別ターミナルで下記のコマンドを実行すると、コンテナにシェルで接続することができます。

docker-compose exec app /bin/bash

本番用コンテナ内の調査などで役立ちますが、本番用コンテナにはツール類がほとんどインストールされていませんので、状況に応じて apt-get でツールのインストールを行う必要があります。

コンテナ停止

コンテナ起動後、別ターミナルで下記のコマンドを実行すると、コンテナが停止します。

docker-compose down

終わりに

コンテナ生活、如何でしたでしょうか。
といっても、コンテナに引きこもっていることを忘れてしまうくらいに普通に開発が行えてしまうので、あまり実感は無いかもしれません。
開発コンテナの利点は環境周りのトラブルの低減です。
今後は是非、コンテナにひきこもって快適なフロントエンド開発を満喫していただければと思います。


番外編:CSS フレームワークについて

本編では扱いませんでしたが、実開発ではデザインについても考えなければいけません。
世の中には Bootstrap をはじめ様々な CSS フレームワークがありますので、それらから選択するのが無難です。ちなみに React 開発においては、Material UI という CSS フレームワークが一番人気だそうです。
参考までに、本記事の構成に Material UI の AppBar コンポーネントを取り入れた例をご紹介しておきます。

Material UI のインストール

npm で開発環境にインストールします。
Material UI 本体の他に、アイコン集もインストールします。

npm install -D @material-ui/core @material-ui/icons 

App コンポーネントの実装

src/client/App.tsx ファイルを下記の内容に書き換えます。

import * as React from "react";
import { Switch, Route, Link } from "react-router-dom";
import AppBar from "@material-ui/core/AppBar";
import Toolbar from "@material-ui/core/Toolbar";
import Typography from "@material-ui/core/Typography";
import IconButton from "@material-ui/core/IconButton";
import MenuIcon from "@material-ui/icons/Menu";
import Drawer from "@material-ui/core/Drawer";
import List from "@material-ui/core/List";
import ListItem from "@material-ui/core/ListItem";
import ListItemIcon from "@material-ui/core/ListItemIcon";
import ListItemText from "@material-ui/core/ListItemText";
import Container from "@material-ui/core/Container";
import HomeIcon from "@material-ui/icons/Home";
import HomeWorkIcon from "@material-ui/icons/HomeWork";

import "./favicon.ico";
import * as styles from "./App.styl";
import Home from "./Home";
import HomeWork from "./HomeWork";

const App = () => {
    const [drawerState, setDrawerState] = React.useState(false);
    const toggleDrawer = (state: boolean) => (event: any) => {
        if (event.type === "keydown" && (event.key === "Tab" || event.key === "Shift")) {
            return;
        }
        setDrawerState(state);
    };

    return (
        <>
            <AppBar position="static">
                <Toolbar>
                    <IconButton edge="start" className={styles.menuButton} color="inherit" aria-label="menu" onClick={toggleDrawer(true)}>
                        <MenuIcon />
                    </IconButton>
                    <Typography variant="h6" className={styles.title}>
                        Sample
                    </Typography>
                </Toolbar>
            </AppBar>
            <Drawer open={drawerState} role="presentation" className={styles.list} onClose={toggleDrawer(false)} onClick={toggleDrawer(false)} onKeyDown={toggleDrawer(false)}>
                <List>
                    <ListItem button key="home" component={Link} to="/">
                        <ListItemIcon><HomeIcon /></ListItemIcon>
                        <ListItemText primary="Home" />
                    </ListItem>
                    <ListItem button key="homework" component={Link} to="/homework">
                        <ListItemIcon><HomeWorkIcon /></ListItemIcon>
                        <ListItemText primary="Home Work" />
                    </ListItem>
                </List>
            </Drawer>
            <Container maxWidth="sm">
                <Switch>
                    <Route exact path="/" component={Home} />
                    <Route exact path="/homework" component={HomeWork} />
                </Switch>
            </Container>
        </>
    );
};

export default App;

src/client/App.styl ファイルを下記の内容に書き換えます。

$spacing = 8px
$basicBackgroundColor = #A0A0FF
$basicForegroundColor = #0000A0

$heading
    color: $basicForegroundColor


:global(body)
    background-color: $basicBackgroundColor

.root
    flex-grow: 1

.menuButton
    margin-right: $spacing * 2

.title
    flex-grow: 1

.list
    width: 250

実行

実行すると見慣れたバーが追加されています。
image.png

メニューボタンを押すとメニューが開きます。
image.png

テスト

src/client/App.spec.tsx ファイルを下記の内容に書き換えます。

import * as React from "react";
import { MemoryRouter } from "react-router-dom";
import { render, cleanup, fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect";

const homeMock = jest.fn(() => <></>);
jest.mock("./Home", () => ({ __esModule: true, default: homeMock }));

const homeworkMock = jest.fn(() => <></>);
jest.mock("./HomeWork", () => ({ __esModule: true, default: homeworkMock }));

import App from "./App";

afterEach(cleanup);
afterEach(jest.clearAllMocks);
afterAll(() => {
    jest.unmock("./Home");
    jest.unmock("./HomeWork");
});

describe("App", () => {
    it("最初に Home を表示すること", () => {
        render(<MemoryRouter><App /></MemoryRouter>);
        expect(homeMock).toBeCalled();
    });

    it("メニューを開けること", () => {
        const app = render(<MemoryRouter><App /></MemoryRouter>);

        const menuButton = app.getByLabelText("menu");
        fireEvent.click(menuButton);

        expect(app.getByRole("presentation")).not.toBeNull();
    });

    it("メニューをクリックするとメニューが閉じること", () => {
        const app = render(<MemoryRouter><App /></MemoryRouter>);

        const menuButton = app.getByLabelText("menu");
        fireEvent.click(menuButton);

        const menu = app.getByRole("presentation");
        fireEvent.click(menu);

        expect(() => app.getByRole("presentation")).toThrow();
    });

    it("キーを押下するとメニューが閉じること", () => {
        const app = render(<MemoryRouter><App /></MemoryRouter>);

        const menuButton = app.getByLabelText("menu");
        fireEvent.click(menuButton);

        const menu = app.getByRole("presentation");
        fireEvent.keyDown(menu, { key: "a", code: 65 });

        expect(() => app.getByRole("presentation")).toThrow();
    });

    it.each([
        ["Tab", 9],
        ["Shift", 16]
    ])("一部のキーを押下してもメニューが閉じないこと [%s, %d]", (key, code) => {
        const app = render(<MemoryRouter><App /></MemoryRouter>);

        const menuButton = app.getByLabelText("menu");
        fireEvent.click(menuButton);

        const menu = app.getByRole("presentation");
        fireEvent.keyDown(menu, { key, code });

        expect(app.getByRole("presentation")).not.toBeNull();
    });

    it("メニューから Home を表示できること", () => {
        const app = render(<MemoryRouter><App /></MemoryRouter>);
        expect(homeMock).toBeCalled();
        homeMock.mockClear();

        const menuButton = app.getByLabelText("menu");
        fireEvent.click(menuButton);

        const menuHomeButton = app.getByText("Home", { exact: true });
        fireEvent.click(menuHomeButton);

        expect(() => app.getByRole("presentation")).toThrow();
        expect(homeMock).toBeCalled();
    });

    it("メニューから Home Work を表示できること", () => {
        const app = render(<MemoryRouter><App /></MemoryRouter>);
        expect(homeworkMock).not.toBeCalled();

        const menuButton = app.getByLabelText("menu");
        fireEvent.click(menuButton);

        const menuHomeWorkButton = app.getByText("Home Work", { exact: true });
        fireEvent.click(menuHomeWorkButton);

        expect(() => app.getByRole("presentation")).toThrow();
        expect(homeworkMock).toBeCalled();
    });
});

いい感じです。
image.png

おまけ

本編に挟み込めなかった小ネタをいくつか。

ワークスペース外のファイルを開く

開発コンテナ内のファイルは、コンテナワークスペースには含まれていなくても VSCode で開くことができます。
下記のコマンドを {File Path} を置き換えて実行するとファイルが開かれます。

code {File Path}

Source Control サイドバーが git を検出しなくなった場合の対処法

ワークスペースをコピーして開発コンテナを開くなどしていると、Source Control サイドバーが git を検出できなくなる場合があります。
.git フォルダーの権限周りがおかしくなっている可能性がありますので、下記のコマンドで .git フォルダーの権限を再設定することで解決するか確認してみてください。

chmod -R 644 ../.git

ダメな場合は開発コンテナをリビルドしましょう。数分で解決です。


変更履歴


  1. ホスト環境とコンテナ環境の両方に入れる必要があります。 

  2. もしくは絶対パスでホストのワークスペースを指定する。でも絶対パスは論外。 

  3. 一行ずつ進めていき、関数を呼び出している場合には関数内に入らず飛び越していく形式 

  4. 一行ずつ進めていき、関数を呼び出している場合には関数内に進んでいく形式 

  5. デバッグは停止しますが、サーバーは停止しません。 

  6. ブレークしていない時でも停止は可能です。 

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【React】チュートリアルの三目並べをやる #2

前回

【React】チュートリアルの三目並べをやる #1

タイムトラベル機能の追加

前回は通常の三目並べ完成までやりました。
今回はその三目並べに「タイムトラベル機能」なるものを実装していきたいと思います。履歴ですね。

公式チュートリアル

着手の履歴の保存

suquaresの配列をsetStateで毎回新規オブジェクトで更新していたことがここで活きるらしいです。
このオブジェクトを更新のたびに保持していきます。

その履歴を保持する場所は一番TOPのGameにするそうです。
これによりBoardはstateを保持する必要がなくなります。

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

また、statusの更新やonClickの処理もすべてGameに持っていくことができます。

以下がすべてを移動させたバージョン

BoardとGame
class Board extends React.Component {
  // constructor削除

  renderSquare(i) {
    return (
      <Square
        value={this.props.squares[i]} //state → props
        onClick={() => {
          this.props.onClick(i); // 処理はすべてGameに移動して、GameのonClickを呼び出す
        }}
      />
    );
  }

  render() {
    return (
      <div>
        {/* satateは削除。Game側で表示する */}

        <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);

    // Gameで履歴を保持する
    this.state = {
      history: [{ squares: Array(9).fill(null) }],
      xIsNext: true,
    };
  }

  render() {
    // statusの表示を移動してきた

    const history = this.state.history;
    const current = history[history.length - 1]; // 最新の履歴を取得
    const winner = this.calculateWinner(current.squares);

    let status;
    if (winner) {
      status = '勝者:' + winner;
    } else {
      status = '次の手番: ' + (this.state.xIsNext ? 'X' : 'O');
    }

    return (
      <div className="game">
        <div className="gmae-board">
          <Board
            squares={current.squares} // クロージャを使ってcurrentからsquaresを取得して、Boardに渡す
            // BoardからonClick処理を移動
            onClick={i => {
              const squares = Object.create(current.squares); // 最新のsquare
              if (this.calculateWinner(squares) || squares[i]) {
                return;
              }

              squares[i] = this.state.xIsNext ? 'X' : 'O';

              // 更新用のstateを作る
              const newState = {
                // historyに新しい履歴を追加する
                history: history.concat({ squares: squares }),
                xIsNext: !this.state.xIsNext,
              };
              this.setState(newState);
            }}
          />
        </div>
        <div className="game-info">
          <div>{status}</div>
          <div>{/* TODO */}</div>
        </div>
      </div>
    );
  }

  // 勝敗判定関数(Boardから移動してきた)
  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;
  }
}

history.concatのところはArray.prototype.pushではなくArray.prototype.concatを使用します。
pushだと既存のstateを更新してしまうことになります。
concatであれば、新たに配列を作り出すため、安全です。
Array.prototype.concat()

過去の着手の表示

※以降はチュートリアルはあまり見ずに自分でやり遂げてみたかったので、ちょっとチュートリアルとは異なります。

履歴はすべて保持しておいて、「○回目の履歴表示」ボタンが押されたら、その履歴の状態を表示させるようです。
これを実現させるためにstateに「表示したい履歴のインデックス」を表すstepNumberを設けます。

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

    // Gameで履歴を保持する
    this.state = {
      history: [{ squares: Array(9).fill(null) }],
      stepNumber: 0, // 表示したい履歴のインデックスを表す
      xIsNext: true,
    };
  }

renderではstepNumberを用いて表示したい履歴を取得します。(currentのところ)

Game
  render() {
    // statusの表示を移動してきた

    const history = this.state.history;
    const current = history[this.state.stepNumber]; // カレントはstepNumberのインデックスで求める
    const winner = this.calculateWinner(current.squares);

そして、履歴表示ボタンのHTMLを作ります。
履歴の配列をmapでループして、新しい配列を作ります。
この配列は「履歴表示ボタンのHTML」の配列になります。
Array.prototype.map()

Game
    // 履歴表示ボタン配列を作成
    const moves = history.map((step, move) => {
      const desc = move ? 'Go to move #' + move : 'Go to game start';
      return (
        // keyが無いと警告がでる
        <li key={move}>
          <button
            // 履歴ボタン押下イベント
            onClick={() => {
              // 対象の履歴インデックスの状態に変更する
              // xIsNextは2で割ったあまりで求められる
              this.setState({
                stepNumber: move,
                xIsNext: move % 2 === 0,
              });
            }}
          >
            {desc} {/* ボタン表示名 */}
          </button>
        </li>
      );
    });

【※2020/03/24追記】
likey属性がないため、エラーになっていました。
一意な値を振ることが推奨されるようです。
key を選ぶ


stepはhistory内の1つ1つの要素を表します。
moveは、今のstep要素が配列の中でどの位置にいる要素であるか(インデックス)を表す。

onClickイベントで、表示させたい履歴のインデックスと、xIsNextsetStateします。

ここではhistrotyは更新しません。履歴を保持している配列の中から、指定したインデックスの履歴を表示させるだけなので、更新する必要はありません。

returnのところ
最後に、BoardのHTMLを返すreturn();の所です。
Boardに渡すonClickは、Squareのクリックイベント(つまり、マス目を押された時)の処理です。

ここでは、マス目を押された時点からまた新しく履歴を保持するようにします。
なので、「最初の履歴 ~ 今表示している履歴」までを、履歴の配列全体から抜き出し、その抜き出した物の最後尾に今の状態(履歴)を追加します。

Game
      return (
      <div className="game">
        <div className="gmae-board">
          <Board
            squares={current.squares} // クロージャを使ってcurrentからsquaresを取得して、Boardに渡す
            // BoardからonClick処理を移動(※これはあくまでマス目押下イベント)
            onClick={i => {
              const squares = Object.create(current.squares); // カレントのsquare
              if (this.calculateWinner(squares) || squares[i]) {
                return;
              }

              squares[i] = this.state.xIsNext ? 'X' : 'O';

              // 履歴の最初~直前に押された履歴までを抜き出す
              const newHistory = history.slice(0, this.state.stepNumber + 1);

              // 更新用のstateを作る
              const newState = {
                // 抜き出した履歴の続きからまた新たに履歴を保持していく
                history: newHistory.concat({ squares: squares }),
                stepNumber: newHistory.length, // 最新のインデックス
                xIsNext: !this.state.xIsNext,
              };
              this.setState(newState);
            }}
          />
        </div>
        <div className="game-info">
          <div>{status}</div>
          <div>{moves}</div>
        </div>
      </div>
    );

movesTODOとなっていたところに埋め込みます。

これで完成です。

全文

index.js
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import * as serviceWorker from './serviceWorker';

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

class Board extends React.Component {
  // constructor削除

  renderSquare(i) {
    return (
      <Square
        value={this.props.squares[i]} //state → props
        onClick={() => {
          this.props.onClick(i); // 処理はすべてGameに移動して、GameのonClickを呼び出す
        }}
      />
    );
  }

  render() {
    return (
      <div>
        {/* satateは削除。Game側で表示する */}

        <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);

    // Gameで履歴を保持する
    this.state = {
      history: [{ squares: Array(9).fill(null) }],
      stepNumber: 0, // 表示したい履歴のインデックスを表す
      xIsNext: true,
    };
  }

  render() {
    // statusの表示を移動してきた

    const history = this.state.history;
    const current = history[this.state.stepNumber]; // カレントはstepNumberのインデックスで求める
    const winner = this.calculateWinner(current.squares);

    // 履歴表示ボタン配列を作成
    const moves = history.map((step, move) => {
      const desc = move ? 'Go to move #' + move : 'Go to game start';
      return (
        <li>
          <button
            // 履歴ボタン押下イベント
            onClick={() => {
              // 対象の履歴インデックスの状態に変更する
              // xIsNextは2で割ったあまりで求められる
              this.setState({
                stepNumber: move,
                xIsNext: move % 2 === 0,
              });
            }}
          >
            {desc} {/* ボタン表示名 */}
          </button>
        </li>
      );
    });

    let status;
    if (winner) {
      status = '勝者:' + winner;
    } else {
      status = '次の手番: ' + (this.state.xIsNext ? 'X' : 'O');
    }

    return (
      <div className="game">
        <div className="gmae-board">
          <Board
            squares={current.squares} // クロージャを使ってcurrentからsquaresを取得して、Boardに渡す
            // BoardからonClick処理を移動(※これはあくまでマス目押下イベント)
            onClick={i => {
              const squares = Object.create(current.squares); // カレントのsquare
              if (this.calculateWinner(squares) || squares[i]) {
                return;
              }

              squares[i] = this.state.xIsNext ? 'X' : 'O';

              // 最初の履歴~直前に押された履歴までを抜き出す
              const newHistory = history.slice(0, this.state.stepNumber + 1);

              // 更新用のstateを作る
              const newState = {
                // 抜き出した履歴の続きからまた新たに履歴を保持していく
                history: newHistory.concat({ squares: squares }),
                stepNumber: newHistory.length, // 最新のインデックス
                xIsNext: !this.state.xIsNext,
              };
              this.setState(newState);
            }}
          />
        </div>
        <div className="game-info">
          <div>{status}</div>
          <div>{moves}</div>
        </div>
      </div>
    );
  }

  // 勝敗判定関数(Boardから移動してきた)
  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;
  }
}

ReactDOM.render(
  <React.StrictMode>
    <Game />
  </React.StrictMode>,
  document.getElementById('root')
);

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();

結果

D1AEZK.gif

感想

ちょっと最後らへんは混乱しました。
数日たってソースみるとおそらく解読できないと思います。

jQueryのようにセレクタをたくさん書かずとも実現できたのはメリットだと思いました。
おそらくjQueryならもっとソースコードが煩雑になるはず。

このチュートリアルはQiitaで別の方々がたくさん試されているので、今更でしたね・・・笑

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

React版Reactivesearch v3を使ってゼロから最速でElasticsearchフロントアプリを作る

はじめに

Reactivesearchを使ったELasticsearch SPAアプリをささっと作る手順です。Reactivesearchは、Elasticsearchに保管したデータを手軽に扱えるようにするフロントエンドUIコンポーネントです。React.js版とVue.js版がありますが、今回はReact版を使いました。Node.jsやReactについて理解することなく、Macの初期状態からとにかく動くものを作るところまでです。
Reactivesearch v3でいい感じの検索SPAを30分ぐらいで作る」をかなり参考にさせていただきました。

作成するもの

完成形としてはこのような検索アプリです。都市や人口のデータをElasticsearchに入れておいて、国名を入力すると、その国の都市のリストが出てくるようなアプリです。

完成形

事前準備

Elasticsearch環境

Amazon Elasticsearch Serviceで検索できる状態まで最速で立ち上げる」を参考に、Elasticsearch環境を事前に用意します。リンク先にあるとおり都市のデータも投入しておきます。

手順

Node.jsのインストール

ここでは、Node.jsのインストールとバージョン管理をするためにnodebrewを使います。nodebrewのインストールのためにMacのHomebrewを使います。

開発用ディレクトリに移動

Macでターミナルアプリを開き、開発用ディレクトリに移動。

Homebrewをインストール

まだMacにインストールしていない場合はHomebrewからインストールします。

$ /usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"

nodebrewをインストール

Homebrewを使ってnodebrewをインストールし、セットアップします。

$ brew install nodebrew
$ nodebrew setup
Fetching nodebrew...
Installed nodebrew in $HOME/.nodebrew

========================================
Export a path to nodebrew:

export PATH=$HOME/.nodebrew/current/bin:$PATH
========================================

nodebrewのパスの設定

ホームディレクトリの.bash_profileに以下を追加。

export PATH=$HOME/.nodebrew/current/bin:$PATH

Nodeをインストール

nodebrewでNode.jsの最新バージョン(ここではv13.11.0)をインストールし、このバージョンを使うように指定します。このNodeのバージョンではnpm(Node.jsのパッケージ管理)とnpx(Node.jsの実行環境管理)もいっしょにインストールされます。

$ nodebrew install-binary latest
Fetching: https://nodejs.org/dist/v13.11.0/node-v13.11.0-linux-x64.tar.gz
################################################################################ 100.0%
Installed successfully
$ nodebrew list
v13.11.0

current: none
$ nodebrew use v13.11.0
use v13.11.0

Reactivesearchのインストール

create-react-appモジュールのインストール

npmを使って、React環境を簡単にセットアップしてくれるcreate-react-appモジュールをインストールします。

$ npm install -g create-react-app

Reactプロジェクトの作成

開発用ディレクトリで下コマンドを実行します。ここでは、city-rankという名前で開発します。開発用ディレクトリ下にcity-rankというディレクトリが作られ、その配下にReact開発に必要なモジュールやファイルが自動で作成されます。

$ npx create-react-app city-rank

Reactアプリの起動

Reactプロジェクトディレクトリ(ここではcity-rank)に移動し、試しにReactアプリを起動してみます。

$ cd city-rank
$ npm start

Compiled successfully!
You can now view city-rank in the browser.

  Local:            http://localhost:3000
  On Your Network:  http://xxx.xxx.xxx.xxx:3000

Note that the development build is not optimized.
To create a production build, use npm run build.

上のように表示され自動的にブラウザが起動し、http://localhost:3000 にアクセスしてReactのデフォルト画面が表示されます。

Reactivesarchのインストール

Reactivesearchモジュールをインストールします。

$ npm install @appbaseio/reactivesearch

ReactiveSearchアプリの開発

Amazon Elasticsearch Serviceで検索できる状態まで最速で立ち上げる」で作ったElasticsearchをバックエンドとします。
create-react-appでは、プロジェクトディレクトリ配下にsrcディレクトリが作られ、そこにindex.jsやApp.js、それらのCSSファイルなどが作成されています。App.jsを修正することでReactivesearchアプリを作っていきます。

App.jsの編集

src配下のApp.jsを編集し、下のとおりに書き換えます。

App.js
import React, {Component} from 'react';
import {DataSearch,ReactiveBase,ReactiveList,ResultList,SelectedFilters} from '@appbaseio/reactivesearch';

const {ResultListWrapper} = ReactiveList;

class App extends Component {
    render() {
        return ( 
            <div>
                <ReactiveBase
                    app = "cityindex"
                    url = "https://[Elasticsearch ServiceのエンドポイントURL]"
                >
                    <DataSearch
                        componentId = "search-component"
                        dataField = {["countryname"]}
                        queryFormat = "and"
                    />
                    <ReactiveList
                        componentId = "list-component"
                        pagination = {true}
                        size = {10}
                        react = {{
                            "and": ["search-component"]
                        }}
                    >
                        {({data, error, loading}) => (
                            <ResultListWrapper>
                                {
                                    data.map(item => (
                                        <ResultList key = {item._id}>
                                            <ResultList.Content>
                                                <ResultList.Title
                                                    dangerouslySetInnerHTML = {{
                                                        __html: item.cityname
                                                    }}
                                                />
                                                <ResultList.Description>
                                                    <div> {item.countryname} </div>
                                                    <div> {item.population}万人 </div>
                                                </ResultList.Description>
                                            </ResultList.Content>
                                        </ResultList>
                                    ))
                                }
                            </ResultListWrapper>
                        )}
                    </ReactiveList>
                </ReactiveBase>
            </div>
        );
    }
}

export default App;

App.jsを保存すると、自動的にブラウザがリフレッシュされ、下の通り表示されます。
Search窓に国名(countryname)を入れると、動的にその国の都市だけにフィルターされます。

初期画面

ランキング順に修正

都市を人工の多いランク順に並べ替えるため、ReactiveListコンポーネントに、sortOptionsをセットしrankの昇順に並び替えます。

ReactiveListコンポーネント
<ReactiveList
    componentId = "list-component"
    pagination = {true}
    size = {10}
    react = {{
        "and": ["search-component"]
    }}
    sortOptions={[
        {label: "ランク", dataField: "rank", sortBy: "asc"}
    ]}
>

CSSで整形

App.jsの修正

CSSで見た目を整形するのに合わせてApp.js本体も以下の通りに書き直します。CityRank.cssという名前のCSSファイルを新たに作りインポートしています。

App.js
import React, {Component} from 'react';
import {DataSearch,ReactiveBase,ReactiveList,ResultList,SelectedFilters} from '@appbaseio/reactivesearch';
import './CityRank.css';

const {ResultListWrapper} = ReactiveList;

class App extends Component {
    componentDidMount() {
        document.title = "City Population Rank"
    }
    render() {
        return ( 
            <div className = "main-class">
                <ReactiveBase
                    app = "cityindex"
                    url = "https://search-bimilist2-elubdsi3x4beh3gb6tcbdxd72u.ap-northeast-1.es.amazonaws.com"
                >
                    <DataSearch
                        componentId = "search-component"
                        dataField = {["countryname"]}
                        title = "国名を入れてください"
                        placeholder = "例:日本"
                        showIcon={false}
                        className="search-class"
                    />
                    <ReactiveList
                        componentId = "list-component"
                        pagination = {true}
                        size = {10}
                        react = {{
                            "and": ["search-component"]
                        }}
                        sortOptions={[
                            {label: "ランク", dataField: "rank", sortBy: "asc"}
                        ]}
                        className = "list-class"
                        innerClass={{
                            button: "button-innerclass",
                        }}
                    >
                        {({data, error, loading}) => (
                            <ResultListWrapper>
                                {
                                    data.map(item => (
                                        <ResultList key = {item._id} className = "item-class">
                                            <ResultList.Content>
                                                <ResultList.Title
                                                    dangerouslySetInnerHTML = {{
                                                        __html: item.cityname
                                                    }}
                                                />
                                                <ResultList.Description>
                                                    <div className="description-class">
                                                        <div className="countryname-class"> {item.countryname} </div>
                                                        <div className="population-class"> {item.population}万人 </div>
                                                    </div>
                                                </ResultList.Description>
                                            </ResultList.Content>
                                        </ResultList>
                                    ))
                                }
                            </ResultListWrapper>
                        )}
                    </ReactiveList>
                </ReactiveBase>
            </div>
        );
    }
}

export default App;

CityRank.cssという名前のCSSファイルを同じフォルダに作成します。

CityRank.css
body {
    background-color: #f9f4ef;
    display: flex;
    flex-direction: column;
    align-items: center;
}

.main-class {
    width: 480px;
    margin-top: 10px;
}

.search-class {
    width: 200px;
}

.list-class {
    color: #172c66;
}

.list-class .button-innerclass{
    color: #172c66;
    background-color: #fffffe;
}

.list-class .button-innerclass:hover{
    color: #001858;
    background-color: #eaddcf;
}

.list-class .button-innerclass:focus{
    color: #001858;
    background-color: #eaddcf;
}

.list-class .item-class {
    background-color: #fffffe;
    padding: 8px;
    margin: 2px;
}

.list-class .item-class:hover {
    background-color: #eaddcf;
}

.list-class article {
    color: #172c66;
    display: flex;
    flex-direction: row;
    align-items: center;
    padding: 2px;
}

.list-class h2 {
    color: #001858;
}

.list-class .description-class {
    display: flex;
    flex-direction: row;
    color: #172c66; 
}

.list-class .countryname-class {
    width: 120px;
}

.list-class .population-class {
    width: 90px;
    padding-right: 10px;
    text-align: right;
}

保存すると次のような見た目になります。
CSS整形後

以上となります。

今回の手順で勘違いしやすいポイント

Node.jsやReact系はググるとたくさん情報が出てきますが古いものや部分的で混乱する情報もいろいろありますので、勘違いしないよう整理しました。

node initは不要

今回はcreate-react-appを使います。create-react-appが同等の準備してくれますので、node initの実行は不要です。

npm installに--saveは不要

npmの現バージョンでは--saveオプションなしでも同等の処理となります。

リンク

Reactivesearch v3でいい感じの検索SPAを30分ぐらいで作る

Reactive Manual UI Components for Elasticsearch

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【StyledComponent + CSSアニメーション】keyframesに引数をつける

StyledComponentを使ってCSSアニメーションを作る時に使うkeyframesに引数を渡して動的にアニメーションを調整する方法を、忘備録として。

結論

こんな感じ。

keyframes
import { keyframes } from 'styled-components'

export const zoomInToAnyScale = (num: number) =>
  keyframes`
    from { transform: scale(0) }
    to { transform: scale(${num}) }
  `
使い方
const StyledDiv = styled.div`
  animation: ${zoomInToAnyScale(3)} 0.3s ease 0.5s forwards;
`

const ZoomInDiv = styled.div<{ scale: nuber }>`
  animation: ${({scale}) => zoomInToAnyScale(scale) } 0.3s ease 0.5s forwards;
`

環境

一応、環境はこんな感じ。

package version
React 16.13.0
StyledComponents 5.0.1
TypeScript 3.8.3

解説

(解説というほどのものでもないけど)
StyledComponentsで宣言するkeyframesは元はただの関数です。引数にとるのはTemplateStringsArray | CSSKeyframesとなっていますが、要はCSSが書かれた文字列orオブジェクトなので、こんな書き方もできます。

keyframes
import { keyframes } from 'styled-components'

export const zoomInToAnyScale = (num: number) => {
  return keyframes({
    from: { transform: `scale(0)` },
    to: { transform: `scale(${num})` }
  })

つまりは、テンプレートリテラルなどに埋めてそのまま引数として渡せばいいだけ。
ちなみにstyled.divなどはただの関数もうちょっと複雑になってるようです。

終わりに

最近CSSアニメーションに夢中になって、いろいろな作品を見て「すげええええ」って言いながら勉強しています。
いや、本当にすごいんですよ。CSSアニメーターの方全員尊敬してます。神。

StyledComponents自体の記事は多いのですが、keyframesはあまり見かけなかったので書いてみました。
実務とかだと、ただのCSSアニメーションは扱いづらいのかもしれないですね。

トリガーで発火させるようなアニメーションは、JS側で管理しやすいreact-springとかの方が良さそうです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む