使用 Vue + Flask 搭建单页应用
單頁應用,只加載一個主頁面,然后通過 AJAX 無刷新加載其它頁面片段。表面上看,就只有一個 HTML 文件,所謂單頁。開發上,做到了前后端分離,前端專注于渲染模板,而后端只要提供 API 就行,不用自己去套模板了。效果上,頁面和共用的 JS、CSS 文件都只加載一次,能減輕服務器壓力和節省一定的網絡帶寬。另外,由于不需要每次都加載頁面以及共用的靜態文件,響應速度也有一定提高,用戶體驗比較好。當然,也有一些缺點,比如 SEO 優化不大方便,不過也有相應的解決方案。總的來說,使用單頁應用的好處還是遠多于壞處,這也是越來越多的人使用單頁應用的原因。
構建單頁應用的方式有很多,這里我們選擇 Flask + Vue 實現。本文以實現一個 CRUD 的 Demo 為主線,在其中穿插必要的技術點進行講述。里面可能涉及了一些你沒接觸或者不熟悉的概念,不過不要緊,我會給出相應的參考文章幫助你理解。當然,大牛可忽略這些 :)。看完這篇文章后,相信你也能搭建自己的單頁應用了。
1 前端
這里我們會用到 Vue 框架。如果你之前沒有接觸過,推薦去看下官方文檔的「基礎」一節。也可以先直接向下看,Demo 用的都是一些基礎的東西,大致看下應該就能理解。即使暫時不理解,照著例子實踐一遍后,去看下文檔收獲也應該更多。
為了更便捷的創建基于 Vue 的項目,我們可以使用 Vue Cli 腳手架。通過腳手架創建項目的時候,它會輔助我們做一些配置,省去我們手動配置的時間。剛接觸的伙伴前期會用它創建項目就行了,至于更深的一些東西后期再去了解。
安裝腳手架
$ npm install -g @vue/cli 復制代碼這里我們安裝的是最新的 3 版本。
基于 Vue 的 UI 組件庫很多,比如 iView、Element、Vuetify 等。國內使用 iView、Element 的特別多,而使用 Vuetify 的人相對要少很多,不知道是大家看不慣它的 Material Design 風格還是它的中文文檔稀缺的緣故。不過我個人挺喜歡 Vuetify 的風格的,所以我會使用這個組件庫搭建前端頁面。
如果你沒使用過這個組件庫,照著本文一步步實踐下去,也能對 Vuetify 的用法有個大致的了解。如果這個過程中,感覺碰到的疑問太多,可以看下 YouTube 上的這個視頻教程。
https://dwz.cn/lxMHF4bY
也不要到處去找類似的資源了,就是這個系列的視頻看完再加上官方文檔,掌握常用的點基本沒問題。
不過,還是建議先照著本文實現一下 Demo,再去學習,我覺得這樣效果更好。
新建目錄 spa-demo,然后切換到該目錄下新建前端項目 client
$ vue create client 復制代碼創建項目時會讓你手動選擇一些配置,這里貼下我當時的設置
? Please pick a preset: Manually select features ? Check the features needed for your project: Babel, Router, Linter ? Use history mode for router? (Requires proper server setup for index fallback in production) Yes ? Pick a linter / formatter config: Basic ? 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 package.json ? Save this as a preset for future projects? (y/N) N 復制代碼回車安裝完成后,我們切換到 client 目錄下,執行命令
$ npm run serve 復制代碼上述命令執行完成后會有類似這樣的輸出
...App running at: - Local: http://localhost:8080/ - Network: http://172.20.10.3:8080/... 復制代碼在瀏覽器中訪問
http://localhost:8080/
如果看到包含下面文字的頁面
Welcome to Your Vue.js App
說明項目安裝成功。
安裝 Vuetify
$ vue add vuetify 復制代碼同樣會提示你選擇一些配置,這里我選擇的 Default
? Choose a preset: Default (recommended) 復制代碼回車安裝完成后,重新開下服務器
$ npm run serve 復制代碼執行完畢后,我們在瀏覽器中訪問
http://localhost:8080/
會看到頁面內容又些改變,有這么一行文字
Welcome to Vuetify
這里說明 Vuetify 安裝成功。
看下此時的目錄結構
spa-demo └── client├── README.md├── babel.config.js├── package-lock.json├── package.json├── node_module│?? └── ...├── public│?? ├── favicon.ico│?? └── index.html└── src├── App.vue├── assets│?? ├── logo.png│?? └── logo.svg├── components│?? └── HelloWorld.vue├── main.js├── plugins│?? └── vuetify.js├── router.js└── views├── About.vue└── Home.vue 復制代碼簡化 spa-demo/client/src/App.vue,將其修改為
<template><v-app><v-content><router-view></router-view></v-content></v-app> </template><script>export default {name: 'App',data () {return {//}}} </script> 復制代碼修改 spa-demo/client/src/views/Home.vue,在頁面放入一個 Data table
<template><div class="home"><v-container class="my-5"><!-- 對話框 --><!-- 表格 --><v-data-table:headers="headers":items="books"hide-actionsclass="elevation-1"><template slot="items" slot-scope="props"><td>{{ props.item.name }}</td><td>{{ props.item.category }}</td><td class="layout px-0"><v-icon small class="ml-4" @click="editItem(props.item)">edit</v-icon><v-icon small @click="deleteItem(props.item)">delete</v-icon></td></template><template slot="no-data"><v-alert :value="true" color="info" outline>無數據</v-alert></template></v-data-table></v-container></div> </template><script>export default {data: () => ({headers: [{ text: '書名', value: 'name', sortable: false, align: 'left'},{ text: '分類', value: 'category', sortable: false },{ text: '操作', value: 'name', sortable: false }],books: [],}),created () {this.books = [{ name: '生死疲勞', category: '文學' },{ name: '國家寶藏', category: '人文社科' },{ name: '人類簡史', category: '科技' },]},} </script> 復制代碼我們使用了數據 headers 和 books 控制表的頭部和數據,并在創建的時候,給 books 填充了一些臨時數據。
這個頁面中涉及到了 Data table 的使用,相關代碼不用記,在 Vuetify 文檔中搜索 Data table 有很多例子,看了幾個之后你就知道怎么使用了。對于新手來說,不好理解的可能就是那個 slot-scope(作用域插槽 ),這個看下 Vue 官方文檔這些內容
- 「基礎」一節的「組件基礎」
- 「深入了解組件」一節的「組件注冊」、「Prop」、「自定義事件」、「插槽」
靜下心來讀讀就明白了,不難,這里我不再贅述。
同樣,這里你也可以先照葫蘆畫瓢,可以先暫時忽略掉一些不好理解的地方,待實踐一遍之后再去搞清楚。
打開
http://localhost:8080/
看到的頁面是這樣的
就是一個圖書列表。
現在我們要做個可以彈出的對話框,用于新增書籍。我們在 <!-- 對話框 --> 位置新增如下代碼
<v-toolbar flat class="white"><v-toolbar-title>圖書列表</v-toolbar-title><v-spacer></v-spacer><v-dialog v-model="dialog" max-width="600px"><v-btn slot="activator" class="primary" dark>新增</v-btn><v-card><v-card-title><span class="headline">{{ formTitle }}</span></v-card-title><v-card-text><v-alert :value="Boolean(errMsg)" color="error" icon="warning" outline>{{ errMsg }}</v-alert><v-container grid-list-md><v-layout><v-flex xs12 sm6 md4><v-text-field label="書名" v-model="editedItem.name"></v-text-field></v-flex><v-flex xs12 sm6 md4><v-text-field label="分類" v-model="editedItem.category"></v-text-field></v-flex></v-layout></v-container></v-card-text><v-card-actions><v-spacer></v-spacer><v-btn color="blue darken-1" flat @click="close">取消</v-btn><v-btn color="blue darken-1" flat @click="save">保存</v-btn></v-card-actions></v-card></v-dialog> </v-toolbar> 復制代碼對應的,要在 <script></script> 之間添加一些 JS
export default {data: () => ({dialog: false, // 是否展示對話框errMsg: '', // 是否有錯誤信息editedIndex: -1, // 當前在對話框中編輯的圖書在列表中的序號editedItem: { // 當前在對話框中編輯的圖書內容id: 0,name: '',category: ''},defaultItem: { // 默認的圖書內容,用于初始化新增對話框內容id: 0,name: '',category: ''}}),computed: {formTitle () {return this.editedIndex === -1 ? '新增' : '編輯'}},watch: {dialog (val) {if (!val) {this.close()this.clearErrMsg()}}},methods: {clearErrMsg () {this.errMsg = ''},close () {this.dialog = falsesetTimeout(() => {this.editedItem = Object.assign({}, this.defaultItem)this.editedIndex = -1}, 300)}} } 復制代碼為了讓文章簡潔一些,貼代碼的時候我將之前已有的片段進行了省略,你寫的時候可以將上面的代碼根據位置添加到合適的地方。
我們使用了 Toolbar、Dialog 在表格上面添加對話框相關的東西,同樣,不必記代碼,不知道怎么寫的時候查閱下文檔就行。
數據 dialog 表示當前對話框是否展示,errMsg 控制錯誤信息的展示,監聽 dialog 當它變化為 false 的時候關閉對話框并清空 errMsg。計算屬性 formTitle 用于控制對話框的標題。然后添加了兩個表單元素用于填寫書籍的名稱以及分類。
當我們點擊新增后,頁面是這樣的
其實,到這里,我們的前端頁面差不多就 OK 了,后面便是增刪改的實現。這個我們先在前端單方面的實現下,后面再和后端進行整合。這樣,會讓前端的 Demo 更完整一些。
實現保存方法,在 methods 新增 save
save() {if (this.editedIndex > -1) { // 編輯Object.assign(this.books[this.editedIndex], this.editedItem)} else { // 新增this.books.push(this.editedItem)}this.close() } 復制代碼編輯的時候,要展示彈框,我們需要添加 editItem 方法
editItem (item) {this.editedIndex = this.books.indexOf(item)this.editedItem = Object.assign({}, item)this.dialog = true } 復制代碼保存方法和新增時的一致。
實現刪除方法 deleteItem
deleteItem (item) {const index = this.books.indexOf(item)confirm('確認刪除?') && this.books.splice(index, 1) } 復制代碼至此,前端項目告一段落。
2 后端
后端,我們只需要提供增刪改查的接口供前端使用就行。RESTful API 是目前比較成熟的一套互聯網應用程序設計理論,我也會基于此實現圖書的相關操作接口。
考慮到有對 RESTful API 不大熟悉的伙伴,我列了幾個我之前學習的文章,供大家參考。
- 《理解RESTful架構》
- https://dwz.cn/eXu0p6pv
- 《RESTful API 設計指南》
- https://dwz.cn/8v4B0twY
- 《RESTful API 最佳實踐》
- https://dwz.cn/2aSnI8fF
- 知乎問題《怎樣用通俗的語言解釋REST,以及RESTful?》
- https://dwz.cn/bVxrSsf4
看完上面的相關資料,你對這種設計理論應該就有一定掌握了。
同樣,你暫時可不必對 RESTful API 了解得很全面,暫時像下面這樣理解它就行
就是用 URL 定位資源,用 HTTP 描述操作。
這個是在刷上面知乎問題看到的一個回答,作者是 @Ivony。寫得很簡潔,但確實有道理。
等到自己實踐一次后,再回頭看看理論的一些東西,印象更深。
首先列下我們需要實現的接口
| 1 | GET | http://domain/api/v1/books | 獲取所有圖書 |
| 2 | GET | http://domain/api/v1/books/123 | 獲取主鍵為 123 的圖書 |
| 3 | POST | http://domain/api/v1/books | 新增圖書 |
| 4 | PUT | http://domain/api/v1/books/123 | 更新主鍵為 123 的圖書 |
| 5 | DELETE | http://domain/api/v1/books/123 | 刪除主鍵為 123 的圖書 |
我們可以直接使用 Flask 實現上面的接口,不過當資源多的時候,我們寫代碼時會寫很多重復的片段,違反了 DRY(Don't Repeat Yourself) 原則,后面維護起來比較麻煩,所以我們借助 Flask-RESTful 擴展實現。
另外,本節的重心是放在接口的實現上,也為了行文更簡潔,我們將數據直接存在字典里,就不涉及數據庫相關的操作了。
在 spa-demo 目錄下新建 server 目錄,并切換到該目錄下,初始化 Python 環境
$ pipenv --python 3.6.0 復制代碼Pipenv 是當前官方推薦的虛擬環境和包管理工具,我之前寫過一篇文章《Pipenv 快速上手》介紹過,沒接觸過的可以去看下。
安裝 Flask
$ pipenv install flask 復制代碼安裝 Flask-RESTful
$ pipenv install flask-restful 復制代碼新建 spa-demo/server/app.py
# coding=utf-8from flask import Flask, request from flask_restful import Api, Resource, reqparse, abortapp = Flask(__name__) api = Api(app)books = [{'id': 1, 'name': 'book1', 'category': 'cat1'},{'id': 2, 'name': 'book2', 'category': 'cat2'},{'id': 3, 'name': 'book3', 'category': 'cat3'}]# 公共方法區class BookApi(Resource):def get(self, book_id):passdef put(self, book_id):passdef delete(self, book_id):passclass BookListApi(Resource):def get(self):return booksdef post(self):passapi.add_resource(BookApi, '/api/v1/books/<int:book_id>', endpoint='book') api.add_resource(BookListApi, '/api/v1/books', endpoint='books')if __name__ == '__main__':app.run(debug=True) 復制代碼上面就是一個標準的整合了 Flask-RESTful 的代碼結構,在 Flask-RESTful 的官方文檔中可以看到相似的例子。對于每一種資源,我們都可以用類似的結構實現接口。BookApi 類中的 get、put、delete 方法對應接口 2、4、5,BookListApi 類中的 get、post 方法對應接口 1、3。之后便是注冊路由。看到這,有的伙伴可能會有疑問,為什么同一個資源需要定義兩個類呢?其實就是方便給一個資源注冊帶主鍵和不帶主鍵的路由。
此時,項目結構為
spa-demo ├── client │?? └── ... └── server├── Pipfile├── Pipfile.lock└── app.py 復制代碼切換到 spa-demo/server 目錄,運行 app.py
$ pipenv run python app.py 復制代碼然后測試獲取所有圖書接口是否可用。由于是 API 測試,不建議直接使用瀏覽器,畢竟有時構造參數和看 HTTP 信息不大方便,推薦使用 Postman,當然簡單的測試的話可以直接使用命令 curl。
請求接口 1,獲取所有圖書信息
$ curl -i http://127.0.0.1:5000/api/v1/books 復制代碼得到結果
HTTP/1.0 200 OK Content-Type: application/json Content-Length: 249 Server: Werkzeug/0.14.1 Python/3.6.0 Date: Thu, 13 Dec 2018 15:21:56 GMT[{"id": 1,"name": "book1","category": "cat1"},{"id": 2,"name": "book2","category": "cat2"},{"id": 3,"name": "book3","category": "cat3"} ] 復制代碼成功獲取所有圖書,說明接口 1 已經 OK。
然后實現接口 2,獲取指定 ID 的圖書。由于根據 ID 獲取圖書以及圖書不存在時拋 404 的操作后面會頻繁使用到,所以這里提兩個方法到「公共方法區」。
def get_by_id(book_id):book = [v for v in books if v['id'] == book_id]return book[0] if book else Nonedef get_or_abort(book_id):book = get_by_id(book_id)if not book:abort(404, message=f'Book {book_id} not found')return book 復制代碼然后實現 BookApi 中 get 方法
def get(self, book_id):book = get_or_abort(book_id)return book 復制代碼取 ID 為 1 的圖書測試下
$ curl -i http://127.0.0.1:5000/api/v1/books/1 復制代碼結果
HTTP/1.0 200 OK Content-Type: application/json Content-Length: 61 Server: Werkzeug/0.14.1 Python/3.6.0 Date: Thu, 13 Dec 2018 15:31:48 GMT{"id": 1,"name": "book1","category": "cat1" } 復制代碼取 ID 為 5 的圖書測試下
$ curl -i http://127.0.0.1:5000/api/v1/books/5 復制代碼結果
HTTP/1.0 404 NOT FOUND Content-Type: application/json Content-Length: 149 Server: Werkzeug/0.14.1 Python/3.6.0 Date: Thu, 13 Dec 2018 15:32:47 GMT{"message": "Book 5 not found. You have requested this URI [/api/v1/books/5] but did you mean /api/v1/books/<int:book_id> or /api/v1/books ?" } 復制代碼ID 為 1 時,成功獲取到圖書信息;ID 為 5 時,由于圖書不存在,所以會返回 404 的響應。測試結果與預期一致,說明這個接口也 OK 了。
實現接口 3,新增圖書。新增圖書的時候,我們應該校驗參數是否符合要求。Flask-RESTFul 給我們提供了比較優雅的實現,不需要我們使用多個 if 判斷的硬編碼的形式去檢測參數是否有效。
由于圖書名稱和分類都是不能為空的,我們需要自定義規則,我們可以在「公共方法區」新增一個方法
def not_empty_str(s):s = str(s)if not s:raise ValueError("Must not be empty string")return s 復制代碼重寫 BookListApi 的初始化方法
def __init__(self):self.reqparse = reqparse.RequestParser()self.reqparse.add_argument('name', type=not_empty_str, required=True, location='json')self.reqparse.add_argument('category', type=not_empty_str, required=True, location='json')super(BookListApi, self).__init__() 復制代碼然后實現 post 方法
def post(self):args = self.reqparse.parse_args()book = {'id': books[-1]['id'] + 1 if books else 1,'name': args['name'],'category': args['category'],}books.append(book)return book, 201 復制代碼方法中,首先檢測參數是否有效,然后取最后一本書的 ID 加上 1 作為新書的 ID 保存,最后返回添加的圖書信息和狀態碼 201(表示已創建)。
測試下參數校驗是否 OK
$ curl -i \-H "Content-Type: application/json" \-X POST \-d '{"name":"","category":""}' \http://127.0.0.1:5000/api/v1/books 復制代碼結果
HTTP/1.0 400 BAD REQUEST Content-Type: application/json Content-Length: 70 Server: Werkzeug/0.14.1 Python/3.6.0 Date: Thu, 13 Dec 2018 15:46:18 GMT{"message": {"name": "Must not be empty string"} } 復制代碼返回 400 的錯誤,說明參數校驗有效。
看下新增接口是否可用
$ curl -i \-H "Content-Type: application/json" \-X POST \-d '{"name":"t_name","category":"t_cat"}' \http://127.0.0.1:5000/api/v1/books 復制代碼結果
HTTP/1.0 201 CREATED Content-Type: application/json Content-Length: 63 Server: Werkzeug/0.14.1 Python/3.6.0 Date: Thu, 13 Dec 2018 15:53:54 GMT{"id": 4,"name": "t_name","category": "t_cat" } 復制代碼說明創建成功。我們通過獲取指定 ID 的圖書接口確認下
$ curl -i http://127.0.0.1:5000/api/v1/books/4 復制代碼結果
HTTP/1.0 200 OK Content-Type: application/json Content-Length: 63 Server: Werkzeug/0.14.1 Python/3.6.0 Date: Thu, 13 Dec 2018 15:54:18 GMT{"id": 4,"name": "t_name","category": "t_cat" } 復制代碼獲取成功,說明確實創建成功,說明接口 3 也好了。
接口 4、5 的實現與上面類似,這里貼下代碼,就不詳細說明了。
和 BookListApi 類似,首先重寫 BookApi 的初始化方法
def __init__(self):self.reqparse = reqparse.RequestParser()self.reqparse.add_argument('name', type=not_empty_str, required=True, location='json')self.reqparse.add_argument('category', type=not_empty_str, required=True, location='json')super(BookApi, self).__init__() 復制代碼然后實現 put 和 delete 方法
def put(self, book_id):book = get_or_abort(book_id)args = self.reqparse.parse_args()for k, v in args.items():book[k] = vreturn book, 201def delete(self, book_id):book = get_or_abort(book_id)del bookreturn '', 204 復制代碼至此,后端項目基本完畢。
當然,這是不完整的,比如這里面都沒有實現對 API 的認證,這個可以通過 Flask-HTTPAuth 或者其它方式實現。限于篇幅,這里就不展開說明了,有興趣的可以看下這個這個擴展的文檔或者自己研究實現下。
3 整合
單獨的前端或后端都有了雛形,就差整合這一步了。
前端需要請求數據,這里我們使用 axios,切換到 spa-demo/client 目錄下進行安裝
$ npm install axios --save 復制代碼修改 spa-demo/client/src/views/Home.vue,在 script 標簽之間引入 axios,并初始化 API 地址
import axios from 'axios'const booksApi = 'http://localhost:5000/api/v1/books'export default {... } 復制代碼修改鉤子 created 的邏輯,從后端獲取數據
created () {axios.get(booksApi).then(response => {this.books = response.data}).catch(error => {console.log(error)}) } 復制代碼運行前端項目后,查看首頁,會發現沒有數據。查看開發者工具,我們會發現這么一個錯誤
Access to XMLHttpRequest at 'http://localhost:5000/api/v1/books' from origin 'http://localhost:8080' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. 復制代碼就是說當前項目不支持 CORS(Cross-Origin Resource Sharing,即跨域資源訪問)。這個我們可以在前端添加代理的形式實現,也可以在后端通過 Flask-CORS 實現。這里,我使用的后者。
切換到 spa-demo/server 目錄,安裝 Flask-CORS
$ pipenv install flask-cors 復制代碼修改 spa-demo/server/app.py,在頭部引入 CORS
from flask_cors import CORS 復制代碼在代碼
app = Flask(__name__) 復制代碼和
api = Api(app) 復制代碼之間添加一行
CORS(app, resources={r"/api/*": {"origins": "*"}}) 復制代碼然后重新運行 app.py,刷新首頁,我們會看到列表有數據了,說明 CORS 的問題成功解決。
在 spa-demo/client/src/views/Home.vue 中,修改 save 方法,同時新增輔助方法 setErrMsg
setErrMsg (errResponse) {let errResMsg = errResponse.data.messageif (typeof errResMsg === 'string') {this.errMsg = errResMsg} else {let errMsgs = []let kfor (k in errResMsg) {errMsgs.push('' + k + ' ' + errResMsg[k])}this.errMsg = errMsgs.join(',')} }, save() {if (this.editedIndex > -1) { // 編輯axios.put(booksApi + '/' + this.editedItem.id, this.editedItem).then(response => {Object.assign(this.books[this.editedIndex], response.data)this.close()}).catch(error => {this.setErrMsg(error.response)console.log(error)})} else { // 新增axios.post(booksApi, this.editedItem).then(response => {this.books.push(response.data)this.close()}).catch(error => {this.setErrMsg(error.response)console.log(error)})} } 復制代碼此時,圖書新增、保存搞定。
修改 deleteItem 方法
deleteItem (item) {const index = this.books.indexOf(item)confirm('確認刪除?') && axios.delete(booksApi + '/' + this.books[0].id).then(response => {this.books.splice(index, 1)}).catch(error => {this.setErrMsg(error.response)console.log(error)}) } 復制代碼此時,刪除方法也搞定了。
至此,整合完畢,基于 Vue + Flask 的前后端分離的一個 CRUD Demo 就完成了。
看完本文,你可以按著步驟自己實現下。剛接觸的伙伴在看的過程中在某些地方可能有疑惑,我也在我能想到的地方提供了一些資料,你可以試著看下。如果沒能提供全,你需要自己百度/谷歌下解決。不過,我還是建議不要妄求每個點都了解的特別清楚,先明白關鍵點,試著實現一下,回頭去看相關資料的時候,也更有感觸一些。
完整代碼可到 GitHub 查看
https://github.com/kevinbai-cn/spa-demo
4 參考
- 《Full-stack single page application with Vue.js and Flask》
- https://bit.ly/2C9kSiG
- 《Developing a Single Page App with Flask and Vue.js》
- https://bit.ly/2ElaXrB
- 《Vuetify Documents》
- https://bit.ly/2QupMzx
- 《Designing a RESTful API with Python and Flask》
- https://bit.ly/2vqq3Y1
- 《Designing a RESTful API using Flask-RESTful》
- https://bit.ly/2nGDNtL
本文首發于公眾號「小小后端」。
與50位技術專家面對面20年技術見證,附贈技術全景圖總結
以上是生活随笔為你收集整理的使用 Vue + Flask 搭建单页应用的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: CodeForces 780 E Und
- 下一篇: 工作中用到的