普通视图

发现新文章,点击刷新页面。
昨天 — 2026年5月8日首页

TDD实战-会议室冲突检测的红绿重构循环

作者 花满溪
2026年5月7日 17:49

本文通过一个真实的"三个会议交叉重叠"场景,完整展示测试驱动开发(TDD)的"红-绿-重构"循环。你将从零开始,亲眼见证测试如何驱动代码设计、暴露边缘情况、最终产出健壮的实现。

第一步:需求分析

业务场景

会议室预约系统允许用户将多个会议分配到同一间会议室。当会议时间发生重叠时,系统需要自动检测冲突并标红。本次要实现的策略是冲突均标红:只要两个会议的时间有重叠,两者都标记为冲突。

聚焦一个场景:三个会议交叉重叠

假设有三个会议,时间如下:

会议 开始时间 结束时间
Meeting 1 08:00 09:00
Meeting 2 08:30 09:30
Meeting 3 09:00 10:00

时间关系:Meeting 1 与 Meeting 2 重叠,Meeting 2 与 Meeting 3 重叠,但 Meeting 1 和 Meeting 3 不重叠——它们首尾相接(09:00 = 09:00)。

用时间轴线表示如下:

Meeting 1  ████████░░░░░░░░░░░░
Meeting 2  ░░░░████████░░░░░░░░
Meeting 3  ░░░░░░░░░███████████
           08:00    09:00    10:00

测试用例

对应该场景,我们列出了 7 个测试用例,覆盖三种操作类型:

操作类型一:添加会议

用例名称 操作 预期结果
1A 2A 3A => 1x 2x 3x 三个会议依次分配到 A 全部标红

操作类型二:移出会议

用例名称 操作 预期结果
-> 1 null => 2x 3x 移出 1 2、3 冲突,1 正常
-> 2 null => 移出 2 全部正常
-> 3 null => 1x 2x 移出 3 1、2 冲突,3 正常

操作类型三:移动到其他会议室

用例名称 操作 预期结果
-> 1 B => 2x 3x 1 移到 B 2、3 冲突,1 正常
-> 2 B => 2 移到 B 全部正常
-> 3 B => 1x 2x 3 移到 B 1、2 冲突,3 正常

关键洞察: "移出 2"这个用例——当"桥梁"会议被移除后,1 和 3 不重叠,冲突全部消失。这是整个场景中最关键的设计洞察。


第二步:搭建测试环境

首先安装 Vitest:

pnpm add -D vitest

package.json 中添加测试脚本:

{
  "scripts": {
    "test": "vitest",
  }
}

第三步:红——编写第一个失败的测试

TDD 的第一步是:先写一个会失败的测试,明确描述你期望的最小行为。

我们在 test/three-cross.spec.js 中开始:

import { describe, it, expect } from 'vitest'
import { handleRoomChange } from '../index'

describe('三个会议交叉重叠 - 冲突均标红', () => {

    it('无会议时,添加第一个会议应该无冲突', () => {
        const meeting1 = {
            id: 1, name: 'Meeting 1',
            start: '2021-01-01 08:00:00', end: '2021-01-01 09:00:00',
            date: '2021-01-01', isConflict: false, roomId: null
        }

        meeting1.roomId = 'A'
        handleRoomChange(meeting1)

        expect(meeting1.isConflict).toBe(false)
    })
})

运行测试:

pnpm test

输出:

 FAIL  test/three-cross.spec.js
  TypeError: handleRoomChange is not a function

测试失败,符合预期——因为 handleRoomChange 还不存在。这正是"红"阶段:我们需要创建这个模块。


第四步:绿——编写最小化代码通过测试

创建一个最简的方法,让它刚好通过当前测试:

// index.js
  handleRoomChange(meeting: Meeting): void {
    meeting.isConflict = false
  }

这个实现很简单——它无条件地把 isConflict 设为 false最小化代码意味着用最少的工作让当前测试变绿

运行测试:

✓ 三个会议交叉重叠 - 冲突均标红
  ✓ 无会议时,添加第一个会议应该无冲突

绿了。


第五步:红——添加第二个测试,暴露新行为

现在写第二个测试——添加两个时间重叠的会议,两者都应被标记为冲突:

