Help us understand the problem. What is going on with this article?

Redux ExampleのTodo ListをはじめからていねいにをElmで

概要

Redux ExampleのTodo Listをはじめからていねいに(1)を以下のパターンで試した。

感想。
React+Reduxは記事多いしReactの資産使えるけどボイラープレートが多くて複雑で大変。
Vue+Vuexは使いやすくて直感的。typescriptで型をつけようとすると、公式のだと足りなくて大変。
Elmはシンプルな仕組みだけど、記法が見慣れないのとjsの連携が大変。

Elmでも同様の手順で試してみる。

ExampleのTodo Listの機能は次の3つ。

  1. TodoをTodo Listに追加する「Add Todo」
  2. Todoの完了・未完了を切り替える「Toggle Todo」
  3. 表示するTodo Listを完了または未完了のTodoだけにする「Filter Todo」

環境

  • Windows 10
  • Vagrant 2.2.5
  • virtualbox 6.0.10
  • Ubuntu 18.04 LTS (Bionic Beaver)
  • Docker version 18.09.5, build e8ff056
  • docker-compose version 1.24.0, build 0aa59064

仮想環境のIPは192.168.50.10に指定。

ブラウザはchromeで確認。

ディレクトリ構成(Hello world時)

.
├── bin
├── docker
│   ├── docker-compose.yml
│   ├── config
│   │   ├── babel.config.js
│   │   └── webpack.config.js
│   └── elm-todo
│       └── Dockerfile
├── elm.json
├── public
│   └── index.html
└── src
    ├── Main.elm
    └── index.js

この時点のソース

ビルドツール

docker/elm-todo/Dockerfile
FROM node:12.10.0
# コンテナ上の作業ディレクトリ作成
WORKDIR /app

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

## for js
### babel
RUN yarn add --dev @babel/core \
  @babel/preset-env

RUN yarn add --dev @babel/cli

## elm
RUN yarn add --dev elm
RUN yarn add --dev elm-format
RUN yarn add --dev elm-minify
RUN yarn add --dev elm-webpack-loader

RUN yarn add --dev elm-test

## webpackインストール
RUN yarn add --dev webpack
RUN yarn add --dev webpack-cli
RUN yarn add --dev webpack-dev-server

## plugin
RUN yarn add --dev copy-webpack-plugin
RUN yarn add --dev html-webpack-plugin

### loaders
RUN yarn add --dev babel-loader
RUN yarn add --dev html-loader
RUN yarn add --dev elm-webpack-loader
RUN yarn add --dev elm-hot-webpack-loader
docker/docker-compose.yml
version: '3'
services:
  elm-todo:
    build: ./elm-todo
    volumes:
      - ../src:/app/src
      - ../public:/app/public
      - ../dist:/app/dist
      - ../elm.json:/app/elm.json
      - ./config/webpack.config.js:/app/webpack.config.js
      - ./config/babel.config.js:/app/babel.config.js
      # packageのキャッシュ
      - cacheGardenElmStuffStarter:/app/elm-stuff
      - cacheGardenDotElmStarter:/root/.elm
    ports:
      - 3000:3000
    command: [yarn, webpack-dev-server, --hot, --colors, --port, '3000', --host, '0.0.0.0', ]

volumes:
  # elmのpackageを毎回ダウンロードしなくてもよいように、キャッシュを行う。2か所のキャッシュが必要。
  cacheGardenElmStuffStarter: 
  cacheGardenDotElmStarter:
docker/config/babel.config.js
const presets = [
  [
    '@babel/env',
    {
      useBuiltIns: 'entry',
    },
  ],
];

