0
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

MithrilのTodo ListをはじめからていねいにTypescriptで(1)

Last updated at Posted at 2017-10-11

MithrilのTodo ListをはじめからていねいにTypescriptで(1)

概要

Redux ExampleのTodo Listをはじめからていねいに(1)を参考にした、
Redux ExampleのTodo ListをはじめからていねいにをTypescriptでを以前行った。
今回は軽量のvirtual domフレームワークのmithrilでなぞって、どれくらい異なるかを試してみたいと思う。
最近のreduxの流れも追ってみたい。
mvcだと、mithrilはシンプルでけっこうキレイに書けている気がするけど、reduxに載せようと思うとどうか。
TodoMVC(v0.2)
mithril-todomvc(v1.1, coffee)

mithrilもreduxも勉強中なので、間違っていたりもっとよいやり方を知っていたら、教えていただけると幸い。
まずはHello Worldまで。

Typescriptは以下のサイトをまず読んでおくべきだった。
型の国のTypeScript

環境

エディタ

Visual Studio Codeを使用。@typesで定義をインストール。

package.json
{
  "name": "develop",
  "version": "1.0.0",
  "description": "開発用テスト環境",
  "devDependencies": {
    "@types/mithril": "^1.1.9",
    "@types/react": "^16.0.3",
    "@types/redux": "^3.6.0",
    "@types/redux-actions": "^2.2.2",
    "@types/redux-form": "^7.0.2",
    "@types/redux-logger": "^3.0.4",
    "@types/redux-saga": "^0.10.5",
    "tslint": "^4.5.1",
    "typescript": "^2.1.5"
  },
  "license": "MIT",
}

動作環境

  • windows10
  • vagrant1.9.7
  • virtualbox5.1.26
  • ubuntu-16.04
  • Docker version 17.06.2-ce, build cec0b72
  • docker-compose version 1.16.1, build 6d1ac21

仮想環境は192.168.50.10のIPアドレスで起動。

ディレクトリ構成

hello world時

redux-todo-mithril
  + bin  # docker-compose省略
  + dist # 出力先
  - docker
    -  config # 各種設定ファイル
      - .babelrc
      - tsconfig.json
      - tslint.json
      - webpack.config.babel.js
    - webpack
      - Dockerfile
    - docker-compose.yml
  - public
    - index.html
  - src
    - components
      - App.tsx
    - app.ts

設定ファイル

docker/config/.babelrc
{
  "presets": [
    ["env", {
      "targets": {
        "browsers": ["last 2 versions"]
      }
    }]
  ],
  "plugins": [
    ["transform-react-jsx", {
        "pragma": "m"
    }]
  ]
}
docker/config/tsconfig.json
{
    "compilerOptions": {
        "module": "commonjs",
        "target": "esnext",
        "outDir": "./dist",
        "lib": [
            "dom", 
            "es2015"
        ],
        "jsx": "preserve",
        "noImplicitAny": false,
        "strictNullChecks": true,
        "sourceMap": false
    },
    "include":[
        "./src/**/*" 
    ]
}
docker/config/tslint.json
{
  "extends": ["tslint:latest", "tslint-config-airbnb"],
	"rules": {
    "strict-boolean-expressions": [true, "allow-boolean-or-undefined"],
    "no-empty-interface": [false],
    "no-submodule-imports": [false]
  },
  "typeCheck": true
}
docker/config/webpack.config.babel.js
import webpack from 'webpack';

// 環境変数から本番環境を判断。
const isProduction = process.env.NODE_ENV === 'production';

// source-map : 正確・最小限。コンパイル速度低
// eval-source-map: 正確。コンパイル速度低。再作成速度中。
const devtool = isProduction ? 'source-map' : 'eval-source-map';

// productionの場合
const plugins = isProduction ? 
[
  new webpack.DefinePlugin({
    'process.env': {
      NODE_ENV: '"production"',
    },
  }),
  // code-splittingを有効にするプラグイン
  new webpack.optimize.CommonsChunkPlugin({
    name: "vendor",
    filename: "vendor.mithril.js",
    minChunks: Infinity,}),
  new webpack.LoaderOptionsPlugin({
    minimize: true,
  }),
] :
  // 開発用設定。
[
    // hot loadを有効にするためのプラグイン
    new webpack.HotModuleReplacementPlugin(),
    // mithrilをグローバル変数として登録。これをしないとjsxのみのファイルでm not findのエラーとなる
    new webpack.ProvidePlugin({
      m: "mithril",
  })
];

