FastAPI + 原生 JS 做一套电脑组装报价指南
作为一个平时主要写后端接口的开发者,我很少碰前端,但前段时间朋友接了个活,帮一家电脑店做"选配件自动出报价"的小工具。店里的人不懂技术,就想打开网页,勾选CPU、主板、显卡,右边就实时显示总价。
接到这个需求,我第一反应是:别上Vue、React那一套重型框架,也别引入Webpack、Vite这些构建工具。直接一点,用FastAPI做后端接口,前端用原生JavaScript写,一个仓库两个目录,把这套前后端分离的小项目跑通。
这篇文章我把整个项目的设计思路、关键实现、踩坑过程完整写下来。如果你也想做一个前后端分离的小工具,或者正在学FastAPI和原生JS怎么配合,这篇应该能给你一个能直接抄作业的参考。
项目本身不复杂,核心就两块:
- 后端维护一份配件库(CPU、主板、内存、显卡、固态、电源、机箱),通过接口暴露数据,根据用户提交的配置生成报价单。
- 前端是几个下拉框加一个汇总区域,用户选完配件,页面实时计算总价,最后可以生成一张简单的报价单。
别看功能简单,这里面涵盖了前后端分离项目最常见的问题:跨域、异步请求顺序、状态管理、数据校验、部署路径。把这些东西全部跑通之后,你再去套Vue、React,会发现心里特别有底。
1. 为什么用 FastAPI 加原生 JS 做组装报价
1.1 选题背后的真实需求
电脑组装报价这个场景,我在网上调研了一圈,发现大多数现成方案要么是纯静态页面写死配件价格,要么是Excel表格手工报价。纯静态页面改一次价格就得改HTML,Excel表格没法让顾客自助查询。
朋友要的东西,本质上是一个"数据与展示分离"的小系统:
- 配件数据存在后端,随时可以增删改。
- 前端只是读取数据做展示和计算。
- 顾客在浏览器里操作,电脑店不需要安装任何软件。
这就天然是一个前后端分离的架构模型。前后端分离这个词听着玄乎,落到这个项目里就是两个独立的部分:FastAPI跑在8000端口提供JSON数据,前端页面用fetch去拿数据。中间通过HTTP协议通信,两边约定好JSON格式就行,不需要任何复杂的框架来支撑。
1.2 技术选型对比
选型的时候,我列了一张对比清单,把几套常见方案都过了一遍:
- Spring Boot + Vue:功能全,上手成本高,适合中大型团队,一个人快速交付的场景用它有点杀鸡用牛刀。
- 若依框架:自带权限、菜单、代码生成,功能确实全,但引入它之后你要处理的东西比业务本身还多,不划算。
- Flask + 原生JS:Flask能用,但异步支持和数据校验手感不如FastAPI顺手。
- Gradio:适合做AI演示工具,不适合做这种带多交互表格的报价页面。
- FastAPI + 原生JS:最终选择,原因在后面。
这里顺带提一下,很多人看到"前后端分离"第一个想到的就是Spring Boot配Vue,或者若依这类脚手架。但我这个场景有一个核心特点:业务逻辑极其简单,数据量几十条,用户量就是一个门店的店员加顾客,并发基本可以忽略。这种项目上一个全家桶,部署麻烦,学习成本高,后续维护反而更痛苦。
1.3 为什么 FastAPI 在这个场景里合适
FastAPI有几个很实际的优点,让我确定就是它了:
- 自带数据校验和自动生成OpenAPI文档。前端联调的时候,直接访问
/docs页面就能看到每个接口的入参和出参格式,省掉大量"接口到底要传什么字段"的扯皮。 - 异步支持好,接口响应快。我在本地实测下来,接口延迟都在10ms以内,用户操作时基本无感知。
- 用Python的类型注解写Pydantic模型,既能当请求参数校验模型,又能当数据模型,一套代码吃透,减少重复定义。
1.4 整体架构设计
整个项目的目录结构我设计得比较朴素,就一个仓库两个目录:
pc-price-guide/ ├── backend/ │ ├── main.py │ ├── data.py │ ├── models.py │ └── requirements.txt └── frontend/ ├── index.html ├── style.css └── app.js数据流是这样的:
- 用户在页面上选择CPU、主板、显卡等配件。
- 前端把所有选中配件的ID收集起来,通过fetch发送到后端的报价接口。
- 后端根据ID查配件库,累加价格,返回报价明细和总价。
- 前端把结果渲染到页面。
这里有个关键决定:总价计算必须放在后端,不能让前端自己算。
原因是价格、库存、兼容性这些逻辑应该由后端统一控制。如果前端也存一份价格数据,改价的时候就得改两个地方,今天店长调了CPU价格,明天发现电脑上显示的还是旧价格,迟早出问题。后端的配件库是唯一数据源,前端只负责展示用户的选择。
2. 后端设计:FastAPI 怎么管好配件和报价
2.1 数据模型设计
配件数据是核心。设计模型时我犯过一个错误,刚开始把所有配件混在一个列表里,用type字段区分。后来发现行不通,因为不同类型的配件属性差异太大——CPU要标核心数和主频,显卡要标显存,电源要标功率,机箱要标类型。
虚拟属性堆在一个模型里,要么空着,要么写一堆可选字段,代码乱得没法看。
后来我用Pydantic给每个配件类型建了独立模型:
from pydantic import BaseModel class CPU(BaseModel): id: int name: str price: int cores: int class Motherboard(BaseModel): id: int name: str price: int cpu_socket: str class GraphicsCard(BaseModel): id: int name: str price: int vram: int价格字段我特意用整数而不是浮点数。报价场景里价格都取整,而且浮点数做累加会有精度问题。用整数,算总价时绝对不会出现那种差几分钱的对不上的尴尬。
2.2 接口路径怎么设计
接口规划遵循一个简单原则:资源用名词,动作用HTTP方法。我设计了三个核心接口:
GET /api/parts/{part_type}:获取某种类型的配件列表,比如/api/parts/cpu返回所有CPU。GET /api/parts/{part_type}/{id}:获取单个配件详情,用于后续扩展。POST /api/quote:提交用户选择的配件ID列表,返回报价明细和总价。
这里有一个取舍问题:要不要做一个汇总接口/api/parts一次把所有类型全部返回?
一开始我觉得这样省事,前端一次请求全搞定。真做之后发现不是那么回事。前端要在一堆数据里反复filter,代码难读,而且某个类型数据量大时响应也会变慢。拆成按类型区分的资源接口,前端代码反而更清晰。
2.3 报价计算逻辑
报价接口的核心逻辑,是后端接收一套ID列表,逐一去配件库查找并累加价格:
@app.post("/api/quote") async def create_quote(items: QuoteRequest): total = 0 details = [] for item in items.parts: part = find_part(item.part_type, item.part_id) if part is None: raise HTTPException(status_code=404, detail=f"{item.part_type} id={item.part_id} 不存在") total += part.price details.append({ "type": item.part_type, "id": part.id, "name": part.name, "price": part.price }) return { "total": total, "details": details }这段代码看着简单,里面有两个细节值得展开:
查找不到配件时,必须返回明确错误,不能跳过。否则用户选了个已经下架或者不再兼容的配件,页面不知道,最后总价少一截,报价单就是废的。这个校验逻辑是报价系统的底线。
入参用QuoteRequest模型校验:
class PartItem(BaseModel): part_type: str part_id: int class QuoteRequest(BaseModel): parts: list[PartItem]前端传错字段名,后端直接返回422状态码和详细错误信息。联调的时候一眼就能看出问题。
2.4 CORS 配置
前后端分离项目里,跨域是最容易让人头大的问题。我这个项目开发阶段的架构是:前端页面运行在5500端口(通过python http.server),后端接口跑在8000端口。两个端口不一样,浏览器就会做跨域限制。
处理方式很直接,在FastAPI里挂上CORSMiddleware:
from fastapi.middleware.cors import CORSMiddleware app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=False, allow_methods=["*"], allow_headers=["*"], )注意,allow_origins用"*"在开发阶段没问题,部署到生产环境就不能这么干了,必须指定具体域名。这个我在后面部署时栽了跟头,后文会细说。
3. 前端实现:原生 JS 维持前后端分离的边界
3.1 页面结构设计
前端不借助任何框架,只用一个HTML文件、一个CSS文件、一个JS文件。页面结构上我分成三个区域:
- 左侧是配件选择区,每种配件类型对应一个下拉框。
- 右侧顶部是已选配件清单区域,实时列出当前选择的所有配件。
- 右侧底部是总价和"生成报价单"按钮。
这个布局把"选择"和"结果"分开,贴合店里接待顾客的实际场景:顾客在左边挑,店员和顾客一起盯着右边看总价。操作路径短,顾客自己都能上手。
关于要不要用Vue,我也纠结过。Vue的响应式绑定在这个场景里确实好用,但仔细想想,这个页面没复杂到需要框架:
- 配件类型大概7种,每种是一个下拉框。
- 用户选完之后需要实时显示已选清单和总价。
- 中间穿插联动规则,比如选了AMD的CPU,主板平台自动切到AM5。
用原生JS完全能搞定,而且不用引入框架就不用处理构建工具链,整个项目可以在任何一台机器上直接打开调试。
3.2 数据加载与渲染
页面加载时,通过fetch从后端拉配件数据:
async function loadParts() { const response = await fetch('http://localhost:8000/api/parts/cpu'); const data = await response.json(); const select = document.getElementById('cpu-select'); data.forEach(part => { const option = document.createElement('option'); option.value = part.id; option.textContent = `${part.name}(¥${part.price})`; select.appendChild(option); }); }这里有个小坑:渲染文本时用textContent而不是innerHTML。原因很简单,如果将来开放给店员维护配件数据,难免有人输入一些奇奇怪怪的字符,用textContent可以避免把用户输入当成HTML解析,防止XSS注入。多写几行代码,胜在安全。
3.3 状态管理
原生JS没有响应式系统,如果不做统一管理,代码会越来越乱。我的做法是维护一个全局状态对象:
let state = { cpuId: null, motherboardId: null, gpuId: null, memoryId: null, storageId: null, powerId: null, caseId: null };每个下拉框的change事件只更新state里对应的字段,然后调用一个统一的updateQuote()函数重新计算和渲染。所有交互逻辑都收敛到一个函数里,不会出现"改了这里的值还要去另一个地方手动同步"的尴尬。
这个做法的本质是"单向数据流":界面操作更新状态,状态变化触发重新渲染。和Vue的v-model相比少了很多魔法层面上的自动追踪,但逻辑完全可控,哪里出问题直接定位到对应函数就行。
3.4 价格联动与汇总
用户每选择一个配件,前端把state里所有的ID收集起来,调用报价接口:
async function updateQuote() { const parts = []; for (const [type, id] of Object.entries(state)) { if (id) { parts.push({ part_type: type, part_id: id }); } } const response = await fetch('http://localhost:8000/api/quote', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ parts }) }); const result = await response.json(); renderQuote(result.details, result.total); }这段代码里藏着一个绕不开的问题:每次下拉框变化都会触发fetch,用户连续改动时,前端会同时发出好几个请求。如果不做控制,后发的请求先返回,先发的请求后返回,最终显示的价格就是错的,用户会觉得这个页面有bug。
解决这个异步竞态问题,我用了一个计数器加锁的方法:
let quoteRequestId = 0; async function updateQuote() { const currentRequestId = ++quoteRequestId; const response = await fetch(...); const result = await response.json(); if (currentRequestId !== quoteRequestId) return; renderQuote(result.details, result.total); }每次发起请求前,先把当前请求号加一,请求回来时检查这个请求号是不是最新的,如果不是就直接丢弃。这个方法虽然简单,但实测下来非常稳。连续快速切换十几个配件,显示的总价始终是最终结果。
3.5 兼容性联动
报价工具里有个比较有意思的需求:CPU和主板必须兼容。比如选Intel第14代CPU,要配LGA1700接口的主板;选AMD Ryzen 7000系列,要配AM5接口的主板。
这个逻辑放在后端还是前端,我认真考虑过。最终决定在前端做过滤:后端返回主板数据时带上cpu_socket字段,前端根据已选CPU的socket类型过滤主板下拉框的选项。
理由是交互友好。用户一旦选定CPU,主板下拉框立刻收窄到兼容的型号,不需要请求后端再计算一次,也没有等待感。
但要注意,"不能选"只是软校验。真正的硬校验必须放在后端报价接口里:如果前端传了不匹配的CPU和主板组合,后端要拒绝这次报价。这是前后端分离项目里很重要的观念:前端的过滤只是锦上添花,后端的校验才是最后一道防线。前端可以被绕过,后端不能被绕过。
4. 实操过程与三个典型的坑
4.1 搭建环境的完整步骤
先交代一下我的本地环境:Windows 11,Python 3.11。前端就是两个静态文件,不需要任何构建工具。
虚拟环境操作步骤:
cd backend python -m venv venv venv\Scripts\activate pip install fastapi uvicornrequirements.txt内容:
fastapi uvicorn[standard]启动后端:
uvicorn main:app --reload --host 0.0.0.0 --port 8000--reload是开发时改代码自动重启,--host 0.0.0.0是为了让局域网里其他设备也能访问接口,方便手机测试。
前端启动:
cd frontend python -m http.server 5500然后在浏览器访问http://localhost:5500。
这就是标准的前后端分离开发模式:两个进程,两个端口,通过HTTP通信。
4.2 坑一:CORS 导致的前端请求全部失败
先说坑一,也是最常见的坑:CORS。
最开始前端不通过HTTP服务启动,我直接在浏览器里双击打开index.html。页面能正常打开,但下拉框里一片空白,控制台报错:
Access to fetch at 'http://localhost:8000/api/parts/cpu' from origin 'null' has been blocked by CORS policy大家注意这个origin 'null'。当你以file://协议直接打开本地HTML文件时,浏览器的Origin是字符串"null",不是http://localhost:5500。我当时已经在FastAPI里配置了CORS,allow_origins写的是["http://localhost:5500"],所以"null"这个来源被拦截了。
解决办法有两种:
- 在allow_origins里加上"null"这个字符串。
- 更规范的做法:让前端也走HTTP服务,设置与后端一致的来源。
我选择了后者。这不仅仅是CORS能解决,还避免了file协议下浏览器的各种安全限制。之后在浏览器访问http://localhost:5500,后端CORS配置里也加上了这个端口,问题迎刃而解。
4.3 坑二:接口入参格式不匹配
第二个坑是前后端数据结构约定出了问题。
前端最初提交的数据结构是这样的:
{ "cpu": 101, "motherboard": 202 }后端模型定义的是:
class QuoteRequest(BaseModel): parts: list[PartItem]前端传过去后,后端直接返回422错误:
422 Unprocessable Entity这个报错对新手来说有点抽象,翻译过来就是"你传来的请求体和我约定的模型对不上"。字段名不匹配、字段类型错误、缺字段,都会触发这个错误。
排查过程其实很简单,打开FastAPI自动生成的/docs页面,直接查看QuoteRequest模型的示例结构,对照一看就发现前端少了parts这一层封装。
后来我统一了数据结构,前端把所有选中配件生成一个parts数组,每个元素包含part_type和part_id。两边对着/docs页面确认无误后再开始联调。这一步省下来的时间远比当时扯皮的要多。
4.4 坑三:并发请求显示错乱
第三个坑是被联动触发出来的。
在实现CPU和主板兼容过滤后,每次切换CPU,前端会同时触发出两个请求:
- 获取主板列表(因为CPU变了,主板下拉框需要重新过滤)。
- 获取报价(因为CPU变了,总价可能变化)。
两个请求并发发出,响应顺序并不固定。如果先发出的请求后返回,页面就会被旧数据刷新,总价显示乱跳。
处理思路我在前面已经提了,用requestId计数器。这个思路本质上是一个轻量级的取消机制:不真正取消前一个请求,而是忽略它的响应。原生JS的AbortController理论上能真正断开请求,但在我这个场景里,忽略响应更快更简单,代码只多三行。
实际测试效果很好,连续快速切换CPU、GPU、内存十几下,报价单内容和总价始终是最终状态,没出现过一次错乱。
4.5 联调阶段的经验
前后端分离项目里,联调是最容易出问题的地方。我的经验是:接口文档先行。
FastAPI自动生成/docs页面,在/docs里能看到每个接口的入参模型、返回示例。每次前端改了请求格式,后端改了返回结构,都先去看一眼文档,两边按照文档对齐,不要凭记忆开发。
我这次项目深有体会:所谓"前后端分离"不只是部署分离,更重要的是契约分离。JSON结构就是这个契约。契约清晰,双方各做各的,效率翻倍。
5. 部署、优化和后续扩展
5.1 部署方式
本地调试跑通之后,朋友要求把这个工具部署到店里的电脑上,让店员通过浏览器访问,不用每次都打开开发环境。
部署方案我选了最省事的:在FastAPI里挂载静态文件,让前端也由后端统一服务。这样就没有跨域问题了,因为前端和后端同源。
from fastapi.staticfiles import StaticFiles app.mount("/", StaticFiles(directory="../frontend", html=True), name="frontend")前端里的API地址也要跟着改,不能用写死的http://localhost:8000,改成相对路径:
const API_BASE = ''; // 空字符串,请求走同源这样请求/api/parts/cpu和/api/quote都会打到同一个域名和端口上。
启动命令还是那个:
uvicorn main:app --host 0.0.0.0 --port 8000店员访问http://<服务器IP>:8000就能直接用。路由器在同一个局域网下,手机也能访问。
5.2 一个值得注意的部署陷阱
部署时我踩了一个隐蔽的坑:把allow_origins改成了具体域名后,前端页面能打开但接口还是报跨域错误。
排查了很久发现,问题出在静态文件服务和API在同一个源上,理论上不该有CORS,但浏览器在 http://192.168.1.100:8000 访问时依然报错。后来仔细看请求,原来是前端代码里还有一处写死了http://localhost:8000/api/quote的绝对地址。
所以代码里不要混用绝对地址和相对地址。统一用相对路径后,问题消失了。这也提醒我,部署前要做一次"地址替换审计",全局搜索 localhost 和 127.0.0.1,全部改成相对路径或者环境变量配置。
5.3 项目目录结构心得
经历过这些折腾后,我对小型前后端分离项目的目录结构有了更清晰的认识:
pc-price-guide/ ├── backend/ │ ├── main.py # 入口,路由和CORS配置 │ ├── models.py # Pydantic模型 │ ├── data.py # 配件数据库(可替换成SQLite/MySQL) │ └── requirements.txt ├── frontend/ │ ├── index.html │ ├── style.css │ └── app.js └── README.md有人可能会问,要不要用SQLite存数据?配件几十条数据,每次启动从JSON文件读入内存其实就够用了。真要换SQLite或者MySQL,只需要改动data.py里的查找函数,接口层的逻辑一行都不用动。这就是把数据访问层独立出来的好处。
5.4 后续还能加什么功能
这个报价指南第一期做完后,我又想到了几个可以扩展的方向:
- 添加价格区间约束:用户设个预算,超出时前端给出提示,后端在报价时返回"超预算"标记。
- 导出PDF报价单:前端拿到报价JSON后,通过打印模板生成一张带样式和店名的报价单。
- 配件图片接入:给每个配件加图片URL字段,前端渲染时展示缩略图,能明显提升门店洽谈效果。
- 后台管理页面:做一个简单的密码校验页面,认证通过后可以增删改配件数据。用FastAPI的OAuth2密码流程就能实现。
- 多套配置方案对比:用户同时维护两三个配置方案,并排对比性能和价格。
这些扩展点都在当前架构上可以平滑地加进去,后端加接口,前端加区块,不需要推翻重来。
如果之后业务量变大,想上Vue,前端部分可以保留当前的核心逻辑,只是把状态管理替换成Vue的响应式写法。后端完全不用动。这就是前后端分离换来的灵活性。
就我个人这次实践而言,最大的收获是:用最快的速度把一个前后端分离的小项目完整跑通。FastAPI处理接口和数据校验,原生JS处理交互和渲染,中间用一份清晰的JSON约定完成沟通。没有框架和构建工具包袱,反而把"前端的请求是怎么到后端的""接口返回的数据到底长什么样""跨域到底怎么回事"这些问题想得特别明白。
如果你也是第一次自己动手做前后端分离的小项目,我建议先学会用这套"笨办法"跑通,再上手Vue、React也不迟。地基打扎实了,上面盖什么楼都稳。另外有个小技巧:本地开发时给前端页面也起一个HTTP服务,不要直接双击打开HTML文件,虽然听起来麻烦一点,但能少踩90%的跨域坑。