[譯] 用 Flask 和 Vue.js 開發一個單頁面應用

Leben_Ito發表於2019-03-04

這篇文章會一步一步的教會你如何用 VUE 和 Flask 建立一個基礎的 CRUD 應用。我們將從使用 Vue CLI 建立一個新的 Vue 應用開始,接著我們會使用 Python 和 Flask 提供的後端介面 RESTful API 執行基礎的 CRUD 操作。

最終效果:

final app

主要依賴:

  • Vue v2.5.2
  • Vue CLI v2.9.3
  • Node v10.3.0
  • npm v6.1.0
  • Flask v1.0.2
  • Python v3.6.5

目錄

目的

在本教程結束的時候,你能夠...

  1. 解釋什麼是 Flask
  2. 解釋什麼是 Vue 並且它和其他 UI 庫以及 Angular、React 等前端框架相比又如何
  3. 使用 Vue CLI 搭建一個 Vue 專案
  4. 在瀏覽器中建立並渲染 Vue 元件
  5. 使用 Vue 元件建立一個單頁面應用(SPA)
  6. 將一個 Vue 應用與後端的 Flask 連線
  7. 使用 Flask 開發一個 RESTful API
  8. 在 Vue 元件中使用 Bootstrap 樣式
  9. 使用 Vue Router 去建立路由和渲染元件

什麼是 Flask?

Flask 是一個用 Python 編寫的簡單,但是及其強大的輕量級 Web 框架,非常適合用來構建 RESTful API。就像 Sinatra(Ruby)和 Express(Node)一樣,它也十分簡便,所以你可以從小處開始,根據需求構建一個十分複雜的應用。

第一次使用 Flask?看看這下面兩個教程吧:

  1. Flaskr TDD
  2. Flask for Node Developers

什麼是 Vue?

Vue 是一個用於構建使用者介面的開源 JavaScript 框架。它綜合了一些 React 和 Angular 的優點。也就是說,與 React 和 Angular 相比,它更加友好,所以初學者額能夠很快的學習並掌握。它也同樣強大,因此它能夠提供所有你需要用來建立一個前端應用所需要的功能。

有關 Vue 的更多資訊,以及使用它與 Angular 和 React 的利弊,請檢視以下文章:

  1. Vue: Comparison with Other Frameworks
  2. Angular vs. React vs. Vue: A 2017 comparison

第一次使用 Vue?不妨花點時間閱讀官方指南中的 介紹

安裝 Flask

首先建立一個新專案資料夾:

$ mkdir flask-vue-crud
$ cd flask-vue-crud
複製程式碼

在 “flask-vue-crud” 資料夾中,建立一個新資料夾並取名為 “server”。然後,在 “server” 資料夾中建立並執行一個虛擬環境:

$ python3.6 -m venv env
$ source env/bin/activate
複製程式碼

以上命令因環境而異。

安裝 Flask 和 Flask-CORS 擴充套件:

(env)$ pip install Flask==1.0.2 Flask-Cors==3.0.4
複製程式碼

在新建立的資料夾中新增一個 app.py 檔案

from flask import Flask, jsonify
from flask_cors import CORS


# configuration
DEBUG = True

# instantiate the app
app = Flask(__name__)
app.config.from_object(__name__)

# enable CORS
CORS(app)


# sanity check route
@app.route('/ping', methods=['GET'])
def ping_pong():
    return jsonify('pong!')


if __name__ == '__main__':
    app.run()
複製程式碼

為什麼我們需要 Flask-CORS?為了進行跨域請求 — e.g.,來自不同協議,IP 地址,域名或埠的請求 — 你需要允許 跨域資源共享(CORS)。而這正是 Flask-CORS 能為我們提供的。

值得注意的是上述安裝允許跨域請求在全部路由無論任何域,協議或者埠都可用。在生產環境中,你應該允許跨域請求成功在前端應用託管的域上。參考 Flask-CORS 文件 獲得更多資訊。

執行應用:

(env)$ python app.py
複製程式碼

開始測試,將你的瀏覽器指向到 http://localhost:5000/ping。你將會看到:

"pong!"
複製程式碼

返回終端,按下 Ctrl+C 來終止服務端然後退回到專案根目錄。接下來,讓我們把注意力轉到前端進行 Vue 的安裝。

安裝 Vue

我們將會使用強力的 Vue CLI 來生成一個自定義專案模板。

全域性安裝:

$ npm install -g vue-cli@2.9.3
複製程式碼

第一次使用 npm?瀏覽一下 什麼是 npm? 官方指南吧

然後,在 “flask-vue-crud” 中,執行以下命令初始化一個叫做 client 的新 Vue 專案幷包含 webpack 配置:

$ vue init webpack client
複製程式碼