export default {
  // src以下のソースをビルド対象とする
  context: __dirname + '/src',
  // エントリーポイントとしてapp.jsを起点にビルドする
  entry: {
    todo: './app.ts',
    // code-splitting用の設定
    vendor: ['mithril', 'redux', 'redux-actions', 'redux-saga']
  },
  output: {
    path: __dirname + '/dist',
		filename: "[name].mithril.js",
  },
  // importするときに、以下の配列に登録した拡張子は省略できる
  resolve: {
    extensions: [".js", ".ts", ".tsx"]
  },
  module: {
    rules: [
      // .ts, .tsxに一致する拡張子のファイルはts-loaderを通してトランスパイル
      { test: /\.tsx?$/, exclude: /node_modules/, loader: ["babel-loader", "ts-loader"] }
    ]
  },
  // プラグイン設定
  plugins,
  // source-mapを出力して、ブラウザの開発者ツールからデバッグできるようにする。
  devtool,
  // 開発サーバの設定
  devServer: {
    // public/index.htmlをデフォルトのホームとする
    contentBase: './public',
    // バンドルしたファイルを/assets/js/フォルダに出力したものとする。
    publicPath: "/assets/js/",
    // インラインモード
    inline: true,
    // 8080番ポートで起動
    port: 8080,
    // dockerのコンテナ上でサーバを動かすときは以下の設定で全ての接続を受け入れる
    host:"0.0.0.0",
    // hot loadを有効にする
    hot: true,
    // ログレベルをinfoに
    clientLogLevel: "info",
  },
  // vagrantの仕様でポーリングしないとファイルの変更を感知できない
  watchOptions: {
    aggregateTimeout: 300,
    // 5秒毎にポーリング
    poll: 5000
  }
};
docker/webpack/Dockerfile
FROM node:8.6.0

# コンテナ上の作業ディレクトリ作成
WORKDIR /app

# 後で確認出来るようにpackage.jsonを作成
RUN npm init -y

# typescript
RUN npm i -D typescript

# tslint
RUN npm i -D tslint
RUN npm i -D tslint-config-airbnb

# typedoc
RUN npm i -D typedoc 

# ビルドツール
RUN npm i -D webpack

# 開発用サーバ
RUN npm i -D webpack-dev-server

# es6用トランスパイラ
RUN npm i -D babel-loader
RUN npm i -D babel-core
RUN npm i -D babel-cli
RUN npm i -D babel-preset-es2015
RUN npm i -D babel-preset-env
RUN npm i -D babel-plugin-transform-react-jsx
# async
RUN npm i -D babel-preset-es2017

# webpack用typescript loader
RUN npm i -D ts-loader

# jsViewライブラリmithril
RUN npm i -S mithril

# フレームワーク
RUN npm i -S redux
RUN npm i -S redux-actions
RUN npm i -S redux-saga
RUN npm i -S redux-logger

RUN sed -i -e "s/\(\"scripts\": {\)/\1\n    \"tslint\": \"tslint -p 'tsconfig.json' --type-check\",/g" /app/package.json
RUN sed -i -e "s/\(\"scripts\": {\)/\1\n    \"tsc\": \"tsc\",/g" /app/package.json
RUN sed -i -e "s/\(\"scripts\": {\)/\1\n    \"babel\": \"babel\",/g" /app/package.json
RUN sed -i -e "s/\(\"scripts\": {\)/\1\n    \"typedoc\": \"typedoc\",/g" /app/package.json
RUN sed -i -e "s/\(\"scripts\": {\)/\1\n    \"webpack\": \"webpack\",/g" /app/package.json
RUN sed -i -e "s/\(\"scripts\": {\)/\1\n    \"dev-server\": \"webpack-dev-server\", /g" /app/package.json
docker/docker-compose.yml
version: '3'
services:

  # トランスパイル(ts)
  tsc:
    build: ./webpack
    volumes: 
      - ./config/tsconfig.json:/app/tsconfig.json
      - ../src:/app/src
      - ../dist/transpiled-tsc:/app/dist
    command: [npm, run, tsc]

  # トランスパイル(babel)
  babel:
    build: ./webpack
    volumes: 
      - ./config/.babelrc:/app/.babelrc
      - ../dist/transpiled-tsc:/app/src
      - ../dist/transpiled-babel:/app/dist
    depends_on:
      - tsc
    command: [npm, run, babel, --, src, -d, dist]

  # 構文チェック(ts)
  tslint:
    build: ./webpack
    volumes: 
      - ./config/tsconfig.json:/app/tsconfig.json
      - ./config/tslint.json:/app/tslint.json
      - ../src:/app/src
    command: [npm, run, -s, tslint, --, -c, tslint.json, 'src/**/*.ts', 'src/**/*.tsx']

  # Apiドキュメント
  typedoc:
    build: ./webpack
    volumes: 
      - ./config/tsconfig.json:/app/tsconfig.json
      - ../src:/app/src
      - ../dist/apidoc:/app/typedoc
    command: [npm, run, typedoc, --, --target, es6, --jsx, preserve, --ignoreCompilerErrors, --exclude, '**/*.test.ts', --out, ./typedoc/, ./src/]

  # ビルドツール
  webpack:
    build: ./webpack
    volumes:
      - ./config/.babelrc:/app/.babelrc
      - ./config/tsconfig.json:/app/tsconfig.json
      - ./config/webpack.config.babel.js:/app/webpack.config.babel.js
      - ../src:/app/src
      - ../dist/bundle-webpack:/app/dist
      - ../public:/app/public
    command: [npm, run, dev-server]
    ports:
      -  8080:8080

