Node のバージョン管理を nvs に移行した

Node のバージョン管理には nvm を使っていたけど、クロスプラットフォームnvs の存在を知り乗り換えた。

github.com

Windows では msi をダウンロードしてインストールするだけ。

Windows では PowerShell Core を使っているので、<ユーザー>\Documents\PowerShell\Microsoft.PowerShell_profile.ps1nvs のバージョン自動切り替え機能を有効にする設定を記述しておく。

nvs auto on

あとは、プロジェクトのディレクトリ内に .node-version を配置しておけば、シェルで移動したとき自動で Node のバージョンを切り替えてくれる。便利。

React の SPA を Azure App Service でホストする

2021-07-06 追記

ZipDeploy で OK だった。

tnakamura.hatenablog.com

はじめに

React で実装した Single Page Application(SPA)を Azure App Service でホストしたい。Azure Static Web Apps ではなく、Web Apps。というのも、大人の事情で、GitHub や Azure DevOps が使えず、ローカル Git からデプロイするしかないので。

Kudu のカスタムデプロイスクリプトを書いて、なんとか実現できた。

express で React アプリをサーブするために server.js を作成

不本意だけど、express をプロジェクトに追加し、エントリポイントとして server.js を用意する。

const express = require('express');
const path = require('path');
const app = express();
const port = process.env.PORT || 3000;

app.use(express.static(path.join(__dirname, 'build')));

app.get('/*', function (req, res) {
  res.sendFile(path.join(__dirname, 'build', 'index.html'));
});

app.listen(port);

.deployment を Kudu から入手

Kudu から入手できるやつそのまま。

[config]
command = deploy.cmd

deploy.cmd を Kudu から入手して改変

Kudu から入手できるファイルに React アプリのビルドを追加した。 「Build React SPA」の箇所がそう。

@if "%SCM_TRACE_LEVEL%" NEQ "4" @echo off

:: ----------------------
:: KUDU Deployment Script
:: Version: 1.0.17
:: ----------------------

:: Prerequisites
:: -------------

:: Verify node.js installed
where node 2>nul >nul
IF %ERRORLEVEL% NEQ 0 (
  echo Missing node.js executable, please install node.js, if already installed make sure it can be reached from current environment.
  goto error
)

:: Setup
:: -----

setlocal enabledelayedexpansion

SET ARTIFACTS=%~dp0%..\artifacts

IF NOT DEFINED DEPLOYMENT_SOURCE (
  SET DEPLOYMENT_SOURCE=%~dp0%.
)

IF NOT DEFINED DEPLOYMENT_TARGET (
  SET DEPLOYMENT_TARGET=%ARTIFACTS%\wwwroot
)

IF NOT DEFINED NEXT_MANIFEST_PATH (
  SET NEXT_MANIFEST_PATH=%ARTIFACTS%\manifest

  IF NOT DEFINED PREVIOUS_MANIFEST_PATH (
    SET PREVIOUS_MANIFEST_PATH=%ARTIFACTS%\manifest
  )
)

IF NOT DEFINED KUDU_SYNC_CMD (
  :: Install kudu sync
  echo Installing Kudu Sync
  call npm install kudusync -g --silent
  IF !ERRORLEVEL! NEQ 0 goto error

  :: Locally just running "kuduSync" would also work
  SET KUDU_SYNC_CMD=%appdata%\npm\kuduSync.cmd
)
goto Deployment

:: Utility Functions
:: -----------------

:SelectNodeVersion

IF DEFINED KUDU_SELECT_NODE_VERSION_CMD (
  :: The following are done only on Windows Azure Websites environment
  call %KUDU_SELECT_NODE_VERSION_CMD% "%DEPLOYMENT_SOURCE%" "%DEPLOYMENT_TARGET%" "%DEPLOYMENT_TEMP%"
  IF !ERRORLEVEL! NEQ 0 goto error

  IF EXIST "%DEPLOYMENT_TEMP%\__nodeVersion.tmp" (
    SET /p NODE_EXE=<"%DEPLOYMENT_TEMP%\__nodeVersion.tmp"
    IF !ERRORLEVEL! NEQ 0 goto error
  )
  
  IF EXIST "%DEPLOYMENT_TEMP%\__npmVersion.tmp" (
    SET /p NPM_JS_PATH=<"%DEPLOYMENT_TEMP%\__npmVersion.tmp"
    IF !ERRORLEVEL! NEQ 0 goto error
  )

  IF NOT DEFINED NODE_EXE (
    SET NODE_EXE=node
  )

  SET NPM_CMD="!NODE_EXE!" "!NPM_JS_PATH!"
) ELSE (
  SET NPM_CMD=npm
  SET NODE_EXE=node
)