// sourceType: scriptにしないと、babelが グローバルの this を void 0 に変えてしまう
const overrides = [{
  test: /Main\.js$/,
  sourceType: 'script',
}];
module.exports = { presets, overrides };
docker/config/webpack.js
const path = require('path');
const HTMLWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  mode: 'development',
  entry: {
    index: './src/index.js'
  },
  plugins: [
    new HTMLWebpackPlugin({
      template: 'public/index.html',
      inject: 'body',
      chunks: ['index'],
    }),
  ],
  resolve: {
    modules: [path.join(__dirname, 'src'), 'node_modules'],
    extensions: ['.js', '.elm'],
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
        },
      },
      {
        test: /\.elm$/,
        exclude: [/elm-stuff/, /node_modules/],
        use: [
          { loader: 'elm-hot-webpack-loader' },
          {
            loader: 'elm-webpack-loader',
            options: {
              debug: false,
              forceWatch: true,
            },
          },
        ],
      },
    ],
  },
  devServer: {
    hot: true,
    progress: true,
    inline: true,
    stats: 'errors-only',
    contentBase: path.join(__dirname, 'src'),
    historyApiFallback: true,
  },
  watch: true,
  watchOptions: {
    aggregateTimeout: 300,
    poll: 1000,
  },
};
elm.json
{
  "type": "application",
  "source-directories": [
    "src"
  ],
  "elm-version": "0.19.0",
  "dependencies": {
    "direct": {
      "elm/browser": "1.0.1",
      "elm/core": "1.0.2",
      "elm/html": "1.0.0",
      "elm/json": "1.1.3"
    },
    "indirect": {
      "elm/time": "1.0.0",
      "elm/url": "1.0.0",
      "elm/virtual-dom": "1.0.2"
    }
  },
  "test-dependencies": {
    "direct": {},
    "indirect": {}
  }
}
public/index.html
<!DOCTYPE html>
<html>

<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Todo</title>
</head>

<body>
  <div id="app"></div>
</body>

</html>
bin/up.sh
#!/bin/bash

bin_dir=$(cd $(dirname $0) && pwd)
parent_dir=$bin_dir/..
docker_dir=$parent_dir/docker
composeFile=${1:-"docker-compose.yml"}

# docker-composeの起動
cd $docker_dir && docker-compose -f $composeFile up

Add Todo

1. Hello World

まずはHellow Worldを表示させる。

import { Elm } from './Main';

const flags = {};

// elmのDOMを作成する元となるDOM要素
const mountNode = document.getElementById('app');

// 初期値を与える
const app = Elm.Main.init({ node: mountNode, flags });
src/Main.elm
module Main exposing (Model, Msg(..), init, main, subscriptions, update, view)

import Browser
import Html exposing (..)
import Html.Attributes exposing (..)
import Json.Decode as D exposing (Value)

main : Program Value Model Msg
main =
    Browser.element
        { init = init
        , view = view
        , update = update
        , subscriptions = subscriptions
        }

type alias Model =
    { message : String
    }


init : Value -> ( Model, Cmd Msg )
init flags =
    ( Model "Hello World", Cmd.none )



type Msg
    = Nothing


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        Nothing ->
            ( model, Cmd.none )


subscriptions : Model -> Sub Msg
subscriptions model =
    Sub.batch []


view : Model -> Html Msg
view model =
    div []
        [ p [] [ text model.message ]
        ]

これでHello Worldが表示できる。
dockerを起動するshellは以下。

bin/up.sh

以下のURLで、 Hellow worldが表示されているのを確認できる。
http://192.168.50.10:3000/

2. 発行したメッセージをupdateに渡してModelを更新する

Model

アプリケーションが管理すべき状態を表したもの。
ReduxだとStoreにあたる。

type alias Model =
    { id: Int,
    text: String
    }

init : Value -> ( Model, Cmd Msg )
init flags =
    ( Model 0 "test", Cmd.none )

Update

UpdateはModelを更新する関数となる。
メッセージを受け取ってModelを更新する。
ReduxでいえばReducerにあたる。
メッセージは慣習的にMsgというカスタム型で表す。
ReduxでいえばActionにあたる。

type Msg
    = AddTodo String

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        AddTodo text ->
            (Model 0 text , Cmd.none )

