跳至内容

使用 Node.js 和 MongoDB 构建带分类的简单任务管理器

学习如何使用 Node.js 和 MongoDB 按类别组织任务。构建包含创建、列表、获取、更新、删除端点及类别筛选的 REST API。
更新 2026年9月10日  · 10分钟

用 AI 探索

ChatGPTClaudePerplexity

一开始,用一个朴素的待办清单就很好:添加一些任务,完成后勾掉即可。但随着任务越来越多,使用体验会变得糟糕。一个能改善体验的做法是引入分类,这样每个任务都可以归属到 Work、Study、Personal 等类别,您也能按类别筛选列表。

在本教程中,您将学习如何使用 Node.js 和 MongoDB 构建一个可按类别组织任务的 REST API。完成后,您将拥有一个包含五个端点(创建、列表、获取、更新、删除)以及类别筛选功能的可用 Node.js 服务器。您还将看到如何在不依赖 Mongoose 这类 ODM 的情况下,使用原生 MongoDB 驱动来实施类别校验。

您将学到什么

  • 如何使用 Node.js、Express 和 MongoDB 构建 REST API,包括完整的 CRUD 端点
  • 如何验证输入、强制类别约束,并安全高效地处理 ObjectId 值
  • 如何组织并测试 API,然后将其连接到前端

如果您更倾向于克隆代码并边读边看,可在 GitHub 上找到本教程的完整代码。

前置条件