// 在同一个 describe 块中添加
it('两个重叠的会议应都被标记为冲突', () => {
    const meeting1 = {
        id: 1, name: 'Meeting 1',
        start: '2021-01-01 08:00:00', end: '2021-01-01 09:00:00',
        date: '2021-01-01', isConflict: false, roomId: null
    }
    const meeting2 = {
        id: 2, name: 'Meeting 2',
        start: '2021-01-01 08:30:00', end: '2021-01-01 09:30:00',
        date: '2021-01-01', isConflict: false, roomId: null
    }

    meeting1.roomId = 'A'
    handleRoomChange(meeting1)
    meeting2.roomId = 'A'
    handleRoomChange(meeting2)

    expect(meeting1.isConflict).toBe(true)
    expect(meeting2.isConflict).toBe(true)
})

运行:

 FAIL  test/three-cross.spec.ts
  ✓ 无会议时,添加第一个会议应该无冲突
  ✗ 两个重叠的会议应都被标记为冲突
    AssertionError: expected false to be true

红。 现在的代码把所有会议都设为 false,显然不对。


第六步:绿——让第二个测试通过

现在需要真正实现冲突检测逻辑了。但我们仍然只做刚好够用的代码:

import dayjs from 'dayjs'

let meetingsMap = {}

function hasConflict(m1, m2) {
    return dayjs(m1.end).isAfter(dayjs(m2.start))
        && dayjs(m2.end).isAfter(dayjs(m1.start))
}
export function handleRoomChange(meeting) {
    const { date, roomId } = meeting;
    if (!meetingsMap[date]) meetingsMap[date] = {}
    if (!meetingsMap[date][roomId]) meetingsMap[date][roomId] = []
    const roomMeetings = meetingsMap[date][roomId]
    roomMeetings.push(meeting)

    // 暴力检测所有会议两两之间的冲突
    for (let i = 0; i < roomMeetings.length; i++) {
        for (let j = i + 1; j < roomMeetings.length; j++) {
            if (hasConflict(roomMeetings[i], roomMeetings[j])) {
                roomMeetings[i].isConflict = true
                roomMeetings[j].isConflict = true
            }
        }
    }
}

关键设计决策分析:

  1. 为什么用 meetingsMap 因为需要按日期和会议室维度持久化会议列表,才能做两两比较。
  2. 为什么用 dayjs 时间比较需要精确到秒,用字符串比较不可靠,dayjs 是项目已有的日期库。
  3. 冲突条件m1 未结束 && m2 已开始——经典的区间重叠判断。
  4. 嵌套循环:最简单直观的两两比较方式,O(n²) 复杂度,但对于一个会议室通常只有几个会议的场景完全够用。

运行测试:

✓ 无会议时,添加第一个会议应该无冲突
✓ 两个重叠的会议应都被标记为冲突

两个测试都通过了。


第七步:红——用核心场景驱动设计

现在到了我们真正关心的场景:三个会议交叉重叠。先标记所有会议:

it('1 A 2 A 3 A => 1 x 2 x 3 x', () => {
    const meetings = [
        { id: 1, name: 'Meeting 1', start: '2021-01-01 08:00', end: '2021-01-01 09:00',
          date: '2021-01-01', isConflict: false, roomId: null },
        { id: 2, name: 'Meeting 2', start: '2021-01-01 08:30', end: '2021-01-01 09:30',
          date: '2021-01-01', isConflict: false, roomId: null },
        { id: 3, name: 'Meeting 3', start: '2021-01-01 09:00', end: '2021-01-01 10:00',
          date: '2021-01-01', isConflict: false, roomId: null },
    ]

    meetings.forEach(m => { m.roomId = 'A'; 
    handleRoomChange(m)
    })

    expect(meetings[0].isConflict).toBe(true)  // 1-2 重叠
    expect(meetings[1].isConflict).toBe(true)  // 2-3 重叠
    expect(meetings[2].isConflict).toBe(true)  // 2-3 重叠
})

运行测试,通过了!因为嵌套循环已经处理了所有两两关系。

现在加上关键用例——移出会议

