纯前端检索的三种经典断点
一个把全部数据内联到页面、在浏览器里过滤的搜索/筛选页很常见。没有后端参与时,它通常有三个共通的断点:
- 无法分享:“看看这个搜索结果”只能靠口头描述关键词。
- 刷新即失忆:F5 之后输入框和结果列表回到初始状态。
- 回退语义断裂:从结果点进详情页再按浏览器后退,列表页重新初始化,刚才的查询不见了——地址栏却仍然只写着
/search。
根因只有一个:查询状态只活在 DOM 里,没有同步到 URL。
最小实现:读一次,写每次
页面初始化时从地址栏恢复状态:
const initialQuery =
new URLSearchParams(window.location.search).get('q')?.trim() || '';
if (initialQuery) {
input.value = initialQuery;
}
render(initialQuery);
每次渲染时把状态写回去。注意这里用 replaceState 而不是 pushState:用户连续敲字产生的中间态不该逐个进入历史栈,否则一次后退只会退回“上一个没打完的词”:
const syncUrl = (query) => {
const url = new URL(window.location.href);
if (query) {
url.searchParams.set('q', query);
} else {
url.searchParams.delete('q');
}
const target = `${url.pathname}${url.search}`;
if (target !== `${window.location.pathname}${window.location.search}`) {
window.history.replaceState(null, '', target);
}
};
在防抖后的 render(query) 入口调用 syncUrl(query.trim()),三行接入完毕。写入前先比较 pathname + search,避免把同值 replaceState 变成无意义的噪音操作。
与客户端路由共存的两个坑
坑一:View Transitions / SPA 路由会缓存页面 DOM。 以 Astro 的 <ClientRouter> 为例,站内导航默认缓存当前页 DOM,后退命中缓存时不会重新执行页面脚本。如果只写“初始化时读一次 URL”,从详情页后退回来时状态其实还在(DOM 没销毁),看起来正常;但若你的页面脚本会在每次 astro:page-load 重新初始化,就必须确认重初始化路径同样从 URL 读状态,而不是拿 DOM 的残留值当真值。两种生命周期模型下,“URL 是唯一事实来源” 都成立,只是恢复时机不同。
坑二:replaceState 不影响 popstate。 它不会制造历史条目,因此回退行为保持干净:从详情页后退时命中列表页缓存,前进/后退跨查询时走正常的页面加载并重新解析 URL。
顺手补上的可访问性收益
状态进 URL 后,还有两个免费的好处:
- 结果计数容器加上
role="status" aria-live="polite",屏幕阅读器会在查询结果数变化时播报“8 条结果”; - 浏览器自动补全开始认识你的查询词,地址栏输入站点名就能直达带参数的历史 URL。
边界与取舍
- 只同步值得分享的状态。 视图切换、滚动位置这类个人偏好放
localStorage更合适;查询词、筛选组合放 URL。全部塞进地址栏会让 URL 难以阅读。 - 保持参数极简。 一个
q足矣;把高亮位置、拼音开关等实现细节暴露为查询参数,等于给未来的重构挖坑。 - 空态也同步。 用户删光关键词时记得
delete('q'),否则留下一个/search?q=的尾巴,看起来像坏了的分享链接。
小结
把“当前在搜什么”写进地址栏,本质是选择URL 而非组件内部状态作为事实来源。实现只需初始化时读、渲染时 replaceState 写,换来分享、刷新恢复与回退语义三项完整能力——这可能是所有前端页面里性价比最高的一次升级。