webpack 是一個模組打包構建工具,用於構建,壓縮以及打包 JavaScript 檔案和其他客戶端資源。

它會請求你對這個專案進行一些配置。按下Enter鍵去選擇前三個為預設設定,然後使用以下的設定去完成後續的配置:

  1. Vue build: Runtime + Compiler
  2. Install vue-router?: Yes
  3. Use ESLint to lint your code?: Yes
  4. Pick an ESLint preset: Airbnb
  5. Set up unit tests: No
  6. Setup e2e tests with Nightwatch: No
  7. Should we run npm install for you after the project has been created: Yes, use NPM

你會看到一些配置請求比如:

? Project name client
? Project description A Vue.js project
? Author Michael Herman michael@mherman.org
? Vue build standalone
? Install vue-router? Yes
? Use ESLint to lint your code? Yes
? Pick an ESLint preset Airbnb
? Set up unit tests No
? Setup e2e tests with Nightwatch? No
? Should we run `npm install` for you after the project has been created? (recommended) npm
複製程式碼

快速瀏覽一下生成的專案架構。看起來好像特別多,但是我們會用到那些在 “src” 中的檔案和 index.html 檔案。

index.html 檔案是我們 Vue 應用的起點。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <title>client</title>
  </head>
  <body>
    <div id="app"></div>
    <!-- built files will be auto injected -->
  </body>
</html>
複製程式碼

注意那個 idapp<div> 元素。那是一個佔位符,Vue 將會用來連線生成的 HTML 和 CSS 構建 UI。

注意那些在 “src” 資料夾中的資料夾:

├── App.vue
├── assets
│   └── logo.png
├── components
│   └── HelloWorld.vue
├── main.js
└── router
    └── index.js
複製程式碼

分解:

名字 作用
main.js app 接入點,將會和根元件一起載入並初始化 Vue
App.vue 根元件 —— 起點,所有其他元件都將從此處開始渲染
“assets” 儲存影象和字型等靜態資源
“components” 儲存 UI 元件
“router” 定義 URL 地址並對映到元件

檢視 client/src/components/HelloWorld.vue 檔案。這是一個 單檔案元件,它分為三個不同的部分:

  1. template:特定元件的 HTML
  2. script:通過 JavaScript 實現元件邏輯
  3. style:CSS 樣式

執行開發服務端:

$ cd client
$ npm run dev
複製程式碼

在你的瀏覽器中導航到 http://localhost:8080。你將會看到:

default vue app

新增一個新元件在 “client/src/components” 資料夾中,並取名為 Ping.vue

<template>
  <div>
    <p>{{ msg }}</p>
  </div>
</template>

<script>
export default {
  name: 'Ping',
  data() {
    return {
      msg: 'Hello!',
    };
  },
};
</script>
複製程式碼

更新 client/src/router/index.js 使 ‘/’ 對映到 Ping 元件:

import Vue from 'vue';
import Router from 'vue-router';
import Ping from '@/components/Ping';

Vue.use(Router);

export default new Router({
  routes: [
    {
      path: '/',
      name: 'Ping',
      component: Ping,
    },
  ],
});
複製程式碼

最後,在 client/src/App.vue 中,從 template 裡刪除掉圖片:

<template>
  <div id="app">
    <router-view/>
  </div>
</template>
複製程式碼

你現在應該能在瀏覽器中看見一個 Hello!

為了更好地使客戶端 Vue 應用和後端 Flask 應用連線,我們可以使用 axios 庫來傳送 AJAX 請求。

那麼我們開始安裝它:

$ npm install axios@0.18.0 --save
複製程式碼

然後在 Ping.vue 中更新元件的 script 部分,就像這樣:

<script>
import axios from 'axios';

export default {
  name: 'Ping',
  data() {
    return {
      msg: '',
    };
  },
  methods: {
    getMessage() {
      const path = 'http://localhost:5000/ping';
      axios.get(path)
        .then((res) => {
          this.msg = res.data;
        })
        .catch((error) => {
          // eslint-disable-next-line
          console.error(error);
        });
    },
  },
  created() {
    this.getMessage();
  },
};
</script>
複製程式碼

在新的終端視窗啟動 Flask 應用。在瀏覽器中開啟 http://localhost:8080 你會看到 pong!。基本上,當我們從後端得到回覆的時候,我們會將 msg 設定為響應物件的 data 的值。

安裝 Bootstrap

接下來,讓我們引入一個熱門 CSS 框架 Bootstrap 到應用中以方便我們快速新增一些樣式。

安裝:

$ npm install bootstrap@4.1.1 --save
複製程式碼

忽略 jquerypopper.js 的警告。不要把它們新增到你的專案中。稍後會告訴你為什麼。

插入 Bootstrap 樣式到 client/src/main.js 中:

