前几天写表格页面时踩了个小别扭:我把好友数据直接写在前端 json 里,改个姓名年龄就得去前端文件改代码,当时我懵了,这不就是十几年前前后端揉在一块的老式写法?
寻思着能不能把数据单独放到服务里,前端通过网络请求拉取数据,顺着这个想法,我拆出了现在三段式的项目目录。

从截图能看出来,我把项目粗暴分成三块:backend放后端服务、frontend做原生前端页面、demo单独用来测试第三方大模型接口。当时拆分文件夹的初衷很简单:把服务、业务页面、测试代码拆开,避免所有代码堆在一个文件夹里越写越乱。
先用 json-server 搭简易后端,跑通第一个本地前后端联调
把目录点开之后结构就更清晰了,后端靠 json-server 快速起服务,前端原生写页面,完全零框架,纯原生 JS 吃透请求逻辑。

说实话,之前看教材里的「前后端分离」四个字总觉得太虚,实操完这个小 demo 才算落地。拿生活化的例子类比:后端是奶茶后厨,存着原材料(data.json 数据),前端是进店的顾客(浏览器),顾客不能直接冲进后厨拿原料,只能通过点餐窗口(HTTP 接口 endpoint)下单取成品,这个点餐地址就是接口地址http://localhost:3000/friends。
先贴后端配置,backend文件夹里就两个核心文件:
json
// package.json
{
"scripts": {
"dev": "json-server --watch data.json --port 3000"
}
}
json
// data.json 数据源,后端唯一维护数据
{
"friends":[
{
"id": 1,
"name": "张三",
"age": 18
},
{
"id": 2,
"name": "李四",
"age": 20
}
]
}
运行pnpm dev就能在 3000 端口拉起后端服务,数据改完只需要改这个 json 文件,前端不用动任何代码,这就是分离最直观的好处。
前端frontend里 html 只负责页面骨架,所有数据从接口动态获取,main.js是请求 + 渲染逻辑,这里我踩了当天第一个大坑。
javascript
let friends = [];
async function loadData() {
// endpoint:接口终点,笔记里标注的API请求地址,类比快递收件地址
const endpoint = 'http://localhost:3000/friends';
const res = await fetch(endpoint)
const data = await res.json()
return data;
}
// 拿到数据拼接表格DOM
function renderData(friends) {
const oBody = document.querySelector('table tbody');
if(friends.length > 0){
oBody.innerHTML = friends.map(function(friend){
return `<tr><td>${friend.id}</td><td>${friend.name}</td><td>${friend.age}</td></tr>`
}).join('');
}
}
async function init() {
console.log('init start');
const friends =await loadData();
renderData(friends);
}
init();
// 坑就在这一行,坑了我半小时
console.log('init end');
踩坑记录最开始我默认代码从上到下顺序执行,以为init()走完接口请求,才会打印init end,结果控制台永远先输出init end,再打印接口返回的数据。翻了 MDN 文档才醒悟:await 只会阻塞当前 async 函数内部的代码,全局顶层代码不受异步等待约束。
顺带捋懂了 IP 和域名:127.0.0.1:3000里 IP 用来在网络里定位服务器,域名(比如www.baidu.com)是为了方便人类记忆,DNS 解析会自动把域名翻译成 IP 地址,而我们写的 endpoint 就是精准到具体资源的访问地址。
换个场景:用 fetch 调用 DeepSeek 线上大模型接口(demo 文件夹)
本地前后端跑通之后,我好奇 fetch 既然是通用 HTTP 请求方案,能不能直接调线上第三方接口?于是单独开了 demo 文件夹,写了个极简 AI 聊天页面。
这里又踩了第二个致命错误:POST 请求的 body 不能直接传 JS 对象。最开始随手把 payload 对象丢进 body 里,接口疯狂报错,反复试错才想起 HTTP 传输协议只认字符串 / 二进制流,浏览器没法解析原生 JS 对象,必须用JSON.stringify()序列化。
错误写法(千万别照搬):
javascript
// 错误示范,直接传入对象,接口参数解析失败
const response = await fetch(endpoint, {
method: 'POST',
headers,
body: payload
})
修正后的可用代码:
javascript
// demo/main.js
const endpoint = 'https://api.deepseek.com/chat/completions';
const headers = {
'Content-Type': 'application/json',
// 鉴权信息放在请求头,笔记里提到的请求头配置,最好写到.env文件中
Authorization: `Bearer sk-xxx`
}
// 请求体:POST的数据载体
const payload = {
model: 'deepseek-v4-flash',
messages: [
{ role: 'system', content: 'You are a helpful assistant' },
{ role: 'user', content: '你好, Deepseek'}
]
}
try {
const response = await fetch(endpoint, {
method: 'POST',
headers,
// 关键:必须序列化转字符串
body: JSON.stringify(payload)
})
const data = await response.json();
document.getElementById('replay').innerHTML =
data.choices[0].message.content;
} catch(err) {
// 捕获接口异常,避免页面白屏
}
搭配同目录的index.html和style.css,一个简陋的 AI 对话页面就做完了。写完这个 demo,笔记里的「请求行、请求头、请求体」概念瞬间落地:
- 请求行:接口地址 + 请求方法 + HTTP 版本,就是
POST https://api.deepseek.com/xxx HTTP/1.1 - 请求头:Content-Type、Authorization 这类配置信息
- 请求体:POST 独有的数据载体,GET 请求没有 body
实操落地后,分清 B/S 和 C/S 架构
之前看书里的两种架构一直云里雾里,做完两个 demo 彻底通透:
- B/S(浏览器 / 服务端) :咱们写的网页项目就是典型 BS,用户不用装任何软件,打开浏览器就能访问,前后端分离是 BS 架构主流开发模式;
- C/S(客户端 / 服务端) :微信、抖音 APP 这类,需要下载安装客户端软件,再和后端服务通信。
另外前端发送 HTTP 不止 fetch 一种,笔记里还标注了XMLHttpRequest,是 fetch 出现之前的原生请求方案,老旧项目还能见到,新项目优先用 fetch 搭配 async/await。
梳理下来三个实打实的收获,顺便聊聊 fetch 局限性
折腾完两套 demo,抛开书本空话,实打实总结三点感悟:
- 前后端分离核心:页面渲染逻辑和数据源彻底拆分,后端只产出标准化 JSON 接口,前端只负责页面展示,修改数据源不用改动前端代码,这也是我拆分 backend/frontend 目录的初衷;
- endpoint 不是花哨命名:每个接口地址是服务资源的唯一入口,项目体量变大后统一管理接口地址,后续改域名、端口不用全项目搜索替换;
- async/await 是异步语法糖,极大优化了 fetch 的代码可读性,但一定要牢记 await 的作用域限制,这是新手写异步最容易踩的坑。
顺带一提,fetch 也不是万能方案:如果项目需要兼容 IE 等老旧浏览器,原生 fetch 无法使用,得降级用 XHR 或者 axios 二次封装。
如果你刚入门前后端接口调用,也踩过异步顺序、POST 序列化这类离谱 bug,搞懂了记得回来留个言,我也想看看你踩过哪些稀奇古怪的坑。