Redux Example の TODO List を TypeScript で作成
(2016.04.09--2016.04.17 OS X El Capitan)
Redux Example の Todo List を typescript で作成しました。
JavaScript, TypeScript と、それらのアプリケーションフレームワークの勉強を目的としました。
準備
以下のコマンドで、必要なプログラム、パッケージをインストールしました。
brew update
brew install node
npm install typescript -g
npm install typings -g
npm install jspm -g
typings と jspm について
-
typings は、typescript で使用する "型定義ファイル" を管理するためのパッケージ。
javascript には、静的型付けがないため、".d.ts" という拡張子を持つ型定義ファイルを別に用意することで、
javascript で書かれた外部ライブラリを typescript でも使用できるようにしている。 -
JSPM は、外部ライブラリ呼び出しを簡単にしてくれるパッケージのようだ。
続けて、"todo-list" という名前のフォルダを作成して、そのフォルダに移動し、
npm init
と入力して、プロジェクトの設定を行いました。
mkdir todo-list
cd !$
npm init
npm init とすると色々と聞かれましたが、entry point に関する質問を index.js から index.tsx に変更した以外は、
デフォルトのままにしました(今回の場合、src/index.tsx か dist/index.js にするべきだったかもしれません)。
name: (todo-list)
version: (1.0.0)
description:
entry point: (index.js) index.tsx
test command:
git repository:
keywords:
author:
license: (ISC)
質問に答えると、"package.json" というアプリケーションの設定ファイルが自動作成されました。
"package.json" の内容は、以下のようになりました。
{
"name": "todo-list",
"version": "1.0.0",
"description": "",
"main": "index.tsx",
"dependencies": {},
"devDependencies": {},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC"
}
続いて、npm install の --save-dev オプションでローカルにも jspm をインストールし、
jspm init
と入力しました。
npm install jspm --save-dev
jspm init
色々と聞かれましたが、transpiler(ES6で書かれたコードをES5のコードへ変換してくれる)の選択に関する質問
を TypeScript とした以外は全てデフォルトのままとしました。
Would you like jspm to prefix the jspm package.json properties under jspm? [yes]:
Enter server baseURL (public folder path) [./]:
Enter jspm packages folder [./jspm_packages]:
Enter config file path [./config.js]:
Configuration file config.js doesn't exist, create it? [yes]:
Enter client baseURL (public folder URL) [/]:
Do you wish to use a transpiler? [yes]:
Which ES6 transpiler would you like to use, Babel, TypeScript or Traceur? [babel]:TypeScript
質問に答えると config.js
というファイルが自動生成され、以下のような内容となりました。
System.config({
baseURL: "/",
defaultJSExtensions: true,
transpiler: "typescript",
paths: {
"github:*": "jspm_packages/github/*",
"npm:*": "jspm_packages/npm/*"
},
map: {
"typescript": "npm:typescript@1.8.9",
"github:jspm/nodelibs-os@0.1.0": {
"os-browserify": "npm:os-browserify@0.1.2"
},
"npm:os-browserify@0.1.2": {
"os": "github:jspm/nodelibs-os@0.1.0"
},
"npm:typescript@1.8.9": {
"os": "github:jspm/nodelibs-os@0.1.0"
}
}
});
続いて、jspm を使用して react, react-dom, redux, react-redux をインストールして、
typings を使用してそれらの型定義ファイルをインストールしました。
jspm install react
jspm install react-dom
jspm install redux
jspm install react-redux
typings install react --ambient --save
typings install react-dom --ambient --save
typings install redux --ambient --save
typings install react-redux --ambient --save
最後に、mkdir
コマンドで、以下のようなフォルダ構造を作成しました。
tree todo-list/src --charset=xyz
todo-list/src
|-- actions
|-- components
|-- constants
|-- containers
`-- reducers
以下のサイトなどを参考にさせていただきました
tsconfig.json の作成
Reux Example の Todo List を作成する前に、
typescript 用に tsconfig.json という設定ファイルを作成しました。
ファイルの作成には、Atom エディタを使用しました。
Atom を起動して、メニューバーの "Atom" から "Install Shell Commands" を選択した後、ターミナルで
apm install atom-typescript
として、atom-typescript パッケージをインストールしました。
todo-list/tsconfig.json には、主に以下のような内容を指定しました。
- typescript のコンパイルオプション
- 外部ライブラリの型定義ファイルの場所
- これから作成する typescript ファイルの場所
{
"compilerOptions": {
"target": "es5",
"sourceMap": true,
"declaration": true,
"module": "system",
"jsx": "react",
"noImplicitAny": true,
"noEmitOnError": true,
"removeComments": true,
"experimentalDecorators": true
},
"compileOnSave": false,
"filesGlob": [
"./typings/main.d.ts",
"./src/*.tsx",
"./src/**/*.ts",
"./src/**/*.tsx"
],
"files": [
"./typings/main.d.ts"
],
"atom": {
"rewriteTsconfig": true
}
}
TODO List アプリ
Redux ExampleのTodo Listをはじめからていねいに(1)
などを参考にさせていただき、todo-list/src 以下に TODO List アプリを作成していきました。
上記のページにあるように TODO List アプリには、以下の3つの機能があるようでした。
- Todo を Todo List に追加する「Add Todo」
- Todo の完了・未完了を切り替える「Toggle Todo」
- 表示する Todo List を完了または未完了の Todo だけにする「Filter Todo」
Action
Redux では、ユーザー操作などによって、アプリの状態(Store に格納される)や View を変化させるのに、
まず Action と呼ばれる オブジェクト を発行するようでした;
イベントを受け取ると、以下のように「一方向の流れでページの View を遷移させていく」ようでした。
イベント が発生する
-> Action が作成される
-> Dispatcher が Action を発行する
-> Store に格納されたアプリの状態(State)が変わる
-> State を View へ反映させるとページの見た目が変わる
Action には、type と呼ばれるプロパティ(Action の id 的なもの)を持たせるというルールがあるようでした。
そこで、まずアプリの機能に必要となる Action type を constants/index.ts に列挙しました。
export const ADD_TODO = 'ADD_TODO';
export const TOGGLE_TODO = 'TOGGLE_TODO';
export const SET_VISIBILITY_FILTER = 'SET_VISIBILITY_FILTER';
続いて、actions/index.ts に以下の3つの関数(Action Creator と呼ばれる 関数)を作成しました。
- テキストを引数に取り、type が 'ADD_TODO' である Action を作成する関数
- id を引数に取り、type が 'TOGGLE_TODO' である Action を作成する関数
- filter("SHOW_ALL" or "SHOW_ACTIVE" or "SHOW_COMPLETED")を引数に取り、type が 'SET_VISIBILITY_FILTER' である Action を作成する関数
import * as types from '../constants/index';
let nextTodoId = 0
export const addTodo = (text: string) => {
return {
type: types.ADD_TODO,
id: nextTodoId++,
text
}
}
export const toggleTodo = (id: number) => {
return {
type: types.TOGGLE_TODO,
id
}
}
export const setVisibilityFilter = (filter: string) => {
return {
type: types.SET_VISIBILITY_FILTER,
filter
}
}
Reducer
状態の設計
Reducer には、発行された Action によって状態がどう遷移するのかを記述するようでした。
そこで、まずアプリの状態を表現するオブジェクトをデザインしました。
State を表現する オブジェクト の型は以下のようになりそうでした。
{ State: {visibilityFilter: string, todos: {id:number, text:string, completed:boolean}[]} }
つまり、例えば以下のような感じになりそうでした(Redux Read Me の Reducers を参考にしました)。
{
visibilityFilter: 'SHOW_ALL',
todos: [
{
id: 0,
text: 'Consider using Redux',
completed: true,
},
{
id: 1,
text: 'Keep all state in a single tree',
completed: false
}
]
}
TODO List アプリの3つの機能と状態オブジェクトの関係を考えてみたところ、
アプリの機能と良くマッチしていそうでした。
-
Todo を Todo List に追加する「Add Todo」
addTodo アクションによって、todos プロパティの値(配列型)に新たな todo オブジェクトが追加される。
新規に作成される todo は、どの todo なのかを指定するための id と、 todo の内容を記述した text と、
その todo が完了したか未完了かを表す completed というプロパティを持つ(初期値は、未完了のため false)。 -
Todo の完了・未完了を切り替える「Toggle Todo」
各々の todo には、completed というプロパティがあり、
toggleTodo アクションで指定された id に対応する todo は、値が true となり完了した状態となる
or 既に完了していた場合、値が false となり未完了の状態になる。 -
表示する Todo List を完了または未完了の Todo だけにする「Filter Todo」
setVisibilityFilter アクションによって、visibilityFilter プロパティの値(初期値は、"SHOW_ALL")が、
"SHOW_ALL" or "SHOW_ACTIVE" or "SHOW_COMPLETED" のいずれかになり、
それに対応して View に表示する todo を切り替えるロジックを作成することができる。
Reducer の作成時の注意点
状態が設計できたため、Reducer の作成をしていきました。
Reducer は、状態(の一部)と Action を引数として取り、
Action により状態がどう遷移するかをプログラムし、
最後に遷移後の状態を return するという 関数 のようでした。
Reducer は、現在の状態を次の状態へ遷移させますが、変化させる方法には重要なポイントがあり、
現在の状態を書き換えて、次の状態を作り出すのではなく、
現在の状態を不変(immutable)のまま、次の状態を作り出すことのようでした。
なぜこのようにするかというと、理由は良くわかりませんでしたが、
Full-Stack Redux Tutorial のサイトによると、
現在の状態を不変にすることで、テストが書きやすくなるといったメリットがあるようでした。
Redux Read Me の Reducers に、reducer でやってはいけないことが列記されていました。
Mutate its arguments;
Perform side effects like API calls and routing transitions;
Calling non-pure functions, e.g. Date.now() or Math.random().
現在の状態を不変にするには、Object.assign({}, state, {key: value})
という形で、
次の状態を作り出すと良いようでした。こうすると、現在の状態を変えずに、
3つ目の引数の key で指定したプロパティの値だけが変化した新しいオブジェクトが作成されるようでした。
Typescript でも Object.assign() は使用できるようでしたが、
ServiceStackApps/typescript-redux によると、
Typescript で target に ES5 を指定すると Object.assign() が使用できないため、
jspm で es6-shim パッケージを導入すると良いとのことでした。
このため、es6-shim パッケージと型定義ファイルのインストールを行いました。
jspm install es6-shim
typings install es6-shim --ambient --save
Reducer の作成
Example の Reducer は、typescript で書くと以下のようになりました。
Reducer は、アプリの機能(Action)と、アプリの状態(State)の対応関係を考えることで、どのような実装となるのかを考えることができそうでした。
const todo = (state: any, action: any) => {
switch (action.type) {
case 'ADD_TODO':
return {
id: action.id,
text: action.text,
completed: false
}
case 'TOGGLE_TODO':
if (state.id !== action.id) {
return state
}
return Object.assign({}, state, {
completed: !state.completed
})
default:
return state
}
}
const todos = (state: any[] = [], action: any) => {
switch (action.type) {
case 'ADD_TODO':
return [
...state,
todo(undefined, action)
]
case 'TOGGLE_TODO':
return state.map(t =>
todo(t, action)
)
default:
return state
}
}
export default todos
const visibilityFilter = (state = 'SHOW_ALL', action: any) => {
switch (action.type) {
case 'SET_VISIBILITY_FILTER':
return action.filter
default:
return state
}
}
export default visibilityFilter
最後に、reducers/index.ts という、reducer をまとめる役割を持つファイルを作成しました。
Redux では、各々の reducer で別々に扱っていた状態の一部を、combineReducers() を使用することで、
1つの状態を表すオブジェクトにまとめる必要があるようでした。
import { combineReducers } from 'redux'
import todos from './todos'
import visibilityFilter from './visibilityFilter'
const todoApp = combineReducers({
todos,
visibilityFilter
})
export default todoApp
Components
Redux ExampleのTodo Listをはじめからていねいに(1) の動作イメージの開発者ツール などを見て、
TODO List アプリに、どういう Components があり、どういう構造をしていて、それぞれ何をしているかを調べました。
- TODO List アプリの HTML の構造
- Provider
- App
- Connect(AddTodo)
- AddTodo
- Connect(TodoList)
- TodoList
- Todo
- Todo
- ...
- Footer
- Connect(Link): "SHOW_ALL"
- Connect(Link): "SHOW_ACTIVE"
- Connect(Link): "SHOW_COMPLETED"
-
TODO List アプリのそれぞれの Components の役割
- Provider: これで囲むと、react components が redux とやり取りするために
connect()
を使用できるようになる。 - App: アプリケーションのルートに相当する components。
- AddTodo: todo を state に加える役割。redux とやり取りをする。
- TodoList: todo の管理。visibilityFilter の状態によって表示する todo を決める。また管理する todo がクリックされると completed かどうかを切り替えるために redux とやり取りをする。
- Todo: todo の表示。todo が completed かどうかを切り替える役割。completed な TODO に横線を引くという表示の切り替えは自分で実行するが、状態の切り替えは TodoList を介して行うため、直接 redux とやり取りしない。
- Footer: "SHOW_ALL", "SHOW_ACTIVE", "SHOW_COMPLETED" リンクを表示する役割。直接 redux とやり取りしない。
- Link: クリックされると、現在の visibilityFilter の状態を切り替える。直接 redux とやり取りをする。
- Provider: これで囲むと、react components が redux とやり取りするために
Connect() で囲まれた Components と、そうでない Components がある点が気になりました。
Redux Read Me の Usage with React によると、
Components は、主に以下の2つに分かれるようでした。
- Redux と直接やり取りをしないもの (Presentational Components)
- Redux と直接やり取りをするもの (Container Components)
直接 redux をやり取りするものには、Connect() と付くようでした。
実際の Example のコード を見てみると、
redux と直接やり取りをする Components でも、
Presentational Components, Container Components に分離することができるようでした。
TodoList
TodoList は、Presentational Components, Container Components に以下のように分離されていました。
Presentational Components
Todo.tsx
- TodoList を介して、onClick 関数をプロパティとして受け取り、クリックされた時に TOGGLE_TODO Action が発行されるようにしている。
- クリックされた時に、local な状態を参照して、text に横棒を引いたり、消したりする。
- TodoList を介して、text をプロパティとして受け取り、表示させる。
TodoList.tsx
- 表示させる todo のリストを VisibleTodoList Container を介してプロパティとして受け取り、表示させる。
- Todo にプロパティとして、key (id), id, completed, text, onClick関数 を渡している(プロパティのバケツリレーをしている)。
- completed かどうかを切り替える関数をプロパティとして受け取り、VisibleTodoList Container を介して状態を切り替える。
import React, { PropTypes } from 'react'
export interface TodoProps extends React.Props<any> {
onClick: any;
completed: boolean;
text: string;
}
export default class Todo extends React.Component<TodoProps, any> {
render() {
return (
<li
onClick={this.props.onClick}
style={ {
textDecoration: this.props.completed ? 'line-through' : 'none'
} }
>
{this.props.text}
</li>
);
}
}
import React, { PropTypes } from 'react'
import Todo from './Todo'
export interface TodoListProps extends React.Props<any> {
todos: {id: number, completed: boolean, text: string}[],
onTodoClick: any
}
export default class TodoList extends React.Component<TodoListProps, any> {
render() {
return (
<ul>
{this.props.todos.map(todo =>
<Todo
key={todo.id}
{...todo}
onClick={() => this.props.onTodoClick(todo.id)}
/>
)}
</ul>
)
}
}
Container Components
VisibleTodoList.ts
- import { connect } from 'react-redux' で redux とやり取りするため connect を使用出来るようにしている。
- State を利用して、表示する todo を決めて、プロパティに変換して、TodoList に connect している(View 側を変更するため)。
- TOGGLE_TODO Action を dispatch する関数をプロパティに変換して、TodoList に connect している(State 側を変更するため)。
import { connect } from 'react-redux'
import { toggleTodo } from '../actions/index'
import TodoList from '../components/TodoList'
const getVisibleTodos = (todos, filter) => {
switch (filter) {
case 'SHOW_ALL':
return todos
case 'SHOW_COMPLETED':
return todos.filter(t => t.completed)
case 'SHOW_ACTIVE':
return todos.filter(t => !t.completed)
}
}
const mapStateToProps = (state) => {
return {
todos: getVisibleTodos(state.todos, state.visibilityFilter)
}
}
const mapDispatchToProps = (dispatch) => {
return {
onTodoClick: (id) => {
dispatch(toggleTodo(id))
}
}
}
const VisibleTodoList = connect(
mapStateToProps,
mapDispatchToProps
)(TodoList)
export default VisibleTodoList
Link
Link は、Presentational Components, Container Components に以下のように分離されていました。
Presentational Components
Footer.tsx
- FooterLink にプロパティ(key は filter)として、"SHOW_ALL", "SHOW_ACTIVE", "SHOW_COMPLETED" を渡している(別々に計3回渡している)。
- FooterLink にプロパティ(key は children)として、"All", "ACTIVE", "COMPLETED" を渡している(別々に計3回渡している)。
- 3つの FooterLink を介して、Link を表示する。
Link.tsx
- active かどうかを FooterLink Container を介してプロパティとして受け取り、リンクの有効・無効を切り替える。
- FooterLink Container を介して、"All", "ACTIVE", "COMPLETED" という文字列のどれかをプロパティとして受け取り、表示する。
(Footer.tsx の の中身が、children という key に自動で割り当てられている。react の props.children) - FooterLink Container を介して onClick関数をプロパティとして受け取り、クリックされた時に SET_VISIBILITY_FILTER Action が発行されるようにしている。
import React from 'react'
import FilterLink from '../containers/FilterLink'
const Footer = () => (
<p>
Show:
{" "}
<FilterLink filter="SHOW_ALL">
All
</FilterLink>
{", "}
<FilterLink filter="SHOW_ACTIVE">
Active
</FilterLink>
{", "}
<FilterLink filter="SHOW_COMPLETED">
Completed
</FilterLink>
</p>
)
export default Footer
import React, { PropTypes } from 'react'
export interface LinkProps extends React.Props<any> {
active: boolean,
children: any,
onClick: any
}
export default class Link extends React.Component<LinkProps, any> {
if (active) {
return <span>{this.props.children}</span>
}
render() {
return (
<a href="#"
onClick={e => {
e.preventDefault()
this.props.onClick()
}}
>
{this.props.children}
</a>
)
}
}
Container Components
FooterLink.ts
- import { connect } from 'react-redux' で redux とやり取りするため connect を使用出来るようにしている。
- ownProps には、Footer Component から受け取ったプロパティが入っている。
- State を利用して、自身が active かどうかを切り替え、プロパティに変換して、Link に connect している。
- SET_VISIBILITY_FILTER Action をプロパティに変換して、Link に connect している。
import { connect } from 'react-redux'
import { setVisibilityFilter } from '../actions/index'
import Link from '../components/Link'
const mapStateToProps = (state, ownProps) => {
return {
active: ownProps.filter === state.visibilityFilter
}
}
const mapDispatchToProps = (dispatch, ownProps) => {
return {
onClick: () => {
dispatch(setVisibilityFilter(ownProps.filter))
}
}
}
const FilterLink = connect(
mapStateToProps,
mapDispatchToProps
)(Link)
export default FilterLink
AddTodo
AddTodo は、Presentational Component, Container Component に分離されずに書かれていました。
AddTodo.tsx
- import { connect } from 'react-redux' で redux とやり取りするため connect を使用出来るようにしている。
- ref によって、input 変数が自分自身を参照するようにしている。
- AddTodo がクリックされると、input.value で入力された値を取得して、値が空の時は何もしない。空でない場合は、ADD_TODO Action が発行されるようにしている。
import React from 'react'
import { connect } from 'react-redux'
import { addTodo } from '../actions/index'
let AddTodo = ({ dispatch }):any => {
let input
return (
<div>
<form onSubmit={e => {
e.preventDefault()
if (!input.value.trim()) {
return
}
dispatch(addTodo(input.value))
input.value = ''
}}>
<input ref={node => {
input = node
}} />
<button type="submit">
Add Todo
</button>
</form>
</div>
)
}
AddTodo = connect()(AddTodo)
export default AddTodo
App
App Component は、全ての Components をまとめる役割があるようでした。
Container Component で connect() した Presentational Component は、自動で子要素として呼び出されるようで、
App Component で、以下のような構造を作るには、
- Provider
- App
- Connect(AddTodo)
- AddTodo
- Connect(TodoList)
- TodoList
- Todo
- Todo
- ...
- Footer
- Connect(Link): "SHOW_ALL"
- Connect(Link): "SHOW_ACTIVE"
- Connect(Link): "SHOW_COMPLETED"
以下の 3つを App.tsx から呼び出せば良さそうでした。
<AddTodo />
<VisibleTodoList />
<Footer />
コードは、以下のようになりました。
import React from 'react'
import Footer from './Footer'
import AddTodo from '../containers/AddTodo'
import VisibleTodoList from '../containers/VisibleTodoList'
const App = () => (
<div>
<AddTodo />
<VisibleTodoList />
<Footer />
</div>
)
export default App
Provider
最後に App Component を Provider で囲み、TODO List アプリを挿入する HTML の場所を指定するようでした。
id="root" の場所に挿入することにしました。また、Store を Provider に渡してやるというルールがあるようでした。
以下のように src/index.tsx を作成しました。
import React from 'react'
import { render } from 'react-dom'
import { Provider } from 'react-redux'
import { createStore } from 'redux'
import todoApp from './reducers/index'
import App from './components/App'
let store = createStore(todoApp)
render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
)
Compile、Build
以下のコマンドでプロジェクトを dist というディレクトリにコンパイルしました。
tsc --project todo-list --outDir todo-list/dist
また、外部ライブラリをまとめたファイルを作成するため、jspm でビルドを行いました。
jspm bundle-sfx --minify dist/index
index.html の作成
todo-list/index.html を以下のように作成しました。
<html>
<head>
<title>Redux Todo Example</title>
</head>
<body>
<h1>TODO List App</h1>
<div id="root"></div>
<script src="build.js"></script>
</body>
</html>
最終的なプロジェクトのディレクトリ構造を
tree todo-list -L 2 --charset=xyz
として、調べました。
todo-list
|-- build.js
|-- build.js.map
|-- config.js
|-- dist
| |-- actions
| |-- components
| |-- constants
| |-- containers
| |-- index.d.ts
| |-- index.js
| |-- index.js.map
| `-- reducers
|-- index.html
|-- jspm_packages
| |-- ...
| |-- ...
|-- node_modules
| |-- ...
| |-- ...
|-- package.json
|-- src
| |-- actions
| |-- components
| |-- constants
| |-- containers
| |-- index.tsx
| `-- reducers
|-- tsconfig.json
`-- typings.json
GitHub への投稿と GitHub Pages の作成
無料で使える!GitHub Pagesを使ってWebページを公開する方法 を
参考にさせていただき、TODO List アプリを GitHub Pages にアップしました。
.gitignore を以下のように作成しました(config.js は除かない方が良かったかもしれません。。)。
config.js
dist
jspm_packages
node_modules
typings
.DS_Store
GitHub にログインして、ブラウザで todo-list というレポジトリを作成して、
以下のコマンドにより push しました。
git init
git add .
git commit -m 'first'
git remote add origin https://github.com/UserName/todo-list.git
git push -u origin master
GitHub にログインして、ブラウザで UserName.github.io という名前のリポジトリを作成しました。
続いて、ブラウザで todo-list プロジェクトに gh-pages というブランチを作成しました。
作成された GitHub Pages の TODO List アプリ にアクセスすると、ちゃんと使用することができました。