import 'bootstrap/dist/css/bootstrap.css';
import Vue from 'vue';
import App from './App';
import router from './router';

Vue.config.productionTip = false;

/* eslint-disable no-new */
new Vue({
  el: '#app',
  router,
  components: { App },
  template: '<App/>',
});
複製程式碼

更新 client/src/App.vue 中的 style

<style>
#app {
  margin-top: 60px
}
</style>
複製程式碼

通過使用 ButtonContainer 確保 Bootstrap 在 Ping 元件中正確連線:

<template>
  <div class="container">
    <button type="button" class="btn btn-primary">{{ msg }}</button>
  </div>
</template>
複製程式碼

執行開發服務端:

$ npm run dev
複製程式碼

你應該會看到:

vue with bootstrap

然後,新增一個叫做 Books 的新元件到新檔案 Books.vue 中:

<template>
  <div class="container">
    <p>books</p>
  </div>
</template>
複製程式碼

更新路由:

import Vue from 'vue';
import Router from 'vue-router';
import Ping from '@/components/Ping';
import Books from '@/components/Books';

Vue.use(Router);

export default new Router({
  routes: [
    {
      path: '/',
      name: 'Books',
      component: Books,
    },
    {
      path: '/ping',
      name: 'Ping',
      component: Ping,
    },
  ],
  mode: 'hash',
});
複製程式碼

測試:

  1. http://localhost:8080
  2. http://localhost:8080/#/ping

想要擺脫掉 URL 中的雜湊值嗎?更改 modehistory 以使用瀏覽器的 history API 來導航:

export default new Router({
  routes: [
    {
      path: '/',
      name: 'Books',
      component: Books,
    },
    {
      path: '/ping',
      name: 'Ping',
      component: Ping,
    },
  ],
  mode: 'history',
});
複製程式碼

檢視文件以獲得更多路由 資訊

最後,讓我們新增一個高效的 Bootstrap 風格表格到 Books 元件中:

<template>
  <div class="container">
    <div class="row">
      <div class="col-sm-10">
        <h1>Books</h1>
        <hr><br><br>
        <button type="button" class="btn btn-success btn-sm">Add Book</button>
        <br><br>
        <table class="table table-hover">
          <thead>
            <tr>
              <th scope="col">Title</th>
              <th scope="col">Author</th>
              <th scope="col">Read?</th>
              <th></th>
            </tr>
          </thead>
          <tbody>
            <tr>
              <td>foo</td>
              <td>bar</td>
              <td>foobar</td>
              <td>
                <button type="button" class="btn btn-warning btn-sm">Update</button>
                <button type="button" class="btn btn-danger btn-sm">Delete</button>
              </td>
            </tr>
          </tbody>
        </table>
      </div>
    </div>
  </div>
</template>
複製程式碼

你現在應該會看到:

books component

現在我們可以開始構建我們的 CRUD 應用的功能。

我們的目的是什麼?

我們的目標是設計一個後端 RESTful API,由 Python 和 Flask 驅動,對應一個單一資源 — books。這個 API 應當遵守 RESTful 設計原則,使用基本的 HTTP 動詞:GET、POST、PUT 和 DELETE。

我們還會使用 Vue 搭建一個前端應用來使用這個後端 API:

final app

本教程只設計簡單步驟。處理錯誤是讀者(就是你!)的額外練習。通過你的理解解決前後端出現的問題吧。

獲取路由

服務端

新增一個書單到 server/app.py 中:

BOOKS = [
    {
        'title': 'On the Road',
        'author': 'Jack Kerouac',
        'read': True
    },
    {
        'title': 'Harry Potter and the Philosopher\'s Stone',
        'author': 'J. K. Rowling',
        'read': False
    },
    {
        'title': 'Green Eggs and Ham',
        'author': 'Dr. Seuss',
        'read': True
    }
]
複製程式碼

新增路由介面:

@app.route('/books', methods=['GET'])
def all_books():
    return jsonify({
        'status': 'success',
        'books': BOOKS
    })
複製程式碼

執行 Flask 應用,如果它並沒有執行,嘗試在 http://localhost:5000/books 手動測試路由。

想更有挑戰性?寫一個自動化測試吧。檢視 這個 資源可以瞭解更多關於測試 Flask 應用的資訊。

客戶端

更新元件:

