ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

课程列表前端实战:单一状态源串联筛选排序分页与收藏

课程列表前端实战:单一状态源串联筛选排序分页与收藏

1. 拿到题目先别急着敲代码:课程列表到底在考什么

“课程列表”这四个字,在前端Web赛道竞赛里几乎是出现频率最高的题型之一。它的外在形态很简单——一堆课程卡片排成网格,配上搜索框、分类标签、排序下拉和分页条。但真正做过这套题的人都知道,它的杀伤力全部藏在“功能叠加”里:单看每一个需求都不难,问题是四五个交互条件互相咬合,状态一乱,整个页面就崩。你搜了个关键词,翻到第二页,再改一下分类,结果列表空白——这种场面在赛场上太常见了。

我把这类题目的本质总结成一句话:它考的不是你会不会写卡片,而是你能不能用一个统一的状态,把所有交互串成一条单向数据流。谁能把“状态 -> 派生数据 -> 渲染”这条链路走通,谁就能在有限时间里把功能全做完还留出时间调样式;谁一上来就东拼西凑写事件回调,改到第三个功能基本就失控了。

这篇文章适合三类人看:第一次接触这类竞赛题、想知道标准解法的新手;做过一两次但总在细节上丢分的同学;还有平时写业务代码、想拿这套思路去重构列表页的开发者。下面的内容我会从读题、定数据结构、搭骨架,一路讲到筛选排序分页的具体实现,再把我在赛场上踩过的坑和交卷前的自检清单完整摊开。所有代码都是可以直接跑起来的原生实现,不依赖任何框架,你复制过去改改数据就能用。

1.1 从需求描述里拆出一张可验收的清单

竞赛题目的需求描述通常写得很“客气”,一两句话就带过一个功能,但每一句话背后都是一个评分点。我的习惯是拿到题先不写代码,花十五到二十分钟,把描述逐句翻译成一张能打勾的清单。比如“支持按课程名称搜索”,翻译过来至少要拆成四条:输入即筛选、清空后恢复全量、不区分大小写、首尾空格自动忽略。再比如“支持分页展示”,拆开是:总条数正确、页码高亮正确、上一页下一页在边界处要禁用、筛选后页码要重置到第一页。

这张清单的价值在于,它让你在写代码时始终知道自己“还差什么”,而不是写着写着凭感觉加功能。我见过太多人把时间花在给卡片加阴影和渐变上,最后交卷时收藏按钮点了没反应。竞赛评分是功能优先的,视觉是加分项,顺序反了就是白干。

下面这张表是我常用的拆解模板,你可以直接套:

需求原文片段拆解出的验收点常见丢分位置
展示课程列表卡片数量与数据一致、字段完整、图片有兜底少渲染字段、图片裂开
按名称搜索实时筛选、忽略大小写、trim、无结果提示未 trim、未重置页码
分类/难度筛选单选生效、与搜索叠加、可回到全部条件互相覆盖
排序价格升降、评分降序、默认顺序可还原字符串比较数字
分页页码计算、边界禁用、筛选后归位总页数为 0 时崩
收藏单卡切换、状态持久、刷新不丢用索引做标识

把这张表贴在草稿纸边上,写完一个功能划掉一行,比赛结束前你会发现自己是全场最踏实的那个。

1.2 数据字段先定死,后面少返工一半

第二个必须先做的事,是把数据结构定下来。竞赛一般会给你一份 JSON,或者让你自己造数据。不管哪种,字段名一旦定死就别再改,因为渲染、筛选、排序、收藏四个模块全都依赖它。我通常会用下面这套字段,覆盖了绝大多数课程列表题目的需求:

