1. 为什么第十五天该做Search页面
从零基础开始学前端,到第十五天这个节点,其实处在一个很微妙的位置。语法基础基本过了一遍,HTML和CSS的常用布局方式也上手了,JavaScript的循环、数组操作、函数声明这些核心概念也都见过了。但这个阶段的通病是:知道每个知识点是什么,却不知道怎么把几个知识点拼成一样能用的东西。
Search页面就是为这个阶段量身定制的练习项目。它看着简单——一个输入框、一个搜索结果列表,最多加一个历史记录。但真正动手做的时候,你会发现里面同时牵扯到DOM操作、事件监听、数组处理、数据筛选、状态同步、本地存储这些前端最基础的技能点。一个Search页面做完,前面十五天学的东西基本全都能串起来。
我选择在第十五天安排这个任务,还有一个很现实的考虑:它足够小,一个晚上加一个上午就能出完整效果;又足够完整,做完之后你可以很自然往里面扩展筛选、排序、搜索历史、高亮关键词这些进阶功能。对小白来说,没有比这种“跳一跳够得着”的项目更能建立信心了。
Search页面在真实项目中也是无处不在的。淘宝的搜索框、掘金的文章搜索、GitHub的仓库搜索,底层逻辑和我接下来要写的东西是同构的。只是真实场景下数据量更大、正则更复杂、还有后端的配合,但前端这一层的基本骨架,就是今天这套东西。你把这个页面做实了,后面去改任何搜索场景的UI交互,心里都会有一点底。
今天这篇记录,我不光写最终代码,还会还原我踩坑的过程、当时卡住的地方,以及为什么最后那样改。这样你看到的不只是答案,还有思考路径,自己写的时候会更少走弯路。
1.1 学习阶段的定位:第十四天结束时你该有的底子
我特别想强调一下:做Search页面之前,你不需要等什么“时机成熟”。只要下面的东西你都见过了,就可以直接开工。
你需要在第十四天结束时,能看着一个普通的静态页面说出每个标签是干嘛的,会用Flexbox做横向排列和垂直居中,已经背得下来getElementById和querySelector的差别,写过至少一次数组的forEach或者filter遍历,并且知道addEventListener总是比在HTML标签里写onclick更值得养成习惯。
这个标准其实不高。如果你还没达到,我建议先花一天把这几项补一补,不用追求深入理解,先做到“见过、用过、能认出来”就行。因为Search页面的实现过程会把这几样全部激活,用一次比看十遍都管用。我当时就是带着这种半生不熟的状态开始的,边做边查边改,一天下来反而把之前模糊的概念全部打通了。
1.2 Search页面的技术点拆解:为什么它值得做一天
一个完整的Search页面前端部分,拆开来看涉及这么几块:
一个输入框需要监听用户的输入行为,这里就牵扯到input事件和keyup事件的取舍,以及防抖的概念——也就是用户停止输入多少毫秒后才真正发起筛选,避免每敲一个字母就跑一遍逻辑。这在高频输入场景里是躲不掉的问题,今天我会用一个极简版本让你感受一下。
然后是数据筛选这一块,需要把关键词转成可以匹配的规则,用String的includes方法做包含判断,或者用indexOf做基础检查,再配合数组的filter方法把符合条件的结果捞出来。这个过程的从左到右思路,是后面学习任何框架时都会反复遇到的。
接着是页面更新,查到的数据怎么渲染成真实可见的HTML。小白最顺手的是拼字符串往里塞,但这种方式很快会因为转义问题翻车。我会演示一种更结构化的做法,先建一个DocumentFragment或者容器节点,循环把新元素插进去,这样更干净。
再往下就是状态管理的小启蒙。关键词、筛选结果、历史记录,这些数据并不是散落的变量,它们之间有一条清晰的关系链:输入值决定筛选结果,筛选结果决定页面显示,历史记录决定底部那块列表。你把自己代入“页面里所有的数据都是有来源的”这种思维方式,后面学Vue或React的时候会觉得顺很多。
最后还有一个升级选项:把搜索历史写进localStorage,这样刷新页面之后还能恢复。这一步看上去只是多点了几下API,但它背后关联的是“持久化”这个概念,属于前端开发里比较基础也比较重要的一块内容。
2. 从设计到代码:Search页面的完整搭建过程
先说清楚,我这里要搭的不是复制粘贴就能跑的玩具页面,而是一个你可以反复折腾、继续扩展的骨架。整页我会按两个文件来组织:index.html负责骨架和布局,lut.js负责查数据和更新页面。如果你还没接触过模块化或者工程化,先别急,单文件一样能跑,重点是先把逻辑跑通。
2.1 页面的功能需求梳理
动手敲代码之前,我先给自己列了一份需求清单。清单不一定越长越好,但一定要清晰,因为后面每一行代码都是围绕这里面的某一项展开的。
页面顶部需要一个输入框和搜索按钮,输入框要支持按回车就搜索,同时还要在旁边放一个清空按钮,用来快速重置页面状态。中间是搜索结果展示区,默认状态下显示占位提示,查询到结果后实时渲染成列表,如果没有结果则显示“未找到相关内容”的提示。底部是搜索历史区,用标签块的形式展示最近8条记录,点击标签可以快速回填关键词并触发搜索,历史记录要存储到浏览器本地,关闭页面后再打开依然能恢复。
这份清单看起来简单,但每条都带有前端交互中常见的标准问题。比如按回车触发搜索就牵扯到keydown事件和防止默认行为,实时渲染要考虑性能,历史记录回填要处理输入框值和搜索逻辑的一致性。我会在实现过程中逐一说明为什么这样处理。
2.2 HTML骨架的搭建
我会先把页面的语义结构定下来。注意这里用了很多语义化标签,而不是一把div到底。原因有两层:一是有利于读代码的人快速理解每个区域是干什么的,二是有利于搜索引擎和屏幕阅读器识别页面层次。对小白来说,从第一天就养成这个习惯,后面写复杂页面会省很多事。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Search 页面练习</title> <link rel="stylesheet" href="style.css"> </head> <body> <main class="search-container"> <h1>Search 练习</h1> <!-- 搜索输入区 --> <section class="search-box"> <input type="text" id="searchInput" placeholder="输入关键词,例如:JS" autocomplete="off"> <button id="searchBtn">搜索</button> <button id="clearBtn">清空</button> </section> <!-- 搜索结果区 --> <section class="result-section"> <ul id="resultList"></ul> <p id="emptyTip">输入关键词开始搜索</p> </section> <!-- 历史记录区 --> <section class="history-section"> <h2>搜索历史</h2> <div id="historyTags"></div> </section> </main> <script src="lut.js"></script> </body> </html>这段结构里你看不到任何跟数据相关的硬编码,因为数据来源在后续章节会单独设计。这里唯一需要留意的点就是:button元素如果放在form外面,默认不会触发表单提交,这反而让逻辑更好控制。
2.3 CSS样式:先做整体再做细节
写CSS我习惯分三步:第一步重置默认样式并设置整体布局;第二步把主要区块的框架样式定下来,比如容器的宽度、卡片投影、间距;第三步做交互反馈,比如悬停效果、聚焦状态、过渡动画。
* { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei", sans-serif; background: #f5f6fa; min-height: 100vh; display: flex; justify-content: center; align-items: flex-start; padding: 40px 20px; } .search-container { width: 100%; max-width: 640px; background: #fff; border-radius: 16px; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06); padding: 32px; } h1 { font-size: 28px; font-weight: 700; color: #1a1a2e; margin-bottom: 24px; } .search-box { display: flex; gap: 12px; margin-bottom: 32px; } .search-box input { flex: 1; height: 44px; padding: 0 16px; font-size: 16px; border: 2px solid #e2e2ea; border-radius: 10px; outline: none; transition: border-color 0.2s; } .search-box input:focus { border-color: #4f46e5; } .search-box button { padding: 0 20px; font-size: 15px; border: none; border-radius: 10px; cursor: pointer; background: #4f46e5; color: #fff; transition: opacity 0.2s; } .search-box button:hover { opacity: 0.8; } .result-section { min-height: 160px; border-top: 1px solid #eee; padding-top: 16px; } .result-section ul { list-style: none; } .result-section li { padding: 10px 12px; margin-bottom: 4px; border-radius: 8px; cursor: pointer; transition: background 0.15s; } .result-section li:hover { background: #f0f0f4; } .history-section { margin-top: 32px; border-top: 1px solid #eee; padding-top: 20px; } .history-section h2 { font-size: 18px; color: #333; margin-bottom: 12px; } #historyTags { display: flex; flex-wrap: wrap; gap: 8px; } .history-tag { background: #f1f2f7; border-radius: 20px; padding: 6px 14px; font-size: 14px; color: #4f46e5; cursor: pointer; transition: background 0.15s; } .history-tag:hover { background: #e2e2ea; }你可能已经注意到,我的历史标签、输入框聚焦、列表项悬停都做了明显的交互反馈,这对小白来说很重要。前端页面不是“摆上去就行”,你能不能感知到“这个元素可以点”“那个区域有反应”,直接决定页面是不是显得专业。
2.4 JS逻辑:让页面活起来
接下来是今天的主体——JavaSript部分。我会用lut.js这个名字,说明这段代码的核心逻辑是通过输入关键词,在预置数据里进行查找匹配,然后把结果展示到页面上。
先设计数据源,这里用到一个数组,每一项包含标题和分类。真实项目的搜索往往是请求后端接口,但前端练习阶段重点在交互层,用本地数组足以模拟。
// 预置的数据源,模拟后端返回的搜索结果 const searchData = [ { title: "JavaScript 基础语法指南", category: "教程" }, { title: "前端面试题:JS 原型与闭包", category: "面试" }, { title: "用 CSS 实现一个响应式布局", category: "教程" }, { title: "React 入门到放弃:组件生命周期", category: "框架" }, { title: "Vue 3 实战:搜索框防抖处理", category: "框架" }, { title: "Webpack 打包优化之路", category: "工程化" }, { title: "浏览器缓存机制详解", category: "原理" }, { title: "Git 团队协作常用命令", category: "工具" }, { title: "TypeScript 类型体操入门", category: "教程" }, { title: "性能优化:如何减少 DOM 操作", category: "优化" } ];然后是一组基础元素引用,也就是把之前HTML里的几个关键节点用querySelector拿过来,方便后续操作。这里的命名我倾向于用语义化前缀,比如$input表示输入框、$list表示结果列表,这是很多前端开发者更习惯的风格,事实上并非强制,但保持一致性对阅读很有帮助。
const $input = document.querySelector('#searchInput'); const $btn = document.querySelector('#searchBtn'); const $clearBtn = document.querySelector('#clearBtn'); const $list = document.querySelector('#resultList'); const $emptyTip = document.querySelector('#emptyTip'); const $historyTags = document.querySelector('#historyTags');接下来我会一步步写核心逻辑,每个函数的职责尽量单一,这样调试的时候定位问题更快。
3. 核心交互逻辑:让页面不算难用
页面光能渲染数据是不够的,真正决定它好不好用的是交互细节。我在这里把所有交互拆成四个模块:监听输入、触发搜索、更新列表、管理历史。每块单独说清楚,你自己写的时候可以照着一个个去实现。
3.1 监听输入与回车触发
Search页面的使用场景决定了用户有两种触发方式:一种是点击搜索按钮,另一种是输入完直接按回车。这两种模式我都要兼容,因此分离事件绑定会让代码更清晰,也方便后续扩展防抖或自动完成功能。
// 处理搜索逻辑的函数 function handleSearch() { const keyword = $input.value.trim(); if (!keyword) { $list.innerHTML = ''; $emptyTip.textContent = '请输入搜索关键词'; $emptyTip.style.display = 'block'; return; } // 执行筛选 const result = searchData.filter(item => item.title.toLowerCase().includes(keyword.toLowerCase()) || item.category.includes(keyword) ); // 渲染结果 renderResult(result, keyword); // 记录历史 addHistory(keyword); } // 点击搜索按钮 $btn.addEventListener('click', handleSearch); // 按回车触发 $input.addEventListener('keydown', event => { if (event.key === 'Enter') { handleSearch(); } });这段代码里值得展开讲的地方有四个。
第一是trim()的使用。用户在手机输入法或中文输入法下,很容易在首尾带上空格,如果不去掉,' JS'就搜不到关键词为JS的结果,这种问题排查起来非常隐蔽。
第二是大小写的统一。我把关键词和标题都转成了小写再比较,这样用户输入js能搜到JavaScript,输入GIT也能搜到Git相关的内容。这个细节在实际项目中几乎每次都会遇到,数据库搜索就已经考虑了大小写,但前端筛选必须自己处理。
第三是空关键词的保护。如果输入框是空的,还往下走,结果列表自然什么都查不到,而且还会把空关键词写进历史记录。所以我在这里先判断并给出提示,这属于“防御式编程”的思维,实际上很多前端异常都来自输入边界没挡住。
第四是事件参数里event.key的判断。我不用event.keyCode或event.which的原因很简单:keyCode已经废弃了,虽然老项目里很多还在用,但新代码里不要再用。我用的是直观的字符串比较'Enter',可读性远好于13这种魔法数字。
3.2 筛选逻辑与关键词高亮
筛选操作本身用数组filter方法就能完成。但搜索结果还有一个很常见的交互需求:关键词高亮。也就是用户输入“JS”,那么所有包含“JS”的结果列表中,这个关键词应该被突出显示。这一步需要操作字符串和DOM,也是很多小白觉得不太好理解的地方。
我的做法是把原文中的关键词用<mark>标签包起来。这个标签是HTML里专门用来高亮文本的,浏览器默认给它加一个黄底样式。如果你用的是React或者Vue,逻辑完全一样,只是渲染方式换成框架的语法而已。
function highlightKeyword(text, keyword) { const regex = new RegExp(`(${keyword})`, 'ig'); return text.replace(regex, '<mark>$1</mark>'); } function renderResult(result, keyword) { $emptyTip.style.display = 'none'; $list.innerHTML = ''; if (result.length === 0) { $emptyTip.textContent = `没有找到与“${keyword}”相关的内容`; $emptyTip.style.display = 'block'; return; } result.forEach(item => { const li = document.createElement('li'); li.innerHTML = `${highlightKeyword(item.title, keyword)} <span style="color: #888; font-size: 13px; margin-left: 8px;">[${item.category}]</span>`; $list.appendChild(li); }); }这里有一个点我踩过坑,必须提醒你:我用的是innerHTML直接插字符串,这意味着如果关键词包含HTML特殊字符——比如用户输入了<b>——它会被当成标签解析。所以在有用户输入参与的地方,用innerHTML一定要先做转义处理。今天我演示的数据源是写死的,不会有这个问题,但真实开发中要非常小心。
另一种方案是全程用textContent赋值,再用replace配合createElement逐个处理高亮。这样安全,但代码更长,对小白来说也更绕。你可以先理解现在的写法,等过段时间再回来处理“XSS注入”这个安全问题。
3.3 历史记录:从内存到本地存储
历史记录是实现搜索功能时容易忽略但又非常重要的部分。好用的搜索引擎、电商页面都会保留你最近的搜索记录,方便快速二次搜索。实现思路分三层:先放到数组里,再渲染到页面上,最后存进浏览器的本地存储里。
const HISTORY_KEY = 'search_history'; const MAX_HISTORY = 8; function getHistory() { try { return JSON.parse(localStorage.getItem(HISTORY_KEY)) || []; } catch { return []; } } function addHistory(keyword) { let history = getHistory(); history = history.filter(item => item !== keyword); history.unshift(keyword); history = history.slice(0, MAX_HISTORY); localStorage.setItem(HISTORY_KEY, JSON.stringify(history)); renderHistory(); } function renderHistory() { const history = getHistory(); $historyTags.innerHTML = ''; if (history.length === 0) { $historyTags.textContent = '暂无搜索记录'; return; } history.forEach(item => { const tag = document.createElement('span'); tag.className = 'history-tag'; tag.textContent = item; tag.addEventListener('click', function () { $input.value = item; handleSearch(); }); $historyTags.appendChild(tag); }); } // 页面初始化时渲染历史 renderHistory();localStorage的使用体验很接近一个简单的数据库:用字符串键值对存储,数据不会因为页面刷新就消失。但需要注意的是,它只能存字符串,所以我用JSON.stringify把数组转成JSON字符串再存,取出来时再用JSON.parse转回数组。
我设置了最大8条记录的限制,避免历史无限膨胀。每次添加新记录时,先去掉重复项,再把新的放最前,最后裁掉尾部超出的部分。这三步走下来,数据的顺序和唯一性都保证了。
点击历史标签是可以回填搜索的,也就是$input.value = item之后再调用handleSearch(),这比直接把历史记录当纯文本展示有用得多,用户点一下就能重搜。这里还要注意一点,tag.addEventListener是在renderHistory里注册的,每次重新渲染历史时旧的绑定会被覆盖,不会有事件堆积的问题,因为每次我都是先用innerHTML = ''清空容器再重新创建节点。
3.4 清空与恢复初始状态
清空按钮负责把搜索状态归零:清输入框、清结果列表、恢复默认提示。这个操作看起来简单,但滴水不漏地实现也有几个注意点。
$clearBtn.addEventListener('click', function () { $input.value = ''; $list.innerHTML = ''; $emptyTip.textContent = '输入关键词开始搜索'; $emptyTip.style.display = 'block'; });这里不需要清掉历史记录,因为历史记录属于用户浏览数据,清空输入不应该连带抹掉。当然,如果你做的是“清除所有数据”按钮,那就是另一套逻辑了。
清空之后焦点最好也回到输入框上,这样用户可以直接开始下一轮输入。这个细节我是在实际测试时才发现的,不把焦点拉回来,用户每次清空后都要再点一下输入框才能打字,体验上很别扭。
4. 防抖优化与搜索实时性:代码更符合真实场景
如果你按上面的代码写完,点击按钮搜索或回车搜索,已经是完整可用的Search页面了。但真实产品的搜索框通常多一个更自然的动作:不按任何按钮,输入即触发搜索。比如掘金、GitHub的搜索框,你输入到一半,结果就已经实时出现在下方了。
这个需求并不复杂,监听input事件就行。但如果不做处理,每敲一个字母、每删一个字符,事件都会触发一次,紧跟着筛选函数跑一遍、渲染函数跑一遍。如果数据量小还好,一旦数据源是几万条,页面就会明显卡顿,甚至出现输入跟不上手速的情况。
防抖就是用来解决这个问题的。
4.1 防抖的原理与最小实现
防抖的核心思想是:当一个高频事件连续触发时,并不每次立即执行回调,而是等事件停止触发之后,过指定的时间间隔再执行一次。就像电梯门,每进一个人就重新计时,人齐了隔几秒关门。如果一直有人进,门就一直不关。
实现一个防抖函数不需要任何库,几行代码就能完成。
function debounce(fn, delay = 300) { let timer = null; return function(...args) { if (timer) { clearTimeout(timer); } timer = setTimeout(() => { fn.apply(this, args); timer = null; }, delay); }; }这里需要注意闭包的运用。timer变量不会因为函数执行完就被回收,因为返回的新函数一直持有对它的引用。这就是JavaScript闭包的一个典型应用场景。以后你在Vue、React的项目里看到各种debounce封装,它们背后的原理都是一样的。
4.2 在Search页面接入实时搜索
把防抖函数和输入事件组合起来:
// 输入事件防抖,200ms 内停止输入后执行搜索 $input.addEventListener('input', debounce(function () { handleSearch(); }, 200));这样每停止输入200毫秒后自动执行搜索逻辑。输入过程中的反复触发会被持续清理掉,最后一次停止后才真正干活。
接入之后你会明显感觉到输入过程变得“丝滑”:连续打“前端面试”,并不会在“前”“前端”“前端面”这些中间状态上卡住,只在停下来的那一刻显示“前端面试”相关结果。这个体验上的差异,就是防抖带来的。
可能有人会问:为什么用200毫秒而不是0毫秒或500毫秒?这个值没有标准答案,取决于搜索的成本。如果每次搜索要请求后端,网络耗时高,防抖时间可以放大到400~500毫秒;如果只是本地数据筛选,150~300毫秒的体验最跟手。你可以自己调节这个值,感受不同延迟带来的差异。
4.3 防抖 + 回车 + 点击的关系
有了防抖实时搜索之后,回车和点击按钮还需要保留吗?我建议保留。原因有两点:
第一,防抖只在用户输入文本时触发,如果用户输入完等了一会儿再去点击按钮,此时防抖早就执行完了,点击会再做一次搜索,数据没变,但用户获得了明确的操作反馈。
第二,很多用户使用搜索框时有敲回车确认的习惯,这是一种“完成”的心理暗示。即使实时搜索结果已经出来了,回车也应该无缝执行一次完整的搜索流程。
这三个触发路径——输入防抖、回车事件、点击按钮——最终都汇到同一个handleSearch函数上。这样设计的好处是统一出口,后续要改搜索逻辑,只需要改一处。
但你要注意一个边界情况:防抖重新触发会不会造成历史记录里出现很多半截关键词?比如用户想搜“JavaScript”,先输入“Java”停了300毫秒,历史会记录“Java”,然后再输入“Script”,又会记录“JavaScript”。这在技术上没错,但确实可能污染历史列表。常见的处理方式是:防抖触发的自动搜索不写入历史,只有回车和手动点击按钮才记录历史。我建议你把这个逻辑加进去。
function handleSearch({ saveHistory = true } = {}) { // 其他逻辑不变 if (saveHistory) { addHistory(keyword); } } // 防抖触发时不存历史 const debouncedSearch = debounce(function () { handleSearch({ saveHistory: false }); }, 200);这样一个简单的参数扩展,就解决了历史污染的问题。
5. 常见问题与踩坑实录
每次写技术分享,我都习惯把自己真实遇到的问题列出来。有些问题你上网搜不到满意的答案,就是因为太细了,踩过的人不一定愿意分享。这里我挑五个自己卡过、琢磨过、解决掉的问题,直接给你结论。
5.1 中文输入法导致的高频多余搜索
这是中文用户做搜索框一定会遇到的问题。使用拼音输入法时,用户敲字母的过程会触发input事件,但此时输入框里的值是拼音字母。比如用户想搜“前端”,他打的是qianduan,这个过程中防抖函数会不断触发搜索“q”“qi”“qia”……不仅浪费性能,还会给用户展示一堆莫名其妙的结果。
解决思路是监听compositionstart和compositionend事件,判断用户是否处于中文组合状态。当compositionstart触发时,说明正在拼音组合期间,不应触发搜索;等compositionend触发时,才执行一次搜索。
看一个简洁的方案:
let isComposing = false; $input.addEventListener('compositionstart', () => { isComposing = true; }); $input.addEventListener('compositionend', function () { isComposing = false; handleSearch(); }); $input.addEventListener('input', debounce(function () { if (!isComposing) { handleSearch({ saveHistory: false }); } }, 200));这段代码的要点在于:input事件仍然会被触发,但isComposing为true时不处理。等中文输入结束后,compositionend里直接手动调用一次搜索,补上最后一次组合结果。如果你不做这个处理,中文搜索场景下防抖的价值就打了对折。
5.2 filter 结果为空时的页面空白
我第一次做搜索结果渲染时,只处理了“有结果”的情况。输入一个毫无匹配的关键词,结果列表清空了,但页面上什么都没有,用户不知道是自己搜错了还是页面坏了。这个问题看着小,对体验的伤害却很大。
现在的代码里已经在renderResult中做了判断:结果为空时显示“没有找到与xx相关内容”的提示。这里建议把关键词也一并展示,让用户确认不是系统漏了关键字。这个思路也延伸到搜索按钮前,如果输入框为空,提示“请输入关键词”,不要直接不管。
5.3 localStorage 数据格式损坏
JSON.parse解析已经损坏的JSON字符串时会直接抛错,这是我在测试时故意写坏数据发现的。也许用户开了多个标签页同时操作,或者缓存数据被人手动改坏了,都会导致整个脚本崩溃。
所以我在getHistory里用try...catch兜底,解析失败就返回空数组。这就是“防御式编程”的价值——不能假设外部数据永远是正确的。
function getHistory() { try { return JSON.parse(localStorage.getItem(HISTORY_KEY)) || []; } catch { return []; } }这一行改动,成本极低,收益却很大。你写任何本地存储相关的代码时,都应该带上这个习惯。
5.4 渲染大量结果时页面卡顿
如果你的数据源是几百条,直接appendChild无所谓。但如果数据源上升到几千条,一次性创建几千个DOM节点会让页面出现明显的卡顿感,尤其是在低端手机上。这就是为什么真实项目里搜索分页、懒加载成为标配。
作为一个过渡方案,你可以只渲染前20条结果,并在底部加一个“加载更多”的按钮。二十条数据用户一目了然,体验并不会差多少,但渲染压力比一次性全量渲染小得多。
5.5 高亮标签与输入内容的冲突
在3.2节我提到了innerHTML拼接高亮内容的安全问题。这里再展开一下,因为很多人会忽略它。如果你输入的关键词里本身就含<或>,比如搜索“div”,高亮逻辑会把所有“div”替换成<mark>div</mark>,正常。但如果用户直接输入<script>,且数据源里碰巧包含“script”字样,替换出来的字符串就可能被浏览器当作标签执行。
安全写法是先转义再高亮。转义函数也不长:
function escapeHTML(str) { return str.replace(/[&<>"']/g, function (match) { const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; return map[match]; }); }在高亮之前先对企业内容转义,再对关键词做高亮替换,这样输出到innerHTML就安全了。等你后面学到XSS攻击相关知识时,会发现这个问题在真实项目里有多重要。
6. 拆分与扩展:把Search页面做得更像真实项目
完成了今天的版本,你已经拥有一套完整的Search页面基础能力。如果你的目标是求职或做作品集,还可以在现有代码上继续扩展几个方向,不用重写,只需要叠加。
6.1 搜索结果的分组与筛选条件
现在的结果是一股脑排成一个列表,只有标题匹配。真实场景里搜索常常需要按类型过滤。你可以给数据源增加一个type字段,然后在页面顶部放一排可选筛选项,比如“全部”“教程”“面试”“工具”。点击筛选时,保留关键词筛选条件,同时再套一层类型过滤。这一步实际操作下来,就是filter里多加一个判断条件:
const result = searchData.filter(item => { const isKeywordMatch = item.title.includes(keyword) || item.category.includes(keyword); const isTypeMatch = currentType === 'all' || item.category === currentType; return isKeywordMatch && isTypeMatch; });这个“多条件叠加”的思路,在实际开发中非常高频。商品列表按价格区间筛选、勿扰模式下按分类筛选,全是同一个套路。
6.2 空数据时的兜底推荐
当搜索结果为空,与其只显示一句“没有找到”,不如推荐几个热门关键词给用户点击。你可以预置一个热门词数组,空结果时渲染成标签形式。这会让你的Search页面显得很“聪明”,也体现出对用户下一步操作的引导设计。
6.3 请求后端接口的思考
如果你已经学了fetch,可以把searchData替换成异步请求逻辑。比如:
async function fetchSearchResult(keyword) { const res = await fetch(`/api/search?q=${encodeURIComponent(keyword)}`); const data = await res.json(); return data.list; }注意这里用encodeURIComponent对关键词做编码,防止中文和特殊字符在URL里出错。这个习惯要尽早养成,不然一到真实接口就容易踩中文参数乱码的坑。
6.4 无障碍功能的补充
在主流前端团队的验收清单里,无障碍属于必不可少的一环。你可以给输入框加aria-label,给搜索结果列表加aria-live区域,这样屏幕阅读器能够朗读搜索状态变化。项目展示或面试时点出这一点,远比只会写业务代码更有竞争力。
7. 项目复盘与经验总结
Search页面做下来,我很想分享几个体会。
第一,项目不需要大,但一定要完整。我见过很多学习前端的初学者,一开始就想着要做一个“论坛系统”“电商平台”,结果做了一半陷入技术债务不能自拔。Search页面的体量刚刚好,能触发你处理数据、交互、状态、持久化这些核心问题,又不会让你因为工程复杂度而放弃。
第二,拆解需求再动手,比撸起袖子就写高效得多。我在动手前把功能点列成了清单,写到哪里都不会跑偏。中途想加个“防抖”,也能很自然地插在交互逻辑这一层,不会牵一发动全身。
第三,写代码时要时刻想着“别人会怎么读这段代码”。变量命名清晰、函数职责单一、关键地方加注释,这些东西虽然是“软素质”,但越是早养成,后面的项目就会越受益。
第四,从第十五天这个进度来看,今天涉及的内容并不超纲。你以前用过的forEach、addEventListener、querySelector,今天以新的方式组合在一起,就变成了一个能用的产品。这就是前端有意思的地方——它的创造力不在于写出多罕见的语法,而在于把常见的基础零件拼成有用的东西。
Search页面做完之后的下一步,我建议把这份代码翻新一遍:尝试把渲染逻辑和筛选逻辑拆到两个独立函数文件里,再尝试把数据源换成调用一个真实的公开API,比如GitHub的搜索接口、辞典API等。当你发现不用改HTML、不用改CSS,只是换数据源就能让页面拥有完全不同的内容时,你就摸到了前后端分离的入门门槛。
把今天的代码存好,后面学框架的时候,你可以试着用Vue或React重新实现一遍这个Search页面。到那时你会意识到:虽然框架的写法完全不同,但数据驱动页面的思路没有变,今天打下的基础没有白费。