<template>
  <div class="container">
    <div class="row">
      <div class="col-sm-10">
        <h1>Books</h1>
        <hr><br><br>
        <button type="button" class="btn btn-success btn-sm">Add Book</button>
        <br><br>
        <table class="table table-hover">
          <thead>
            <tr>
              <th scope="col">Title</th>
              <th scope="col">Author</th>
              <th scope="col">Read?</th>
              <th></th>
            </tr>
          </thead>
          <tbody>
            <tr v-for="(book, index) in books" :key="index">
              <td>{{ book.title }}</td>
              <td>{{ book.author }}</td>
              <td>
                <span v-if="book.read">Yes</span>
                <span v-else>No</span>
              </td>
              <td>
                <button type="button" class="btn btn-warning btn-sm">Update</button>
                <button type="button" class="btn btn-danger btn-sm">Delete</button>
              </td>
            </tr>
          </tbody>
        </table>
      </div>
    </div>
  </div>
</template>

<script>
import axios from 'axios';

export default {
  data() {
    return {
      books: [],
    };
  },
  methods: {
    getBooks() {
      const path = 'http://localhost:5000/books';
      axios.get(path)
        .then((res) => {
          this.books = res.data.books;
        })
        .catch((error) => {
          // eslint-disable-next-line
          console.error(error);
        });
    },
  },
  created() {
    this.getBooks();
  },
};
</script>
複製程式碼

當元件初始化完成後,通過 created 生命週期鉤子呼叫 getBooks() 方法,它從我們剛剛設定的後端介面獲取書籍。

查閱 例項生命週期鉤子 瞭解更多有關元件生命週期和可用方法的資訊。

在模板中,我們通過 v-for 指令遍歷書籍列表,每次遍歷建立一個新表格行。索引值用作 key。最後,使用 v-ifYesNo,來表現使用者已讀或未讀這本書。

books component

Bootstrap Vue

在下一節中,我們將會使用一個模態去新增新書。為此,我們在本節會加入 Bootstrap Vue 庫到專案中,它提供了一組基於 Bootstrap 的 HTML 和 CSS 設計的 Vue 元件。

為什麼選擇 Bootstrap Vue?Bootstrap 的 模態 元件使用 jQuery,但你應該避免把它和 Vue 在同一專案中一起使用,因為 Vue 使用 虛擬 DOM 來更新 DOM。換句話來說,如果你用 jQuery 來操作 DOM,Vue 不會有任何反應。至少,如果你一定要使用 jQuery,不要在同一個 DOM 元素上同時使用 jQuery 和 Vue。

安裝:

$ npm install bootstrap-vue@2.0.0-rc.11 --save
複製程式碼

client/src/main.js 中啟用 Bootstrap Vue 庫:

import 'bootstrap/dist/css/bootstrap.css';
import BootstrapVue from 'bootstrap-vue';
import Vue from 'vue';
import App from './App';
import router from './router';

Vue.config.productionTip = false;

Vue.use(BootstrapVue);

/* eslint-disable no-new */
new Vue({
  el: '#app',
  router,
  components: { App },
  template: '<App/>',
});
複製程式碼

POST 路由

服務端

更新現有路由以處理新增新書的 POST 請求:

@app.route('/books', methods=['GET', 'POST'])
def all_books():
    response_object = {'status': 'success'}
    if request.method == 'POST':
        post_data = request.get_json()
        BOOKS.append({
            'title': post_data.get('title'),
            'author': post_data.get('author'),
            'read': post_data.get('read')
        })
        response_object['message'] = 'Book added!'
    else:
        response_object['books'] = BOOKS
    return jsonify(response_object)
複製程式碼

更新 imports:

from flask import Flask, jsonify, request
複製程式碼

執行 Flask 服務端後,你可以在新的終端裡測試 POST 路由:

$ curl -X POST http://localhost:5000/books -d \
  '{"title": "1Q84", "author": "Haruki Murakami", "read": "true"}' \
  -H 'Content-Type: application/json'
複製程式碼

你應該會看到:

{
  "message": "Book added!",
  "status": "success"
}
複製程式碼

你應該會在 http://localhost:5000/books 的末尾看到新書。

如果書名已經存在了呢?如果一個書名對應了幾個作者呢?通過處理這些小問題可以加深你的理解,另外,如何處理 書名作者,以及 閱覽狀態 都缺失的無效負載情況。

客戶端

在客戶端上,讓我們新增那個模態以新增一本新書,從 HTML 開始:

<b-modal ref="addBookModal"
         id="book-modal"
         title="Add a new book"
         hide-footer>
  <b-form @submit="onSubmit" @reset="onReset" class="w-100">
  <b-form-group id="form-title-group"
                label="Title:"
                label-for="form-title-input">
      <b-form-input id="form-title-input"
                    type="text"
                    v-model="addBookForm.title"
                    required
                    placeholder="Enter title">
      </b-form-input>
    </b-form-group>
    <b-form-group id="form-author-group"
                  label="Author:"
                  label-for="form-author-input">
        <b-form-input id="form-author-input"
                      type="text"
                      v-model="addBookForm.author"
                      required
                      placeholder="Enter author">
        </b-form-input>
      </b-form-group>
    <b-form-group id="form-read-group">
      <b-form-checkbox-group v-model="addBookForm.read" id="form-checks">
        <b-form-checkbox value="true">Read?</b-form-checkbox>
      </b-form-checkbox-group>
    </b-form-group>
    <b-button type="submit" variant="primary">Submit</b-button>
    <b-button type="reset" variant="danger">Reset</b-button>
  </b-form>