goto :EOF

::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
:: Deployment
:: ----------

:Deployment
echo Handling node.js deployment.

:: 1. KuduSync
IF /I "%IN_PLACE_DEPLOYMENT%" NEQ "1" (
  call :ExecuteCmd "%KUDU_SYNC_CMD%" -v 50 -f "%DEPLOYMENT_SOURCE%" -t "%DEPLOYMENT_TARGET%" -n "%NEXT_MANIFEST_PATH%" -p "%PREVIOUS_MANIFEST_PATH%" -i ".git;.hg;.deployment;deploy.cmd"
  IF !ERRORLEVEL! NEQ 0 goto error
)

:: 2. Select node version
call :SelectNodeVersion

:: 3. Install npm packages
IF EXIST "%DEPLOYMENT_TARGET%\package.json" (
  pushd "%DEPLOYMENT_TARGET%"
  call :ExecuteCmd !NPM_CMD! install --production
  IF !ERRORLEVEL! NEQ 0 goto error

  :: Build React SPA
  call :ExecuteCmd !NPM_CMD! run build
  IF !ERRORLEVEL! NEQ 0 goto error

  popd
)

::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
goto end

:: Execute command routine that will echo out when error
:ExecuteCmd
setlocal
set _CMD_=%*
call %_CMD_%
if "%ERRORLEVEL%" NEQ "0" echo Failed exitCode=%ERRORLEVEL%, command=%_CMD_%
exit /b %ERRORLEVEL%

:error
endlocal
echo An error has occurred during web site deployment.
call :exitSetErrorLevel
call :exitFromFunction 2>nul

:exitSetErrorLevel
exit /b 1

:exitFromFunction
()

:end
endlocal
echo Finished successfully.

デプロイ

git push してからビルドが終わるまでの間は、アプリに繋がらない。 ステージング用のデプロイスロットを用意し、ステージング環境にデプロイした後、本番環境とスワップする必要がある。

git push staging main:master

おわりに

Azure Static Web Apps が使える場合は使いましょう。

Nuxt で vue-infinite-loading を使うメモ

Vue で無限スクロールを実装するなら、今だと vue-infinite-loading を使うのが良さげ。

github.com

ただ、Nuxt で使う場合はひと工夫必要だった。といっても、サーバーサイドレンダリング時は描画しないように <no-ssr></no-ssr> で囲むだけだったが。

<no-ssr>
  <infinite-loading @infinite="handleInfinite" v-if="hasNext">
  </infinite-loading>
</no-ssr>

redux-form で表形式入力的なフォーム

redux-form の FieldArray を使えば、 複数のデータを同時に編集して一括登録、 みたいなことができる。 表形式入力的なフォーム。 DataGrid もどき。

import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { createStore, combineReducers } from 'redux';
import { Provider, connect } from 'react-redux';
import {
  Field,
  FieldArray,
  reduxForm,
  reducer as formReducer
} from 'redux-form';
import {
  Grid,
  Row,
  Col,
  Button,
  Table,
  FormGroup,
  ControlLabel,
  FormControl
} from 'react-bootstrap';

// bootstrap の CSS を読み込む。
// webpack で CSS Module を使っていることが前提。
import '../node_modules/bootstrap/dist/css/bootstrap.css';

// カスタマーを複数追加するアクション
const ADD_CUSTOMERS = "ADD_CUSTOMERS";

// カスタマーを複数追加するときに使う Action Creator
function addCustomers(customers) {
  return { type: ADD_CUSTOMERS, customers };
}

// 初期状態
const initialState = {
  customers: [
    { code: "10", name: "香川", kana: "カガワ" },
    { code: "4", name: "本田", kana: "ホンダ" },
    { code: "5", name: "長友", kana: "ナガトモ" },
    { code: "9", name: "岡崎", kana: "オカザキ" }
  ]
};

