2
2

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 + Redux のTodo ListをTypescriptで(1)

Last updated at Posted at 2017-10-15

Mithril + Redux のTodo ListをTypescriptで

MithrilのTodo ListをはじめからていねいにTypescriptでで基本のTodoListを作った。
ここに、MithrilTodoMVCに相当する機能を付け加えていきたい。

今回はデータの保存とルーティングまで。

storageの実装

dispatch -> reducer -> store -> viewの流れに、redux-sagaを加える。

dispath -> saga -> reducer -> store -> view の流れにして、sagaで保存をする。

環境の準備

redux-sagaでyieldを使用するため、babel-polyfillを追加。

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
RUN npm i -S babel-polyfill

# 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 -p tsconfig.json \",/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

vendorにbabel-polyfillを追加。

docker/config/webpack.config.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', 'babel-polyfill']
  },
  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
  }
};

idの作り方の変更

保存したファイルを読み込んだ時、今の実装だと再びidが0から発番されて、重複したidができてしまう。
todomvcを参考にして変更

src/actions/todos.ts
import { Action } from 'redux';
import { createAction } from 'redux-actions';
export const ADD = 'ADD_TODO';
export const TOGGLE = 'TOGGLE_TODO';
export interface IAddTodoAction extends Action {
  type: 'ADD_TODO';
  payload: {
    text: string;
  };
}
export interface IToggleTodoAction extends Action {
  type: 'TOGGLE_TODO';
  payload: {
    id: number;
  };
}
export const addTodo    = createAction(ADD,    (text: string) => ({ text}));
export const toggleTodo = createAction(TOGGLE, (id: number) => ({ id }));
src/models/TodoState.ts
const uniqueId = (() => {
  let count = 0;
  return () => {
    count += 1;
    return count;
  };
})();

export default class TodoState {
  public id: number;
  public text: string;
  public completed: boolean = false;
  constructor(data) {
    this.id = uniqueId();
    this.text = data.text;
    this.completed = data.completed || false;
  }
}

storageを追加。

src/browser/storage.ts
const STORAGE_ID = 'todos-mithril';
export async function get() {
  return JSON.parse(localStorage.getItem(STORAGE_ID) || '[]'); // tslint:disable-line
}
export async function put(todos) {
  localStorage.setItem(STORAGE_ID, JSON.stringify(todos));
}

actionを追加

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

export const GET_REQUEST = 'TODO_LIST_GET_REQUESTED';
export const getRequsetTodoList = createAction(GET_REQUEST);

export const GET_FAILED = 'TODO_LIST_GET_FAILED';
export const getFailureTodoList = createAction(GET_FAILED, message => message);

export const GET_SUCCESS = 'TODO_LIST_GET_SUCCEEDED';
export const getSuccessTodoList = createAction(GET_SUCCESS, todoList => ({ todoList }));

export const PUT_REQUEST = 'TODO_LIST_PUT_REQUESTED';
export const putRequsetTodoList = createAction(PUT_REQUEST, todoList => ({ todoList }));

export const PUT_FAILED = 'TODO_LIST_PUT_FAILED';
export const putFailureTodoList = createAction(PUT_FAILED, message => message);

export const PUT_SUCCESS = 'TODO_LIST_PUT_SUCCEEDED';
export const putSuccessTodoList = createAction(PUT_SUCCESS, todoList => ({ todoList }));

sagaの追加

関数 意味
put Actionをdispatchする
call Promiseの完了を待つ
select stateを取得
takeEvery 指定したActionのdispatchを待って、そのActionを引数としてタスクを起動
src/sagas/index.ts
import { takeEvery } from 'redux-saga/effects';
import { GET_REQUEST, PUT_REQUEST } from '../actions/storage';
import { ADD, TOGGLE } from '../actions/todos';
import { addTodoList, getTodoList, putTodoList, toggleTodo } from './todos';
function* mySaga() {
  yield takeEvery(ADD, addTodoList);
  yield takeEvery(TOGGLE, toggleTodo);
  yield takeEvery(GET_REQUEST, getTodoList);
  yield takeEvery(PUT_REQUEST, putTodoList);
}
export default mySaga;
src/sagas/todos.ts
import { call, put, select, takeEvery } from 'redux-saga/effects';
import { GET_FAILED, GET_REQUEST, GET_SUCCESS,
         PUT_FAILED, PUT_REQUEST, PUT_SUCCESS } from '../actions/storage';
import { get as getTodo, put as putTodo } from '../browser/storage';
import TodoState from '../models/TodoState';

// ワーカー Saga:ADD_TODO Action によって起動する
export function* addTodoList(action: {type: string, payload: {text}}) {
  const todos = yield select((state: any) => state.todos);
  const { text } = action.payload;
  const todoList = [...todos, new TodoState({ text })];
  yield put({ type: PUT_REQUEST, payload:{ todoList } });
}
// ワーカー Saga:TOGGLE_TODO Action によって起動する
export function* toggleTodo(action: {type: string, payload: {id}}) {
  const todos = yield select((state: any) => state.todos);
  const { id } = action.payload;
  const todoList = todos.map((t) => {
    // actionCreatorに渡したidと一致するtodoのみ処理
    if (t.id !== id) {
      return t;
    }
    // completedだけを反転
    return  new TodoState({ id:t.id, text:t.text, completed: !t.completed });
  });
  yield put({ type: PUT_REQUEST, payload:{ todoList } });
}