</b-modal>
複製程式碼

div 標籤中新增這段程式碼。然後簡單閱覽一下。v-model 是一個用於 表單輸入繫結 的指令。你馬上就會看到。

hide-footer 具體幹了什麼?在 Bootstrap Vue 的 文件 中瞭解更多

更新 script 部分:

<script>
import axios from 'axios';

export default {
  data() {
    return {
      books: [],
      addBookForm: {
        title: '',
        author: '',
        read: [],
      },
    };
  },
  methods: {
    getBooks() {
      const path = 'http://localhost:5000/books';
      axios.get(path)
        .then((res) => {
          this.books = res.data.books;
        })
        .catch((error) => {
          // eslint-disable-next-line
          console.error(error);
        });
    },
    addBook(payload) {
      const path = 'http://localhost:5000/books';
      axios.post(path, payload)
        .then(() => {
          this.getBooks();
        })
        .catch((error) => {
          // eslint-disable-next-line
          console.log(error);
          this.getBooks();
        });
    },
    initForm() {
      this.addBookForm.title = '';
      this.addBookForm.author = '';
      this.addBookForm.read = [];
    },
    onSubmit(evt) {
      evt.preventDefault();
      this.$refs.addBookModal.hide();
      let read = false;
      if (this.addBookForm.read[0]) read = true;
      const payload = {
        title: this.addBookForm.title,
        author: this.addBookForm.author,
        read, // property shorthand
      };
      this.addBook(payload);
      this.initForm();
    },
    onReset(evt) {
      evt.preventDefault();
      this.$refs.addBookModal.hide();
      this.initForm();
    },
  },
  created() {
    this.getBooks();
  },
};
</script>
複製程式碼

實現了什麼?

  1. addBookForm 的值被 表單輸入繫結 到,沒錯,v-model。當資料更新時,另一個也會跟著更新。這被稱之為雙向繫結。花點時間從 這裡 瞭解一下吧。想想這個帶來的結果。你認為這會使狀態管理更簡單還是更復雜?React 和 Angular 又會如何做到這點?在我看來,雙向資料繫結(可變性)使得 Vue 和 React 相比更加友好,但是從長遠看擴充套件性不足。

  2. onSubmit 會在使用者提交表單成功時被觸發。在提交時,我們會阻止瀏覽器的正常行為(evt.preventDefault()),關閉模態框(this.$refs.addBookModal.hide()),觸發 addBook 方法,然後清空表單(initForm())。

  3. addBook 傳送一個 POST 請求到 /books 去新增一本新書。

  4. 根據自己的需要檢視其他更改,並根據需要參考 Vue 的 文件

你能想到客戶端或者服務端還有什麼潛在的問題嗎?思考這些問題去試著加強使用者體驗吧。

最後,更新 template 中的 “Add Book” 按鈕,這樣一來我們點選按鈕就會顯示出模態框:

<button type="button" class="btn btn-success btn-sm" v-b-modal.book-modal>Add Book</button>
複製程式碼

那麼元件應該是這樣子的:

<template>
  <div class="container">
    <div class="row">
      <div class="col-sm-10">
        <h1>Books</h1>
        <hr><br><br>
        <button type="button" class="btn btn-success btn-sm" v-b-modal.book-modal>Add Book</button>
        <br><br>
        <table class="table table-hover">
          <thead>
            <tr>
              <th scope="col">Title</th>
              <th scope="col">Author</th>
              <th scope="col">Read?</th>
              <th></th>
            </tr>
          </thead>
          <tbody>
            <tr v-for="(book, index) in books" :key="index">
              <td></td>
              <td></td>
              <td>
                <span v-if="book.read">Yes</span>
                <span v-else>No</span>
              </td>
              <td>
                <button type="button" class="btn btn-warning btn-sm">Update</button>
                <button type="button" class="btn btn-danger btn-sm">Delete</button>
              </td>
            </tr>
          </tbody>
        </table>
      </div>
    </div>
    <b-modal ref="addBookModal"
             id="book-modal"
             title="Add a new book"
             hide-footer>
      <b-form @submit="onSubmit" @reset="onReset" class="w-100">
      <b-form-group id="form-title-group"
                    label="Title:"
                    label-for="form-title-input">
          <b-form-input id="form-title-input"
                        type="text"
                        v-model="addBookForm.title"
                        required
                        placeholder="Enter title">
          </b-form-input>
        </b-form-group>
        <b-form-group id="form-author-group"
                      label="Author:"
                      label-for="form-author-input">
            <b-form-input id="form-author-input"
                          type="text"
                          v-model="addBookForm.author"
                          required
                          placeholder="Enter author">
            </b-form-input>
          </b-form-group>
        <b-form-group id="form-read-group">
          <b-form-checkbox-group v-model="addBookForm.read" id="form-checks">
            <b-form-checkbox value="true">Read?</b-form-checkbox>
          </b-form-checkbox-group>
        </b-form-group>
        <b-button type="submit" variant="primary">Submit</b-button>
        <b-button type="reset" variant="danger">Reset</b-button>
      </b-form>
    </b-modal>
  </div>
