ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

useHash 实战:用 React Hook 实现轻量级 URL 状态同步方案

useHash 实战:用 React Hook 实现轻量级 URL 状态同步方案 1. 从useHash说起一个被低估的前端状态同步方案最近在整理手头项目里的自定义Hook时翻到了之前封装的一个useHash突然觉得这个工具远比它表面看起来要有意思得多。前端开发里提到状态管理大家第一反应通常是 Redux、Zustand、Pinia 这类库但很多时候我们需要的只是把某个关键状态同步到 URL 上让用户能刷新不丢、能分享、能前进后退——这种场景下useHash这种基于 URL hash 的轻量方案反而是最优雅的解决办法。useHash本质上是监听hashchange事件把window.location.hash解析成结构化数据再通过 Hook 的形式暴露给 React 组件使用。它解决的问题很具体当你的页面状态需要体现在地址栏里、需要支持浏览器前进后退、需要用户复制链接后还能还原到同样界面时把状态放进 hash 是最低成本的手段。比如一个多 Tab 的筛选页、一个带有步骤引导的表单、一个需要分享当前视图的数据看板这些场景用useHash都能获得很好的体验。这篇文章我会从哈希路由的历史讲起厘清 hash 究竟适合存什么、怎么解析、怎么监听然后手写一个完整的useHash实现再把它接入一个真实的多 Tab 场景里跑通。不管你是刚接触 React Hook 的新手还是已经写过不少自定义 Hook 的老手这篇文章都能给你一些可直接落地的参考。2. 为什么是 hash——先搞清楚它的底层逻辑2.1 hash 是什么和 history 路由有什么区别统一资源定位符URL的结构里#后面跟着的那部分叫 fragment片段标识符也就是我们常说的 hash。它最早的作用是让浏览器定位到页面内某个锚点位置比如https://example.com/docs#section-3会滚动到 id 为section-3的元素。但前端框架们后来发现了一个关键特性改变 hash 不会导致浏览器向服务器发起新的请求而window.location.hash也完全可以被 JavaScript 读取和修改这就让 hash 成了单页应用做路由的基础方案。和 HTML5 的 History APIpushState/replaceState相比hash 方案有几个天然优势。第一它不需要服务端做任何配置因为#后面的内容根本不会发送到服务器你随便把一个静态页面部署到任意静态服务器上hash 路由都能直接跑第二它在老版本浏览器里的兼容性更好hashchange事件从 IE8 时代就有第三它对后端无感不会出现部署到 Nginx 后刷新子路由 404 的问题。当然它也有缺点URL 里会多一个#符号不够美观SEO 也不友好——不过对于纯前端应用、特别是需要分享某些状态的场景hash 的实用性远大于它的不美观。2.2 hash 适合存什么不适合存什么想用好useHash第一步是搞清楚“什么状态值得放进 URL”。我个人的经验是适合放进 hash 的状态往往具备三个特征可序列化能变成字符串、体积小hash 太长会很难看、可分享用户希望把这个状态分享给别人。典型的例子是当前激活的 Tab 标签页的 key当前的分页页码和每页条数筛选条件中的某个分类 ID弹窗或抽屉的打开状态比如详情面板是否展开某个步骤条当前走到第几步数据看板里当前选中的时间范围不适合放进 hash 的信息也很多。比如表单里输入到一半的草稿内容——你总不希望用户每次打字都往 URL 里塞一大段文字吧再比如一些敏感信息hash 会出现在浏览器历史记录和分享出去的链接里隐私数据放进去等于泄露还有频繁变化的状态也不适合比如鼠标实时坐标、动画进度这种每秒变几十次的值放进 hash 会导致历史记录被刷屏浏览器前进后退直接变成“幽灵操作”。2.3 hashchange 事件的监听原理hashchange是浏览器原生事件触发时机是 URL 中 hash 部分发生变化时。注意几个细节当用户点击页面内a href#/path这类链接时会触发hashchange当用户手动在地址栏修改 hash 并回车时也会触发当用户点击浏览器的前进/后退按钮切换历史记录时同样会触发。这就是为什么 hash 能天然和浏览器历史机制配合——每一次 hash 变化都会留下历史记录用户可以用回到上一页的按钮返回到上一个状态。在 React 的 Hook 体系里我们需要在useEffect中注册hashchange事件监听器然后在组件卸载时移除监听。这里有个 React 18 下要注意的点React 严格模式StrictMode在开发环境会故意执行两次 effect 挂载和卸载以帮助你发现副作用代码中的问题所以在写监听类的 Hook 时务必把addEventListener和removeEventListener成对写好否则会出现监听器重复绑定。这个我在后面写代码时会专门展示。3. 手写一个完整的 useHash Hook3.1 基础版返回原始 hash 字符串先实现一个最基础的版本能够读取当前 hash 并实时响应变化import { useState, useEffect, useCallback } from react; function getRawHash() { // 去掉开头的 #返回纯 hash 字符串 return window.location.hash.replace(/^#/, ); } function useRawHash() { const [hash, setHash] useState(getRawHash); useEffect(() { const handleHashChange () { setHash(getRawHash()); }; window.addEventListener(hashchange, handleHashChange); return () { window.removeEventListener(hashchange, handleHashChange); }; }, []); const setRawHash useCallback((value, options {}) { const { replace false } options; const nextHash value.startsWith(#) ? value : #${value}; if (replace) { // replace 模式下不会新增历史记录 window.location.replace(nextHash); } else { window.location.hash nextHash; } }, []); return [hash, setRawHash]; }这里有两个细节值得说明。第一个是replace参数默认情况下我们赋值给window.location.hash会新增一条历史记录用户点击后退会回到上一个 hash。但有时候我们希望状态同步更新但不要污染历史记录比如输入框搜索关键字这种高频变化就应该用window.location.replace直接替换当前记录。第二个是getRawHash里用replace(/^#/, )去掉开头的#这样拿到的字符串更干净方便后续解析而设置时再统一补上#避免业务方每次都要纠结带不带#符号。3.2 进阶版支持对象序列化和反序列化实际项目中我们往 hash 里放的往往不是单个字符串而是一组参数。比如#taboverviewpage2size20这时候就需要把对象和字符串互相转换。通常用URLSearchParams来搞定序列化它处理复杂编码和 URL 转义比手写拼接要可靠得多import { useState, useEffect, useCallback, useRef } from react; function parseHashToObject(hashString) { if (!hashString) return {}; // 去掉可能存在的 ? 前缀 const cleanString hashString.startsWith(?) ? hashString.slice(1) : hashString; const params new URLSearchParams(cleanString); const result {}; for (const [key, value] of params.entries()) { result[key] decodeURIComponent(value); } return result; } function stringifyObjectToHash(obj) { const params new URLSearchParams(); Object.entries(obj).forEach(([key, value]) { if (value ! undefined value ! null value ! ) { params.set(key, encodeURIComponent(String(value))); } }); const queryString params.toString(); return queryString ? ?${queryString} : ; } function useHash() { const [hashObject, setHashObject] useState(() parseHashToObject(getRawHash()) ); const setHashObjectCallback useCallback((updater, options {}) { setHashObject((prev) { const next typeof updater function ? updater(prev) : updater; const nextHashString stringifyObjectToHash(next); const fullHash nextHashString ? #${nextHashString} : ; if (options.replace) { window.location.replace(fullHash); } else { window.location.hash fullHash; } return next; }); }, []); // 监听外部变化浏览器前进后退、手动改地址栏 useEffect(() { const handleHashChange () { setHashObject(parseHashToObject(getRawHash())); }; window.addEventListener(hashchange, handleHashChange); return () { window.removeEventListener(hashchange, handleHashChange); }; }, []); return [hashObject, setHashObjectCallback]; } function getRawHash() { return window.location.hash.replace(/^#/, ); }这个版本把对象和 URL 之间的转换封装好了业务组件里直接操作{ tab: overview, page: 2 }这样的对象。注意两点URLSearchParams的toString()方法会自动对中文做百分号编码所以存入之前先encodeURIComponent一下可以防止二次编码问题读取时再用decodeURIComponent还原。这也是为什么很多组件库的 useHash 实现看起来“平平无奇”实际拿下来用却总在中文参数上出问题——多半就是忘记了编码这一层。3.3 完整版hash 解析、合并更新与自定义序列化前面这个版本已经能覆盖大多数场景但如果你的项目同时用到多个 Hook 实例或者需要在字符串形态和对象形态之间切换还需要增强两个能力支持自定义序列化函数和支持多个 key 的局部更新。下面给出一个更完整的实现import { useState, useEffect, useCallback, useRef } from react; const DEFAULT_SEPARATOR ; function useHash({ serialize (obj) { const params new URLSearchParams(); Object.entries(obj).forEach(([key, value]) { if (value ! undefined value ! null value ! ) { params.set(key, encodeURIComponent(String(value))); } }); return params.toString(); }, deserialize (hashString) { const clean hashString.replace(/^\?/, ); const params new URLSearchParams(clean); const result {}; for (const [key, value] of params.entries()) { result[key] decodeURIComponent(value); } return result; }, } {}) { const [hashObject, setHashObject] useState(() deserialize(window.location.hash.replace(/^#/, )) ); const serializeRef useRef(serialize); const deserializeRef useRef(deserialize); serializeRef.current serialize; deserializeRef.current deserialize; useEffect(() { const handleHashChange () { const raw window.location.hash.replace(/^#/, ); setHashObject(deserializeRef.current(raw)); }; window.addEventListener(hashchange, handleHashChange); return () window.removeEventListener(hashchange, handleHashChange); }, []); const setHash useCallback((patch, options {}) { const { replace false, merge false } options; setHashObject((prev) { let next; if (typeof patch function) { next patch(prev); } else if (merge) { next { ...prev, ...patch }; } else { next patch; } const searchString serializeRef.current(next); const nextHash searchString ? #${searchString} : ; if (replace) { window.location.replace(nextHash); } else { window.location.hash nextHash; } return next; }); }, []); return [hashObject, setHash]; } export default useHash;这个版本最核心的改动是提供了serialize和deserialize两个可自定义函数。默认实现基于URLSearchParams覆盖常规场景但你也可以传入 JSON 序列化比如把 hash 存成#{tab:list,page:2}这种结构。用哪个取决于你的偏好。如果追求 URL 简洁易读就用URLSearchParams如果状态嵌套很深、结构复杂JSON 反而更直接。我自己项目里通常用URLSearchParams因为 URL 看起来更干净调试也更直观。还要留意serializeRef和deserializeRef这个设计。如果直接在 effect 里依赖外部传入的serialize/deserialize函数一旦父组件每次渲染时传入新函数引用effect 就会反复重新订阅事件。我用useRef存最新函数引用让订阅只发生一次既能保证读到最新函数又不会产生重复监听。3.4 操作 hash 时避开 React 的批量更新陷阱这个坑是我在实际项目中踩过之后的深刻教训。React 18 中如果在一个事件处理函数里连续多次调用setHash比如setHash({ tab: list, page: 1 }); setHash({ tab: detail, page: 2 });React 会把这两次更新合并成一个渲染周期最终页面上只会看到{ tab: detail, page: 2 }的结果。如果你确实需要连续更新且每次都要写入历史记录最好拆到不同的事件循环里setHash({ tab: list, page: 1 }); setTimeout(() { setHash({ tab: detail, page: 2 }); }, 0);另外还有一个隐藏问题当你在 effect 里读取 hash 并同步初始化状态时如果组件初始化时读到的值与当前 hash 不一致React 会以 hash 为准。但如果你在渲染过程中直接调用setHash又会触发“渲染期间更新同一组件”的警告。正确做法是在useEffect中处理初始化同步。写 Hook 时保持一个原则状态变更统一通过事件回调触发不要在渲染函数体中直接操作 hash。4. useHash 的实际应用场景多 Tab 状态同步4.1 场景设计一个带筛选的数据列表页光讲 Hook 本身难免有点飘我搭一个具体的场景来演示怎么用。假设我们有一个用户订单管理页面页面左侧是功能 Tab全部订单、待付款、待发货、已完成右侧是数据表格和筛选区域筛选条件包括订单状态、时间范围、搜索关键字、当前页码。这些状态如果只存在组件内部 state 里用户刷新页面就丢了如果放进全局状态管理库又拿不到“复制链接分享给同事同事打开就是同一筛选结果”的能力。用useHash是最合适不过了。看下这个组件的大致结构import React from react; import useHash from ./hooks/useHash; const ORDER_TABS [ { key: all, label: 全部订单 }, { key: pending, label: 待付款 }, { key: shipping, label: 待发货 }, { key: completed, label: 已完成 }, ]; function OrderListPage() { const [hashState, setHash] useHash(); // 从 hash 中读取当前 Tab默认 all const activeTab hashState.tab || all; // 从 hash 中读取页码默认 1 const page Number(hashState.page) || 1; // 从 hash 中读取搜索关键字 const keyword hashState.keyword || ; const handleTabChange (nextTab) { // 切换 Tab 时重置页码 setHash( (prev) ({ ...prev, tab: nextTab, page: 1 }), { merge: true } ); }; const handlePageChange (nextPage) { setHash({ page: nextPage }, { merge: true }); }; const handleSearch (kw) { setHash({ keyword: kw, page: 1 }, { merge: true }); }; return ( div div classNametab-list {ORDER_TABS.map((tab) ( button key{tab.key} className{activeTab tab.key ? active : } onClick{() handleTabChange(tab.key)} {tab.label} /button ))} /div div classNamefilter-bar input defaultValue{keyword} onKeyDown{(e) { if (e.key Enter) handleSearch(e.target.value); }} / /div {/* 模拟表格展示不同 Tab 的数据 */} div classNametable-content p当前 Tab: {activeTab}/p p当前页码: {page}/p p搜索关键字: {keyword}/p button onClick{() handlePageChange(page 1)}下一页/button /div /div ); } export default OrderListPage;在这个场景里useHash带来的体验提升非常直观用户停留在“待发货”Tab 的第 3 页刷新浏览器界面依然保持在待发货第 3 页。用户把当前 URL 复制发给同事同事打开后看到的是完全相同的订单筛选状态。用户点击浏览器后退按钮Tab 会从“待发货”变回“全部订单”因为每一次 Tab 切换都写入了一条历史记录。筛选条件、搜索词和页码放在 hash 里组件代码却几乎不需要维护状态同步逻辑一个 Hook 全搞定。4.2 多 Tab 场景下的“回退按钮失效”问题hash 方案用在这种多 Tab 页面上有一个体验死角如果用户把所有 Tab 都点了一遍浏览器的历史记录会积累一长串#tabxxx用户连续按几次后退会觉得页面一直“变来变去”体验并不好。解决方案是路由级别的 Tab 切换使用replace: true只有那些“用户认为应该可以后退”的操作比如从列表页进入详情页才写入历史记录。const handleTabChange (nextTab) { setHash( (prev) ({ ...prev, tab: nextTab, page: 1 }), { merge: true, replace: true } ); };把replace: true加在 Tab 切换上用户点击多个 Tab 时历史记录里始终只有一条后退时不会在不同 Tab 之间反复横跳。详情页、弹窗这类“层级式”状态再走正常的新增历史记录模式。这种区分需要你根据业务具体决策但思路是通用的经常切换的并列状态用 replace 覆盖代表“前进”状态的导航用 push 记录。4.3 在列表异步请求时避免状态竞态有一个容易被忽略的问题是异步请求的竞态。比如用户在 Tab A 发起一个请求请求还没返回用户又切换到了 Tab B当 Tab A 的请求返回时如果直接渲染数据界面就会显示出错的数据。把 Tab 状态放在 hash 里后解决办法也简单请求的回调里判断当前 hash 中的 Tab 是否还是发起请求时的 Tab如果不是就丢弃这次结果。const handleTabChange async (nextTab) { const requestIdRef useRef(0); const currentRequestId requestIdRef.current; setHash({ tab: nextTab, page: 1 }, { merge: true, replace: true }); const data await fetchOrderList({ tab: nextTab, page: 1 }); // 如果期间用户又切换了 Tab丢弃过期响应 if (currentRequestId requestIdRef.current) { setTableData(data); } };这里用requestIdRef递增编号只有最新请求的响应才被保留。类似的技巧也可以用 AbortController 直接取消过期请求但requestIdRef的写法更简单直观对任何异步的结果都适用。5. useHash 进阶玩法从组件隔离到跨页面通信5.1 与全局状态管理结合hash 作为状态的“URL 镜像”有些场景下useHash并不是要取代状态管理库而是作为全局状态的“URL 镜像”。举例来说你在 Zudand 或 Redux 中维护了一个筛选条件对象每次筛选条件变化时额外同步一份写入 hash这样 URL 就能反映当前界面的状态。当用户从另一个入口进入页面时需要把 hash 里的内容读取出来重新初始化全局状态。这样做的价值在于状态管理库负责组件间的共享和响应式更新而 hash 负责跨会话、跨链接的状态恢复。两者各司其职互不干扰。我的习惯是定义一个工具函数function applyHashToStore(store, hashString) { const parsed parseHashToObject(hashString); store.setState({ filter: parsed.filter ? JSON.parse(parsed.filter) : undefined, page: Number(parsed.page) || 1, }); }应用启动时先调用一次把 hash 里的参数注入到全局 store 里之后 store 中筛选条件变化时再反过来把新状态写回 hash。要注意避免死循环store 变化回调里写 hash 时务必判断当前 hash 和要写入的值是否相同不同才写。5.2 hash 作为跨页面通信的轻量通道有些场景需要不同页面之间传递简单信息比如从列表页跳转到详情页后详情页操作完成返回列表页希望列表能自动刷新。这种“返回后要刷新”的意图可以通过 hash 传递。列表页在返回时监听hashchange发现 hash 从“列表页状态”变回“列表页状态但带上一个refresh1标记”时就知道要重新拉取列表数据。// 详情页返回列表页时 setHash({ tab: all, refresh: Date.now() }, { merge: true }); // 列表页监听 hashchangerefresh 变化时触发数据刷新 useEffect(() { const handleHashChange () { const parsed parseHashToObject(getRawHash()); if (parsed.refresh) { refreshList(); } }; window.addEventListener(hashchange, handleHashChange); return () window.removeEventListener(hashchange, handleHashChange); }, []);这里用Date.now()作为refresh的值能保证每次返回这一跳 hash 都不同从而确保hashchange事件一定触发。如果只用固定的refresh1当前后两页 hash 相同时浏览器不会触发事件刷新逻辑就不会执行。类似这种“轻量通信”还可以用在多开页签同步上多个浏览器页签同时打开同一个应用时可以用window.addEventListener(storage)监听 localStorage但如果是同一页签内的不同页面框架hash 反而是更天然可观察的信号。这种用法不常见但在特定的微前端或复杂页面布局下会很顺手。5.3 hash 状态的可视化调试往 URL 里放状态也有一个附带好处调试非常方便。你不需要打开 React DevTools 去查看某个 state 的值是多少直接看浏览器地址栏就知道当前页面处于什么状态。比如用户反馈“我这边表格空白”你可以让他把地址栏里的 hash 发给你贴到本地一打开问题立刻复现——这比传统方式下“描述半天 截图 网络请求记录”要高效太多。建议在开发环境把useHash和 React DevTools 的Custom Hook面板搭配使用。当你在组件里调用useHash后DevTools 的 Components 面板可以直接展开这个 Hook 看到返回的对象值配合地址栏对照检查能快速定位是 hash 解析的问题还是组件渲染的问题。6. 踩坑记录useHash 使用中常见的坑与排查思路6.1 初始化不一致页面加载时 hash 已有值组件却用默认值覆盖这是我见过最多的问题。组件里写了const [activeTab, setActiveTab] useState(all);然后监听 hashchange 更新activeTab。结果是用户从分享链接进入页面URL 里 hash 是#tabcompleted但组件先渲染了activeTab all页面闪一下“全部订单”然后才变成“已完成”。这种闪烁虽然短暂但很容易被用户捕捉到。解决思路来自 React 官方推荐的初始化模式useState的初始值应该直接读 hash。我的useHash实现里useState(() deserialize(window.location.hash...))在组件挂载的第一个渲染周期就拿到了正确的初始值因此没有闪烁问题。如果你实在没法在初始 state 中读 hash比如 hash 解析依赖异步数据至少也要在首屏通过useLayoutEffect同步避免明显闪烁。6.2 hash 值里有中文或特殊字符导致编码错乱往 URL 里塞中文参数时如果不做编码处理浏览器会自动编码一次但你的代码里如果又手动拼接字符串就会造成双重编码或乱码。比如要存入的值是keyword手机壳未经处理时浏览器地址栏可能显示#keyword%E6%89%8B%E6%9C%BA%E5%A3%B3而代码里再次调用encodeURIComponent就会变成%25E6%2589%258B...这种二次编码结果解析出来就是乱码。我的做法是写入时统一用encodeURIComponent读取时用decodeURIComponent严格保证只编码一次。URLSearchParams.toString()本身会编码所以不要在传入params.set之前再次编码。如果项目里还会用到 hash 字符串手写拼接尤其要小心这种双重编码问题。6.3 hash 长度不要无节制增长虽然 hash 没有明确的长度限制但一个塞了几百个字符的 URL 分享出去既不美观在很多即时通讯工具里也可能被截断或转义出错。我建议 hash 里的参数总长度控制在 100 个字符以内。如果筛选条件很复杂可以考虑只把“关键的几个标识”写进 URL详细条件继续放在 store 或 localStorage 里通过一个简短的会话 ID 关联。这样既保留了可分享性又不需要把全部状态序列化进 URL。6.4 与前端路由库的冲突问题如果项目里同时用了 React Router 的 HashRouter那么useHash监听的可能不是同一个 hash。HashRouter用#/path这种格式来管理路由如果你的业务还希望往同一个 hash 里塞业务参数比如#/order?tabpendingpage2那useHash解析时就要先去掉#/order这部分路径再解析?后面的参数。这会让两者协作变得复杂一些。我实际的建议是如果已经用了 HashRouter就尽量把业务参数放到?查询字符串里由useHash只解析?之后的内容如果项目用的是 BrowserRouter那么业务参数放不放进 hash 就看你的取舍了——不放也行因为 BrowserRouter 支持的?参数同样能被监听和管理只是需要额外的守卫处理。7. useHash 与组件库生态站在巨人的肩膀上加倍干活7.1 两种主流的开源 useHash 实现对比其实社区里成熟的开源实现有很多比较有代表性的有两个方向。一个是react-router-dom生态里自带的useSearchParams——它的能力范围更宽可以操作 URL 的查询字符串但它面向的是整个 URL 而不仅是 hash。另一个是react-use库里的useHash它的实现思路和我在前面写的基础版类似但只返回原始 hash 字符串不负责对象结构和多 key 解析。如果你只是想把一个简单状态同步到 URL直接引react-use就够了如果你的业务需要多个字段还是自己封装一个带序列化能力的版本更合适。对比一下各方案的特点方案返回类型更新模式额外能力适用场景自写基础版字符串手动简单直接仅需同步一个字符串react-use useHash字符串手动原生事件封装快速接入不想写监听useSearchParams结构化手动与路由深度集成已使用 React Router 的项目自写增强版对象手动/合并自定义序列化、replace模式多字段、可分享状态7.2 封装团队内部通用的 useHash 时该注意什么如果你们团队有多个项目都要用到类似的useHash我建议在封装时额外考虑几点参数命名风格统一用短横线分隔page-size还是驼峰pageSize建议统一否则不同项目里 URL 格式五花八门跨项目分享链接时体验割裂。默认值策略哪些字段即使为空也要写进 URL哪些空值就直接省略我的默认策略是“空串和 undefined 不写入 URL”解析时用||补默认值这样 URL 更干净。版本兼容若将来 hash 的格式要调整有没有考虑兼容旧版本建议在解析时对旧的字段名做一层映射避免老链接直接失效。单元测试parseHashToObject和stringifyObjectToHash这类纯函数非常适合写单测。把编码、解码、空值过滤、replace 模式行为这些边界都覆盖到团队后续改动才有安全感。8. 扩展思考useHash 还能往哪些方向走从前面的内容可以看出useHash最核心的价值在于它沟通了 UI 状态和 URL 状态。沿着这个思路还能扩展出不少变体。8.1 使用 useHash 实现简单的前端路由对于只包含两三个页面的小工具比如一个纯前端的计算器、一个单页面的问卷完全可以用useHash做一个极简路由。核心逻辑就是定义#/home、#/result这样的路径然后根据 hash 匹配渲染对应组件。不要小看这种做法很多内部后台的“无路由模式”就是这样跑通的省去了引入路由库的重量。const routes { /home: HomePage, /result: ResultPage, /detail: DetailPage, }; function App() { const [hash] useHash(); const path hash.split(?)[0] || /home; const Component routes[path] || NotFoundPage; return Component /; }这样做的好处是零依赖、秒加载面试讲起来也清晰缺点是缺少路由库提供的懒加载、嵌套路由、路由守卫这些能力。所以它适合规模很小的页面不适合大中型的单页应用。8.2 与浏览器历史 API 结合实现更高级状态恢复hash 方案有个局限是状态都堆在 URL 里某些临时性状态比如弹窗是否打开、列表的滚动位置没必要持久化到 URL 中。此时可以利用history.state存放一些非 URL 的临时状态而 hash 只保留核心业务参数。history.state不会显示在地址栏里适合存“编辑器草稿”“列表滚动位置”这类状态。// 切换 Tab 时把滚动位置和弹窗状态存到 history.state const state { scrollTop: container.scrollTop, drawerOpen: true }; window.history.replaceState(state, ); // 用户点击后退时读取 state 恢复界面 window.addEventListener(popstate, () { const state window.history.state; if (state) { container.scrollTop state.scrollTop; setDrawerOpen(state.drawerOpen); } });hash 负责可分享的核心状态history.state 负责不可分享的临时状态两者组合起来能让应用的恢复体验做得非常细致。8.3 在服务端渲染SSR项目中如何安全使用 useHashSSR 场景下一个容易踩的坑是服务端渲染时没有window对象调用window.location.hash会直接报错。所以useHash在服务端必须安全降级。常规做法是function getRawHash() { if (typeof window undefined) return ; return window.location.hash.replace(/^#/, ); }在useState初始值中调用getRawHash()时也做了安全判断这样组件在服务端渲染时拿到的是一个空对象客户端水合后再从真实 hash 中恢复。不过要注意水合阶段如果出现 hash 值与初始渲染值不一致React 可能会报警告这种情况可以在客户端挂载后用 effect 立刻更新一次。具体策略取决于你用的 SSR 框架但我建议把useHash封装成纯客户端 Hook并明确告诉使用方它不参与服务端渲染。9. 一些实操建议与真实体会花了不少文字把useHash从原理讲到了完整实现又做了场景演示和踩坑梳理最后分享几条我自己的经验希望能让你少走点弯路。第一不要把useHash当成万能钥匙。它擅长的是“轻量状态 URL 同步”不擅长管理复杂业务状态。如果你的页面状态有几十个字段、字段之间还有联动校验老老实实用状态管理库不要硬塞进 URL。第二处理好历史记录策略是体验的胜负手。Tab 切换、筛选变化这类高频率操作优先考虑replace模式详情页进入、弹窗打开这类有层级的操作再使用正常 push 模式。你可以把这一策略固化到 Hook 的默认参数里避免业务侧每次都要思考。第三写自测代码时把边界情况过一遍。至少覆盖这几类空 hash、纯#、包含中文的 key-value、包含特殊字符如%与的 value、嵌套对象的 JSON 序列化与解析、replace 模式下历史记录长度不变。边界问题基本都藏在序列化和解析这两个纯函数里。第四从维护者的角度看注释和文档要写清楚序列化格式。因为 URL 里的状态是外部可见的不同人接手时如果不知道某个参数的格式和取值范围很容易改出兼容性问题。我会在 Hook 文件头部写一段注释列出当前项目的 hash 参数模板#tab字符串page数字keyword字符串并注明每个字段的默认值和取值范围。实际用下来useHash是一个性价比极高的工具代码量不大却能明显改善用户体验和可调试性。每次看到用户在群里发来一条带完整 hash 的链接我都能直接复现他们遇到的问题那种“不用远程录像、不用一步步询问”的感觉确实是传统开发流程给不了的。如果你的项目里恰好有类似的“页面状态需要体现在 URL 中”的需求不妨按这篇文章的思路封装一个自己的useHash跑通了之后你会回来感谢它的。
返回列表