- 新增 data/view.ts 与 useViewRoute:视图与地址双向同步,支持前进后退 - 条目地址带上所属分组,刷新 / 收藏后「返回」有确定落点 - 服务端只把 /c/** 回落到 index.html,其余路径仍返回 404 - 明细页返回按钮去掉左侧箭头图标
106 lines
3.6 KiB
TypeScript
106 lines
3.6 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react'
|
||
import { parseViewPath, viewPath, type View } from './data/view'
|
||
|
||
/**
|
||
* 视图 ↔ 地址同步。
|
||
*
|
||
* 三个入口都要覆盖:首屏(解析地址)、点击(写地址)、浏览器前进后退(popstate)。
|
||
* 视图本身仍然放在 React state 里,地址只是它的镜像,避免每次渲染都去读 URL;
|
||
* 地址与视图的换算规则收在 `data/view.ts`,这里只管 history。
|
||
*/
|
||
|
||
/** 存在 history.state 里的键:当前这条记录是应用内第几页(0 = 没有上一页) */
|
||
const HISTORY_INDEX_KEY = 'stashIndex'
|
||
|
||
type StashHistoryState = { [HISTORY_INDEX_KEY]?: number }
|
||
|
||
/** 读取当前历史深度;不是本应用写的记录一律当作 0 */
|
||
function historyIndex(): number {
|
||
const state = window.history.state as StashHistoryState | null
|
||
const index = state === null ? undefined : state[HISTORY_INDEX_KEY]
|
||
return typeof index === 'number' ? index : 0
|
||
}
|
||
|
||
/** 当前 history.state 的副本,写入前先保留别人(如浏览器扩展)放的字段 */
|
||
function currentHistoryState(): StashHistoryState {
|
||
const state = window.history.state as StashHistoryState | null
|
||
return state === null ? {} : { ...state }
|
||
}
|
||
|
||
export type ViewRoute = {
|
||
/** 当前视图,初值来自地址栏 */
|
||
view: View
|
||
/** 打开一个视图:默认压一条新历史,`replace` 表示替换当前这条 */
|
||
go: (view: View, options?: { replace?: boolean }) => void
|
||
/**
|
||
* 返回。
|
||
*
|
||
* 应用内有上一页时交给浏览器(`history.back()`),
|
||
* 直接打开条目地址(收藏 / 刷新后没有上一页)时退到 `fallback`,
|
||
* 这时它替换当前记录,避免在历史里留下一条「自己指向自己」的条目。
|
||
*/
|
||
back: (fallback: View) => void
|
||
}
|
||
|
||
export function useViewRoute(): ViewRoute {
|
||
const [view, setView] = useState<View>(() => parseViewPath(window.location.pathname))
|
||
|
||
// 首屏把地址规范化:去掉多余的斜杠 / 尾部斜杠,未知地址归到首页,
|
||
// 并补上历史深度(直接打开时是 0,说明应用内没有上一页)
|
||
useEffect(() => {
|
||
const canonical = viewPath(parseViewPath(window.location.pathname))
|
||
const state = currentHistoryState()
|
||
const recorded = state[HISTORY_INDEX_KEY] !== undefined
|
||
|
||
if (canonical === window.location.pathname && recorded) return
|
||
|
||
window.history.replaceState({ ...state, [HISTORY_INDEX_KEY]: historyIndex() }, '', canonical)
|
||
}, [])
|
||
|
||
// 前进后退:地址变了,视图跟着变
|
||
useEffect(() => {
|
||
const handlePopState = () => setView(parseViewPath(window.location.pathname))
|
||
window.addEventListener('popstate', handlePopState)
|
||
return () => window.removeEventListener('popstate', handlePopState)
|
||
}, [])
|
||
|
||
const go = useCallback((next: View, options?: { replace?: boolean }) => {
|
||
const path = viewPath(next)
|
||
const index = historyIndex()
|
||
|
||
// 已经在同一页(例如重复点同一个侧栏项):不写历史,也不改深度
|
||
if (path === window.location.pathname) {
|
||
setView(next)
|
||
return
|
||
}
|
||
|
||
const replace = options?.replace === true
|
||
const state: StashHistoryState = {
|
||
...currentHistoryState(),
|
||
[HISTORY_INDEX_KEY]: replace ? index : index + 1,
|
||
}
|
||
|
||
if (replace) {
|
||
window.history.replaceState(state, '', path)
|
||
} else {
|
||
window.history.pushState(state, '', path)
|
||
}
|
||
|
||
setView(next)
|
||
}, [])
|
||
|
||
const back = useCallback(
|
||
(fallback: View) => {
|
||
if (historyIndex() > 0) {
|
||
window.history.back()
|
||
return
|
||
}
|
||
|
||
go(fallback, { replace: true })
|
||
},
|
||
[go],
|
||
)
|
||
|
||
return { view, go, back }
|
||
}
|