☰
FastAPI+原生JS前后端分离实战:电脑组装报价工具开发指南
2026/9/30 12:51:26 网站建设 项目流程

来,先说一个我这半年遇见好几次的场景:同事想装机,问我预算八千怎么配,明天又问那块显卡现在多少钱。我与其一遍遍翻电商和报价站,不如自己写一个小工具。正好手头一直想练练 FastAPI,于是就有了这个“FastAPI + 原生js 写的一个 前后端 电脑组装报价指南”。项目不大,就两个核心页面、三四个接口,但前后端分离的流程、异步渲染、报价计算和打印输出全走了一遍。这篇文章我会把需求怎么拆、接口怎么设计、原生 JS 怎么实现、部署的时候有哪些坑,一次性讲清楚。适合刚入门的后端同学,也适合前端想看看接口对接完整流程的朋友。

1. 项目从0到1:需求拆解与技术选型

1.1 装机报价的核心需求是什么

咱先把“做一个报价工具”这句话拆开看。很多人以为做这种工具就是把配件价格列个表,用户点一点,页面显示总价就完了。实际跑起来远不止这些。

我当时的真实需求有三层。第一层是“数据可得”:常见 CPU、主板、内存、显卡、SSD、电源、机箱这些配件,得有结构化数据,包含品名、规格、价格、兼容性备注,不能写死在前端模板里,不然更新一个价格要改一遍 HTML,维护成本太高。第二层是“能算、能校验”:用户勾选一堆配件后,后端要能算总价,并且能提示一些明显不合理的组合,比如 AMD 的 CPU 配 Intel 的主板,DDR5 内存插到只支持 DDR4 的主板上。第三层是“能交付”:前端要把最终报价单渲染成可阅读、可打印、可复制的形式,而不是停留在控制台里打印一堆 JSON。

这三层需求对应到技术上,正好就是后端接口、计算逻辑、前端交互和输出排版。项目虽小,但它把前后端分离项目该有的环节都覆盖了:后端提供 REST API,前端用异步请求拉数据渲染页面,两者通过约定好的 JSON 结构通信。这也是我选择把它写成博客记录的原因,它是个“麻雀虽小五脏俱全”的练手项目。

1.2 为什么是 FastAPI + 原生JS

先说后端为什么选 FastAPI 而不是 Flask 或者 Django。最直接的原因是 FastAPI 基于 Python 类型注解和 Pydantic,定义请求和响应模型相当省事。比如用户提交的是“配件ID 列表”,你只需要写一个class QuoteRequest(BaseModel): part_ids: List[int],FastAPI 会自动做类型校验,不符合要求直接返回 422,不用自己写一坨if not isinstance(...)的判断。另一个原因是我需要它的自动接口文档,启动服务后打开/docs就能看到所有接口的入参、出参和示例,前后端联调时特别方便。

前端选原生 JS,也是有意的。说实话,这种页面用 Vue 或 React 能写,但在交互复杂度不高的情况下,引入框架反而增加构建成本。原生 JS 配合 fetch、DOM 操作、事件监听,就能把配件列表渲染、勾选联动、报价单输出全搞定。而且没有打包步骤,前端就是一个静态文件夹,开发时交给浏览器插件起个本地服务器,部署时甚至可以和后端放到一起跑。对想增强“原生前端功夫”的人来说,这比一上来就套框架更能理解浏览器和 HTTP 交互的本质。

技术选型这东西没有绝对的好,关键是你知道自己图什么。我图的是:FastAPI 省后端样板代码,原生 JS 省前端构建流程,两者配合把精力集中在“报价逻辑怎么做才合理”这件事上。

2. FastAPI 后端:数据模型、接口设计与报价逻辑

2.1 配件数据模型与报价返回结构

后端第一步,是把配件数据结构定义清楚。我用 Pydantic 的BaseModel来定义Part,每个配件包含 id、分类、名称、规格、价格和备注。这里的 spec 字段很关键,报价页面上要展示“B650M MORTAR / AM5 / DDR5 / MATX”这类细节,用户一眼能看出这块主板支持什么平台。

from pydantic import BaseModel from typing import Optional class Part(BaseModel): id: int category: str name: str spec: str price: float notes: str = ""

