phoenixとvuejsをwebpackを使って連携してみた

# phoenix + vue(vuex + vue-router) + webpack

phoenix と vuejs を webpack を使って連携した雛形を作ったので公開したいと思います。

以下の目的で作成しました。

- 今後、phoenixでアプリケーションを構築する時に素早く始めたい

vue + phoenixの環境を手早く構築したい方の参考になればと思います。

**注意事項**

- webpack の watch と Docker が相性が悪かったので、Dockerは今回は置いておきます
- 今回の件に限らず、いろいろやっていて思ったのですが・・・localで開発する際はDockerはrdbやredisなどのプロセスをたてておくだけで、ソースいじる部分はdockerじゃない方が何かと効率が良いんじゃないかと思う今日この頃です。


以下に要点を記載すると共に、[github](https://github.com/kumanote/phoenix_vue_webpack_template)にソースコードを置いておきます。


## 構築手順

### project の作成

今回は`webpack`を使いたいので、標準でついてくる`brunch`は外しておきます。
`brunch`の方がいんですかねーーここらへんはよく調査してないです。。

```
$ mix phoenix.new --no-brunch phoenix_vue_webpack_template
```

### vue + webpack integration

次に、`vue`+`webpack`と`phoenix`の連携をしてみようと思います。
基本的には[vue-cli](https://github.com/vuejs/vue-cli)のtemplateの流用で作ります。

#### package.json作成

```
$ touch package.json
```

**package.json**

- `axios`、`moment`は特段必須ではありません。（apiを呼び出す際に利用するため入れています。）
- `phoenix`は後々`socket`を使うために入れてあります。

```
{
  "name": "phoenix_vue_webpack_template",
  "version": "1.0.0",
  "description": "phoenix + vue(vuex/vue-router) + webpack integration sample.",
  "author": "kumanote,LLC.",
  "scripts": {
    "deps": "npm install && mix deps.get",
    "watch": "webpack --watch --color --config client/build/webpack.dev.js",
    "start": "mix phoenix.server",
    "build": "node client/build/build.js"
  },
  "dependencies": {
    "axios": "^0.16.1",
    "moment": "^2.18.1",
    "phoenix": "^1.2.1",
    "vue": "^2.2.6",
    "vue-material": "^0.7.1",
    "vue-router": "^2.3.1",
    "vuex": "^2.3.1"
  },
  "devDependencies": {
    "autoprefixer": "^6.7.2",
    "babel-core": "^6.22.1",
    "babel-eslint": "^7.1.1",
    "babel-loader": "^6.2.10",
    "babel-plugin-transform-runtime": "^6.22.0",
    "babel-preset-env": "^1.3.2",
    "babel-preset-stage-2": "^6.22.0",
    "babel-register": "^6.22.0",
    "chalk": "^1.1.3",
    "connect-history-api-fallback": "^1.3.0",
    "copy-webpack-plugin": "^4.0.1",
    "css-loader": "^0.28.0",
    "eslint": "^3.19.0",
    "eslint-config-standard": "^6.2.1",
    "eslint-friendly-formatter": "^2.0.7",
    "eslint-loader": "^1.7.1",
    "eslint-plugin-html": "^2.0.0",
    "eslint-plugin-promise": "^3.4.0",
    "eslint-plugin-standard": "^2.0.1",
    "eventsource-polyfill": "^0.9.6",
    "extract-text-webpack-plugin": "^2.0.0",
    "file-loader": "^0.11.1",
    "friendly-errors-webpack-plugin": "^1.1.3",
    "html-webpack-plugin": "^2.28.0",
    "opn": "^4.0.2",
    "optimize-css-assets-webpack-plugin": "^1.3.0",
    "ora": "^1.2.0",
    "rimraf": "^2.6.0",
    "semver": "^5.3.0",
    "shelljs": "^0.7.6",
    "url-loader": "^0.5.8",
    "vue-loader": "^11.3.4",
    "vue-style-loader": "^2.0.5",
    "vue-template-compiler": "^2.2.6",
    "webpack": "^2.3.3",
    "webpack-bundle-analyzer": "^2.2.1",
    "webpack-merge": "^4.1.0"
  },
  "engines": {
    "node": ">= 4.0.0",
    "npm": ">= 3.0.0"
  },
  "browserslist": [
    "> 1%",
    "last 2 versions",
    "not ie <= 8"
  ]
}
```

#### webpack系のファイルを作成

- client/build/build.js
- client/build/check-versions.js
- client/build/utils.js
- client/build/vue-loader.conf.js
- client/build/webpack.base.conf.js
- client/build/webpack.dev.conf.js
- client/build/webpack.prod.conf.js
- client/config/dev.env.js
- client/config/index.js
- client/config/prod.env.js
- .babelrc
- .editorconfig
- .eslintignore
- .eslintrc.js
- .postcssrc.js

ここでは開発時に必要な`client/build/webpack.dev.js`のみ説明します。

**client/build/webpack.dev.js**
依存しているファイルの説明は省きますが、hot module replace等を利用しています。
これを`webpack --watch`で使用し、`phoenix`と連携します。

```
var utils = require('./utils')
var webpack = require('webpack')
var config = require('../config')
var merge = require('webpack-merge')
var baseWebpackConfig = require('./webpack.base.conf')
var HtmlWebpackPlugin = require('html-webpack-plugin')
var FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin')

module.exports = merge(baseWebpackConfig, {
  module: {
    rules: utils.styleLoaders({ sourceMap: config.dev.cssSourceMap })
  },
  // cheap-module-eval-source-map is faster for development
  devtool: '#cheap-module-eval-source-map',
  plugins: [
    new webpack.DefinePlugin({
      'process.env': config.dev.env
    }),
    // https://github.com/glenjamin/webpack-hot-middleware#installation--usage
    new webpack.HotModuleReplacementPlugin(),
    new webpack.NoEmitOnErrorsPlugin(),
    // https://github.com/ampedandwired/html-webpack-plugin
    new HtmlWebpackPlugin({
      filename: config.build.index,
      template: 'web/static/index.html',
      inject: true
    }),
    new FriendlyErrorsPlugin()
  ]
})
```

これで、`webpack`系が揃ったので、
**config/dev.exs**に`watcher`を設定します。

```
-  watchers: []
+  watchers: [node: ["node_modules/webpack/bin/webpack.js", "--watch", "--color", "--config", "client/build/webpack.dev.conf.js"]]
```

また、`index.html`の更新時にリロードしてほしいので、以下の変更も加えます。（あと`web/templates`は不要なため削除しておきます。）

```
   live_reload: [
     patterns: [
-      ~r{priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$},
+      ~r{priv/static/.*(js|css|png|jpeg|jpg|gif|svg|html)$},
       ~r{priv/gettext/.*(po)$},
-      ~r{web/views/.*(ex)$},
-      ~r{web/templates/.*(eex)$}
+      ~r{web/views/.*(ex)$}
     ]
   ]
```

#### index.htmlを作成（レンダリングを確認）

##### client側

- まずは、不要なファイル群を削除します。

```
rm -rf priv/static/*  # <- webpackでビルドしたものをここに配置します。既存のものは削除します。
rm -rf web/templates  # <- server side の templateはもはや不要なので削除します。
```

- 次に`index.html`と`webpack`のエントリーとなる`main.js`を作成していきます。（`web/static`の配下につくっていきます。）

以下が作成したファイル群になります。

- web/static/index.html
- web/static/js/App.vue
- web/static/js/components/Index.vue
- web/static/js/main.js
- web/static/js/router/index.js
- web/static/js/store/index.js

**web/static/index.html**

```
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="description" content="phoenix + vue(vuex/vue-router) + webpack integration sample.">
    <meta name="author" content="kumanote,LLC">
    <title>phoenix_vue_webpack_template</title>
  </head>
  <body>
    <div id="app"></div>
    <!-- built files will be auto injected -->
  </body>
</html>
```

##### server側

**web/controllers/page_controller.ex**を以下のように修正します。

```
   def index(conn, _params) do
-    render conn, "index.html"
+    html(conn, File.read!("priv/static/index.html"))
   end
```

また、ページリロード時にindex.htmlにfallbackするように**web/router.ex**を以下のように修正します。

```
defmodule PhoenixVueWebpackTemplate.Router do
     pipe_through :browser # Use the default browser stack
 
     get "/", PageController, :index
+    get "/*path", PageController, :index
   end
```

#### 動作確認

この段階で、`index.html`だけであれば、確認できるようになっています。
しかも`hot deploy`状態なので、`web/static/main.js`に依存しているものは全て、ローカルで変更を加えたらブラウザにリロードが走るはずです。

```
$ npm install && mix deps.get
$ mix ecto.create && mix ecto.migrate
$ mix phoenix.server
```

ここまでの変更内容は[commit log @ github](https://github.com/kumanote/phoenix_vue_webpack_template/commit/0fd16c0738daf894e5609de51cb42e1eafac9e6d)をご覧ください。


### apiの作成とvue側の対応

ここからは、`phoenix`でのapiの作成と`vuejs`側の利用部分の作り込みになります。

#### apiの実装

- **User Model**の作成を行います。

```
$ mix phoenix.gen.json User users name:string age:integer
```

これだけで、modelのソースコードとjsonを返すcontrollerが自動生成されます。（便利だけど本気の製品には使えなそうな気がします。）

- priv/repo/migrations/20170720144527_create_user.exs
- test/controllers/user_controller_test.exs
- test/models/user_test.exs
- web/controllers/user_controller.ex
- web/models/user.ex
- web/views/changeset_view.ex
- web/views/user_view.ex

- 次に**table**の作成を行います。

```
$ mix ecto.migrate
```

ただ、これを行うと以下のようなエラーがでてしまいます。

```
== Compilation error on file web/controllers/user_controller.ex ==
** (CompileError) web/controllers/user_controller.ex:18: undefined function user_path/3
    (stdlib) lists.erl:1338: :lists.foreach/2
    (stdlib) erl_eval.erl:670: :erl_eval.do_apply/6
    (elixir) lib/kernel/parallel_compiler.ex:117: anonymous fn/4 in Kernel.ParallelCompiler.spawn_compilers/1
```

これは、**web/router.ex**にてこのUserControllerを使う記述を追記してあげることで解消します。
今回は一覧表示と追加の２つだけ実装しようと思うので、以下の部分のみ追加しました。

```
+  scope "/api", PhoenixVueWebpackTemplate do
+    pipe_through :api
+
+    get "/users", UserController, :index
+    get "/users/:id", UserController, :show
+    post "/users", UserController, :create
+  end
```

再度気をとりなおして

```
$ mix ecto.migrate
Compiling 10 files (.ex)
Generated phoenix_vue_webpack_template app

23:52:38.829 [info]  == Running PhoenixVueWebpackTemplate.Repo.Migrations.CreateUser.change/0 forward

23:52:38.829 [info]  create table users

23:52:38.835 [info]  == Migrated in 0.0s
```

ちゃんとtableが作られました。

これで server 側の実装は終わりになります。

実際に`curl`で確認できます。

```
$ curl -X GET http://localhost:4000/api/users
{"data":[]}$
```

#### client（vue）の実装

- web/static/js/api/user.js
- web/static/js/components/Index.vue
- web/static/js/components/user/Add.vue
- web/static/js/components/user/List.vue
- web/static/js/router/index.js
- web/static/js/store/index.js
- web/static/js/store/modules/user/add.js
- web/static/js/store/modules/user/list.js


**web/static/js/api/user.js**

api関連は全てapiフォルダに格納します。
`axios`を使い、ajaxリクエストを飛ばします。
返却された値は呼び出し側（`store`）で処理するようにします。

```
import axios from 'axios'

export default {
  getUserList (callback) {
    const url = '/api/users'
    axios.get(url).then(function (response) {
      callback(response.data)
    }).catch(function (error) {
      console.log(error)
    })
  },
  addUser (name, age, callback) {
    const url = '/api/users'
    const data = {
      'user': {
        'name': name,
        'age': age
      }
    }
    axios.post(url, data).then(function (response) {
      callback(response.data)
    }).catch(function (error) {
      console.log(error)
    })
  }
}
```

**web/static/js/store/modules/user/list.js**

`namespaced: true`を指定することで、store moduleの名前空間が使えるようになり便利です。
ここでは、必要に応じて api の呼び出しを行い、結果を格納しています。

```
import api from '@/api/user'

const state = {
  userList: []
}

const getters = {
  userList: state => state.userList
}

const actions = {
  getUserList ({ commit, state }) {
    api.getUserList(apiResponseData => {
      const userList = apiResponseData.data
      commit('receiveUserList', userList)
    })
  }
}

const mutations = {
  receiveUserList (state, userList) {
    state.userList = []
    for (let i = 0; i < userList.length; i++) {
      state.userList.push(userList[i])
    }
  }
}

export default {
  namespaced: true,
  state,
  getters,
  actions,
  mutations
}
```

**web/static/js/components/user/List.vue**

view部分になります。
表示時に `store`の`getUserList action`を呼び出します。
結果的にapiの呼び出し〜結果の格納が行われ、viewの`table`部分にレコードが表示されるようになります。

```
<template>
  <div>
    <h2>User List</h2>
    <ul>
      <li><router-link to="/">Top</router-link></li>
      <li><router-link to="/user/add">Add</router-link></li>
    </ul>
    <table>
      <thead>
        <tr>
          <th>ID</th>
          <th>Name</th>
          <th>Age</th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="(user, index) in userList" :key="index">
          <td>{{user.id}}</td>
          <td>{{user.name}}</td>
          <td>{{user.age}}</td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

<script>
import { mapGetters } from 'vuex'
export default {
  name: 'userList',
  computed: mapGetters({
    userList: 'userList/userList'
  }),
  mounted () {
    this.$store.dispatch('userList/getUserList')
  }
}
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>
```

**web/static/js/router/index.js**

localhost:4000/user/list で `web/static/js/components/user/List.vue` がレンダリングされるようにします。
/user/add も同様
また`mode: 'history'`を指定することで、アンカーではなく、URLのパスが変わるようにします。
（`router.ex`でindex.htmlにfallbackするように記載する必要があるのはこのためです。）

```
import Vue from 'vue'
import Router from 'vue-router'
import Index from '@/components/Index'
import UserList from '@/components/user/List'
import UserAdd from '@/components/user/Add'

Vue.use(Router)

export default new Router({
  mode: 'history',
  routes: [
    {
      path: '/',
      name: 'index',
      component: Index
    },
    {
      path: '/user/list',
      name: 'user_list',
      component: UserList
    },
    {
      path: '/user/add',
      name: 'user_add',
      component: UserAdd
    }
  ]
})
```

ここまでやると vue -> api -> phoenix -> vue という一連の流れのサンプルが実装できました。

apiの部分の実装箇所は[commit log @ github](https://github.com/kumanote/phoenix_vue_webpack_template/commit/9a9b400439257cfa4d9475246bf1c436b0aaad2e)をご覧ください。

見た目しょぼいですが、以下が動作イメージになります。

<img width="684" alt="スクリーンショット 2017-07-21 0.27.58.png" src="https://qiita-image-store.s3.amazonaws.com/0/188896/fce771b2-fb1e-4818-edd1-e08b1a4b5b43.png">

<img width="190" alt="スクリーンショット 2017-07-21 0.28.07.png" src="https://qiita-image-store.s3.amazonaws.com/0/188896/1c4ed616-cd0e-bfb3-43e2-2ade5186e3e2.png">

<img width="365" alt="スクリーンショット 2017-07-21 0.28.32.png" src="https://qiita-image-store.s3.amazonaws.com/0/188896/5aaff269-b48a-c8c4-9888-1e7b8529ae8e.png">

<img width="380" alt="スクリーンショット 2017-07-21 0.28.45.png" src="https://qiita-image-store.s3.amazonaws.com/0/188896/2ce59607-f452-a04d-07cd-84ea53e5e633.png">


以上になります。