</template>

<script>
import axios from 'axios';

export default {
  data() {
    return {
      books: [],
      addBookForm: {
        title: '',
        author: '',
        read: [],
      },
    };
  },
  methods: {
    getBooks() {
      const path = 'http://localhost:5000/books';
      axios.get(path)
        .then((res) => {
          this.books = res.data.books;
        })
        .catch((error) => {
          // eslint-disable-next-line
          console.error(error);
        });
    },
    addBook(payload) {
      const path = 'http://localhost:5000/books';
      axios.post(path, payload)
        .then(() => {
          this.getBooks();
        })
        .catch((error) => {
          // eslint-disable-next-line
          console.log(error);
          this.getBooks();
        });
    },
    initForm() {
      this.addBookForm.title = '';
      this.addBookForm.author = '';
      this.addBookForm.read = [];
    },
    onSubmit(evt) {
      evt.preventDefault();
      this.$refs.addBookModal.hide();
      let read = false;
      if (this.addBookForm.read[0]) read = true;
      const payload = {
        title: this.addBookForm.title,
        author: this.addBookForm.author,
        read, // property shorthand
      };
      this.addBook(payload);
      this.initForm();
    },
    onReset(evt) {
      evt.preventDefault();
      this.$refs.addBookModal.hide();
      this.initForm();
    },
  },
  created() {
    this.getBooks();
  },
};
</script>
複製程式碼

趕緊測試一下!試著新增一本書:

add new book

alert 元件

接下來,讓我們新增一個 Alert 元件,當新增一本新書後,它會顯示一個資訊給當前使用者。我們將為此建立一個新元件,因為你以後可能會在很多元件中經常用到這個功能。

新增一個新檔案 Alert.vue 到 “client/src/components” 中:

<template>
  <p>It works!</p>
</template>
複製程式碼

然後,在 Books 元件的 script 中引入它並註冊這個元件:

<script>
import axios from 'axios';
import Alert from './Alert';

...

export default {
  data() {
    return {
      books: [],
      addBookForm: {
        title: '',
        author: '',
        read: [],
      },
    };
  },
  components: {
    alert: Alert,
  },

  ...

};
</script>
複製程式碼

現在,我們可以在 template 中引用這個新元件:

<template>
  <b-container>
    <b-row>
      <b-col col sm="10">
        <h1>Books</h1>
        <hr><br><br>
        <alert></alert>
        <button type="button" class="btn btn-success btn-sm" v-b-modal.book-modal>Add Book</button>

        ...

      </b-col>
    </b-row>
  </b-container>
</template>
複製程式碼

重新整理瀏覽器,你會看到:

bootstrap alert

從 Vue 官方文件的 元件化應用構建 中獲得更多有關元件化應用構建的資訊。

接下來,讓我們加入 b-alert 元件到 template 中:

<template>
  <div>
    <b-alert variant="success" show>{{ message }}</b-alert>
    <br>
  </div>
</template>

<script>
export default {
  props: ['message'],
};
</script>
複製程式碼

記住 script 中的 props 選項。我們可以從父元件(Books)傳遞資訊,就像這樣:

<alert message="hi"></alert>
複製程式碼

試試這個:

bootstrap alert

文件 中獲取更多 props 相關資訊。

為了方便我們動態傳遞自定義訊息,我們需要在 Books.vue 中使用 bind 繫結資料。

<alert :message="message"></alert>
複製程式碼

message 新增到 Books.vue 中的 data 中:

data() {
  return {
    books: [],
    addBookForm: {
      title: '',
      author: '',
      read: [],
    },
    message: '',
  };
},
複製程式碼

接下來,在 addBook 中,更新 message 內容。

addBook(payload) {
  const path = 'http://localhost:5000/books';
  axios.post(path, payload)
    .then(() => {
      this.getBooks();
      this.message = 'Book added!';
    })
    .catch((error) => {
      // eslint-disable-next-line
      console.log(error);
      this.getBooks();
    });
},
複製程式碼