it('1 A 2 A 3 A -> 2 null =>', () => {
        const meetings = [
            {
                id: 1, name: 'Meeting 1', start: '2021-01-01 08:00', end: '2021-01-01 09:00',
                date: '2021-01-01', isConflict: false, roomId: null
            },
            {
                id: 2, name: 'Meeting 2', start: '2021-01-01 08:30', end: '2021-01-01 09:30',
                date: '2021-01-01', isConflict: false, roomId: null
            },
            {
                id: 3, name: 'Meeting 3', start: '2021-01-01 09:00', end: '2021-01-01 10:00',
                date: '2021-01-01', isConflict: false, roomId: null
            },
        ]

        meetings.forEach(m => { m.roomId = 'A'; handleRoomChange(m) })

        // 移出 Meeting 2
        meetings[1].prevRoomId = meetings[1].roomId
        meetings[1].roomId = null
        handleRoomChange(meetings[1])

        expect(meetings[0].isConflict).toBe(false)  // 1 和 3 不重叠
        expect(meetings[1].isConflict).toBe(false)  // 已移出
        expect(meetings[2].isConflict).toBe(false)  // 3 和 1 不重叠
    })

运行:

 FAIL  ✗ 1 A 2 A 3 A -> 2 null =>
  AssertionError: expected true to be false

红! 问题在于:当我们移出 Meeting 2 时,之前的 handleRoomChange 只会往列表里加会议,从没考虑过移除。Meeting 1 的 isConflict 仍然停留在 true,没有被重新计算。

现在,移出需求驱动我们设计一个新的能力。


第八步:绿——实现移除和重算

我们需要区分"添加"和"移除"两种情况。设计 handleRoomChange 的分支逻辑:

export function clearMap() {
    meetingsMap = {}
}

export function handleRoomChange(meeting) {
    const { prevRoomId, roomId, date } = meeting

    // 先从旧会议室移除
    if (prevRoomId) {
        if (!meetingsMap[date]) meetingsMap[date] = {}
        if (!meetingsMap[date][prevRoomId]) meetingsMap[date][prevRoomId] = []
        const roomMeetings = meetingsMap[date][prevRoomId]
        const index = roomMeetings.findIndex(m => m.id === meeting.id)
        if (index !== -1) roomMeetings.splice(index, 1)

        const conflictIds = new Set()
        for (let i = 0; i < roomMeetings.length; i++) {
            for (let j = i + 1; j < roomMeetings.length; j++) {
                if (hasConflict(roomMeetings[i], roomMeetings[j])) {
                    conflictIds.add(roomMeetings[i].id)
                    conflictIds.add(roomMeetings[j].id)
                }
            }
        }
        roomMeetings.forEach(m => {
            m.isConflict = conflictIds.has(m.id)
        })
    }

    // 再添加到新会议室
    if (roomId) {
        if (!meetingsMap[date]) meetingsMap[date] = {}
        if (!meetingsMap[date][roomId]) meetingsMap[date][roomId] = []
        const roomMeetings = meetingsMap[date][roomId]
        roomMeetings.push(meeting)

        const conflictIds = new Set()
        for (let i = 0; i < roomMeetings.length; i++) {
            for (let j = i + 1; j < roomMeetings.length; j++) {
                if (hasConflict(roomMeetings[i], roomMeetings[j])) {
                    conflictIds.add(roomMeetings[i].id)
                    conflictIds.add(roomMeetings[j].id)
                }
            }
        }
        roomMeetings.forEach(m => {
            m.isConflict = conflictIds.has(m.id)
        })
    } else {
        meeting.isConflict = false
    }
}

这个步骤:

  • 每个测试用例都需要将 meetingsMap 重置。
  • 每次添加或移除会议后,都重新计算整个会议室的状态。虽然看起来做了重复计算,但胜在简单且正确——会议室里的会议数量极少,性能不是问题,正确性才是。

运行测试:

✓ 两个重叠的会议应都被标记为冲突
✓ 1 A 2 A 3 A => 1 x 2 x 3 x
✓ 1 A 2 A 3 A -> 2 null =>

所有测试通过。


第九步:红——继续添加剩余场景

测完核心逻辑,快速补全剩余用例:

