手写实现课程表调度算法,搞定前端排课难题

发布时间:2026/9/23 8:39:46
手写实现课程表调度算法,搞定前端排课难题 手写实现课程表调度算法,搞定前端排课难题 配置环境就卡半天,后端接口返回的 JSON 数据一团乱麻,前端渲染出来的课程表要么重叠,要么空白。别急,这锅不能全甩给 CSS 布局。真正的坑,在于课程表数据背后的时间冲突检测与网格映射逻辑。很多初学者直接套用现成组件,一旦遇到跨天、跨周或复杂的选修课组合,立马报错。今天我们就拆解一套手写实现的方案,不依赖重型 UI 库,从数据结构底层解决这个老大难问题。 入口定位:为什么现成组件不够用 在正式写代码前,我们先看一个典型场景。某高校教务系统需要展示“周视图”课程表。输入数据是后端推送的数组,每个对象包含 courseId、startDay、startPeriod、duration(持续节数)、room 等字段。 看似简单的数据,直接映射到 DOM 节点时,极易出现两个问题:时间轴错位:上午第1节和下午第1节在物理时间上是断层的,但逻辑索引可能连续。 空间冲突:同一教室在不同时间段被占用,或者同一教师在同一时间有两门课。很多开源库(如 FullCalendar)虽然强大,但其内部状态机极其复杂,为了一个自定义的“课程合并”功能,往往需要重写整个渲染管线。相比之下,手写实现一个轻量级的调度器,不仅能精准控制渲染粒度,还能深入理解时间片分割的底层逻辑。这也是面试中高频考察的“算法与前端结合”场景。 核心片段:时间片映射与冲突检测 我们要解决的核心矛盾,是将离散的“课程事件”映射到连续的“网格单元”上。这里有两个关键算法:时间索引转换 和 冲突区间合并。 1. 时间索引标准化 高校课表通常以“节”为单位。假设每天8节课,分上下午。我们需要将 startPeriod 和 duration 转换为全局唯一的网格索引。 // 核心逻辑:将课程时间转换为全局网格坐标 // 参数:startDay (1-7), startPeriod (1-8), duration (连续节数) function mapCourseToGrid(course) {const { startDay, startPeriod, duration } = course;const gridCells = [];// 遍历该课程占据的每一节课for (let i = 0; i duration; i++) {// 计算当前是第几天const currentDay = startDay + Math.floor((startPeriod + i - 1) / 8);// 计算当前是当日的第几节 (取模运算得到日内偏移)const currentPeriod = ((startPeriod + i - 1) % 8) + 1;// 生成全局唯一 ID: Day-Period// 这个 ID 是后续 DOM 定位和冲突检测的唯一依据gridCells.push({day: currentDay,period: currentPeriod,globalId: `${currentDay}-${currentPeriod}`,courseId: course.courseId});}return gridCells; }逐行解析:Math.floor((startPeriod + i - 1) / 8):这是处理跨天逻辑的关键。如果 startPeriod 是 7,duration 是 2,那么第一节课在第 startDay 天,第二节课应该落在 startDay + 1 天。这里的整除操作实现了日期的进位。 (startPeriod + i - 1) % 8:处理日内循环。如果跨天了,新的日子从第1节开始,取模操作保证了 period 始终在 1-8 之间。 globalId:这是前端渲染的锚点。无论课程多么复杂,最终都拆解为一个个独立的 (day, period) 坐标对。2. 冲突检测与合并 有了网格坐标,冲突检测就变成了集合运算。我们需要检查同一个 globalId 是否被多个 courseId 占用。 // 检测课程表冲突 // 参数:courses (原始课程数组) function detectConflicts(courses) {const gridMap = new Map(); // 使用 Map 存储 全局ID - 课程ID 列表const conflicts = [];// 第一遍:填充网格courses.forEach(course = {const cells = mapCourseToGrid(course);cells.forEach(cell = {if (!gridMap.has(cell.globalId)) {gridMap.set(cell.globalId, []);}gridMap.get(cell.globalId).push(course.courseId);});});// 第二遍:查找冲突gridMap.forEach((courseIds, globalId) = {if (courseIds.length 1) {conflicts.push({location: globalId,conflictingCourses: courseIds});}});return conflicts; }设计思想解析:空间换时间:我们使用 Map 结构,将 O(N^2) 的两两比较优化为 O(N) 的哈希查找。对于几百门课程的场景,性能差异巨大。 解耦渲染与逻辑:detectConflicts 函数不关心 UI,只返回冲突数据。前端可以根据这个数组,高亮显示冲突单元格,或者弹出提示框。这种纯函数式设计,使得算法部分可以独立单元测试。设计思想:从“渲染视图”到“数据模型” 很多开发者容易陷入误区,认为课程表是一个“视觉问题”,其实它是一个“数据建模问题”。 1. 分离关注点 传统的做法是直接在 Vue/React 组件里写 v-for 循环渲染表格。但课程表有特殊性:单元格合并:一门3节课的课程,在视觉上占据3个格子,但逻辑上是一个实体。 样式差异化:必修课和选修课颜色不同,教师办公室和教学楼背景不同。如果直接在模板里处理这些逻辑,代码会充满 if-else。正确的做法是,在渲染前,先通过手写实现的算法层,将原始数据加工成“渲染指令”。例如,算法层输出一个对象:{ type: 'span', rowSpan: 3, colSpan: 1, content: '高等数学' }。视图层只负责把 rowSpan 传给 td 标签。 2. 状态管理的陷阱 课程表数据通常是静态的,但用户行为是动态的(拖拽换教室、点击查看详情)。如果使用 Redux 等重型状态库,每次点击都会触发全局重绘,导致卡顿。 建议采用局部状态 + 不可变数据策略。将课程表数据视为只读的 props,用户的交互状态(如选中的课程 ID)单独维护。这样,当用户点击某个单元格时,只有该单元格的高亮样式变化,其余 500+ 个单元格无需重新计算 DOM diff。 3. 可扩展性设计 参考 FullCalendar 官方源码仓库 的架构,它将“事件解析”、“布局计算”、“DOM 渲染”分为三个独立模块。我们在手写实现时也应遵循此原则。例如,未来如果要支持“月视图”,只需替换“布局计算”模块中的算法,而无需改动事件解析逻辑。这种模块化思维,是区分初级和资深前端的关键。 手写简化版:React 组件实战 下面给出一个极简的 React 组件实现,展示如何将上述算法融入业务代码。 import React, { useMemo } from 'react';// 假设 courses 是后端返回的原始数据 const CourseTable = ({ courses }) = {// 使用 useMemo 缓存计算结果,避免每次渲染都重新计算冲突和网格const { gridData, conflicts } = useMemo(() = {// 1. 构建网格数据const cells = new Map();courses.forEach(c = {const mapped = mapCourseToGrid(c);mapped.forEach(m = {if (!cells.has(m.globalId)) cells.set(m.globalId, []);cells.get(m.globalId).push(c);});});// 2. 找出冲突(复用之前的逻辑,此处简化)const conf = [];cells.forEach((list, id) = {if (list.length 1) conf.push(id);});// 3. 生成渲染行数据const rows = [];for (let day = 1; day = 7; day++) {const row = [];for (let period = 1; period = 8; period++) {const id = `${day}-${period}`;const cellCourses = cells.get(id) || [];row.push({id,courses: cellCourses,isConflict: conf.includes(id)});}rows.push(row);}return { gridData: rows, conflicts: conf };}, [courses]);return (table className=course-gridtheadtrth时间/th{Array.from({length: 8}, (_, i) = th key={i}第{i+1}节/th)}/tr/theadtbody{gridData.map((row, dayIdx) = (tr key={dayIdx}td className=day-label第{dayIdx+1}天/td{row.map(cell = (td key={cell.id} className={cell.isConflict ? 'conflict' : 'normal'}onClick={() = handleCellClick(cell)}{cell.courses.map(c = (div key={c.courseId} className=course-tag{c.courseName}/div))}/td))}/tr))}/tbody/table); };代码亮点:useMemo 依赖数组:只有 courses 变化时才重新计算网格。这是性能优化的核心。 冲突类名:isConflict 直接映射到 CSS class,通过样式区分正常与冲突状态,逻辑与表现彻底分离。 扁平化数据:gridData 是一个二维数组,直接对应表格的行和列。这种结构对于 map 渲染极其友好,避免了嵌套循环带来的性能损耗。应用场景与进阶避坑 1. 大数据量下的性能瓶颈 如果课程表超过 1000 门课程(如全校所有班级),上述 O(N) 的算法依然有效,但 DOM 渲染会成为瓶颈。 解决方案:引入虚拟滚动。只渲染可视区域内的行。由于我们的数据已经结构化为 gridData,实现虚拟滚动只需截取数组片段即可,无需重新计算逻辑。 2. 跨学期与历史数据 课程表往往涉及历史查询。建议将 semester 作为一级缓存 Key。 const cacheKey = `${semester}-${viewMode}`; if (cache.has(cacheKey)) return cache.get(cacheKey);这样可以避免用户切换视图时重复计算。 3. 无障碍访问 (A11y) 纯 div 布局的课程表对屏幕阅读器不友好。务必使用语义化的 table 标签,并在冲突单元格上添加 aria-label=冲突:高等数学与线性代数时间重叠。这不仅是技术细节,更是产品责任。 4. 测试策略 针对手写实现的算法,必须编写单元测试。用例1:单节课课程,无冲突。 用例2:跨天课程(第7节+第1节),验证日期进位。 用例3:三门课同一时间,验证冲突列表长度。 用例4:空数组,验证不报错。结尾互动 从环境配置到算法落地,课程表看似简单,实则是对数据结构、时间逻辑和前端工程化能力的综合考验。通过手写实现,我们不仅解决了一个 UI 问题,更构建了一套可复用的时间调度引擎。 在实际项目中,你更倾向于使用现成的日历组件进行二次封装,还是像本文一样从头手写实现核心调度逻辑?特别是在处理“跨天合并”和“冲突高亮”时,有没有遇到过更棘手的边界情况?评论区交流一下你的方案,看看谁的写法更优雅。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询