// カスタマーを操作する Reducer
const customersReducer = (state = initialState, action) => {
  switch (action.type) {
    case ADD_CUSTOMERS:
      return Object.assign({}, state, {
        customers: [...state.customers, ...action.customers]
      });
    default:
      return state;
  }
};

// redux-form が提供する reducer も使う
const rootReducer = combineReducers({
  customers: customersReducer,
  form: formReducer
});

// アプリ唯一のストアを作成
const store = createStore(rootReducer);

// redux-form 用バリデーション関数
// customers の中身を forEach で回して 1 つずつチェックする。
const validate = (values) => {
  const errors = {};
  if (!values.customers || values.customers.length == 0) {
    errors.customers = { _error: "カスタマーを入力してください。" };
  } else {
    const customersArrayErrors = [];
    values.customers.forEach((customer, index) => {
      const customerErros = {};
      if (customer && !customer.code) {
        customerErros.code = "コードを入力してください。";
      }
      if (customer && !customer.name) {
        customerErros.name = "名前を入力してください。";
      }
      if (customer && !customer.kana) {
        customerErros.kana = "カナを入力してください。";
      }
      customersArrayErrors[index] = customerErros;
    });
    if (0 < customersArrayErrors.length) {
      errors.customers = customersArrayErrors;
    }
  }
  return errors;
};

// フォーム入力用コンポーネント
const renderField = ({ input, type, meta: { touched, error } }) => (
  <td>
    <FormControl {...input} type={type}/>
    {touched && error && <span className="text-danger">{error}</span>}
  </td>
);

// カスタマー表形式入力用コンポーネント
// FieldArray の component に渡したコンポーネントには
// props に fileds が渡ってくる。
const renderCustomers = ({ fields, meta: { touched, error } }) => (
  <Table>
    <thead>
      <tr>
        <th>コード</th>
        <th>名前</th>
        <th>カナ</th>
      </tr>
    </thead>
    <tbody>
      {fields.map((customer, index) =>
        <tr key={index}>
          <Field
            name={`${customer}.code`}
            type="text"
            component={renderField}/>
          <Field
            name={`${customer}.name`}
            type="text"
            component={renderField}/>
          <Field
            name={`${customer}.kana`}
            type="text"
            component={renderField}/>
        </tr>
      )}

      {
        touched && error &&
        <tr>
          <td><span className="text-danger">{error}</span></td>
          <td></td>
          <td></td>
        </tr>
      }

      <tr>
        <td>
          <Button type="button" onClick={() => fields.push()}>
            行追加
          </Button>
        </td>
        <td></td>
        <td></td>
      </tr>
    </tbody>
  </Table>
);

// カスタマー登録フォーム本体
class _CustomerTableForm extends Component {
  render() {
    const {
      handleSubmit,
      pristine,
      reset,
      submitting
    } = this.props;

    return (
      <form onSubmit={handleSubmit}>
        <FieldArray name="customers" component={renderCustomers} />

        <FormGroup>
          <Button bsStyle="primary"
                  type="submit"
                  disabled={submitting}>
            保存
          </Button> 
          <Button type="button"
                  disabled={pristine || submitting}
                  onClick={reset}>
            リセット
          </Button>
        </FormGroup>
      </form>
    );
  }
}

// カスタマー登録フォームを redux-form 化。
// form にはユニークな文字列を指定する。
const CustomerTableForm = reduxForm({
  form: "customerForm",
  validate
})(_CustomerTableForm);

// さらに connect でコンテナコンポーネント化。
// redux-form でラップしたコンポーネントには onSubmit の props が
// 生えている。
// submit 時の処理はコンテナコンポーネントで実装する。
const CustomerTableFormContainer = connect(
  state => {
    return {};
  },
  dispatch => {
    return {
      onSubmit: (values) => {
        dispatch(addCustomers(values.customers));
      }
    };
  }
)(CustomerTableForm);

// カスタマー一覧を表示するコンポーネント
class CustomerList extends Component {
  renderCustomers() {
    return this.props.customers.map(c =>
      <tr key={c.code}>
        <td>{c.code}</td>
        <td>{c.name}</td>
        <td>{c.kana}</td>
      </tr>
    );
  }