it('1 A 2 A 3 A -> 1 null => 2 x 3 x', () => {
    // 移出 1 → 2 和 3 仍然冲突
    ...
    expect(meetings[0].isConflict).toBe(false)
    expect(meetings[1].isConflict).toBe(true)
    expect(meetings[2].isConflict).toBe(true)
})

it('1 A 2 A 3 A -> 3 null => 1 x 2 x', () => {
    // 移出 3 → 1 和 2 仍然冲突
    ...
    expect(meetings[0].isConflict).toBe(true)
    expect(meetings[1].isConflict).toBe(true)
    expect(meetings[2].isConflict).toBe(false)
})

it('1 A 2 A 3 A -> 1 B => 2 x 3 x', () => {
    // 1 移到 B → A 会议室只剩 2 和 3,它们冲突
    // 1 在 B 会议室只有自己,无冲突
    ...
    expect(meetings[0].isConflict).toBe(false)
    expect(meetings[1].isConflict).toBe(true)
    expect(meetings[2].isConflict).toBe(true)
})

由于 handleRoomChange 已经同时处理了移除和添加,这些用例都直接通过:

1 A 2 A 3 A -> 1 null => 2 x 3 x
✓ 1 A 2 A 3 A -> 2 null =>
✓ 1 A 2 A 3 A -> 3 null => 1 x 2 x
✓ 1 A 2 A 3 A -> 1 B => 2 x 3 x
✓ 1 A 2 A 3 A -> 2 B =>
✓ 1 A 2 A 3 A -> 3 B => 1 x 2 x

7 个测试,全部通过。


第十步:重构——在安全网下优化代码

测试全部通过后,我们站在一个安全的位置审视代码。

问题 1:面向过程的代码重构

import dayjs from 'dayjs'

export default class AllConflictManager {
    meetingsMap = {}

    clear() {
        this.meetingsMap = {}
    }

    handleRoomChange(meeting) {
        const { prevRoomId, roomId } = meeting
        if (prevRoomId) {
            this.removeMeetingFromRoom(meeting, prevRoomId)
        }
        if (roomId) {
            this.addMeetingToRoom(meeting, roomId)
        } else {
            meeting.isConflict = false
        }
    }

    addMeetingToRoom(meeting, roomId) {
        const { date } = meeting
        const roomMeetings = this.getRoomMeetings(date, roomId)
        roomMeetings.push(meeting)
        const conflictIds = this.findAllConflicts(roomMeetings)
        this.updateConflictStatus(roomMeetings, conflictIds)
    }

    getRoomMeetings(date, roomId) {
        if (!this.meetingsMap[date]) this.meetingsMap[date] = {}
        if (!this.meetingsMap[date][roomId]) this.meetingsMap[date][roomId] = []
        return this.meetingsMap[date][roomId]
    }

    removeMeetingFromRoom(meeting, roomId) {
        const { date } = meeting
        const roomMeetings = this.getRoomMeetings(date, roomId)
        const index = roomMeetings.findIndex(m => m.id === meeting.id)
        if (index !== -1) roomMeetings.splice(index, 1)
        const conflictIds = this.findAllConflicts(roomMeetings)
        this.updateConflictStatus(roomMeetings, conflictIds)
    }

    findAllConflicts(roomMeetings) {
        const conflictIds = new Set()
        for (let i = 0; i < roomMeetings.length; i++) {
            for (let j = i + 1; j < roomMeetings.length; j++) {
                if (this.hasConflict(roomMeetings[i], roomMeetings[j])) {
                    conflictIds.add(roomMeetings[i].id)
                    conflictIds.add(roomMeetings[j].id)
                }
            }
        }
        return conflictIds
    }

    updateConflictStatus(roomMeetings, conflictIds) {
        roomMeetings.forEach(m => {
            m.isConflict = conflictIds.has(m.id)
        })
    }

    hasConflict(m1, m2) {
        return dayjs(m1.end).isAfter(dayjs(m2.start)) && dayjs(m2.end).isAfter(dayjs(m1.start))
    }
}

问题 2:测试中重复的测试数据

每个测试用例都重复定义了三个 meeting 对象。提取到公共数据:

