URL 状态与数据加载
问题:刷新后筛选条件为什么消失了
商品页把搜索词只放在组件 state 中,点击分享、刷新或浏览器返回时,页面都无法恢复原来的筛选。搜索词不是某个组件的私有编辑状态;它描述“当前正在查看哪组结果”,因此 URL 更适合作为持久、可复制的来源。
下面的实验只使用 React 19 和浏览器 API。它演示 URL、History、Effect 与取消的边界,没有实现路由框架:没有路径匹配、嵌套路由、服务端回源配置、滚动恢复或表单导航。
完整实验:URL 驱动搜索并淘汰旧结果
在已有 Vite React 项目中,将下面保存为 src/App.jsx。假加载器用延迟模拟网络,因此例子无需后端;换成真实 fetch 时,状态协议不变。
import { useEffect, useState } from 'react'
const PRODUCTS = ['React 指南', '路由手册', '缓存实验', '测试清单']
function readQuery() {
return new URLSearchParams(window.location.search).get('q') ?? ''
}
function loadProducts(query, { signal }) {
const delay = query === 'react' ? 800 : 200
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
resolve(PRODUCTS.filter(name => name.toLowerCase().includes(query.toLowerCase())))
}, delay)
signal.addEventListener('abort', () => {
clearTimeout(timer)
reject(new DOMException('请求已取消', 'AbortError'))
}, { once: true })
})
}
export default function App() {
const [query, setQuery] = useState(readQuery)
const [view, setView] = useState({ status: 'idle', items: [], error: null })
useEffect(() => {
function restoreFromHistory() {
setQuery(readQuery())
}
window.addEventListener('popstate', restoreFromHistory)
return () => window.removeEventListener('popstate', restoreFromHistory)
}, [])
useEffect(() => {
const controller = new AbortController()
let relevant = true
setView(previous => ({ ...previous, status: 'loading', error: null }))
loadProducts(query.trim(), { signal: controller.signal }).then(
items => {
if (relevant) setView({ status: 'success', items, error: null })
},
error => {
if (relevant && error.name !== 'AbortError') {
setView({ status: 'error', items: [], error })
}
},
)
return () => {
relevant = false
controller.abort()
}
}, [query])
function navigate(nextQuery) {
const url = new URL(window.location.href)
if (nextQuery) url.searchParams.set('q', nextQuery)
else url.searchParams.delete('q')
window.history.pushState(null, '', url)
// pushState 不触发 popstate;当前页面必须主动更新 React state。
setQuery(nextQuery)
}
return (
<main>
<h1>资料搜索</h1>
<nav aria-label="快速筛选">
{['react', '路由', '缓存', ''].map(value => (
<button key={value || 'all'} onClick={() => navigate(value)}>
{value || '全部'}
</button>
))}
</nav>
<p>当前查询:<strong>{query || '全部'}</strong></p>
{view.status === 'loading' && <p role="status">加载中…</p>}
{view.status === 'error' && <p role="alert">{view.error.message}</p>}
{view.status === 'success' && (
<ul>{view.items.map(item => <li key={item}>{item}</li>)}</ul>
)}
</main>
)
}运行 npm run dev,先点“react”,立刻点“缓存”。第二个请求先完成;800ms 后,第一个请求即使曾被启动,也不能把列表改回 React 结果。再按浏览器返回键,查询和列表都应恢复。
过程:四个系统依次交接
navigate修改地址栏。history.pushState只创建历史条目,不会刷新页面,也不会派发popstate。- 当前页面立即调用
setQuery;用户按返回/前进时,则由浏览器派发popstate,监听器重新读取 URL。 query变化后,新 Effect 建立新的请求控制器;React 会先清理旧 Effect,再运行新 Effect。- 清理既尝试
abort()节省工作,也把旧闭包的relevant设为false。后者是最终提交状态的门:即使真实请求在fetch之后还有无法取消的解析步骤,旧链也不能写回。
URL 是筛选状态的来源,query 是当前渲染使用的快照。这里没有用另一个 Effect“把 state 同步回 URL”,因为事件已经知道用户想导航;在事件里同时更新 History 和 state,因果关系更直接。
换成真实 fetch
只需替换加载器,仍要检查 HTTP 状态:fetch 收到 404 或 500 时通常会正常兑现 Promise。
async function loadProducts(query, { signal }) {
const response = await fetch(`/api/products?q=${encodeURIComponent(query)}`, { signal })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.json()
}请求瀑布来自启动依赖,不来自组件大小
假设父组件等待项目详情成功后才渲染成员列表,子组件挂载后才在 Effect 请求成员。即使两次请求各需 400ms,成员请求也要等第一份结果后才开始,总等待约为两段之和。把子组件文件变小、增加 memo,不能改变这条启动链。
如果两份数据都只依赖 URL 中已有的 projectId,可以在同一个加载入口启动它们:const project = loadProject(id); const members = loadMembers(id);,再用 Promise.all([project, members]) 等待。这里是启动两份独立工作的方式,不是可直接调用的真实接口。相反,若成员接口需要第一份响应才提供的权限令牌,就确有依赖,不能机械并发。
以现有模拟加载器验证启动顺序时,在函数开始处记录 query 与时间,并把两次调用放到一个事件中。观察“何时开始”比只看最终总耗时更能判断是否存在瀑布。路由 loader 可以在组件挂载前组织依赖与预取;SSR 也能提前产生初始内容,但两者都不自动消除所有串行等待。
适用边界
- 用户每输入一个字符都写入历史会让返回键难用。即时搜索常用
replaceState更新当前条目,提交搜索时再用pushState。 - 查询参数适合筛选、分页、排序等可序列化页面状态;密码、访问令牌、大对象和只在输入过程中存在的草稿不应进入 URL。
- URL 可见不等于有权限。服务端仍须校验身份和资源访问权。
- 直接访问
/products/42是否返回应用 HTML,由服务器或托管平台配置决定,React 核心无法修复 404 回源。 - 手写 Effect 加载容易产生瀑布、重复请求和无缓存返回。真实产品应优先评估框架的 route loader 或专门的服务端状态库,并把那项依赖写进项目契约。
练习:同时保存页码
扩展 URL 协议:查询参数 page 只接受十进制整数 1..20,缺失或非法时读取为 1;切换搜索词时页码重置为 1;翻页使用 pushState。实现纯函数 readSearch(search) 与 writeSearch(baseUrl, next),不得修改传入的 URL,并用断言覆盖 page=0、page=3.5、page=21、合法查询和输入 URL 未被修改。
下一篇
路由错误边界与嵌套路由会继续拆分 URL 匹配、布局组合、404 与渲染异常。