Flask & Vue 構建前后端分離的應用
最近在使用 Flask 制作基於 HTML5 的桌面應用,前面寫過《用 Python 構建 web 應用》,借助於完善的 Flask 框架,可以輕松的構建一個網站應用。服務端的路由管理和前端模板頁面的渲染都使用 Flask 提供的 API 即可,並且由於 werkzuge 提供了強大的開發功能,可以在運行時自動重新加載整個應用。如果使用 gevent 提供的 WSGIServer 作為服務器網關,在使用時需要進行一定的配置。此時仍然是由 Python 負責前后端的處理。
盡管 Jinja2 為界面渲染提供了諸多便利的方法,但修改模板中的 HTML 文件后都需要手動刷新 Chrome 瀏覽器以便觀察變化。如果能給將界面的渲染從服務端分離出來,服務端只需要提供數據或相應的 API,界面由其他框架負責處理,那么將給程序開發帶來極大的便利,可以考慮采用 Vue+Flask 的模式構建應用。
Vue 的使用非常靈活,既可以將其應用在現有網站的部分頁面中(可兼容已經完成的網站項目),又可以將其作為一個單獨的完整前端項目進行開發。由於我所構建的網站較小,而且使用 Flask 模板開發界面並不方便,最終我選擇了將前端界面作為一個獨立於服務端的項目進行開發,后端的數據或驗證以 api 的形式開放給前端調用。
前后端分離的好處是,界面上的復雜的東西可以輕松的使用 Vue 框架處理,由 webpack 的 dev server 監聽文件事件,界面改動后自動刷新瀏覽器,同時可以利用 Vue Devtoools 可以很方便的查看界面中的相應變量。另外 Vue 的文檔比較全面(含有官方中文文檔),並且入門門檻較低容易上手。
環境
需要注意的是,環境對應用開發有一定的影響,有些文章中 Vue-cli 版本如果和你使用的不一樣,將會有一些配置上的區別。
如果環境和你的有所不同,參照相應的官方文檔進行操作。
Flask 后端
盡管前端分離成的一個單獨的項目,但是在生產環境中還是需要 Flask 提供路由訪問生成好的 html 界面文件。不過訪問頁面的路由可以做的比較簡單。
與其他的 Flask 應用沒有區別,首先實例化一個 Flask 應用:
from flask import Flask, Blueprint
app = Flask(__name__,
template_folder='templates',
static_folder='templates/static',
static_url_path='/static')
@app.route('/', methods=['GET'])
def app_index():
if 'user' in session:
return redirect('/user')
return redirect('/home')
home = Blueprint( 'home', __name__,
template_folder='vtemplates',
static_folder='vtemplates/vstatic',
static_url_path='/vstatic' )
@home.route('/home', defaults={'path': ''}, methods=['GET'])
@home.route('/home/<path:path>', methods=['GET'])
def home_index(path):
return render_template('home.html')
app.register_blueprint(home)
if __name__ == '__main__':
app.run(debug=True)
傳入 Flask 的參數中 template_folder
, static_folder
和 static_url_path
都是可以指定的,如果你需要兼容舊版本的應用,可以使用藍圖(Blueprint)並為其指定不同的模板路徑和靜態文件路徑。在這里我用到了藍圖,實例化了 home 藍圖,並為其指定了一個不同的模板和靜態文件路徑(假設這個文件夾是我們稍后會用 Vue 構建出來的),這樣的話就可以避免藍圖和應用的模板相互影響。
另一個要注意的地方是,必須在定義 home 藍圖的所有路由后再調用 app.register_blueprint(home)
, 否則將會出現找不到相應路由的錯誤提示。
我們這里將會構建單頁應用,所以對於 home 的路由訪問全部渲染到 home.html
頁面上。
我們在項目根目錄下面新建一個 templates
文件夾,在里面新建名為 home.html
的文件,添加以下內容:
<!DOCTYPE html>
<html>
<head>
<title>Home Page with Jinja2 Template Engine</title>
</head>
<body>
Hello, this is a home page rendered by Jinja2 Template Engine.
</body>
</html>
現在運行這個 Python 腳本:
python app.py
服務器程序默認運行在 127.0.0.1:5000 地址上,訪問 http://127.0.0.1:5000
,我們能夠在瀏覽器界面上看到 "Hello, this is a home page rendered by Jinja2 Template Engine."。注意這個位置有一個隱藏的坑:盡管我們設置了 home 藍圖的 template_folder 路徑為 vtemplates
(注意我們這個時候還沒有創建這個文件夾),但是在訪問 /home
路徑時,渲染的文件卻是 templates/home.html
,看上去似乎不錯,這讓我們可以在藍圖和應用間共享模板,但是卻會帶來另一個問題。
接下來我們手動創建另一個文件夾 vtemplates
,在里面新建名為 home.html
的文件,添加以下內容(稍后會使用 Vue-cli 自動構建 vtemplates
文件夾):
<!DOCTYPE html>
<html>
<head>
<title>Home Page</title>
</head>
<body>
Hello, this is a home page (it will be built by vue-cli commands).
</body>
</html>
打開瀏覽器,訪問 http://127.0.0.1:5000
這個地址,它會重定向至 http://127.0.0.1:5000/home
,但是這里顯示的界面仍然是 ./templates/home.html
文件的內容,而非 ./vtemplates/home.html
。如果藍圖要訪問的模板文件與應用中的重名了,那么 Flask 渲染模板的順序可能和你所想的不同。在 github 的 issue 中有一些相關的討論:https://github.com/pallets/flask/issues/2664,基本上是討論模板的渲染順序問題。為了防止渲染錯誤的頁面,我們直接將 templates
路徑下的重名文件刪除,再次訪問 http://127.0.0.1:5000/home
,出現的內容是 “Hello, this is a home page (it will be built by vue-cli commands).”。
好了,一個基本的 Flask 后端程序就完成了(目前僅僅提供 HTML 文件的渲染)。前端將會由 Vue 構建的項目處理。
Vue 前端
創建一個 Vue 項目比較簡單,Vue 的官方文檔也比較詳細,就不過多介紹了。在項目根目錄下創建一個名為 frontend 的子項目:
vue create frontend
如果沒有什么要定制的話,回車使用默認配置即可。完成后會在項目根目錄下面看到 frontend 文件夾。進入該文件夾,便是前端項目了。
在 frontend 文件夾中,輸入 yarn serve
會打開一個開發用的服務器,根據項目源代碼改動情況自動重新加載服務器;輸入 yarn build
會在 /frontend
文件夾中構建用於生產環境的 dist 文件夾。前面說過,我們想讓 home 藍圖的模板路徑為 /vtemplates
,因此我們需要對 Vue-cli 做一些配置。
在 /frontend
文件夾中新建一個名為 vue.config.js
的文件,並添加以下內容:
module.exports = {
chainWebpack: config => {
config.module.rules.delete('eslint');
},
pages: {
home: {
entry: 'src/home/main.js',
template: 'public/index.html',
filename: 'home.html',
title: 'Home Page',
chunks: ['chunk-vendors', 'chunk-common', 'home']
},
user: {
entry: 'src/user/main.js',
template: 'public/index.html',
filename: 'user.html',
title: 'User Page',
chunks: ['chunk-vendors', 'chunk-common', 'user']
}
},
assetsDir: 'vstatic',
configureWebpack: {
devtool: 'source-map',
},
devServer: {
index: 'home.html',
proxy: {
'/api': {
target: 'http://127.0.0.1:5000/api/',
changeOrigin: true,
pathRewrite: {
'^/api': ''
}
},
'/user': {
target: 'http://127.0.0.1:8080/user.html/',
changeOrigin: false,
pathRewrite: {
'^/user': ''
}
}
}
},
outputDir: '../vtemplates'
}
在這個文件中,配置了將會輸出兩個 html 文件:home.html
和 user.html
。並且將輸出目錄放在了根目錄下的 vuetempletas
文件夾中,將靜態文件路徑設為了 vstatic
。
我想讓 home 作為一個 SPA(single page app 單頁應用),user 作為另一個 SPA。你可以按照自己喜歡的方式組織代碼。
在 /frontend/src
目錄下新建一個 home
文件夾,用於放置 home 應用的代碼,代碼簡略結構圖如下:
/ # 項目根目錄
|- frontend # 前端子項目
|- ...
|- src
|- home
|- venv # python virtualenv
|- templates # 用 Jinja2 語法編碼的模板
-- ...
-- app.py # 后端應用
在 /frontend/src/home
中添加 home.js
,現在的代碼很簡單,只用導入 Vue 依賴和 App.vue 文件就好。如果想要做成一個復雜的單頁應用,那么你還需要使用路由,如 vue-router,官網上對單頁應用有相應的示例 可供參考。:
import Vue from 'vue'
import App from './App.vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)
Vue.config.productionTip = false
import Index from './pages/Index.vue'
const routes = [
{
path: '/',
name: 'index',
component: Index,
alias: ['/home', '/index'],
},
];
const router = new VueRouter({
routes,
mode: 'hash'
});
new Vue({
router,
render: h => h(App),
}).$mount('#app')
注意這里要調用
Vue.use(VueRouter)
加載 VueRouter 插件,否則不會顯示相應的子界面。
使用 yarn serve
啟動開發服務器,在瀏覽器中輸入 localhost:8080/home.html
就可以看到如下帶有 Vue Logo 和 “Hello, this is Home App” 的界面了。
注意在上面 vue.config.js
配置文件中,我將 devServer 的 index 字段設為了 'home.html',因此直接訪問 localhost:8080
和訪問 localhost:8080/home.html
的效果是一樣的。
前后端結合
有時候在開發過程中,我們想要通過類似 localhost:8080/home
的方式而不用在路徑末尾加上 .html
后綴的方式訪問路由。比如現有的服務器路由就是不帶后綴名的,那么我們可以通過修改 devServer 的配置,使得在開發前端界面時保持多頁面的路徑統一。
錯誤說法
我在 webpack dev server 的配置中找了一下,如果前端的路由采用的是 history 模式,也就是傳統的 url 模式,那么可以在 devServer 中加入以下內容,重寫路徑:
devServer: {
historyApiFallback: true,
historyApiFallback: {
rewrites: [
{ from: '/^home', to: 'home.html'},
{ from: '/^user', to: 'user.html'},
]
},
}
如果前端路由采用的 hash 模式,那么上面的方法就不奏效了,沒有找到其他比較好的方法,但是我們可以修改 devServer 的 proxy 表來改變路由:
proxy: {
'/user': {
target: 'http://127.0.0.1:8080/user.html',
changeOrigin: false,
pathRewrite: {
'^/user': ''
}
}
}
以上划掉的內容為第一次編寫時的錯誤說法,保留以供參考。和正確的做法相比,錯誤的做法中 historyApiFallBack 有兩處用錯了:
- 在 devServer 中聲明了兩次,可能導致了重寫路由失敗
- 重寫的路由中,“to” 的內容不是以根目錄結尾的,可以看到每個路由都比正確的做法少了兩個斜杠。
現在我們在開發服務器中訪問 http://127.0.0.1:8080/user
也就訪問到了相應的界面。這樣做就使得服務端和前端的多頁面路由跳轉是一致的。
當前端開發完成后,使用 yarn build
命令將會在根目錄的 vtemplates
目錄下創建前端要用到的界面文件和 JS 代碼。只需使用 python app.py
啟動服務器即可。
完成了訪問頁面的路由統一,接下來只需要處理前后端通信的 API 即可。
我們在 app.py
文件中添加一個用於處理前后端通信的藍圖 api:
# app.py
api = Blueprint( 'api', __name__ )
@api.route('/home/signin', methods=['POST'])
def home_signin():
username = request.form.get('username')
password = request.form.get('password')
resp = { 'status': 'success' }
if username == 'test' and password == '1234':
session['user'] = username
else:
resp['status'] = 'fail'
return jsonify(resp)
app.register_blueprint(api, url_prefix='/api')
定義一個路由,以便可以響應相應的 POST 操作。
然后在前端項目 frontend 中添加一個用於通信的 src/api.js
,內容如下:
import $ from 'jquery'
export function fetchPost(url, params = {}) {
return new Promise((resolve, reject) => {
$.post(url, params).then( resp => {
resolve( resp );
}).catch( error => {
reject( error );
});
});
}
export default {
fetchPost: fetchPost
}
由於在 devServer 中我們已經定義了 api 地址的跨域訪問,因此可以使用 JQuery,當然如果你更熟悉 axios,那么你可以引入 axios 替換掉 jquery。
然后我們在 /frontend/src/home/
路徑下再添加一個 api.js
文件,負責處理前后端的 api 路由:
# home/api.js
import {fetchPost} from '../api.js'
export const singin = function(params) {
return fetchPost('/api/home/signin', params);
}
最后修改 /frontend/src/home/pages/Index.vue
文件,添加兩個輸入框和按鈕,並且添加相應的數據,以下為該文件中的內容:
<template>
<div>
<hr />
This is Index Page In Home SPA.
<form class="m-1">
<div class="m-1">
Username: <input type="text" v-model="username"/>
</div>
<div class="m-1">
Password: <input type="text" v-model="password"/>
</div>
<div class="m-1">
<button type="button" @click="toSignin()">SignIn</button>
</div>
</form>
</div>
</template>
<script>
import {signin} from '../api.js'
export default {
name: 'homeIndex',
data() {
return {
username: null,
password: null,
}
},
methods: {
toSignin: function() {
signin({
username: this.username,
password: this.password
}).then( resp => {
if( resp.status === 'success' ) {
window.location = '/user'
} else {
alert('Username or password is wrong.')
}
})
}
}
}
</script>
<style>
.m-1 {
margin: 5px;
}
</style>
在該界面中輸入一些錯誤的用戶名或密碼,將會在瀏覽器中彈出警告框,輸入正確的用戶名(test)和密碼(1234)后,前端頁面自動跳轉到 /user
路徑下。這樣前后端結合的工作就完成了。我們還做了一個非常簡陋的登錄示例。最后,我們將寫好的前端代碼打包到相應目錄下,在瀏覽器中輸入 localhost:5000
訪問我們的網站,可以正常的顯示和跳轉,和訪問前端的開發服務器一樣,只是所有服務都由 Flask 提供了。
拓展:利用 PyQt5 制作桌面應用
既然使用 Python Flask 和 Vue 制作了一個前后端分離的網站應用,那么我們實際上可以考慮添加 PyQt5 組件,利用現有的代碼制作一個基於 HTML5 的桌面應用,當然也可以直接通過在瀏覽器中輸入 IP + 地址的方式訪問這個桌面應用。
我們在項目根目錄下新建一個 deskapp.py,內容如下:
from PyQt5.QtWidgets import QApplication
from PyQt5.QtWebEngineWidgets import QWebEngineView
from PyQt5.QtCore import QUrl
def startWeb():
from app import app
app.run()
def main(argv):
qtapp = QApplication(argv)
from threading import Thread
webapp = Thread(target=startWeb)
webapp.daemon = True
webapp.start()
view = QWebEngineView()
view.setWindowTitle('DeskApp')
port = 5000
view.setUrl( QUrl("http://localhost:{}/".format(port)))
view.show()
return qtapp.exec_()
import sys
if __name__ == '__main__':
main(sys.argv)
使用 python deskapp.py
運行程序,就會顯示一個桌面應用,在我們的網站應用規模較小時這樣做沒什么問題,但是最終應用的生產環境的 web app 可能使用的是 gevent.pywsgi.WSGIServer
,並且后台可能需要處理的事情較多,這時有可能會出現界面閃爍的情況,如果出現了這種情況,可以參考 PyFladesk 這個項目使用的方式:使用 QThread 包裝我們的 web 應用。由於 Python 中有 GIL 全局鎖,所以它的多線程不是真正意義上的多線程,但是 QThread 是 Qt 提供的多線程機制,線程之間不會相互影響。
總結
如果你只想在部分頁面中使用 Vue,並且要在 Flask 的模板中使用 Vue,那么你需要讓 Vue 使用不同的定界符,詳見 specify delimiters for a vuejs component
最開始我的項目中的前后端的通信部分都是分散在各個 Vue 文件中,我在查看 xmall-front 前端項目 的源代碼時發現了將前后端的通信操作集中到一個文件,以 API 的形式開放給各個 Vue 頁面更利於聚合代碼,因此在介紹【前后端結合】這一節中采用了這種方式。
總的來說,使用 Flask 構建一個 web 應用並不困難,使用 Flask + Vue 構建一個前后端分離的 web 應用也比較簡單,我們可以用 Flask + Vue 構建一個復雜的網站應用,但前后端分離使得開發過程並不會太復雜。另外,我們可以嘗試使用 QWebEngineView 構建一個基於 HTML5 的桌面應用,既能夠用瀏覽器訪問,也可以打包成一個 .exe 可執行文件。總之,使用 HTML5 開發可以給我們帶來很多便利。
所有相關的代碼存放在 github 上。
參考
- developing-a-single-page-app-with-flask-and-vuejs
- 使用 Vue.js 和 Flask 來構建一個單頁的App
- specify delimiters for a vuejs component
- xmall-front 前端項目
- https://stackoverflow.com/questions/43838135/vue-app-doesnt-load-when-served-through-python-flask-server
- https://forum.vuejs.org/t/routes-not-working-after-npm-build/34261
- https://router.vuejs.org/guide/essentials/history-mode.html
- https://codeburst.io/full-stack-single-page-application-with-vue-js-and-flask-b1e036315532
- https://blog.csdn.net/MRblackLu/article/details/71263276
- https://github.com/vuejs-templates/webpack/issues/450
- https://stackoverflow.com/questions/31945763/how-to-tell-webpack-dev-server-to-serve-index-html-for-any-route