这个模型是整个报价系统的基础。之后接口返回的配件列表,其实就是List[Part]。而报价单返回结构我另外定义一个QuoteResult,里面包含选中配件列表、总价、配件数量和兼容性提示列表。为什么单独定义返回模型而不是直接把列表和总价拼成字典?因为 FastAPI 的response_model可以自动过滤多余字段,还能生成清晰的接口文档,对前端对接非常友好。

class QuoteItem(BaseModel): category: str name: str spec: str price: float notes: str = "" class QuoteResult(BaseModel): items: list[QuoteItem] total: float count: int warnings: list[str] = []

实际项目里配件数据我先放在内存列表里,方便演示。正式做的话,这部分可以换成 SQLite 或 MySQL,但接口不需要改,加一层数据访问模块就行。这就是数据模型和业务逻辑分离的好处。

2.2 报价接口与计算逻辑

接口设计我定了两个。一个是GET /api/parts,返回全部配件,前端初始化页面时用它渲染列表。另一个是POST /api/quote,接收用户勾选的配件 ID 数组,后端查配件、算总价、做兼容性校验,返回报价单 JSON。用 POST 而不是 GET,是因为提交的是一组结构化 ID,POST 的 body 能装更多数据,语义也更贴合“创建一张报价单”的动作。

from fastapi import FastAPI, HTTPException app = FastAPI(title="PC Quote API") @app.get("/api/parts", response_model=list[Part]) def get_parts(): return PARTS_DB @app.post("/api/quote", response_model=QuoteResult) def create_quote(request: QuoteRequest): ids = request.part_ids selected = [p for p in PARTS_DB if p.id in ids] if len(selected) != len(set(ids)): raise HTTPException(status_code=400, detail="部分配件ID不存在") total = round(sum(p.price for p in selected), 2) warnings = check_compatibility(selected) return QuoteResult( items=selected, total=total, count=len(selected), warnings=warnings, )

这段代码里有几个细节值得展开。第一,我检查len(selected) != len(set(ids))是为了防止前端传了不存在的 ID,比如库里已经删掉的配件还被缓存页面上勾选着,后端必须兜住这种异常。第二,round(sum(...), 2)是为了避免浮点计算出现 0.1 + 0.2 那种精度问题。第三,兼容性校验独立成一个check_compatibility函数,方便后续加规则。

兼容性校验是这个项目的亮点,也是很容易被忽略的地方。最简单的规则是三组:CPU 插槽和主板插槽要匹配,内存代数和主板内存规格要匹配,电源功率不能低于整机参考功耗。我实现时先给配件加了一个“平台标识”字段,比如 CPU 的platform="AM5"、主板的platform="AM5",然后做交叉匹配,发现不一致就把提示文本加入 warnings,前端展示成黄色警告条。

def check_compatibility(parts: list[Part]) -> list[str]: warnings = [] cpu = next((p for p in parts if p.category == "CPU"), None) board = next((p for p in parts if p.category == "主板"), None) memory = next((p for p in parts if p.category == "内存"), None) if cpu and board and cpu.platform != board.platform: warnings.append(f"CPU平台{cpu.platform}与主板平台{board.platform}不匹配") if memory and board and "DDR4" in memory.spec and "DDR5" in board.spec: warnings.append("内存DDR4与主板DDR5不兼容") return warnings

这种规则看起来简单,却是整个工具最核心的价值。它把“装机老手凭经验扫一眼”的判断自动化了,报价指南才算真正能干点活儿。

2.3 CORS 与静态文件托管

前后端分离开发时,最绕不开的问题是跨域。我的前端跑在 5500 端口,后端跑在 8000 端口,浏览器会拦截 fetch 请求,这时就需要后端配置 CORS 中间件。

from fastapi.middleware.cors import CORSMiddleware app.add_middleware( CORSMiddleware, allow_origins=["http://127.0.0.1:5500", "http://localhost:5500"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], )

这里要注意allow_origins要写全协议、域名和端口,不能只写127.0.0.1:5500。有些同学折腾半天发现还是跨域,多半是漏了http://前缀,或者开发时用了局域网 IP 访问页面却没把那个 IP 加进列表。另一个建议是开发阶段先不用“允许所有来源”这种粗暴配置,把前端地址明确列出来,部署时再根据真实域名调整,既能减少踩坑,也更安全。