ここまでで、msg -> update -> modelの一連の流れができた。
実際にModelが更新されるのを見てみる。

init : Value -> ( Model, Cmd Msg )
init flags =
    ( Model 0 "", addNewTodo )


addNewTodo : Cmd Msg
addNewTodo =
    Task.perform AddTodo (Task.succeed "Hello World!")

まず、initでmodel: { id = 0, text = "" }となる。
つぎに、addNewTodoがAddTodoのメッセージを発行する。
updateでModelが更新され、model: { id = 0, text = "Hello World!" }となる。
デバッグメッセージをコンソールにだして確認してみる。

view : Model -> Html Msg
view model =
    let
        _ =
            Debug.log "model" model
    in
    div []
        [ text "Hello World"
        ]

この時点のソース

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

Todo Listの作成

viewで表示する前に、少しだけModelを書き換える。
Modelでtodoを保持することはできたが、このままでは1つのtodoしか保持できない。
複数のtodoを保持できるよう拡張する。

type alias Todo =
    { id : Int
    , text : String
    }


type alias Model =
    { todos : List Todo
    }


init : Value -> ( Model, Cmd Msg )
init flags =
    ( Model [], Cmd.none )


type Msg
    = AddTodo String


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        AddTodo text ->
            ( { model | todos = Todo (List.length model.todos) text :: model.todos }, Cmd.none )

updateが少し長くなってしまったので分割してみてみる。

List.length model.todos

この部分は、リストの長さを出している。登録するごとに、idは1つずつ増えていく。

Todo (List.length model.todos) text

この部分は、新しいTodoオブジェクトを作成している。
type alias Todoにより、Todo Int Stringの形式で、Todoオブジェクトを作るコンストラクタが自動的に生成されている。

todo :: model.todos

この部分はListの先頭にTodoオブジェクトを追加した新しいListを作成している。

{model | todos = todos}

この部分は、Modelのtodosを更新した新しいModelを作成している。

View

Modelを元にHTMLを作成する。

ToDoのビューとTodoListのビューを作る。

Todoは渡されてきたtextを表示するだけとする。

todo : Todo -> Html Msg
todo t =
    li [] [ text t.text ]

todoListはTodoをtodoに渡すものとする。

view : Model -> Html Msg
view model =
    div []
        [ todoList model.todos
        ]

todoList : List Todo -> Html Msg
todoList todos =
    ul [] (List.map todo todos)

まだTodoを追加するフォームはないので、手動でtodoを追加。

init : Value -> ( Model, Cmd Msg )
init flags =
    ( Model [], Cmd.batch [ addNewTodo, addNewTodo2 ] )


addNewTodo : Cmd Msg
addNewTodo =
    Task.perform AddTodo (Task.succeed "Hello World!")


addNewTodo2 : Cmd Msg
addNewTodo2 =
    Task.perform AddTodo (Task.succeed "Hello Elm!")

この時点のソース

パフォーマンスの最適化を行う

Reactでも配列を扱うときは、パフォーマンスのためにkeyを指定する。
elmも同様な仕組みがあるので導入する。

import Html.Keyed as Keyed
import Html.Lazy exposing (lazy)

-- 省略...

view : Model -> Html Msg
view model =
    div []
        [ lazy todoList model.todos
        ]


todoList : List Todo -> Html Msg
todoList todos =
    Keyed.node "ul" [] (List.map keyedTodo todos)


keyedTodo : Todo -> ( String, Html Msg )
keyedTodo t =
    ( String.fromInt t.id, todo t )


todo : Todo -> Html Msg
todo t =
    li [] [ text t.text ]

この時点のソース

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

フォームからtodoを追加できるようにする。
入力内容を保存する状態をModelに追加する。
入力EventでonInputではiphoneで動かない不具合があるのでライブラリを追加する。