// data/three-cross.json
[
    { "id": 1, "name": "Meeting 1", "start": "2021-01-01 08:00", "end": "2021-01-01 09:00",
      "date": "2021-01-01", "isConflict": false, "roomId": null, "prevRoomId": null },
    { "id": 2, "name": "Meeting 2", "start": "2021-01-01 08:30", "end": "2021-01-01 09:30",
      "date": "2021-01-01", "isConflict": false, "roomId": null, "prevRoomId": null },
    { "id": 3, "name": "Meeting 3", "start": "2021-01-01 09:00", "end": "2021-01-01 10:00",
      "date": "2021-01-01", "isConflict": false, "roomId": null, "prevRoomId": null }
]

问题 3:Manager 实例创建和重置重复

提取到 test-utils.js

import AllConflictManager from '../index'

const manager = new AllConflictManager()

export function resetMeetings(meetings) {
    meetings.forEach(m => {
        m.isConflict = false
        m.prevRoomId = null
        m.roomId = null
    })
    manager.clear()
}

export function assignToRoom(meeting, roomId) {
    meeting.roomId = roomId
    manager.handleRoomChange(meeting)
}

export function removeMeetingFromRoom(meeting) {
    meeting.prevRoomId = meeting.roomId
    meeting.roomId = null
    manager.handleRoomChange(meeting)
}

export function moveMeeting(meeting, newRoomId) {
    meeting.prevRoomId = meeting.roomId
    meeting.roomId = newRoomId
    manager.handleRoomChange(meeting)
}

问题 4:测试代码精简

重构后的测试文件干净很多:

import { describe, it, expect, beforeEach } from 'vitest'
import { resetMeetings, assignToRoom, removeMeetingFromRoom, moveMeeting } from './test-utils'
import meetings from '../data/three-cross.json';

describe('三个会议交叉重叠 - 冲突均标红', () => {
    beforeEach(() => {
        resetMeetings(meetings)
        assignToRoom(meetings[0], 'A')
        assignToRoom(meetings[1], 'A')
        assignToRoom(meetings[2], 'A')
    })

    it('1 A 2 A 3 A => 1 x 2 x 3 x', () => {
        expect(meetings[0].isConflict).toBe(true)
        expect(meetings[1].isConflict).toBe(true)
        expect(meetings[2].isConflict).toBe(true)
    })
    // ...其余用例
})

重构后运行测试:

 Test Files  1 passed (1)
      Tests  7 passed (7)

全部通过。 我们可以在安全网的保护下自信地说:重构没有破坏任何功能。


最终代码全景

源码骨架

AllConflictManager
├── meetingsMap            ← 按 date → roomId 的二维存储
├── clear()                ← 重置状态
├── handleRoomChange()     ← 入口:先移除旧房间,再添加新房间
├── addMeetingToRoom()     ← 添加 + 重算冲突
├── removeMeetingFromRoom()← 移除 + 重算冲突
├── findAllConflicts()     ← O(n²) 两两比较
├── updateConflictStatus() ← 批量更新标记
└── hasConflict()          ← 区间重叠判断

测试覆盖

7 个用例覆盖 3 类操作:
  ┌─ 添加会议(1 个)
  ├─ 移出会议(3 个)
  └─ 移动会议(3 个)

回顾:TDD 如何驱动了设计

让我们回顾整个过程中,测试是如何驱动设计决策的:

测试驱动了接口设计

第一个测试只验证"添加无冲突会议",导致最初实现只是一个空壳方法。当第二个测试需要"检测重叠"时,才被迫引入 meetingsMap 存储和 hasConflict 方法。

测试驱动了移除逻辑

移出会议的测试迫使我们设计 handleRoomChange 的分支结构(先移除旧房间,再添加新房间)。如果只是按直觉写"添加"逻辑,永远不会考虑到移除后的状态重算。有了移除逻辑后,发现测试用例仍无法通过,meetingsMap 存储需重置。

测试驱动了冲突重算策略

当移出 Meeting 2 后,Meeting 1 的 isConflict 仍然为 true——这个失败迫使我们意识到:每次状态变更后都需要完整重算,而不是增量更新recalculateConflicts 方法从这里诞生。

测试驱动了数据抽取

测试代码中的重复数据——7 个用例写了 21 个 meeting 对象——驱动我们将测试数据提到 JSON 文件。这既是重构,也是一种设计信号:测试数据应该与测试逻辑分离。


