☰
手把手教你用Node.js+Express开发待办事项应用:前后端联调全流程
2026/10/14 3:53:08 网站建设 项目流程

又到了交“第四次作业”的时候。这次题目看着不算难——某高校《Web开发基础》课程留的作业,要求用课堂上学过的知识做一个简单的待办事项管理应用,覆盖增删改查、状态切换、本地存储这几个核心功能。但我在实际完成过程中发现,真正一次跑通的同学并不多,问题往往不是卡在某个大难题上,而是堆在各种不起眼的细节里:字段命名前后不一致、接口返回格式没约定好、前端拿到数据后不会渲染、中文乱码、同一个页面刷三次状态才正确。

这篇文章把我完成“第四次作业”的完整过程记录下来,从拆解需求、设计数据结构和接口,到后端实现、前端页面编写、联调测试,再到最后提交前的自查清单,每一步都写清楚“我为什么这么做”和“我踩过哪些坑”。如果你正在写类似的课程作业,或者刚接触前后端联调,这篇内容可以直接当参考手册用,照着流程走,能少走很多弯路。

1. 拿到“第四次作业”后,我是这样拆解需求的

1.1 题目到底让我做什么

作业原文就三句话:实现一个待办事项管理应用,支持新增、删除、修改状态、查看列表;数据要持久保存;界面风格不限。看起来简单,但“简单”恰恰是这类作业最容易被低估的地方。我见过不少同学拿到题就开写,结果写到一半发现前端只管展示写死的假数据,后端只管返回结果没做参数校验,模块之间完全是断开的。

正确的打开方式是先做需求拆解。把题目翻译成功能列表:

  • 新增:用户输入一条待办事项内容,提交后保存并显示在列表中。
  • 删除:每条待办事项都有删除入口,点击后从列表和数据中同步移除。
  • 修改状态:待办事项有“未完成/已完成”两种状态,点击按钮或者复选框完成切换。
  • 查看列表:页面加载时自动获取已有事项并渲染。
  • 持久化:数据不会因为页面刷新而丢失。

这个拆法看着简单,但它决定了后面的所有设计。每条需求背后都藏着一个技术点:新增对应“表单提交和接口调用”,删除对应“传参和接口行为设计”,状态切换对应“数据更新和局部更新策略”,持久化对应“存储方案选型”。把需求列表展开成技术点列表,写代码时就会很清楚。

1.2 技术选型:为什么是原生JS + Node.js

我们课程前半段讲的是HTML、CSS、JavaScript,后半段引入了Node.js,所以这套作业的基本约束就是原生前端技术,不允许用框架。很多同学听说不能用框架,第一反应是“那我用后端模板渲染不久完事了”,也有人打算用jQuery。我最后选的是:前端原生JavaScript,后端用Node.js内置的http模块外加Express框架,数据存储直接用JSON文件。

选Express而不是纯http模块,是因为课程中明确推荐了它,而且课程作业场景下,Express的学习成本极低、路由写法直观、中间件体系清晰,能让“后端到底在干什么”这件事更透明。不选数据库而用JSON文件存储,是因为课程作业这类小规模项目,往往数据量很小、并发极低、以验证功能为主,引入数据库虽然很正规,但增加了环境依赖。考虑到多数同学可能在自己电脑上部署,减少安装MySQL这类外部依赖能显著降低启动门槛。

我并不是说数据库不好。作业的意义在于把功能完整跑通,同时把“数据从哪来、送到哪去、怎么存下来”这条链路理解清楚。JSON文件存储已经是持久化,已经满足作业要求,等后面做更大项目时再迁移到数据库也不迟。

1.3 模块划分与任务路线图

拆完需求、定完技术栈,接下来就是建模块划分。我按“前端展示层、后端接口层、数据存储层”三层来组织代码:

  • 前端:index.html、styles.css、script.js,负责页面结构、样式、交互逻辑。
  • 后端:server.js,负责路由和接口逻辑,提供JSON格式的数据响应。
  • 数据:data/todos.json,负责持久化存储。

这个结构对作业来说已经算清晰了。我觉得比代码结构更重要的是任务的先后顺序——正确的开发路线是“先数据层,再接口层,最后前端”。也就是说,先把数据结构和文件读写搞定,再让接口可以正确吐数据,最后才写页面。很多人习惯先把页面画出来再做后端,结果真到联调时一堆问题,反而更慢。

2. 核心功能设计与数据模型

2.1 待办事项的数据结构