  render() {
    return (
      <Table>
        <thead>
          <tr>
            <th>コード</th>
            <th>名前</th>
            <th>カナ</th>
          </tr>
        </thead>
        <tbody>
          {this.renderCustomers()}
        </tbody>
      </Table>
    );
  }
}

const CustomerListContainer = connect(
  state => {
    return { customers: state.customers.customers };
  }
)(CustomerList);

class App extends Component {
  render() {
    return (
      <Grid>
        <Row>
          <Col md={12}>
            <CustomerTableFormContainer />
            <CustomerListContainer />
          </Col>
        </Row>
      </Grid>
    );
  }
}

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById("app")
);

だんだん、バリデーションのための関数を書くのがメンドクなってきた。

redux-form でも react-bootstrap のフォーム関連コンポーネントを使う

試行錯誤の末に、 react-bootstrap が提供する FormGroupFormControl といったフォーム関連コンポーネントを、 redux-form と一緒に使うことができた。

import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { createStore, combineReducers } from 'redux';
import { Provider, connect } from 'react-redux';
import {
  Field,
  reduxForm,
  reducer as formReducer
} from 'redux-form';
import {
  Grid,
  Row,
  Col,
  Table,
  FormGroup,
  ControlLabel,
  FormControl
} from 'react-bootstrap';

// bootstrap の CSS を読み込む。
// webpack で CSS Module を使っていることが前提。
import '../node_modules/bootstrap/dist/css/bootstrap.css';

// カスタマーを追加するアクション
const ADD_CUSTOMER = "ADD_CUSTOMER";

// カスタマーを追加するときに使う Action Creator
function addCustomer(customer) {
  return { type: ADD_CUSTOMER, customer };
}

// 初期状態
const initialState = {
  customers: [
    { code: "10", name: "香川", kana: "カガワ" },
    { code: "4", name: "本田", kana: "ホンダ" },
    { code: "5", name: "長友", kana: "ナガトモ" },
    { code: "9", name: "岡崎", kana: "オカザキ" }
  ]
};

// カスタマーを操作する Reducer
const customersReducer = (state = initialState, action) => {
  switch (action.type) {
    case ADD_CUSTOMER:
      return Object.assign({}, state, {
        customers: [...state.customers, action.customer]
      });
    default:
      return state;
  }
};

// redux-form が提供する reducer も使う
const rootReducer = combineReducers({
  customers: customersReducer,
  form: formReducer
});

// アプリ唯一のストアを作成
const store = createStore(rootReducer);

// redux-form 用バリデーション関数
const validate = (values) => {
  // 検証に失敗したら values と同じキーで
  // エラーメッセージをセットする。
  const errors = {};
  if (!values.code) {
    errors.code = "コードを入力してください。";
  }
  if (!values.name) {
    errors.name = "名前を入力してください。";
  }
  if (!values.kana) {
    errors.kana = "カナを入力してください。";
  }
  return errors;
};

// フォームの入力用コンポーネント。
// react-bootstrap のフォーム関連コンポーネントを使う。
class FormField extends Component {
  render() {
    // input と meta は redux-form が渡してくる props。
    // type や label は Field に渡した props がそのまま渡ってくる。
    // select や textarea に対応するために componentClass を受け取る。
    // select の option に対応するために children も受け取る。
    const {
      input,
      label,
      type,
      componentClass,
      children,
      meta: {
        touched,
        error
      }
    } = this.props;

    return (
      <FormGroup>
        <ControlLabel>
          {label}
        </ControlLabel>

        <FormControl
          {...input}
          type={type || "text"}
          componentClass={componentClass || "input"}
          >
          {children}
        </FormControl>

        {touched && error && <span className="text-danger">{error}</span>}
      </FormGroup>
    );
  }
}

// カスタマー登録フォーム本体
class _CustomerForm extends Component {
  render() {
    const {
      handleSubmit,
      pristine,
      reset,
      submitting
    } = this.props;

    return (
      <form onSubmit={handleSubmit}>
        <Field name="code"
               component={FormField}
               type="text"
               label="コード"/>
        <Field name="name"
               component={FormField}
               type="text"
               label="名前"/>
        <Field name="kana"
               component={FormField}
               type="text"
               label="カナ"/>

        <div className="form-group">
          <button className="btn btn-primary"
                  type="submit"
                  disabled={submitting}>
            追加
          </button> 
          <button className="btn btn-default"
                  type="button"
                  onClick={reset}
                  disabled={submitting || pristine}>
            クリア
          </button>
        </div>
      </form>
    );
  }
}

