FlaskとVue.jsでSPA Webアプリ開発

# はじめに

Flask（バックエンド）とVue.js（フロントエンド）を使ってSPAの開発を行ったので，そのときの環境や手順についてまとめていきます．FlaskではSPA用のWebサーバとREST APIを動かします．Vue.jsではVue CLI 3を使って環境を構築するのと，フロントエンド単体で開発を進められるようにRESTのモックサーバなどの環境を用意します．

# Flaskとは

Python製の軽量なWebアプリケーションフレームワークです．通常はテンプレートエンジンとしてJinja2を使ってHTMLを返す仕組みが一般的かと思いますが，今回はVue.jsとの組み合わせでSPAで開発を行います．また追加のパッケージを利用してREST APIの実装も行います．

[http://flask.palletsprojects.com/en/1.1.x/](http://flask.palletsprojects.com/en/1.1.x/)

# Vue.jsとは

WebアプリケーションのUI開発のためのJavaScriptのフレームワークです．シングルページアプリケーション（SPA）の開発が可能です．Vue.jsでアプリケーションを開発するためのコマンドラインインタフェース（CLI）ツールである，Vue CLI 3を使って開発を行うと便利です．

[https://jp.vuejs.org](https://jp.vuejs.org)

# 事前の開発環境

今回の自分の開発環境は次のようになっています．PythonとNode.jsが必要です．

* Python : 3.7.4
* pipenv : version 2018.11.26
* Node.js : v10.16.1
* npm : 6.9.0

# ディレクトリ構成

ディレクトリ構成は次のようになります．バックエンドとフロントエンドで分割して，それぞれのディレクトリで環境を作っていきます．

```
myspa
  ├─backend (Flaskのバックエンド)
  └─frontend (Vue.jsのバックエンド)
```

バックエンドとフロントエンドをまとめて管理したいので，myspaディレクトリで`git init`をしておきます．

```console
$ mkdir myspa && cd myspa
$ git init
```

# フロントエンドの準備

まずはVue CLI 3を使ってフロントエンドのプロジェクト作成を行います．

## Vue CLI 3のインストール

npmを使ってグローバルにVue CLI 3をインストールします．

```console
$ npm install -g @vue/cli
$ vue --version
3.10.0
```

## プロジェクトの作成

ディレクトリ構成にあわせてfrontendという名前でVue.jsのプロジェクトを作成します．対話形式でいろいろと聞かれるので，今回はManually select featuresで`Router`と`Vuex`を追加して作成しました．linterは`Standard`を選んでいます．はじめにgitリポジトリを作っていない場合は，自動的に作成したプロジェクトにgitリポジトリが作成されます．

```console
$ cd myspa
$ vue create frontend
Vue CLI v3.10.0
? Please pick a preset: Manually select features
? Check the features needed for your project: Babel, Router, Vuex, Linter
? Use history mode for router? (Requires proper server setup for index fallback in production) Yes
? Pick a linter / formatter config: Standard
? Pick additional lint features: (Press <space> to select, <a> to toggle all, <i> to invert selection)Lint on save
? Where do you prefer placing config for Babel, PostCSS, ESLint, etc.? In dedicated config files
? Save this as a preset for future projects? No
```

今回インストールしたパッケージのバージョンは次のようになっています．

* vue: 2.6.10
* vue-router: 3.1.2
* vuex: 3.1.1

この時点で試しにサーバを起動して`http://localhost:8080/`にアクセスするとサンプルのWelcomページが表示されます．フロントエンド単体での開発では，このサーバを使っていくことができます．

```console
$ cd frontend
$ npm run serve
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/45310/12c9f54f-ecb9-34d1-4336-3f4b219f7021.png)

## Prettierの設定

`package.json`にPrettierの設定を追加します．Linterの内容に合わせて次の設定を行いまいました．

```diff:frontend/package.json
+   "prettier": {
+     "semi": false,
+     "singleQuote": true
+   }
```

# バックエンドの準備

次にバックエンドの環境を作成します．今回はPythonの環境構築にPipenvを使用します．

## Pipenvのインストール

pipからPipenvのインストールを行います．

```console
$ python -m pip install pipenv
$ pipenv --version
pipenv, version 2018.11.26
```

## Pipenvで初期化

ディレクトリ構成にあわせてbackendという名前でディレクトリを作成し，その中でPipenvで初期化を行います．初期化に成功すると仮想環境が作成され，ディレクトリ内に`Pipfile`ファイルが作成されています．

```console
$ cd myspa
$ mkdir backend && cd backend
$ pipenv --python 3.7
```

## Flaskのインストール

Pipenvで作成した仮想環境にFlaskのインストールを行います．

```console
$ pipenv install flask
```

同様に開発に便利なパッケージのインストールを行います．コード整形，静的解析，リファクタリングを行うツールです．今回の内容とは直接関係ないですが，入れておくと便利です．

```console
$ pipenv install --dev autopep8 flake8 rope
```

今回インストールしたパッケージのバージョンは次のようになっています．

* Flask: 1.1.1
* autopep8: 1.4.4
* flake8: 3.7.8
* rope: 0.14.0

## Webサーバの作成

Flaskを使ってWebサーバを起動するコードを書きます．`route`の設定がSPA用に一般的なFlaskのサンプルコードと少し変わっています．また`static_folder`と`template_folder`に，フロントエンドのVue.jsでBuildして出力するパスを指定しています．（このあとVue.jsの設定をこのパスにあわせて変更します．）

```python:backend/main.py
from flask import Flask, render_template

app = Flask(__name__, static_folder='../frontend/dist/static', template_folder='../frontend/dist')

@app.route('/', defaults={'path': ''})
@app.route('/<path:path>')
def index(path):
    return render_template('index.html')

if __name__ == '__main__':
    app.run()
```

次にフロントエンドでFlaskから読み込むファイルを生成します．Flaskのコードで指定しているパスの内容にあわせて設定を変更してBuildを行います．

まずはfrontendディレクトリ直下に`vue.config.js`を用意します．

```js:frontend/vue.config.js
module.exports = {
  assetsDir: 'static',
};
```

次に`public`ディレクトリ内の`favicon.ico`ファイルを`static/img`ディレクトリに配置するために，次のようにファイルのパスを変更します．

```:変更前
frontend
  └─public
      ├─favicon.ico
      └─index.html
```

```:変更後
frontend
  └─public
      ├─static
      │   └─img
      │       └─favicon.ico
      └─index.html
```

次に`index.html`内のファビコンを読み込んでいる個所を，変更したパスの内容に合わせて更新します．

```diff:public/index.html
     <meta charset="utf-8">
     <meta http-equiv="X-UA-Compatible" content="IE=edge">
     <meta name="viewport" content="width=device-width,initial-scale=1.0">
-    <link rel="icon" href="<%= BASE_URL %>favicon.ico">
+    <link rel="icon" href="<%= BASE_URL %>static/img/favicon.ico">
     <title>frontend</title>
   </head>
   <body>
```

最後にフロントエンドのコードのBuildを行います．Buildに成功すると`frontend/dist`ディレクトリにFlaskから読み込むファイル一式が生成されます．

```console
$ cd frontend
$ npm run build
```

# Webサーバの起動

ここまで準備ができたらFlaskのWebサーバを起動します．まずPipfileにサーバ起動のスクリプトを定義します．

```diff:backend/Pipfile
+ [scripts]
+ start = "python main.py"
```

定義したスクリプトを実行して`http://127.0.0.1:5000/`にアクセスすると，フロントエンドのローカルサーバと同様にWelcomページが表示されます．また画面上部の`Home | About`のリンクもうまく切り替わり，`http://127.0.0.1:5000/about`でブラウザを更新してもうまく表示されます．（SPAのサーバとしてうまく動いています．）

```console
$ cd backend
$ pipenv run start
```

これでFlask（バックエンド）とVue.js（フロントエンド）を連携した環境が立ち上がりました．

# Vue.jsで画面の作成

ここまではVue CLIが用意してくれたWelcomページを表示しているだけなので，次は自分で画面を作っていくとっかかりについてまとめます．

## Vue.js用のUIライブラリ

今回はUIライブラリにElement UIを使いました．Element UIはVue.jsのコンポーネントライブラリです．
[https://element.eleme.io](https://element.eleme.io)

Vue CLIからプラグインとして追加することができます．

```console
$ vue add element
? How do you want to import Element? Fully import
? Do you wish to overwrite Element's SCSS variables? No
? Choose the locale you want to load ja
```

Welcomページを表示すると少し内容が変わっています．Element UIのコンポーネントの`el-button`が表示されています．

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/45310/c94823e0-f463-558f-e90f-ad889f7eb337.png)

[リファレンス](https://element.eleme.io/#/en-US/component/installation)を確認しながら，いろいろなComponentsを試してみるといいと思います．

## App.vue・Home.vue・About.vueの更新

Home.vueとAbout.vueをそのまま流用して画面を作成します．App.vueにトップバー`el-menu`を配置して，ホームページとアバウトページへのリンクを作成します．
`el-menu`に`router`，`el-menu-item`に`route`を指定してVue Routerと連動させます．またElement UIを使用しない場合には`router-link`タグを使用します．`a`タグではVue Routerによるページの切り替えにならないので注意です．

```vue:frontend/src/App.vue
<template>
  <div id="app">
    <el-menu :default-active="activeIndex" mode="horizontal" router>
      <el-menu-item index="home" :route="{ name:'home' }">Home</el-menu-item>
      <el-menu-item index="about" :route="{ name:'about' }">About</el-menu-item>
      <el-menu-item>
        <a href="https://element.eleme.io" target="_blank">Link</a>
      </el-menu-item>
    </el-menu>
    <router-view />
  </div>
</template>

<script>
export default {
  name: 'app',
  data () {
    return {
      activeIndex: this.$route.name
    }
  }
}
</script>

<style scoped>
#app {
  font-family: 'Avenir', Helvetica, Arial, sans-serif;
  text-align: center;
  color: #2c3e50;
}
a {
  text-decoration: none;
}
</style>
```

```vue:frontend/src/views/Home.vue
<template>
  <div class="home">
    <h1>This is a home page</h1>
  </div>
</template>

<script>
export default {
  name: 'home'
}
</script>
```

```vue:frontend/src/views/About.vue
<template>
  <div class="about">
    <h1>This is an about page</h1>
  </div>
</template>

<script>
export default {
  name: 'home'
}
</script>
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/45310/157310f5-e436-718c-1244-4e93b625799e.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/45310/03e56d27-30e6-38ce-4b08-cf1d3cfd5eb7.png)

はじめのうちは，このように既存のページを流用しながら作成していくと動きが理解しやすいと思います．また更にページを追加する場合はvueファイルを新規に作成して，`router.js`にHomeやAboutと同じように新しいページの設定を追加します．

```js:frontend/router.js
import Vue from 'vue'
import Router from 'vue-router'
import Home from './views/Home.vue'
import About from './views/About.vue'

Vue.use(Router)

export default new Router({
  mode: 'history',
  base: process.env.BASE_URL,
  routes: [
    {
      path: '/',
      name: 'home',
      component: Home
    },
    {
      path: '/about',
      name: 'about',
      component: About
    }
  ]
})
```

# REST APIの作成

画面に表示するデータを取得，または更新するためのAPIをFlaskで作成します．また，そのAPIで取得したデータを画面に表示するコードをVue.jsで作成します．

## FlaskでREST API

FlaskでREST APIを扱うのに，今回は`Flask-RESTful`を使用しました．Pipenvからインストールします．

```console
$ pipenv install flask_restful
```

`flask_restful.Resource`を継承したクラスを作成して，GET・POST・PUT・DELETEの定義を必要に応じて行います．そのクラスを`flask_restful.Api`に登録します．詳細は[User Guide](https://flask-restful.readthedocs.io/en/latest/index.html)を参照してください．

```py:backend/main.py
from flask import Flask, render_template
from flask_restful import Api, Resource

app = Flask(__name__, static_folder='../frontend/dist/static', template_folder='../frontend/dist')
api = Api(app)

class Spam(Resource):
    def get(self):
        return {'id': 42, 'name': 'Name'}

api.add_resource(Spam, '/api/spam')

@app.route('/', defaults={'path': ''})
@app.route('/<path:path>')
def index(path):
    return render_template('index.html')

if __name__ == '__main__':
    app.run()
```

Webサーバを起動して`http://127.0.0.1:5000/api/spam`にアクセスすると次のようにJSONで情報が取得できていることが分かります．実際にはフロンエンドのコード内で，例えば`axios`などのHTTP クライアントを使ってアクセスすることになります．

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/45310/73e3abe6-bcb1-b39a-0f72-c5f1dfce1bda.png)

## Blueprintによる分割

`Blueprint`を使うとFlaskのアプリケーション機能を分割することができ，整理がしやすくなります．分離する側でBlueprintを用意して，それをメインのFlaskオブジェクトに登録する流れになります．
前述でREST APIをmain.pyに実装していましたが，これをBlueprintを使ってapi.pyに分割します．
api.py側でBlueprintのインスタンスを用意して`flask_restful.Api`にはこれを渡すようにします．またBlueprintには`url_prefix`を指定することができます．main.py側ではこのBlueprintのインスタンスを`Flask.register_blueprint()`で登録します．

```py:backend/api.py
from flask import Blueprint
from flask_restful import Api, Resource

api_bp = Blueprint('api', __name__, url_prefix='/api')

class Spam(Resource):
    def get(self):
        return {'id': 42, 'name': 'Name'}

api = Api(api_bp)
api.add_resource(Spam, '/spam')
```

```py:backend/main.py
from flask import Flask, render_template
from api import api_bp

app = Flask(__name__, static_folder='../frontend/dist/static', template_folder='../frontend/dist')
app.register_blueprint(api_bp)

@app.route('/', defaults={'path': ''})
@app.route('/<path:path>')
def index(path):
    return render_template('index.html')

if __name__ == '__main__':
    app.run()
```

## FlaskでORM

REST APIで操作するデータをDBで管理するときに，今回はSQLを書く代わりにORMを使用しました．定番のSQLAlchemyを使っています．Pipenvからインストールします．

```console
$ pipenv install flask-sqlalchemy
```

簡単なサンプルコードを載せておきます．`db.Model`を継承したクラスを作成するとテーブルに対する各種操作を使うことができます．main.pyでDBの初期化を行い，REST APIではテーブルから読み取った情報を返すように変更しています．

```py:backend/models.py
from datetime import datetime
from flask_sqlalchemy import SQLAlchemy

db = SQLAlchemy()

class SpamModel(db.Model):
    __tablename__ = 'spam_table'

    pk = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.Text)
    note = db.Column(db.Text)
    created_at = db.Column(db.DateTime, nullable=False, default=datetime.now)
    updated_at = db.Column(db.DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)

def init_db(app):
    db.init_app(app)
    db.create_all()

def get_all():
    return SpamModel.query.order_by(SpamModel.pk).all()

def insert(name, note):
    model = SpamModel(name=name, note=note)
    db.session.add(model)
    db.session.commit()
```

```py:backend/api.py
from flask import Blueprint
from flask_restful import Api, Resource
from models import get_all

api_bp = Blueprint('api', __name__, url_prefix='/api')

class Spam(Resource):
    def get(self):
        return [{'id': x.pk, 'name': x.name, 'note': x.note} for x in get_all()]

api = Api(api_bp)
api.add_resource(Spam, '/spam')
```

```py:backend/main.py
from flask import Flask, render_template
from api import api_bp
from models import get_all, init_db, insert

app = Flask(__name__, static_folder='../frontend/dist/static', template_folder='../frontend/dist')
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///myspa.db'
app.register_blueprint(api_bp)

@app.route('/', defaults={'path': ''})
@app.route('/<path:path>')
def index(path):
    return render_template('index.html')

if __name__ == '__main__':
    with app.app_context():
        init_db(app)
        if not get_all():
            insert('foo', 'This is foo.')
            insert('bar', 'This is bar.')
    app.run()
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/45310/63365894-2206-a011-f1a4-b5518f25b792.png)

詳細は[User Guide](https://flask-sqlalchemy.palletsprojects.com/en/2.x/)を参照してください．

## フロントエンドでの表示

REST APIで取得した情報を画面に表示するサンプルについてまとめます．

まずフロントエンドでREST APIを扱うのに今回は`axios`を使用しました．npmからインストールを行います．

```console
$ npm install axios
```

Home.vue内でAPI経由でデータを取得して画面に表示します．表示するコンポーネントはElement UIの`el-table`を使用しました．`el-table`と`tableData`変数をバインディングして，APIで取得したデータで`tableData`を更新することで，画面上の表示が反映されます．

```vue:frontend/src/views/Home.vue
<template>
  <div class="home">
    <h1>This is a home page</h1>
    <el-table class="data-table" :data="tableData" stripe>
      <el-table-column prop="id" label="ID" width="180"></el-table-column>
      <el-table-column prop="name" label="名前" width="180"></el-table-column>
      <el-table-column prop="note" label="備考"></el-table-column>
    </el-table>
  </div>
</template>

<script>
const axios = require('axios').create()
export default {
  name: 'home',
  data () {
    return {
      tableData: []
    }
  },
  mounted () {
    this.updataTableData()
  },
  methods: {
    updataTableData: async function () {
      const response = await axios.get('/api/spam')
      this.tableData = response.data
    }
  }
}
</script>

<style scoped>
.data-table {
  width: 80%;
  margin: auto;
}
</style>
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/45310/c4dcc950-4f8c-6593-dbe8-7b783a631de8.png)

## フロントエンドのAPIモックサーバ

ここまでで，バックエンド側で用意したREST APIを使う環境は整いましたが，このままだとフロントエンド単体で開発を行うときに不便なので，フロントエンド開発用のモックのサーバを`json-server`を使って用意します．npmからインストールを行います．

```console
$ npm install --save-dev json-server
```

`db.json`を作成してREST APIで扱いたいデータを定義します．ルート直下のキー名（spam）がURLと一致します．

```json:frontend/db.json
{
    "spam": [
        { "id": 1, "name": "foo", "note": "This is dummy foo." },
        { "id": 2, "name": "bar", "note": "This is dummy bar." }
    ]
}
```

`routes.json`を用意すると，ルーティングを設定することができます．db.jsonと下記の設定とで`/api/spam`のパスでREST APIにアクセスすることができます．

```json:frontend/routes.json
{
    "/api/spam": "/spam"
}
```

最後にjson-serverを使う場合とFlaskのサーバを使う場合とでaxiosの設定を変えたかったので，vue.jsの環境変数を利用します．`.env.development`ファイルを用意すると開発環境のみで有効な環境変数を定義できます．

```tex:frontend/.env.development
VUE_APP_REST_SERVER=json-mock
```

この環境変数を確認してaxiosの`baseURL`の設定を切り替えます．

```diff:frontend/src/views/Home.vue
  <script>
- const axios = require('axios').create()
+ const axios =
+   process.env.VUE_APP_REST_SERVER === 'json-mock'
+     ? require('axios').create({ baseURL: 'http://localhost:3000' })
+     : require('axios').create()
  export default {
```

ここまで準備ができたら，以下のコマンドでjson-serverを起動します．また，package.jsonの`scripts`に登録をしておくと便利です．

```console
$ npx json-server --watch db.json --routes routes.json
```

```diff:frontend/package.json
    "scripts": {
      "serve": "vue-cli-service serve",
      "build": "vue-cli-service build",
      "lint": "vue-cli-service lint",
+     "json-mock": "npx json-server --watch db.json --routes routes.json"
    },
```

json-serverを起動している状態でフロントエンドのローカルサーバを起動すると，db.jsonで定義しているデータが読み込めます．これでフロントエンド単体での開発でも，API経由でのデータの取得を動かすことができます．

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/45310/e9f7fec5-c37b-7b65-3566-5269a8681afd.png)