到了部署阶段,我直接把前端静态文件挂到 FastAPI 上,这样整个项目一个服务就起来了。FastAPI 自带StaticFiles,把frontend目录映射到/static路径,再把/根路径指向index.html。

from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse app.mount("/static", StaticFiles(directory="../frontend"), name="static") @app.get("/") def index(): return FileResponse("../frontend/index.html")

这样一个 FastAPI 服务同时承担 API 和静态页面,本地演示、部署到服务器都很省事。要是以后前端想拆出去独立部署,后端代码一行不用改,CORS 配置调整一下就行。

3. 原生JS 前端:从页面渲染到报价单打印

3.1 页面布局与配件列表渲染

前端的 HTML 结构我分成了三个区域:最上面是标题栏,说明这是“电脑组装报价指南”;中间是配件列表区,按 CPU、主板、内存、显卡、SSD、电源、机箱分组展示;右侧或下方是报价单区域,实时显示已选配件和总价。

页面骨架用原生 HTML 写清楚,然后 JS 在页面加载完成后调用后端接口,动态生成配件卡片。这里用原生 JS 的好处体现得很明显:你完全清楚每个节点是怎么创建、怎么挂载到 DOM 的。

async function loadParts() { const res = await fetch('http://127.0.0.1:8000/api/parts'); const parts = await res.json(); renderParts(parts); } function renderParts(parts) { const container = document.getElementById('parts-list'); container.innerHTML = ''; const grouped = parts.reduce((acc, part) => { (acc[part.category] = acc[part.category] || []).push(part); return acc; }, {}); Object.keys(grouped).forEach(category => { const section = document.createElement('div'); section.className = 'category-section'; const title = document.createElement('h3'); title.textContent = category; section.appendChild(title); grouped[category].forEach(part => { const card = document.createElement('label'); card.className = 'part-card'; card.innerHTML = ` <input type="checkbox">let selectedIds = new Set(); document.addEventListener('change', (e) => { if (e.target.matches('input[type="checkbox"]')) { const id = Number(e.target.dataset.id); if (e.target.checked) { selectedIds.add(id); } else { selectedIds.delete(id); } updatePreviewTotal(); } }); async function submitQuote() { if (selectedIds.size === 0) { alert('至少选择一个配件'); return; } const res = await fetch('http://127.0.0.1:8000/api/quote', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ part_ids: [...selectedIds] }), }); if (!res.ok) { const err = await res.json(); alert(err.detail || '生成报价单失败'); return; } const quote = await res.json(); renderQuote(quote); }

用事件委托在document上监听 change,是原生 JS 里比较常用的做法,这样以后动态添加配件卡片也不用重新绑定事件。选择Set来存 ID,天然去重,不用操心同一个配件被勾选两次。

3.3 报价单渲染与打印导出

后端返回的QuoteResult结构包含 items、total、count、warnings,前端拿到后要渲染成正式报价单。我的报价单区域是一个独立的div,里面生成表格,每一行是一个配件分类、名称、规格、单价,最后一行是总价。

function renderQuote(quote) { const box = document.getElementById('quote-result'); box.innerHTML = ''; const title = document.createElement('h2'); title.textContent = '配置报价单'; box.appendChild(title); if (quote.warnings.length > 0) { const warningBox = document.createElement('div'); warningBox.className = 'warning-box'; quote.warnings.forEach(w => { const p = document.createElement('p'); p.textContent = '警告:' + w; warningBox.appendChild(p); }); box.appendChild(warningBox); } const table = document.createElement('table'); const tbody = document.createElement('tbody'); quote.items.forEach(item => { const tr = document.createElement('tr'); tr.innerHTML = ` <td>${item.category}</td> <td>${item.name}</td> <td>${item.spec}</td> <td>¥${item.price}</td> `; tbody.appendChild(tr); }); const totalTr = document.createElement('tr'); totalTr.className = 'total-row'; totalTr.innerHTML = ` <td colspan="3">总价</td> <td>¥${quote.total}</td> `; tbody.appendChild(totalTr); table.appendChild(tbody); box.appendChild(table); }

打印功能的实现也很简单,浏览器自带的window.print()就可以。但得注意,默认打印整个页面会把配件列表也打出来,很丑。解决方法是加一个@media print样式,把无关区域隐藏,只保留报价单区域。

@media print { body * { visibility: hidden; } #quote-result, #quote-result * { visibility: visible; } #quote-result { position: absolute; left: 0; top: 0; width: 100%; } }

这样用户在页面上点“打印报价单”,打印预览里就只有干净整洁的表格,不会被左侧的配件卡片干扰。

4. 完整实操:从环境搭建到跑通全流程

4.1 环境准备与项目目录

想完整跑一遍这个项目,先准备 Python 3.10 以上的环境和任意现代浏览器。目录我建议这样组织,前后端分开但能一眼看明白。

pc-quote/ ├── backend/ │ ├── main.py │ └── requirements.txt └── frontend/ ├── index.html ├── style.css └── app.js

后端依赖很简单,只需要 FastAPI 和 Uvicorn。写进requirements.txt,然后创建虚拟环境并安装。

cd pc-quote/backend python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install fastapi uvicorn

4.2 后端代码逐步实现

按照前面章节的思路,把配件数据、兼容性校验、两个接口、CORS、静态文件托管全部写进main.py。这里我补上完整的数据初始化,让项目可以开箱即用。

PARTS_DB = [ Part(id=1, category="CPU", name="AMD Ryzen 5 7600", spec="6核12线程 / AM5", platform="AM5", price=1299), Part(id=2, category="CPU", name="Intel i5-13490F", spec="10核16线程 / LGA1700", platform="LGA1700", price=1399), Part(id=3, category="主板", name="微星 B650M MORTAR", spec="AM5 / DDR5 / MATX", platform="AM5", price=1099), Part(id=4, category="主板", name="技嘉 B760M 小雕", spec="LGA1700 / DDR5 / MATX", platform="LGA1700", price=949), Part(id=5, category="内存", name="金士顿 骇客神条 16G", spec="DDR5 5600", price=399), Part(id=6, category="显卡", name="RTX 4060 8G", spec="NVIDIA / 8GB GDDR6", price=2199), Part(id=7, category="SSD", name="三星 980 Pro 1TB", spec="NVMe PCIe 4.0", price=699), Part(id=8, category="电源", name="长城 750W金牌全模组", spec="750W / 80Plus金牌", price=549), Part(id=9, category="机箱", name="先马 朱雀", spec="ATX / 侧透", price=199), ]

platform字段是后加的,因为兼容性校验需要它。所以Part模型也要加platform: Optional[str] = None,这样其他没有平台属性需求的配件也能正常创建。

启动后端很直接:

uvicorn main:app --reload --port 8000

启动后访问http://127.0.0.1:8000/docs,就能看到 FastAPI 自动生成的接口文档,可以在页面上直接测试/api/quote接口,传{"part_ids": [1, 3, 5]}看返回结果。这一步能验证后端逻辑对不对,前端还没介入时就能把接口调通。

4.3 前端页面与脚本落地

前端需要三个文件。index.html负责骨架,style.css负责配色和布局,app.js负责逻辑。我先给出index.html的关键部分:

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>电脑组装报价指南</title> <link rel="stylesheet" href="style.css"> </head> <body> <header> <h1>电脑组装报价指南</h1> <p>勾选你想要的配件,一键生成配置报价单</p> </header> <main> <section id="parts-list"></section> <aside> <h2>当前报价单</h2> <div id="quote-summary">暂未生成</div> <div id="quote-result"></div> <button id="submit-quote">生成报价单</button> <button id="print-quote">打印报价单</button> </aside> </main> <script src="app.js"></script> </body> </html>

app.js里除了前面写到的loadParts、renderParts、submitQuote,还需要在网页加载完成后初始化,并给两个按钮绑定事件:

document.addEventListener('DOMContentLoaded', () => { loadParts(); document.getElementById('submit-quote').addEventListener('click', submitQuote); document.getElementById('print-quote').addEventListener('click', () => window.print()); });

开发阶段打开index.html时,注意不要直接用file://协议打开,否则 fetch 会报跨域或安全错误。我是用 VS Code 的 Live Server 插件起了一个本地静态服务器,地址是http://127.0.0.1:5500,正好和 CORS 配置里的地址对应。

到这里,整个流程就通了:浏览器打开前端页面,JS 调/api/parts拿配件数据并渲染,用户勾选配件后点“生成报价单”,前端把 ID 数组 POST 给/api/quote,后端返回报价结果,前端再渲染出正式报价单。整套闭环下来,前后端项目该有的数据流转、接口设计、异常处理全都体现到了。

5. 避坑实录:跨域、精度与浏览器兼容

5.1 跨域配置的常见坑

跨域是前后端分离开发里最容易卡壳的地方。我遇到过的坑主要有三类。第一类是allow_origins配了但不生效,原因多半是地址没写全。比如前端用的地址是http://localhost:5500,而后端只配了http://127.0.0.1:5500,浏览器可能因为域名不一致仍然拦截,两个都要配上才稳。第二类是前端请求带了自定义 Header 或者credentials,但后端没开对应选项,导致浏览器预检请求失败。第三类是用file://打开 HTML,这时候 Origin 是null,后端怎么配都可能出问题,最简单的解法是别用 file 协议访问,起个静态服务器。

排查跨域问题时,不要只会看浏览器控制台的报错,打开开发者工具的 Network 面板,找到那个红色的请求,看 Request Headers 里的 Origin 是什么,再看 Response Headers 里有没有Access-Control-Allow-Origin。对不上号就改配置,基本一次能定位。

5.2 浮点数精度与金额计算

扶摇直上的教训是:金额计算不要直接用浮点数相加。Python 里0.1 + 0.2的结果是0.30000000000000004,如果把配件价格累加起来直接返回给前端,总价可能会出现4999.999999999999这种尴尬数字。我在项目里用round(sum(...), 2)初步解决,但这只够用,如果以后加折扣、税率、各种优惠,建议直接用Decimal或者以分为单位做整数运算,最后再格式化输出。

前端展示报价的时候也要注意格式化。item.price可能是整数形式的1299,也可能带小数,显示给用户应该统一走toFixed(2)或者自己写格式化函数,不然会出现有的价格显示1299、有的显示399.00,报价单看起来不专业。

5.3 前端异步与浏览器兼容问题

原生 JS 写异步有一个很容易踩的坑:页面加载时同时发多个请求,如果某个接口慢,先返回的响应未必是第一个请求的结果。我项目里分两个请求场景,一个是初始化时请求配件列表,一个是生成报价单时请求报价,属于串行关系,不冲突。但如果你以后做成“同时加载配件和价格配置”,就要用Promise.all保证所有请求都完成后统一渲染,避免出现一次渲染一半数据的半成品页面。

另一个兼容性问题是剪贴板复制。navigator.clipboard.writeText在非 HTTPS 环境下不一定可用,而且需要页面获得焦点。我的方案是写一个兜底函数,优先用 Clipboard API,失败就退回document.execCommand('copy')这种老办法。说实话,现在大多数浏览器对 Clipboard API 支持都很好,但兼容代码写上去不吃亏,尤其是报价单工具经常被用户在局域网环境里访问,那些环境可能是老浏览器。

async function copyQuoteText() { const quoteText = document.getElementById('quote-result').innerText; try { await navigator.clipboard.writeText(quoteText); } catch (e) { const textarea = document.createElement('textarea'); textarea.value = quoteText; document.body.appendChild(textarea); textarea.select(); document.execCommand('copy'); document.body.removeChild(textarea); } }

5.4 优化建议与后续扩展

这个项目跑通后,可以扩展的方向其实不少。最直接的是把配件数据换到数据库,这样能支持管理员后台维护价格,而不是改代码里的列表。其次是给报价单加一个“保存/分享”功能,后端把报价单 JSON 存起来生成一个短链接,用户下次打开就能看到同一份配置。还可以增加更多兼容性规则,比如电源功率是否满足显卡供电需求、机箱是否支持主板版型、散热器高度是否超过机箱限高,这些都是装机场景里很实际的判断。

我个人在使用这个工具的过程中,最大的体会是:小项目的技术难点往往不在“用什么框架”,而在“边界条件有没有处理干净”。比如 ID 不存在怎么办、总价精度怎么保、跨域怎么配、打印样式怎么控,这些才是实际开发里真正耗时的地方。把这些问题一个一个处理掉,比换一个更“高级”的框架更有价值。当然,如果你本来就想练练现代前端框架和 FastAPI 的配合,那也可以把原生 JS 那层换掉,后端接口完全不用动,这恰好就是前后端分离带来的自由度。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询