数据结构是整个应用的地基,这一步如果没想清楚,后面的接口和前端都会跟着乱。我定义的每条待办事项长这样:

{ "id": "2024051215301234567", "title": "完成Web开发基础作业", "completed": false, "createdAt": "2024-05-12 15:30:12" }

字段含义不复杂:

  • id:唯一标识,我用了时间戳加随机数的组合,格式是年月日时分秒毫秒,重复概率极低,不需要额外依赖。
  • title:待办事项的内容文字。
  • completed:是否完成,布尔值,false表示未完成,true表示已完成。
  • createdAt:创建时间,字符串格式,方便直接展示。

这里的核心决策是“用布尔值还是字符串表示状态”。很多同学会用字符串,比如"status": "undone"和"status": "done"。我最后选了布尔值completed,因为待办事项的原始需求只有“完成/未完成”两种状态,布尔值天然对应,代码中判断也最简单直接。等以后真有“进行中、已暂停、已完成”多状态需求时,再迁移到字符串不迟。这是“作业场景下保持最小设计”的原则:能用简单解决,就不要提前复杂化。

另一个容易踩坑的点是title要不要做长度限制。我在前端做了maxlength="50",后端也做了同样长度的校验。原因是数据存到JSON文件里,如果用户输入超长文本,文件会变得很臃肿,读取和写入效率都会受影响。虽然真实业务场景中50个字符可能不够,但在课程作业中这个限制合理,而且能体现“考虑边界条件”的意识。

2.2 后端接口设计:增删改查的边界

接口设计我遵循了REST风格,用HTTP方法对应操作语义。整组接口如下:

方法路径作用请求体/参数响应
GET/api/todos获取全部待办事项无{ "code": 0, "data": [...] }
POST/api/todos新增待办事项{ "title": "内容" }创建成功后的完整对象
DELETE/api/todos/:id删除指定事项URL路径中的id删除成功提示
PUT/api/todos/:id修改状态{ "completed": true }更新后的完整对象

我在这里定的响应格式统一是{ "code": 0, "msg": "ok", "data": ... }。这个格式虽然多了几层包装,但好处非常明显:前端可以统一判断code是否为0,再决定是走成功逻辑还是错误提示,而不是靠“接口有没有返回内容”这种模糊判断。

code: 0表示成功,非0表示失败。我规定code: 1为参数错误,code: 2为找不到资源。这套编号虽然简单,但已经足够应对课程作业的各种异常。使用统一的响应格式还有个好处——以后如果作业升级,让你加个用户登录,加了鉴权之后只需要在失败时返回code: 401这种业务码即可,前端逻辑不用大改。

2.3 前端的交互状态设计

前端部分我画了一张简单的状态流转图(用文字描述):初始状态是“无数据”,用户提交后进入“加载中”,接口返回成功后变成“展示列表”,出错时变成“错误提示”。这些状态不需要引入复杂的库,用几个JavaScript变量就能控制:

  • currentPage:当前页码,默认1,本作业数据量小,先不做分页展示,但变量提前留好。
  • isLoading:是否正在请求数据,控制按钮禁用,避免用户重复提交。
  • errorMessage:当前错误信息,为空时不展示错误区域。

为什么要考虑这些状态?因为真实操作中,网络请求不是瞬间的。用户点了“新增”按钮,如果按钮不置灰、没有加载提示,他会连续点好几次,这就会触发重复提交。虽然作业不要求做防抖,但这种细节能让界面显得可靠很多。

还有一个容易被忽略的点:新增成功后列表怎么更新。我的方案是“全量刷新”——新增成功后重新请求GET /api/todos,用返回的数据整体渲染。这个方案对当前的小数据量场景非常合适,写起来也简单:fetch拿数据,渲染函数统一处理。如果你一上来就做“局部插入”,还需要保证新数据字段和后端返回的字段完全一致,否则状态更新后会显示不出预期结果。

3. 实操全过程:从空目录到跑通

3.1 初始化项目与环境准备

我先创建了项目目录todo-app,然后在里面初始化了package.json。如果用的是Node.js,我建议在项目根目录执行npm init -y,快速生成默认配置。接着安装依赖:

npm init -y npm install express --save

这里选Express的某一个具体版本没有太大讲究,因为是课程作业,稳定性优先。安装完成后,我在根目录创建了server.js和index.html,再建了一个data目录用来放JSON存储文件。