その他の詳細な内容は[リファレンス](https://www.npmjs.com/package/json-server#getting-started)を確認してください．

# おまけ

SPAの開発とは直接関係ないですが，今回はサーバから動画を配信してWebブラウザ上で表示するアプリを作りました．

## Motion JPEGで動画配信

今回は実装が簡単そうだったので[Motion JPEG](https://ja.wikipedia.org/wiki/Motion_JPEG)を使用しました．Flaskでのコードは[Stack Overflow](https://stackoverflow.com/questions/48909132/reading-mjpeg-stream-from-flask-server-with-opencv-python)などにあったものが，そのまますぐ動きました．フロントエンドも`img`タグを使って`src`にバックエンドで用意した配信用のURLを指定するだけで動画として表示されます．
今回はサンプル用に[ライフゲーム](https://ja.wikipedia.org/wiki/ライフゲーム)をバックエンドで実装し，その画像をMotion JPEGで動画として動かしました．画像の描画処理はOpenCVを使っています．Pipenvからインストールします．

```console
$ pipenv install opencv-python
```

次に要点となるコードを簡単に載せておきます．
まずはmain.pyで動画配信用のルーティングを行います．`gen()`で適当な間隔（今回は1秒に3回程度）で画像を送り続けています．実際の画像は`LifeGameCamera.get_frame()`で作っています．

```py:backend/main.py
def gen(camera):
    while True:
        frame = camera.get_frame()
        yield b'--frame\r\n' + b'Content-Type: image/jpeg\r\n\r\n' + frame + b'\r\n\r\n'
        sleep(0.33)

@app.route('/video')
def video():
    camera = LifeGameCamera()
    return Response(gen(camera), mimetype='multipart/x-mixed-replace; boundary=frame')
```

LifeGameCameraで実際に作る画像ですが，描画処理でOpenCVを使う場合はイメージを表すデータは`numpy.ndarray(height, width, channels)`型になります．上のmain.pyのコードで必要なのが`bytes`型になるので変換する必要があります．具体的には`cv2.imencode()`でJPEG形式にして，それを`numpy.ndarray.tobytes()`で`bytes`型に変換しています．

```py:backend/camera.py
import cv2

class LifeGameCamera():
    def get_frame(self):
        image = self.draw_image()  # OpenCVを使って描画
        _, encimg = cv2.imencode('.jpg', image)
        return encimg.tobytes()
```

最後にフロントエンドですが，前述の通りimgタグ（今回はElement UIを使っているので`el-image`タグ）を使用するだけです．srcにバックエンドで追加した`/video`を指定します．

```vue:frontend/src/views/Home.vue
<el-image class="main-image" src="/video" fit="fill"></el-image>
```

バックエンドのサーバを起動して確認すると，画面に動画が表示（再生）されます．

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/45310/d15e0452-82fe-323c-f8e4-87c281039990.png)

# おわりに

今回はFlask（バックエンド）とVue.js（フロントエンド）を使ったSPAの開発についてまとめました．バックエンド側もそれほど多くないコード量でサーバを立ち上げることができたと思います．また今回の環境ではおまけのようなバックエンドのプラスアルファの機能を組み込むのも簡単に行えると思います．SPAの開発を行うときの一つの選択肢になるのではと思います．