const courses = [ { id: 1001, // 唯一标识,收藏和事件委托全靠它 title: '从零手写一个响应式布局系统', teacher: '陈老师', category: 'frontend', // frontend / backend / design / mobile level: 'advanced', // basic / advanced / project price: 199, // 数字,0 表示免费 students: 3821, // 学习人数,用于排序 rating: 4.8, // 评分,一位小数 cover: 'assets/cover-01.jpg', tags: ['CSS', '布局'], updateTime: '2024-03-11' } // ... ];

这里面有两个细节值得单独说。第一,id必须是稳定且唯一的,不要用数组下标代替。因为筛选和排序之后,数组下标会变,你如果拿index去标记收藏,用户收藏第一条、再换个排序,收藏状态就跑到别的课上了。第二,price和rating、students一定要是数字类型,不能是"199元"这样的字符串。字符串排序会出现"1000" < "199"这种反直觉结果,这个问题我在赛场上见过不止一次,改起来只要一个Number(),但当时找原因能耗掉半小时。

字段定完之后,我还会顺手写一个escapeHTML工具函数。因为渲染卡片基本都是用模板字符串拼innerHTML,如果课程标题里带个引号或者尖括号,轻则样式错乱,重则整块列表渲染失败。这个函数只有几行,但它是保命的:

function escapeHTML(str) { return String(str).replace(/[&<>"']/g, ch => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch])); }

2. 整体架构与渲染选型:为什么我坚持原生三件套

竞赛环境里,我几乎不会考虑引入任何框架或构建工具。原因很实际:一是时间不够,光配环境、装依赖、踩版本坑就能吃掉半小时,而题目本身可能只是让你渲染一个列表;二是评分看的是最终页面效果和代码可读性,不是用了多新潮的技术栈;三是原生方案的调试链路最短,报错直接指向你写的那一行,不需要穿过一层层封装去猜。

原生三件套(HTML + CSS + JavaScript)还有一个隐性优势:它逼着你想清楚数据流。用框架的时候,v-for一写,列表就出来了,你可能从来没认真想过“什么时候该重新渲染”“重渲染会不会丢状态”。而手写渲染函数,这些问题一个都躲不掉。做完这道题,你对“状态驱动视图”的理解会比照抄文档扎实得多。

2.1 文件拆分与目录结构

我不建议把 HTML、CSS、JS 全塞进一个文件里,哪怕题目允许。单文件在写到三百行之后会非常难维护,改样式要来回滚动,找一个函数得靠搜索。我的习惯是拆成三个文件,外加一个assets放图片:

course-list/ ├── index.html ├── css/ │ └── style.css ├── js/ │ ├── data.js // 课程数据,纯数组 │ └── app.js // 全部逻辑 └── assets/ └── cover-01.jpg

data.js单独拆出来的好处是,你可以在调试阶段随手改数据验证边界情况,比如把某门课的价格改成 0 看“免费”标签是否正常,或者把数组改成空数组看空状态有没有兜住。app.js里我会按顺序组织成四块:状态定义、工具函数、渲染函数、事件绑定。这个顺序不是强迫症,是因为渲染函数依赖工具函数,事件绑定依赖渲染函数,从上往下读一遍就能理解整个文件在干什么,评委看代码也舒服。

2.2 渲染策略:字符串模板加事件委托

渲染卡片有两种常见写法:一种是document.createElement一个个创建节点,再appendChild;另一种是拼字符串然后一次性赋给innerHTML。两种都能用,我更倾向后者,理由是代码短、可读性高,而且在这类题目的数据量级(几十条)下性能差异完全可以忽略。

但用innerHTML就必须配套两件事:转义和事件委托。转义前面已经写了。事件委托是指不要在每张卡片的按钮上单独绑addEventListener,因为每次重新渲染,旧节点被销毁,绑定的事件也就没了,你得在渲染函数里重新绑一遍,几十个按钮绑几十次,代码啰嗦还容易漏。正确做法是在列表容器上绑一次,通过event.target.closest()找到真正被点击的元素:

listEl.addEventListener('click', (e) => { const favBtn = e.target.closest('[data-action="favorite"]'); if (favBtn) { const id = Number(favBtn.dataset.id); toggleFavorite(id); return; } const detailBtn = e.target.closest('[data-action="detail"]'); if (detailBtn) { openDetail(Number(detailBtn.dataset.id)); } });

这样写的好处是:无论列表重新渲染多少次,事件监听始终只有容器上的那一个,.closest()会帮你找到实际被点的按钮,dataset.id给你唯一的课程标识。我把这套“容器监听 + data 属性 + closest 派发”称为列表类题目的标准骨架,后面所有交互都套在这个模板上,能省掉大量重复代码。

提示:closest()在遇到null时会报错,所以拿e.target之前不用判断,但拿到的结果一定要判空再取属性,否则点击容器空白处就会抛异常,控制台一片红,看着吓人但其实是低级问题。

3. 核心逻辑逐个击破:筛选、排序、分页的串联

这一节是整道题的心脏。很多人的实现方式是:搜索框的input事件里直接操作 DOM 过滤卡片,分类按钮的click事件里又操作一遍 DOM,排序再操作一遍。看起来每个功能都能跑,但它们互相不认识——你把搜索和分类一起用,就发现只有一个生效,因为后执行的那个把前一个的结果覆盖了。

正确的做法是引入单一状态源。所有用户操作只改状态,不碰 DOM;状态一变,就统一走一遍“派生数据 -> 渲染”的流程。这样一来,无论用户怎么组合操作,结果永远是正确的,因为每次都是从原始数据重新算出来的。

3.1 一个状态对象管住所有交互

const state = { keyword: '', // 搜索关键词 category: 'all', // 分类筛选 level: 'all', // 难度筛选 sort: 'default', // 排序方式 page: 1, // 当前页 pageSize: 6, // 每页条数 favorites: new Set(JSON.parse(localStorage.getItem('course_fav') || '[]')) };

favorites用Set而不是数组,是因为我们只关心“有没有收藏过”,has和add都是 O(1),判断时不用写indexOf(...) > -1这种啰嗦表达式。初始化时从localStorage读一次,读不到就给空数组,JSON.parse遇到空字符串会抛错,所以|| '[]'这个兜底不能少。

然后是核心的派生数据函数。它不修改任何状态,只根据状态算出一个当前应该显示的数组:

function getVisibleCourses() { const kw = state.keyword.trim().toLowerCase(); const filtered = courses.filter(item => { const matchKeyword = !kw || item.title.toLowerCase().includes(kw) || item.teacher.toLowerCase().includes(kw); const matchCategory = state.category === 'all' || item.category === state.category; const matchLevel = state.level === 'all' || item.level === state.level; return matchKeyword && matchCategory && matchLevel; }); return sortCourses(filtered); }

这段代码有三个地方值得停下来想一想。第一,!kw放在最前面,利用短路求值,关键词为空时后面两个includes根本不执行,省掉不必要的字符串比较。第二,三个条件用&&连接而不是if嵌套,因为它们的逻辑关系就是“同时满足”,写成一串更直观。第三,筛选和排序拆成两个函数,而不是写在一起,因为筛选完需要先知道结果总数才能算分页,这个数据后面还要用。

3.2 多条件筛选最容易翻车的两个地方

第一个坑是大小写和空格。用户在搜索框里敲“CSS”和“css”,期望得到相同结果,所以两边都要toLowerCase()。空格更容易忽略,用户复制粘贴一个词,首尾可能带空格,不trim()就永远搜不到。这两行处理加起来不到十个字符,但它是“能不能用”和“好不好用”的分界线。

第二个坑是筛选后页码越界。假设你当前在第 3 页,然后搜索一个只有 2 条结果的词,总页数变成 1,但state.page还是 3,渲染出来就是一个空白列表。正确的处理是在每次筛选条件变化时,把页码强制归 1:

function updateFilter(patch) { Object.assign(state, patch); state.page = 1; // 条件一变,页回到第一页 render(); }

同样的逻辑适用于分类切换、难度切换、排序切换。只有翻页操作本身才修改state.page。这条规则一旦定死,你就再也不会遇到“筛完是空白”的问题了。

3.3 排序:数字比较、中文比较与稳定性

排序看上去最简单,实际上细节最多。先说数字排序,Array.prototype.sort默认把元素转成字符串比较,所以必须传比较函数:

function sortCourses(list) { const arr = [...list]; // 不改原数组,避免副作用 switch (state.sort) { case 'price-asc': return arr.sort((a, b) => a.price - b.price); case 'price-desc': return arr.sort((a, b) => b.price - a.price); case 'rating-desc': return arr.sort((a, b) => b.rating - a.rating || b.students - a.students); case 'students-desc': return arr.sort((a, b) => b.students - a.students); default: return arr; // 默认顺序,即数据原始顺序 } }

[...list]这个展开是关键。sort会原地修改数组,如果你直接对筛选结果排序,虽然这里结果本来就是新数组没问题,但万一哪天你改成对courses直接操作,原始数据就被改掉了,后面的“恢复默认顺序”就再也恢复不了。写代码时多花五个字符,能省掉一个小时的调试。

评分排序那里我加了|| b.students - a.students,这是兜底排序。两门课评分都是 4.8 的时候,如果只按评分排,它们的相对顺序取决于引擎实现,看起来就是“乱跳”。加上学习人数作为第二排序键,结果就稳定了,用户点两次同样的排序,看到的顺序一致,不会觉得“这个网站好奇怪”。

如果题目涉及按课程名排序,一定要用localeCompare并指定中文环境:

arr.sort((a, b) => a.title.localeCompare(b.title, 'zh-Hans-CN'));

直接比较字符串是按 Unicode 码点来的,中文的排序结果完全不符合人的直觉。localeCompare会按拼音顺序排,虽然竞赛里不一定考,但写上不亏。

3.4 分页:总页数为 0 的那一行必须有兜底

分页的计算公式很简单:总页数 = Math.ceil(总条数 / 每页条数)。真正的风险在边界:总条数为 0 时,Math.ceil(0 / 6)结果是 0,你如果直接拿它去算页码,就会出现“第 1 页 / 共 0 页”这种自相矛盾的显示,还可能因为Math.min(state.page, 0)把页码算成 0,导致slice拿到负索引。

我的处理方式是先兜底再切片:

function renderList() { const result = getVisibleCourses(); const total = result.length; const totalPages = Math.max(1, Math.ceil(total / state.pageSize)); // 页码永远落在 [1, totalPages] 区间内 state.page = Math.min(Math.max(1, state.page), totalPages); const start = (state.page - 1) * state.pageSize; const pageItems = result.slice(start, start + state.pageSize); // 渲染卡片 + 渲染分页 + 渲染统计 renderCards(pageItems); renderPager(totalPages, total); renderSummary(total, start, pageItems.length); }

Math.max(1, ...)保证了即使一条数据都没有,totalPages也是 1,页面显示“第 1 页 / 共 1 页”,虽然有点怪,但至少不崩,空状态那块会单独给出提示。这里我还顺手做了一个小细节:把“共 X 门课程,当前显示第 X-Y 条”这类统计信息渲染出来。它不占多少代码,但在评分时属于加分项,因为它体现了你对信息完整性的考虑,而不是只顾着把卡片摆出来。

4. 交互细节与视觉还原

功能跑通之后,剩下的时间应该花在两件事上:一是把交互打磨顺,二是把视觉往设计稿上靠。这两块往往决定了你和别人的分差。功能大家都能做完,但有的页面用起来“跟手”,有的页面点一下卡一下,评分人的感受是很直接的。

4.1 搜索防抖:不是为了性能,是为了体验

搜索框的input事件每次按键都会触发。数据只有几十条的时候,每按一次键就重新渲染一遍,浏览器其实扛得住,但会有一个副作用:输入框的光标位置可能跳动,中文输入法在拼音还没选字的时候就开始筛选,导致输入卡顿。解决办法是加防抖,等用户停止输入 250 到 300 毫秒再执行:

function debounce(fn, delay = 300) { let timer = null; return function (...args) { clearTimeout(timer); timer = setTimeout(() => fn.apply(this, args), delay); }; } const onSearchInput = debounce((value) => { updateFilter({ keyword: value }); }, 300); searchInput.addEventListener('input', (e) => onSearchInput(e.target.value));

这里有个容易忽略的点:如果用户在输入框里按了回车,期望立刻看到结果,而防抖还在等那 300 毫秒,体验上会有延迟感。我的处理是给输入框再绑一个keydown,回车时立即执行一次,同时清掉待执行的定时器。这个小技巧写出来只有几行,但用起来差别很明显。

注意:防抖函数里保存的timer是闭包变量,每个debounce()调用都会创建一个独立的作用域。不要在循环里重复创建防抖函数,否则相当于没有防抖。

4.2 收藏功能与本地存储

收藏是这类题目的常客,实现起来有三个要点。第一,状态存在Set里,渲染时用state.favorites.has(item.id)判断按钮的样式,收藏了就是实心,没收藏就是空心。第二,点击时先改状态,再调用render(),不要直接去改按钮的class,否则筛选一次状态就丢了。第三,每次变更后写回localStorage:

function toggleFavorite(id) { if (state.favorites.has(id)) { state.favorites.delete(id); } else { state.favorites.add(id); } localStorage.setItem('course_fav', JSON.stringify([...state.favorites])); render(); }

Set转数组要用展开运算符,JSON.stringify不认识Set,直接传进去会得到{},存进去再读出来全是空的,这是新手非常容易踩的一个坑。读回来的时候再用new Set(array)包一层,一进一出就闭环了。

还有一个体验细节:如果题目要求“只看已收藏”,那就在筛选逻辑里加一条state.onlyFavorite && !state.favorites.has(item.id)的条件,依然走统一的状态流,不要单独写一套渲染。另外,收藏按钮点击时最好加个轻微的状态反馈,靠 CSS 的:active或者transition就能实现,不用写 JS。

4.3 响应式布局与空状态

卡片列表的布局我基本固定用 Grid:

.course-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 20px; }

auto-fill加minmax是响应式网格的经典组合,屏幕宽就多排几列,窄就自动减少,一行代码搞定,无需写一堆媒体查询。卡片内部用flex-direction: column,把封面、标题、讲师、标签、价格依次排下来,标题用-webkit-line-clamp: 2限制两行,防止长短不一的标题把卡片撑得参差不齐。

空状态是一个特别容易漏掉的点。搜索不到结果时,如果不做处理,页面就是一片空白,评分人第一反应会以为你功能坏了。正确的做法是渲染一段友好的提示,配上“清空筛选”的按钮:

function renderCards(items) { if (items.length === 0) { listEl.innerHTML = ` <div class="empty-state"> <p>没有找到符合条件的课程</p> <button type="button"><img src="${cover}" alt="${title}" onerror="this.src='assets/placeholder.png'">

5. 现场排查实录:那些把我卡住的坑

比赛现场最怕的不是不会写,而是“明明写对了却不生效”。这种问题往往不是逻辑错,而是某个细节没对上,而控制台又不会直接告诉你。下面这些是我自己踩过、也见过别人反复踩的,整理成速查表,遇到问题先对照一遍,能省掉大量无头苍蝇式的排查时间。

5.1 高频问题速查表

现象最可能的原因排查动作
列表一片空白,控制台无报错数据字段名拼错,模板里取到 undefinedconsole.log(courses[0])对照字段
报Cannot read properties of null容器选择器写错或脚本在 DOM 之前执行检查id,把script放到body末尾或加defer
搜索框输入后结果不更新监听的是change而非input,或忘了调render()改事件类型,确认渲染函数被调用
点收藏所有卡片一起变用数组下标当标识改用item.id,校验唯一性
筛选后页码显示“第 3 页 / 共 1 页”条件变更时未重置页码在updateFilter里加state.page = 1
排序后顺序和预期不符用字符串比较数字比较函数里显式做减法
刷新后收藏全丢Set未转数组就JSON.stringify用[...set]包一层
卡片高度参差不齐标题未限制行数加line-clamp或固定高度
点击卡片内按钮触发了卡片跳转事件冒泡未阻止在按钮回调里e.stopPropagation()
中文标题排序像乱码直接比较字符串改用localeCompare(..., 'zh-Hans-CN')

排查这类问题,我有一条铁律:永远先看数据,再看渲染,最后看事件。大部分“渲染不出来”的问题,其实数据那一步就没拿到东西。在getVisibleCourses()返回处打一个console.log(result.length),一眼就能判断是筛选逻辑错了还是模板拼错了。很多人一上来就盯着 CSS 和模板改,方向从一开始就偏了。

还有一个小技巧:临时在页面上放一个调试面板,把state用JSON.stringify打出来。因为Set不能直接序列化,记得转成数组再打。这个面板调完记得删掉,别留在交卷的代码里。

5.2 交卷前必须走一遍的自检清单

比赛时间是有限的,我一般会在还剩三十分钟的时候停止加功能,开始走清单。这份清单我按“会不会直接扣大分”排序,从高到低逐项确认:

  1. 所有功能逐一点击验证。搜索、分类、难度、三种排序、翻页、收藏、清空筛选,每个单独点一遍。
  2. 组合操作验证。搜索 + 分类 + 排序 + 翻到第二页,看结果是否仍然正确;改一个条件,确认页码回到第一页。
  3. 边界验证。搜一个不存在的词看空状态;清空搜索看是否恢复全量;在最后一页点击下一页,按钮应该是禁用状态;只有一条数据时,分页条是否还正常显示。
  4. 刷新验证。收藏几门课,刷新页面,看收藏是否保留;搜索条件是否需要保留(题目一般不作要求,不保留反而更简单)。
  5. 控制台验证。清空控制台,把所有操作再走一遍,确认没有报错、没有警告。控制台干净是代码质量最直观的体现。
  6. 响应式验证。用开发者工具把窗口拖窄到 375px 左右,看卡片是否变成单列、文字有没有溢出、按钮是否还点得到。
  7. 代码清理。删掉调试用的console.log、注释掉的废弃代码、临时的测试数据,变量名统一,缩进统一。

我个人在实际操作中的体会是,前三十分钟的清单检查,往往比最后三十分钟再写一个新功能更值钱。因为功能漏一个可能只扣一两分,但组合操作一旦出问题,说明你的状态管理是散的,那可能整块功能都拿不到分。而一份干净、没有报错、每个按钮都能点的作品,给人的印象会比“功能多但有个别点不动”的作品好得多。

最后分享一个我自己一直在用的小习惯:把render()函数当成唯一入口,所有交互改完状态后都只调它一次,绝不直接在事件里改 DOM。这条规则看起来简单,但只要你能从头到尾守住,这类课程列表题目基本不会出现难以排查的问题——因为任何时候页面显示的内容,都是当前状态的一次忠实投影,你只要看状态,就知道页面应该长什么样。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进