// ワーカー Saga:PUT_REQUEST Action によって起動する
export function* putTodoList(action: {type: string, payload: {todoList: TodoState[]}}) {
  try {
    yield call(putTodo, action.payload.todoList);
    yield put({ type: PUT_SUCCESS, payload:{ todoList: action.payload.todoList } });
  } catch (e) {
    yield put({ type: PUT_FAILED, message: e.message });
  }
}

// ワーカー Saga:GET_REQUEST Action によって起動する
export function* getTodoList(action: {type: string;}) {
  try {
    const todoList: TodoState[] = yield call(getTodo);
    yield put({ type: GET_SUCCESS, payload:{ todoList } });
  } catch (e) {
    yield put({ type: GET_FAILED, message: e.message });
  }
}

reducerの修正

ADDとTOGGLEをSagaに移したので削除。
PUT_SUCCESSとGET_SUCCESSを追加。やることは同じなので、統一したほうがよいだろうか。。。

src/reducers/todos.ts
import { handleActions } from 'redux-actions';
import { GET_SUCCESS, PUT_SUCCESS } from '../actions/storage';
import TodoState from '../models/TodoState';

export default handleActions({
  [GET_SUCCESS]: (state: TodoState[],  { payload: { todoList } }: any) => {
    return todoList.map(todo => new TodoState(todo));
  },
  [PUT_SUCCESS]: (state: TodoState[],  { payload: { todoList } }: any) => {
    return todoList;
  },
},                           []);

sagaとstoreの紐付け

src/store.ts
import { applyMiddleware, createStore } from 'redux';
import createSagaMiddleware from 'redux-saga';
import { createLogger } from 'redux-logger';
import reducers from './reducers';
import sagas from './sagas';

// Saga ミドルウェアを作成する
const sagaMiddleware = createSagaMiddleware();

// Store にマウントする
const store = createStore(
  reducers,
  applyMiddleware(sagaMiddleware, createLogger()),
);

// Saga を起動する
sagaMiddleware.run(sagas);
export default store;
src/app.ts
import * as m from 'mithril';
import App from './components/App';
import {createStore } from 'redux';
import { addTodo, toggleTodo } from './actions/todos'
import { setVisibilityFilter, COMPLETED } from './actions/filter'
import {getRequsetTodoList, putRequsetTodoList } from './actions/storage';
import reducers from './reducers';
import Provider from './mithril-redux';
import store from './store';

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

// storeからTodosListを取得
store.dispatch(getRequsetTodoList());

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

この時点のソース

ルーティング機能

mithrilのm.routeを使うと、dispatchのタイミングとredrawのタイミングがうまく制御できない。
page.jsを使ってルーティングを行う。

docker/webpack/Dockerfile
// 省略
npm i -S page
//省略
docker/config/webpack.config.babel.js
// 省略
    vendor: ['mithril', 'redux', 'redux-actions', 'redux-saga', 'babel-polyfill', 'page']
// 省略
src/app.ts
import * as m from 'mithril';
import * as page from 'page';
import App from './components/App';
import {createStore } from 'redux';
import { toggleTodo } from './actions/todos'
import { setVisibilityFilter } from './actions/filter'
import {getRequsetTodoList, putRequsetTodoList } from './actions/storage';
import reducers from './reducers';
import Provider from './mithril-redux';
import store from './store';

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

store.dispatch(getRequsetTodoList());

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

page('/', (ctx)=>{
  if(ctx.hash){
    store.dispatch(setVisibilityFilter(ctx.hash));
  }
});
page();
store.subscribe(render);

aタグのURLを#filterに変更

src/components/Link.tsx
import * as m from 'mithril';
import { ClassComponent, Vnode } from 'mithril';  // tslint:disable-line: no-duplicate-imports
interface IAttr {
  props: {
    active: boolean;
    filter: string;
  };
}
export default class Link implements  ClassComponent<IAttr> {
  public view({ children, attrs:{ props } }: Vnode<IAttr, this>) {
    if (props.active) {
      return <span>{children}</span>;
    }

    return (
    <a href={`/#${props.filter}`} >
      {children}
    </a>);
  }
}

propsにfilterを追加。onClickを削除。

import { setVisibilityFilter, VisibilityFilterType  } from '../actions/filter';
import Link from '../components/Link';
import { connect } from '../mithril-redux';

interface IOwnProps {
  filter: VisibilityFilterType;
}

const mapStateToProps = (state, ownProps: IOwnProps) => {
  return {
    active: ownProps.filter === state.visibilityFilter,
    filter: ownProps.filter
  };
};

export default connect(
  mapStateToProps,
  null,
)(Link);

この時点のソース

続き。
Mithril + Redux のTodo ListをTypescriptで(2)

参考

mithril todo mvc
todo-redux-saga
redux-sagaを触ろうとしてそれ以前に整理しまくった話
実践 Redux Saga
redux-saga での stateの取り方 備忘録
redux-sagaで非同期処理と戦う
Reduxで非同期処理を行う方法を解説した記事の翻訳
moducks で Redux-Saga の冗長さと戦う
page.js

2
2
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
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?