elm.json
  "dependencies": {
    "direct": {
      "elm/browser": "1.0.1",
      "elm/core": "1.0.2",
      "elm/html": "1.0.0",
      "elm/json": "1.1.3",
+      "elm-community/html-extra": "3.2.0"
    },
}
type alias Model =
    { todos : List Todo
    , inputText : String
    }

init : Value -> ( Model, Cmd Msg )
init flags =
    ( Model [] "", Cmd.none)

入力内容をTodo追加のときに使うようになるため、メッセージに付与している文字列は不要になる。
また、文字が入力された時に更新するメッセージが必要となる。

type Msg
-    = AddTodo String
+    = AddTodo
+    | InputText String

更新部分は以下のように書き換える。

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        AddTodo ->
            ( { model | todos = Todo (List.length model.todos) model.inputText :: model.todos, inputText = "" }, Cmd.none )

        InputText text ->
            ( { model | inputText = text }, Cmd.none )

ビューを追加する。
インプットエリアに入力されたとき、ボタンが押されたときそれぞれにイベントとメッセージを登録。

import Html.Events exposing (onClick)
import Html.Events.Extra exposing (onChange)

-- 省略

view : Model -> Html Msg
view model =
    div []
        [ lazy addTodo model.inputText
        , lazy todoList model.todos
        ]

-- 省略

addTodo : String -> Html Msg
addTodo val =
    div []
        [ input [ value val, onChange InputText ] []
        , button [ onClick AddTodo ] [ text "Add Todo" ]
        ]

この時点のソース

Toggle Todo

Todoの完了・未完了を切り替える「Toggle Todo」の機能を作る。

1. 完了・未完了を表すcompletedによってスタイルを変える

todoにcompleted要素を追加して、とりあえず取り消し線を表示する

complated要素を追加。デフォルトFalse。

type alias Todo =
    { id : Int
    , text : String
+    , completed : Bool
    }


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        AddTodo ->
-            ( { model | todos = Todo (List.length model.todos) model.inputText :: model.todos, inputText = "" }, Cmd.none )
+            ( { model | todos = Todo (List.length model.todos) model.inputText False :: model.todos, inputText = "" }, Cmd.none )

        InputText text ->
            ( { model | inputText = text }, Cmd.none )

completedによってviewを変える.

todo : Todo -> Html Msg
todo t =
    let
        decorationValue =
            if t.completed then
                "line-through"

            else
                "none"
    in
    li [ style "textDecoration" decorationValue ] [ text t.text ]

これで、stateで保持されるtodoのcompletedがtrueのとき取り消し線がつく。
動作確認は、一時的に初期値をTrueに変えてやればよい。

completed要素を操作する

updateで更新に必要なのはtodoのid

type Msg
    = AddTodo
    | InputText String
+    | ToggleTodo Int

指定したidのcompletedを反転させる関数を作成

toggleTodoCompleted : Int -> List Todo -> List Todo
toggleTodoCompleted id list =
    List.map
        (\t ->
            if t.id /= id then
                t

            else
                { t | completed = not t.completed }
        )
        list

update関数で上記関数を使用。

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        AddTodo ->
            ( { model | todos = Todo (List.length model.todos) model.inputText False :: model.todos, inputText = "" }, Cmd.none )

        InputText text ->
            ( { model | inputText = text }, Cmd.none )

        ToggleTodo id ->
            ( { model | todos = toggleTodoCompleted id model.todos }, Cmd.none )

2. クリックしてcompletedの値を変える

イベントを追加する

todo : Todo -> Html Msg
todo t =
    let
        decorationValue =
            if t.completed then
                "line-through"

            else
                "none"
    in
-    li [ style "textDecoration" decorationValue ] [ text t.text ]
+    li [ style "textDecoration" decorationValue, onClick (ToggleTodo t.id) ] [ text t.text ]

これでクリックするとcompletedの値が変更され、取り消し線がON/OFFされる。

Filter Todo

