前两周把页面布局、盒模型、Flex、JavaScript基础这些过了一遍之后,我一直想找个机会把它们串起来。第十五天的任务,给自己定的是搭一个带交互逻辑的Search页面:有输入框、有热门标签、有搜索结果列表,还得有加载中和空状态。正好把前面学的东西揉在一起练一遍。这篇记录下当天的完整搭建过程,包括选型思路、每个模块的实现代码,以及踩过的几个不大不小的坑。
1. 第十五天该做点什么:Search页面的功能拆解
1.1 为什么第十五天适合拿搜索页练手
前端新手容易陷入两个极端:一是教材看了一堆,代码一行没写;二是直接去啃复杂框架源码,被各种抽象概念劝退。到了第十五天这个节点,最需要的是一个"看得见、摸得着、能跑起来"的完整页面。
搜索页面很合适。它的功能足够单一清晰,不会牵扯复杂业务,但麻雀虽小五脏俱全:表单交互、异步数据、状态切换、渲染逻辑、本地存储,这些前端基础能力一个不落。做完这个页面,你对"一个前端页面是怎么从零到一"的认知会比看十遍文档都牢。
我选的技术栈也相当朴素:纯HTML + CSS + 原生JavaScript,没有引入框架和UI库。原因很简单,这个阶段最重要的是把原生的手感和思想练扎实。等后面接触Vue或React,你会发现组件化、响应式这些概念,本质上都是从原生写法里抽象出来的。
1.2 给自己列的功能清单
动手写代码之前,我先把需求拆成一个个小点。这个习惯是从第一周就养成的,先想清楚要做什么,再讨论怎么做。
需求清单是这么列的:
- 顶部固定搜索栏,包含返回按钮、输入框、搜索按钮
- 输入关键词后回车或点击搜索,触发搜索
- 热门搜索标签,点击标签直接填入关键词并搜索
- 异步模拟请求,展示加载中的过渡效果
- 搜索结果列表,包含模拟的标题、描述、链接
- 无结果时展示空状态,带一句友好提示
- 搜索失败时展示错误状态,提供重试按钮
- 记录最近搜索关键词,存本地,刷新不丢
这个清单看着不长,但它覆盖了前端日常开发里绝大多数场景。我建议你也做一个类似清单,每完成一项就划掉一项,成就感会支撑你坚持学下去。
1.3 页面文件结构的安排方式
按第十天学到的模块化思想,我把项目文件分成了这么几个模块:
search-demo/ ├── index.html ├── css/ │ └── style.css └── js/ ├── data.js // 模拟数据存放 ├── search.js // 搜索逻辑与渲染 └── history.js // 本地记录模块为什么不把所有代码塞进一个JS文件?第十五天的你已经可以体会模块的好处了:data.js只负责数据,search.js只负责搜索,history.js只负责历史记录。改动数据的时候不用翻逻辑代码,出bug了也能按文件定位问题。这个习惯后面进入团队协作时尤其重要。
2. 静态结构与样式:先把页面的"骨架"搭端正
2.1 HTML结构:一个form标签的意外收获
搜索页的HTML部分,第一版我差点直接写div套div。后来想着"输入框加按钮"本质上是一个表单提交动作,就改用form包起来了。
<div class="search-page"> <header class="search-header"> <span class="back-btn">←</span> <form class="search-form" id="searchForm"> <input type="search" id="searchInput" class="search-input" placeholder="搜索感兴趣的内容" autocomplete="off" /> <button type="submit" class="search-btn">搜索</button> </form> </header> <main class="search-body"> <section class="hot-section" id="hotSection"> <h2 class="section-title">热门搜索</h2> <div class="hot-tags" id="hotTags"></div> </section> <section class="history-section" id="historySection"> <h2 class="section-title">最近搜索</h2> <div class="history-tags" id="historyTags"></div> </section> <section class="result-section" id="resultSection"> <div class="result-empty" id="resultEmpty">搜索你感兴趣的内容吧</div> </section> </main> </div>一个细节是input的type用了search而不是text,浏览器会给搜索框带上清除按钮,移动端键盘也会变为"搜索"键,这是免费的体验提升。另一个细节是autocomplete="off",避免浏览器下拉历史记录和页面的"最近搜索"撞车,看着就很重复。
2.2 CSS布局:固定顶部栏与可滚动内容的组合
顶部搜索栏需要始终可见,所以用了固定定位。
.search-header { position: sticky; top: 0; z-index: 100; display: flex; align-items: center; gap: 8px; padding: 10px 16px; background: #fff; border-bottom: 1px solid #f0f0f0; }这里我用sticky而不是fixed,原因是sticky仍然占据文档流空间,页面内容不会突然"钻"到头部底下,position切换的适配麻烦少很多。sticky在iOS Safari和安卓主流浏览器上支持都很稳定,移动端场景几乎可以放心用。
卡片列表的样式是当天花时间最多的地方。我想要的效果是白底卡片、圆角、浅阴影,每个结果卡片之间用间距分开,底部放一个链接按钮。阴影我选了非常轻的0 2px 8px rgba(0,0,0,0.04),既有一点点层次感,又不会显得太重。
2.3 热门标签与最近搜索的UI复用思路
热门搜索和最近搜索,从视觉上看都是一个个tag。我没写两套一样的样式,而是复用了同一个tag类:
.tag { display: inline-block; padding: 6px 14px; margin: 4px; font-size: 14px; color: #333; background: #f7f7f7; border-radius: 16px; cursor: pointer; transition: background 0.2s; } .tag:active { background: #e8e8e8; }复用样式比复制粘贴好在哪?一是改圆角、改颜色只改一个地方;二是两个区域长得一致,页面的整体感更强。做前端,警惕重复代码应该从第一天就开始。弹窗、按钮、标签这类小东西,能抽出来就尽量抽。
热门标签和最近搜索的数据,都通过JS动态渲染,HTML里只留空的容器。这样数据一变,页面跟着变,不用去改HTML。渲染函数后面会统一讲。
3. 交互逻辑:让输入框真正"活"起来
3.1 防抖函数:为什么输入搜索必须防抖
如果你在input上直接绑input事件,每敲一个字就发一次搜索请求,打字稍微快一点,一秒能触发七八次搜索。专业接口遇到这种流量会被限流,就算不限流,连续渲染七八次结果列表,页面也会闪烁抖动,体验很差。
所以要防抖。防抖的核心思想很简单:把多次触发合并成一次,你停止输入一段时间后,才真正执行搜索。我给每次输入都设置了500ms的计时器,500ms内如果有新输入,就把上一个计时器清掉重来。
function debounce(fn, delay = 500) { let timer = null; return function (...args) { if (timer) clearTimeout(timer); timer = setTimeout(() => { fn.apply(this, args); timer = null; }, delay); }; }我之前一开始写的版本没有保存timer=null这一行,结果在极端情况下计时器判断会错乱。这个细节虽然小,但能教会你一个道理:跟异步相关的代码,状态管理一定要严格,定时器的复位和置空必须配套。
3.2 三条触发搜索的路径
搜索行为一共有三个入口:回车/点搜索按钮、点热门标签、点最近搜索tag。它们的处理逻辑有共通之处也有差异。
form表单的submit事件天然覆盖了回车和点击搜索按钮两个动作,所以我把主搜索逻辑挂在submit上:
searchForm.addEventListener('submit', (e) => { e.preventDefault(); const keyword = searchInput.value.trim(); if (!keyword) return; performSearch(keyword); });e.preventDefault()是必须的,否则表单刷新页面,前端程序就白写了。trim()是为了去掉首尾空格,防止用户搜了个寂寞。
热门标签和最近搜索的点击,需要事件委托。因为标签是动态渲染的,直接在单个tag上绑监听,新渲染出来的tag就不会有点击响应。事件委托把监听挂在父容器上,利用事件冒泡识别被点的tag:
hotTags.addEventListener('click', (e) => { const tag = e.target.closest('.tag'); if (!tag) return; const keyword = tag.dataset.keyword; searchInput.value = keyword; performSearch(keyword); });dataset是HTML5提供的数据挂载方式,热门标签渲染时把关键词存进data-keyword里,点击时再取出来。比从标签文本偷字符串要可靠——万一以后标签要显示带图标的,文本就取不到了。
3.3 状态机思维:加载中、成功、空结果、失败
搜索区域应该有四种状态,很多新手只做成功渲染和空结果,加载中和错误状态漏掉,结果网速慢时页面白屏、接口报错时页面永远不提示。我这次专门把状态切换显式地做进了逻辑:
function setState(state) { loadingEl.hidden = state !== 'loading'; errorEl.hidden = state !== 'error'; emptyEl.hidden = state !== 'empty'; listEl.hidden = state !== 'list'; }四种状态用一个函数统一管理,不管哪种情况进入,页面显示都是可控的。这里用hidden属性而不是style.display,是因为hidden更语义化,而且可以搭配CSS去做淡入淡出的过渡。
状态机的本质是:你先把所有可能出现的情况列全,再为每种情况定义显示内容。做前端UI,最忌讳的是"除了成功情况我别的都没想"。
4. 数据层的临时方案:Mock接口与异步渲染
4.1 写一个能骗过自己的Mock搜索接口
这个阶段没有真实后端,但前端不能因为没接口就停下,于是我自己造了一个Mock数据源。学前端一定会遇到这类临时方案,它的核心诉求是:接口的调用方式要贴近真实,以后换真接口时改动最小。
data.js里先放一个模拟数据库:
const mockDB = [ { title: '前端学习路线完整指南', desc: '从HTML、CSS到JavaScript,再到框架和工程化,一条比较稳妥的学习顺序。', link: '#article-1' }, // ...若干条数据 ];然后写一个模拟异步搜索函数。我给它包了Promise和setTimeout,模拟真实接口的网络延迟和异步特性:
function searchByKeyword(keyword) { return new Promise((resolve, reject) => { setTimeout(() => { const kw = keyword.toLowerCase(); const result = mockDB.filter( item => item.title.toLowerCase().includes(kw) || item.desc.toLowerCase().includes(kw) ); if (Math.random() < 0.2) { reject(new Error('网络开了个小差')); } else { resolve(result); } }, 600); }); }里面故意留了20%的失败概率,是为了让错误状态不是摆设。实际开发里接口失败是很常见的事,从Mock阶段就习惯处理异常,比上线后被用户骂了再补要舒服很多。
4.2 渲染结果列表与HTML拼接的边界控制
搜索结果渲染,我用的是模板字符串拼接。拼接需要注意转义,搜索关键词是用户自己输入的,如果直接插进HTML,理论上存在注入风险。虽然本地Mock没有安全问题,但养成习惯比较好:凡是渲染用户输入,都要先做转义。
function escapeHtml(str) { return str.replace(/[&<>"']/g, m => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[m]); }渲染函数把一组结果映射成一组卡片HTML,然后一次性塞进容器:
function renderResult(list) { if (!list.length) { setState('empty'); return; } const html = list.map(item => ` <div class="result-item"> <h3 class="result-title">${escapeHtml(item.title)}</h3> <p class="result-desc">${escapeHtml(item.desc)}</p> <a class="result-link" href="${item.link}">查看详情</a> </div> `).join(''); listEl.innerHTML = html; setState('list'); }map加join的写法,比for循环拼字符串更简洁。你在控制台里把数组和map玩熟,会发现很多UI渲染都是这个套路。
4.3 localStorage让最近搜索"记住用户"
最近搜索用localStorage实现。它的API非常简单:setItem存,getItem取,存进去的是字符串。
const HISTORY_KEY = 'search_history_list'; const HISTORY_MAX = 8; function getHistory() { try { return JSON.parse(localStorage.getItem(HISTORY_KEY)) || []; } catch (e) { return []; } } function addHistory(keyword) { const list = getHistory().filter(item => item !== keyword); list.unshift(keyword); const trimmed = list.slice(0, HISTORY_MAX); localStorage.setItem(HISTORY_KEY, JSON.stringify(trimmed)); renderHistory(); }有几个细节值得解释一下。存的是数组经过JSON.stringify后的字符串,取出来时必须JSON.parse才能变回数组。用filter把同关键词先清掉,再unshift到最前面,是为了保证"最近搜索"里不出现重复项,且最新搜索永远排第一位。限制最多8条,防止历史越积越长,这是产品细节,前端也能主动做。
try/catch包一下JSON.parse是好习惯,因为localStorage可能被清空、被篡改,也可能存了脏数据,解析失败时不让页面崩溃。
5. 移动端适配和细节打磨
5.1 从上到下核对不同尺寸设备的布局表现
写完功能后,我用开发者工具的设备模拟切了iPhone SE、iPhone 15 Pro、安卓常见机型宽度,逐个检查。
主要问题是搜索栏在窄屏下会挤压输入框:返回箭头、输入框、搜索按钮三者并排,360px宽的屏幕下输入框只剩大约200px。解决方案是给search-input设flex: 1,让输入框吃掉剩余空间,按钮保留固有宽度。
类似地,结果卡片在超大屏上会被拉得很宽,文字行太长不容易读。我给主体内容设置了一个max-width: 720px并居中,兼顾手机和平板。
5.2 字号、间距与触摸热区的规范
移动端触摸目标的尺寸不能太小。这次做热门搜索tag时,我一开始设置的padding导致tag高度只有30px,手指点起来很费劲。后来把垂直padding加到8px,整体高度接近36px,手感明显改善。
正文和描述的字号,我分别定为15px和13px,标题层级之间拉开差距。热区、字号这些细节,做的时候麻烦一点,用户用起来会舒服很多。
5.3 软键盘触发搜索与键盘遮挡问题
input的type="search"在移动端键盘上会显示"搜索"按钮。第一次测试时我发现,在输入法里点搜索并不触发form的submit事件,而是把键盘收起来。解决方案是监听keydown事件,判断key为Enter时手动调用performSearch。
searchInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); const keyword = searchInput.value.trim(); if (keyword) performSearch(keyword); } });注意这里有一个双触发问题:如果form的submit已经处理了输出,再加上keydown监听,搜索可能被执行两遍。我的做法是keydown里只负责触发,且同时在submit里判断"禁止重复执行同关键词的搜索",用requestSeq这类递增序号来保证只响应最新一次请求。这个套路在真实项目中非常常用:
let requestSeq = 0; async function performSearch(keyword) { const seq = ++requestSeq; setState('loading'); addHistory(keyword); try { const data = await searchByKeyword(keyword); if (seq !== requestSeq) return; // 过期响应直接丢弃 renderResult(data); } catch (err) { if (seq !== requestSeq) return; setState('error'); } }requestSeq的玩法,核心是给每次请求编号。响应回来时如果编号不是最新,说明用户已发起新搜索,旧结果不该渲染。这是处理异步竞态的最低成本方案,原理上跟服务器端用token校验一个路数。
6. 提交前的自查:我用了哪些检查维度
6.1 功能与异常路径的完整性检查
编码完成后,我按清单从头到尾点了一遍:
- 输入关键词回车搜索:正常
- 输入空格纯空值搜索:被拦截,无响应
- 连续快速输入再停顿:只触发最后一次搜索,无闪烁
- 点热门标签:自动填入并搜索
- 点历史标签:自动填入并搜索
- 无匹配结果:出现空状态文案
- 模拟请求失败:出现错误状态,点重试可恢复
- 刷新页面:最近搜索记录还在
有一个之前没预料到的情况:当用户在结果列表存在时,把输入框内容清空,页面仍然停留在旧结果。这并不算bug,但体验上不够聪明,于是我加了一条逻辑:输入框内容为空时清空结果区,恢复初始的"搜索你感兴趣的内容吧"提示。
以下是最终的错误状态视觉方案,用一张表格记录实现情况:
| 状态 | 触发条件 | 展示内容 | 处理方式 |
|---|---|---|---|
| loading | 请求发出到返回之间 | 居中旋转加载动画 | 防抖后置loading,避免闪烁 |
| list | 有匹配结果 | 卡片列表 | 渲染前清空旧列表 |
| empty | 无匹配结果 | 文字加插画 | 文案含关键词提示 |
| error | Mock请求失败 | 失败提示加重试按钮 | 点击重试重新发起 |
6.2 代码注释与提交信息:写给未来的自己
写注释这件事,我给自己定了规矩:只解释为什么,不解释是什么。代码本身已经说明了它在做什么,注释再重复一遍就是噪音。比如requestSeq那段注释写的是"旧响应不覆盖新结果",这就是在解释原因,比"定义请求序号变量"有用一百倍。
git提交我用的信息是:
feat: 搭建Search页面 - 实现搜索防抖与输入触发搜索 - 增加热门标签与最近搜索记录 - 完成空状态、错误状态与重试机制 - 使用Mock接口模拟异步请求commit message写成这样,两周后回头翻记录,能一眼看出那次提交做了什么、包含哪些点。git log就是项目的历史书,提交信息就是每章的标题,写得含糊等于历史书缺页。
6.3 一个更敢做的猜想:Search页面的第二天改造
第十五天的Search页面是一个功能完整的原生JS版本。但说实话,跑通之后我隐约感觉到了原生写法的天花板:状态切换靠一个函数手动控制,渲染靠拼接字符串,历史记录和搜索逻辑彼此耦合。如果需求再加一个筛选条件、再加一个分页,这套代码会迅速变臃肿。
再往后学几天,我会尝试用一个轻量框架把这个页面重构成组件化版本。状态、模板、逻辑分离之后,同样的功能代码量会少很多,维护起来也轻松。这种"先把原生做透,再用框架重构"的路径,我觉得对新手理解框架价值特别有帮助。你在学Vue或React的时候如果觉得概念抽象,回想一下今天手写的状态切换和渲染拼接,很多概念就落地了。
把时间拉回第十五天的最后时刻,这个Search页面从需求清单变成可交互的成品,一共花了一个下午。其中有防抖的思考,有状态管理的梳理,有局部刷新和渲染的取舍,也有对异常情况的处理。前端二字听起来很大,落到一个搜索页上,无非是把每个细节都处理到位罢了。