Flask & Vue 构建前后端分离的应用

最近在使用 Flask 制作基于 HTML5 的桌面应用,前面写过《用 Python 构建 web 应用》,借助于完善的 Flask 框架,可以轻松的构建一个网站应用。服务端的路由管理和前端模板页面的渲染都使用 Flask 提供的 API 即可,并且由于 werkzuge 提供了强大的开发功能,可以在运行时自动重新加载整个应用。如果使用 gevent 提供的 WSGIServer 作为服务器网关,在使用时需要进行一定的配置。此时仍然是由 Python 负责前后端的处理。

尽管 Jinja2 为界面渲染提供了诸多便利的方法,但修改模板中的 HTML 文件后都需要手动刷新 Chrome 浏览器以便观察变化。如果能给将界面的渲染从服务端分离出来,服务端只需要提供数据或相应的 API,界面由其他框架负责处理,那么将给程序开发带来极大的便利,可以考虑采用 Vue+Flask 的模式构建应用。

Vue 的使用非常灵活,既可以将其应用在现有网站的部分页面中(可兼容已经完成的网站项目),又可以将其作为一个单独的完整前端项目进行开发。由于我所构建的网站较小,而且使用 Flask 模板开发界面并不方便,最终我选择了将前端界面作为一个独立于服务端的项目进行开发,后端的数据或验证以 api 的形式开放给前端调用。

前后端分离的好处是,界面上的复杂的东西可以轻松的使用 Vue 框架处理,由 webpackdev server 监听文件事件,界面改动后自动刷新浏览器,同时可以利用 Vue Devtoools 可以很方便的查看界面中的相应变量。另外 Vue 的文档比较全面(含有官方中文文档),并且入门门槛较低容易上手。

环境

需要注意的是,环境对应用开发有一定的影响,有些文章中 Vue-cli 版本如果和你使用的不一样,将会有一些配置上的区别。

  • Python 3.6.7
  • 服务端依赖项:flask 等,具体见文件
  • Vue 2.5.17
  • Vue 3.2.0
  • 前端其他依赖项见文件

如果环境和你的有所不同,参照相应的官方文档进行操作。

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_folderstatic_folderstatic_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.htmluser.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')

使用 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': ''
        }
    }
}

现在我们在开发服务器中访问 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 上。

参考

  1. developing-a-single-page-app-with-flask-and-vuejs
  2. 使用 Vue.js 和 Flask 来构建一个单页的App
  3. specify delimiters for a vuejs component
  4. xmall-front 前端项目
  5. https://stackoverflow.com/questions/43838135/vue-app-doesnt-load-when-served-through-python-flask-server
  6. https://forum.vuejs.org/t/routes-not-working-after-npm-build/34261
  7. https://router.vuejs.org/guide/essentials/history-mode.html
  8. https://codeburst.io/full-stack-single-page-application-with-vue-js-and-flask-b1e036315532
  9. https://blog.csdn.net/MRblackLu/article/details/71263276
  10. https://github.com/vuejs-templates/webpack/issues/450
  11. https://stackoverflow.com/questions/31945763/how-to-tell-webpack-dev-server-to-serve-index-html-for-any-route
12-28 00:22