课表这个东西,几乎每个学期都要折腾一遍。有人用手机拍一张教务系统截图,有人手抄在便利贴上贴桌角,还有人干脆靠记忆硬扛——直到走错教室才发现记错了楼层。我这次做的“day02-我的课表”,说白了就是把一个学期几十门课的时间、地点、教师、周次这些零散信息,整理成一个随时能查、一眼能看懂、还能随手改的结构化小工具。它不复杂,但解决的是真实痛点:上课前三十秒找不到教室在哪、第几周开始上、这门课是不是单周才有。
这个项目适合谁?如果你是刚接触前端或者数据处理的新手,它可以当作一个完整的“小闭环”练手项目——从数据建模、界面排布到交互逻辑,麻雀虽小五脏俱全。如果你只是想要一个自己能用的课表工具,跟着做一遍,半小时内就能跑起来。下面我把自己从零搭这个课表的完整思路、踩过的坑、以及那些文档里不会写的细节,全部摊开讲。
1. 整体设计与思路拆解
1.1 为什么不做成“大而全”的教务系统
一开始我也想过要不要接数据库、做登录、搞多用户。冷静下来问自己:我需要的到底是什么?答案很朴素——一个学期内,我自己能快速查到“今天第几节、在哪、上什么”。多用户、权限、云端同步这些需求,在单人使用场景下全是负担。每多一层架构,就多一处可能出问题的地方,而课表这种工具的核心价值是“打开就能用”,不是“功能齐全”。
所以我定下的第一条原则是数据与视图分离,但都保持极简。数据层就是一个结构清晰的数组,视图层负责把它渲染成按周次和节次排布的网格。中间不引入任何状态管理库,不引入路由,甚至不引入构建工具——直接一个 HTML 文件加一段脚本就能跑。这样做的好处是,你把这个文件丢到任何地方双击都能打开,不需要装环境、不需要起服务。对于课表这种“低频但高频查”的工具,部署成本越低越好。
第二条原则是周次逻辑必须前置。很多课表工具的通病是把“第几周”当成一个附加筛选条件,结果用户每次看课表都要先想“现在是第几周”。我的做法是把当前周次作为渲染的默认上下文,打开就自动定位到本周,单双周课程自动过滤。这个决策看起来小,但它决定了整个数据模型里“周次”字段必须是结构化的,而不是一句“1-16周”的字符串。
1.2 数据模型怎么定:一张表还是多张表
课表的数据本质上是二维的:横轴是星期一到星期日,纵轴是第 1 节到第 N 节。但真实课程不是规整的格子——有的课连上两节,有的课只在单周出现,有的课一周上两次但地点不同。如果硬塞进二维数组,很快就会出现“一个格子放不下”的尴尬。
我试过两种建模方式。第一种是按节次建二维数组,schedule[day][period],简单直接,但连堂课要重复存两份数据,改一个地方容易漏改另一个。第二种是按课程建扁平列表,每门课一条记录,包含星期、起始节、持续节数、周次范围、地点、教师。我最终选了第二种,原因是它更贴近人的认知——我们记的是“周三上午三四节有课”,而不是“周三第二格和第三格各有一门课”。扁平列表在增删改时只需要动一条记录,渲染时再展开成格子,逻辑清晰得多。
具体字段我定成这样:name(课程名)、teacher(教师)、location(地点)、day(星期几,1-7)、startPeriod(起始节次)、duration(持续节数)、weeks(周次数组,如[1,2,3,5,6,7])、color(可选,用于区分课程)。周次用数组而不是范围字符串,是为了过滤时直接includes判断,省去解析字符串的麻烦。这个选择在数据量小时看不出优势,但当你需要“本周课程”快速筛选时,数组的查询成本几乎为零。
1.3 渲染策略:一次性渲染还是按需重绘
课表页面有个特点:数据几乎不变,但视图会随“当前周次”变化。如果每次切换周次都重建整个 DOM,在课程少的时候无所谓,但一旦有几十条记录,频繁重绘会带来不必要的性能开销和闪烁感。
我的策略是网格骨架只建一次,课程块按周次动态挂载。具体来说,先用循环生成固定的时间轴和星期表头,这部分是静态的;然后每次周次变化时,清空课程容器,根据过滤后的课程列表重新生成课程块。这样骨架不动,只有内容区更新,视觉上更稳定。实测下来,即使一个学期三十多门课,切换周次也是瞬间完成,没有任何卡顿感。
提示:清空容器时用
innerHTML = ''比逐个removeChild快得多,但要注意如果容器上绑了事件监听,清空后监听会丢失。我的做法是把事件委托绑在父容器上,这样子元素怎么换都不影响。
2. 核心细节解析与实操要点
2.1 周次数组的生成与校验
周次是课表里最容易出错的地方。教务系统给的往往是“1-16周”或“2-17周(双)”,手工转成数组时稍不留神就漏一周或多一周。我写了一个小工具函数来生成周次数组,核心逻辑是接收起始周、结束周、单双周标记,返回一个整数数组。
function buildWeeks(start, end, parity) { const weeks = []; for (let w = start; w <= end; w++) { if (parity === 'odd' && w % 2 === 0) continue; if (parity === 'even' && w % 2 !== 0) continue; weeks.push(w); } return weeks; }这个函数看起来简单,但有两个细节值得说。第一,parity参数我用'odd'/'even'/'all'三个值,而不是布尔值,因为布尔值在调用处可读性差——buildWeeks(1,16,true)你根本不知道 true 代表单周还是双周。第二,循环里用continue跳过不符合的周,而不是在 push 时判断,这样逻辑更直白,也方便以后加“跳过某几周”的例外规则。
校验方面,我在数据录入后加了一个自检:遍历所有课程,检查startPeriod + duration - 1是否超过总节数,检查weeks数组是否有重复值,检查同一时间段是否有两门课冲突。冲突检测的逻辑是两两比较:如果两门课的day相同,且周次数组有交集,且节次区间有重叠,就标记为冲突。这个自检在开发阶段帮我抓出过两次手误——一次是把“周三”写成了“周二”,一次是把持续节数 2 写成了 3,导致和下一门课撞车。
2.2 节次时间轴的灵活配置
不同学校、不同校区的作息时间差别很大。有的学校上午四节连排,有的上午只有两节大课。如果我把节次时间写死在代码里,换一个场景就得改源码,这显然不合理。所以我把节次配置抽成一个独立数组,每项包含节次序号、开始时间、结束时间。
const periodConfig = [ { period: 1, start: '08:00', end: '08:45' }, { period: 2, start: '08:55', end: '09:40' }, { period: 3, start: '10:00', end: '10:45' }, { period: 4, start: '10:55', end: '11:40' }, // ... ];这样做的直接好处是,时间轴的显示完全由配置驱动,改作息只需要改这个数组,渲染逻辑一行不用动。更深一层的好处是,它为“当前时间高亮”功能提供了数据基础——我可以拿当前时间和每个节次的起止时间比较,自动高亮正在上的那节课。这个功能我一开始没做,后来发现它才是课表工具真正的“爽点”:打开页面,不用找,正在上的课已经亮着了。
注意:时间比较要用
Date对象或者统一转成分钟数,不要直接比较字符串。'08:00' < '10:00'在字符串比较下碰巧成立,但'9:00'和'10:00'就会出错。我统一转成“距零点分钟数”来比较,稳妥且直观。
2.3 课程块的颜色分配策略
课程块如果全是同一个颜色,视觉上很难快速区分。但让用户手动给每门课选颜色,又增加了录入负担。我的做法是自动分配 + 允许覆盖:根据课程名的哈希值映射到一个预设色板,同一门课每次渲染颜色一致,不同课程大概率不同色。色板我选了八个低饱和度颜色,保证文字可读性。
哈希函数不需要很复杂,简单的字符码累加取模就够了:
function pickColor(name) { let hash = 0; for (let i = 0; i < name.length; i++) { hash = (hash * 31 + name.charCodeAt(i)) % 100000; } return palette[hash % palette.length]; }这里有个小坑:如果两门课名字很像,哈希值可能落在同一个色上。实测下来这种情况不多,而且课程块上都有文字,颜色只是辅助识别,不必追求绝对唯一。如果确实需要区分,可以在数据里手动指定color字段覆盖自动分配,这个优先级我在渲染时做了判断——有手动色就用手动色,没有才走哈希。
2.4 响应式布局的取舍
课表天然是宽表格,手机上直接看会挤成一团。我一开始想做成横向滚动,但实际用下来体验很差——看周三的课要左右滑,看下午的课要上下滑,两个方向都滑很容易迷失。后来改成移动端按天切换:顶部一排星期标签,点哪天显示哪天的课程列表,纵向排列。桌面端则保持完整网格。
这个取舍的关键在于识别使用场景。桌面端通常是坐下来规划一周安排,需要全局视野;手机端通常是课前匆忙查一下“下一节在哪”,需要的是快速定位单天信息。两种场景对信息密度的要求完全相反,用同一套布局硬套必然有一端体验受损。用媒体查询在 768px 处切换布局,代码量不大,但体验提升明显。
3. 实操过程与核心环节实现
3.1 从零搭建页面骨架
动手之前先把 HTML 结构想清楚。整个页面分三块:顶部工具栏(周次切换、当前周显示)、主体课表区(时间轴 + 网格)、底部或侧边的课程详情弹层。工具栏和课表区是常驻的,详情弹层默认隐藏,点击课程块才出现。
<div class="toolbar"> <button id="prevWeek">上一周</button> <span id="weekLabel">第 1 周</span> <button id="nextWeek">下一周</button> </div> <div class="schedule-wrapper"> <div class="time-axis" id="timeAxis"></div> <div class="grid" id="grid"></div> </div> <div class="detail-panel" id="detailPanel"></div>时间轴和网格分开两个容器,是为了让时间轴固定不动、网格区域独立滚动。如果放在同一个滚动容器里,往下翻看下午课程时,时间轴会跟着滚走,就失去了参照。这个细节在课程节次多的时候尤其重要——十几节课的学校,没有固定时间轴根本没法看。
网格的构建用 CSS Grid 最省事。列数等于星期数加一(第一列留给时间轴占位,如果时间轴独立则不需要),行数等于节次数。每个课程块用grid-row和grid-column定位,grid-row: start / span duration直接表达“从第几节开始、连上几节”,比绝对定位好维护得多。
3.2 课程数据的录入与格式化
数据录入我推荐先用一个独立的 JS 文件存数组,而不是塞在 HTML 里。这样改数据时不用在标签海洋里找,也方便以后换成从接口拉取。每条记录按前面定的字段写,周次用buildWeeks生成。
const courses = [ { name: '数据结构', teacher: '某老师', location: '教学楼A-301', day: 1, startPeriod: 1, duration: 2, weeks: buildWeeks(1, 16, 'all'), }, { name: '大学英语', teacher: '某老师', location: '教学楼B-205', day: 3, startPeriod: 3, duration: 2, weeks: buildWeeks(2, 17, 'even'), }, ];录入时有个经验:先按星期排序再录入,而不是想到哪门录哪门。排序后录入的好处是,冲突检测时相邻记录更容易被发现,而且渲染出来的课程块顺序稳定,不会因为数组顺序变化导致视觉跳动。我一般用courses.sort((a, b) => a.day - b.day || a.startPeriod - b.startPeriod)在渲染前统一排一次。
3.3 周次切换与课程过滤的完整逻辑
周次切换是这个项目的核心交互。状态很简单:一个currentWeek变量,初始值设为当前真实周次(需要根据学期开始日期计算,或者手动指定)。点击上一周/下一周时,currentWeek加减一,然后触发重绘。
重绘的核心是过滤:
function renderCourses(week) { const container = document.getElementById('grid'); container.innerHTML = ''; const visible = courses.filter(c => c.weeks.includes(week)); visible.forEach(course => { const block = document.createElement('div'); block.className = 'course-block'; block.style.gridRow = `${course.startPeriod} / span ${course.duration}`; block.style.gridColumn = course.day; block.style.backgroundColor = course.color || pickColor(course.name); block.textContent = course.name; block.addEventListener('click', () => showDetail(course)); container.appendChild(block); }); }这段代码有几个值得注意的点。第一,gridColumn直接用course.day,前提是网格的第一列不是时间轴。如果时间轴占了第一列,这里要加一。我选择时间轴独立容器,所以网格列从 1 开始就是星期一,省去了偏移计算。第二,课程块上只显示课程名,教师和地点放在点击后的详情里,避免块内文字过多导致溢出。第三,事件监听是逐个绑定的,课程数量在几十条级别时完全没问题,如果以后数据量大了再改成事件委托。
当前周的计算我留了一个手动覆盖入口。因为学期开始日期各校不同,自动计算容易偏差一周,与其猜不如让用户第一次打开时确认一下。我在工具栏加了一个“设为当前周”的按钮,点一下就把当前显示的周次存到本地存储,下次打开直接读。
3.4 课程详情弹层的实现细节
点击课程块弹出详情,展示课程名、教师、地点、周次范围、节次时间。弹层用固定定位居中,背景加半透明遮罩,点击遮罩关闭。这些是常规做法,但有两个细节我特意处理了。
第一个是弹层打开时禁止背景滚动。手机上如果不锁滚动,手指在弹层上滑动会带着背后的课表一起动,体验很割裂。做法是打开时给body加overflow: hidden,关闭时移除。第二个是弹层内容用textContent而不是innerHTML。课程名和地点是用户录入的数据,虽然是自己人用,但养成用textContent的习惯能避免以后接外部数据时的注入风险。这个习惯在个人项目里看起来多余,但它是成本极低的防御性编程。
周次范围的展示我做了人性化处理:如果周次数组是连续的,显示“第 1-16 周”;如果有单双周规律,显示“第 2-16 周(双)”;如果是零散周次,直接列出“第 1,3,5,7 周”。判断连续性的逻辑是检查数组相邻元素差值是否恒为 1,这个判断不复杂,但能让详情读起来自然很多。
4. 常见问题与排查技巧实录
4.1 课程块错位与重叠的排查
课程块错位是这类项目最高频的问题,表现是课程块跑到错误的行或列,或者两个块叠在一起。排查时我按这个顺序走:先看grid-row和grid-column的值是否符合预期,再看网格容器的行列定义是否和预期一致,最后看是否有课程数据本身越界。
有一次我遇到课程块整体偏移一行,查了半天发现是网格容器多定义了一行表头,而课程块的grid-row没有加偏移量。这类问题的根源往往是“表头算不算在网格内”这个约定不统一。我的解决办法是表头用独立容器,网格容器只放课程块,从结构上消除偏移的可能。如果非要把表头放进网格,那就在所有grid-row计算里统一加一,并且写注释标明,避免以后自己忘了。
重叠问题则多半是数据冲突。两门课同一天、同一节、周次有交集,渲染时就会叠在一起。我的自检函数会在控制台打印冲突详情,格式是“课程A 与 课程B 在第 X 周第 Y 节冲突”,定位非常直接。
4.2 周次过滤不生效的三种典型原因
周次过滤失效,课程该显示的不显示、不该显示的显示了,我遇到过三种原因。第一种是weeks数组里存的是字符串而不是数字,includes(3)匹配不到'3'。这种问题在从 JSON 或表单读取数据时特别常见,解决办法是在buildWeeks里强制Number(w)。第二种是currentWeek是字符串,加减操作变成了字符串拼接,'1' + 1得到'11'而不是2。第三种是过滤后没有清空旧容器,新旧课程块混在一起,看起来像过滤失效。
这三种原因有个共同点:都是类型问题。所以我在项目里养成了一个习惯,所有涉及周次、节次的变量,在入口处统一parseInt一次,之后全程用数字。这个习惯花不了几秒钟,但能省掉大量调试时间。
4.3 移动端点击延迟与误触
移动端浏览器有个历史遗留的 300ms 点击延迟问题,虽然现代浏览器在设置了正确的 viewport 后基本没有了,但课程块较小的时候误触仍然存在。我的处理是给课程块设置最小点击区域,高度不低于 44px(这是触控目标的一个常用参考值),同时给点击加一个轻微的按下态反馈,让用户知道点中了。
另一个移动端问题是横向滑动和点击的冲突。如果课程块在可横向滚动的容器里,用户想滑动查看时容易误触发点击。解决办法是监听touchstart和touchend的位置差,超过阈值就判定为滑动,不触发点击。这个逻辑不复杂,但能明显减少误操作。
4.4 常见问题速查表
| 现象 | 可能原因 | 排查动作 | 解决方式 |
|---|---|---|---|
| 课程块位置偏移 | 网格行列定义与定位值不匹配 | 检查容器行列数与grid-row/column值 | 统一表头与网格的容器划分 |
| 课程块重叠 | 数据时间冲突 | 运行冲突自检函数看控制台输出 | 修正冲突课程的时间或周次 |
| 周次过滤失效 | 类型不一致或未清空容器 | 打印typeof currentWeek和数组元素类型 | 入口统一转数字,重绘前清空 |
| 移动端点击无反应 | 点击区域过小或滑动误判 | 检查块高度和触摸事件逻辑 | 增大点击区域,加滑动阈值判断 |
| 时间轴随页面滚动消失 | 时间轴与网格在同一滚动容器 | 检查 DOM 结构 | 时间轴独立容器,固定定位 |
| 颜色区分度低 | 哈希碰撞或色板饱和度太高 | 打印各课程分配到的颜色 | 调整色板或手动指定颜色 |
4.5 几个让我少走弯路的实操心得
第一个心得是先用手写数据跑通渲染,再接真实数据。我一开始就把教务系统的真实课表往里灌,结果渲染出问题时分不清是数据错还是代码错。后来改成先用三四条手写的、确定正确的数据验证渲染逻辑,跑通后再换真实数据,排查效率高很多。
第二个心得是把“当前周”做成可覆盖的,不要迷信自动计算。学期开始日期、节假日调课这些因素让自动计算很容易偏,与其花时间做精确的周次推算,不如给用户一个手动校准的入口。这个取舍在个人工具里非常划算。
第三个心得是课程块的文字要能截断但不能丢信息。课程名太长时用text-overflow: ellipsis截断显示,但完整名称在详情弹层里必须能看到。我见过一些课表工具,块内文字截断后就没有别的地方看全名了,这在课程名相似的时候很要命。
第四个心得是本地存储的键名带上版本号。课表数据结构以后可能会改,如果键名固定,旧数据和新代码不兼容时会出各种诡异问题。我用schedule_v1这样的键名,以后结构变了就升到v2,旧数据自然失效,不会互相干扰。
5. 数据持久化与后续扩展的边界
5.1 本地存储的读写时机
课表数据变动不频繁,但“当前周”这个状态变动相对频繁。我的策略是分开存:课程数据在录入或修改后存一次,当前周在每次切换后存一次。读取时机统一放在页面初始化,先读课程数据,再读当前周,如果当前周不存在就用默认值。
function saveCourses() { localStorage.setItem('schedule_v1_courses', JSON.stringify(courses)); } function loadCourses() { const raw = localStorage.getItem('schedule_v1_courses'); return raw ? JSON.parse(raw) : []; }这里有个细节:JSON.parse可能因为数据损坏抛异常,我包了一层 try-catch,解析失败就返回空数组并清掉损坏的存储。这个处理在个人项目里看起来小题大做,但存储损坏导致页面白屏的情况我确实遇到过,加一层保护成本极低。
5.2 导入导出:给数据留一条后路
本地存储有个天然风险:换设备、清缓存、换浏览器,数据就没了。所以我加了一个极简的导入导出功能——导出就是把课程数组转成 JSON 字符串放进一个文本框让用户复制,导入就是粘贴 JSON 后解析覆盖。没有文件下载上传那么正式,但足够用,而且实现成本几乎为零。
导出时我加了一个小处理:把weeks数组压缩成范围表示再输出,比如[1,2,3,4,5]输出成"1-5"。这样导出的文本更短,人看着也清楚。导入时再解析回数组。这个转换逻辑和详情弹层里的周次展示逻辑可以复用,写一次两处用。
5.3 这个项目还能往哪长
课表跑通之后,我陆续加过两个小功能,都是顺手的事。一个是课前提醒,用浏览器的通知能力,在当前时间距离下节课开始还有十分钟时弹一条提醒。这个功能依赖页面一直开着,实用性有限,但作为练手挺有意思。另一个是空教室查询,思路是把所有课程的占用情况反过来算,某个时间段没有课的教学楼房间就是空的——当然这需要全班的课表数据,个人项目里只能是个思路。
真正值得扩展的方向其实是数据录入的便利性。现在录入靠手写数组,课程多了很烦。如果能把教务系统导出的表格做一次格式转换,自动生成课程数组,录入成本会大幅下降。这个转换脚本不难写,核心是列名映射和周次字符串解析,但各校表格格式不一,通用性是个问题。我的建议是先针对自己的表格格式写一个一次性脚本,跑完就扔,不要追求通用。
提示:扩展功能之前先问自己“这个功能我一周会用几次”。课表的核心场景是查,不是管。任何增加录入负担、增加页面复杂度的功能,都要慎重。工具的价值在于用的时候顺手,不在于功能列表长。
最后分享一个我在这个项目里体会最深的小技巧:把最容易出错的地方做成可见的。周次和节次是课表里最容易错的两个维度,我就把它们在界面上做得尽量显眼——周次在顶部大字显示,节次在时间轴上标清楚起止时间。错误往往发生在看不见的地方,让关键信息可见,比事后排查有效得多。这个思路放到别的项目里也一样成立,先想清楚哪里最容易错,然后让它在界面上无处可藏。