最後,新增一個 v-if,以保證只有 showMessage 值為 true 的時候警告才會顯示。

<alert :message=message v-if="showMessage"></alert>
複製程式碼

新增 showMessagedata 中:

data() {
  return {
    books: [],
    addBookForm: {
      title: '',
      author: '',
      read: [],
    },
    message: '',
    showMessage: false,
  };
},
複製程式碼

再次更新 addBook,設定 showMessage 的值為 true

addBook(payload) {
  const path = 'http://localhost:5000/books';
  axios.post(path, payload)
    .then(() => {
      this.getBooks();
      this.message = 'Book added!';
      this.showMessage = true;
    })
    .catch((error) => {
      // eslint-disable-next-line
      console.log(error);
      this.getBooks();
    });
},
複製程式碼

趕快測試一下吧!

add new book

挑戰:

  1. 想想什麼情況下 showMessage 應該被設定為 false。更新你的程式碼。
  2. 試著用 Alert 元件去顯示錯誤資訊。
  3. 修改 Alert 為 可取消 的樣式。

PUT 路由

服務端

對於更新,我們需要使用唯一識別符號,因為我們不能依靠標題作為唯一。我們可以使用 Python 基本庫 提供的 uuid 作為唯一。

server/app.py 中更新 BOOKS

BOOKS = [
    {
        'id': uuid.uuid4().hex,
        'title': 'On the Road',
        'author': 'Jack Kerouac',
        'read': True
    },
    {
        'id': uuid.uuid4().hex,
        'title': 'Harry Potter and the Philosopher\'s Stone',
        'author': 'J. K. Rowling',
        'read': False
    },
    {
        'id': uuid.uuid4().hex,
        'title': 'Green Eggs and Ham',
        'author': 'Dr. Seuss',
        'read': True
    }
]
複製程式碼

不要忘了引入:

import uuid
複製程式碼

我們需要重構 all_books 來保證每一本新增的書都有它的唯一 ID:

@app.route('/books', methods=['GET', 'POST'])
def all_books():
    response_object = {'status': 'success'}
    if request.method == 'POST':
        post_data = request.get_json()
        BOOKS.append({
            'id': uuid.uuid4().hex,
            'title': post_data.get('title'),
            'author': post_data.get('author'),
            'read': post_data.get('read')
        })
        response_object['message'] = 'Book added!'
    else:
        response_object['books'] = BOOKS
    return jsonify(response_object)
複製程式碼

新增一個新的路由:

@app.route('/books/<book_id>', methods=['PUT'])
def single_book(book_id):
    response_object = {'status': 'success'}
    if request.method == 'PUT':
        post_data = request.get_json()
        remove_book(book_id)
        BOOKS.append({
            'id': uuid.uuid4().hex,
            'title': post_data.get('title'),
            'author': post_data.get('author'),
            'read': post_data.get('read')
        })
        response_object['message'] = 'Book updated!'
    return jsonify(response_object)
複製程式碼

新增輔助方法:

def remove_book(book_id):
    for book in BOOKS:
        if book['id'] == book_id:
            BOOKS.remove(book)
            return True
    return False
複製程式碼

想想看如果你沒有 id 識別符號你會怎麼辦?如果有效載荷不正確怎麼辦?重構輔助方法中的 for 迴圈,讓他更加 pythonic。

客戶端

步驟:

  1. 新增模態和表單
  2. 處理更新按鈕點選事件
  3. 傳送 AJAX 請求
  4. 通知使用者
  5. 處理取消按鈕點選事件

(1)新增模態和表單

首先,加入一個新的模態到 template 中,就在第一個模態下面:

<b-modal ref="editBookModal"
         id="book-update-modal"
         title="Update"
         hide-footer>
  <b-form @submit="onSubmitUpdate" @reset="onResetUpdate" class="w-100">
  <b-form-group id="form-title-edit-group"
                label="Title:"
                label-for="form-title-edit-input">
      <b-form-input id="form-title-edit-input"
                    type="text"
                    v-model="editForm.title"
                    required
                    placeholder="Enter title">
      </b-form-input>
    </b-form-group>
    <b-form-group id="form-author-edit-group"
                  label="Author:"
                  label-for="form-author-edit-input">
        <b-form-input id="form-author-edit-input"
                      type="text"
                      v-model="editForm.author"
                      required
                      placeholder="Enter author">
        </b-form-input>
      </b-form-group>
    <b-form-group id="form-read-edit-group">
      <b-form-checkbox-group v-model="editForm.read" id="form-checks">
        <b-form-checkbox value="true">Read?</b-form-checkbox>
      </b-form-checkbox-group>
    </b-form-group>
    <b-button type="submit" variant="primary">Update</b-button>
    <b-button type="reset" variant="danger">Cancel</b-button>
  </b-form>