// カスタマー登録フォームを redux-form 化。
// form にはユニークな文字列を指定する。
const CustomerForm = reduxForm({
  form: "customerForm",
  validate
})(_CustomerForm);

// さらに connect でコンテナコンポーネント化。
// redux-form でラップしたコンポーネントには onSubmit の props が
// 生えている。
// submit 時の処理はコンテナコンポーネントで実装する。
const CustomerFormContainer = connect(
  state => {
    return {};
  },
  dispatch => {
    return {
      onSubmit: (values) => {
        dispatch(addCustomer(values));
      }
    };
  }
)(CustomerForm);

// カスタマー一覧を表示するコンポーネント
class CustomerList extends Component {
  renderCustomers() {
    return this.props.customers.map(c =>
      <tr key={c.code}>
        <td>{c.code}</td>
        <td>{c.name}</td>
        <td>{c.kana}</td>
      </tr>
    );
  }

  render() {
    return (
      <Table>
        <thead>
          <tr>
            <th>コード</th>
            <th>名前</th>
            <th>カナ</th>
          </tr>
        </thead>
        <tbody>
          {this.renderCustomers()}
        </tbody>
      </Table>
    );
  }
}

const CustomerListContainer = connect(
  state => {
    return { customers: state.customers.customers };
  }
)(CustomerList);

class App extends Component {
  render() {
    return (
      <Grid>
        <Row>
          <Col md={12}>
            <CustomerFormContainer />
            <CustomerListContainer />
          </Col>
        </Row>
      </Grid>
    );
  }
}

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById("app")
);

redux-form の Field は自身に渡された props を、component に指定したコンポーネントにほとんど渡してくれる。なので FormControl が必要とするものを Field に渡せばいいって寸法。

ライブラリのソースコードを読む習慣は大事だね。

redux-form 慣れてきた

React でフォームを実装するためのライブラリは、決定版と呼べるものが見当たらないので、一番スターが多い redux-form を使っている。

github.com

フォームの入力内容をストアに持つのはいかがなものかと思っていたのに、不思議と使っているうちに気にならなくなってきた。あと、v5 までは API がツラくて、何度も使うのをやめたくなったけど、v6 で API が刷新されてだいぶマシになった。

基本的にシンプルなフォームなら下記のように書ける。 onChange を書きまくらなくていいだけでも十分助かる。

import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { createStore, combineReducers } from 'redux';
import { Provider, connect } from 'react-redux';
import {
  Field,
  reduxForm,
  reducer as formReducer
} from 'redux-form';

// カスタマーを追加するアクション
const ADD_CUSTOMER = "ADD_CUSTOMER";

// カスタマーを追加するときに使う Action Creator
function addCustomer(customer) {
  return { type: ADD_CUSTOMER, customer };
}

// 初期状態
const initialState = {
  customers: [
    { code: "10", name: "香川", kana: "カガワ" },
    { code: "4", name: "本田", kana: "ホンダ" },
    { code: "5", name: "長友", kana: "ナガトモ" },
    { code: "9", name: "岡崎", kana: "オカザキ" }
  ]
};

// カスタマーを操作する Reducer
const customersReducer = (state = initialState, action) => {
  switch (action.type) {
    case ADD_CUSTOMER:
      return Object.assign({}, state, {
        customers: [...state.customers, action.customer]
      });
    default:
      return state;
  }
};

// redux-form が提供する reducer も使う
const rootReducer = combineReducers({
  customers: customersReducer,
  form: formReducer
});

// アプリ唯一のストアを作成
const store = createStore(rootReducer);

// redux-form 用バリデーション関数
const validate = (values) => {
  // 検証に失敗したら values と同じキーで
  // エラーメッセージをセットする。
  const errors = {};
  if (!values.code) {
    errors.code = "コードを入力してください。";
  }
  if (!values.name) {
    errors.name = "名前を入力してください。";
  }
  if (!values.kana) {
    errors.kana = "カナを入力してください。";
  }
  return errors;
};

