(二)这个编号,其实是整个系列里最难写的一篇。(一)通常熬过了需求梳理和技术选型,读者们热情还在,但你到了第二篇,面对的是一大堆还没落地的代码骨架、各种边角约定,以及最难搞的“前后端第一次握手”。这篇文章我就以一套任务管理型 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.ts:import 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 = create<AuthState>((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 里已经给过了,关键是代码里的依赖方向:路由调用 controller,controller 调用 service,service 调用 repository,repository 最后访问数据库。方向是单向的,不许反过来,更不许 controller 跳过 service 直接拼 SQL。为什么要限定得这么死?因为只有方向单向,哪天你把 Express 换成别的框架,或者换个调用方式,你只需要改 controller 这一层;哪天你想从 PostgreSQL 换成别的数据库,你只需要改 repository 这一层。这就是“从零到一”阶段花半小时拆层,后面省下几天重构时间的原因。3.2 第一个接口创建过程:注册接口从路由到数据库光讲分层太抽象,我直接带大家把一个“用户注册”接口从路由写到底层数据访问,你会立刻理解每一层应该放什么。先从路由说起。新建server/src/routes/auth.ts:import { 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 PORT=3000 DATABASE_URL=postgres://postgres:password@localhost:5432/my_web_app CORS_ORIGIN=http://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 ('demo@example.com', '$2b$10$...', '演示用户') ON CONFLICT (email) DO NOTHING `) const { rows } = await pool.query( `SELECT id FROM users WHERE email = 'demo@example.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,联调全瘫”。所以联调的第一件事不是看接口通没通,而是对齐接口规范。我这里推荐一个最朴素的接口结构,能被前后端同时轻松扩展:成功响应:状态码 2xx,code=0,message='ok',data放业务数据。失败响应:状态码 4xx/5xx,code放字符串错误码,message放给用户看的提示,data=null。把这条约定写进项目的 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的启动命令拼在一起。这些都不难,难的是先把骨架钉稳。把这一篇的约定守住,你可以放心地往里面填充任何业务功能了。
(二)这个编号,其实是整个系列里最难写的一篇。(一)通常熬过了需求梳理和技术选型,读者们热情还在,但你到了第二篇,面对的是一大堆还没落地的代码骨架、各种边角约定,以及最难搞的“前后端第一次握手”。这篇文章我就以一套任务管理型 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.ts:import 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 = create<AuthState>((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 里已经给过了,关键是代码里的依赖方向:路由调用 controller,controller 调用 service,service 调用 repository,repository 最后访问数据库。方向是单向的,不许反过来,更不许 controller 跳过 service 直接拼 SQL。为什么要限定得这么死?因为只有方向单向,哪天你把 Express 换成别的框架,或者换个调用方式,你只需要改 controller 这一层;哪天你想从 PostgreSQL 换成别的数据库,你只需要改 repository 这一层。这就是“从零到一”阶段花半小时拆层,后面省下几天重构时间的原因。3.2 第一个接口创建过程:注册接口从路由到数据库光讲分层太抽象,我直接带大家把一个“用户注册”接口从路由写到底层数据访问,你会立刻理解每一层应该放什么。先从路由说起。新建server/src/routes/auth.ts:import { 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 PORT=3000 DATABASE_URL=postgres://postgres:password@localhost:5432/my_web_app CORS_ORIGIN=http://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 ('demo@example.com', '$2b$10$...', '演示用户') ON CONFLICT (email) DO NOTHING `) const { rows } = await pool.query( `SELECT id FROM users WHERE email = 'demo@example.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,联调全瘫”。所以联调的第一件事不是看接口通没通,而是对齐接口规范。我这里推荐一个最朴素的接口结构,能被前后端同时轻松扩展:成功响应:状态码 2xx,code=0,message='ok',data放业务数据。失败响应:状态码 4xx/5xx,code放字符串错误码,message放给用户看的提示,data=null。把这条约定写进项目的 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的启动命令拼在一起。这些都不难,难的是先把骨架钉稳。把这一篇的约定守住,你可以放心地往里面填充任何业务功能了。