从零到一构建Web应用:前端工程化与REST API联调实战

发布时间:2026/10/20 3:24:57
从零到一构建Web应用:前端工程化与REST API联调实战
二这个编号其实是整个系列里最难写的一篇。一通常熬过了需求梳理和技术选型读者们热情还在但你到了第二篇面对的是一大堆还没落地的代码骨架、各种边角约定以及最难搞的“前后端第一次握手”。这篇文章我就以一套任务管理型 Web 应用为例把从零到一构建完整应用的经典路线第二站讲透前端工程化骨架怎么搭、后端 REST 服务怎么分层、数据库设计怎么“钉”住、前后端第一次联调会遇到哪些真问题。适合已经完成了基本选型、正在把想法变成真实可运行系统的开发者。1. 先对齐起点这篇二假设你已经完成了什么1.1 为什么续篇必须从“假设”开始任何连载型的技术文章都有一个尴尬点读者不是同一批人。有人把一从头到尾跟着做了一遍有人直接从这篇开始读还有人是项目做了一半才来找补知识点。所以“二”一定要先明确自己的起点否则后文每一步代码和目录结构都会被各种“我之前没建这个文件夹”的弹幕淹没。我这篇文章做了一个明确假设一已经完成了需求拆解和技术选型并且初始化好了仓库。也就是说你已经知道这个应用的核心用户是谁、核心流程是什么也定好了技术栈——我这里以 React Vite TypeScript 前端、Express TypeScript 后端、PostgreSQL 数据库为基线整体目录采用最经典的 monorepo 式布局服务端和客户端并列在同一个仓库里。如果你是从这篇开始读的也没关系只需要先补齐我理解的“一终点”一台装了 Node.js 的电脑一个空的 Git 仓库以及一颗“马上要开始写正经代码”的决心。1.2 本篇的技术栈与整体目录规划为什么第一篇定了选型第二篇还要再列一遍技术栈因为选型和目录结构是两件事。选型回答“用什么”目录结构回答“代码放哪、约定是什么”。很多从零开始的项目死在后期重构不是因为代码写得差而是因为最开始没人说清楚“接口层放哪、路由放哪、数据库访问代码放哪”导致每个人按自己的习惯乱放三个月后无人愿意改动。下面是我在这条路线里一直推荐的基础目录它足够简单又给后期留了升级空间my-web-app/ ├── client/ # 前端 │ ├── src/ │ │ ├── api/ # 请求层封装 │ │ ├── components/ # 通用组件 │ │ ├── pages/ # 页面级组件 │ │ ├── store/ # 全局状态 │ │ └── main.tsx │ ├── .env.development │ └── vite.config.ts ├── server/ # 后端 │ ├── src/ │ │ ├── controllers/ # 控制器接请求、返响应 │ │ ├── services/ # 服务层业务逻辑 │ │ ├── repositories/ # 数据访问层SQL 操作 │ │ ├── middlewares/ # 中间件 │ │ ├── routes/ # 路由定义 │ │ ├── db.ts # 数据库连接 │ │ └── app.ts │ └── .env └── package.json # 根脚本方便一键启动这个结构在“从零到一”的路线里角色分工非常明确前端只负责发请求和渲染后端只负责接收请求和处理数据数据库只负责存数据。先约定好目录再动手写第一行代码比我见过的任何“架构讨论”都有效。2. 前端工程化骨架从“能跑”到“好改”2.1 环境变量区分开发、测试、生产的第一步很多人把环境变量当成部署阶段才考虑的事实际上开发第一天就应该接好。我在多个项目里见过同一个坑前端代码里直接硬编码了http://localhost:3000等联调的时候后端换了端口所有人都在代码里全局替换地址替换完了发现还有漏网之鱼接口在测环境请求到了本地服务。前端基于 Vite 时环境变量的约定很直接项目根目录创建.env.development和.env.production变量名必须以VITE_开头代码里通过import.meta.env读取# client/.env.development VITE_API_BASE_URL/api// client/src/api/request.ts const baseURL import.meta.env.VITE_API_BASE_URL || /api这里把开发环境的值设成/api而不是完整的http://localhost:3000是一个刻意设计。开发环境下我们会让 Vite 开发服务器把/api开头的请求代理到后端后面联调章节会展开这样前端代码里永远是相对路径换环境时不用改任何业务代码只改.env文件。务必把.env加进.gitignore。仓库里永远只提交.env.example这份模板文件让新加入的人复制一份再填自己的配置。否则哪个不经意的提交把本地的数据库密码带上去了比代码写得烂更让人头大。2.2 请求层封装给 axios 包一层后面所有接口都走它第二篇做的所有封装里我最坚持的就是请求层。原因很简单如果不做这一层你的每个页面都会出现“发请求、拼 token、处理错误”七零八落的代码等想统一加一个错误提示或者处理 token 过期时改到崩溃。以 axios 为例新建client/src/api/request.tsimport axios from axios const request axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || /api, timeout: 10000, }) request.interceptors.request.use((config) { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( (response) response.data, (error) { const message error.response?.data?.message ?? 网络异常请稍后重试 // 这里接入统一错误提示 return Promise.reject(error) } ) export default request这段代码看起来简单价值全在拦截器请求发出前自动带 token响应回来后在进入业务代码之前先做一层错误处理。你的页面里再也不用关心“token 怎么传”“错误怎么提示”只需要关心业务数据本身。还有一个细节第二个拦截器直接返回了response.data让业务侧拿到的就是后端返回的数据对象而不是 axios 包裹后的那层结构。类型定义上后续需要用泛型补全但在工程化骨架阶段先把“统一入口”立住比纠结类型推导更紧迫。2.3 路由与页面骨架先把空页面铺出来我观察到一个现象很多团队项目在自己的服务端 API 还没就绪时前端开发就卡死了。其实最有效的解法是先把路由和空页面铺出来让每个人都有一个“可以跳转、可以占位、可以写局部逻辑”的地盘。React Router v6 的createBrowserRouter是现在比较清爽的写法// client/src/router.tsx import { createBrowserRouter } from react-router-dom import App from ./App import LoginPage from ./pages/LoginPage import DashboardPage from ./pages/DashboardPage import NotFoundPage from ./pages/NotFoundPage export const router createBrowserRouter([ { path: /, element: App /, children: [ { path: /login, element: LoginPage / }, { path: /dashboard, element: DashboardPage / }, ], }, { path: *, element: NotFoundPage / }, ])注意根路径/的 element 是App它作为布局容器里面用Outlet /渲染子路由。这个阶段每一页都可以只是“标题 空容器”但路由结构必须定下来。因为路由一改导航栏、权限守卫、页面文件的位置全都跟着变越早定改动成本越低。设置*通配路由到 404 页也不是形式主义。我见过几次真实项目直接把未匹配路由的页面白屏掉用户一脸问号开发者也排查半天才发现是路由漏配。早把这个兜底放上去后面加路由时心里会踏实很多。2.4 状态管理先不急初期一个全局 store 就够这里要给很多从“不知道状态管理选什么”开始焦虑的同学一个明确结论在从零到一的早期骨架阶段一个轻量全局 store 完全够用没必要立刻上重型状态库。我用过一个特别顺手的方案用一个很小的 store 库只存“当前用户”和“登录状态”这类全局信息其余页面级的数据都让组件自己管理。下面是一个简化示例// client/src/store/auth.ts import { create } from zustand interface AuthState { user: { id: string; email: string } | null setUser: (user: AuthState[user]) void logout: () void } export const useAuthStore createAuthState((set) ({ user: null, setUser: (user) set({ user }), logout: () set({ user: null }), }))为什么不在第一天就铺开正规的 reducer、副作用方案因为服务端接口还没稳定你连“哪些状态是全局的”都还没看清。过早引入复杂状态管理只会给你的“骨架阶段”平添一半重写成本。经验是先让全局 store 只装登录态和主题之类的东西等真出现跨页面共享复杂数据、并且发现 props 传递已经痛苦到难以忍受时再引入进阶方案。这个时机判断比技术选型本身更考验功力。3. 后端服务骨架REST API 长什么样分层怎么分3.1 分层结构controller - service - repository后端最容易出现的问题是“所有逻辑都在路由回调函数里”。一开始一段接口还好等十几个接口撑在一起路由文件就变成了一个几千行的灾难现场。我推荐的分层简单说就是“服务员、后厨、冷库”三段式Controller服务员负责接请求、解析参数、调用下层、返回响应。它不该知道 SQL也不该知道业务规则。Service后厨负责所有业务规则比如密码校验、查重、权限判断。它也不该知道请求到底是 HTTP 还是命令行。Repository冷库负责跟数据库打交道执行 SQL返回数据。它不暴露任何业务决策。经典的目录结构在 1.2 里已经给过了关键是代码里的依赖方向路由调用 controllercontroller 调用 serviceservice 调用 repositoryrepository 最后访问数据库。方向是单向的不许反过来更不许 controller 跳过 service 直接拼 SQL。为什么要限定得这么死因为只有方向单向哪天你把 Express 换成别的框架或者换个调用方式你只需要改 controller 这一层哪天你想从 PostgreSQL 换成别的数据库你只需要改 repository 这一层。这就是“从零到一”阶段花半小时拆层后面省下几天重构时间的原因。3.2 第一个接口创建过程注册接口从路由到数据库光讲分层太抽象我直接带大家把一个“用户注册”接口从路由写到底层数据访问你会立刻理解每一层应该放什么。先从路由说起。新建server/src/routes/auth.tsimport { Router } from express import { authController } from ../controllers/auth.controller const router Router() router.post(/register, authController.register) export default router路由文件只做“路径 - 控制器方法”的映射不做任何业务。接着是 controller 层import { Request, Response, NextFunction } from express import { authService } from ../services/auth.service export const authController { async register(req: Request, res: Response, next: NextFunction) { try { const user await authService.register(req.body) res.status(201).json({ code: 0, message: ok, data: user }) } catch (err) { next(err) } }, }这里有个重要动作next(err)。controller 捕获异常后把错误抛给 Express 的错误处理中间件而不是自己 try-catch 之后直接返回 500。这样所有接口的错误格式才能统一。service 层的职责是校验参数、处理业务逻辑import { AppError } from ../utils/app-error import { userRepository } from ../repositories/user.repository import bcrypt from bcryptjs export const authService { async register(body: { email: string; password: string; name?: string }) { if (!body.email || !body.password) { throw new AppError(邮箱和密码不能为空, 400, INVALID_INPUT) } const exists await userRepository.findByEmail(body.email) if (exists) { throw new AppError(该邮箱已被注册, 400, EMAIL_EXISTS) } const hashed await bcrypt.hash(body.password, 10) return userRepository.create({ email: body.email, passwordHash: hashed, name: body.name, }) }, }业务规则不能为空、不能重复就明确落在这一层。最后是 repository 层它负责真实的 SQL 操作import { pool } from ../db export const userRepository { async findByEmail(email: string) { const { rows } await pool.query( SELECT id, email, password_hash FROM users WHERE email $1, [email] ) return rows[0] }, async create(data: { email: string; passwordHash: string; name?: string }) { const { rows } await pool.query( INSERT INTO users (email, password_hash, name) VALUES ($1, $2, $3) RETURNING id, email, name, created_at, [data.email, data.passwordHash, data.name] ) return rows[0] }, }看到这里你应该明白了没有哪个层特别高大上但每层的职责都非常清晰。新人接手时只看一个接口就能理解全链路这对团队协作的意义远大于代码本身。3.3 全局错误处理express 的 4 参数中间件为何重要Express 新手最常犯的错误是把错误处理和业务逻辑混在路由里比如每个接口里都自己res.status(500).json(...)。这样一旦错误格式不统一前端拦截器就无处发力。正确做法是写一个全局错误处理中间件放在所有路由之后用 4 个参数的形式定义import { Request, Response, NextFunction } from express export const errorHandler ( err: any, req: Request, res: Response, _next: NextFunction ) { const statusCode err.statusCode ?? 500 const errorCode err.errorCode ?? INTERNAL_ERROR const message err.message ?? 服务器内部错误 if (statusCode 500) { console.error(req.method, req.url, err) } res.status(statusCode).json({ code: errorCode, message, data: null, }) }配合一个自定义业务错误类AppError业务代码里想抛错误只需要throw new AppError(...)export class AppError extends Error { statusCode: number errorCode: string constructor(message: string, statusCode 500, errorCode INTERNAL_ERROR) { super(message) this.statusCode statusCode this.errorCode errorCode } }那 4 参数中间件的“为什么”是什么Express 识别错误处理中间件不走参数数量而是看你有没有 4 个参数err, req, res, next。只有这种签名才会被 Express 当作错误处理流程。同时要注意任何 async 路由中只要出现异常都必须传给 next(err)否则错误会被 Express 默认逻辑吞掉或打回原始 HTML前端拦截器会收到一个完全无法解析的响应。开发阶段也可以用一个小的包裹函数统一捕获 async 错误但至少在骨架阶段理解“错误必须走中间件”这个约定就够了。3.4 环境配置与安全基线后端骨架启动阶段还有两件事要顺手做掉环境配置和安全基线。这两件事加在一起只需几分钟但别忘了。环境配置用dotenv加载.env文件名类比前端# server/.env PORT3000 DATABASE_URLpostgres://postgres:passwordlocalhost:5432/my_web_app CORS_ORIGINhttp://localhost:5173安全基线就两行中间件import helmet from helmet import cors from cors app.use(helmet()) app.use(cors({ origin: process.env.CORS_ORIGIN?.split(,) ?? true, credentials: true, }))helmet给响应头发上一批基础安全字段cors只允许白名单来源访问。我把这两个依赖提前放进“骨架期”而不是“上线前”是因为安全配置越早成为常规动作就越不容易在大版本迭代时被遗忘。上线前再补的人百分之八十都会漏掉某一个中间件。4. 数据库设计与迁移先把表结构“钉”下来4.1 从功能需求到表结构user 与业务表很多从零开始的项目数据库表都是在写接口的过程中“顺手”建的于是今天加一个字段、明天改一个长度表结构和代码互相搞不清谁依赖谁。在从零到一的路线里我强烈建议在动业务接口之前先把核心表结构定下来。以我们这套模拟任务管理系统为例核心表就是三张users用户、projects项目、tasks任务关系是一个用户拥有多个项目一个项目包含多个任务。第一版建表就锁定“用户和业务表分来放”的原则。不要因为一开始业务简单就把用户信息塞进业务表里。等后面要做多用户、多权限时拆表代价极大而分开设计只是顺手的事。users表存账号和密码哈希CREATE TABLE users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email VARCHAR(255) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, name VARCHAR(100), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() );projects表通过owner_id指向用户CREATE TABLE projects ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, name VARCHAR(120) NOT NULL, description TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now() );tasks表通过project_id归属到项目CREATE TABLE tasks ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, title VARCHAR(200) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT todo, due_date DATE, created_at TIMESTAMPTZ NOT NULL DEFAULT now() );为什么不直接用自增整数做主键UUID 已经是现代 Web 应用的常见选择它能让每个对象拥有“全局唯一”的身份以后做数据合并、迁移、同步时会省很多事。生成方式也可以让created_at这种字段完全交给数据库默认值业务侧不需要关心。4.2 用迁移脚本而不是手动建表“手动建表”的坑我亲自带的一个项目里发生过某同学在本地数据库敲了一段 CREATE TABLE过了一周需求改了直接在表上加列又过了一周其他同学的本地库表结构完全对不上了联合调试时互相报“字段不存在”。这就是没有把表结构纳入版本控制的后果。解决办法是用数据库迁移migration脚本。思路很简单把每次表结构变更写成一个有编号的 SQL 文件记录“变更内容”和“回滚内容”。实际应用里可以使用现成的迁移工具管理版本顺序这里为了讲清楚原理用一个简化版本让大家明白为什么迁移比手动建表可靠-- migrations/001_create_users.up.sql CREATE TABLE users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email VARCHAR(255) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() );-- migrations/001_create_users.down.sql DROP TABLE IF EXISTS users;up是前进、down是回滚。每次结构变更都新增一个002_xxx.up/down.sql、003_xxx.up/down.sql。前面说“钉”住表结构指的就是把这些脚本提交到 Git 仓库里所有人的表结构跟代码一起演进。你再也听不到“我本地能跑啊”这句经典台词因为只要大家的迁移版本一致表结构就一致。4.3 字段类型与约束选择里的坑表结构初稿里最容易出问题的不是主外键而是字段类型选择。这里挑三个我见过的高频坑。第一时间字段必须用TIMESTAMPTZ而不是DATETIME。数据库存的是带时区的时间应用层无论用户在世界哪个角落拿到的时间都是正确的绝对时刻。用不带时区的类型等部署到云上才发现差了八小时那真叫一个酸爽。第二VARCHAR 的长度要按业务上界来定不要全都填 255。邮箱 255 没问题任务标题 200 有点宽但够用项目名你给它 120 就差不多了。长度给得太随意一是存储浪费二是想做约束时反而不知道合理边界在哪。第三ON DELETE行为要想清楚。用户删了账号他的项目要不要一起删这里按业务理解选了CASCADE。如果你的业务需要保留历史数据就该选SET NULL或RESTRICT。这个决定不能等到数据已经跑起来才做否则删个用户可能直接在线上报错。索引也不用急着加满。第一版只要覆盖高频查询users.email有唯一约束会自动生成唯一索引这已经够了。任务的查询通常会按project_id过滤可以给它建一个普通索引CREATE INDEX idx_tasks_project_id ON tasks (project_id);其他索引等真正出现慢查询再补不要一开始把索引当成装饰品。4.4 种子数据让前端开发不必先有真实接口前端页面还没全做完后端接口也没完全稳定时页面开发会非常憋屈。解决这个问题的经典手段是种子数据seed往数据库里插一批模拟数据让前端拿到真实的数据形态。种子脚本通常写成独立脚本执行时可以重复运行关键是幂等设计——每次跑之前先清掉自己上次插的数据或者用ON CONFLICT跳过已经存在的记录。简化示例// server/seed.ts import { pool } from ./src/db async function seed() { await pool.query( INSERT INTO users (email, password_hash, name) VALUES (demoexample.com, $2b$10$..., 演示用户) ON CONFLICT (email) DO NOTHING ) const { rows } await pool.query( SELECT id FROM users WHERE email demoexample.com ) const userId rows[0].id await pool.query( INSERT INTO projects (owner_id, name, description) VALUES ($1, 示例项目, 这是一个种子数据项目) ON CONFLICT DO NOTHING , [userId]) await pool.end() } seed()种子数据不该塞满真实细节够前端实现三个典型状态就行有数据的列表、空列表、部分完成的字段。有了这些前端在接口就绪之前就能把页面写成“看起来是真的”的样子联调时再替换成接口数据改动量会小很多。5. 前后端第一次联调当 React 真正请求 Express5.1 CORS 问题与 vite proxy 的取舍前后端第一次联调拦路虎往往不是业务代码而是 CORS。前端跑在http://localhost:5173后端跑在http://localhost:3000浏览器发现请求跨了源默认会拦截响应。处理跨域的“经典路线”里我推荐开发阶段用 Vite 的 proxy 功能而不是完全依赖后端的 CORS 白名单。理由有两个一是代理方案下前端请求始终保持同源代码里不用区分“开发环境是跨域的”和“生产环境是同源的”两套逻辑二是它可以顺带解决 cookie 跨域、Session 传递等一堆连带问题。在client/vite.config.ts里写import { defineConfig } from vite import react from vitejs/plugin-react export default defineConfig({ plugins: [react()], server: { proxy: { /api: { target: http://localhost:3000, changeOrigin: true, }, }, }, })生效之后前端发的/api/auth/register会被 Vite dev server 转发给http://localhost:3000/api/auth/register。同源问题在开发环境彻底消失。那后端配置cors白名单还有用吗有用。当你对接的不是浏览器直连、而是别的前端工具或跨域场景时后端明确允许哪些源访问仍然是最后一道闸门。开发环境里如果代理没有生效CORS 配置就是你的应急保险两边都做了联调时才不会腹背受敌。5.2 统一错误格式前端拦截器与后端约定后端在 3.3 节已经定义了统一错误响应格式{ code: EMAIL_EXISTS, message: 该邮箱已被注册, data: null }这个约定不是后端单方面定的。前端拦截器在 2.2 节读取的error.response.data.message也必须严格对齐这个结构。前后端刚开始协作时最容易出现的问题就是“后端这边叫msg前端那边读message联调全瘫”。所以联调的第一件事不是看接口通没通而是对齐接口规范。我这里推荐一个最朴素的接口结构能被前后端同时轻松扩展成功响应状态码 2xxcode0messageokdata放业务数据。失败响应状态码 4xx/5xxcode放字符串错误码message放给用户看的提示datanull。把这条约定写进项目的 README 或接口文档里比“大家默契就好了”靠谱得多。一旦约定成型前端拦截器可以统一处理大部分错误后端也不用在每个接口里解释自己为什么返回这种结构。5.3 用真实注册接口跑通一次完整链路该到验收时刻了。我习惯的启动顺序是这样的先启动 PostgreSQL然后用迁移脚本建表再启动后端确认监听3000最后启动前端开发服务器确认监听5173。全部就绪后在页面点一次真实注册。第一次跑通时验证三样东西前端网络面板里出现POST /api/auth/register状态码 201响应结构符合约定。后端日志没有报错数据库里多了一行用户记录并且password_hash是一长串不可逆的哈希值。前端页面收到了用户数据并且存储了后续请求要用的 token。如果任何一步失败参考下面的快速排查表多数情况下问题不在代码而在环境或约定失败现象可能原因处理方式网络面板请求显示 CORS error前端没走代理直接用完整后端地址发请求使用/api相对路径检查 Vite proxy请求 404后端接口路径或前缀与前端不一致检查前后端路由是否都统一为/api/auth/register请求 500后端日志有连接报错数据库连接字符串有误或迁移没跑核对DATABASE_URL运行迁移脚本收到 400 但 message 奇怪前后端字段名不一致对着接口文档逐字段检查请求体字段命名我见过最多的是第一种前端同学嫌代理麻烦直接把baseURL写成了http://localhost:3000后端 CORS 又没配好于是反复死在 CORS 上。按上面统一用/api proxy 的习惯这个坑几乎不会再踩到。5.4 联调通过之后骨架阶段才算真正结束当第一个真实接口完整跑通你再回头看这篇二做的事情前端有了请求层、路由和状态存储后端有了分层结构、统一错误处理和数据库迁移前后端有了统一的接口约定。这一整套骨架的价值不在于它引用了哪个框架、用了哪条命令而在于“约定已经形成”。我自己带过不少人从零做 Web 应用最深的体会就是早期最耗时间的从来不是某个技术难点而是“约定长什么样”的问题。接口返回结构前端不知道后端怎么给错误处理后端不知道前端怎么读表结构前后端都不知道谁来决定。这篇里我帮你把这些约定在骨架阶段就固定下来后面不管做多少页面、加多少功能你会发现团队很少再为“联调崩了”吵架——因为骨架阶段该定的都定了。最后分享一个扩展方向如果你觉得手动执行迁移脚本已经不够用下一步可以引入更完整的迁移工具做版本管理如果你觉得前后端各跑一个服务太麻烦可以研究一下单仓一键启动脚本把client和server的启动命令拼在一起。这些都不难难的是先把骨架钉稳。把这一篇的约定守住你可以放心地往里面填充任何业务功能了。