总结:TDD 的节奏感

回顾完整的红-绿-重构循环:

RED (写测试)      →  1. 定义行为期望
                     2. 确认当前代码做不到
                     ↓
GREEN (写代码)    →  3. 用最直接的方式让测试通过
                     4. 确信结果正确
                     ↓
REFACTOR (优化)   →  5. 在测试保护下改进代码质量
                     6. 测试依然通过
                     ↓
(回到第 1 步,覆盖下一个场景)

这个循环的核心价值在于节奏感:每一步都有明确的目标,每一步的结果都可以立即验证。没有模糊区间,没有"感觉应该没问题"——只有绿色的通过和红色的失败。

当你习惯了这个节奏后,你会发现编码的过程不再是"写完再看",而是一个持续获得正向反馈的过程。每个测试从红变绿的那一刻,都在告诉你:你又推进了一步。

你不需要一次设计出完美的架构。你只需要写一个会失败的测试,然后让它通过。然后重复。最终,好的设计会自己浮现出来。

测试用例

两个重叠的会议

会议 开始时间 结束时间
Meeting 1 08:00 09:00
Meeting 2 08:30 10:00
时间关系:Meeting 1 和 Meeting 2 互相重叠
用例名称 操作步骤 预期结果
1 A 2 A => 2 x Meeting 1 分配到 A,Meeting 2 分配到 A Meeting 1 正常,Meeting 2 冲突
1 A 2 A -> 1 null => 上述基础上,Meeting 1 移出 两个会议都正常
1 A 2 A -> 1 B => 上述基础上,Meeting 1 移到 B 两个会议都正常
1 A 2 A -> 2 null => 上述基础上,Meeting 2 移出 两个会议都正常
1 A 2 A -> 2 B => 上述基础上,Meeting 2 移到 B 两个会议都正常

三个全重叠的会议

会议 开始时间 结束时间
Meeting 1 08:00 09:30
Meeting 2 08:30 09:30
Meeting 3 09:00 10:00
时间关系:三个会议两两之间都互相重叠
用例名称 操作步骤 预期结果
1 A 2 A 3 A => 1 x 2 x 3 x 初始状态 Meeting 1 冲突,Meeting 2 冲突,Meeting 3 冲突
1 A 2 A 3 A -> 1 null => 2 x 3 x Meeting 1 移出 Meeting 1 正常,Meeting 2 冲突,Meeting 3 冲突
1 A 2 A 3 A -> 2 null => 1x 3 x Meeting 2 移出 Meeting 1 冲突,Meeting 2 正常,Meeting 3 冲突
1 A 2 A 3 A -> 3 null => 1 x 2 x Meeting 3 移出 Meeting 1 冲突,Meeting 2 冲突,Meeting 3 正常
1 A 2 A 3 A -> 1 B => 2 x 3 x Meeting 1 移到 B Meeting 1 正常,Meeting 2 冲突,Meeting 3 冲突
1 A 2 A 3 A -> 2 B => 1 x 3 x Meeting 2 移到 B Meeting 1 冲突,Meeting 2 正常,Meeting 3 冲突
1 A 2 A 3 A -> 3 B => 1 x 2 x Meeting 3 移到 B Meeting 1 冲突,Meeting 2 冲突,Meeting 3 正常

三个会议 - 交叉重叠

会议 开始时间 结束时间
Meeting 1 08:00 09:00
Meeting 2 08:30 09:30
Meeting 3 09:00 10:00

时间关系:Meeting 1 与 Meeting 2 重叠,Meeting 2 与 Meeting 3 重叠,Meeting 1 与 Meeting 3 不重叠(首尾相接)

用例名称 操作步骤 预期结果
1 A 2 A 3 A => 1 x 2 x 3 x 初始状态 Meeting 1 冲突,Meeting 2 冲突,Meeting 3 冲突
1 A 2 A 3 A -> 1 null => 2 x 3 x Meeting 1 移出 Meeting 1 正常,Meeting 2 冲突,Meeting 3 冲突
1 A 2 A 3 A -> 2 null => Meeting 2 移出 三个会议都正常
1 A 2 A 3 A -> 3 null => 1 x 2 x Meeting 3 移出 Meeting 1 冲突,Meeting 2 冲突,Meeting 3 正常
1 A 2 A 3 A -> 1 B => 2 x 3 x Meeting 1 移到 B Meeting 1 正常,Meeting 2 冲突,Meeting 3 冲突
1 A 2 A 3 A -> 2 B => Meeting 2 移到 B 三个会议都正常
1 A 2 A 3 A -> 3 B => 1 x 2 x Meeting 3 移到 B Meeting 1 冲突,Meeting 2 冲突,Meeting 3 正常