1. Hello World

public/index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <title>Todosサンプル</title>
  <script src="assets/js/vendor.mithril.js"></script>
</head>
<body>
  <h1>Todosサンプル</h1>
  <div id="app"></div>
  <script src="assets/js/todo.mithril.js"></script>
</body>
</html>
src/components/App.tsx
import * as m from 'mithril';
// tslint:disable-next-line: no-duplicate-imports
import { ClassComponent, Vnode } from 'mithril';

interface IAttr {}

export default class App implements  ClassComponent<IAttr> {
  public view(vnode: Vnode<IAttr, this>): Vnode<IAttr, HTMLElement> {
    return (<div> Hello World!!! mithril</div>);
  }
}

src/app.ts
import * as m from 'mithril';
import App from './components/App';

const root = document.getElementById('app');
m.render(root, m(App));

docker-compose upを行い、
http://192.168.50.10:8080/webpack-dev-server/ もしくはhttp://192.168.50.10:8080/に接続で確認。

この時点のソース

2. actionCreatorで発行したactionをreducerに渡してstoreのstateを更新する

Reduxの流れについては以下の記事が分かりやすかった。
たぶんこれが一番分かりやすいと思います React + Redux のフロー図解

Acitions

src/actions/index.ts
import { Action } from 'redux';
import { createAction } from 'redux-actions';

export const ADD = 'ADD_TODO';

/**
 * id保存用
 */
let nextTodoId = 0;

/**
 * actionを発行する関数。
 * actionは以下のオブジェクト
 * {
 *  type: 'ADD_TODO';
 *  payload: {
 *    id: number;
 *    text: string;
 *  }
 * }
 */
export const addTodo = createAction(ADD, text => ({ text, id: nextTodoId++ })
);

Reducers

src/models/TodoState.ts
export class TodoState {
  constructor(
    public id: number,
    public text: string,
  ) {}
}
src/reducers/index.ts
import { handleActions } from 'redux-actions';
import { ADD } from '../actions';
import { TodoState } from '../models/TodoState';

export default handleActions({
  [ADD]: (state,  { payload }) => {
    // actionTypeがADDのとき、
    // 新しいTodoStateを返す
    return new TodoState(payload.id, payload.text);
  },
},                           new TodoState(0, '')); // 初期状態

Store

src/app.ts
import * as m from 'mithril';
import App from './components/App';
import {createStore } from 'redux';
import { addTodo } from './actions'
import reducers from './reducers';
const store = createStore(reducers)

store.dispatch(addTodo('Hello World!'))
console.log(store.getState()) 

const root = document.getElementById('app');
m.render(root, m(App));

この時点のソース

3. storeで保持したstateをViewで表示する

Actions

reducersで受け取るアクションを明確にするため、interfaceを追加

src/actions/index.ts
import { Action } from 'redux';
import { createAction } from 'redux-actions';

export const ADD = 'ADD_TODO';

export interface IAddTodoAction extends Action {
  type: 'ADD_TODO';
  payload: {
    id: number;
    text: string;
  };
}

// 省略

Reducers

TodoStateの配列を返すように変更。

src/reducers/todos.ts
import { handleActions } from 'redux-actions';
import { ADD, IAddTodoAction } from '../actions';
import { TodoState } from '../models/TodoState';

export default handleActions({
  [ADD]: (state: TodoState[],  { payload }: IAddTodoAction) => {
    return [...state, new TodoState(payload.id, payload.text)];
  },
}, []);

reducerのindexをcombineに。

src/reducers/index.ts
import { combineReducers } from 'redux';
import todos from './todos';

export default combineReducers({
  todos,
});

Components

TodoListの作成