「Filter Todo」では、以下の3つのフィルターによって表示を変更する。

  • SHOW_ALL: 全部表示
  • SHOW_COMPLETED: 完了しているtodoのみ
  • SHOW_ACTIVE: 完了していないtodoのみ

1. フィルターの値をModelに格納

まずはModelの用意。

type Filter
    = SHOW_ALL
    | SHOW_COMPLETED
    | SHOW_ACTIVIE

type alias Model =
    { todos : List Todo
    , inputText : String
    , filter : Filter
    }


init : Value -> ( Model, Cmd Msg )
init flags =
    ( Model [] "" SHOW_ALL, Cmd.batch [] )
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        AddTodo ->
            ( { model | todos = Todo (List.length model.todos) model.inputText False :: model.todos, inputText = "" }, Cmd.none )

        InputText text ->
            ( { model | inputText = text }, Cmd.none )

        ToggleTodo id ->
            ( { model | todos = toggleTodoCompleted id model.todos }, Cmd.none )

+        SetVisibilityFilter filter ->
+            ( { model | filter = filter }, Cmd.none )

2. フィルターの値によってviewを変更

view : Model -> Html Msg
view model =
    let
        todos =
            case model.filter of
                SHOW_ALL ->
                    model.todos

                SHOW_COMPLETED ->
                    List.filter (\t -> t.completed) model.todos

                SHOW_ACTIVIE ->
                    List.filter (\t -> not t.completed) model.todos
    in
    div []
        [ lazy addTodo model.inputText
        , lazy todoList todos
        ]

この時点のソース

3. リンクをクリックしてフィルターを操作してviewを変更

とりあえず、リンクを表示させる

view : Model -> Html Msg
view model =
        -- 省略
    div []
        [ lazy addTodo model.inputText
        , lazy todoList todos
        , footer
        ]

footer : Html Msg
footer =
    p []
        [ text "Show: "
        , link "ALL"
        , text ","
        , link "Active"
        , text ","
        , link "Completed"
        ]


link : String -> Html Msg
link val =
    a [ href "#" ] [ text val ]

クリックしたときにonClickを呼ぶ

footer : Html Msg
footer =
    p []
        [ text "Show: "
        , link "ALL" (SetVisibilityFilter SHOW_ALL)
        , text ","
        , link "Active" (SetVisibilityFilter SHOW_ACTIVIE)
        , text ","
        , link "Completed" (SetVisibilityFilter SHOW_COMPLETED)
        ]


link : String -> Msg -> Html Msg
link val msg =
    a [ href "#", onClick msg ] [ text val ]

activeな状態なリンクを押せないようにする

view : Model -> Html Msg
view model =
    -- 省略
    div []
        [ lazy addTodo model.inputText
        , lazy todoList todos
        , footer model.filter
        ]

footer : Filter -> Html Msg
footer filter =
    p []
        [ text "Show: "
        , link "ALL" (SetVisibilityFilter SHOW_ALL) (filter == SHOW_ALL)
        , text ","
        , link "Active" (SetVisibilityFilter SHOW_ACTIVIE) (filter == SHOW_ACTIVIE)
        , text ","
        , link "Completed" (SetVisibilityFilter SHOW_COMPLETED) (filter == SHOW_COMPLETED)
        ]


link : String -> Msg -> Bool -> Html Msg
link val msg isActive =
    if isActive then
        span [] [ text val ]

    else
        a [ href "#", onClick msg ] [ text val ]

この時点のソース

参考

Redux ExampleのTodo Listをはじめからていねいに(1)
Redux ExampleのTodo ListをはじめからていねいにVuex+Typescriptで(1)
Redux ExampleのTodo ListをはじめからていねいにをTypescriptで(1)
MithrilのTodo ListをはじめからていねいにTypescriptで(1)
Mithril + Redux のTodo ListをTypescriptで(1)
Elmの公式ガイド
elm 0.19 で Todoアプリのようなものを試してみたメモ

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away