// フォームの入力用コンポーネント。
// input と meta は redux-form が渡してくる props。
// type や label は Field に渡した props がそのまま渡ってくる。
const renderField = ({ input, type, label,meta: { touched, error } }) => (
  <div className="form-group">
    <label>{label}</label>
    <input {...input} type={type} className="form-control" />
    {touched && error && <span className="text-danger">{error}</span>}
  </div>
);

// カスタマー登録フォーム本体
class _CustomerForm extends Component {
  render() {
    const {
      handleSubmit,
      pristine,
      reset,
      submitting
    } = this.props;

    return (
      <form onSubmit={handleSubmit}>
        <Field name="code"
               component={renderField}
               label="コード"/>
        <Field name="name"
               component={renderField}
               label="名前"/>
        <Field name="kana"
               component={renderField}
               label="カナ"/>

        <div className="form-group">
          <button className="btn btn-primary"
                  type="submit"
                  disabled={submitting}>
            追加
          </button> 
          <button className="btn btn-default"
                  type="button"
                  onClick={reset}
                  disabled={submitting || pristine}>
            クリア
          </button>
        </div>
      </form>
    );
  }
}

// カスタマー登録フォームを redux-form 化。
// form にはユニークな文字列を指定する。
const CustomerForm = reduxForm({
  form: "customerForm",
  validate
})(_CustomerForm);

// さらに connect でコンテナコンポーネント化。
// redux-form でラップしたコンポーネントには onSubmit の props が
// 生えている。
// submit 時の処理はコンテナコンポーネントで実装する。
const CustomerFormContainer = connect(
  state => {
    return {};
  },
  dispatch => {
    return {
      onSubmit: (values) => {
        dispatch(addCustomer(values));
      }
    };
  }
)(CustomerForm);

// カスタマー一覧を表示するコンポーネント
class CustomerList extends Component {
  renderCustomers() {
    return this.props.customers.map(c =>
      <tr key={c.code}>
        <td>{c.code}</td>
        <td>{c.name}</td>
        <td>{c.kana}</td>
      </tr>
    );
  }

  render() {
    return (
      <table>
        <thead>
          <tr>
            <th>コード</th>
            <th>名前</th>
            <th>カナ</th>
          </tr>
        </thead>
        <tbody>
          {this.renderCustomers()}
        </tbody>
      </table>
    );
  }
}

const CustomerListContainer = connect(
  state => {
    return { customers: state.customers.customers };
  }
)(CustomerList);

class App extends Component {
  render() {
    return (
      <div id="wrapper">
        <CustomerFormContainer />
        <CustomerListContainer />
      </div>
    );
  }
}

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById("app")
);

Browserify から Webpack に移行した

ES2015 + React の開発環境を Browserify で構築していたけど、 近いうち CSS もバンドルすることになりそうなので、 Webpack に移行することにした。

ブログに手順をメモしておく。 新規にプロジェクトを作成するときも同じ手順でいけるはず。

npm で必要なパッケージをインストール。

# 実際は 1 行
$ npm install --save-dev webpack \
  webpack-dev-server \
  babel-core \
  babel-loader \
  babel-preset-es2015 \
  babel-preset-react \
  file-loader

Webpack の設定ファイル webpack.config.js はこんな感じで書いてみた。

module.exports = {
  entry: {
    javascript: __dirname + "/src/index.jsx",
    html: __dirname + "/src/index.html"
  },

  output: {
    path: __dirname + "/dist",
    filename: "bundle.js"
  },

  module: {
    loaders: [
      {
        test: /\.js[x]?$/,
        exclude: /node_modules/,
        loader: "babel",
        query: {
          presets: ["react", "es2015", "stage-1"]
        }
      },
      {
        test: /\.html$/,
        loader: "file?name=[name].[ext]"
      }
    ]
  },

  devtool: "source-map",

  resolve: {
    extensions: ["", ".js", ".jsx"]
  }
};

あわせて package.jsonscripts も修正しておく。

{
  "scripts": {
    "start": "webpack-dev-server",
    "build": "webpack"
  }
}

これで npm run build でビルドが、`npm start' で開発サーバーが起動できるようになった。 webpack-dev-server 便利。