基于Vue的应用前端GUI搭建

  • 引言
  • 1. 概览
    • 1.1 总体结构
    • 1.2 项目编译时会发生什么
    • 1.3 路径会被如何定位
  • 2. 响应式组件
    • 2.1 用CSS调节组件大小和间距
    • 2.2 组件传参
  • 引用

这篇文章的目标是跟进Vue 3.2发布以来前端的新进展和自己对于基于VuePython搭建前后端应用的实践经验和新理解, 搭建一个尽量简单/清晰/实用/有参考价值的前端GUI页面. 该GUI页面尽管是基于WEB技术实现, 但也为移植到基于FlaskWebviewPython应用中预留了接口. 这篇文章包含的一些技术细节希望可以给未来的自己和朋友们带来价值.

引言

时隔三年, 回看当初断更的博客——如何用 Vue.js + Electron 把你的 Python 控制台应用套上好看的 GUI——感到颇为惭愧, 为当时雄心壮志开坑不填而惭愧, 同时也为自己这三年中没有更加勤奋精进惭愧.

当初搭建基于Vue的前端应用时对于HTML/JS/CSS等WEB前端技术一窍不通, 一番折腾之后终于弄出了一个能用的页面. 中间走了不少弯路不谈, 里面的实现也谈不上简单明晰. 特别是用到JS去写前端逻辑时, 从C/C++这样的强类型语言启蒙的我感到JS的语法简直时一团浆糊. 这样尽管这段时间的开发实践让我对JS的实用性和接受度颇为改观, 但我的"JS语法好似答辩"的刻板印象仍然没有动摇. 我对JS的理解可以概括成两点: 1) JS的终极准则就是全局变量——别管, 全局变量就完事了; 2) 时刻警惕无处不在的_未定义_. 可以说这两点导致了我的代码就是我的代码, 今天的代码就是今天的代码, 换一个人或者过几天, 代码就读不懂啦.

除了JS让我放下了结构良好的代码的执念, 眼花缭乱的前端UI框架也让我放下了响应性良好的页面的执念. 对于我这种玩票选手, 钻研页面组件的响应性实在过于奢侈, 以至于有时候过分依赖某一个UI框架提供的组件而又不了解其底层实现反而失去了对页面的掌控.

所以这次, 我准备在脚本上拥抱Vue 3, 在页面上回归Element-uiCSS. Vue 3中增加了对TypeScript的原生支持. TypeScript作为一个JS的超集, 为JS提供了类型支持. 我的直观感觉是函数传参总算不至于一堆字符串和undefined乱飞了. 而新增加的组合式API让组件的结构看上去更清晰, 对比起来感觉以前好像一堆钩子. 不过组合式API似乎对简陋的单HTML文件不太友好, 所以就显得写一个方便日后删改的Demo很有必要. 而我这次选择Element-ui一方面是因为它有TypeScript版本Element-plus, 另一方面也是因为它够用而不过分复杂, 自己写几行CSS排排版也就能做出像样的页面了. 还有一点是因为我毕竟是饿了么重度用户, 我自己对于GUI的概念也已经渐渐变成了饿了么的模样.

为了让这个演示应用尽可能地简单, 同时又包含足够多的技术细节供日后参考, 我准备让页面包含响应式页面组件, 组件参数传递, HTTP通信, 图片展示, 引入外部定义的全局变量等几个基本的内容. 为此, 我在这篇文章里给出一个Dalle-mini的网页应用. 这个应用根据用户输入的_提示_(prompt)给出一幅由后端Dalle-mini模型生成的图像. 项目我已经发布到我的Github仓库. 接下来我将介绍几个的技术细节.

基于Vue+Python的应用搭建——前端[1]


1. 概览

1.1 总体结构

要使用组合式API意味着仅仅写一个包含所有功能的HTML是行不通的. 当然, Vue 3也提供了传统的选项式API. 选项式API的好处是可以快速写出一个单HTML文件的网页应用, 也方便在浏览器控制台操作vm实例. 但我个人觉得要用回选项式API, 不如直接一步到位切回Vue 2, 毕竟老版本用着习惯.

既然要用到组合式API, 那么最新版本的Node.js是少不了了(至少16+). 为了快速安装依赖, 可能还需要安装阿里镜像源的包管理工具cnpm. 我在这里用了官方的构建工具Vite和项目脚手架create-vue. 通过以下命令生成一个项目:

cnpm init vue@latest

要注意在✔ Add TypeScript?时要选择Yes才能使用TypeScript. 其他选项选No即可.

生成的项目文件结构如下:

Project
	|  public
	|   | ...
	|  src
	|   |  assets
	|   |   | ...
	|   |  components
	|   |   | ...
	|   |  App.vue
	|   |  main.ts
	|  index.html
	|  tsconfig.json
	|  ...

注意安装element-plus@element-plus/icons-vue后, 还需要在tsconfig.json中修改相应配置.

1.2 项目编译时会发生什么

在项目中, index.html几乎会被原封不动地编译. 因此一些不希望由Vue做的事情, 以及希望能够方便被外部修改的内容何以写到这个文件里.

main.tsVue组件与HTML之间的接口. 这里主要用来创建Vue应用实例. 同时, 也由于这里位于引用的最顶端, 所以可以在这里声明并引用index.html中的外部变量. 具体的方法在后续的部分会进行详细的说明. 接下来就是.vue应用或者组件.

