2026/10/27 6:29:07

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

课程列表前端实战:单一状态源串联筛选排序分页与收藏 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.jpgdata.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-actionfavorite]); if (favBtn) { const id Number(favBtn.dataset.id); toggleFavorite(id); return; } const detailBtn e.target.closest([data-actiondetail]); 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渲染出来就是一个空白列表。正确的处理是在每次筛选条件变化时把页码强制归 1function 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否则筛选一次状态就丢了。第三每次变更后写回localStoragefunction 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 classempty-state p没有找到符合条件的课程/p button typebutton>img src${cover} alt${title} onerrorthis.srcassets/placeholder.png5. 现场排查实录那些把我卡住的坑比赛现场最怕的不是不会写而是“明明写对了却不生效”。这种问题往往不是逻辑错而是某个细节没对上而控制台又不会直接告诉你。下面这些是我自己踩过、也见过别人反复踩的整理成速查表遇到问题先对照一遍能省掉大量无头苍蝇式的排查时间。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 交卷前必须走一遍的自检清单比赛时间是有限的我一般会在还剩三十分钟的时候停止加功能开始走清单。这份清单我按“会不会直接扣大分”排序从高到低逐项确认所有功能逐一点击验证。搜索、分类、难度、三种排序、翻页、收藏、清空筛选每个单独点一遍。组合操作验证。搜索 分类 排序 翻到第二页看结果是否仍然正确改一个条件确认页码回到第一页。边界验证。搜一个不存在的词看空状态清空搜索看是否恢复全量在最后一页点击下一页按钮应该是禁用状态只有一条数据时分页条是否还正常显示。刷新验证。收藏几门课刷新页面看收藏是否保留搜索条件是否需要保留题目一般不作要求不保留反而更简单。控制台验证。清空控制台把所有操作再走一遍确认没有报错、没有警告。控制台干净是代码质量最直观的体现。响应式验证。用开发者工具把窗口拖窄到 375px 左右看卡片是否变成单列、文字有没有溢出、按钮是否还点得到。代码清理。删掉调试用的console.log、注释掉的废弃代码、临时的测试数据变量名统一缩进统一。我个人在实际操作中的体会是前三十分钟的清单检查往往比最后三十分钟再写一个新功能更值钱。因为功能漏一个可能只扣一两分但组合操作一旦出问题说明你的状态管理是散的那可能整块功能都拿不到分。而一份干净、没有报错、每个按钮都能点的作品给人的印象会比“功能多但有个别点不动”的作品好得多。最后分享一个我自己一直在用的小习惯把render()函数当成唯一入口所有交互改完状态后都只调它一次绝不直接在事件里改 DOM。这条规则看起来简单但只要你能从头到尾守住这类课程列表题目基本不会出现难以排查的问题——因为任何时候页面显示的内容都是当前状态的一次忠实投影你只要看状态就知道页面应该长什么样。