【Todo】いまさらNuxt.jsに入門したエンジニアの話【作ってみた】

#はじめに

みなさんこんにちは。
エンジニア１年生のふっくーです。超駆け出しエンジニアです。

都内のベンチャー企業でバックエンドエンジニアとして働きつつ、TechCommitというエンジニアコミュニティーの運営をしています。

この度 TechCommit のAdvent Calender に参加させていただくことになったので、この機に「そろそろ学ばねば」とずっと思い続けていたNuxt.jsを勉強してみました。

**なにかの技術の入門といえばTodoアプリと相場が決まっている**ので、今回はTodoアプリを作成します。

#アプリ作成

（今回の記事は以下の記事のコードを全面的にコピペ、もとい参考にさせていただきました）
https://qiita.com/ayapon/items/d93807e7699434279531

まずはアプリケーションの大枠を作成します。非常に簡単。

```
$ npx create-nuxt-app 【好きな名前】
```
これを実行するとコンソールが荒ぶりはじめ、様々な設定について「どうしますか？」と聞かれます。とりあえず全てEnter連打でOKです。

作成し終わったらローカルサーバーを立ち上げます。

```
$ cd 【さっき決めた名前】
$ npm run dev
```

成功したら`localhost:3000`にアクセス。Nuxt.jsのロゴが大きく表示されている画面が出ればOKです。

#Todoリストの機能を作る

Todoリストに最低限必要な、Todo作成、削除、検索の機能を用意します。

```javascript:nuxt.config.js
link: [
    { rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
    { rel: 'stylesheet', href: 'https://use.fontawesome.com/releases/v5.6.1/css/all.css'}
]
```

```javascript:store/index.js
import Vuex from 'vuex';

const createStore = () => {
    return new Vuex.Store({
        state: () => ({
            todos: [
                {content: 'すごいことをする', created: '2019-03-31 15:30'}, 
                {content: '面白いことをする', created: '2019-03-31 16:00'}
            ]
        }),
        mutations: {
            insert: function(state, obj) {
                var d = new Date();
                var fmt = d.getFullYear() 
                            + '-' + ('00' + (d.getMonth() + 1)).slice(-2) 
                            + '-' + ('00' + d.getDate()).slice(-2) 
                            + ' ' + ('00' + d.getHours()).slice(-2) 
                            + ':' + ('00' + d.getMinutes()).slice(-2);
                state.todos.unshift({
                    content: obj.content,
                    created: fmt
                })
            },
            remove: function(state, obj) {
                for(let i = 0; i < state.todos.length; i++) {
                    const ob = state.todos[i];
                    if(ob.content == obj.content && ob.created == obj.created) {
                        alert('次の項目を削除します: ' + '"' + ob.content + '"');
                        state.todos.splice(i, 1);
                        return;
                    }
                }
            }
        }
    })
}

export default createStore;
```

```vue:pages/index.vue
<template>
  <section class="container">
    <h1>Todo App</h1>
    <div>
      <input type="text" name="content" v-model="content" @focus="set_flg"/>
      <button @click="insert"><i class="fas fa-plus"></i></button>
      <button @click="find"><i class="fas fa-search"></i></button>
    </div>
    <ul>
      <li v-for="(todo, index) in display_todos" :key="index">
        <span class="todo__content">{{ todo.content }}</span><br>
        <span class="todo__created">{{ todo.created }}</span><span class="todo__remove" @click="remove(todo)">delete</span>
      </li>
    </ul>
  </section>
</template>

<script>
import {mapState} from 'vuex';

export default {
  data: function() {
    return {
      content: '',
      find_flg: false
    }
  },
  computed: {
    ...mapState(['todos']),
    display_todos: function() {
      if(this.find_flg) {
        var arr = [];
        var data = this.todos;
        data.forEach(element => {
          if(element.content.toLowerCase() == this.content.toLowerCase()) {
            arr.push(element);
          }
        });
        return arr;
      } else {
        return this.todos;
      }
    }
  },
  methods: {
    insert: function() {
      this.$store.commit('insert', {content: this.content});
      this.content = '';
    },
    find: function() {
      this.find_flg = true;
    },
    set_flg: function() {
      if(this.find_flg) {
        this.find_flg = false;
        this.content = '';
      }
    },
    remove: function(todo) {
      this.$store.commit('remove', todo)
    }
  }
}
</script>
```

##結果

<img width="827" alt="before.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/262038/dfd65738-8818-9c5b-3ae8-ed3eec43106c.png">
はい、できました。
フォームにTodoの内容を入力して「＋」ボタンを押すと、リストに登録されます。
deleteを押すと削除します（現状だとやや分かりにくいですが）
虫眼鏡ボタンを押すとフォーム内の文字列で検索します。もう一度フォームをクリックすると検索状態を解除します。

#検索機能を改善する

このままだと本当にコードをコピペしただけになってしまうので、自分の勉強のために検索機能を改善しようと思います。

