前后端连接不再难:用原生Fetch与Node.js跑通HTTP数据链路

发布时间:2026/10/17 13:58:15
前后端连接不再难:用原生Fetch与Node.js跑通HTTP数据链路 我见过太多人卡在这一步前端页面写好了后端接口也写好了浏览器打开HTML却拿不到后端的数据。要么跨域报错要么404要么控制台一片红。前后端到底怎么连起来今天就写一个最小demo纯原生实现从零把这个过程跑通。不用框架不装依赖就用浏览器自带的fetch和Node.js原生http模块把HTTP这条前后端之间的传送带彻底看懂。这个demo适合谁刚学前端想接触后端的人写过后端但不理解接口调用的人以及一切想知道我在浏览器点了一下按钮数据是怎么从服务器到我屏幕上的的读者。读完你不仅能跑通一个最小例子还能自己初步排查连不上的常见原因。1. 前后端连接的本质一次HTTP请求的完整旅程1.1 连起来不是物理连线而是双方说同一种语言很多新手对前后端连接有误解以为存在一根前后端连接线像USB一样插上就通。其实不是。前端和后端是两个完全独立的程序前端运行在你自己的浏览器里后端运行在某台电脑或某个进程上它们之间靠HTTP协议通信。HTTP协议本质上就是一套发送文本和接收文本的规则。用生活场景类比前端像一个顾客后端像一个后厨。顾客不需要直接钻进厨房只需要按照菜单写法提交订单请求后厨按订单把菜端出来响应。菜单格式、端菜窗口、菜名写法都是双方早就约定好的。HTTP就是这个约定你写的接口路径好比菜名请求头和请求体好比备注和订单详情。一旦想明白这一点连起来就不神秘了。所谓连接无非是前端发一个HTTP请求后端收到后处理业务逻辑再返回一个HTTP响应。前端页面是皮后端接口是馅HTTP是传送带。我们的任务就是把这条传送带从零接通。1.2 一次请求从输入到响应的完整旅程以在浏览器访问http://localhost:3000/api/message为例一次请求大致经历这些步骤浏览器解析域名localhost指向本机回环地址也就是127.0.0.1。真实环境中这一步是DNS服务器把域名解析成IP地址。浏览器拿着IP和端口3000发起TCP连接。可以理解成先拨通电话建立一条可靠的传输线路。在TCP连接之上浏览器发送一个HTTP请求。请求内容包括请求行方法路径HTTP版本、多个请求头以及可能的请求体。后端进程Node.js跑起来的程序收到请求后根据请求里的URL路径和HTTP方法执行对应逻辑。后端返回HTTP响应内容包括状态行状态码状态文字、响应头、响应体。浏览器收到响应根据响应头里的Content-Type决定怎么处理。如果是HTML就渲染页面如果是JSON就交给JavaScript代码解析。我们的最小demo要实践的就是第3到第6步。理解了这条链路前面提到的跨域404数据是undefined等报错你就有地方下手了。1.3 浏览器、服务器、接口各自扮演什么角色在这个demo里有三样参与者浏览器客户端负责发起请求、接收响应、渲染HTML、执行JavaScript。没有浏览器前端代码就是一串没法运行的静态文件。服务器Node.js进程一个常驻内存的程序监听某个端口随时准备接收请求。它不关心请求来自哪里只关心请求打到了哪个路径、用的哪个方法。接口API后端暴露给前端调用的入口本质就是URL路径请求方法的组合。比如GET /api/message是一个接口POST /api/echo是另一个接口。初学者容易把服务器和后端混在一起。其实后端是一段业务逻辑服务器是承载这段逻辑的程序底座。Node.js的http.createServer创建的就是一个HTTP服务器我们在里面写的回调函数就是接口逻辑。2. 最小原生Demo用浏览器自带fetch加Node自带http模块跑通2.1 环境准备只需要一个现代浏览器和Node.js这个demo的原生性值得强调一下。全程不需要npm install不需要安装Express、Vue、React甚至连package.json都不需要。你只需要一台能运行浏览器的电脑Chrome、Edge、Firefox之类的现代浏览器都行。Node.js建议装LTS长期支持版本。一个空文件夹一个趁手的文本编辑器。打开命令行执行node -v能看到版本号就说明环境OK。Node.js的安装包自带npm但这次我们刻意不用它就是为了让读者看清HTTP的原生面貌。2.2 后端用Node.js原生http模块实现第一个接口新建一个文件夹在里面创建server.js写下这段代码const http require(http); const fs require(fs); const path require(path); const server http.createServer((req, res) { // 解析URL拿到路径和查询参数 const url new URL(req.url, http://localhost:3000); const pathname url.pathname; // 接口返回一段JSON if (pathname /api/message req.method GET) { res.writeHead(200, { Content-Type: application/json; charsetutf-8 }); res.end(JSON.stringify({ message: Hello from server!, time: new Date().toISOString() })); return; } // 把前端页面也交给这个服务器托管 if (pathname / || pathname /index.html) { fs.readFile(path.join(__dirname, index.html), (err, data) { if (err) { res.writeHead(500, { Content-Type: text/plain; charsetutf-8 }); res.end(Server Error); return; } res.writeHead(200, { Content-Type: text/html; charsetutf-8 }); res.end(data); }); return; } // 其余路径一律404 res.writeHead(404, { Content-Type: text/plain; charsetutf-8 }); res.end(404 Not Found); }); server.listen(3000, () { console.log(server running at http://localhost:3000); });代码不复杂但有几个关键点必须说明。http.createServer的回调会在每次请求进来时被调用回调的两个参数req和res分别是请求对象和响应对象。req.url是请求的路径加查询字符串req.method是HTTP方法。我用了new URL()来解析路径处理中文参数时会更稳妥比直接操作字符串省心。res.writeHead(200, { Content-Type: application/json; charsetutf-8 })这行的意思是告诉浏览器我要返回一个200状态的响应内容类型是JSON编码是UTF-8。很多人一开始中文乱码就是因为少了charsetutf-8。res.end(JSON.stringify({...}))是真正把数据发出去的一行。注意res.end接收的是字符串而我们要返回JSON所以必须用JSON.stringify把对象转成字符串。这个细节很多人忽视前端拿到响应后还想直接当对象用结果怎么都读不到属性。2.3 前端一个HTML页面加两个按钮在同一个文件夹下创建index.html!DOCTYPE html html langzh-CN head meta charsetUTF-8 title前后端连通最小demo/title /head body h2前后端连通测试/h2 button idbtnGet点击GET/button p idresult/p script const btnGet document.getElementById(btnGet); const result document.getElementById(result); btnGet.addEventListener(click, async () { try { const res await fetch(/api/message, { method: GET }); if (!res.ok) { throw new Error(请求失败状态码 res.status); } const data await res.json(); result.textContent 后端返回 data.message 时间 data.time; } catch (err) { result.textContent 出错了 err.message; } }); /script /body /html这里用的fetch是浏览器原生提供的API不属于任何框架。fetch(/api/message)发出一个GET请求返回的是一个Promise。你看到我用async/await去等这个Promise这只是语法糖本质上还是Promise的then链。res.json()做什么把响应体里的JSON字符串解析成JavaScript对象。因为HTTP传输的永远是文本服务端返回的是{message:...}这样的字符串前端拿到字符串后必须解析才能像访问对象属性一样读取data.message。2.4 启动与第一次联调先启动后端再用HTTP访问页面启动顺序很重要。在命令行进入项目文件夹执行node server.js看到server running at http://localhost:3000后打开浏览器访问http://localhost:3000。这时候页面由Node服务器托管页面里的fetch(/api/message)会拼成http://localhost:3000/api/message因为前后端同端口天然同源不存在跨域问题。点击点击GET按钮如果下面的文字变成了后端返回Hello from server!说明前后端已经连通。这个最小闭环就是浏览器里的JavaScript发请求Node服务器接收请求返回JSON前端解析JSON并渲染到页面。我还建议你先别急着点按钮直接在浏览器地址栏访问http://localhost:3000/api/message你会看到一行JSON文本。这说明后端接收请求并返回响应这件事是通的接下来再排查前端就少一半工作量。3. 让数据流动起来GET参数、POST请求体、JSON解析3.1 只发GET不够再写一个接收POST的接口我们的最小demo只有GET太单薄了。真实项目里提交表单、上传数据几乎都是POST。在server.js里增加一个POST /api/echo接口它的作用是把前端发来的数据原样返回再附一个服务器时间用来直观展示请求体传到后端的完整过程。if (pathname /api/echo req.method POST) { let body ; req.on(data, chunk { body chunk.toString(); }); req.on(end, () { let parsed {}; try { parsed JSON.parse(body); } catch (e) { res.writeHead(400, { Content-Type: application/json; charsetutf-8 }); res.end(JSON.stringify({ error: JSON解析失败 })); return; } res.writeHead(200, { Content-Type: application/json; charsetutf-8 }); res.end(JSON.stringify({ echo: parsed, receivedAt: new Date().toISOString() })); }); return; }注意Node.js原生http模块不会主动帮你解析req.body。请求体是流式到达的你必须通过req.on(data, ...)一段一段收集在req.on(end, ...)里拿到完整文本。很多人用惯了Express突然用原生模块会发现拿不到body原因就在这里。这个不省心反而能逼你理解流的概念。为什么我用chunk.toString()因为chunk是Buffer对象是字节序列必须转成字符串才能拼接。物理上数据传输就是一串字节HTTP只是给这串字节加上了语义。3.2 前端如何发POSTbody必须传字符串在index.html里再增加一个按钮监听点击后发POST请求const btnPost document.getElementById(btnPost); btnPost.addEventListener(click, async () { try { const res await fetch(/api/echo, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ name: demo, age: 1 }) }); if (!res.ok) { throw new Error(请求失败状态码 res.status); } const data await res.json(); result.textContent 后端回显 JSON.stringify(data); } catch (err) { result.textContent 出错了 err.message; } });这里最容易踩的坑是JSON.stringify({ name: demo, age: 1 })。有些新手直接传对象body: { name: demo }结果后端收到的body是[object Object]或者直接解析失败。原因还是那句话HTTP传输的是文本对象必须先序列化成JSON字符串。headers里的Content-Type: application/json是在告诉后端这个请求体里的文本是JSON格式你按JSON解析。如果不带这个头后端也可以强行按JSON解析但规范的做法是双方保持约定一致。3.3 GET查询参数的正确读取方式除了POSTGET也经常带参数比如分页页码、搜索关键词。在server.js里加一个接口if (pathname /api/greet req.method GET) { const name url.searchParams.get(name) || 陌生人; res.writeHead(200, { Content-Type: application/json; charsetutf-8 }); res.end(JSON.stringify({ message: 你好 name })); return; }前端只要访问http://localhost:3000/api/greet?name张三后端就能从url.searchParams.get(name)里拿到张三。在整个demo里我用new URL(req.url, http://localhost:3000)解析出了url然后url.searchParams就是浏览器封装好的查询参数解析器。你会发现一旦会用这个对象解析query string就跟玩一样简单。3.4 状态码和Content-Type是前后端之间的暗号前后端联调时状态码是双方最快速的对齐语言。200意味着成功400意味着客户端发来的数据有问题404意味着接口路径不存在500意味着服务器内部出错。在我的demo里JSON解析失败时返回了400未知路径返回了404这些不是随手写的而是让调用方通过res.status就能快速定位问题。响应头里的Content-Type同样关键。前端res.json()能正常工作依赖的是后端返回的正文确实是合法JSON并且编码正确。如果后端返回纯文本却标成application/json前端解析也会出错。所以我在每次res.end前都设置响应头这是好习惯。3.5 用Network面板看一次真实请求这个技能越早掌握越好。按F12打开浏览器开发者工具切到Network网络面板再点击页面上的按钮。你会看到列表里多出若干条请求点开api/echo那条可以依次查看Headers实际发出的请求头包括Content-Type。PayloadChrome里叫Payload部分浏览器叫Request实际发送的请求体JSON字符串。Response服务器返回的JSON文本。Status Code状态码红色或4xx/5xx都需要警惕。以后凡是前后端连不上先不要看代码先打开Network面板看一眼请求有没有发出去、状态码是什么、响应体长什么样。绝大多数问题在这一步就能看出来根本不需要猜。4. 连不上的三大拦路虎文件协议、端口冲突、跨域4.1 不要双击HTML用file://打开页面初学者最常犯的错误是把index.html从文件夹里双击打开。这时浏览器地址栏长这样file:///C:/Users/xxx/Desktop/index.html。页面能渲染但点击按钮后fetch(/api/message)会无声无息地失败控制台报错也看不懂。原因在于相对路径/api/message在file://协议下会被拼成file:///api/message。浏览器会尝试用本地文件系统读取这个路径而不是发送HTTP请求到localhost:3000。我们的Node服务器自然接收不到任何东西。所以用HTTP协议访问页面是前置条件。demo里我把前端页面交给后端托管就是为了保证页面和接口都在http://localhost:3000下。如果要用Vite、Live Server之类的开发服务器也一定确保最终访问地址是http://127.0.0.1:端口号形式。4.2 端口被占用和地址写错最常见的起步阻碍启动server.js时偶尔会看到类似Error: listen EADDRINUSE的报错意思是3000端口已经被占用。解决思路有两个换个端口比如server.listen(3001)或者找出占用进程杀掉。在Windows上可以执行netstat -ano | findstr :3000找到占用进程的PID在macOS/Linux上执行lsof -i :3000。找到PID后在任务管理器或kill PID处理即可。还有一类低级错误是URL写错。比如把http://localhost:3000写成http://localhost:3000/多一个斜杠通常没事或者漏写端口号又或者浏览器地址栏输入时用了中文输入法。遇到404建议把Network面板里Request URL那一行完整复制出来和后端路由代码逐字符对比基本立刻露出马脚。改完server.js后必须重启Node进程很多新手改了代码不重启刷新页面发现行为没变。记住Node服务器不是热重载的你改了文件后旧进程还在跑旧代码。按CtrlC终止再执行node server.js。4.3 跨域报错浏览器拦的不是发送而是读取把前端从后端托管中剥离出来放在另一个端口比如某个端口号为5500的静态服务器上然后页面里的fetch去请求http://localhost:3000/api/message你会看到经典的CORS报错。浏览器提示里通常有Access-Control-Allow-Origin字样。跨域的本质是浏览器的同源策略。两个URL只要协议、域名、端口任一不同就是跨域。浏览器允许你发送请求但不允许当前页面的JS读取来自另一个源的响应。很多人以为请求根本没发出去其实请求已经到达后端只是浏览器拿到响应后发现响应头里没有允许当前源读取的声明就拦截下来抛给前端一个错误。解决办法是在后端响应头里显式声明允许哪些来源读取。在我们的demo里可以给接口加上一行res.writeHead(200, { Content-Type: application/json; charsetutf-8, Access-Control-Allow-Origin: * });*表示允许所有来源。简单开发阶段够用但生产环境如果接口涉及用户数据不建议开*而是写成具体的前端域名例如Access-Control-Allow-Origin: https://example.com。如果请求还携带了Cookie凭证则不能使用*需要明确指定来源并额外设置Access-Control-Allow-Credentials: true。更复杂的请求比如带自定义请求头或非简单方法还会触发浏览器先发一个OPTIONS预检请求询问服务器允不允许。初学者遇到OPTIONS请求不要慌那只是浏览器在问路。后端要处理OPTIONS返回允许的方法和头否则正式请求会被拦。在这个最小demo里因为我们严格采用同端口托管前端天然同源没有触发跨域。但理解CORS非常必要因为实际开发中前端经常是独立部署的你早晚会碰上它。4.4 路由不匹配导致的404另一个常见问题是我明明写了接口为什么404。路径大小写、少个斜杠、多个单词拼写错误甚至路由里用了:参数名但前端没传都会导致不匹配。在原生http模块里所有路由都是req.url的字符串判断所以务必保证字符串完全一致。一个排查技巧是在页面点击按钮之前直接在浏览器地址栏手动访问接口URL。如果浏览器直接显示JSON说明后端路由没问题如果404那问题一定在后端路径或端口上。这样就缩小了排查范围不用在前端代码里打半天console.log。5. 从最小Demo走向真实项目的三个重点改造5.1 先理解原生再决定要不要用框架这个demo的原生写法优点是没有黑盒缺点也很明显。路由一多代码就变成一长串if/else解析POST body要手动拼流处理OPTIONS预检要单独写分支。真实项目里Express、Koa这类框架就是把这些重复劳动封装成标准能力。但我的建议是先亲手写一遍原生实现再去用框架。因为框架的文档离不开中间件路由请求对象响应对象这些概念而你在原生demo里已经亲手操作过req、res、req.on(data, ...)再去看框架文档时会发现一切都是似曾相识的封装而不是玄学。比如Express里最简单的写法是app.get(/api/message, (req, res) { res.json({ message: Hello! }); });看起来非常舒服但你得知道res.json的背后就是res.writeHead和res.end(JSON.stringify(...))这样出了问题才知道怎么排查。5.2 静态资源与接口服务的分离真实项目通常会把前端代码和后端接口拆开。开发阶段前端跑一个开发服务器如Vite的npm run dev后端跑一个API服务。前端开发服务器的端口和后端端口通常不一样于是就要面对跨域。常见的解法有两个后端加CORS头或者让前端开发服务器做代理转发把/api的请求转发到后端地址这样浏览器看到的请求依然是同源跨域就消失了。生产阶段前端代码会构建成纯静态文件可以扔给CDN或任何静态服务器后端API单独部署在服务器上。前端页面里的fetch需要写成完整的后端地址或者通过nginx这类反向代理做路径转发。这个demo不展开讲部署但你只要理解HTTP请求的链路这些方案在本质上都是一样的让前端发出的请求能到达后端并且后端响应能被浏览器允许读取。5.3 环境变量与配置管理不要硬编码demo里端口写死为3000一旦要部署到测试环境或生产环境就得改代码非常被动。改造的第一步就是把配置抽出来读环境变量const port process.env.PORT || 3000; server.listen(port, () { console.log(server running at http://localhost:${port}); });启动时通过PORT8080 node server.js就能换端口。同理跨域白名单、数据库连接串、第三方服务密钥等都应该通过环境变量注入而不是写死在代码里。这是一个从demo走向可用项目的基本功。5.4 安全与异常处理的基本底线最后提醒几个底线问题。第一千万不要在生产环境无脑使用Access-Control-Allow-Origin: *尤其接口会读取用户隐私数据时。第二接口要对非法输入做兜底比如我demo里JSON.parse失败返回400而不是让进程崩溃。第三凡是涉及登录态、密码、Token的接口必须用HTTPS并在请求头里合理携带凭证而不是把秘密塞进GET的query string里。第四控制台日志不要打印完整密码之类的敏感字段。这些点现在不急着全做但心里要有数。最小demo的价值是让你聚焦在核心通路上一旦通路建立剩下的就是往这条路上加护栏。最后说说我自己的体会。当年卡在前后端连不上这个问题上时最吃亏的就是闷头看代码不打开开发者工具的Network面板。现在不管换什么框架、什么语言遇到联调问题我的第一反应永远是刷新页面看那条红字请求请求发出去了吗状态码是多少响应头里缺了哪个字段响应体是不是预期格式只要按这个顺序排查九成问题能在两分钟内定位。这个最小demo里用的原生手段就是这套排查能力的底子把它吃透后面走多远都踏实。