src/components/Todo.tsx
import * as m from 'mithril';
import { ClassComponent, Vnode } from 'mithril';

interface IAttr {
  text: string;
}

export default class Todo implements  ClassComponent<IAttr> {
  public view(vnode: Vnode<IAttr, this>): Vnode<IAttr, HTMLElement> {
    const { text } = vnode.attrs;
    return (<li>
      {text}
    </li>);
  }
}
src/components/TodoList.tsx
import * as m from 'mithril';
import { ClassComponent, Vnode } from 'mithril';
import TodoState from '../models/TodoState';
import Todo from './Todo';

interface IAttr {
  props: {
    todos: TodoState[],
  };
}

export default class TodoList implements  ClassComponent<IAttr> {
  public view(vnode: Vnode<IAttr, this>): Vnode<IAttr, HTMLElement> {
    const { todos } = vnode.attrs.props;
    return (
<ul>
  {todos.map(todo => <Todo {...todo} />)}
</ul>);
  }
}

VisbleTodoListはこの後に出てくるContainersの部分を参照。

src/components/App.tsx
import * as m from 'mithril';
import { ClassComponent, Vnode } from 'mithril';
import VisibleTodoList from '../containers/VisibleTodoList';
interface IAttr {}

export default class App implements  ClassComponent<IAttr> {
  public view(vnode: Vnode<IAttr, this>): Vnode<IAttr, HTMLElement> {
    return (<div>
      <VisibleTodoList />
    </div>);
  }
}

connect

mithril-reduxのnpmは2年前だったので自作してみた。

以下のサイトをみて必要そうな機能をピックアップ。
ReactとReduxを結ぶパッケージ「react-redux」についてconnectの実装パターンを試す

src/mithril-redux.ts
import * as m from 'mithril';
import { ClassComponent, Vnode } from 'mithril'; // tslint:disable-line: no-duplicate-imports
let store;

interface IAttr {
  store: any,
 }
/**
 * ラップしたコンポーネントにstore情報を渡す
 * connect関数が使用できるようにする。
 * 
 * @export
 * @class Provider
 * @implements {ClassComponent<IAttr>}
 */
export default class Provider implements  ClassComponent<IAttr> {
  /**
   * storeをセットしてconnect関数を使用可能にする。
   * 
   * @param {any} vnode 
   * @memberof Provider
   */
  oninit(vnode:Vnode<IAttr, {}>){
    store = vnode.attrs.store;
  }
  /**
   * App内でstateを参照できるようにする。
   *
   * @param {Vnode} vnode
   * @returns
   * @memberof Provider
   */
  public view(vnode:Vnode<IAttr, {}>) {
    const app = vnode.children[0];
    return m(app.tag, {
      props: {
        state: store.getState(),
      },
    });
  }
}


/**
 * ReduxとMithrilをバインディングする。
 * 
 * @export
 * @param {*} [mapStateToProps=(state) => ({ state })] vnode.attrs.props.stateにアクセス可能となる
 * @param {*} [mapDispatchToProps=(dispatch) => ({ dispatch })] vnode.attrs.props.dispatchにアクセス可能となる
 * @returns 
 */
export function connect(
  mapStateToProps: any = (state) => ({ state }),
  mapDispatchToProps: any = (dispatch) => ({ dispatch }),
) {
  if(!mapStateToProps){
    mapStateToProps = (state) => ({ state });
  }
  if(!mapDispatchToProps){
    mapDispatchToProps =  (dispatch) => ({ dispatch });
  }
  return (vnode) => {
    return class implements  ClassComponent<{}> {
      view({attrs, children}:Vnode<{},{}>) {
        const props = getProps(mapStateToProps, mapDispatchToProps, attrs);
        return m(vnode, { props }, children);
      }
    };
  };
}

/**
 * propsにstateを渡す
 * @param props
 * @param mapStateToProps
 */
const stateToProps = (props, mapStateToProps) => {
  const map = mapStateToProps(store.getState());
  Object.assign(props, map);
  return props;
};

/**
 * propsにdispatchを渡す
 * @param props
 * @param mapDispatchToProps
 * @param ownProps
 */
const dispatchToProps = (props, mapDispatchToProps, ownProps) => {
  const map = mapDispatchToProps(store.dispatch, ownProps);
  for (const prop in map) {
    props[prop] = map[prop];
  }
  return props;
};

/**
 * propsを作成。
 *
 * @param {any} mapStateToProps
 * @param {any} mapDispatchToProps
 * @param {any} ownProps
 */
function getProps(mapStateToProps, mapDispatchToProps, ownProps) {
  let props = { };

  props = stateToProps(props, mapStateToProps);
  props = dispatchToProps(props, mapDispatchToProps, ownProps);
  return props;
}