有个细节值得注意:data/todos.json这个文件我一开始没有手动创建,而是让后端代码在启动时检查“文件是否存在”,不存在就自动创建并写入空数组。这样做的好处是,换一台电脑部署项目时,不用手动记忆“要先建文件”,启动后直接就可用。别小看这个细节,我在验收演示的时候就遇到过直接把这个文件漏掉的情况。

3.2 后端代码实现

server.js的核心逻辑可以分为三块:读取数据、写入数据、路由处理。先看读取和写入的封装:

const fs = require('fs'); const path = require('path'); const DATA_FILE = path.join(__dirname, 'data', 'todos.json'); function readTodos() { if (!fs.existsSync(DATA_FILE)) { return []; } const raw = fs.readFileSync(DATA_FILE, 'utf8'); try { return JSON.parse(raw); } catch (e) { return []; } } function writeTodos(todos) { fs.writeFileSync(DATA_FILE, JSON.stringify(todos, null, 2), 'utf8'); }

读取时判断文件是否存在、JSON.parse包在try里,是为了防止意外情况导致程序崩溃。写入时用JSON.stringify的第三个参数2让文件内容格式化,这样可以直接打开JSON文件检查数据,调试起来非常方便。

接下来是路由部分。我保留了Express的标准写法,同时用中间件解析请求体:

const express = require('express'); const app = express(); app.use(express.json()); app.get('/api/todos', (req, res) => { const todos = readTodos(); res.json({ code: 0, msg: 'ok', data: todos }); }); app.post('/api/todos', (req, res) => { const title = req.body && req.body.title; if (!title || typeof title !== 'string' || title.trim() === '') { return res.json({ code: 1, msg: 'title不能为空', data: null }); } const todos = readTodos(); const newTodo = { id: String(Date.now()) + String(Math.floor(Math.random() * 1000)), title: title.trim(), completed: false, createdAt: formatTime(new Date()) }; todos.push(newTodo); writeTodos(todos); res.json({ code: 0, msg: 'ok', data: newTodo }); });

express.json()这个中间件非常关键——如果漏了它,req.body会是undefined,接口直接报错。这个问题我见过太多次了,很多同学在联调时发现POST返回“title不能为空”,但实际上请求是带了数据的,原因就是这个中间件没有挂载。

写入ID的方式,我选的是Date.now()拼接三位随机数。虽然不追求绝对唯一,但在单机、单用户的作业场景下已经足够。你甚至可以不用随机数,直接在Date.now()后面加一个自增序号,效果差不多。重点是理解“ID要保证不重复”这个设计原则,而不是死记生成方法。

时间格式化我写了一个简短的函数:

function formatTime(date) { const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0'); const h = String(date.getHours()).padStart(2, '0'); const min = String(date.getMinutes()).padStart(2, '0'); const s = String(date.getSeconds()).padStart(2, '0'); return `${y}-${m}-${d} ${h}:${min}:${s}`; }

这里有个小知识点:getMonth()返回的值是0到11,所以要加1才是真实月份。padStart(2, '0')可以把月份补成两位数,避免出现2024-5-6这种格式不统一的情况。这些细节都是“看起来不影响运行、实际上影响体验”的地方。

修改状态的接口我放在PUT /api/todos/:id:

app.put('/api/todos/:id', (req, res) => { const id = req.params.id; const completed = req.body && req.body.completed; const todos = readTodos(); const index = todos.findIndex(item => item.id === id); if (index === -1) { return res.json({ code: 2, msg: '找不到该事项', data: null }); } if (typeof completed !== 'boolean') { return res.json({ code: 1, msg: 'completed必须是布尔值', data: null }); } todos[index].completed = completed; writeTodos(todos); res.json({ code: 0, msg: 'ok', data: todos[index] }); });

删除接口与此类似,找到目标下标后用splice移除再写回文件。这里要注意的是:删除的判断标准是ID完全匹配,所以前端传参时必须精确。有些同学图省事用item.title当删除依据,这非常不可靠,两条同名的待办事项会互相干扰。

最后启动服务:

app.listen(3000, () => { console.log('服务器运行在 http://localhost:3000'); });

端口号我选了3000,这个端口在Node.js开发中最常用,本地不会被占用。如果遇到端口占用,改个5000或8080都可以。

3.3 前端页面实现

前端页面分为结构和交互两大部分。结构上我用最基础的HTML语义标签:顶部输入区域、中间列表区域、底部统计区域。

<div class="container"> <h1>待办事项</h1> <div class="input-area"> <input type="text" id="todoInput" placeholder="要做什么?" maxlength="50" /> <button id="addBtn">新增</button> </div> <ul id="todoList"></ul> <div class="footer">还未完成 <span id="pendingCount">0</span> 项</div> </div>

列表项是动态生成的,我用JavaScript创建DOM元素而不是拼HTML字符串。虽然拼字符串看起来更省事,但涉及到需要给按钮绑定事件的场景时,动态创建的<li>元素如果没有用document.createElement来生成,事件绑定就会比较绕。

核心的渲染函数是这样的思路:

function renderTodos(list) { const todoList = document.getElementById('todoList'); todoList.innerHTML = ''; list.forEach(item => { const li = document.createElement('li'); li.className = item.completed ? 'completed' : ''; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.checked = item.completed; checkbox.addEventListener('change', () => updateStatus(item.id, checkbox.checked)); const span = document.createElement('span'); span.textContent = item.title; const delBtn = document.createElement('button'); delBtn.textContent = '删除'; delBtn.addEventListener('click', () => deleteTodo(item.id)); li.appendChild(checkbox); li.appendChild(span); li.appendChild(delBtn); todoList.appendChild(li); }); }

注意我使用的是span.textContent而不是span.innerHTML,这是为了防XSS注入。用户如果输入<img src=x onerror=alert(1)>,用innerHTML就会执行这段脚本,而textContent只会显示成普通文本。课程作业不一定会检查这个点,但这是真实的Web开发安全常识,做进去总是加分项。

新增功能的实现:

async function addTodo() { const input = document.getElementById('todoInput'); const title = input.value.trim(); if (!title) { alert('内容不能为空'); return; } const response = await fetch('/api/todos', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title }) }); const result = await response.json(); if (result.code === 0) { input.value = ''; loadTodos(); } else { alert(result.msg); } }

