路由从 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:
Your Name
2026-09-07 21:00:39 +08:00
co-authored by Claude Sonnet 5
parent 97a6b88d59
commit 4e8f45d7d8
46 changed files with 398 additions and 330 deletions
+9 -4
View File
@@ -131,7 +131,12 @@ self.addEventListener('notificationclick', event => {
return;
}
const urlHash = '#/' + data.topic;
// '_' mirrors the placeholder Navigation.composeUrl (src/lib/navigation.js) substitutes for an
// empty path[0] ("no sidepanel view, just open this topic") -- a real empty first segment would
// make the path start with '//', which pushState/URL resolve as a protocol-relative reference
// (host becomes "data.topic") rather than a same-origin path. This file runs outside the webpack
// bundle (importScripts, not an ES module) so it can't import the constant; kept in sync by hand.
const urlPath = '/_/' + data.topic;
event.waitUntil(self.clients.matchAll({
type: 'window',
@@ -141,7 +146,7 @@ self.addEventListener('notificationclick', event => {
let anyClient = null;
for (let i = 0; i < windowClients.length; i++) {
const url = new URL(windowClients[i].url);
if (url.hash.includes(data.topic)) {
if (url.pathname.includes(data.topic)) {
// Found the Tinode tab with the right topic open.
return windowClients[i].focus();
} else {
@@ -154,7 +159,7 @@ self.addEventListener('notificationclick', event => {
// navigate to the right topic.
if (anyClient) {
const url = new URL(anyClient.url);
url.hash = urlHash;
url.pathname = urlPath;
return anyClient.focus().then(thisClient => {
return thisClient.navigate(url);
});
@@ -162,7 +167,7 @@ self.addEventListener('notificationclick', event => {
// Did not find a Tinode browser tab. Open one.
const url = new URL(self.location.origin);
url.hash = urlHash;
url.pathname = urlPath;
return clients.openWindow(url);
}));
});