單頁應用,只加載一個主頁面,而後經過 AJAX 無刷新加載其它頁面片斷。表面上看,就只有一個 HTML 文件,所謂單頁。開發上,作到了先後端分離,前端專一於渲染模板,然後端只要提供 API 就行,不用本身去套模板了。效果上,頁面和共用的 JS、CSS 文件都只加載一次,能減輕服務器壓力和節省必定的網絡帶寬。另外,因爲不須要每次都加載頁面以及共用的靜態文件,響應速度也有必定提升,用戶體驗比較好。固然,也有一些缺點,好比 SEO 優化不大方便,不過也有相應的解決方案。總的來講,使用單頁應用的好處仍是遠多於壞處,這也是愈來愈多的人使用單頁應用的緣由。html
構建單頁應用的方式有不少,這裏咱們選擇 Flask + Vue 實現。本文以實現一個 CRUD 的 Demo 爲主線,在其中穿插必要的技術點進行講述。裏面可能涉及了一些你沒接觸或者不熟悉的概念,不過沒關係,我會給出相應的參考文章幫助你理解。固然,大牛可忽略這些 :)。看完這篇文章後,相信你也能搭建本身的單頁應用了。前端
這裏咱們會用到 Vue 框架。若是你以前沒有接觸過,推薦去看下官方文檔的「基礎」一節。也能夠先直接向下看,Demo 用的都是一些基礎的東西,大體看下應該就能理解。即便暫時不理解,照着例子實踐一遍後,去看下文檔收穫也應該更多。vue
爲了更便捷的建立基於 Vue 的項目,咱們可使用 Vue Cli 腳手架。經過腳手架建立項目的時候,它會輔助咱們作一些配置,省去咱們手動配置的時間。剛接觸的夥伴前期會用它建立項目就好了,至於更深的一些東西后期再去了解。node
安裝腳手架python
$ npm install -g @vue/cli
複製代碼
這裏咱們安裝的是最新的 3 版本。ios
基於 Vue 的 UI 組件庫不少,好比 iView、Element、Vuetify 等。國內使用 iView、Element 的特別多,而使用 Vuetify 的人相對要少不少,不知道是你們看不慣它的 Material Design 風格仍是它的中文文檔稀缺的緣故。不過我我的挺喜歡 Vuetify 的風格的,因此我會使用這個組件庫搭建前端頁面。git
若是你沒使用過這個組件庫,照着本文一步步實踐下去,也能對 Vuetify 的用法有個大體的瞭解。若是這個過程當中,感受碰到的疑問太多,能夠看下 YouTube 上的這個視頻教程。github
https://dwz.cn/lxMHF4bY
數據庫
也不要處處去找相似的資源了,就是這個系列的視頻看完再加上官方文檔,掌握經常使用的點基本沒問題。npm
不過,仍是建議先照着本文實現一下 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-actions
class="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 官方文檔這些內容
靜下心來讀讀就明白了,不難,這裏我再也不贅述。
一樣,這裏你也能夠先照葫蘆畫瓢,能夠先暫時忽略掉一些很差理解的地方,待實踐一遍以後再去搞清楚。
打開
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 = false
setTimeout(() => {
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)
}
複製代碼
至此,前端項目告一段落。
後端,咱們只須要提供增刪改查的接口供前端使用就行。RESTful API 是目前比較成熟的一套互聯網應用程序設計理論,我也會基於此實現圖書的相關操做接口。
考慮到有對 RESTful API 不大熟悉的夥伴,我列了幾個我以前學習的文章,供你們參考。
https://dwz.cn/eXu0p6pv
https://dwz.cn/8v4B0twY
https://dwz.cn/2aSnI8fF
https://dwz.cn/bVxrSsf4
看完上面的相關資料,你對這種設計理論應該就有必定掌握了。
一樣,你暫時可沒必要對 RESTful API 瞭解得很全面,暫時像下面這樣理解它就行
就是用 URL 定位資源,用 HTTP 描述操做。
這個是在刷上面知乎問題看到的一個回答,做者是 @Ivony。寫得很簡潔,但確實有道理。
等到本身實踐一次後,再回頭看看理論的一些東西,印象更深。
首先列下咱們須要實現的接口
序號 | 方法 | URL | 描述 |
---|---|---|---|
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-8
from flask import Flask, request
from flask_restful import Api, Resource, reqparse, abort
app = 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):
pass
def put(self, book_id):
pass
def delete(self, book_id):
pass
class BookListApi(Resource):
def get(self):
return books
def post(self):
pass
api.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 方法對應接口 二、四、5,BookListApi 類中的 get、post 方法對應接口 一、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 None
def 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 也好了。
接口 四、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] = v
return book, 201
def delete(self, book_id):
book = get_or_abort(book_id)
del book
return '', 204
複製代碼
至此,後端項目基本完畢。
固然,這是不完整的,好比這裏面都沒有實現對 API 的認證,這個能夠經過 Flask-HTTPAuth 或者其它方式實現。限於篇幅,這裏就不展開說明了,有興趣的能夠看下這個這個擴展的文檔或者本身研究實現下。
單獨的前端或後端都有了雛形,就差整合這一步了。
前端須要請求數據,這裏咱們使用 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.message
if (typeof errResMsg === 'string') {
this.errMsg = errResMsg
} else {
let errMsgs = []
let k
for (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
https://bit.ly/2C9kSiG
https://bit.ly/2ElaXrB
https://bit.ly/2QupMzx
https://bit.ly/2vqq3Y1
https://bit.ly/2nGDNtL
本文首發於公衆號「小小後端」。