Containers

connectを思料する。

src/containers/VisbleTodoList.ts
import TodoList from '../components/TodoList';
import { connect } from '../mithril-redux';
import TodoState from '../models/TodoState';

interface IStateToProps {
  todos: TodoState[];
}

const mapStateToProps = (store): IStateToProps => {
  return { todos: store.todos };
};

export default connect(
  mapStateToProps,
)(TodoList);

Store

作成したProviderを使用。m.render(root, m(Provider,{ store }, m(App)));のようにするとProviderの中でconnectが使用可能。

src/app.ts
import * as m from 'mithril';
import App from './components/App';
import {createStore } from 'redux';
import { addTodo } from './actions'
import reducers from './reducers';
import Provider from './mithril-redux';

const todos = reducers;
const store = createStore(todos)

store.dispatch(addTodo('Hello World!'))
console.log(store.getState()) 

const root = document.getElementById('app');

m.render(root, m(Provider,{ store }, m(App)));

この時点のソース

4. フォームからtodoを追加

src/containers/AddTodo.tsx
import * as m from 'mithril';
import { ClassComponent, Vnode } from 'mithril'; // tslint:disable-line: no-duplicate-imports
import { addTodo } from '../actions';
import { connect } from '../mithril-redux';

interface IAttr {}

function mapDispatchToProps(dispatch) {
  return {
    onClick(text: string) {
      dispatch(addTodo(text));
    },
  };
}

interface IDispatch {
  onClick(text: string): void;
}

class AddTodoComponent implements  ClassComponent<IAttr> {
  private value: string;
  public view(vnode) {
    const { onClick } = vnode.attrs.props;
    return (
      <div>
        <input
          oninput={m.withAttr('value', value => this.value = value)}
          value={this.value}
        />
        <button
          onclick={
            () => {
              const val = this.value;
              this.value = '';
              onClick(val); // dispatchのタイミングで画面が更新される。
            }
          }
        >
          Add Todo
        </button>
      </div>
    );
  }
}

export default connect(null, mapDispatchToProps)(AddTodoComponent);
src/components/App.tsx
import * as m from 'mithril';
import { ClassComponent, Vnode } from 'mithril';  // tslint:disable-line: no-duplicate-imports
import AddTodo from '../containers/AddTodo';
import VisibleTodoList from '../containers/VisibleTodoList';

interface IAttr {}

export default class App implements  ClassComponent<IAttr> {
  public view(vnode: Vnode<IAttr, this>): Vnode<IAttr, HTMLElement> {
    return (
    <div>
      <AddTodo />
      <VisibleTodoList />
    </div>);
  }
}
src/app.ts
import * as m from 'mithril';
import App from './components/App';
import {createStore } from 'redux';
import { addTodo } from './actions'
import reducers from './reducers';
import Provider from './mithril-redux';

const store = createStore(reducers)

store.dispatch(addTodo('Hello World!'))

const root = document.getElementById('app');

function render(){
  m.render(root, m(Provider,{ store }, m(App)));
}
render();
store.subscribe(render);

この時点のソース

描画について

今回はm.renderを使って以下のようにしている。

function render(){
  m.render(root, m(Provider,{ store }, m(App)));
}
render();
store.subscribe(render);

以下のように、m.mountを使った場合、m.withAttrの値が変更されたタイミングでも画面の描画が行われる。
これはreduxの流れとはずれてわかりにくくなってしまうと考え、今回は避けた。

m.mount(root, {view: ()=>m(Provider,{ store }, m(App))});
store.subscribe(m.redraw)

続き →
MithrilのTodo ListをはじめからていねいにTypescriptで(2)

参考

TodoMVC
mithril(ja)
mithril(en)
Vuex(ja)
Vuex(en)
React
Angular
Riot(ja)
Polymer
Redux
mithril-redux
mithril-todomvc
Mithril.js 試してみた(1) todo アプリを作り始める所まで
俺たちのMithril.jsがこんなに遅いわけがない
redux-sagaで非同期処理と戦う
redux-actions
connectを試す
redux図解
redux-actionsについて学ぼう
mithril-redux-starter
redux-saga
TypeScript2系のコンパイラのオプション一覧
Revised Revised TypeScript in Definitelyland
airbnb
node規約
google規約
ESLint 最初の一歩
Microsoft Typescript
TypeScriptのインターフェースに「I」のプリフィクスを付けるのはよくないのか
tslint
Redux ExampleのTodo Listをはじめからていねいに(1)
react習得記まとめ

0
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?