</b-modal>
複製程式碼

新增表單狀態到 script 中的 data 部分:

editForm: {
  id: '',
  title: '',
  author: '',
  read: [],
},
複製程式碼

挑戰:不使用新的模態,使用一個模態框處理 POST 和 PUT 請求。

(2)處理更新按鈕點選事件

更新表格中的“更新”按鈕:

<button
        type="button"
        class="btn btn-warning btn-sm"
        v-b-modal.book-update-modal
        @click="editBook(book)">
    Update
</button>
複製程式碼

新增一個新方法去更新 editForm 中的值:

editBook(book) {
  this.editForm = book;
},
複製程式碼

然後,新增一個方法去處理表單提交:

onSubmitUpdate(evt) {
  evt.preventDefault();
  this.$refs.editBookModal.hide();
  let read = false;
  if (this.editForm.read[0]) read = true;
  const payload = {
    title: this.editForm.title,
    author: this.editForm.author,
    read,
  };
  this.updateBook(payload, this.editForm.id);
},
複製程式碼

(3)傳送 AJAX 請求

updateBook(payload, bookID) {
  const path = `http://localhost:5000/books/${bookID}`;
  axios.put(path, payload)
    .then(() => {
      this.getBooks();
    })
    .catch((error) => {
      // eslint-disable-next-line
      console.error(error);
      this.getBooks();
    });
},
複製程式碼

(4)通知使用者

更新 updateBook

updateBook(payload, bookID) {
  const path = `http://localhost:5000/books/${bookID}`;
  axios.put(path, payload)
    .then(() => {
      this.getBooks();
      this.message = 'Book updated!';
      this.showMessage = true;
    })
    .catch((error) => {
      // eslint-disable-next-line
      console.error(error);
      this.getBooks();
    });
},
複製程式碼

(5)處理取消按鈕點選事件

新增方法:

onResetUpdate(evt) {
  evt.preventDefault();
  this.$refs.editBookModal.hide();
  this.initForm();
  this.getBooks(); // why?
},
複製程式碼

更新 initForm

initForm() {
  this.addBookForm.title = '';
  this.addBookForm.author = '';
  this.addBookForm.read = [];
  this.editForm.id = '';
  this.editForm.title = '';
  this.editForm.author = '';
  this.editForm.read = [];
},
複製程式碼

在繼續下一步之前先檢查一下程式碼。檢查結束後,測試一下應用。確保按鈕按下後顯示模態框,並正確顯示輸入值。

update book

DELETE 路由

服務端

更新路由操作:

@app.route('/books/<book_id>', methods=['PUT', 'DELETE'])
def single_book(book_id):
    response_object = {'status': 'success'}
    if request.method == 'PUT':
        post_data = request.get_json()
        remove_book(book_id)
        BOOKS.append({
            'id': uuid.uuid4().hex,
            'title': post_data.get('title'),
            'author': post_data.get('author'),
            'read': post_data.get('read')
        })
        response_object['message'] = 'Book updated!'
    if request.method == 'DELETE':
        remove_book(book_id)
        response_object['message'] = 'Book removed!'
    return jsonify(response_object)
複製程式碼

客戶端

更新“刪除”按鈕:

<button
        type="button"
        class="btn btn-danger btn-sm"
        @click="onDeleteBook(book)">
    Delete
</button>
複製程式碼

新增方法來處理按鈕點選然後刪除書籍:

removeBook(bookID) {
  const path = `http://localhost:5000/books/${bookID}`;
  axios.delete(path)
    .then(() => {
      this.getBooks();
      this.message = 'Book removed!';
      this.showMessage = true;
    })
    .catch((error) => {
      // eslint-disable-next-line
      console.error(error);
      this.getBooks();
    });
},
onDeleteBook(book) {
  this.removeBook(book.id);
},
複製程式碼

現在,當使用者點選刪除按鈕時,將會觸發 onDeleteBook 方法。同時,removeBook 方法會被呼叫。這個方法會傳送刪除請求到後端。當返回響應後,通知訊息會顯示出來然後 getBooks 會被呼叫。

挑戰:

  1. 在刪除按鈕點選時加入一個確認提示。
  2. 當沒有書的時候,顯示一個“沒有書籍,請新增”訊息。

delete book

總結

這篇文章介紹了使用 Vue 和 Flask 設定 CRUD 應用程式的基礎知識。

從頭回顧這篇文章以及其中的挑戰來加深你的理解。

你可以在 flask-vue-crud 倉庫 中的 v1 標籤裡找到原始碼。感謝你的閱讀。

想知道更多? 看看這篇文章的續作 Accepting Payments with Stripe, Vue.js, and Flask

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章