路由从 hash 换成 History API(pushState/replaceState/popstate),不再依赖 location.hash
HashNavigation 改名 Navigation(src/lib/navigation.js),接口不变,内部改用真实路径: parseUrlHash/composeUrlHash 改名 parseUrl/composeUrl;navigateTo/navigateReplace 改调 pushState/replaceState 后手动派发 popstate(pushState/replaceState 不会像赋值 location.hash 那样自动触发事件,这里复用 navigateReplace 早就在用的同一个手动 dispatch 手法);goBack() 不用改,history.back() 本来就是真实浏览器历史机制。新增 Navigation.currentUrl() 替代调用方 原本直接读的 window.location.hash(这个类从来没包装过这个读取)。 一处需要专门处理的边界情况:当 path[0] 是空字符串("不选中侧栏视图、只开一个会话"这个仓库里 的合法状态)时,拼出的真实路径开头会变成双斜杠,被浏览器当协议相对地址解析、试图跳到一个叫 会话id的host,pushState 直接抛 SecurityError。composeUrl/parseUrl 内部用占位 token "_" 处理 这种情况,对外的调用方(19个文件)完全无感。 tinode-web.jsx 的 handleHashRoute 改名 handleRouteChange,监听从 hashchange 换成 popstate; service-worker.js 的推送通知点击跳转手动改写成同样的路径格式(这个文件在打包之外单独执行, 没法 import Navigation)。index.html/index-dev.html 加了 <base href="/">——所有静态资源引用 都是相对路径,hash 路由下页面路径永远是 /,换真实路径后深层地址刷新会按当前目录解析出错误 资源地址导致白屏,这一行是标准修法。nginx.conf 的 try_files 兜底规则本来就适用两种路由,只 更新了注释。 实机验证:地址栏不再有 #;点击会话/设置等各类视图正常跳转;浏览器前进后退和应用内"返回" 按钮都正常;收藏页内部的类型 tab 切换(navigateReplace)确认不产生新的历史记录;在会话页/ 个人中心页刷新、直接用完整深层路径打开新标签页都能正确恢复到位,资源正常加载不白屏。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
97a6b88d59
commit
4e8f45d7d8
+79
-44
@@ -1,7 +1,7 @@
|
||||
// Utility class for hash navigation.
|
||||
// Utility class for client-side routing via the History API.
|
||||
|
||||
// Every `navigateTo` call assigns `location.hash`, which the browser already records as a normal
|
||||
// history entry -- `history.back()` would land on whatever the user actually saw before, for free.
|
||||
// Every `navigateTo` call pushes a real history entry -- `history.back()` would land on whatever
|
||||
// the user actually saw before, for free.
|
||||
// The product's "back" buttons never used that: each page hardcodes a single destination view name
|
||||
// (e.g. Profile's `back={friend ? 'friend-profile' : 'chat-standard'}`), which is only ever right for
|
||||
// the one entry point its author had in mind. Reach the same page from a second place (search result
|
||||
@@ -12,16 +12,28 @@
|
||||
// counter is in-memory and intentionally does not survive those).
|
||||
let navigationCount = 0;
|
||||
|
||||
export default class HashNavigation {
|
||||
// Parse hash as in http://www.example.com/path#hash as if it were
|
||||
// path and arguments.
|
||||
static parseUrlHash(hash) {
|
||||
// Placeholder path segment for "no sidepanel view selected, just a topic" (path[0] === '', the last
|
||||
// entry in tinode-web.jsx's own SIDE_PANEL_VIEWS whitelist). A real empty path segment can't be
|
||||
// represented safely as the FIRST segment of a real URL path: joining ['', 'grpXXX'] would produce
|
||||
// "/grpXXX" before the leading '/' this class adds is even accounted for, so the final string comes
|
||||
// out "//grpXXX" -- and a path starting with '//' is resolved by pushState/replaceState as a
|
||||
// protocol-relative reference (same scheme, host becomes "grpXXX"), not a same-origin path. Swapping
|
||||
// in this token keeps composeUrl/parseUrl round-tripping path[0] === '' exactly like before, entirely
|
||||
// inside this file -- no caller needs to know the placeholder exists.
|
||||
const EMPTY_VIEW_PLACEHOLDER = '_';
|
||||
|
||||
export default class Navigation {
|
||||
// Parse a route string (path + optional '?args') into path segments and params.
|
||||
static parseUrl(url) {
|
||||
// Split 'path/?args' into 'path/' and 'args', path -> parts[0], args->path[1].
|
||||
const parts = hash.split('?', 2);
|
||||
const parts = url.split('?', 2);
|
||||
const params = {};
|
||||
let path = [];
|
||||
if (parts[0]) {
|
||||
path = parts[0].replace('#', '').split('/');
|
||||
path = parts[0].replace(/^\//, '').split('/');
|
||||
if (path.length > 1 && path[0] === EMPTY_VIEW_PLACEHOLDER) {
|
||||
path[0] = '';
|
||||
}
|
||||
}
|
||||
if (parts[1]) {
|
||||
parts[1].split('&').forEach((arg) => {
|
||||
@@ -35,34 +47,45 @@ export default class HashNavigation {
|
||||
return {path: path, params: params};
|
||||
}
|
||||
|
||||
// The current route string, in the same shape `navigateTo`/`navigateReplace` consume and
|
||||
// `parseUrl` expects -- callers that used to read `window.location.hash` directly read this
|
||||
// instead (this class never wrapped that read itself, so every call site has to switch explicitly).
|
||||
static currentUrl() {
|
||||
return window.location.pathname + window.location.search;
|
||||
}
|
||||
|
||||
static navigateTo(url) {
|
||||
navigationCount++;
|
||||
window.location.hash = url;
|
||||
window.history.pushState(null, '', url);
|
||||
// pushState never fires popstate on its own (only real back/forward/go() does) -- dispatch a
|
||||
// synthetic one so tinode-web.jsx's listener re-derives state from the new URL, same trick
|
||||
// navigateReplace below already uses for replaceState's identical silence.
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}
|
||||
|
||||
// For switching between sub-tabs of the *same* page (e.g. content-library-view.jsx's favorites/
|
||||
// history type tabs), not for navigating to a different page. `navigateTo`/`location.hash =`
|
||||
// always pushes a new history entry, so clicking through N tabs pushes N entries -- the system
|
||||
// back button (and this class's own goBack()) then has to step back through every tab the user
|
||||
// ever visited before it reaches whatever page they were actually on before this one. `replaceState`
|
||||
// updates the hash (still correct for a refresh or a copied link) without adding a history entry,
|
||||
// so it doesn't inflate navigationCount either -- a tab switch was never a real in-app navigation
|
||||
// to begin with. replaceState doesn't fire `hashchange` on its own, so dispatch one manually for
|
||||
// the existing `window.addEventListener('hashchange', ...)` listener (tinode-web.jsx's
|
||||
// handleHashRoute) to pick up; that handler re-reads window.location.hash itself rather than the
|
||||
// event, so a synthetic event with no detail is indistinguishable from a real one to it.
|
||||
// history type tabs), not for navigating to a different page. `navigateTo` always pushes a new
|
||||
// history entry, so clicking through N tabs pushes N entries -- the system back button (and this
|
||||
// class's own goBack()) then has to step back through every tab the user ever visited before it
|
||||
// reaches whatever page they were actually on before this one. `replaceState` updates the URL
|
||||
// (still correct for a refresh or a copied link) without adding a history entry, so it doesn't
|
||||
// inflate navigationCount either -- a tab switch was never a real in-app navigation to begin with.
|
||||
// replaceState doesn't fire `popstate` on its own, so dispatch one manually for the existing
|
||||
// `window.addEventListener('popstate', ...)` listener (tinode-web.jsx's handleRouteChange) to pick
|
||||
// up; that handler re-reads the current URL itself rather than the event, so a synthetic event with
|
||||
// no detail is indistinguishable from a real one to it.
|
||||
static navigateReplace(url) {
|
||||
window.history.replaceState(null, '', '#' + url);
|
||||
window.dispatchEvent(new Event('hashchange'));
|
||||
window.history.replaceState(null, '', url);
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}
|
||||
|
||||
// Real "back": retraces the hash the user was actually on before, via native browser history,
|
||||
// Real "back": retraces the route the user was actually on before, via native browser history,
|
||||
// rather than a page's own guess at where it must have been reached from. Returns whether it
|
||||
// actually did that -- every call site already has its own hardcoded-destination `navigate(...)`
|
||||
// fallback (a page's `back`/`backUid`/`backTopic` props) for the one case this can't cover: a
|
||||
// fresh load, deep link, or refresh, where there's no in-app history yet to go back to (this
|
||||
// counter is in-memory by design and does not survive those) -- callers keep using that fallback
|
||||
// unchanged, just gated behind `if (!HashNavigation.goBack()) { ...existing fallback... }`.
|
||||
// unchanged, just gated behind `if (!Navigation.goBack()) { ...existing fallback... }`.
|
||||
static goBack() {
|
||||
if (navigationCount > 0) {
|
||||
navigationCount--;
|
||||
@@ -72,8 +95,20 @@ export default class HashNavigation {
|
||||
return false;
|
||||
}
|
||||
|
||||
static composeUrlHash(path, params) {
|
||||
let url = path.join('/');
|
||||
static composeUrl(path, params) {
|
||||
let segments = path;
|
||||
// !segments[0] (not === '') deliberately also catches a sparse hole at index 0 -- parseUrl('')
|
||||
// returns path: [] (empty, not ['']; inherited as-is from the original hash-based parseUrlHash,
|
||||
// which had the identical `if (parts[0])` guard), and setUrlTopic('', topic) then does
|
||||
// `parsed.path[1] = topic` on that empty array, leaving index 0 an actual hole rather than ''.
|
||||
// Array#join treats a hole the same as an empty string either way, so this was always silently
|
||||
// producing the same "no view, just a topic" shape -- harmless under the old scheme since a
|
||||
// leading '#//' is still just inert fragment text, but a real path starting '//' is parsed by
|
||||
// pushState/URL as protocol-relative (steals the topic id as the new host) and throws.
|
||||
if (segments.length > 1 && !segments[0]) {
|
||||
segments = [EMPTY_VIEW_PLACEHOLDER, ...segments.slice(1)];
|
||||
}
|
||||
let url = '/' + segments.join('/');
|
||||
const args = [];
|
||||
for (const key in params) {
|
||||
if (params.hasOwnProperty(key)) {
|
||||
@@ -88,50 +123,50 @@ export default class HashNavigation {
|
||||
return url;
|
||||
}
|
||||
|
||||
static addUrlParam(hash, key, value) {
|
||||
const parsed = HashNavigation.parseUrlHash(hash);
|
||||
static addUrlParam(url, key, value) {
|
||||
const parsed = Navigation.parseUrl(url);
|
||||
parsed.params[key] = value;
|
||||
return HashNavigation.composeUrlHash(parsed.path, parsed.params);
|
||||
return Navigation.composeUrl(parsed.path, parsed.params);
|
||||
}
|
||||
|
||||
static removeUrlParam(hash, key) {
|
||||
const parsed = HashNavigation.parseUrlHash(hash);
|
||||
static removeUrlParam(url, key) {
|
||||
const parsed = Navigation.parseUrl(url);
|
||||
delete parsed.params[key];
|
||||
return HashNavigation.composeUrlHash(parsed.path, parsed.params);
|
||||
return Navigation.composeUrl(parsed.path, parsed.params);
|
||||
}
|
||||
|
||||
static removeUrlParams(hash, keys) {
|
||||
static removeUrlParams(url, keys) {
|
||||
if (!Array.isArray(keys)) {
|
||||
keys = [keys];
|
||||
}
|
||||
const parsed = HashNavigation.parseUrlHash(hash);
|
||||
const parsed = Navigation.parseUrl(url);
|
||||
for (const key of keys) {
|
||||
delete parsed.params[key];
|
||||
}
|
||||
return HashNavigation.composeUrlHash(parsed.path, parsed.params);
|
||||
return Navigation.composeUrl(parsed.path, parsed.params);
|
||||
}
|
||||
|
||||
static setUrlSidePanel(hash, sidepanel) {
|
||||
const parsed = HashNavigation.parseUrlHash(hash);
|
||||
static setUrlSidePanel(url, sidepanel) {
|
||||
const parsed = Navigation.parseUrl(url);
|
||||
parsed.path[0] = sidepanel;
|
||||
return HashNavigation.composeUrlHash(parsed.path, parsed.params);
|
||||
return Navigation.composeUrl(parsed.path, parsed.params);
|
||||
}
|
||||
|
||||
static setUrlInfoPanel(hash, infopanel) {
|
||||
const parsed = HashNavigation.parseUrlHash(hash);
|
||||
static setUrlInfoPanel(url, infopanel) {
|
||||
const parsed = Navigation.parseUrl(url);
|
||||
if (infopanel) {
|
||||
parsed.params.info = infopanel;
|
||||
} else {
|
||||
delete parsed.params.info;
|
||||
}
|
||||
return HashNavigation.composeUrlHash(parsed.path, parsed.params);
|
||||
return Navigation.composeUrl(parsed.path, parsed.params);
|
||||
}
|
||||
|
||||
static setUrlTopic(hash, topic) {
|
||||
const parsed = HashNavigation.parseUrlHash(hash);
|
||||
static setUrlTopic(url, topic) {
|
||||
const parsed = Navigation.parseUrl(url);
|
||||
parsed.path[1] = topic;
|
||||
// Close InfoView on topic change.
|
||||
delete parsed.params.info;
|
||||
return HashNavigation.composeUrlHash(parsed.path, parsed.params);
|
||||
return Navigation.composeUrl(parsed.path, parsed.params);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user