四个会议 - 链式重叠

会议 开始时间 结束时间
Meeting 1 08:00 09:00
Meeting 2 08:30 09:30
Meeting 3 09:00 10:00
Meeting 4 09:30 10:30

时间关系:链式重叠,1-2 重叠,2-3 重叠,3-4 重叠,1-3、1-4、2-4 不重叠

用例名称 操作步骤 预期结果
1 A 2 A 3 A 4 A => 1 x 2 x 3 x 4 x 初始状态 Meeting 1、 2、3、4 冲突
1 A 2 A 3 A 4 A -> 1 null => 2 x 3 x 4 x Meeting 1 移出 Meeting 1 正常,Meeting 2、3 冲突,Meeting 4 正常
1 A 2 A 3 A 4 A -> 2 null => 3 x 4 x Meeting 2 移出 Meeting 1、2 正常,Meeting 3、4冲突
1 A 2 A 3 A 4 A -> 3 null => 1 x 2 x Meeting 3 移出 Meeting 3、4 正常,Meeting 1、2 冲突
1 A 2 A 3 A 4 A -> 4 null => 1 x 2 x 3 x Meeting 4 移出 Meeting 4 正常,Meeting 1、2、3 冲突
1 A 2 A 3 A 4 A -> 1 B => 2 x 3 x 4 x Meeting 1 移到 B Meeting 1 正常,Meeting 2、3、4 冲突
1 A 2 A 3 A 4 A -> 2 B => 3 x 4 x Meeting 2 移到 B Meeting 1、2 正常,Meeting 3、4 冲突
1 A 2 A 3 A 4 A -> 3 B => 1 x 2 x Meeting 3 移到 B Meeting 3、4 正常,Meeting 1、2 冲突
1 A 2 A 3 A 4 A -> 4 B => 1 x 2 x 3 x Meeting 4 移到 B Meeting4 正常,Meeting 1、2、3 冲突
昨天以前首页

HTTP 请求的五种传参方式

作者 花满溪
2026年4月28日 18:25

在 Web 开发中,客户端向服务端传递数据有多种方式。本文介绍五种常见的传参方式,以及它们的适用场景。

为了让读者更好地理解每种传参方式,文中示例均使用 Express 搭建的本地服务器进行演示。你可以在本地启动服务后,通过浏览器或 Postman 实际发送请求,观察服务端的接收效果。

场景对比

传参方式 适用场景 数据类型 大小限制
路径参数 获取确定性资源,如用户信息、商品详情 简单字符串 无限制
查询参数 搜索、过滤、分页 短数据 受 URL 长度限制
x-www-form-urlencoded 传统表单提交 简单键值对 较小
application/json RESTful API 调用 复杂嵌套结构 较大
multipart/form-data 文件上传、混合数据提交 文件 + 表单

一、链接传参 - 路径参数

在 URL 路径中传递参数。例如:/users/{id}

// 示例:GET 请求
http://localhost:3000/users/123

服务端通过路径占位符获取参数 id=123


二、链接传参 - 查询参数

在 URL 查询字符串中传递参数。查询参数是问号 ? 后的键值对,多个参数用 & 连接。

// 示例:GET 请求
http://localhost:3000/search?keyword=node&page=2

服务端获取 keyword=nodepage=2


三、请求体传参 - application/x-www-form-urlencoded

POST 请求,请求头 Content-Type 设置为 application/x-www-form-urlencoded

HTML 表单使用 method="post" 时,默认使用这种编码方式。

<!DOCTYPE html>
<html>
<body>
    <form action="http://localhost:3000/register" method="post">
        <input type="text" name="username">
        <input type="password" name="password">
        <button>submit</button>
    </form>
</body>
</html>