项目中的public文件夹和assets文件夹主要用来放一些图片之类的静态资源或第三方脚本, 区别在于public中的资源会原封不动地保存下来, 放置在index.html同一个文件夹下, 而assets中的资源会被整合到最终的jscss文件中. 实际发现, 组件中引用public文件夹内的json文件时, 最终编译生成的js脚本直接保存了json文件内的值, 而不是间接引用json文件, 尽管仍保存了所有原始文件.

1.3 路径会被如何定位

在引用组件时, 一般会基于当前文件的相对路径查找目标文件, 例如, 在App.vue中引用components下的ImageFrom.vue:

import ImageForm from "./components/ImageForm.vue"

还有一种特殊的路径符号@, 这个符号会被定位到src文件夹下. 在项目文件结构比较深的时候比较有用. 例如, 在components文件夹下的ImageFrom.vue中引用SingleImage.vue可以有两种写法:

import SingleImage from "./SingleImage.vue";
// equals to
import SingleImage from "@/components/SingleImage.vue";

2. 响应式组件

2.1 用CSS调节组件大小和间距

对于一个桌面应用, 经常会有这样的需求: 让窗口始终展示所有组件.

对于网页应用来说, 在横向上这个需求很容易满足, 但纵向上往往存在困难. 因为网页是为阅读内容而生的, 一般更关注横向的排版. 窗口尺寸变化时, 往往组件横向收缩, 而纵向的尺寸和间距不变, 导致部分内容需要窗口纵向滚动才可见. 在绝对尺寸未定的情况下, 自然也无法令组件相对窗口纵向居中. 为了使组件在纵向排版上相对稳定, 必须使组件宽/高同时随窗口尺寸自动调节.

想要自动随页面调节组件尺寸, 用%为单位设定组件的相对宽/高似乎在直观上可行. 但实际上在未设定父节点尺寸的前提下, %是失效的. 现代浏览器提供了vh这一单位, 自动计算相对窗口的绝对尺寸. 这样, 就可以进一步设定组件纵向居中.

<el-container>
  <el-main>
    <el-row class="main-content-row" align="middle" justify="center">
      <el-card class="box-card">
        ...
      </el-card>
    </el-row>
  </el-main>
  <el-footer>
    <el-row justify="center">
      ...
    </el-row>
  </el-footer>
</el-container>
<style>
.main-content-row {
  height: 100%; /* 父节点el-main高度设定了绝对值(vh,px...)才有效 */
}
.el-main {
  height: 95vh; /* 95% 窗口高度, 浏览器自动计算出绝对值 */
  padding-bottom: 0px;
  padding-top: 0px;
}
.el-footer {
  height: 3vh; /* 3% 窗口高度, 留出2%的余量 */
  font-size: var(--el-font-size-extra-small);
}
.box-card {
  padding: 20px;
  min-width: 300px;
}
</style>

这样使el-card组件始终位于窗口中央(纵向略偏上, 因为有一部分页面留给el-footer), el-footer的内容也始终位于页面最底部且居中.

2.2 组件传参

使用组件可以在一定程度上优化代码的结构.

假如有如下一个组件SingleImage.vue, 用于显示一张图片.

<template>
  <el-row justify="center">
    <el-image
      :src="imageUrl"
      v-if="props.loadingStatus !== LoadingStatus.idle"
      v-loading="props.loadingStatus === LoadingStatus.loading"
      fit="contain"
      class="image-row"
    >
    </el-image>
  </el-row>
</template>
<script setup lang="ts">
import LoadingStatus from "./LoadingStatus";
import { Warning } from "@element-plus/icons-vue";
interface Props {
  loadingStatus: LoadingStatus;
  imageUrl: string;
}
const props = defineProps<Props>();
</script>

这个组件接收两个参数, 分别是loadingStatusimageUrl. 第一个参数指定图像加载状态, 第二个参数指定了图像的源. 有以下几点技巧:

  1. 这里使用interface接口定义参数的类型. 在html模板里, 可以使用props.loadingStatus调用属性, 也可以简写为loadingStatus. 注意, 无论如何使用defineProps宏定义组件const props = defineProps<Props>()是不可缺少的.
  2. 父子文件共同引用一个自定义的属性时, 可以引用一个通用的外部.ts文件来统一变量的类型. 例如import LoadingStatus from "./LoadingStatus", 在LoadingStatus.ts中定义并给出枚举类型LoadingStatus的外部接口.
// LoadingStatus.ts
enum LoadingStatus {
  idle,
  loading,
  done,
  error,
}
export default LoadingStatus;
  1. 在父文件里调用子组件:
<!-- ImageForm.vue -->
<template>
  <!-- ... -->
  <single-image :image-url="imageUrl" :loading-status="loadingStatus" />
  <!-- ... -->
</template>
<script setup lang="ts">
import { ref } from "vue";
import SingleImage from "./SingleImage.vue";
import LoadingStatus from "./LoadingStatus";
const imageUrl = ref("");
const loadingStatus = ref(LoadingStatus.idle);
// ...
</script>

引用

@misc{Dayma_DALL·E_Mini_2021,
      author = {Dayma, Boris and Patil, Suraj and Cuenca, Pedro and Saifullah, Khalid and Abraham, Tanishq and Lê Khắc, Phúc and Melas, Luke and Ghosh, Ritobrata},
      doi = {10.5281/zenodo.5146400},
      month = {7},
      title = {DALL·E Mini},
      url = {https://github.com/borisdayma/dalle-mini},
      year = {2021}
}

完整代码见GitHub仓库.