FastAPI 走向全栈
Vue 遇见那条蛇并坠入爱河的故事。
FastAPI 是那种让我心生警惕的软件,因为 Python 后端的体验过于愉快了。Flask/Express 风格的 handler 里加上 type hints,就变成了参数解析和校验。带自动重载的 async 开箱即用,不需要召唤什么诡异的框架配置手册。再加上它的 OpenAPI schema 和自动重载,从 git init 到拿出一个像样的 API,咖啡机都还没完成它的开机自检。
然后你就需要一个前端了。
在过去,这条霓虹灯照亮的高速公路会在这里一头撞上混凝土墙,戛然而止。
FastAPI 在 API 方面非常出色,但它无法真正在站点根路径上提供你的 frontend,除非你想让所有请求都被路由到 StaticFiles。而 SPA 支持更是闻所未闻。你可以提供文件、渲染模板,或者挂上任何你想要的机制,但现代 JavaScript frontend 与 Python 应用之间的最后一英里,很大程度上被留给了读者自行解决。
而那个读者,越来越多地就是我自己。
与此同时,在前端的世界里
我在这项工作中使用 Vue。Vue 所处的领域大致与 React 和 Svelte 相同:基于组件的界面、响应式状态、客户端路由,以及将一堆源文件变成浏览器可以执行的东西所需的常见现代工具链。
不过,fastapi-vue 包也可以与其他框架一起使用,因此我将简要介绍一下这些竞争者。
React
React 是那头 800 磅重的大猩猩。它拥有庞大的生态系统,凡事都有解决方案,而且通常还有另外三个方案在跟第一个争论不休。
它最大的实际优势其实就是体量带来的引力:大量开发者熟悉它,大量库以它为目标,而且大量示例代码都以 npm install react 开头。它比 Vue 流行得多,这使得 React 与 FastAPI 之间相对薄弱的集成本身就相当耐人寻味。
即使是官方的 FastAPI 全栈解决方案——几周前才刚刚首次发布——本质上也是一个模板仓库:一套预先选好的 React 技术栈,你以此为起点,而不是能够走进一个已有的 Python 应用并把前端接入其中的工具。
Svelte
Svelte 采用更偏重编译器的方案。它不把那么多框架机制带到浏览器里,而是在构建时转换组件。最终产物在生产环境中无需 Node 即可运行。
它需要一些小技巧才能实现正确的响应式更新,需要在其自定义语言中使用 $ 来标记哪些内容需要更新。但反过来说,这套自定义语言也让代码比竞争对手更简短。
Vue
Vue 处在一个我很喜欢的水准上:既有足够的结构来构建大型应用,又不会让 HTML 被埋在一层又一层堆积如山的 JavaScript 之下。它的响应式系统配合 Pinia store 开箱即用、不碍事。而当你需要榨出最后一点性能时,它也能很好地与非响应式结构集成,不需要复杂的包装,也不需要 useState 之类的样板代码。
而且它跑得很快——大多数 React 应用可做不到这一点。
它官方的 create-vue 工具还给了我一样在这里特别有用的东西:我不必凭空发明自己心目中 Vue 项目该有的样子。它可以交互式地询问应用是否需要使用 TypeScript、Vue Router、Pinia、Vitest、Playwright/Cypress、ESLint、Prettier 以及其他常规装备。
这一点在后面会变得很重要。
我们到底要怎么部署这个东西?
当我一边是 FastAPI,另一边是 Vue、React 或 Svelte 时,要把这个结合体部署到服务器上,有几种截然不同的方案。
方案一:向 Node 妥协
JavaScript 世界里显而易见的答案是:在生产环境中也部署一个 JavaScript 服务器。
如果我确实想要一个 Node 后端,这就完全说得通了。在网络两端使用同一种语言和密切相关的工具链,确实有实实在在的优势。如果我的团队希望处处都用 TypeScript,那也是一种完全自洽的架构。
我并不想。
我选择 FastAPI,是因为我想用 Python 写后端。仅仅为了分发前端资源就额外安装一个运行时,感觉就像雇了第二个厨师专门负责把盘子从厨房端到餐厅。
还有 JavaScript 依赖宇宙这个小问题。它太臃肿、兼容性差、还经常坏掉。虽然 nvm 可以轻松安装正确的 Node 版本,但它占用的空间巨大,而托管服务器上往往根本没有那么多空间。
Node 在我的开发机器上很有用。Vite 很出色。create-vue 很出色。整个构建生态把本职工作完成得极好。
但这并不意味着我想让它们中的任何一个出现在生产环境的机器上。
方案 2:在服务器端渲染一切
另一个极端则是完全放弃 SPA,直接用 Python 生成 HTML。
这种做法值得比它有时获得的更多尊重。并非每个应用都需要一个客户端应用运行时、一个路由器以及数 MB 的配套基础设施。
像 htmx 这样的工具可以把服务端渲染的 HTML 带到出人意料的高度,它直接通过 HTML 属性添加 HTTP 请求、片段替换、过渡动画、WebSocket 以及 server-sent events。再搭配上 html5tagger 在服务端生成这些文档和 HTML 片段,你就能得到一个在任何地方都能运行、并且同样能被社交媒体、搜索引擎和 agent 读取的网站。
对于以文档、表单和相对简单的交互为主的网站,我很喜欢这种架构。浏览器发起请求,Python 生成 HTML,而且谁也不需要一套复杂到能发射火星探测器的构建流水线。
Vasanko.com 就是基于这种方式构建的,仅在需要更强交互性的后台管理界面上加装了 Vue。这套 CMS 运行在 FastAPI–Vue 架构上,但所有页面都在服务端用 Python 渲染。
选项 3:运行两个服务器
因此,常见的折中方案是:
- FastAPI 运行 API。
- Vite 或另一个 JavaScript 服务器运行 frontend。
- 一个反向代理把两者放在同一个主机名下。
- 生产环境现在有两个应用、两套依赖栈、两个进程,还有一份维系这段婚姻的配置文件。
这能用,但不优雅。
浏览器并不关心 app.js 是由 Python、Node、Caddy、nginx 还是一台意志足够坚定的烤面包机生成的。一旦前端构建完成,结果就是静态的 HTML、CSS、JavaScript、字体和图片。
那么,产品已经离开了流水线,为什么还要让工厂继续运转呢?
用 JavaScript 构建,用 Python 运行
这就是 fastapi-vue-setup 背后的核心想法。
我希望 JavaScript 工具链只出现在它真正有用的地方:
- 用
create-vue创建前端 - 用 Vite 作为开发服务器
- 改动即时热重载
- 在 Node、Bun 或 Deno 上进行标准的 Vue 构建
然后我要它彻底消失。
最终的生产构建产物应该是一个包含已构建好的前端的 Python 包。安装这个包不应该需要 Node、npm、Vite、Vue 源码,或者那个传统上存放在 node_modules 下的引力异常体。
这正是 fastapi-vue-setup 如今构建项目的方式:uv build 会运行前端构建,并将生成的资源文件包含进 Python 包中。生成的 Hatch 配置把 frontend-build 视为构建产物(artifact),并挂接到源码分发(sdist)的构建流程上,同时将包内容限制为仅包含 Python 包本身。
这个区别对 sdist(源码分发)来说尤为重要。我不希望源码分发意味着"这是一些 Python 代码,这是一棵完整的 Vue 开发树,现在请你自己安装 Node 并重建出真正的应用。"前端在分发离开我的机器之前就已经编译好了。
这个包只包含运行所需的内容,而不是我碰巧用来构建它的所有东西。
这样一来,部署就变得无比平淡了:
uvx my-app
目标机器上不需要 Node 安装仪式。不需要 npm install。不需要 frontend 服务器。也不会有 JavaScript 依赖树在三个月后醒过来要求喂食。
该包启动 FastAPI,由 FastAPI 来伺服应用。
另一个棘手的问题
构建前端只是其中一半的工作。
StaticFiles 并不能完全替代前端服务器
FastAPI 暴露了 Starlette 的 StaticFiles,对于普通的静态资源,它完全名副其实:
app.mount("/static", StaticFiles(directory="static"))
麻烦始于前端就是网站本身的时候。
Vue 应用通常想要 /。它还可能想要 /login、/settings、/dashboard/coffee-reactor/7,以及任何其他由客户端 router 拥有的路径。
但是把 StaticFiles 挂载到 / 实际上会把整个剩余的 URL 空间拱手让给这个挂载的应用。FastAPI 自己的维护者多年前就解释过这个问题:把它挂载在根路径上,静态应用就会接管一切,其下的正常路径操作将无法按预期工作。
而我们需要:
- 前端构建产物位于
/* - FastAPI 仍然处理
/api/.../openapi.json- 任意其他路由
这与伺服 /static/logo.svg 并不是同一个问题。
FastAPI 最近刚刚改进了它自己的前端支持,目前的文档现在明确建议前端应用使用 app.frontend() 而不是普通的 StaticFiles。但 fastapi-vue 早于该方案出现,而且职责也略有不同:它是这套完整打包系统中较小的运行时那一半。
运行时 fastapi-vue
配套的 fastapi-vue 包提供了一个自定义的 Frontend 处理器,而不是在应用上挂载 StaticFiles。
在 SPA 模式下,对于属于客户端 router 的路径,它可以返回 index.html,而不要求存在同名的物理文件。
Note
由于 FastAPI 的路由工作方式,app mount 会吞掉该路径下的所有内容。其余路由按顺序尝试,先匹配者胜出。因此在 SPA 模式下,我们必须把 catch-all 放在 app 模块的最后。
禁用 SPA 模式时,它只绑定到实际文件的路径,从而允许其后的路由仍能捕获所有漏网的请求。
它还处理了那些不那么光鲜的细节——而我可不想每个周二都重新实现一遍:
ETag和Last-Modified- 构建产物的不可变缓存
- 带 zstd 压缩的 RAM 缓存
- SPA 路由回退以及按需提供的
/favicon.ico - 在 devmode 下不会意外提供过期的构建产物
这是生产环境的运行时依赖。它体积很小,且为纯 Python 实现。
用于构建和开发 Vue 应用的全部机制都保留在源项目中。
认识 fastapi-vue-setup
有了这些基础,fastapi-vue-setup 主要关注的就是消除重复性的接线工作。
基本命令的设计刻意平淡无奇:将它指向你的应用文件夹即可;如果你已经在该文件夹中,就用 .。
uvx fastapi-vue-setup my-app
Need to change the port numbers your prod and vite and dev backend use by default? Run with --ports. Without that it retains the ports you previously had configured. And you can always --listen on your CLI or scripts/devserver.py to change that at runtime.
但其背后其实隐藏着两种截然不同的情形。
并且它为你的应用获得的 CLI 入口点提供了支持工具,这样入口点还可以接受你自己的命令行选项——这是 fastapi run 无法提供的。
创建新应用
对于一个新项目,我不希望 fastapi-vue-setup 强加一个由我自己预先定死的 Vue 模板。我希望它能创建一个以我指定的名称配置的 Python 项目,并采用我自己选择的 Vue 配置:
- JavaScript 或 TypeScript
- 使用 Vue Router 或不使用路由
- 使用 Pinia 或不使用
- 测试、lint 和格式化选项
- 当前
create-vue支持的其他选项
然后,该工具会围绕开发者实际选择的应用来构建 FastAPI 集成。
这很重要,因为模板不可避免地会固化某个人的偏好。六个月后,它所谓的现代全栈可能就该进博物馆陈列了,而你只能将就着用你所得到的东西。
我宁愿拥有选择的权利。
已有应用?
在这种情况下,我可能已经有一个真实项目,其中包含我不希望被抹掉的代码。它可以用同一个脚本创建,也可以独立存在,脚本会在需要的地方对它进行修补。
安装脚本会检测 Python 项目和后端模块,检测或创建 Vue 前端,并以最小的改动修补它能够安全集成的部分。
Note
首先将你现有的 Vue 项目完整移到 frontend/ 目录下。我们把 Vue 放在那里是为了避免用 Node 的东西污染根目录。把它放在那里后,脚本就会修补现有的应用,而不是创建一个新的。
需要升级到最新版本吗?只需再次运行 fastapi-vue-setup,它就会把新增的功能补丁进来。
这正是本项目与模板仓库(template repository)的主要区别之一。
模板说的是:
Fork 这个仓库来开始你的项目而我需要的工具还能说:
好吧,你已经写了 30,000 行了。告诉我病灶在哪里。
你的应用已准备好运行
uv run scripts/devserver.py
vite.config.js
Configure which paths are proxied to the backend. By default this just /api. This configuration only affects the dev setup, in production everything lands on FastAPI.
JS_RUNTIME
Environment with value bun/deno/node or path to one of them (otherwise we find one).
这会启动支持热重载的 Vue/Vite 开发服务器和 FastAPI。生成的 Vite 集成会将后端请求代理到 Python 开发服务器。你的浏览器连接到 Vite。
因此在开发过程中,我依然能享受到 JavaScript 生态中那些我真正喜欢的东西。
开发完成后:
uv build # Installs and (re)builds everything
my-app # CLI entry point provided (in .venv)
Vue 已被编译,产物已打包进 Python 发行版中,构建流程已完成其使命。如果你愿意,可以 uv publish 你的包,或者直接从 dist/ 复制到生产环境,然后用以下命令安装并运行:
uv tool install my-app-0.1.0.tar.gz
my-app
我事先只需要安装 UV,以及在开发系统上安装 Node 本身。我完全不需要碰 npm,现在我可以在别处安装或运行它,而不必把那套 JavaScript 工具链一起带上。
一个应用,终于
我最终想要的并不是什么特别稀奇的东西。
- 我想用 Python 写后端。
- 我想用 Vue 写前端。
- 我想在开发时使用 Vite。
并且我希望能快速地搭建并部署一个东西。
不是一个 Python 应用加一个 JavaScript 应用。不是两个由 nginx 和彼此猜疑拼接在一起的容器。
一个小于 1MB 的 Python 包,开箱即用™。
它包含了前端。FastAPI 从站点根目录提供前端服务,而不会吞掉应用的其余部分。当我需要 SPA 时,Vue 路由可以正常工作,而普通的文件路由让我的后端可以保留自己的 catch-all 路由,例如用服务端渲染的内容处理任何漂亮的 URL——就像本网站这样。而后者又可以在需要的地方使用 Vue,两个世界完美和谐。
JavaScript 工具链做到了一个工具链该做的事:
它构建好软件,然后便悄然退场。
接下来,你的全栈还需要一个数据库,Kanta 就能满足你。有了这套工具链,你的应用很快就能跑起来。