提交后,浏览器将表单数据编码为 username=admin&password=123456,发送到后端。


四、请求体传参 - application/json

POST 请求,请求头 Content-Type 设置为 application/json

这种方式需要使用 JavaScript(如 fetch)发送请求。

<!DOCTYPE html>
<html>
<body>
    <script>
        fetch('http://localhost:3000/login', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json'
            },
            body: JSON.stringify({
                username: "admin",
                password: "123456",
            })
        });
    </script>
</body>
</html>

请求体内容为 JSON 字符串:{"username":"admin","password":"123456"}


五、请求体传参 - multipart/form-data

POST 请求,请求头 Content-Type 设置为 multipart/form-data

这种方式通常用于文件上传,也可以同时提交普通表单字段。

<!DOCTYPE html>
<html>
<body>
    <form action="http://localhost:3000/upload" method="post" enctype="multipart/form-data">
        <input type="file" accept=".jpg" name="avatar">
        <input type="text" name="username">
        <input type="password" name="password">
        <button>submit</button>
    </form>
</body>
</html>

浏览器自动生成请求头:Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryO1HGmt23sYZeHuMf

boundary 后面的字符串是分隔符,用于区分不同的字段。

请求体格式示例:

------WebKitFormBoundaryO1HGmt23sYZeHuMf
Content-Disposition: form-data; name="avatar"; filename="avatar.jpg"
Content-Type: image/jpeg

[文件二进制数据]
------WebKitFormBoundaryO1HGmt23sYZeHuMf
Content-Disposition: form-data; name="username"

admin
------WebKitFormBoundaryO1HGmt23sYZeHuMf
Content-Disposition: form-data; name="password"

123456
------WebKitFormBoundaryO1HGmt23sYZeHuMf--

每个字段由 boundary 分隔,字段名和字段值之间有一个空行。


六、本地服务器

以下是一个 Express 服务器实现,集成了上述五种传参方式的处理逻辑。启动服务后,你可以配合前文的 HTML 页面或 Postman 进行测试。

环境准备

npm init -y
npm i express cors multer

服务端代码

const express = require('express');
const cors = require('cors');
const multer = require('multer');
const app = express();
const port = 3000;
app.use(cors());

// 1. 处理路由参数(URL 路径中的参数)
// 示例:GET /users/123
app.get('/users/:id', (req, res) => {
    const userId = req.params.id;
    res.json({
        message: `获取用户信息`,
        userId: userId
    });
});

// 2. 处理 URL 查询参数(Query String)
// 示例:GET /search?keyword=node&page=2
app.get('/search', (req, res) => {
    const { keyword, page } = req.query;
    res.json({
        message: '查询参数已接收',
        keyword: keyword,
        page: page || 1
    });
});

// 3. 处理表单提交的 URL 编码参数
app.use(express.urlencoded({ extended: true }));

// 示例:POST /register,表单数据
app.post('/register', (req, res) => {
    const { username, password } = req.body;
    res.json({
        username,
        password,
    });
});

// 4. 处理 POST 请求的 JSON 参数(请求体)
// 需要配置 JSON 解析中间件
app.use(express.json());

// 示例:POST /login,请求体 {"username": "admin", "password": "123345"}
app.post('/login', (req, res) => {
    const { username, password } = req.body;
    res.json({
        username,
        password,
    });
});

// 5.处理单文件上传
// 配置 multer(内存存储,文件以 Buffer 形式存在)
const upload = multer({ storage: multer.memoryStorage() });

app.post('/upload', upload.single('avatar'), (req, res) => {
    // req.file 包含上传的文件信息
    console.log('文件信息:', req.file);
    console.log('文本字段:', req.body);

    res.json({
        message: '文件上传成功',
        filename: req.file.originalname,
        size: req.file.size,
        mimetype: req.file.mimetype
    });
});

// 启动服务
app.listen(port, () => {
    console.log(`start:http://localhost:${port}`);
});

启动与测试

运行 node server.js 启动服务后,可以:

  • 访问前文的 HTML 页面,直接在浏览器中发送请求
  • 使用 Postman 或 curl 手动构造请求,观察响应结果
  • 查看终端日志,确认服务端是否正确接收参数
❌
❌