今天要解的問題
Day23 結尾說,可能接下來會想把單檔設計翻掉。現在要開搞拉阿!
在一開始這個專案時,我是用 claude cowork 作為我的旅行,因此都會集中在單一的 HTML,好方便我對照,因此在 Day22 開始針對接下來的使用後,這個檔案長成 196 KB:外層頁面(頁首、紀錄)裡面用沙箱 iframe 塞了整份指南。
但隨著我的操作,頁面變得更複雜,跳轉變得超級多元,我有發現會不小心使用上一頁來返回,但就會出錯,這也導致我間接設計了許多的來回按鈕,我不堪其擾,因此我丟給 Claude 的是這段:
因為目前是單個檔案為一個旅程,但我發現這樣會導致我按上一頁,會回到總覽,非常困擾。為了避免這個狀況,我反而要設計更多的跳轉,因此請你協助我捨棄原本單個 html 檔案的要求,請替我設計 釜山與之後範本適合的資料結構

想法與取捨
光拆檔案沒用,要每個畫面都有網址
Claude 先派一個 agent 把現有的旅行頁從頭讀過一遍,回來的結論很短:整份指南在 iframe 裡,全檔沒有一處 pushState、location.hash 或 popstate。換分頁、換日期、跳到某家店、點備註目錄,全都只是切換顯示,瀏覽器的歷史從頭到尾只有一筆。
所以它問我的第一個問題,開頭就先把這點講清楚:
按「上一頁」要能回到上一個畫面,前提是每個畫面都有自己的網址,光是拆成多個檔案做不到這點。
我原本以為只是把檔案拆分就好了,但其實這份架構可能沒那麼簡單,拆檔案是基本,但要如何讓整體流程順暢,每個網址都有適合的畫面。
單一外殼+# 網址,還是每個分頁一個 HTML
它給了兩個方案:
- 單一外殼+
#網址(它的建議):每趟旅行一個小的index.html,資料拆成 JSON,畫面網址像#plan/11-02、#areas/seomyeon/sm-07、#notes/pass。切換不重新載入 - 每個分頁一個 HTML:
plan.html/areas.html/notes.html/log.html各自獨立,切分頁整頁重新載入
第二種的缺點寫在選項說明裡:店家、目錄這類頁內跳轉還是要另外用 #,等於兩套機制都要做;離線還要多快取幾個頁面,而離線快取是 Day19 就定下的需求。所以我選了它建議的第一種。
哪些動作要多一筆上一頁
第二個問題是粒度:同一分頁裡換日期、換地區篩選,要不要也記進上一頁?
- 只記跳轉(它的建議):換分頁、從行程點到地區或店、點目錄才會多一筆;日期列和篩選只改網址
- 全部都記:換日期、換篩選也各算一步
日期與篩選,已經是習慣用手來點,所以不需要。
電腦版:header 放在左邊的 sidebar
計畫第一版只處理手機。想說趁這次,所以補了一句:
想要你同時規劃電腦版本,電腦版本的 header 放在左邊的 sidebar
第二版的計畫改成三種寬度,靠 CSS 斷點切換:
- 電腦版:1024px 以上是左側固定 sidebar,日期、地區清單、備註目錄掛在目前分頁底下
- 平板:601–1023px,固定 header,但沒有漢堡選單
- 手機維持原樣
程式複製進每趟資料夾,不共用
外殼的 app.js、app.css 每趟旅行都一樣,直覺上應該放在共用的 /app/ 底下,但因為整體網站設計上:
- 單篇密碼的訪客拿不到
/app/*。Day19 做的密碼鎖 Worker 只依/trips/<slug>/判斷這組單篇密碼能看哪一趟;程式放在共用路徑,就得改 Worker - 結束的旅行會停在當時的版本。之後改模板,不會把已經去過的那幾趟弄壞
代價是每改一次模板,進行中的那一趟要再複製一次。這一輪後面幾個修正,commit 裡都是 templates/trip/app.js 和 public/trips/2026-10-29/app.js 一起改。
★ 不再依賴順序
舊的指南用「第幾區第幾家」當店家的 id,★ 標記存的是 it-3-7 這種序號。所以舊的規則檔裡有一條:
新增的店一律加在該地區
items最後面:★ 標記用序號當 id,插在中間會錯位
既然要改網址,網址也需要指到某家店,Claude 就順便給每家店一個固定的 id(seomyeon-07),★ 和網址都用它。這條限制跟著拿掉了。
實作
改動落在 27 個檔案。每趟旅行變成一個資料夾:
public/trips/<slug>/
index.html app.js app.css 外殼與程式,不用改
trip.json 頁首、日期、設定(路線顏色、子分類、住宿、機場)
plan.json 建議行程
areas.json 地區與店家(每家店有穩定 id)
notes.json 備註(每段有 id)
log.json 紀錄(實際每日行程)
新開一趟旅行就是把 templates/trip/ 整個複製過去,只改五個 JSON。
載入資料、先存進離線快取
外殼打開後同時抓五個 JSON,抓完立刻請 Service Worker 把這趟的 8 個檔案都存起來:
const FILES = ['trip', 'plan', 'areas', 'notes', 'log'];
let TRIP, PLAN, AREAS, NOTES, LOG;
try {
[TRIP, PLAN, AREAS, NOTES, LOG] = await Promise.all(FILES.map(async f => {
const res = await fetch(f + '.json');
if (!res.ok) throw new Error(f + '.json ' + res.status);
return res.json();
}));
} catch (err) {
$('#main').innerHTML = `<p class="loading">資料載入失敗(${esc(err.message)})。連上網路後重新整理。</p>`;
return;
}
// 先把這趟的檔案都存進離線快取(Service Worker 只收同網域、最多 20 個)
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(reg => reg.active && reg.active.postMessage({
type: 'precache', urls: ['./', 'app.css', 'app.js', ...FILES.map(f => f + '.json')].map(u => new URL(u, location.href).pathname),
})).catch(() => {});
}
指南不再放進 iframe,直接畫在頁面裡。iframe 當初是為了把兩份文件隔開,現在資料是自己的 JSON,不需要隔離,iframe 和外層之間用 postMessage 代存資料的那一層也跟著拿掉。
路由:一個 go()、一個 apply()
網址的格式寫在 app.js 最上面:
/* 網址決定畫面,每個畫面都有自己的 #:
#plan/10-29 建議行程某一天
#areas #areas/<地區> 地區(只顯示某一區)
#areas/<地區>/<店 id> 捲到某家店
#areas/<地區>/<店 id>/map 「回地區」:該區示意圖,亮起這家店
#notes #notes/<段落 id> 備註某一段(#notes/<段落>/<列 id> 捲到表格的某一列)
#log #log/10-29 紀錄某一天
「跳轉」(換分頁、從行程點到地區或店、目錄、Pass 連結)會多一筆上一頁;
同一分頁裡換日期、換地區篩選只改網址,不多一筆。 */
「只記跳轉」落到程式上,就是 go() 的兩條路:
// 跳轉:先記下目前這一筆捲到哪,再新增一筆;replace:只改網址
function go(hash, opt = {}){
if (opt.replace) {
history.replaceState({ y: 0 }, '', hash);
apply('replace', opt.top);
return;
}
history.replaceState({ ...(history.state || {}), y: scrollY }, '');
if (location.hash !== hash) history.pushState({ y: 0 }, '', hash);
apply('push', opt.top);
}
跳轉之前先用 replaceState 把目前捲到的位置 scrollY 寫進這一筆的 state,再 pushState 新的一筆。之後按上一頁回到這一筆時,就知道要捲回哪裡。
呼叫的地方照前面的分法:
$('#areasel').addEventListener('change', e => go(e.target.value === 'all' ? '#areas' : '#areas/' + e.target.value, { replace: true }));
// ...
if (b) go('#notes/' + b.dataset.note);
// ...
if (it) { go(itemHash(it.dataset.item || it.dataset.spot)); return; }
if (ar && AREA[ar.dataset.area]) { go('#areas/' + ar.dataset.area); return; }
地區下拉選單帶 replace: true;備註目錄、店家、行程裡的地區連結都是一般的 go(),會多一筆。
所有畫面都由 apply() 依網址畫出來,第一個參數說明是怎麼來的:
// how:push(跳轉)/replace(同分頁換日期、換地區)/pop(上一頁、下一頁)/init(打開或重新整理)
function apply(how, toTop){
const { tab, p } = parse();
const moved = tab !== curTab;
show(tab);
closeMenu();
const pop = how === 'pop';
scrollMode = how === 'init' ? 'instant' : 'smooth';
lastInto = null;
if (tab === 'plan') {
let i = p[0] ? PLAN.findIndex(d => dkey(d.d) === p[0]) : -1;
if (i < 0) i = defaultDay();
selectDay(i);
if (PLAN[i]) canon('#plan/' + dkey(PLAN[i].d));
if (!pop) top0();
} else if (tab === 'areas') {
// ...
}
if (pop) {
// 回到這一筆離開時捲到的位置
const y = history.state && Number.isFinite(history.state.y) ? history.state.y : 0;
scrollTo({ top: y, behavior: 'instant' });
if (tab === 'notes') updateNoteMark();
} else if (moved && how === 'push' && toTop) top0();
}
pop 的時候不捲到店家或段落,而是捲回 state.y,也就是離開這一筆時的位置。canon() 把不完整的網址補齊(#plan → #plan/10-30),用的是 replaceState,不會多一筆。
最後是開機的三行:
if ('scrollRestoration' in history) history.scrollRestoration = 'manual';
// ...
addEventListener('popstate', () => apply('pop'));
history.replaceState({ y: 0 }, '');
apply('init');
scrollRestoration = 'manual' 關掉瀏覽器自己的捲動還原,捲動位置全部由 state.y 決定;不然兩邊會搶著捲。
舊的 ★ 搬過來
手機上已經有一份用舊頁存的 ★、打包清單和上次選的日期。第一次打開新頁時,把序號換成店家 id:
/* ===== 舊版(單檔頁面)存的紀錄搬過來:★ 從「地區序-店序」換成店 id,選到的日期從序號換成日期 ===== */
(function migrateOld(){
const raw = store.get('doc-guide');
if (raw == null) return;
let old = {};
try { old = JSON.parse(raw) || {}; } catch(e){}
const pick = suffix => { const k = Object.keys(old).find(x => x.endsWith('-' + suffix)); return k ? old[k] : null; };
try {
const star = JSON.parse(pick('star') || '{}');
const out = readObj('star');
Object.keys(star).forEach(k => {
const m = /^it-(\d+)-(\d+)$/.exec(k);
const it = m && AREAS[+m[1]] && AREAS[+m[1]].items[+m[2]];
if (it) out[it.id] = 1;
});
if (Object.keys(out).length) store.set('star', JSON.stringify(out));
} catch(e){}
// ...
store.del('doc-guide');
})();
這段能成立,是因為轉換時 areas.json 的順序跟舊頁一模一樣:Claude 在刪掉一次性的轉換腳本之前,先把五個 JSON 跟舊頁的資料逐欄比對,除了多出來的 id,其他都一致。
檢查與格式
check.mjs 從「用字串切出 const AREAS = [」改成直接讀 JSON,多了 id 的檢查:
if (!ID_RE.test(it?.id || '')) fail(at, 'id 必須是小寫英數字與 -(新店用「地區 id-下一個序號」,例如 seomyeon-24)');
else if (itemIds.has(it.id)) fail(at, `店家 id 重複:${it.id}(★ 標記與網址靠它,不能重複,建立後也不要改)`);
else itemIds.add(it.id);
Claude 故意把模板裡的一個店名和一個 id 改錯,確認兩個都會被擋下。
JSON 交給 Claude 改的機會很多,新加的 fmt.mjs 把格式固定下來,店家、行程一筆一行,diff 才看得懂:
// 這些欄位底下的陣列:每個元素一行
const LINE_KEYS = new Set(['items', 'ev', 'blocks', 'st', 'rows', 'slots']);
export function fmt(v, ind = '', key = null) {
const c = JSON.stringify(v);
if (v === null || typeof v !== 'object') return c;
if (key !== null && !LINE_KEYS.has(key) && c.length <= 80) return c;
const ni = ind + ' ';
if (Array.isArray(v)) {
if (!v.length) return '[]';
const parts = LINE_KEYS.has(key) ? v.map((x) => ni + JSON.stringify(x)) : v.map((x) => ni + fmt(x, ni, ''));
return '[\n' + parts.join(',\n') + '\n' + ind + ']';
}
// ...
}
電腦版 sidebar
同一個 <nav>,1024px 以上改成固定在左邊、直向排列:
/* 電腦:頁首是左側固定 sidebar;日期、地區、備註目錄改放在 sidebar 目前分頁底下 */
@media (min-width:1024px){
.nav{position:fixed;top:0;bottom:0;left:0;width:var(--side);border-bottom:0;border-right:1px solid var(--rule);overflow-y:auto;overscroll-behavior:contain}
.nav .in{flex-direction:column;align-items:stretch;gap:0;max-width:none;padding:24px 14px 32px 18px}
/* ... */
main{margin-left:var(--side)}
.wrap{max-width:860px;padding:0 32px}
/* ... */
}

頁首時間軸從橫的一條改成直的,手機上貼在頁首下方的日期列、備註目錄,在這個寬度改成 sidebar 裡目前分頁底下的子清單,點下去走同一個 go()。上線之後我又要了總覽頁的 sidebar,做法一樣。
收尾前再掃一次 diff:兩個漏洞
實作完重讀一次 diff,撈到兩個問題,都跟「拆成多檔」有關。
離線少了 7 個檔。 總覽頁原本會把還沒結束的旅行預先存到手機,存的只有 /trips/<slug>/ 這一個網址。單檔時代這樣就夠了;現在只存到外殼,旅途中沒網路打開,五個 JSON 抓不到,整頁只剩「資料載入失敗」。改成每趟送一次完整的清單:
// 把還沒結束的旅行先存到這台裝置,旅途中沒網路也能開。
// 旅行頁是外殼+程式+資料檔,全部都要存;Service Worker 一次最多收 20 個,所以一趟送一次
const TRIP_FILES = ['', 'app.js', 'app.css', 'trip.json', 'plan.json', 'areas.json', 'notes.json', 'log.json'];
if ('serviceWorker' in navigator && open.length && navigator.onLine) {
navigator.serviceWorker.ready.then(reg => {
open.forEach(t => reg.active?.postMessage({ type: 'precache', urls: TRIP_FILES.map(f => '/trips/' + t.slug + '/' + f) }));
}).catch(() => {});
}
Claude 清空快取、只打開總覽頁,確認 8 個檔都進去了。
網址壞掉整頁空白。 像 #notes/%E5 這種壞掉的網址(例如通訊軟體截掉一半的連結),decodeURIComponent 會丟錯,apply() 在 show() 之前就中斷,每個分頁都還是 hidden。改成當作沒指定:
function parse(){
// 網址被截斷或手打錯(例如多一個 %)時解碼會丟錯,當成沒有指定,回到預設分頁
let raw = location.hash.replace(/^#\/?/, '');
try { raw = decodeURIComponent(raw); } catch(e) { raw = ''; }
const p = raw.split('/').filter(Boolean);
return TABS.includes(p[0]) ? { tab: p[0], p: p.slice(1) } : { tab: TABS[0], p: [] };
}
另外兩件事 Claude 明說沒測到:本機的單篇密碼登不進去(全站密碼正常),單篇密碼能不能讀到新的 JSON 是讀 Worker 程式確認的;工具也模擬不了真正斷網,只確認了檔案都在快取裡。
踩到的坑
「備註的跳轉有問題」,修到的是另一個 bug
上線之後,我丟了一句很模糊的話:
目前備註的跳轉有問題
Claude 在手機寬度重現到一個問題:從展開的目錄點一段,頁面多捲了大約 500px,那一段落在畫面下半部。原因是捲動時要讓開的高度 --stick,用的還是目錄展開時的高度。目錄收起後 ResizeObserver 還沒更新,scrollIntoView 就已經照舊的值捲下去了。修法是捲動前先重算:
// 捲動前先重算 --stick:目錄或篩選剛收起時,ResizeObserver 還沒更新,會用到展開時的高度而捲過頭
const into = (el, block = 'start') => { setStick(); lastInto = [el, block]; el.scrollIntoView({ block, behavior: scrollMode }); };
這是真的 bug,但它回報時自己也加了一句:「不確定是不是你遇到的那個」,請我講是手機還是電腦、點了哪裡。
不是。我回傳的是平板寬度下備註目錄的截圖:畫面上是「網路・App」那一節,目錄亮的卻是前一節「必帶物品」。位置是對的,亮錯段落。
固定寫死的 120px
亮哪一節是用 IntersectionObserver 判斷的,舊的寫法:
const io = new IntersectionObserver(es => {
es.forEach(x => x.isIntersecting ? seen.add(x.target) : seen.delete(x.target));
const first = [...seen].sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top)[0];
if (first) markNote(first.dataset.noteId);
}, { rootMargin: '-120px 0px -55% 0px' });
-120px 是假設頂端貼住的區域固定高 120px。平板寬度(601–1023px)目錄會折成兩行,頁首加目錄約 170px。中間那 50px,上一節的尾巴還藏在目錄底下,卻還算在「看得到」的範圍,所以總是亮成上一節。點最後的「機場・退稅」會亮「替代方案」,也是同一個原因。
修法是不寫死,改用每種寬度實際量到的 --stick,找「標題已經捲過貼頂列下緣」的最後一節:
const update = () => {
queued = false;
if (curTab !== 'notes' || notePin) return;
const line = (parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--stick')) || 0) + 24;
let cur = secs[0];
for (const s of secs) { if (s.getBoundingClientRect().top <= line) cur = s; else break; }
markNote(cur.dataset.noteId);
};
還有一個情況量了也沒用:頁尾那幾節內容很短,捲到底了標題也到不了頂端,照規則永遠會亮成前一節。所以從目錄或連結跳過來時,先把所點的那一節釘住(notePin),等使用者自己捲動才放開:
const release = () => { if (notePin) { notePin = ''; update(); } };
['wheel', 'touchstart', 'keydown', 'mousedown'].forEach(t => addEventListener(t, e => {
if (e.type === 'mousedown' && e.target.closest && e.target.closest('[data-note],.subnav')) return;
release();
}, { passive: true }));
放開的條件是滾輪、觸控、按鍵、滑鼠按下,不是 scroll 事件。跳轉本身的平滑捲動也會觸發 scroll,用它來放開,釘住就沒意義了。點目錄本身的 mousedown 要排除,不然一按下去就先放開了。
驗證時還有個小插曲:Claude 在瀏覽器面板測的前幾次讀數不對,看起來像新邏輯也亮錯。後來判斷邏輯本身沒問題,是面板被藏在背景時 scroll 事件晚送到,讀到的是舊狀態。最後 390、944、1280px 三種寬度,點目錄(網路・App、交通、替代方案、機場・退稅)和自己捲動(住宿、錢・換錢)亮的都是對的那一節。
「跳轉有問題」這種描述,Claude 先重現了一個、修掉,同時說不確定是不是同一個;真正的那個是看到截圖才找到。
小結
- 每個畫面都有網址:
#plan/10-29、#areas/<地區>/<店 id>、#notes/<段落>;跳轉多一筆上一頁、回去時捲回原位,換日期和篩選只改網址 - 資料夾結構:外殼+
app.js/app.css+五個 JSON,新旅行複製templates/trip/;程式每趟各放一份,不改 Worker、舊旅行不會被新模板弄壞 - ★ 改用店家 id:新店不必再加在最後面,舊頁存的 ★、打包清單、選到的日期自動搬過來
- 電腦版:旅行頁和總覽頁都是左側 sidebar;備註目錄在平板寬度不再亮錯段落
- 順手修掉示意圖下方店家清單被截成「…」的問題:店名一行、站名和步行時間放到下一行
離出發剩不到一個月。就是不斷地加東西,然後也在不斷的修改網站體驗!
本文同步發表於 kiwi-walk.com:https://kiwi-walk.com/blogs/engineer/ironman-2026-day24-hash-routes/