現状の仕様ですと、
・検索状態を解除するには、**検索ワードが入力されているフォームをクリックする**。その際フォームは自動的に空になる。
・フォームが空の状態で検索ボタンを押すと、空白で検索したことになり、結果がゼロになる。

こうなっています。

これを以下のように変更します。イメージはGoogle。
・検索状態を解除するには、**フォームが空の状態で検索ボタンを押す**、または**新規投稿をする**。
・フォームをクリックすると自動的に空になる機能はなしにする。

##変更してみた
```vue:pages/index.vue
<div>
  <input type="text" name="content" v-model="content" /> // set_flg削除
  ・　・　・
</div>

・　・　・

methods: {
    insert: function() {
        ・　・　・
        this.find_flg = false; // １行追加
    },
    find: function() {
        this.find_flg = this.content != ''; // trueを変更
    },
    // set_flgメソッド削除
    ・　・　・
}
```

##結果

先ほど決めた仕様のように大体動きました。
しかし、「**検索ワードを変更しようとすると、１文字消しただけで検索結果が全消しされてしまう**」というバグが。

つまりこれは、**１文字消しただけでもその時点でのフォームの中身で検索している**ということです。Railsのように同期処理が当たり前という感覚で実装していたので、「Vueってすげー」と思いました。

これを解消するためには、フォームの値とは別に「今どういう値で検索しているか」を記憶しておく必要があります。

ということで、dataにsearch_wordという値を持たせてみます。

#最終的なコード

```vue:pages/index.vue
<template>
  <section class="container">
    <h1>Todo App</h1>
    <div>
      <input type="text" name="content" v-model="content" />
      <button @click="insert"><i class="fas fa-plus"></i></button>
      <button @click="find"><i class="fas fa-search"></i></button>
    </div>
    <ul>
      <li v-for="(todo, index) in display_todos" :key="index">
        <span class="todo__content">{{ todo.content }}</span><br>
        <span class="todo__created">{{ todo.created }}</span><span class="todo__remove" @click="remove(todo)">delete</span>
      </li>
    </ul>
  </section>
</template>

<script>
import {mapState} from 'vuex';

export default {
  data: function() {
    return {
      content: '',
      search_word: '',
      find_flg: false
    }
  },
  computed: {
    ...mapState(['todos']),
    display_todos: function() {
      if(this.find_flg) {
        var arr = [];
        var data = this.todos;
        data.forEach(element => {
          if(element.content.toLowerCase() == this.search_word.toLowerCase()) {
            arr.push(element);
          }
        });
        return arr;
      } else {
        return this.todos;
      }
    }
  },
  methods: {
    insert: function() {
      this.$store.commit('insert', {content: this.content});
      this.content = '';
      this.find_flg = false;
    },
    find: function() {
      this.search_word = this.content
      this.find_flg = this.content != '';
    },
    remove: function(todo) {
      this.$store.commit('remove', todo)
    }
  }
}
</script>
```

これで、フォームの内容を修正している間は検索結果が変わらないようになりました↓
<img width="700" alt="スクリーンショット 2019-12-06 16.02.04.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/262038/8579139c-0b19-15c1-5c9e-f8345f608548.png">

#おまけ
ちょっとこのままでは見た目が微妙なので、cssをいい感じに当ててテンションを上げて終わりたいと思います。

```javascript:nuxt.config.js
css: [
    'pages/style.css'
]
```

```css:pages/style.css
.container {
    width: 700px;
    margin: 100px auto;
    text-align: center;
}

h1 {
    font-size: 32pt;
    color: #004d3d;
}

input {
    width: 593px;
    margin: 20px 0px;
    padding: 8px 24px;
    font-size: 16pt;
    background-color: #f7f7f7;
    border-radius: 24px;
    display: inline;
}

input:focus {
    outline: none;
}

button {
    width: 48px;
    height: 48px;
    color: #fff;
    background-color: #004d3d;
    font-size: 16pt;
    border-radius: 24px;
    display: inline;
    cursor: pointer;
}

button:hover {
    color: #ddd;
    background-color: #003c2c;
}

ul {
    margin-top: 20px;
    padding: 0;
    text-align: center;
}

li {
    list-style: none;
    text-align: left;
    padding: 7px 15px;
    font-size: 16pt;
    width: 700px;
    border: 1px solid #ddd;
}

span {
    margin: 0 5px;
}

.todo__created, .todo__remove {
    margin-right: 30px;
    font-size: 12px;
    color: #666;
}

.todo__remove {
    font-weight: bold;
    font-size: 12px;
    color: #a66;
    cursor: pointer;
}

.todo__remove:hover {
    color: #733;
}
```

##結果
<img width="1304" alt="スクリーンショット 2019-12-06 16.11.26.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/262038/7c071643-5f39-db79-339e-2d16c383763e.png">

ここまで読んでいただいてありがとうございました。
失礼します。