这里我用了async/await而不是纯粹的.then()链式写法。原因是作业代码量不大,async/await读起来更像同步逻辑,理解成本更低。当然,如果你习惯用.then()也没问题,关键是理解fetch返回的是Promise,需要等异步结果回来后再操作DOM。

加载数据的函数:

async function loadTodos() { const response = await fetch('/api/todos'); const result = await response.json(); if (result.code === 0) { renderTodos(result.data); updatePendingCount(result.data); } }

我在页面里做了两处调用loadTodos():一处是页面初始化时,另一处是新增、删除、状态修改成功之后。这种“操作完就重新拉列表”的模式对作业来说最简单可靠,不用费心处理“局部更新”的各种边界条件。

3.4 联调测试与细节打磨

前后端都写完,并不意味着结束。联调阶段才是真正发现问题的时候。我把常见的联调场景都按顺序测了一遍:

  1. 启动后端服务,打开http://localhost:3000。
  2. 新增一条数据,观察列表是否出现。
  3. 刷新页面,观察数据是否还保留。
  4. 勾选复选框,观察状态是否变化。
  5. 刷新页面,观察状态是否保持“已完成”。
  6. 删除一条数据,观察列表是否更新。
  7. 输入空内容提交,观察前端是否拦截。
  8. 打开data/todos.json,观察存储内容是否符合预期。

第五步是重点。如果你勾选完成后刷新页面显示“未完成”,那说明你没有把更新结果写回JSON文件,或者写回了但读出来时判断逻辑不对。这种状态丢失的问题,哪怕后端逻辑看起来没问题,也得实际刷新两次才能确认。

还有一个容易出细节问题的地方:前端请求PUT接口时,需要在headers里设置Content-Type: application/json吗?我的做法是设置了。因为express.json()中间件默认只解析Content-Type为application/json的请求体,如果你前端用的是默认的text/plain;charset=UTF-8,后端就解析不到req.body,会走到参数校验失败的逻辑里。这个问题非常隐蔽,因为前端不报错,后端也不报错,结果就是“接口返回code为1”,你排查半天才发现是Content-Type的问题。

另一个细节是其他响应消息的设置。我加了一个app.use((req, res) => res.status(404).json({ code: 2, msg: '接口不存在' }))放在所有路由的最后,这样访问/api/xxx的时候不会返回一个看不懂的HTML错误页,而是给出统一的JSON错误。这个小习惯在前后端联调时能帮你快速定位“是不是路径写错了”。

4. 作业中的常见问题与排查实录

4.1 接口能通但前端不显示

这个问题我在实操中遇到过,也帮几个同学排查过。症状是:打开浏览器开发者工具的Network面板,能看到GET /api/todos返回了正常数据,但页面上就是什么都不显示。

