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で定義をインストール。
{
"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
設定ファイル
{
"presets": [
["env", {
"targets": {
"browsers": ["last 2 versions"]
}
}]
],
"plugins": [
["transform-react-jsx", {
"pragma": "m"
}]
]
}
{
"compilerOptions": {
"module": "commonjs",
"target": "esnext",
"outDir": "./dist",
"lib": [
"dom",
"es2015"
],
"jsx": "preserve",
"noImplicitAny": false,
"strictNullChecks": true,
"sourceMap": false
},
"include":[
"./src/**/*"
]
}
{
"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
}
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
}
};
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
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
<!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>
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>);
}
}
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
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
export class TodoState {
constructor(
public id: number,
public text: string,
) {}
}
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
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を追加
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の配列を返すように変更。
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に。
import { combineReducers } from 'redux';
import todos from './todos';
export default combineReducers({
todos,
});
Components
TodoListの作成
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>);
}
}
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
の部分を参照。
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の実装パターンを試す
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を思料する。
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が使用可能。
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を追加
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);
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>);
}
}
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習得記まとめ