开始之前,您应已具备:

  • 已安装 Node.js 18+(默认包含 npm
  • 本地运行的 MongoDB,或免费的 MongoDB Atlas 集群
  • Postman 或其他任意 HTTP 客户端
  • JavaScript 与 REST 基础知识

步骤 1:设置项目

接下来开始构建我们的简单任务管理器。第一步是在终端中运行以下命令来创建新文件夹并初始化一个 Node 项目:

mkdir task-manager-categories
cd task-manager-categories
npm init -y

然后安装应用运行所需的四个运行时依赖:用于路由的 express、官方 MongoDB 驱动、用于加载环境变量的 dotenv,以及用于安全响应头的 helmet

npm install express mongodb dotenv helmet

在开发阶段,使用下面的命令将 nodemon 安装为开发依赖,这样文件变更后服务器会自动重载:

npm install --save-dev nodemon

接着,在 IDE 中打开项目文件夹,更新 package.json 中的 scripts,如下所示。之后您可以用 npm start 启动服务器,或用 npm run dev 进入开发模式运行:

"scripts": {
  "start": "node server.js",
  "dev": "nodemon server.js"
}

下面是我们将逐步搭建的结构。各文件夹职责清晰: db/ 负责数据库连接,lib/ 放置辅助函数,middleware/ 存放 Express 中间件,routes/ 定义请求处理器。稍后我们会在 public/ 中加入一个小型前端。即便是小项目,这样的结构也能让脉络清晰,便于定位新代码应放何处。您可以现在就创建这些文件夹并边走边填充,也可以跳过这步,按后续步骤逐个添加文件:

task-manager-categories/
├── db/
│ └── connect.js
├── lib/
│ └── taskDocument.js
├── middleware/
│ └── parseObjectId.js
├── routes/
│ └── tasks.js
├── public/
│ ├── index.html
│ ├── styles.css
│ └── app.js
├── .env
├── .env.example
├── .gitignore
├── package.json
└── server.js

步骤 2:连接 MongoDB

为了存取任务,我们首先需要将应用连接到 MongoDB。这需要使用连接字符串,它告诉 MongoDB 驱动如何连接到您的数据库。与其将其硬编码在代码中,不如放到环境变量文件中。这样既能避免敏感信息进入代码库,也便于在不同环境间切换。

创建一个 .env.example 文件来记录所需变量,再创建一个 .env 文件填写您的实际值,如下所示:

# Copy this file to `.env` and fill in real values.

# --- Local MongoDB ---
# Use this if you're running MongoDB locally
MONGO_URI=mongodb://127.0.0.1:27017

# --- MongoDB Atlas ---
# Replace <username>, <password>, and <cluster-url> with your actual values
# Example: mongodb+srv://user:pass@cluster0.abcde.mongodb.net/
# MONGO_URI=mongodb+srv://<username>:<password>@<cluster-url>/?retryWrites=true&w=majority

DB_NAME=taskmanager
PORT=3000

现在我们已有连接字符串,可以用它来设置 MongoDB 连接。新建一个 db/connect.js 文件。在这里初始化 MongoDB 客户端,并让应用其他部分可以复用:

const { MongoClient } = require("mongodb");

let client;
let db;

async function connectDB() {
  if (!process.env.MONGO_URI) {
    throw new Error("MONGO_URI is not set. Check your .env file.");
  }
  client = new MongoClient(process.env.MONGO_URI, {
    appName: "devrel-tutorial-javascript-crud-geeksforgeeks",
  });
  await client.connect();
  db = client.db(process.env.DB_NAME || "taskmanager");
  await db.collection("tasks").createIndex({ category: 1 });

  console.log(`MongoDB connected (db: ${db.databaseName})`);
}

function getTasksCollection() {
  if (!db) {
    throw new Error("Database not initialized. Call connectDB() first.");
  }
  return db.collection("tasks");
}

async function closeDB() {
  if (client) await client.close();
}

module.exports = { connectDB, getTasksCollection, closeDB };

该文件设置了一个可在整个应用中复用的 MongoDB 单例客户端。您只需创建一次,因为驱动已在内部处理了连接池。如果每次请求都新建客户端,一开始看似可行,但很快就会产生性能问题。

步骤 3:定义任务结构与校验

此时应用已能连接到 MongoDB。接下来在开始保存数据之前,需要先定义任务的具体结构。

在这里使用原生 MongoDB 驱动会和以往有些不同:不像 Mongoose 那样有独立的 schema 文件。相反,“schema” 就是您插入到数据库中的对象形状。一开始可能显得有些松散,但这其实很有帮助,因为您更贴近 MongoDB 的实际工作方式,逻辑也不被抽象层遮蔽。

但我们仍需要校验。因此我们不将逻辑分散在各个路由中,而是集中在一个地方,让所有操作遵循同一规则。新建一个名为 lib/taskDocument.js 的文件,并加入以下代码:

class ValidationError extends Error {
  constructor(message) {
    super(message);
    this.name = 'ValidationError';
  }
}

const ALLOWED_CATEGORIES = ['Work', 'Personal', 'Study', 'Other'];

function assertValidCategory(category) {
  if (!ALLOWED_CATEGORIES.includes(category)) {
    throw new ValidationError(
      `category must be one of: ${ALLOWED_CATEGORIES.join(', ')}`
    );
  }
}

function buildTaskDocument(body = {}) {
  if (!body.title || typeof body.title !== 'string' || !body.title.trim()) {
    throw new ValidationError('title is required and must be a non-empty string');
  }
  if (body.category != null) {
    assertValidCategory(body.category);
  }
  const now = new Date();
  return {
    title: body.title.trim(),
    description: typeof body.description === 'string' ? body.description.trim() : '',
    category: body.category != null ? body.category : 'Other',
    completed: Boolean(body.completed),
    createdAt: now,
    updatedAt: now
  };
}

function buildTaskUpdate(body = {}) {
  const updates = {};
  if (typeof body.title === 'string' && body.title.trim()) {
    updates.title = body.title.trim();
  }
  if (typeof body.description === 'string') {
    updates.description = body.description.trim();
  }
  if (body.category != null) {
    assertValidCategory(body.category);
    updates.category = body.category;
  }
  if (body.completed != null) {
    updates.completed = Boolean(body.completed);
  }
  if (Object.keys(updates).length === 0) {
    throw new ValidationError('no valid fields provided for update');
  }
  updates.updatedAt = new Date();
  return updates;
}

module.exports = {
  ALLOWED_CATEGORIES,
  buildTaskDocument,
  buildTaskUpdate,
  ValidationError
};

该文件相当于写入数据库的守门人。所有创建或更新请求都会经过这里,从而在一个位置统一实施规则。

它确保任务的结构始终正确、类别一致,并避免在查询数据时出现意外。自定义的 ValidationError 还能将无效输入与真实的服务器问题区分开,让您的 API 能做出恰当响应。有了这层保障,其余部分就能保持简洁。各路由只需专注于自身职责,因为它们接收到的数据已经是有效的。接下来我们将把路由接起来,并开始把任务保存到 MongoDB。

步骤 4:处理 ObjectId 与请求校验

既然已经知道任务的结构,接下来处理引用任务的方式。每当路由包含 :id 参数时,它会以普通字符串形式传入;但 MongoDB 需要的是 ObjectId。如果字符串格式错误,驱动会抛出不太友好的错误。我们不在每个路由重复处理,而是用中间件统一处理,这样所有端点行为一致。

为此,新建一个名为 middleware/parseObjectId.js 的文件,并加入以下内容:

const { ObjectId } = require('mongodb');

function parseObjectId(req, res, next) {
  const { id } = req.params;
  if (!ObjectId.isValid(id)) {
    return res.status(400).json({ error: 'invalid task id' });
  }
  req.taskId = new ObjectId(id);
  next();
}

module.exports = parseObjectId;

该中间件在路由处理器之前运行。它会校验 id 是否有效、将其转换为 ObjectId,并附加到 req.taskId。这样到达路由逻辑时,您始终在使用正确的 ObjectId,而无效输入会被尽早以清晰的 400 响应拒绝。接下来我们会将它接入路由,把所有部分串联起来。

步骤 5:构建任务路由(CRUD + 筛选)

到这一步,大部分繁重工作已完成。我们定义了有效任务的样子,也处理了 ID 的解析与校验。这意味着路由处理器只需专注做一件事:与数据库交互。

现在创建名为 routes/tasks.js 的文件,并加入以下代码,来搭建实际的 API 路由:

const express = require('express');
const { getTasksCollection } = require('../db/connect');
const {
  ALLOWED_CATEGORIES,
  buildTaskDocument,
  buildTaskUpdate,
  ValidationError
} = require('../lib/taskDocument');
const parseObjectId = require('../middleware/parseObjectId');

const router = express.Router();

// POST /tasks
router.post('/', async (req, res, next) => {
  try {
    const doc = buildTaskDocument(req.body);
    const result = await getTasksCollection().insertOne(doc);
    res.status(201).json({ _id: result.insertedId, ...doc });
  } catch (err) {
    if (err instanceof ValidationError) {
      return res.status(400).json({ error: err.message });
    }
    next(err);
  }
});

// GET /tasks (optionally ?category=Work)
router.get('/', async (req, res, next) => {
  try {
    const { category } = req.query;
    if (category && !ALLOWED_CATEGORIES.includes(category)) {
      return res.status(400).json({
        error: `category must be one of: ${ALLOWED_CATEGORIES.join(', ')}`
      });
    }
    const filter = category ? { category } : {};
    const tasks = await getTasksCollection()
      .find(filter)
      .sort({ createdAt: -1 })
      .toArray();
    res.json(tasks);
  } catch (err) { next(err); }
});

// GET /tasks/:id
router.get('/:id', parseObjectId, async (req, res, next) => {
  try {
    const task = await getTasksCollection().findOne({ _id: req.taskId });
    if (!task) return res.status(404).json({ error: 'task not found' });
    res.json(task);
  } catch (err) { next(err); }
});

// PUT /tasks/:id
router.put('/:id', parseObjectId, async (req, res, next) => {
  try {
    const updates = buildTaskUpdate(req.body);
    const result = await getTasksCollection().findOneAndUpdate(
      { _id: req.taskId },
      { $set: updates },
      { returnDocument: 'after' }
    );
    if (!result) return res.status(404).json({ error: 'task not found' });
    res.json(result);
  } catch (err) {
    if (err instanceof ValidationError) {
      return res.status(400).json({ error: err.message });
    }
    next(err);
  }
});

// DELETE /tasks/:id
router.delete('/:id', parseObjectId, async (req, res, next) => {
  try {
    const result = await getTasksCollection().deleteOne({ _id: req.taskId });
    if (result.deletedCount === 0) {
      return res.status(404).json({ error: 'task not found' });
    }
    res.status(204).end();
  } catch (err) { next(err); }
});

module.exports = router;

上面的每个路由都遵循同样的模式:从请求中获取输入,交给我们之前编写的辅助函数处理,再调用 MongoDB,并返回响应。由于校验与 ID 解析已就绪,这里的代码保持小而可预期。实际表现如下:

  • 在插入前,POST 路由会使用 buildTaskDocument 构建新任务
  • GET 路由可按类别筛选,并按最新优先排序返回结果
  • PUT 路由使用 buildTaskUpdate,从而安全一致地进行部分字段更新
  • DELETE 路由按已解析的 ObjectId 删除任务

此外,还有一些值得注意的细节:

  • find() 返回的是游标而非数组,因此我们在排序后再链式调用 .toArray() 来获取结果
  • findOneAndUpdate 配合 returnDocument: 'after' 能直接返回更新后的文档
  • 返回合适的 HTTP 状态码:创建用 201,删除用 204,必要时返回 400404
  • 任何意外错误都会传给 next(err),从而在一个地方统一处理,而不是在每个路由中重复

步骤 6:在服务器中将所有部分连线

此时各个部件都已就位:有了校验、整洁的路由与可用的数据库连接。现在只需把它们连在一起并启动服务器。创建一个 server.js 文件,作为应用入口:

require('dotenv').config();

const path = require('path');
const express = require('express');
const helmet = require('helmet');
const { connectDB, closeDB } = require('./db/connect');
const tasksRouter = require('./routes/tasks');

if (!process.env.MONGO_URI) {
  console.error('Fatal: MONGO_URI is not set. Copy .env.example to .env and fill it in.');
  process.exit(1);
}

const PORT = Number(process.env.PORT) || 3000;

const app = express();

app.use(helmet());
app.use(express.json({ limit: '100kb' }));
app.use(express.static(path.join(__dirname, 'public')));

app.get('/health', (req, res) => {
  res.json({ status: 'ok', service: 'task-manager-with-categories' });
});
app.use('/tasks', tasksRouter);

app.use((req, res) => {
  res.status(404).json({ error: 'not found' });
});

app.use((err, req, res, next) => {
  if (err.type === 'entity.too.large') {
    return res.status(413).json({ error: 'payload too large' });
  }
  if (err.type === 'entity.parse.failed') {
    return res.status(400).json({ error: 'invalid JSON body' });
  }
  console.error(err);
  res.status(500).json({ error: 'internal server error' });
});

async function start() {
  try {
    await connectDB();
    const server = app.listen(PORT, () => {
      console.log(`Server running on http://localhost:${PORT}`);
    });

    const shutdown = () => {
      console.log('\nShutting down gracefully...');
      server.close(async () => {
        await closeDB();
        process.exit(0);
      });
      setTimeout(() => process.exit(1), 10_000).unref();
    };
    process.on('SIGINT', shutdown);
    process.on('SIGTERM', shutdown);
  } catch (err) {
    console.error('Failed to start server:', err);
    process.exit(1);
  }
}

if (require.main === module) {
  start();
}

module.exports = { app, start };

该文件以整洁的方式把所有内容串起来。它会在运行前检查环境变量是否已设置,使用 helmet 提供基础安全防护,并限制传入的 JSON 体积,避免误接收巨大的负载。它还会服务于 public/ 文件夹,这样前端可以与后端共存于同一应用,无需额外服务器。

所有路由都挂载在 /tasks 下,未命中的请求会返回干净的 404。错误在一个地方统一处理,而不是在每个路由中重复,这让维护更容易。数据库连接会在服务器开始监听之前建立,因此不会在尚未就绪时接收请求;关闭流程也很优雅,能正确地关闭连接。

现在运行 npm run dev,确认一切正常。如果配置正确,您会看到类似输出:

MongoDB connected (db: taskmanager)
Server running on http://localhost:3000

至此,一切已连通并运行!我们已经有一个完整可用的 API,接下来进行测试。

步骤 7:测试 API 端点

在构建 UI 之前,先逐个确认每个端点能单独工作。这能大大简化调试,因为您能快速判断问题来自 API 还是前端。我们将使用 Postman(或任意 HTTP 客户端),按顺序运行几次请求,每一步都基于上一步创建的数据继续进行。

创建任务: 发送一个包含标题、描述和类别的 POST 请求。如果成功,API 将返回 201 Created,连同新任务及其生成的 _id

POST http://localhost:3000/tasks
Content-Type: application/json

{
  "title": "Write GeeksForGeeks article",
  "description": "First draft by Friday",
  "category": "Work"
}

再创建几个: 用不同的请求体重复发送 POST 请求,以便有足够数据用于测试列表与筛选。完成后,您会有一个可查询的小数据集。

{ "title": "Go for a run", "category": "Personal" }
{ "title": "Read MongoDB docs", "category": "Study" }
{ "title": "Buy groceries" } // category defaults to "Other"

列出所有任务: 返回全部任务,并按最新优先排序。

GET http://localhost:3000/tasks

按类别筛选: 这将仅返回 Work 类别的任务。先前添加的索引能在数据增长时保持该查询的速度。

GET http://localhost:3000/tasks?category=Work

更新任务: 使用刚刚创建的某个任务的 _id(可从 POST 响应或列表端点复制)。这会返回更新后的任务。您只需发送想要更改的字段,因此部分更新很简单。

PUT http://localhost:3000/tasks/<paste-task-id-here>
Content-Type: application/json

{ "completed": true }

删除任务: 使用欲删除任务的 _id。该请求返回 204 No Content。若再次尝试获取该任务,会得到 404 并显示 "task not found"

DELETE http://localhost:3000/tasks/<paste-task-id-here>

至此,您已确认所有路由均按预期运行。API 的职责已完成,接下来我们在其之上构建一个 UI。

步骤 8:添加一个与 API 交互的前端

Postman 很适合验证 API,但在实际网页上体验会更直观,因此我们为应用加入一些前端代码。

为避免在此粘贴大量 HTML 和 CSS,我已将代码放到 GitHub 仓库。前往那里,将 index.htmlstyles.cssfavicon.svg 的内容复制到项目根目录的 public/ 文件夹。保存后访问 localhost:3000。然后将下面的代码粘贴到 app.js 文件:

const form = document.getElementById('task-form');
const titleEl = document.getElementById('title');
const descEl = document.getElementById('description');
const catEl = document.getElementById('category');
const errEl = document.getElementById('form-error');
const listEl = document.getElementById('task-list');
const emptyEl = document.getElementById('empty');
const filterEl = document.getElementById('filter');
const countEl = document.getElementById('task-count');

async function api(method, path, body) {
  const res = await fetch(path, {
    method,
    headers: body ? { 'Content-Type': 'application/json' } : {},
    body: body ? JSON.stringify(body) : undefined
  });

  if (res.status === 204) return null;

  const data = await res.json();
  if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`);
  return data;
}

const fetchTasks = (category) =>
  api('GET', '/tasks' + (category ? `?category=${encodeURIComponent(category)}` : ''));

const createTask = (body) => api('POST', '/tasks', body);
const updateTask = (id, body) => api('PUT', `/tasks/${id}`, body);
const deleteTask = (id) => api('DELETE', `/tasks/${id}`);

function updateCount(tasks) {
  const active = tasks.filter((t) => !t.completed).length;
  const total = tasks.length;

  if (total === 0) {
    countEl.textContent = '';
    return;
  }

  const scope = filterEl.value ? ` in ${filterEl.value}` : '';
  countEl.textContent = `${total} tasks${scope} · ${active} active`;
}

function renderTask(task) {
  const li = document.createElement('li');
  li.className = 'task' + (task.completed ? ' completed' : '');

  const checkbox = document.createElement('input');
  checkbox.type = 'checkbox';
  checkbox.checked = task.completed;
  checkbox.addEventListener('change', () =>
    updateTask(task._id, { completed: checkbox.checked }).then(refresh)
  );

  const title = document.createElement('div');
  title.textContent = task.title;

  const badge = document.createElement('span');
  badge.textContent = task.category;

  const del = document.createElement('button');
  del.textContent = 'Delete';
  del.onclick = () => deleteTask(task._id).then(refresh);

  li.append(checkbox, title, badge, del);
  return li;
}

function render(tasks) {
  listEl.innerHTML = '';
  emptyEl.classList.toggle('hidden', tasks.length > 0);

  for (const task of tasks) {
    listEl.appendChild(renderTask(task));
  }

  updateCount(tasks);
}

async function refresh() {
  try {
    const tasks = await fetchTasks(filterEl.value);
    render(tasks);
  } catch (e) {
    errEl.textContent = e.message;
  }
}

form.addEventListener('submit', async (e) => {
  e.preventDefault();
  errEl.textContent = '';

  const title = titleEl.value.trim();
  if (!title) {
    errEl.textContent = 'Title is required.';
    return;
  }

  try {
    await createTask({
      title,
      description: descEl.value.trim(),
      category: catEl.value
    });

    form.reset();
    await refresh();
  } catch (e) {
    errEl.textContent = e.message;
  }
});

filterEl.addEventListener('change', refresh);

refresh();

这段 JavaScript 代码将一切与之前构建的 API 对接。每个函数都映射到一个路由,使前端易于理解。所有请求也都通过统一的 api() 助手,这样您只需在一个地方处理错误,而无需到处重复逻辑。

筛选通过传递形如 ?category=Work 的查询字符串来实现,这与您已在后端加入的逻辑直接对应。创建、更新或删除任务后,应用会再次获取最新列表,保持 UI 同步。任务计数虽是小细节,却能让应用在使用时更有反馈感。

现在使用 npm run dev 重启服务器,然后打开 http://localhost:3000 体验任务管理器。添加几个任务、更新它们、在类别间切换,并尝试删除。此时前后端已端到端联通并协同工作。

总结

恭喜!您已使用 Node.js、Express 和 MongoDB 从零构建了一个完整的任务管理器 API。您完成了数据校验、保持了路由简洁,并把所有部分连线成一个可用系统。更重要的是,您并未用抽象层遮蔽 MongoDB 的工作方式,而是贴近其本质。从这里出发,您可以继续打磨,让应用走向生产可用。

要点回顾

  • 使用中间件并集中化校验,能让代码保持整洁与可预期。
  • 原生 MongoDB 驱动在避免不必要抽象的同时,提供足够的可控性。
  • 结构良好的 API 易于扩展为生产级应用。

FAQs

构建 MongoDB API 一定要用 Mongoose 吗?

不需要。本教程使用原生 MongoDB 驱动,它提供更高的可控性且更轻量。如果您需要 schema 抽象,之后可以再加入 Mongoose。

没有 Schema 如何校验数据?

您可以将校验集中在辅助函数中(例如 buildTaskDocument),确保每个路由在写入数据库前都执行相同规则。

如果传入无效的 ObjectId 会怎样?

中间件会及早以 400 响应拒绝,从而避免 MongoDB 抛出令人困惑的错误。

主题
MongoDB

Top DataCamp Courses

Courses

Python 中的 MongoDB 入门

3小时
24.2K
学习使用 MongoDB 灵活处理和分析结构化数据。
查看详情Right Arrow
开始课程
查看更多Right Arrow