排查思路是:先确认loadTodos()有没有被调用,再看result.code是不是0,最后看result.data是不是数组。当时一位同学的情况是:后端返回的data不是数组,而是对象,因为他在写GET /api/todos时返回的是readTodos()的第一个元素,写成了data: todos[0]。前端渲染函数里用了list.forEach,对象没有forEach方法,所以直接报错。

这个问题给我们的教训是:接口返回格式一定要在写后端时就定好,并且前端渲染前要加一层防御性判断。

if (result.code === 0 && Array.isArray(result.data)) { renderTodos(result.data); }

4.2 中文乱码问题

中文乱码出现的地方通常在两处:一是接口返回的中文在浏览器里显示为\uXXXX,二是JSON文件里打开看是乱码。

前者其实不是错误——JSON格式本身支持\uXXXX转义,JavaScript解析时会自动还原成中文。只是因为你在浏览器Network面板里直接看JSON,浏览器的解析器会展示成中文,但如果你用命令行工具curl查看原始响应,才会看到\u开头的内容,这不影响功能。

后者是编码问题。确保后端代码保存为UTF-8格式,同时在读取文件时传入utf8编码参数。fs.readFileSync(DATA_FILE, 'utf8')已经做了这件事。还有个小细节:res.json()会自动设置响应头Content-Type: application/json; charset=utf-8,所以前端不用手动处理编码。如果你用res.send(JSON.stringify(...)),就需要手动检查响应头是否有charset。

4.3 状态更新后页面不同步

这个问题是典型的“后端有数据、前端不刷新”场景。出现的原因有三种:

一是前端调用了updateStatus接口之后,没有在成功回调里重新加载列表。这种最简单,加上loadTodos()即可。

二是后端更新成功但返回的data不是更新后的对象,而是更新前的对象,前端拿着旧数据渲染,自然看不到变化。

三是前端在change事件里获取的checkbox.checked值,经过fetch异步调用后,和实际DOM状态不一致。这个属于事件时序问题,解决方法是:在发出请求时先记录要更新的目标状态,再发送请求,不要在请求回调里去读checkbox.checked。

我的建议是:统一采用“所有修改操作成功后都重新拉取列表”的模式,这样即使多花了一点请求量,也从根本上避免了前后端状态不一致的问题。课程作业的数据量小,性能根本不是瓶颈。

4.4 小技巧:给所有按钮加状态锁

我在前端做了一个简单的防重复提交:

let isSubmitting = false; async function addTodo() { if (isSubmitting) return; isSubmitting = true; try { // 具体逻辑 } finally { isSubmitting = false; } }

这样用户在快速点击“新增”按钮时,只有第一次点击会触发请求,后面的点击全部被忽略,避免在JSON文件里写入多条相同数据。这个技巧在实际操作里非常管用,尤其是作业演示时如果网络稍卡,用户会下意识多点几次,没有状态锁就会重复插入。

5. 作业做完之后,再回头说说那些更重要的体会

这个“第四次作业”做完之后,我的一个很深的感受是:课程作业的价值不在于把功能跑通,而在于把一个完整的链路串起来——从用户输入到前端处理,从HTTP请求到后端路由,从数据校验到文件持久化,再从后端响应回到前端渲染。任何一个环节断层,整个链路就断。你光会写前端、光会写后端都不够,联调能力才是这门课真正要训练的东西。

我在实际完成过程中还发现,把TODO应用的代码结构理清楚之后,再去理解MVC这种经典的分层架构就变得很自然。数据存储是Model层,路由处理是Controller层,页面渲染是View层。虽然作业代码没有严格按照MVC目录来组织,但这个思路已经在代码里了。以后你接触更大型的项目时,会发现它在做的事情本质上也是这三层的协作,只是分工更细致、解耦更彻底。

如果下次作业往这个方向扩展,比如让你加一个“编辑标题”功能,你只需要在后端增加一个PUT接口的新逻辑,更新title字段,前端再提供一个编辑弹层或者内联输入框,其他代码不用大改。这就是合理的模块划分带来的好处——作业是一次性的,但代码能力是积累出来的。

最后再分享一个小技巧:写作业时把代码提交到版本管理工具里,每个功能做完了就提交一次,比如“完成新增功能”、“完成删除功能”、“完成状态切换”。这样每次改动都有记录,如果你改坏了什么,可以很快回退到上一个可用版本,而不需要从头开始排查。我当时写这个应用时就是三个commit走完的,提交历史本身就是一份完成进度的记录,演示给老师看的时候也是加分的。

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

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

立即咨询