[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"blog-list":3},[4,327,929,1619,3243,3486,4430,5224,5805,6420,7037,7372,7868,8161,8700,9256,10592,11251],{"id":5,"title":6,"body":7,"category":311,"date":312,"description":313,"draft":314,"extension":315,"meta":316,"navigation":317,"path":318,"readingTime":319,"seo":320,"stem":321,"tags":322,"__hash__":326},"blog\u002Fblog\u002Fdebugging-legacy-code.md","既有系統除錯思路：從重現問題到建立防護",{"type":8,"value":9,"toc":303},"minimark",[10,19,24,27,40,44,47,73,77,80,171,174,256,260,263,275,278,282,293,296,299],[11,12,13,14,18],"p",{},"接手既有系統時，最大的困難往往不是技術，而是「不確定改這裡會影響哪裡」。比起憑感覺修改，用固定的流程除錯更穩定：",[15,16,17],"strong",{},"重現、定位、歸因、修復、防範","，每一步都留下可以驗證的證據。",[20,21,23],"h2",{"id":22},"一重現先讓問題穩定發生","一、重現：先讓問題穩定發生",[11,25,26],{},"無法穩定重現的問題，修好了也無法證明修好。先把條件寫清楚：",[28,29,30,34,37],"ul",{},[31,32,33],"li",{},"哪個頁面、什麼操作順序、什麼資料。",[31,35,36],{},"只發生在特定瀏覽器、螢幕尺寸或登入身分嗎？",[31,38,39],{},"能不能縮小到最小重現範例（Minimal Reproduction）：拿掉不相關的程式碼，問題還在，代表範圍縮對了。",[20,41,43],{"id":42},"二定位用二分法縮小範圍","二、定位：用二分法縮小範圍",[11,45,46],{},"用 DevTools 把範圍切半，再切半：",[28,48,49,55,61,67],{},[31,50,51,54],{},[15,52,53],{},"Console","：先看有沒有錯誤或警告，它們常直接指出方向。",[31,56,57,60],{},[15,58,59],{},"Network","：確認請求有沒有送出、狀態碼、回傳資料是否符合預期。",[31,62,63,66],{},[15,64,65],{},"Elements","：看實際套用的 CSS，以及被劃掉的規則是被誰覆蓋。",[31,68,69,72],{},[15,70,71],{},"Sources","：在可疑位置設中斷點，或用「Event Listener Breakpoints」在特定事件發生時暫停。",[20,74,76],{"id":75},"三歸因用假設驗證不要憑猜測","三、歸因：用假設驗證，不要憑猜測",[11,78,79],{},"介面行為問題常見的原因，可以對照著檢查：",[81,82,83,99],"table",{},[84,85,86],"thead",{},[87,88,89,93,96],"tr",{},[90,91,92],"th",{},"現象",[90,94,95],{},"常見原因",[90,97,98],{},"驗證方式",[100,101,102,114,125,136,160],"tbody",{},[87,103,104,108,111],{},[105,106,107],"td",{},"彈窗點外面會立刻關閉，或關不掉",[105,109,110],{},"事件冒泡或捕獲順序，點擊被上層攔截",[105,112,113],{},"在 capture 階段記錄事件路徑",[87,115,116,119,122],{},[105,117,118],{},"元件值沒有更新",[105,120,121],{},"響應性失效（解構、非響應式物件）",[105,123,124],{},"在 DevTools 檢查該值是否仍是響應式",[87,126,127,130,133],{},[105,128,129],{},"樣式改了沒效果",[105,131,132],{},"選擇器優先權較低，或被框架樣式覆蓋",[105,134,135],{},"在 Elements 面板看被劃掉的規則",[87,137,138,141,149],{},[105,139,140],{},"浮層被遮住",[105,142,143,144,148],{},"stacking context 讓 ",[145,146,147],"code",{},"z-index"," 失效",[105,150,151,152,155,156,159],{},"檢查祖先元素是否有 ",[145,153,154],{},"transform","、",[145,157,158],{},"opacity"," 等",[87,161,162,165,168],{},[105,163,164],{},"偶發錯誤",[105,166,167],{},"非同步時序或請求競態",[105,169,170],{},"加上時間戳記記錄，比對順序",[11,172,173],{},"例如要確認是哪一層攔截了點擊，可以在 capture 階段觀察事件路徑：",[175,176,181],"pre",{"className":177,"code":178,"language":179,"meta":180,"style":180},"language-js shiki shiki-themes github-dark","document.addEventListener('click', (e) => {\n  console.log('capture:', e.target, e.composedPath())\n}, true)\n","js","",[145,182,183,220,243],{"__ignoreMap":180},[184,185,188,192,196,199,203,206,210,213,217],"span",{"class":186,"line":187},"line",1,[184,189,191],{"class":190},"s95oV","document.",[184,193,195],{"class":194},"svObZ","addEventListener",[184,197,198],{"class":190},"(",[184,200,202],{"class":201},"sU2Wk","'click'",[184,204,205],{"class":190},", (",[184,207,209],{"class":208},"s9osk","e",[184,211,212],{"class":190},") ",[184,214,216],{"class":215},"snl16","=>",[184,218,219],{"class":190}," {\n",[184,221,223,226,229,231,234,237,240],{"class":186,"line":222},2,[184,224,225],{"class":190},"  console.",[184,227,228],{"class":194},"log",[184,230,198],{"class":190},[184,232,233],{"class":201},"'capture:'",[184,235,236],{"class":190},", e.target, e.",[184,238,239],{"class":194},"composedPath",[184,241,242],{"class":190},"())\n",[184,244,246,249,253],{"class":186,"line":245},3,[184,247,248],{"class":190},"}, ",[184,250,252],{"class":251},"sDLfK","true",[184,254,255],{"class":190},")\n",[20,257,259],{"id":258},"四修復最小範圍並評估影響面","四、修復：最小範圍，並評估影響面",[11,261,262],{},"修改前先回答三個問題：",[264,265,266,269,272],"ol",{},[31,267,268],{},"這段程式碼還有哪些地方會用到？（全域搜尋、查看引用）",[31,270,271],{},"修改會改變哪些既有行為？",[31,273,274],{},"有沒有更小的改法？例如修元件本身，而不是在每個呼叫端各補一次。",[11,276,277],{},"既有系統通常沒有完整測試，修復後要手動走過「會受影響的流程」，而不只是驗證被回報的那一個情境。",[20,279,281],{"id":280},"五防範讓同樣的問題不再發生","五、防範：讓同樣的問題不再發生",[28,283,284,287,290],{},[31,285,286],{},"補一個能重現原問題的測試；沒有測試環境時，至少把重現步驟寫進 PR 說明。",[31,288,289],{},"能用工具擋下的，就收斂成規則，例如 ESLint 規則或元件的 props 限制。",[31,291,292],{},"把根因記錄在程式碼註解或文件中，寫的是「為什麼這樣做」，而不是「做了什麼」。",[20,294,295],{"id":295},"小結",[11,297,298],{},"除錯的重點是把「感覺」換成「證據」：能穩定重現、能說明原因、能驗證修復、能防止再發。流程固定下來，面對不熟悉的舊程式碼時也能一步一步縮小範圍，而不是到處試改。",[300,301,302],"style",{},"html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":180,"searchDepth":222,"depth":222,"links":304},[305,306,307,308,309,310],{"id":22,"depth":222,"text":23},{"id":42,"depth":222,"text":43},{"id":75,"depth":222,"text":76},{"id":258,"depth":222,"text":259},{"id":280,"depth":222,"text":281},{"id":295,"depth":222,"text":295},"case","2026-10-29","面對非本人撰寫的舊程式碼時，如何透過清晰的最小重現範例、斷點排查與元件解耦逐步解決問題。",false,"md",{},true,"\u002Fblog\u002Fdebugging-legacy-code",7,{"title":6,"description":313},"blog\u002Fdebugging-legacy-code",[323,324,325],"Debugging","Legacy Code","DevTools","dKhjFex4bygbBuYZC85m1yXOyi68cnetrZEScQVjaME",{"id":328,"title":329,"body":330,"category":918,"date":312,"description":919,"draft":314,"extension":315,"meta":920,"navigation":317,"path":921,"readingTime":519,"seo":922,"stem":923,"tags":924,"__hash__":928},"blog\u002Fblog\u002Ffrontend-quality.md","前端工程化與品質：測試策略、無障礙與 Web Vitals",{"type":8,"value":331,"toc":909},[332,335,339,342,404,407,593,598,601,605,612,655,709,716,720,723,773,776,812,872,875,901,903,906],[11,333,334],{},"「品質」不是上線前才補的一道工序，而是讓每次修改都能被驗證、被理解、被安全地回復。這篇整理前端常見的三個面向：測試策略、無障礙，以及效能指標。",[20,336,338],{"id":337},"測試策略測行為不測實作","測試策略：測行為，不測實作",[11,340,341],{},"前端測試常見分三層：",[81,343,344,360],{},[84,345,346],{},[87,347,348,351,354,357],{},[90,349,350],{},"層級",[90,352,353],{},"工具範例",[90,355,356],{},"驗證什麼",[90,358,359],{},"成本",[100,361,362,376,390],{},[87,363,364,367,370,373],{},[105,365,366],{},"單元測試",[105,368,369],{},"Vitest",[105,371,372],{},"純函式、工具函式、狀態邏輯",[105,374,375],{},"低",[87,377,378,381,384,387],{},[105,379,380],{},"元件測試",[105,382,383],{},"Vue Test Utils",[105,385,386],{},"使用者看得到的互動結果",[105,388,389],{},"中",[87,391,392,395,398,401],{},[105,393,394],{},"端對端測試",[105,396,397],{},"Playwright",[105,399,400],{},"完整流程（登入、下單…）",[105,402,403],{},"高",[11,405,406],{},"元件測試的原則是「像使用者一樣操作，檢查使用者看得到的結果」，而不是檢查元件內部的變數名稱。這樣重構內部實作時，測試不會跟著壞。",[175,408,412],{"className":409,"code":410,"language":411,"meta":180,"style":180},"language-ts shiki shiki-themes github-dark","import { mount } from '@vue\u002Ftest-utils'\nimport { describe, expect, it } from 'vitest'\nimport Counter from '.\u002FCounter.vue'\n\ndescribe('Counter', () => {\n  it('點擊按鈕後，顯示的數字加一', async () => {\n    const wrapper = mount(Counter)\n\n    await wrapper.get('button').trigger('click')\n\n    expect(wrapper.text()).toContain('1')\n  })\n})\n","ts",[145,413,414,428,440,452,458,476,500,517,522,551,556,581,587],{"__ignoreMap":180},[184,415,416,419,422,425],{"class":186,"line":187},[184,417,418],{"class":215},"import",[184,420,421],{"class":190}," { mount } ",[184,423,424],{"class":215},"from",[184,426,427],{"class":201}," '@vue\u002Ftest-utils'\n",[184,429,430,432,435,437],{"class":186,"line":222},[184,431,418],{"class":215},[184,433,434],{"class":190}," { describe, expect, it } ",[184,436,424],{"class":215},[184,438,439],{"class":201}," 'vitest'\n",[184,441,442,444,447,449],{"class":186,"line":245},[184,443,418],{"class":215},[184,445,446],{"class":190}," Counter ",[184,448,424],{"class":215},[184,450,451],{"class":201}," '.\u002FCounter.vue'\n",[184,453,455],{"class":186,"line":454},4,[184,456,457],{"emptyLinePlaceholder":317},"\n",[184,459,461,464,466,469,472,474],{"class":186,"line":460},5,[184,462,463],{"class":194},"describe",[184,465,198],{"class":190},[184,467,468],{"class":201},"'Counter'",[184,470,471],{"class":190},", () ",[184,473,216],{"class":215},[184,475,219],{"class":190},[184,477,479,482,484,487,490,493,496,498],{"class":186,"line":478},6,[184,480,481],{"class":194},"  it",[184,483,198],{"class":190},[184,485,486],{"class":201},"'點擊按鈕後，顯示的數字加一'",[184,488,489],{"class":190},", ",[184,491,492],{"class":215},"async",[184,494,495],{"class":190}," () ",[184,497,216],{"class":215},[184,499,219],{"class":190},[184,501,502,505,508,511,514],{"class":186,"line":319},[184,503,504],{"class":215},"    const",[184,506,507],{"class":251}," wrapper",[184,509,510],{"class":215}," =",[184,512,513],{"class":194}," mount",[184,515,516],{"class":190},"(Counter)\n",[184,518,520],{"class":186,"line":519},8,[184,521,457],{"emptyLinePlaceholder":317},[184,523,525,528,531,534,536,539,542,545,547,549],{"class":186,"line":524},9,[184,526,527],{"class":215},"    await",[184,529,530],{"class":190}," wrapper.",[184,532,533],{"class":194},"get",[184,535,198],{"class":190},[184,537,538],{"class":201},"'button'",[184,540,541],{"class":190},").",[184,543,544],{"class":194},"trigger",[184,546,198],{"class":190},[184,548,202],{"class":201},[184,550,255],{"class":190},[184,552,554],{"class":186,"line":553},10,[184,555,457],{"emptyLinePlaceholder":317},[184,557,559,562,565,568,571,574,576,579],{"class":186,"line":558},11,[184,560,561],{"class":194},"    expect",[184,563,564],{"class":190},"(wrapper.",[184,566,567],{"class":194},"text",[184,569,570],{"class":190},"()).",[184,572,573],{"class":194},"toContain",[184,575,198],{"class":190},[184,577,578],{"class":201},"'1'",[184,580,255],{"class":190},[184,582,584],{"class":186,"line":583},12,[184,585,586],{"class":190},"  })\n",[184,588,590],{"class":186,"line":589},13,[184,591,592],{"class":190},"})\n",[594,595,597],"h3",{"id":596},"mock-的邊界","Mock 的邊界",[11,599,600],{},"Mock 的位置越靠近系統邊界越好：mock 網路請求（例如使用 MSW），而不是 mock 自己的內部函式。內部函式被 mock 掉，測試就只是在驗證 mock 本身。",[20,602,604],{"id":603},"無障礙從鍵盤開始","無障礙：從鍵盤開始",[11,606,607,608,611],{},"最容易檢查、效益也最大的一項是",[15,609,610],{},"鍵盤操作","。不用滑鼠，只用鍵盤走過一遍頁面：",[28,613,614,620,631,638],{},[31,615,616,619],{},[145,617,618],{},"Tab"," 能依合理順序走遍所有可互動元素嗎？",[31,621,622,623,626,627,630],{},"目前焦點的位置看得出來嗎？（不要移除 ",[145,624,625],{},"outline","，改用 ",[145,628,629],{},":focus-visible"," 自訂樣式）",[31,632,633,634,637],{},"彈窗打開時，焦點有進入彈窗嗎？按 ",[145,635,636],{},"Esc"," 能關閉嗎？關閉後焦點有回到觸發按鈕嗎？",[31,639,640,641,155,644,155,647,650,651,654],{},"用 ",[145,642,643],{},"\u003Cbutton>",[145,645,646],{},"\u003Ca>",[145,648,649],{},"\u003Clabel>"," 等語意化元素，而不是在 ",[145,652,653],{},"\u003Cdiv>"," 上綁點擊事件。",[175,656,660],{"className":657,"code":658,"language":659,"meta":180,"style":180},"language-css shiki shiki-themes github-dark",":focus-visible {\n  outline: 2px solid #1d4ed8;\n  outline-offset: 2px;\n}\n","css",[145,661,662,668,691,704],{"__ignoreMap":180},[184,663,664,666],{"class":186,"line":187},[184,665,629],{"class":194},[184,667,219],{"class":190},[184,669,670,673,676,679,682,685,688],{"class":186,"line":222},[184,671,672],{"class":251},"  outline",[184,674,675],{"class":190},": ",[184,677,678],{"class":251},"2",[184,680,681],{"class":215},"px",[184,683,684],{"class":251}," solid",[184,686,687],{"class":251}," #1d4ed8",[184,689,690],{"class":190},";\n",[184,692,693,696,698,700,702],{"class":186,"line":245},[184,694,695],{"class":251},"  outline-offset",[184,697,675],{"class":190},[184,699,678],{"class":251},[184,701,681],{"class":215},[184,703,690],{"class":190},[184,705,706],{"class":186,"line":454},[184,707,708],{"class":190},"}\n",[11,710,711,712,715],{},"自動化工具（例如 axe）能抓到缺少 ",[145,713,714],{},"alt","、對比不足、表單欄位沒有標籤等問題，但無法判斷操作流程是否合理，所以仍需要搭配手動檢查。",[20,717,719],{"id":718},"效能core-web-vitals","效能：Core Web Vitals",[11,721,722],{},"Google 的 Core Web Vitals 目前有三項，「良好」的門檻是：",[81,724,725,738],{},[84,726,727],{},[87,728,729,732,735],{},[90,730,731],{},"指標",[90,733,734],{},"衡量",[90,736,737],{},"良好",[100,739,740,751,762],{},[87,741,742,745,748],{},[105,743,744],{},"LCP（Largest Contentful Paint）",[105,746,747],{},"最大內容多快出現",[105,749,750],{},"≤ 2.5 秒",[87,752,753,756,759],{},[105,754,755],{},"INP（Interaction to Next Paint）",[105,757,758],{},"互動後多快有畫面回應",[105,760,761],{},"≤ 200 毫秒",[87,763,764,767,770],{},[105,765,766],{},"CLS（Cumulative Layout Shift）",[105,768,769],{},"版面在載入過程中跳動多少",[105,771,772],{},"≤ 0.1",[11,774,775],{},"常見的改善方向：",[28,777,778,788,794],{},[31,779,780,783,784,787],{},[15,781,782],{},"LCP","：最大的圖片使用適當格式與尺寸，並提高載入優先權（例如 ",[145,785,786],{},"fetchpriority=\"high\"","）；減少阻塞渲染的資源。",[31,789,790,793],{},[15,791,792],{},"INP","：把長時間執行的 JavaScript 拆小，避免佔住主執行緒。",[31,795,796,799,800,803,804,807,808,811],{},[15,797,798],{},"CLS","：圖片與嵌入內容事先指定 ",[145,801,802],{},"width"," \u002F ",[145,805,806],{},"height","（或 ",[145,809,810],{},"aspect-ratio","），讓瀏覽器先保留空間。",[175,813,817],{"className":814,"code":815,"language":816,"meta":180,"style":180},"language-html shiki shiki-themes github-dark","\u003Cimg src=\"\u002Fcover.webp\" width=\"1600\" height=\"728\" alt=\"專案封面\" fetchpriority=\"high\">\n","html",[145,818,819],{"__ignoreMap":180},[184,820,821,824,828,831,834,837,840,842,845,848,850,853,856,858,861,864,866,869],{"class":186,"line":187},[184,822,823],{"class":190},"\u003C",[184,825,827],{"class":826},"s4JwU","img",[184,829,830],{"class":194}," src",[184,832,833],{"class":190},"=",[184,835,836],{"class":201},"\"\u002Fcover.webp\"",[184,838,839],{"class":194}," width",[184,841,833],{"class":190},[184,843,844],{"class":201},"\"1600\"",[184,846,847],{"class":194}," height",[184,849,833],{"class":190},[184,851,852],{"class":201},"\"728\"",[184,854,855],{"class":194}," alt",[184,857,833],{"class":190},[184,859,860],{"class":201},"\"專案封面\"",[184,862,863],{"class":194}," fetchpriority",[184,865,833],{"class":190},[184,867,868],{"class":201},"\"high\"",[184,870,871],{"class":190},">\n",[20,873,874],{"id":874},"大型專案的維護思維",[28,876,877,883,889,895],{},[31,878,879,882],{},[15,880,881],{},"讓錯誤盡早出現","：TypeScript、ESLint、型別化的 API 回傳，把錯誤擋在編譯與提交階段。",[31,884,885,888],{},[15,886,887],{},"小步修改、可回復","：每次變更範圍小，才容易 review，也容易回退。",[31,890,891,894],{},[15,892,893],{},"把規則寫成工具","：能由 CI 自動檢查的（格式、型別、測試），就不要靠人記得。",[31,896,897,900],{},[15,898,899],{},"留下「為什麼」","：註解與文件記錄決策原因，接手的人才能判斷何時可以改。",[20,902,295],{"id":295},[11,904,905],{},"測試守住「行為」、無障礙守住「每個人都能用」、Web Vitals 守住「用起來順不順」。三者都可以從很小的習慣開始：寫一個行為測試、用鍵盤走一遍頁面、替圖片加上尺寸。",[300,907,908],{},"html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}",{"title":180,"searchDepth":222,"depth":222,"links":910},[911,914,915,916,917],{"id":337,"depth":222,"text":338,"children":912},[913],{"id":596,"depth":245,"text":597},{"id":603,"depth":222,"text":604},{"id":718,"depth":222,"text":719},{"id":874,"depth":222,"text":874},{"id":295,"depth":222,"text":295},"quality","測試、自動化驗證、無障礙設計、效能優化與大型專案的維護思維。",{},"\u002Fblog\u002Ffrontend-quality",{"title":329,"description":919},"blog\u002Ffrontend-quality",[925,926,927],"Testing","Accessibility","Web Vitals","NxcOha3EK8H-KqYX2pKqLo1EFXTqQ8zz5I7qR9j2saY",{"id":930,"title":931,"body":932,"category":918,"date":1609,"description":1610,"draft":314,"extension":315,"meta":1611,"navigation":317,"path":1612,"readingTime":524,"seo":1613,"stem":1614,"tags":1615,"__hash__":1618},"blog\u002Fblog\u002Fjwt-storage-security.md","JWT 要存哪裡：從 localStorage 到 Cookie + CSRF Token",{"type":8,"value":933,"toc":1598},[934,941,944,948,955,958,986,989,993,1000,1103,1110,1113,1117,1120,1123,1133,1137,1148,1155,1166,1170,1177,1248,1251,1377,1383,1387,1390,1393,1413,1416,1420,1426,1429,1432,1519,1522,1525,1528,1578,1581,1595],[11,935,936,937,940],{},"我的第一個全端專案「遊戲揪團平台」，是把 JWT 存在 Pinia，再持久化到 localStorage。做法簡單，每次請求放進 ",[145,938,939],{},"Authorization"," header 就好。當時我對權限這塊的理解還比較薄弱，沒有想太多。",[11,942,943],{},"後來做 LINE OA 商家管理平台，後台可以管理多間店家的內容和帳號權限，登入憑證被偷的影響大很多，所以我改成 HttpOnly Cookie 加 CSRF Token。這篇整理兩種做法的風險，以及實作時遇到的問題。",[20,945,947],{"id":946},"jwt-localstorage-的風險xss","JWT + localStorage 的風險：XSS",[11,949,950,951,954],{},"localStorage 的優點是簡單，缺點是",[15,952,953],{},"任何在頁面上執行的 JavaScript 都讀得到","。",[11,956,957],{},"XSS（跨站腳本攻擊）是指攻擊者讓自己的腳本在你的網站上執行，例如透過沒有處理好的使用者輸入，或被植入惡意程式的第三方套件。一旦發生，偷走 token 只要一行：",[175,959,961],{"className":177,"code":960,"language":179,"meta":180,"style":180},"const token = localStorage.getItem('token')\n",[145,962,963],{"__ignoreMap":180},[184,964,965,968,971,973,976,979,981,984],{"class":186,"line":187},[184,966,967],{"class":215},"const",[184,969,970],{"class":251}," token",[184,972,510],{"class":215},[184,974,975],{"class":190}," localStorage.",[184,977,978],{"class":194},"getItem",[184,980,198],{"class":190},[184,982,983],{"class":201},"'token'",[184,985,255],{"class":190},[11,987,988],{},"攻擊者把 token 傳回自己的伺服器後，就能在別的地方假冒使用者，直到 token 過期。JWT 通常是無狀態的，簽發後伺服器很難提前作廢，所以有效期越長，風險越高。",[20,990,992],{"id":991},"改放-httponly-cookie","改放 HttpOnly Cookie",[11,994,995,996,999],{},"加上 ",[145,997,998],{},"HttpOnly"," 的 Cookie，JavaScript 讀不到，瀏覽器會在請求時自動帶上：",[175,1001,1003],{"className":177,"code":1002,"language":179,"meta":180,"style":180},"export const accessTokenCookieConfig = {\n  httpOnly: true, \u002F\u002F JavaScript 讀不到\n  secure: process.env.NODE_ENV === 'production', \u002F\u002F 只走 HTTPS\n  sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',\n  maxAge: 15 * 60 * 1000,\n}\n",[145,1004,1005,1020,1033,1052,1078,1099],{"__ignoreMap":180},[184,1006,1007,1010,1013,1016,1018],{"class":186,"line":187},[184,1008,1009],{"class":215},"export",[184,1011,1012],{"class":215}," const",[184,1014,1015],{"class":251}," accessTokenCookieConfig",[184,1017,510],{"class":215},[184,1019,219],{"class":190},[184,1021,1022,1025,1027,1029],{"class":186,"line":222},[184,1023,1024],{"class":190},"  httpOnly: ",[184,1026,252],{"class":251},[184,1028,489],{"class":190},[184,1030,1032],{"class":1031},"sAwPA","\u002F\u002F JavaScript 讀不到\n",[184,1034,1035,1038,1041,1044,1047,1049],{"class":186,"line":245},[184,1036,1037],{"class":190},"  secure: process.env.",[184,1039,1040],{"class":251},"NODE_ENV",[184,1042,1043],{"class":215}," ===",[184,1045,1046],{"class":201}," 'production'",[184,1048,489],{"class":190},[184,1050,1051],{"class":1031},"\u002F\u002F 只走 HTTPS\n",[184,1053,1054,1057,1059,1061,1063,1066,1069,1072,1075],{"class":186,"line":454},[184,1055,1056],{"class":190},"  sameSite: process.env.",[184,1058,1040],{"class":251},[184,1060,1043],{"class":215},[184,1062,1046],{"class":201},[184,1064,1065],{"class":215}," ?",[184,1067,1068],{"class":201}," 'none'",[184,1070,1071],{"class":215}," :",[184,1073,1074],{"class":201}," 'lax'",[184,1076,1077],{"class":190},",\n",[184,1079,1080,1083,1086,1089,1092,1094,1097],{"class":186,"line":460},[184,1081,1082],{"class":190},"  maxAge: ",[184,1084,1085],{"class":251},"15",[184,1087,1088],{"class":215}," *",[184,1090,1091],{"class":251}," 60",[184,1093,1088],{"class":215},[184,1095,1096],{"class":251}," 1000",[184,1098,1077],{"class":190},[184,1100,1101],{"class":186,"line":478},[184,1102,708],{"class":190},[11,1104,1105,1106,1109],{},"前端的 axios 要開啟 ",[145,1107,1108],{},"withCredentials: true","，跨網域請求才會帶上 Cookie。",[11,1111,1112],{},"這樣 XSS 就偷不走 token 了。但「瀏覽器會自動帶上」這個特性，帶來了另一個問題。",[20,1114,1116],{"id":1115},"cookie-的風險csrf","Cookie 的風險：CSRF",[11,1118,1119],{},"CSRF（跨站請求偽造）是使用者已經登入你的網站時，瀏覽惡意網站，對方偷偷發出一個請求到你的 API。因為 Cookie 會自動帶上，伺服器會以為是使用者本人操作。",[11,1121,1122],{},"攻擊者不需要讀到 Cookie，只要讓瀏覽器幫忙送出就好。",[11,1124,1125,1128,1129,1132],{},[145,1126,1127],{},"SameSite"," 可以擋掉大部分情況，但我的 Cookie 在正式環境設成 ",[145,1130,1131],{},"SameSite=None","，這個設定不會限制跨站請求，所以還需要 CSRF Token。",[20,1134,1136],{"id":1135},"safari前後端要放在同一個網域","Safari：前後端要放在同一個網域",[11,1138,1139,1140,1143,1144,1147],{},"這個問題是我用手機測試時才發現的。前後端在完全不同的網域時，例如前端在 ",[145,1141,1142],{},"xxx.pages.dev","、API 在 ",[145,1145,1146],{},"xxx.onrender.com","，對瀏覽器來說這就是第三方 Cookie。",[11,1149,1150,1151,1154],{},"Safari 預設會封鎖第三方 Cookie，就算設了 ",[145,1152,1153],{},"SameSite=None; Secure"," 也一樣。結果是登入 API 回傳成功，Cookie 卻沒有被存下來，一直是未登入狀態。",[11,1156,1157,1158,1161,1162,1165],{},"後來我把網域改掉，讓前後端使用同一個主網域，例如 ",[145,1159,1160],{},"admin.example.com"," 和 ",[145,1163,1164],{},"api.example.com","。這樣 Cookie 就不算第三方，Safari 也能正常運作。",[20,1167,1169],{"id":1168},"csrf-token-怎麼運作","CSRF Token 怎麼運作",[11,1171,1172,1173,1176],{},"登入時後端產生一組隨機值，放在一個",[15,1174,1175],{},"前端可以取得","的地方，之後每次修改資料的請求都要放進自訂 header：",[175,1178,1180],{"className":177,"code":1179,"language":179,"meta":180,"style":180},"\u002F\u002F 前端：每個請求帶上 CSRF Token\napi.interceptors.request.use((config) => {\n  if (authStore.csrfToken) {\n    config.headers['x-csrf-token'] = authStore.csrfToken\n  }\n  return config\n})\n",[145,1181,1182,1187,1207,1215,1231,1236,1244],{"__ignoreMap":180},[184,1183,1184],{"class":186,"line":187},[184,1185,1186],{"class":1031},"\u002F\u002F 前端：每個請求帶上 CSRF Token\n",[184,1188,1189,1192,1195,1198,1201,1203,1205],{"class":186,"line":222},[184,1190,1191],{"class":190},"api.interceptors.request.",[184,1193,1194],{"class":194},"use",[184,1196,1197],{"class":190},"((",[184,1199,1200],{"class":208},"config",[184,1202,212],{"class":190},[184,1204,216],{"class":215},[184,1206,219],{"class":190},[184,1208,1209,1212],{"class":186,"line":245},[184,1210,1211],{"class":215},"  if",[184,1213,1214],{"class":190}," (authStore.csrfToken) {\n",[184,1216,1217,1220,1223,1226,1228],{"class":186,"line":454},[184,1218,1219],{"class":190},"    config.headers[",[184,1221,1222],{"class":201},"'x-csrf-token'",[184,1224,1225],{"class":190},"] ",[184,1227,833],{"class":215},[184,1229,1230],{"class":190}," authStore.csrfToken\n",[184,1232,1233],{"class":186,"line":460},[184,1234,1235],{"class":190},"  }\n",[184,1237,1238,1241],{"class":186,"line":478},[184,1239,1240],{"class":215},"  return",[184,1242,1243],{"class":190}," config\n",[184,1245,1246],{"class":186,"line":319},[184,1247,592],{"class":190},[11,1249,1250],{},"後端比對 Cookie 和 header 裡的值是否相同：",[175,1252,1254],{"className":177,"code":1253,"language":179,"meta":180,"style":180},"const a = Buffer.from(String(req.cookies.csrf_token))\nconst b = Buffer.from(String(req.headers['x-csrf-token']))\n\nif (a.length !== b.length || !timingSafeEqual(a, b)) {\n  return res.status(403).json({ success: false, message: 'CSRF invalid' })\n}\n",[145,1255,1256,1278,1303,1307,1338,1373],{"__ignoreMap":180},[184,1257,1258,1260,1263,1265,1268,1270,1272,1275],{"class":186,"line":187},[184,1259,967],{"class":215},[184,1261,1262],{"class":251}," a",[184,1264,510],{"class":215},[184,1266,1267],{"class":190}," Buffer.",[184,1269,424],{"class":194},[184,1271,198],{"class":190},[184,1273,1274],{"class":194},"String",[184,1276,1277],{"class":190},"(req.cookies.csrf_token))\n",[184,1279,1280,1282,1285,1287,1289,1291,1293,1295,1298,1300],{"class":186,"line":222},[184,1281,967],{"class":215},[184,1283,1284],{"class":251}," b",[184,1286,510],{"class":215},[184,1288,1267],{"class":190},[184,1290,424],{"class":194},[184,1292,198],{"class":190},[184,1294,1274],{"class":194},[184,1296,1297],{"class":190},"(req.headers[",[184,1299,1222],{"class":201},[184,1301,1302],{"class":190},"]))\n",[184,1304,1305],{"class":186,"line":245},[184,1306,457],{"emptyLinePlaceholder":317},[184,1308,1309,1312,1315,1318,1321,1324,1326,1329,1332,1335],{"class":186,"line":454},[184,1310,1311],{"class":215},"if",[184,1313,1314],{"class":190}," (a.",[184,1316,1317],{"class":251},"length",[184,1319,1320],{"class":215}," !==",[184,1322,1323],{"class":190}," b.",[184,1325,1317],{"class":251},[184,1327,1328],{"class":215}," ||",[184,1330,1331],{"class":215}," !",[184,1333,1334],{"class":194},"timingSafeEqual",[184,1336,1337],{"class":190},"(a, b)) {\n",[184,1339,1340,1342,1345,1348,1350,1353,1355,1358,1361,1364,1367,1370],{"class":186,"line":460},[184,1341,1240],{"class":215},[184,1343,1344],{"class":190}," res.",[184,1346,1347],{"class":194},"status",[184,1349,198],{"class":190},[184,1351,1352],{"class":251},"403",[184,1354,541],{"class":190},[184,1356,1357],{"class":194},"json",[184,1359,1360],{"class":190},"({ success: ",[184,1362,1363],{"class":251},"false",[184,1365,1366],{"class":190},", message: ",[184,1368,1369],{"class":201},"'CSRF invalid'",[184,1371,1372],{"class":190}," })\n",[184,1374,1375],{"class":186,"line":478},[184,1376,708],{"class":190},[11,1378,1379,1380,1382],{},"惡意網站雖然能讓瀏覽器帶上 Cookie，但讀不到 Token，也就寫不進自訂 header。這裡用 ",[145,1381,1334],{}," 比對，避免從回應時間猜出內容。",[20,1384,1386],{"id":1385},"access-token-與-refresh-token","Access Token 與 Refresh Token",[11,1388,1389],{},"Access Token 我設定 15 分鐘，就算外洩，影響時間也短。為了不讓使用者一直重新登入，再搭配 7 天的 Refresh Token。",[11,1391,1392],{},"Refresh Token 我多做了幾件事：",[28,1394,1395,1401,1407],{},[31,1396,1397,1400],{},[15,1398,1399],{},"資料庫只存 SHA-256 雜湊","：一開始是明碼存入，後來才改掉。就算資料庫外洩，也拿不到能用的 token。",[31,1402,1403,1406],{},[15,1404,1405],{},"每次更新都輪替","：舊的 Refresh Token 用過就失效。",[31,1408,1409,1412],{},[15,1410,1411],{},"限制同時登入的數量","：超過上限時移除最舊的一筆。",[11,1414,1415],{},"JWT 無法提前作廢的問題，也因此得到緩解：把資料庫裡的 Refresh Token 刪掉，使用者最多 15 分鐘後就要重新登入。",[20,1417,1419],{"id":1418},"踩到的坑多支請求同時-401","踩到的坑：多支請求同時 401",[11,1421,1422,1423,954],{},"頁面通常會同時發出多支 API。Access Token 過期時，這些請求會同時收到 401，然後同時呼叫 ",[145,1424,1425],{},"\u002Frefresh",[11,1427,1428],{},"因為 Refresh Token 每次都會輪替，第一支成功後，其他請求拿的是舊 token，就會被拒絕，接著觸發登出，把 CSRF Token 也清掉，後面的請求全部失敗。",[11,1430,1431],{},"解法是讓同一時間只有一次 refresh，其他請求共用結果：",[175,1433,1435],{"className":177,"code":1434,"language":179,"meta":180,"style":180},"let refreshPromise = null\n\nrefreshPromise ??= authApi.post('\u002Frefresh').finally(() => {\n  refreshPromise = null\n})\nconst { data } = await refreshPromise\n",[145,1436,1437,1450,1454,1485,1494,1498],{"__ignoreMap":180},[184,1438,1439,1442,1445,1447],{"class":186,"line":187},[184,1440,1441],{"class":215},"let",[184,1443,1444],{"class":190}," refreshPromise ",[184,1446,833],{"class":215},[184,1448,1449],{"class":251}," null\n",[184,1451,1452],{"class":186,"line":222},[184,1453,457],{"emptyLinePlaceholder":317},[184,1455,1456,1459,1462,1465,1468,1470,1473,1475,1478,1481,1483],{"class":186,"line":245},[184,1457,1458],{"class":190},"refreshPromise ",[184,1460,1461],{"class":215},"??=",[184,1463,1464],{"class":190}," authApi.",[184,1466,1467],{"class":194},"post",[184,1469,198],{"class":190},[184,1471,1472],{"class":201},"'\u002Frefresh'",[184,1474,541],{"class":190},[184,1476,1477],{"class":194},"finally",[184,1479,1480],{"class":190},"(() ",[184,1482,216],{"class":215},[184,1484,219],{"class":190},[184,1486,1487,1490,1492],{"class":186,"line":454},[184,1488,1489],{"class":190},"  refreshPromise ",[184,1491,833],{"class":215},[184,1493,1449],{"class":251},[184,1495,1496],{"class":186,"line":460},[184,1497,592],{"class":190},[184,1499,1500,1502,1505,1508,1511,1513,1516],{"class":186,"line":478},[184,1501,967],{"class":215},[184,1503,1504],{"class":190}," { ",[184,1506,1507],{"class":251},"data",[184,1509,1510],{"class":190}," } ",[184,1512,833],{"class":215},[184,1514,1515],{"class":215}," await",[184,1517,1518],{"class":190}," refreshPromise\n",[11,1520,1521],{},"另一個坑是多分頁。如果每次登入都產生新的 CSRF Token，其他分頁手上的值就對不上了，所以我改成已經有的話就沿用。",[20,1523,1524],{"id":1524},"這樣就安全了嗎",[11,1526,1527],{},"沒有絕對安全的做法。HttpOnly Cookie 只是讓 token 不會被偷走，如果網站真的有 XSS，攻擊者還是能在頁面上直接發送請求。所以輸入處理、依賴套件管理這些基本功還是要做。",[81,1529,1530,1543],{},[84,1531,1532],{},[87,1533,1534,1537,1540],{},[90,1535,1536],{},"做法",[90,1538,1539],{},"主要風險",[90,1541,1542],{},"需要的防護",[100,1544,1545,1556,1567],{},[87,1546,1547,1550,1553],{},[105,1548,1549],{},"JWT + localStorage",[105,1551,1552],{},"XSS 偷走 token",[105,1554,1555],{},"防 XSS、縮短有效期",[87,1557,1558,1561,1564],{},[105,1559,1560],{},"JWT + HttpOnly Cookie",[105,1562,1563],{},"CSRF",[105,1565,1566],{},"SameSite、CSRF Token",[87,1568,1569,1572,1575],{},[105,1570,1571],{},"再加 Refresh Token 機制",[105,1573,1574],{},"實作較複雜",[105,1576,1577],{},"輪替、雜湊儲存、處理並發",[20,1579,1580],{"id":1580},"這次學到的事",[28,1582,1583,1586,1589,1592],{},[31,1584,1585],{},"沒有最好的存法，只有不同的風險。localStorage 防不了 XSS，Cookie 要處理 CSRF。",[31,1587,1588],{},"我做揪團平台時對權限這塊還比較薄弱，那個專案也沒有改回來。後來做管理多間店家資料的後台，才真正開始思考憑證被偷的影響。",[31,1590,1591],{},"只在電腦上測試不夠。這個問題是拿手機實測才發現的，Cookie 這類行為要連 Safari 一起測。",[31,1593,1594],{},"機制越多，邊界情況越多。真正花時間的不是加上 Cookie，而是處理並發和多分頁。",[300,1596,1597],{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}",{"title":180,"searchDepth":222,"depth":222,"links":1599},[1600,1601,1602,1603,1604,1605,1606,1607,1608],{"id":946,"depth":222,"text":947},{"id":991,"depth":222,"text":992},{"id":1115,"depth":222,"text":1116},{"id":1135,"depth":222,"text":1136},{"id":1168,"depth":222,"text":1169},{"id":1385,"depth":222,"text":1386},{"id":1418,"depth":222,"text":1419},{"id":1524,"depth":222,"text":1524},{"id":1580,"depth":222,"text":1580},"2026-09-30","比較 JWT 存在 localStorage 與 HttpOnly Cookie 的差異，說明 XSS 與 CSRF 的風險，並記錄實際專案改用 Cookie 加 CSRF Token 的做法與踩坑。",{},"\u002Fblog\u002Fjwt-storage-security",{"title":931,"description":1610},"blog\u002Fjwt-storage-security",[1616,1563,1617],"JWT","XSS","0MSTWsXPii5S3XFzL8Gw6vNQJYLiOqaFkmfljY_sG_0",{"id":1620,"title":1621,"body":1622,"category":311,"date":3232,"description":3233,"draft":314,"extension":315,"meta":3234,"navigation":317,"path":3235,"readingTime":553,"seo":3236,"stem":3237,"tags":3238,"__hash__":3242},"blog\u002Fblog\u002Fnotion-as-cms.md","用 Notion API 當 CMS：東京自由行旅遊網站的實作紀錄",{"type":8,"value":1623,"toc":3216},[1624,1627,1631,1634,1637,1644,1647,1654,1728,1735,1739,1746,1749,1877,1880,2013,2020,2024,2031,2174,2177,2181,2184,2254,2257,2328,2331,2334,2345,2352,2355,2362,2445,2449,2452,2455,2545,2548,2810,2814,2817,2820,2823,2930,2934,2952,2955,2966,3055,3077,3081,3084,3087,3185,3188,3200,3202,3213],[11,1625,1626],{},"為了 9 月去日本東京自由行，我用 Nuxt 4 做了一個旅遊行程網站，出門時可以直接用手機查看。行程、行李清單、購物清單和票券這些內容會一直調整，所以我不想另外做一套後台，而是把 Notion 當成 CMS：內容都在 Notion 編輯，網站只負責讀取和顯示。",[20,1628,1630],{"id":1629},"為什麼選-notion","為什麼選 Notion",[11,1632,1633],{},"行程內容本來就適合用表格管理，日期、順序、地區、交通方式都是欄位。Notion 的資料庫可以直接當後台使用，在手機上也能編輯，我不用自己做登入、表單和資料庫。",[11,1635,1636],{},"整體流程是這樣：",[175,1638,1642],{"className":1639,"code":1641,"language":567,"meta":180},[1640],"language-text","Notion 資料庫 → Nuxt 伺服器路由 → transformer → 前端頁面\n",[145,1643,1641],{"__ignoreMap":180},[20,1645,1646],{"id":1646},"金鑰只留在伺服器",[11,1648,1649,1650,1653],{},"Notion API 需要金鑰，所以不能從瀏覽器直接呼叫。我把金鑰放在 ",[145,1651,1652],{},"runtimeConfig"," 的私有區塊，在伺服器建立共用的 client：",[175,1655,1657],{"className":409,"code":1656,"language":411,"meta":180,"style":180},"\u002F\u002F server\u002Futils\u002Fnotion.ts\nimport { Client } from '@notionhq\u002Fclient'\n\nconst config = useRuntimeConfig()\n\nexport const notion = new Client({\n  auth: config.notionApiKey,\n})\n",[145,1658,1659,1664,1676,1680,1695,1699,1719,1724],{"__ignoreMap":180},[184,1660,1661],{"class":186,"line":187},[184,1662,1663],{"class":1031},"\u002F\u002F server\u002Futils\u002Fnotion.ts\n",[184,1665,1666,1668,1671,1673],{"class":186,"line":222},[184,1667,418],{"class":215},[184,1669,1670],{"class":190}," { Client } ",[184,1672,424],{"class":215},[184,1674,1675],{"class":201}," '@notionhq\u002Fclient'\n",[184,1677,1678],{"class":186,"line":245},[184,1679,457],{"emptyLinePlaceholder":317},[184,1681,1682,1684,1687,1689,1692],{"class":186,"line":454},[184,1683,967],{"class":215},[184,1685,1686],{"class":251}," config",[184,1688,510],{"class":215},[184,1690,1691],{"class":194}," useRuntimeConfig",[184,1693,1694],{"class":190},"()\n",[184,1696,1697],{"class":186,"line":460},[184,1698,457],{"emptyLinePlaceholder":317},[184,1700,1701,1703,1705,1708,1710,1713,1716],{"class":186,"line":478},[184,1702,1009],{"class":215},[184,1704,1012],{"class":215},[184,1706,1707],{"class":251}," notion",[184,1709,510],{"class":215},[184,1711,1712],{"class":215}," new",[184,1714,1715],{"class":194}," Client",[184,1717,1718],{"class":190},"({\n",[184,1720,1721],{"class":186,"line":319},[184,1722,1723],{"class":190},"  auth: config.notionApiKey,\n",[184,1725,1726],{"class":186,"line":519},[184,1727,592],{"class":190},[11,1729,1730,1731,1734],{},"前端只呼叫自己的 ",[145,1732,1733],{},"\u002Fapi\u002Fnotion\u002F*"," 路由，看不到金鑰，也不會直接接觸 Notion。",[20,1736,1738],{"id":1737},"用-transformer-隔開-notion-的資料結構","用 transformer 隔開 Notion 的資料結構",[11,1740,1741,1742,1745],{},"Notion 回傳的資料巢狀很深，例如讀一個標題要寫到 ",[145,1743,1744],{},"page.properties[key].title[0].plain_text","。如果前端直接用，Notion 的結構就會散落在每個元件裡。",[11,1747,1748],{},"所以我先寫一組取值函式，處理空值：",[175,1750,1752],{"className":409,"code":1751,"language":411,"meta":180,"style":180},"export function getTitle(page: any, key: string) {\n  return page.properties[key]?.title?.[0]?.plain_text ?? ''\n}\n\nexport function getMultiSelect(page: any, key: string) {\n  return page.properties[key]?.multi_select?.map((item: any) => item.name) ?? []\n}\n",[145,1753,1754,1788,1807,1811,1815,1842,1873],{"__ignoreMap":180},[184,1755,1756,1758,1761,1764,1766,1769,1772,1775,1777,1780,1782,1785],{"class":186,"line":187},[184,1757,1009],{"class":215},[184,1759,1760],{"class":215}," function",[184,1762,1763],{"class":194}," getTitle",[184,1765,198],{"class":190},[184,1767,1768],{"class":208},"page",[184,1770,1771],{"class":215},":",[184,1773,1774],{"class":251}," any",[184,1776,489],{"class":190},[184,1778,1779],{"class":208},"key",[184,1781,1771],{"class":215},[184,1783,1784],{"class":251}," string",[184,1786,1787],{"class":190},") {\n",[184,1789,1790,1792,1795,1798,1801,1804],{"class":186,"line":222},[184,1791,1240],{"class":215},[184,1793,1794],{"class":190}," page.properties[key]?.title?.[",[184,1796,1797],{"class":251},"0",[184,1799,1800],{"class":190},"]?.plain_text ",[184,1802,1803],{"class":215},"??",[184,1805,1806],{"class":201}," ''\n",[184,1808,1809],{"class":186,"line":245},[184,1810,708],{"class":190},[184,1812,1813],{"class":186,"line":454},[184,1814,457],{"emptyLinePlaceholder":317},[184,1816,1817,1819,1821,1824,1826,1828,1830,1832,1834,1836,1838,1840],{"class":186,"line":460},[184,1818,1009],{"class":215},[184,1820,1760],{"class":215},[184,1822,1823],{"class":194}," getMultiSelect",[184,1825,198],{"class":190},[184,1827,1768],{"class":208},[184,1829,1771],{"class":215},[184,1831,1774],{"class":251},[184,1833,489],{"class":190},[184,1835,1779],{"class":208},[184,1837,1771],{"class":215},[184,1839,1784],{"class":251},[184,1841,1787],{"class":190},[184,1843,1844,1846,1849,1852,1854,1857,1859,1861,1863,1865,1868,1870],{"class":186,"line":478},[184,1845,1240],{"class":215},[184,1847,1848],{"class":190}," page.properties[key]?.multi_select?.",[184,1850,1851],{"class":194},"map",[184,1853,1197],{"class":190},[184,1855,1856],{"class":208},"item",[184,1858,1771],{"class":215},[184,1860,1774],{"class":251},[184,1862,212],{"class":190},[184,1864,216],{"class":215},[184,1866,1867],{"class":190}," item.name) ",[184,1869,1803],{"class":215},[184,1871,1872],{"class":190}," []\n",[184,1874,1875],{"class":186,"line":319},[184,1876,708],{"class":190},[11,1878,1879],{},"再用 transformer 把 Notion 的頁面轉成網站自己的型別：",[175,1881,1883],{"className":409,"code":1882,"language":411,"meta":180,"style":180},"export function transformItinerary(page: any): Itinerary {\n  return {\n    id: page.id,\n    title: getTitle(page, 'Trip Name'),\n    date: getDate(page, 'Date'),\n    day: getSelect(page, 'Day'),\n    order: getNumber(page, 'Order'),\n    category: getMultiSelect(page, 'Category'),\n    \u002F\u002F ...\n  }\n}\n",[145,1884,1885,1912,1918,1923,1940,1955,1970,1985,2000,2005,2009],{"__ignoreMap":180},[184,1886,1887,1889,1891,1894,1896,1898,1900,1902,1905,1907,1910],{"class":186,"line":187},[184,1888,1009],{"class":215},[184,1890,1760],{"class":215},[184,1892,1893],{"class":194}," transformItinerary",[184,1895,198],{"class":190},[184,1897,1768],{"class":208},[184,1899,1771],{"class":215},[184,1901,1774],{"class":251},[184,1903,1904],{"class":190},")",[184,1906,1771],{"class":215},[184,1908,1909],{"class":194}," Itinerary",[184,1911,219],{"class":190},[184,1913,1914,1916],{"class":186,"line":222},[184,1915,1240],{"class":215},[184,1917,219],{"class":190},[184,1919,1920],{"class":186,"line":245},[184,1921,1922],{"class":190},"    id: page.id,\n",[184,1924,1925,1928,1931,1934,1937],{"class":186,"line":454},[184,1926,1927],{"class":190},"    title: ",[184,1929,1930],{"class":194},"getTitle",[184,1932,1933],{"class":190},"(page, ",[184,1935,1936],{"class":201},"'Trip Name'",[184,1938,1939],{"class":190},"),\n",[184,1941,1942,1945,1948,1950,1953],{"class":186,"line":460},[184,1943,1944],{"class":190},"    date: ",[184,1946,1947],{"class":194},"getDate",[184,1949,1933],{"class":190},[184,1951,1952],{"class":201},"'Date'",[184,1954,1939],{"class":190},[184,1956,1957,1960,1963,1965,1968],{"class":186,"line":478},[184,1958,1959],{"class":190},"    day: ",[184,1961,1962],{"class":194},"getSelect",[184,1964,1933],{"class":190},[184,1966,1967],{"class":201},"'Day'",[184,1969,1939],{"class":190},[184,1971,1972,1975,1978,1980,1983],{"class":186,"line":319},[184,1973,1974],{"class":190},"    order: ",[184,1976,1977],{"class":194},"getNumber",[184,1979,1933],{"class":190},[184,1981,1982],{"class":201},"'Order'",[184,1984,1939],{"class":190},[184,1986,1987,1990,1993,1995,1998],{"class":186,"line":519},[184,1988,1989],{"class":190},"    category: ",[184,1991,1992],{"class":194},"getMultiSelect",[184,1994,1933],{"class":190},[184,1996,1997],{"class":201},"'Category'",[184,1999,1939],{"class":190},[184,2001,2002],{"class":186,"line":524},[184,2003,2004],{"class":1031},"    \u002F\u002F ...\n",[184,2006,2007],{"class":186,"line":553},[184,2008,1235],{"class":190},[184,2010,2011],{"class":186,"line":558},[184,2012,708],{"class":190},[11,2014,2015,2016,2019],{},"這樣前端只認識 ",[145,2017,2018],{},"Itinerary","。Notion 的欄位改名時，只要改 transformer 這一個地方。",[20,2021,2023],{"id":2022},"快取不要每次請求都打-notion","快取：不要每次請求都打 Notion",[11,2025,2026,2027,2030],{},"Notion API 的回應不算快，也有請求速率限制，而行程內容不會每秒變動。所以我用 Nitro 的 ",[145,2028,2029],{},"defineCachedEventHandler"," 包住路由，快取 60 秒：",[175,2032,2034],{"className":409,"code":2033,"language":411,"meta":180,"style":180},"export default defineCachedEventHandler(async () => {\n  const response = await notion.dataSources.query({\n    data_source_id: config.notionDataSourceId,\n  })\n\n  return response.results\n    .map(transformItinerary)\n    .sort((a, b) => a.date !== b.date\n      ? a.date.localeCompare(b.date)\n      : a.order - b.order)\n}, { maxAge: 60 })\n",[145,2035,2036,2056,2076,2081,2085,2089,2096,2106,2136,2150,2164],{"__ignoreMap":180},[184,2037,2038,2040,2043,2046,2048,2050,2052,2054],{"class":186,"line":187},[184,2039,1009],{"class":215},[184,2041,2042],{"class":215}," default",[184,2044,2045],{"class":194}," defineCachedEventHandler",[184,2047,198],{"class":190},[184,2049,492],{"class":215},[184,2051,495],{"class":190},[184,2053,216],{"class":215},[184,2055,219],{"class":190},[184,2057,2058,2061,2064,2066,2068,2071,2074],{"class":186,"line":222},[184,2059,2060],{"class":215},"  const",[184,2062,2063],{"class":251}," response",[184,2065,510],{"class":215},[184,2067,1515],{"class":215},[184,2069,2070],{"class":190}," notion.dataSources.",[184,2072,2073],{"class":194},"query",[184,2075,1718],{"class":190},[184,2077,2078],{"class":186,"line":245},[184,2079,2080],{"class":190},"    data_source_id: config.notionDataSourceId,\n",[184,2082,2083],{"class":186,"line":454},[184,2084,586],{"class":190},[184,2086,2087],{"class":186,"line":460},[184,2088,457],{"emptyLinePlaceholder":317},[184,2090,2091,2093],{"class":186,"line":478},[184,2092,1240],{"class":215},[184,2094,2095],{"class":190}," response.results\n",[184,2097,2098,2101,2103],{"class":186,"line":319},[184,2099,2100],{"class":190},"    .",[184,2102,1851],{"class":194},[184,2104,2105],{"class":190},"(transformItinerary)\n",[184,2107,2108,2110,2113,2115,2118,2120,2123,2125,2127,2130,2133],{"class":186,"line":519},[184,2109,2100],{"class":190},[184,2111,2112],{"class":194},"sort",[184,2114,1197],{"class":190},[184,2116,2117],{"class":208},"a",[184,2119,489],{"class":190},[184,2121,2122],{"class":208},"b",[184,2124,212],{"class":190},[184,2126,216],{"class":215},[184,2128,2129],{"class":190}," a.date ",[184,2131,2132],{"class":215},"!==",[184,2134,2135],{"class":190}," b.date\n",[184,2137,2138,2141,2144,2147],{"class":186,"line":524},[184,2139,2140],{"class":215},"      ?",[184,2142,2143],{"class":190}," a.date.",[184,2145,2146],{"class":194},"localeCompare",[184,2148,2149],{"class":190},"(b.date)\n",[184,2151,2152,2155,2158,2161],{"class":186,"line":553},[184,2153,2154],{"class":215},"      :",[184,2156,2157],{"class":190}," a.order ",[184,2159,2160],{"class":215},"-",[184,2162,2163],{"class":190}," b.order)\n",[184,2165,2166,2169,2172],{"class":186,"line":558},[184,2167,2168],{"class":190},"}, { maxAge: ",[184,2170,2171],{"class":251},"60",[184,2173,1372],{"class":190},[11,2175,2176],{},"代價是在 Notion 改完內容後，最多要等一分鐘才會更新。對行程網站來說完全可以接受。",[20,2178,2180],{"id":2179},"踩到的坑slug-不一定唯一","踩到的坑：slug 不一定唯一",[11,2182,2183],{},"行程詳情頁一開始只用 slug 查詢，後來發現同一間飯店住兩次，就會有兩筆相同的 slug。Notion 不會幫欄位檢查唯一值，所以我再用第幾天來區分：",[175,2185,2187],{"className":409,"code":2186,"language":411,"meta":180,"style":180},"const trip = (day ? matches.find(item => item.day === `Day ${day}`) : null)\n  ?? matches[0]\n",[145,2188,2189,2241],{"__ignoreMap":180},[184,2190,2191,2193,2196,2198,2201,2204,2207,2210,2212,2214,2217,2220,2223,2226,2229,2232,2234,2236,2239],{"class":186,"line":187},[184,2192,967],{"class":215},[184,2194,2195],{"class":251}," trip",[184,2197,510],{"class":215},[184,2199,2200],{"class":190}," (day ",[184,2202,2203],{"class":215},"?",[184,2205,2206],{"class":190}," matches.",[184,2208,2209],{"class":194},"find",[184,2211,198],{"class":190},[184,2213,1856],{"class":208},[184,2215,2216],{"class":215}," =>",[184,2218,2219],{"class":190}," item.day ",[184,2221,2222],{"class":215},"===",[184,2224,2225],{"class":201}," `Day ${",[184,2227,2228],{"class":190},"day",[184,2230,2231],{"class":201},"}`",[184,2233,212],{"class":190},[184,2235,1771],{"class":215},[184,2237,2238],{"class":251}," null",[184,2240,255],{"class":190},[184,2242,2243,2246,2249,2251],{"class":186,"line":222},[184,2244,2245],{"class":215},"  ??",[184,2247,2248],{"class":190}," matches[",[184,2250,1797],{"class":251},[184,2252,2253],{"class":190},"]\n",[11,2255,2256],{},"路由帶了查詢參數之後，快取的 key 也要跟著改，否則不同天會拿到同一份快取：",[175,2258,2260],{"className":409,"code":2259,"language":411,"meta":180,"style":180},"{ maxAge: 60, getKey: event => `${getRouterParam(event, 'slug') ?? ''}:${getQuery(event).day ?? ''}` }\n",[145,2261,2262],{"__ignoreMap":180},[184,2263,2264,2267,2270,2272,2274,2276,2279,2281,2284,2286,2289,2292,2294,2296,2298,2301,2303,2305,2308,2311,2313,2315,2317,2319,2322,2325],{"class":186,"line":187},[184,2265,2266],{"class":190},"{ ",[184,2268,2269],{"class":194},"maxAge",[184,2271,675],{"class":190},[184,2273,2171],{"class":251},[184,2275,489],{"class":190},[184,2277,2278],{"class":194},"getKey",[184,2280,675],{"class":190},[184,2282,2283],{"class":208},"event",[184,2285,2216],{"class":215},[184,2287,2288],{"class":201}," `${",[184,2290,2291],{"class":194},"getRouterParam",[184,2293,198],{"class":201},[184,2295,2283],{"class":190},[184,2297,489],{"class":201},[184,2299,2300],{"class":201},"'slug'",[184,2302,212],{"class":201},[184,2304,1803],{"class":215},[184,2306,2307],{"class":201}," ''}:${",[184,2309,2310],{"class":194},"getQuery",[184,2312,198],{"class":201},[184,2314,2283],{"class":190},[184,2316,541],{"class":201},[184,2318,2228],{"class":190},[184,2320,2321],{"class":215}," ??",[184,2323,2324],{"class":201}," ''}`",[184,2326,2327],{"class":190}," }\n",[11,2329,2330],{},"這也是用 Notion 當 CMS 要注意的事：它是編輯工具，不是資料庫，資料的正確性要由程式自己把關。",[20,2332,2333],{"id":2333},"讓內容維持好編輯",[11,2335,2336,2337,2340,2341,2344],{},"交通方式是一段多步驟的說明，如果拆成很多欄位，編輯起來很麻煩。所以我讓它在 Notion 裡維持一段純文字，用 ",[145,2338,2339],{},"#"," 當標題、",[145,2342,2343],{},"⏱"," 當時間，再寫一個 parser 轉成結構化資料給前端顯示。",[11,2346,2347,2348,2351],{},"票券則是放在一般的 Notion 頁面，由 ",[145,2349,2350],{},"blocks.children.list"," 讀取標題、圖片和檔案。這部分比較敏感，所以另外加了密碼驗證，通過後才能讀取。",[20,2353,2354],{"id":2354},"內容和使用者狀態分開",[11,2356,2357,2358,2361],{},"行李清單的項目來自 Notion，但「打勾」是每個使用者自己的狀態，不該寫回 Notion。所以打勾狀態和自己新增的項目都存在 ",[145,2359,2360],{},"localStorage","，顯示時再合併：",[175,2363,2365],{"className":409,"code":2364,"language":411,"meta":180,"style":180},"const items = computed(() =>\n  [...(data.value ?? []), ...customItems.value].map(item => ({\n    ...item,\n    checked: checkedMap.value[item.id] ?? false,\n  })),\n)\n",[145,2366,2367,2384,2416,2424,2436,2441],{"__ignoreMap":180},[184,2368,2369,2371,2374,2376,2379,2381],{"class":186,"line":187},[184,2370,967],{"class":215},[184,2372,2373],{"class":251}," items",[184,2375,510],{"class":215},[184,2377,2378],{"class":194}," computed",[184,2380,1480],{"class":190},[184,2382,2383],{"class":215},"=>\n",[184,2385,2386,2389,2392,2395,2397,2400,2402,2405,2407,2409,2411,2413],{"class":186,"line":222},[184,2387,2388],{"class":190},"  [",[184,2390,2391],{"class":215},"...",[184,2393,2394],{"class":190},"(data.value ",[184,2396,1803],{"class":215},[184,2398,2399],{"class":190}," []), ",[184,2401,2391],{"class":215},[184,2403,2404],{"class":190},"customItems.value].",[184,2406,1851],{"class":194},[184,2408,198],{"class":190},[184,2410,1856],{"class":208},[184,2412,2216],{"class":215},[184,2414,2415],{"class":190}," ({\n",[184,2417,2418,2421],{"class":186,"line":245},[184,2419,2420],{"class":215},"    ...",[184,2422,2423],{"class":190},"item,\n",[184,2425,2426,2429,2431,2434],{"class":186,"line":454},[184,2427,2428],{"class":190},"    checked: checkedMap.value[item.id] ",[184,2430,1803],{"class":215},[184,2432,2433],{"class":251}," false",[184,2435,1077],{"class":190},[184,2437,2438],{"class":186,"line":460},[184,2439,2440],{"class":190},"  })),\n",[184,2442,2443],{"class":186,"line":478},[184,2444,255],{"class":190},[20,2446,2448],{"id":2447},"串接-google-maps","串接 Google Maps",[11,2450,2451],{},"每個行程都有 Notion 裡維護的 Google Maps 連結，在詳情頁點一下就能開啟地圖。",[11,2453,2454],{},"迪士尼樂園的規劃則用 Google Maps JavaScript API，在地圖上標出每個項目，再用線連起來，看得出當天的動線。點擊標記可以聚焦到對應的項目：",[175,2456,2458],{"className":409,"code":2457,"language":411,"meta":180,"style":180},"markers = props.items.map((item, index) => {\n  const marker = new g.maps.Marker({ \u002F* 位置與標籤 *\u002F })\n  marker.addListener('click', () => focusItem(index))\n  return marker\n})\n",[145,2459,2460,2487,2512,2534,2541],{"__ignoreMap":180},[184,2461,2462,2465,2467,2470,2472,2474,2476,2478,2481,2483,2485],{"class":186,"line":187},[184,2463,2464],{"class":190},"markers ",[184,2466,833],{"class":215},[184,2468,2469],{"class":190}," props.items.",[184,2471,1851],{"class":194},[184,2473,1197],{"class":190},[184,2475,1856],{"class":208},[184,2477,489],{"class":190},[184,2479,2480],{"class":208},"index",[184,2482,212],{"class":190},[184,2484,216],{"class":215},[184,2486,219],{"class":190},[184,2488,2489,2491,2494,2496,2498,2501,2504,2507,2510],{"class":186,"line":222},[184,2490,2060],{"class":215},[184,2492,2493],{"class":251}," marker",[184,2495,510],{"class":215},[184,2497,1712],{"class":215},[184,2499,2500],{"class":190}," g.maps.",[184,2502,2503],{"class":194},"Marker",[184,2505,2506],{"class":190},"({ ",[184,2508,2509],{"class":1031},"\u002F* 位置與標籤 *\u002F",[184,2511,1372],{"class":190},[184,2513,2514,2517,2520,2522,2524,2526,2528,2531],{"class":186,"line":245},[184,2515,2516],{"class":190},"  marker.",[184,2518,2519],{"class":194},"addListener",[184,2521,198],{"class":190},[184,2523,202],{"class":201},[184,2525,471],{"class":190},[184,2527,216],{"class":215},[184,2529,2530],{"class":194}," focusItem",[184,2532,2533],{"class":190},"(index))\n",[184,2535,2536,2538],{"class":186,"line":454},[184,2537,1240],{"class":215},[184,2539,2540],{"class":190}," marker\n",[184,2542,2543],{"class":186,"line":460},[184,2544,592],{"class":190},[11,2546,2547],{},"地圖腳本不小，也不是每頁都需要，所以只在要用時才載入。我把載入的 Promise 存起來，多個地方同時呼叫也只會載入一次：",[175,2549,2551],{"className":409,"code":2550,"language":411,"meta":180,"style":180},"let loaderPromise: Promise\u003Cvoid> | null = null\n\nexport function loadGoogleMaps(apiKey: string): Promise\u003Cvoid> {\n  if (window.google?.maps) return Promise.resolve()\n  if (loaderPromise) return loaderPromise\n\n  loaderPromise = new Promise((resolve, reject) => {\n    const script = document.createElement('script')\n    script.src = `https:\u002F\u002Fmaps.googleapis.com\u002Fmaps\u002Fapi\u002Fjs?key=${apiKey}`\n    script.async = true\n    script.onload = () => resolve()\n    script.onerror = () => reject(new Error('Google Maps 載入失敗'))\n    document.head.appendChild(script)\n  })\n\n  return loaderPromise\n}\n",[145,2552,2553,2582,2586,2617,2637,2649,2653,2679,2701,2716,2726,2745,2777,2788,2793,2798,2805],{"__ignoreMap":180},[184,2554,2555,2557,2560,2562,2565,2567,2570,2573,2576,2578,2580],{"class":186,"line":187},[184,2556,1441],{"class":215},[184,2558,2559],{"class":190}," loaderPromise",[184,2561,1771],{"class":215},[184,2563,2564],{"class":194}," Promise",[184,2566,823],{"class":190},[184,2568,2569],{"class":251},"void",[184,2571,2572],{"class":190},"> ",[184,2574,2575],{"class":215},"|",[184,2577,2238],{"class":251},[184,2579,510],{"class":215},[184,2581,1449],{"class":251},[184,2583,2584],{"class":186,"line":222},[184,2585,457],{"emptyLinePlaceholder":317},[184,2587,2588,2590,2592,2595,2597,2600,2602,2604,2606,2608,2610,2612,2614],{"class":186,"line":245},[184,2589,1009],{"class":215},[184,2591,1760],{"class":215},[184,2593,2594],{"class":194}," loadGoogleMaps",[184,2596,198],{"class":190},[184,2598,2599],{"class":208},"apiKey",[184,2601,1771],{"class":215},[184,2603,1784],{"class":251},[184,2605,1904],{"class":190},[184,2607,1771],{"class":215},[184,2609,2564],{"class":194},[184,2611,823],{"class":190},[184,2613,2569],{"class":251},[184,2615,2616],{"class":190},"> {\n",[184,2618,2619,2621,2624,2627,2629,2632,2635],{"class":186,"line":454},[184,2620,1211],{"class":215},[184,2622,2623],{"class":190}," (window.google?.maps) ",[184,2625,2626],{"class":215},"return",[184,2628,2564],{"class":251},[184,2630,2631],{"class":190},".",[184,2633,2634],{"class":194},"resolve",[184,2636,1694],{"class":190},[184,2638,2639,2641,2644,2646],{"class":186,"line":460},[184,2640,1211],{"class":215},[184,2642,2643],{"class":190}," (loaderPromise) ",[184,2645,2626],{"class":215},[184,2647,2648],{"class":190}," loaderPromise\n",[184,2650,2651],{"class":186,"line":478},[184,2652,457],{"emptyLinePlaceholder":317},[184,2654,2655,2658,2660,2662,2664,2666,2668,2670,2673,2675,2677],{"class":186,"line":319},[184,2656,2657],{"class":190},"  loaderPromise ",[184,2659,833],{"class":215},[184,2661,1712],{"class":215},[184,2663,2564],{"class":251},[184,2665,1197],{"class":190},[184,2667,2634],{"class":208},[184,2669,489],{"class":190},[184,2671,2672],{"class":208},"reject",[184,2674,212],{"class":190},[184,2676,216],{"class":215},[184,2678,219],{"class":190},[184,2680,2681,2683,2686,2688,2691,2694,2696,2699],{"class":186,"line":519},[184,2682,504],{"class":215},[184,2684,2685],{"class":251}," script",[184,2687,510],{"class":215},[184,2689,2690],{"class":190}," document.",[184,2692,2693],{"class":194},"createElement",[184,2695,198],{"class":190},[184,2697,2698],{"class":201},"'script'",[184,2700,255],{"class":190},[184,2702,2703,2706,2708,2711,2713],{"class":186,"line":524},[184,2704,2705],{"class":190},"    script.src ",[184,2707,833],{"class":215},[184,2709,2710],{"class":201}," `https:\u002F\u002Fmaps.googleapis.com\u002Fmaps\u002Fapi\u002Fjs?key=${",[184,2712,2599],{"class":190},[184,2714,2715],{"class":201},"}`\n",[184,2717,2718,2721,2723],{"class":186,"line":553},[184,2719,2720],{"class":190},"    script.async ",[184,2722,833],{"class":215},[184,2724,2725],{"class":251}," true\n",[184,2727,2728,2731,2734,2736,2738,2740,2743],{"class":186,"line":558},[184,2729,2730],{"class":190},"    script.",[184,2732,2733],{"class":194},"onload",[184,2735,510],{"class":215},[184,2737,495],{"class":190},[184,2739,216],{"class":215},[184,2741,2742],{"class":194}," resolve",[184,2744,1694],{"class":190},[184,2746,2747,2749,2752,2754,2756,2758,2761,2763,2766,2769,2771,2774],{"class":186,"line":583},[184,2748,2730],{"class":190},[184,2750,2751],{"class":194},"onerror",[184,2753,510],{"class":215},[184,2755,495],{"class":190},[184,2757,216],{"class":215},[184,2759,2760],{"class":194}," reject",[184,2762,198],{"class":190},[184,2764,2765],{"class":215},"new",[184,2767,2768],{"class":194}," Error",[184,2770,198],{"class":190},[184,2772,2773],{"class":201},"'Google Maps 載入失敗'",[184,2775,2776],{"class":190},"))\n",[184,2778,2779,2782,2785],{"class":186,"line":589},[184,2780,2781],{"class":190},"    document.head.",[184,2783,2784],{"class":194},"appendChild",[184,2786,2787],{"class":190},"(script)\n",[184,2789,2791],{"class":186,"line":2790},14,[184,2792,586],{"class":190},[184,2794,2796],{"class":186,"line":2795},15,[184,2797,457],{"emptyLinePlaceholder":317},[184,2799,2801,2803],{"class":186,"line":2800},16,[184,2802,1240],{"class":215},[184,2804,2648],{"class":190},[184,2806,2808],{"class":186,"line":2807},17,[184,2809,708],{"class":190},[20,2811,2813],{"id":2812},"不是所有資料都放-notion迪士尼餐廳資料","不是所有資料都放 Notion：迪士尼餐廳資料",[11,2815,2816],{},"規劃東京迪士尼時，我想直接看到各餐廳的菜單，但官網沒有繁體中文，一間一間查也很花時間。這類資料量大、又幾乎不會改，不適合在 Notion 手動維護，所以我用爬蟲把資料整理下來。",[11,2818,2819],{},"我寫了在瀏覽器裡執行的腳本，逐間讀取官網的餐廳頁面，總共整理出 154 間餐廳，其中 122 間有菜單，合計 1,842 個品項，再用 GPT 翻譯成繁體中文。這批資料只供個人旅遊使用，放在密碼驗證之後，沒有公開。",[11,2821,2822],{},"每間餐廳存成一個 JSON 檔，並保留原文和繁體中文，也記下來源網址和取得日期，之後要核對或更新才有依據：",[175,2824,2827],{"className":2825,"code":2826,"language":1357,"meta":180,"style":180},"language-json shiki shiki-themes github-dark","{\n  \"source\": {\n    \"official_url\": \"...\",\n    \"retrieved_date\": \"2026-09-10\",\n    \"language_original\": \"en\"\n  },\n  \"restaurant\": {\n    \"name\": {\n      \"original\": \"La Taverne de Gaston\",\n      \"zh_tw\": \"加斯頓餐館\"\n    }\n  }\n}\n",[145,2828,2829,2834,2842,2854,2866,2876,2881,2888,2895,2907,2917,2922,2926],{"__ignoreMap":180},[184,2830,2831],{"class":186,"line":187},[184,2832,2833],{"class":190},"{\n",[184,2835,2836,2839],{"class":186,"line":222},[184,2837,2838],{"class":251},"  \"source\"",[184,2840,2841],{"class":190},": {\n",[184,2843,2844,2847,2849,2852],{"class":186,"line":245},[184,2845,2846],{"class":251},"    \"official_url\"",[184,2848,675],{"class":190},[184,2850,2851],{"class":201},"\"...\"",[184,2853,1077],{"class":190},[184,2855,2856,2859,2861,2864],{"class":186,"line":454},[184,2857,2858],{"class":251},"    \"retrieved_date\"",[184,2860,675],{"class":190},[184,2862,2863],{"class":201},"\"2026-09-10\"",[184,2865,1077],{"class":190},[184,2867,2868,2871,2873],{"class":186,"line":460},[184,2869,2870],{"class":251},"    \"language_original\"",[184,2872,675],{"class":190},[184,2874,2875],{"class":201},"\"en\"\n",[184,2877,2878],{"class":186,"line":478},[184,2879,2880],{"class":190},"  },\n",[184,2882,2883,2886],{"class":186,"line":319},[184,2884,2885],{"class":251},"  \"restaurant\"",[184,2887,2841],{"class":190},[184,2889,2890,2893],{"class":186,"line":519},[184,2891,2892],{"class":251},"    \"name\"",[184,2894,2841],{"class":190},[184,2896,2897,2900,2902,2905],{"class":186,"line":524},[184,2898,2899],{"class":251},"      \"original\"",[184,2901,675],{"class":190},[184,2903,2904],{"class":201},"\"La Taverne de Gaston\"",[184,2906,1077],{"class":190},[184,2908,2909,2912,2914],{"class":186,"line":553},[184,2910,2911],{"class":251},"      \"zh_tw\"",[184,2913,675],{"class":190},[184,2915,2916],{"class":201},"\"加斯頓餐館\"\n",[184,2918,2919],{"class":186,"line":558},[184,2920,2921],{"class":190},"    }\n",[184,2923,2924],{"class":186,"line":583},[184,2925,1235],{"class":190},[184,2927,2928],{"class":186,"line":589},[184,2929,708],{"class":190},[594,2931,2933],{"id":2932},"本機正常部署到-vercel-卻找不到檔案","本機正常，部署到 Vercel 卻找不到檔案",[11,2935,2936,2937,2940,2941,1161,2944,2947,2948,2951],{},"一開始 API 用 ",[145,2938,2939],{},"node:fs"," 的 ",[145,2942,2943],{},"readdirSync",[145,2945,2946],{},"readFileSync"," 搭配 ",[145,2949,2950],{},"process.cwd()"," 讀取這些 JSON，本機完全正常，部署到 Vercel 後卻找不到檔案。",[11,2953,2954],{},"原因是這種在執行時才動態決定路徑的讀檔方式，Vercel 的 serverless function 打包器無法靜態追蹤，這批檔案根本沒有被包進部署結果裡。",[11,2956,2957,2958,2961,2962,2965],{},"解法是改用 Nitro 的 ",[145,2959,2960],{},"serverAssets","，把 JSON 內嵌進 server build，再用 ",[145,2963,2964],{},"useStorage()"," 讀取：",[175,2967,2969],{"className":409,"code":2968,"language":411,"meta":180,"style":180},"\u002F\u002F nuxt.config.ts\nnitro: {\n  serverAssets: [{ baseName: 'tdr-menu', dir: 'data\u002Ftdr-menu' }],\n}\n\n\u002F\u002F API 路由\nconst storage = useStorage('assets:tdr-menu')\nconst keys = await storage.getKeys(park)\n",[145,2970,2971,2976,2983,3003,3007,3011,3016,3035],{"__ignoreMap":180},[184,2972,2973],{"class":186,"line":187},[184,2974,2975],{"class":1031},"\u002F\u002F nuxt.config.ts\n",[184,2977,2978,2981],{"class":186,"line":222},[184,2979,2980],{"class":194},"nitro",[184,2982,2841],{"class":190},[184,2984,2985,2988,2991,2994,2997,3000],{"class":186,"line":245},[184,2986,2987],{"class":194},"  serverAssets",[184,2989,2990],{"class":190},": [{ baseName: ",[184,2992,2993],{"class":201},"'tdr-menu'",[184,2995,2996],{"class":190},", dir: ",[184,2998,2999],{"class":201},"'data\u002Ftdr-menu'",[184,3001,3002],{"class":190}," }],\n",[184,3004,3005],{"class":186,"line":454},[184,3006,708],{"class":190},[184,3008,3009],{"class":186,"line":460},[184,3010,457],{"emptyLinePlaceholder":317},[184,3012,3013],{"class":186,"line":478},[184,3014,3015],{"class":1031},"\u002F\u002F API 路由\n",[184,3017,3018,3020,3023,3025,3028,3030,3033],{"class":186,"line":319},[184,3019,967],{"class":215},[184,3021,3022],{"class":251}," storage",[184,3024,510],{"class":215},[184,3026,3027],{"class":194}," useStorage",[184,3029,198],{"class":190},[184,3031,3032],{"class":201},"'assets:tdr-menu'",[184,3034,255],{"class":190},[184,3036,3037,3039,3042,3044,3046,3049,3052],{"class":186,"line":519},[184,3038,967],{"class":215},[184,3040,3041],{"class":251}," keys",[184,3043,510],{"class":215},[184,3045,1515],{"class":215},[184,3047,3048],{"class":190}," storage.",[184,3050,3051],{"class":194},"getKeys",[184,3053,3054],{"class":190},"(park)\n",[11,3056,3057,3058,3061,3062,2940,3065,3068,3069,3072,3073,3076],{},"這裡還有一個小坑：",[145,3059,3060],{},"dir"," 要寫相對於 ",[145,3063,3064],{},"server\u002F",[145,3066,3067],{},"data\u002Ftdr-menu","。如果寫成 ",[145,3070,3071],{},"server\u002Fdata\u002Ftdr-menu","，路徑會疊成 ",[145,3074,3075],{},"server\u002Fserver\u002Fdata\u002Ftdr-menu","，一樣找不到。",[20,3078,3080],{"id":3079},"另外串接-gpt-與-gemini","另外串接 GPT 與 Gemini",[11,3082,3083],{},"旅途中常會遇到臨時想問的問題，所以網站裡也做了 AI 聊天，同樣由伺服器路由呼叫，金鑰不會出現在前端。可以選擇 GPT 或 Gemini，也可以上傳圖片一起詢問。",[11,3085,3086],{},"Gemini 遇到 429（請求過多）時，會自動改用 OpenAI 回答，使用者不用自己切換：",[175,3088,3090],{"className":409,"code":3089,"language":411,"meta":180,"style":180},"try {\n  return { message: await askGemini({ history, images }) }\n} catch (error: any) {\n  if (error.status === 429) {\n    return { provider: 'openai', message: await askOpenAI({ history, images }) }\n  }\n  throw error\n}\n",[145,3091,3092,3099,3115,3135,3149,3169,3173,3181],{"__ignoreMap":180},[184,3093,3094,3097],{"class":186,"line":187},[184,3095,3096],{"class":215},"try",[184,3098,219],{"class":190},[184,3100,3101,3103,3106,3109,3112],{"class":186,"line":222},[184,3102,1240],{"class":215},[184,3104,3105],{"class":190}," { message: ",[184,3107,3108],{"class":215},"await",[184,3110,3111],{"class":194}," askGemini",[184,3113,3114],{"class":190},"({ history, images }) }\n",[184,3116,3117,3120,3123,3126,3129,3131,3133],{"class":186,"line":245},[184,3118,3119],{"class":190},"} ",[184,3121,3122],{"class":215},"catch",[184,3124,3125],{"class":190}," (",[184,3127,3128],{"class":208},"error",[184,3130,1771],{"class":215},[184,3132,1774],{"class":251},[184,3134,1787],{"class":190},[184,3136,3137,3139,3142,3144,3147],{"class":186,"line":454},[184,3138,1211],{"class":215},[184,3140,3141],{"class":190}," (error.status ",[184,3143,2222],{"class":215},[184,3145,3146],{"class":251}," 429",[184,3148,1787],{"class":190},[184,3150,3151,3154,3157,3160,3162,3164,3167],{"class":186,"line":460},[184,3152,3153],{"class":215},"    return",[184,3155,3156],{"class":190}," { provider: ",[184,3158,3159],{"class":201},"'openai'",[184,3161,1366],{"class":190},[184,3163,3108],{"class":215},[184,3165,3166],{"class":194}," askOpenAI",[184,3168,3114],{"class":190},[184,3170,3171],{"class":186,"line":478},[184,3172,1235],{"class":190},[184,3174,3175,3178],{"class":186,"line":319},[184,3176,3177],{"class":215},"  throw",[184,3179,3180],{"class":190}," error\n",[184,3182,3183],{"class":186,"line":519},[184,3184,708],{"class":190},[20,3186,3187],{"id":3187},"目前的限制",[28,3189,3190,3197],{},[31,3191,3192,3193,3196],{},"查詢沒有處理分頁。Notion 每次查詢最多回傳 100 筆，資料超過之後要用 ",[145,3194,3195],{},"next_cursor"," 繼續讀取。",[31,3198,3199],{},"Notion 欄位是用名稱取值，有人在後台改欄位名稱，資料就會變成空值，不會報錯。",[20,3201,1580],{"id":1580},[28,3203,3204,3207,3210],{},[31,3205,3206],{},"不是每個專案都需要自己做後台，先想清楚內容由誰維護、多常更新。",[31,3208,3209],{},"第三方資料要在邊界轉換成自己的型別，不要讓外部結構滲進元件。",[31,3211,3212],{},"快取的 key 要包含所有會影響結果的參數。",[300,3214,3215],{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}",{"title":180,"searchDepth":222,"depth":222,"links":3217},[3218,3219,3220,3221,3222,3223,3224,3225,3226,3229,3230,3231],{"id":1629,"depth":222,"text":1630},{"id":1646,"depth":222,"text":1646},{"id":1737,"depth":222,"text":1738},{"id":2022,"depth":222,"text":2023},{"id":2179,"depth":222,"text":2180},{"id":2333,"depth":222,"text":2333},{"id":2354,"depth":222,"text":2354},{"id":2447,"depth":222,"text":2448},{"id":2812,"depth":222,"text":2813,"children":3227},[3228],{"id":2932,"depth":245,"text":2933},{"id":3079,"depth":222,"text":3080},{"id":3187,"depth":222,"text":3187},{"id":1580,"depth":222,"text":1580},"2026-09-11","為了 9 月的東京自由行，用 Nuxt 4 做了一個旅遊行程網站，把 Notion 當成內容後台，並用伺服器路由、transformer 與快取隔開 Notion 的資料結構。",{},"\u002Fblog\u002Fnotion-as-cms",{"title":1621,"description":3233},"blog\u002Fnotion-as-cms",[3239,3240,3241],"Notion API","CMS","Nuxt","XFUHA8oaWUBKIXrewXcfF_21qt4KZI7agXhRe42-Evs",{"id":3244,"title":3245,"body":3246,"category":918,"date":3475,"description":3476,"draft":314,"extension":315,"meta":3477,"navigation":317,"path":3478,"readingTime":460,"seo":3479,"stem":3480,"tags":3481,"__hash__":3485},"blog\u002Fblog\u002Fone-change-many-projects.md","同一個修改要改好幾個專案：維護多個前端的心得",{"type":8,"value":3247,"toc":3464},[3248,3251,3254,3258,3261,3275,3278,3328,3339,3342,3345,3348,3351,3354,3357,3360,3381,3384,3388,3391,3394,3397,3400,3403,3406,3409,3415,3426,3431,3439,3442,3445,3447,3461],[11,3249,3250],{},"我在工作上維護的不是一個前端，而是好幾個：Vue 2 + Vuex 的專案、Vue 3 + Pinia 的專案，還有桌機版和手機版分開的系統。它們服務同一套業務，所以一個需求常常要改好幾次。",[11,3252,3253],{},"這是課堂上學不到的事。這篇整理我實際遇到的狀況。",[20,3255,3257],{"id":3256},"同一個需求要做好幾次","同一個需求，要做好幾次",[11,3259,3260],{},"幾個真實的例子：",[28,3262,3263,3266,3269,3272],{},[31,3264,3265],{},"註冊姓名欄位調整：桌機版和手機版都要改。",[31,3267,3268],{},"登入後回到原本頁面：兩個版本各做一次。",[31,3270,3271],{},"圖片檔名中文改英文：每個專案都要處理。",[31,3273,3274],{},"無障礙修正：多個專案都要符合。",[11,3276,3277],{},"這些修改的概念相同，但實作不一定能直接複製：",[81,3279,3280,3293],{},[84,3281,3282],{},[87,3283,3284,3287,3290],{},[90,3285,3286],{},"差異",[90,3288,3289],{},"Vue 2 專案",[90,3291,3292],{},"Vue 3 專案",[100,3294,3295,3306,3317],{},[87,3296,3297,3300,3303],{},[105,3298,3299],{},"狀態管理",[105,3301,3302],{},"Vuex",[105,3304,3305],{},"Pinia",[87,3307,3308,3311,3314],{},[105,3309,3310],{},"UI 元件庫",[105,3312,3313],{},"Vuetify 2",[105,3315,3316],{},"Vuetify 3",[87,3318,3319,3322,3325],{},[105,3320,3321],{},"路由",[105,3323,3324],{},"Vue Router 3",[105,3326,3327],{},"Vue Router 4",[11,3329,3330,3331,3334,3335,3338],{},"例如 404 路由，Vue Router 3 寫 ",[145,3332,3333],{},"path: '*'","，Vue Router 4 要寫 ",[145,3336,3337],{},"\u002F:pathMatch(.*)*","。同樣的概念，寫法不一樣，測試也要再做一次。",[20,3340,3341],{"id":3341},"最大的風險是漏改",[11,3343,3344],{},"修改次數變多，時間成本還不是最麻煩的，真正的問題是漏掉。",[11,3346,3347],{},"我自己就遇過：有一次把註冊頁的姓名拆解邏輯移除，測試也沒問題，後來才發現登入頁的補填資料表單裡還有同一段邏輯。兩個專案都是這樣。",[11,3349,3350],{},"原因很簡單：相同的邏輯在同一個專案裡就寫了兩份，專案之間又各有一份。需求單只寫「註冊」，我就只改了註冊。",[20,3352,3353],{"id":3353},"我現在的做法",[594,3355,3356],{"id":3356},"動手前先搜尋",[11,3358,3359],{},"改之前先在每個專案搜尋相同的函式和關鍵字，確認影響範圍：",[175,3361,3365],{"className":3362,"code":3363,"language":3364,"meta":180,"style":180},"language-bash shiki shiki-themes github-dark","grep -rn \"splitName\" src\n","bash",[145,3366,3367],{"__ignoreMap":180},[184,3368,3369,3372,3375,3378],{"class":186,"line":187},[184,3370,3371],{"class":194},"grep",[184,3373,3374],{"class":251}," -rn",[184,3376,3377],{"class":201}," \"splitName\"",[184,3379,3380],{"class":201}," src\n",[11,3382,3383],{},"只改眼前這一支檔案，是最容易漏的做法。",[594,3385,3387],{"id":3386},"用-notion-列出專案功能清單","用 Notion 列出專案功能清單",[11,3389,3390],{},"發生過漏改之後，我開始用 Notion 把每個專案的功能列成清單，例如註冊、登入、補填資料、會員資料。",[11,3392,3393],{},"有需求時先對照清單，看看哪些功能會受影響；測試時再照著清單一項一項走過。這樣姓名這類改動，就不會只測到註冊頁，登入頁的補填資料表單也會在測試範圍裡。",[11,3395,3396],{},"清單要隨著功能更新才有用。新增功能時順手補上，它就是一份不靠記憶的測試範圍。",[594,3398,3399],{"id":3399},"一次改完",[11,3401,3402],{},"同一個需求盡量同一天處理完所有專案。隔太久，就會忘記當時的上下文，也更容易出現版本不一致。",[20,3404,3405],{"id":3405},"什麼時候該抽成共用模組",[11,3407,3408],{},"看到重複，第一個想法通常是抽成共用套件。但我現在不會馬上這麼做。",[11,3410,3411,3414],{},[15,3412,3413],{},"適合抽出來的","：",[28,3416,3417,3420,3423],{},[31,3418,3419],{},"不依賴框架的純邏輯，例如日期格式、資料驗證。",[31,3421,3422],{},"真的完全相同，而且會一起改的規則。",[31,3424,3425],{},"像日期選擇器的鍵盤操作這種，同專案內多個頁面共用的邏輯，就可以先抽成 mixin 或 composable。",[11,3427,3428,3414],{},[15,3429,3430],{},"不適合馬上抽的",[28,3432,3433,3436],{},[31,3434,3435],{},"綁定 UI 元件庫的畫面，Vuetify 2 和 3 差異很大。",[31,3437,3438],{},"只是目前長得像，但各自的需求會往不同方向發展。",[11,3440,3441],{},"共用模組也有成本：要處理版本、發布，改一個地方可能影響所有專案。Vue 2 和 Vue 3 要共用元件，更是不容易。",[11,3443,3444],{},"我的判斷方式是：先在專案內抽出來，確認它穩定，而且真的在多處一起變動，才考慮跨專案共用。",[20,3446,1580],{"id":1580},[28,3448,3449,3452,3455,3458],{},[31,3450,3451],{},"維護多個專案時，「知道要改哪裡」比「怎麼改」更花時間。",[31,3453,3454],{},"搜尋是最便宜的防護。",[31,3456,3457],{},"重複程式碼不一定要立刻消除，錯誤的抽象比重複更難維護。",[31,3459,3460],{},"需求單寫的範圍不一定等於實際影響的範圍。",[300,3462,3463],{},"html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":180,"searchDepth":222,"depth":222,"links":3465},[3466,3467,3468,3473,3474],{"id":3256,"depth":222,"text":3257},{"id":3341,"depth":222,"text":3341},{"id":3353,"depth":222,"text":3353,"children":3469},[3470,3471,3472],{"id":3356,"depth":245,"text":3356},{"id":3386,"depth":245,"text":3387},{"id":3399,"depth":245,"text":3399},{"id":3405,"depth":222,"text":3405},{"id":1580,"depth":222,"text":1580},"2026-08-11","公司同時維護多個 Vue 2 與 Vue 3 前端，同一個需求常要改好幾次。整理實際遇到的問題、漏改的情況，以及什麼時候才值得抽成共用模組。",{},"\u002Fblog\u002Fone-change-many-projects",{"title":3245,"description":3476},"blog\u002Fone-change-many-projects",[3482,3483,3484],"Maintenance","Vue 2","Vue 3","1er6hOdG09YFLnYy2WHvggOBGYc78tRqv1k1qby30fA",{"id":3487,"title":3488,"body":3489,"category":311,"date":4422,"description":4423,"draft":314,"extension":315,"meta":4424,"navigation":317,"path":4425,"readingTime":478,"seo":4426,"stem":4427,"tags":4428,"__hash__":4429},"blog\u002Fblog\u002Fnuxt-gtm-ga4.md","在 Nuxt 網站導入 GTM 與 GA4：從載入腳本到自訂事件",{"type":8,"value":3490,"toc":4414},[3491,3494,3498,3528,3531,3538,3542,3549,3591,3606,3645,3652,3661,3664,3668,3671,3768,3778,3791,3987,3994,3998,4008,4116,4119,4123,4129,4305,4315,4318,4392,4398,4400,4411],[11,3492,3493],{},"做個人品牌網站時，我想知道訪客看了哪些作品、有沒有點聯絡，所以導入了 Google Tag Manager（GTM）和 Google Analytics 4（GA4）。一開始我分不清這兩個工具的差別，實際做過才比較清楚。",[20,3495,3497],{"id":3496},"gtm-和-ga4-差在哪","GTM 和 GA4 差在哪",[81,3499,3500,3510],{},[84,3501,3502],{},[87,3503,3504,3507],{},[90,3505,3506],{},"工具",[90,3508,3509],{},"負責的事",[100,3511,3512,3520],{},[87,3513,3514,3517],{},[105,3515,3516],{},"GA4",[105,3518,3519],{},"收集並分析數據，例如瀏覽量和事件",[87,3521,3522,3525],{},[105,3523,3524],{},"GTM",[105,3526,3527],{},"管理網站上的追蹤代碼，決定什麼時候觸發",[11,3529,3530],{},"簡單來說，GA4 是看報表的地方，GTM 是放代碼的地方。網站只要載入 GTM，之後新增或調整追蹤，大多可以在 GTM 後台完成，不用每次都改程式。",[11,3532,3533,3534,3537],{},"網站和 GTM 之間靠 ",[145,3535,3536],{},"dataLayer"," 溝通：網站把事件推進去，GTM 再依照設定送到 GA4。",[20,3539,3541],{"id":3540},"先試-nuxt-模組後來改成自己載入","先試 Nuxt 模組，後來改成自己載入",[11,3543,3544,3545,3548],{},"我一開始查到 Nuxt 有官方模組 ",[145,3546,3547],{},"@nuxt\u002Fscripts","，可以管理第三方腳本，所以先用它接 GA4：",[175,3550,3552],{"className":409,"code":3551,"language":411,"meta":180,"style":180},"\u002F\u002F plugins\u002Fanalytics.client.ts\nexport default defineNuxtPlugin(() => {\n  useScriptGoogleAnalytics({ id: 'G-XXXXXXXXXX' })\n})\n",[145,3553,3554,3559,3574,3587],{"__ignoreMap":180},[184,3555,3556],{"class":186,"line":187},[184,3557,3558],{"class":1031},"\u002F\u002F plugins\u002Fanalytics.client.ts\n",[184,3560,3561,3563,3565,3568,3570,3572],{"class":186,"line":222},[184,3562,1009],{"class":215},[184,3564,2042],{"class":215},[184,3566,3567],{"class":194}," defineNuxtPlugin",[184,3569,1480],{"class":190},[184,3571,216],{"class":215},[184,3573,219],{"class":190},[184,3575,3576,3579,3582,3585],{"class":186,"line":245},[184,3577,3578],{"class":194},"  useScriptGoogleAnalytics",[184,3580,3581],{"class":190},"({ id: ",[184,3583,3584],{"class":201},"'G-XXXXXXXXXX'",[184,3586,1372],{"class":190},[184,3588,3589],{"class":186,"line":454},[184,3590,592],{"class":190},[11,3592,3593,3594,3597,3598,3601,3602,3605],{},"之後為了用 GTM 管理追蹤，我換成 ",[145,3595,3596],{},"useScriptGoogleTagManager","，再把設定移到 ",[145,3599,3600],{},"nuxt.config.ts","，並透過模組提供的 ",[145,3603,3604],{},"proxy.dataLayer"," 推送事件：",[175,3607,3609],{"className":409,"code":3608,"language":411,"meta":180,"style":180},"const { proxy } = useScriptGoogleTagManager()\nproxy.dataLayer?.push({ event, ...params })\n",[145,3610,3611,3629],{"__ignoreMap":180},[184,3612,3613,3615,3617,3620,3622,3624,3627],{"class":186,"line":187},[184,3614,967],{"class":215},[184,3616,1504],{"class":190},[184,3618,3619],{"class":251},"proxy",[184,3621,1510],{"class":190},[184,3623,833],{"class":215},[184,3625,3626],{"class":194}," useScriptGoogleTagManager",[184,3628,1694],{"class":190},[184,3630,3631,3634,3637,3640,3642],{"class":186,"line":222},[184,3632,3633],{"class":190},"proxy.dataLayer?.",[184,3635,3636],{"class":194},"push",[184,3638,3639],{"class":190},"({ event, ",[184,3641,2391],{"class":215},[184,3643,3644],{"class":190},"params })\n",[11,3646,3647,3648,3651],{},"但上線後資料一直讀取不到。自訂事件不能等，我就先拿掉模組的設定，改成直接引用 GTM 的 JS，事件也直接推進 ",[145,3649,3650],{},"window.dataLayer","，資料才順利進來。",[11,3653,3654,3655,3657,3658,3660],{},"事後查文件才發現，",[145,3656,3547],{}," v1 調整了行為：registry 沒有設定 ",[145,3659,544],{}," 時不會自動插入腳本，而我當時的設定正好沒有寫。",[11,3662,3663],{},"這個做法的好處是流程全部看得見，之後要控制載入時機也比較容易；代價是要自己處理重複載入和伺服器端渲染的問題。",[20,3665,3667],{"id":3666},"載入-gtm不要拖慢首屏","載入 GTM：不要拖慢首屏",[11,3669,3670],{},"GTM 的腳本不影響網站功能，所以我把它放到瀏覽器閒置時才載入：",[175,3672,3674],{"className":409,"code":3673,"language":411,"meta":180,"style":180},"onMounted(() => {\n  const run = () => initializeAnalytics()\n\n  if ('requestIdleCallback' in window) {\n    requestIdleCallback(run, { timeout: 3000 })\n  } else {\n    setTimeout(run, 1000)\n  }\n})\n",[145,3675,3676,3687,3705,3709,3724,3737,3747,3760,3764],{"__ignoreMap":180},[184,3677,3678,3681,3683,3685],{"class":186,"line":187},[184,3679,3680],{"class":194},"onMounted",[184,3682,1480],{"class":190},[184,3684,216],{"class":215},[184,3686,219],{"class":190},[184,3688,3689,3691,3694,3696,3698,3700,3703],{"class":186,"line":222},[184,3690,2060],{"class":215},[184,3692,3693],{"class":194}," run",[184,3695,510],{"class":215},[184,3697,495],{"class":190},[184,3699,216],{"class":215},[184,3701,3702],{"class":194}," initializeAnalytics",[184,3704,1694],{"class":190},[184,3706,3707],{"class":186,"line":245},[184,3708,457],{"emptyLinePlaceholder":317},[184,3710,3711,3713,3715,3718,3721],{"class":186,"line":454},[184,3712,1211],{"class":215},[184,3714,3125],{"class":190},[184,3716,3717],{"class":201},"'requestIdleCallback'",[184,3719,3720],{"class":215}," in",[184,3722,3723],{"class":190}," window) {\n",[184,3725,3726,3729,3732,3735],{"class":186,"line":460},[184,3727,3728],{"class":194},"    requestIdleCallback",[184,3730,3731],{"class":190},"(run, { timeout: ",[184,3733,3734],{"class":251},"3000",[184,3736,1372],{"class":190},[184,3738,3739,3742,3745],{"class":186,"line":478},[184,3740,3741],{"class":190},"  } ",[184,3743,3744],{"class":215},"else",[184,3746,219],{"class":190},[184,3748,3749,3752,3755,3758],{"class":186,"line":319},[184,3750,3751],{"class":194},"    setTimeout",[184,3753,3754],{"class":190},"(run, ",[184,3756,3757],{"class":251},"1000",[184,3759,255],{"class":190},[184,3761,3762],{"class":186,"line":519},[184,3763,1235],{"class":190},[184,3765,3766],{"class":186,"line":524},[184,3767,592],{"class":190},[11,3769,3770,3773,3774,3777],{},[145,3771,3772],{},"requestIdleCallback"," 會等主執行緒有空才執行，",[145,3775,3776],{},"timeout"," 則確保不會一直等不到。不支援的瀏覽器就延遲一秒。",[11,3779,3780,3781,3783,3784,1161,3787,3790],{},"實際載入的方式，是建立 ",[145,3782,3536],{}," 並動態插入 script。Nuxt 會在伺服器端渲染，",[145,3785,3786],{},"window",[145,3788,3789],{},"document"," 只存在於瀏覽器，所以要先判斷環境：",[175,3792,3794],{"className":409,"code":3793,"language":411,"meta":180,"style":180},"const loadGTM = () => {\n  if (!import.meta.client || document.getElementById('gtm-script')) return\n\n  const { public: { gtmId } } = useRuntimeConfig()\n\n  window.dataLayer = window.dataLayer || []\n  window.dataLayer.push({ 'gtm.start': Date.now(), event: 'gtm.js' })\n\n  const script = document.createElement('script')\n  script.id = 'gtm-script'\n  script.async = true\n  script.src = `https:\u002F\u002Fwww.googletagmanager.com\u002Fgtm.js?id=${gtmId}`\n  document.head.appendChild(script)\n}\n",[145,3795,3796,3811,3847,3851,3875,3879,3893,3919,3923,3941,3951,3960,3974,3983],{"__ignoreMap":180},[184,3797,3798,3800,3803,3805,3807,3809],{"class":186,"line":187},[184,3799,967],{"class":215},[184,3801,3802],{"class":194}," loadGTM",[184,3804,510],{"class":215},[184,3806,495],{"class":190},[184,3808,216],{"class":215},[184,3810,219],{"class":190},[184,3812,3813,3815,3817,3820,3822,3825,3828,3831,3833,3836,3838,3841,3844],{"class":186,"line":222},[184,3814,1211],{"class":215},[184,3816,3125],{"class":190},[184,3818,3819],{"class":215},"!import",[184,3821,2631],{"class":190},[184,3823,3824],{"class":251},"meta",[184,3826,3827],{"class":190},".client ",[184,3829,3830],{"class":215},"||",[184,3832,2690],{"class":190},[184,3834,3835],{"class":194},"getElementById",[184,3837,198],{"class":190},[184,3839,3840],{"class":201},"'gtm-script'",[184,3842,3843],{"class":190},")) ",[184,3845,3846],{"class":215},"return\n",[184,3848,3849],{"class":186,"line":245},[184,3850,457],{"emptyLinePlaceholder":317},[184,3852,3853,3855,3857,3860,3863,3866,3869,3871,3873],{"class":186,"line":454},[184,3854,2060],{"class":215},[184,3856,1504],{"class":190},[184,3858,3859],{"class":208},"public",[184,3861,3862],{"class":190},": { ",[184,3864,3865],{"class":251},"gtmId",[184,3867,3868],{"class":190}," } } ",[184,3870,833],{"class":215},[184,3872,1691],{"class":194},[184,3874,1694],{"class":190},[184,3876,3877],{"class":186,"line":460},[184,3878,457],{"emptyLinePlaceholder":317},[184,3880,3881,3884,3886,3889,3891],{"class":186,"line":478},[184,3882,3883],{"class":190},"  window.dataLayer ",[184,3885,833],{"class":215},[184,3887,3888],{"class":190}," window.dataLayer ",[184,3890,3830],{"class":215},[184,3892,1872],{"class":190},[184,3894,3895,3898,3900,3902,3905,3908,3911,3914,3917],{"class":186,"line":319},[184,3896,3897],{"class":190},"  window.dataLayer.",[184,3899,3636],{"class":194},[184,3901,2506],{"class":190},[184,3903,3904],{"class":201},"'gtm.start'",[184,3906,3907],{"class":190},": Date.",[184,3909,3910],{"class":194},"now",[184,3912,3913],{"class":190},"(), event: ",[184,3915,3916],{"class":201},"'gtm.js'",[184,3918,1372],{"class":190},[184,3920,3921],{"class":186,"line":519},[184,3922,457],{"emptyLinePlaceholder":317},[184,3924,3925,3927,3929,3931,3933,3935,3937,3939],{"class":186,"line":524},[184,3926,2060],{"class":215},[184,3928,2685],{"class":251},[184,3930,510],{"class":215},[184,3932,2690],{"class":190},[184,3934,2693],{"class":194},[184,3936,198],{"class":190},[184,3938,2698],{"class":201},[184,3940,255],{"class":190},[184,3942,3943,3946,3948],{"class":186,"line":553},[184,3944,3945],{"class":190},"  script.id ",[184,3947,833],{"class":215},[184,3949,3950],{"class":201}," 'gtm-script'\n",[184,3952,3953,3956,3958],{"class":186,"line":558},[184,3954,3955],{"class":190},"  script.async ",[184,3957,833],{"class":215},[184,3959,2725],{"class":251},[184,3961,3962,3965,3967,3970,3972],{"class":186,"line":583},[184,3963,3964],{"class":190},"  script.src ",[184,3966,833],{"class":215},[184,3968,3969],{"class":201}," `https:\u002F\u002Fwww.googletagmanager.com\u002Fgtm.js?id=${",[184,3971,3865],{"class":190},[184,3973,2715],{"class":201},[184,3975,3976,3979,3981],{"class":186,"line":589},[184,3977,3978],{"class":190},"  document.head.",[184,3980,2784],{"class":194},[184,3982,2787],{"class":190},[184,3984,3985],{"class":186,"line":2790},[184,3986,708],{"class":190},[11,3988,3989,3990,3993],{},"用固定的 ",[145,3991,3992],{},"id"," 檢查，可以避免重複載入。",[20,3995,3997],{"id":3996},"cookie-同意把選擇存起來","Cookie 同意：把選擇存起來",[11,3999,4000,4001,4004,4005,4007],{},"追蹤訪客行為前，可能需要先取得同意，所以我做了一個 Cookie 橫幅和 ",[145,4002,4003],{},"useConsent"," composable，把訪客的選擇存在 ",[145,4006,2360],{},"，同意之後才載入 GTM：",[175,4009,4011],{"className":409,"code":4010,"language":411,"meta":180,"style":180},"const accept = () => {\n  localStorage.setItem('cookie-consent', JSON.stringify({ analytics: true }))\n  loadGTM()\n}\n\nconst reject = () => {\n  localStorage.setItem('cookie-consent', JSON.stringify({ analytics: false }))\n}\n",[145,4012,4013,4028,4059,4066,4070,4074,4088,4112],{"__ignoreMap":180},[184,4014,4015,4017,4020,4022,4024,4026],{"class":186,"line":187},[184,4016,967],{"class":215},[184,4018,4019],{"class":194}," accept",[184,4021,510],{"class":215},[184,4023,495],{"class":190},[184,4025,216],{"class":215},[184,4027,219],{"class":190},[184,4029,4030,4033,4036,4038,4041,4043,4046,4048,4051,4054,4056],{"class":186,"line":222},[184,4031,4032],{"class":190},"  localStorage.",[184,4034,4035],{"class":194},"setItem",[184,4037,198],{"class":190},[184,4039,4040],{"class":201},"'cookie-consent'",[184,4042,489],{"class":190},[184,4044,4045],{"class":251},"JSON",[184,4047,2631],{"class":190},[184,4049,4050],{"class":194},"stringify",[184,4052,4053],{"class":190},"({ analytics: ",[184,4055,252],{"class":251},[184,4057,4058],{"class":190}," }))\n",[184,4060,4061,4064],{"class":186,"line":245},[184,4062,4063],{"class":194},"  loadGTM",[184,4065,1694],{"class":190},[184,4067,4068],{"class":186,"line":454},[184,4069,708],{"class":190},[184,4071,4072],{"class":186,"line":460},[184,4073,457],{"emptyLinePlaceholder":317},[184,4075,4076,4078,4080,4082,4084,4086],{"class":186,"line":478},[184,4077,967],{"class":215},[184,4079,2760],{"class":194},[184,4081,510],{"class":215},[184,4083,495],{"class":190},[184,4085,216],{"class":215},[184,4087,219],{"class":190},[184,4089,4090,4092,4094,4096,4098,4100,4102,4104,4106,4108,4110],{"class":186,"line":319},[184,4091,4032],{"class":190},[184,4093,4035],{"class":194},[184,4095,198],{"class":190},[184,4097,4040],{"class":201},[184,4099,489],{"class":190},[184,4101,4045],{"class":251},[184,4103,2631],{"class":190},[184,4105,4050],{"class":194},[184,4107,4053],{"class":190},[184,4109,1363],{"class":251},[184,4111,4058],{"class":190},[184,4113,4114],{"class":186,"line":519},[184,4115,708],{"class":190},[11,4117,4118],{},"我也加了一個開關，決定要不要啟用橫幅，之後調整時不用改動流程。",[20,4120,4122],{"id":4121},"自訂事件把追蹤集中在一個地方","自訂事件：把追蹤集中在一個地方",[11,4124,4125,4126,3414],{},"頁面瀏覽量回答不了「訪客對什麼有興趣」，所以我另外定義了自訂事件，並把推送邏輯集中在 ",[145,4127,4128],{},"useAnalytics",[175,4130,4132],{"className":409,"code":4131,"language":411,"meta":180,"style":180},"export const useAnalytics = () => {\n  const push = (event: string, params: Record\u003Cstring, unknown> = {}) => {\n    if (!import.meta.client) return\n    window.dataLayer?.push({ event, ...params })\n  }\n\n  const trackProjectView = (project: { id: string; title: string }) =>\n    push('project_view', { project_id: project.id, project_name: project.title })\n\n  return { trackProjectView }\n}\n",[145,4133,4134,4151,4199,4217,4230,4234,4238,4277,4290,4294,4301],{"__ignoreMap":180},[184,4135,4136,4138,4140,4143,4145,4147,4149],{"class":186,"line":187},[184,4137,1009],{"class":215},[184,4139,1012],{"class":215},[184,4141,4142],{"class":194}," useAnalytics",[184,4144,510],{"class":215},[184,4146,495],{"class":190},[184,4148,216],{"class":215},[184,4150,219],{"class":190},[184,4152,4153,4155,4158,4160,4162,4164,4166,4168,4170,4173,4175,4178,4180,4183,4185,4188,4190,4192,4195,4197],{"class":186,"line":222},[184,4154,2060],{"class":215},[184,4156,4157],{"class":194}," push",[184,4159,510],{"class":215},[184,4161,3125],{"class":190},[184,4163,2283],{"class":208},[184,4165,1771],{"class":215},[184,4167,1784],{"class":251},[184,4169,489],{"class":190},[184,4171,4172],{"class":208},"params",[184,4174,1771],{"class":215},[184,4176,4177],{"class":194}," Record",[184,4179,823],{"class":190},[184,4181,4182],{"class":251},"string",[184,4184,489],{"class":190},[184,4186,4187],{"class":251},"unknown",[184,4189,2572],{"class":190},[184,4191,833],{"class":215},[184,4193,4194],{"class":190}," {}) ",[184,4196,216],{"class":215},[184,4198,219],{"class":190},[184,4200,4201,4204,4206,4208,4210,4212,4215],{"class":186,"line":245},[184,4202,4203],{"class":215},"    if",[184,4205,3125],{"class":190},[184,4207,3819],{"class":215},[184,4209,2631],{"class":190},[184,4211,3824],{"class":251},[184,4213,4214],{"class":190},".client) ",[184,4216,3846],{"class":215},[184,4218,4219,4222,4224,4226,4228],{"class":186,"line":454},[184,4220,4221],{"class":190},"    window.dataLayer?.",[184,4223,3636],{"class":194},[184,4225,3639],{"class":190},[184,4227,2391],{"class":215},[184,4229,3644],{"class":190},[184,4231,4232],{"class":186,"line":460},[184,4233,1235],{"class":190},[184,4235,4236],{"class":186,"line":478},[184,4237,457],{"emptyLinePlaceholder":317},[184,4239,4240,4242,4245,4247,4249,4252,4254,4256,4258,4260,4262,4265,4268,4270,4272,4275],{"class":186,"line":319},[184,4241,2060],{"class":215},[184,4243,4244],{"class":194}," trackProjectView",[184,4246,510],{"class":215},[184,4248,3125],{"class":190},[184,4250,4251],{"class":208},"project",[184,4253,1771],{"class":215},[184,4255,1504],{"class":190},[184,4257,3992],{"class":208},[184,4259,1771],{"class":215},[184,4261,1784],{"class":251},[184,4263,4264],{"class":190},"; ",[184,4266,4267],{"class":208},"title",[184,4269,1771],{"class":215},[184,4271,1784],{"class":251},[184,4273,4274],{"class":190}," }) ",[184,4276,2383],{"class":215},[184,4278,4279,4282,4284,4287],{"class":186,"line":519},[184,4280,4281],{"class":194},"    push",[184,4283,198],{"class":190},[184,4285,4286],{"class":201},"'project_view'",[184,4288,4289],{"class":190},", { project_id: project.id, project_name: project.title })\n",[184,4291,4292],{"class":186,"line":524},[184,4293,457],{"emptyLinePlaceholder":317},[184,4295,4296,4298],{"class":186,"line":553},[184,4297,1240],{"class":215},[184,4299,4300],{"class":190}," { trackProjectView }\n",[184,4302,4303],{"class":186,"line":558},[184,4304,708],{"class":190},[11,4306,4307,4308,4311,4312,4314],{},"元件只要呼叫 ",[145,4309,4310],{},"trackProjectView(project)","，不需要知道 ",[145,4313,3536],{}," 的格式。事件名稱也集中在一起，不會出現同一件事有兩種名字。",[11,4316,4317],{},"我追蹤的事件主要有這些：",[81,4319,4320,4330],{},[84,4321,4322],{},[87,4323,4324,4327],{},[90,4325,4326],{},"事件",[90,4328,4329],{},"想知道的事",[100,4331,4332,4342,4352,4362,4372,4382],{},[87,4333,4334,4339],{},[105,4335,4336],{},[145,4337,4338],{},"project_view",[105,4340,4341],{},"訪客看了哪個專案",[87,4343,4344,4349],{},[105,4345,4346],{},[145,4347,4348],{},"project_demo_click",[105,4350,4351],{},"誰點了線上展示",[87,4353,4354,4359],{},[105,4355,4356],{},[145,4357,4358],{},"project_github_click",[105,4360,4361],{},"誰點了 GitHub",[87,4363,4364,4369],{},[105,4365,4366],{},[145,4367,4368],{},"contact_click",[105,4370,4371],{},"從哪裡點了聯絡，例如導覽列或頁尾",[87,4373,4374,4379],{},[105,4375,4376],{},[145,4377,4378],{},"contact_submit",[105,4380,4381],{},"誰送出了聯絡表單",[87,4383,4384,4389],{},[105,4385,4386],{},[145,4387,4388],{},"social_click",[105,4390,4391],{},"點了哪個社群連結",[11,4393,4394,4395,4397],{},"事件推進 ",[145,4396,3536],{}," 之後，還要在 GTM 後台建立對應的觸發條件和 GA4 事件代碼，資料才會進到 GA4。只寫前端是不夠的。",[20,4399,1580],{"id":1580},[28,4401,4402,4405,4408],{},[31,4403,4404],{},"先想清楚要回答什麼問題，再決定追蹤什麼，不是事件越多越好。",[31,4406,4407],{},"第三方腳本也算效能的一部分，能延後就延後。",[31,4409,4410],{},"把追蹤邏輯包成 composable，元件會乾淨很多，之後改名或調整也只要改一個地方。",[300,4412,4413],{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}",{"title":180,"searchDepth":222,"depth":222,"links":4415},[4416,4417,4418,4419,4420,4421],{"id":3496,"depth":222,"text":3497},{"id":3540,"depth":222,"text":3541},{"id":3666,"depth":222,"text":3667},{"id":3996,"depth":222,"text":3997},{"id":4121,"depth":222,"text":4122},{"id":1580,"depth":222,"text":1580},"2026-07-30","記錄在個人品牌網站導入 Google Tag Manager 與 GA4 的做法，包含延後載入、Cookie 同意機制，以及用 dataLayer 追蹤專案與聯絡行為。",{},"\u002Fblog\u002Fnuxt-gtm-ga4",{"title":3488,"description":4423},"blog\u002Fnuxt-gtm-ga4",[3524,3516,3241],"HM3DIhhjuw5bwH0UvPRJUojLDKuYMSBohKxzUz0tqLc",{"id":4431,"title":4432,"body":4433,"category":918,"date":5215,"description":5216,"draft":314,"extension":315,"meta":5217,"navigation":317,"path":5218,"readingTime":524,"seo":5219,"stem":5220,"tags":5221,"__hash__":5223},"blog\u002Fblog\u002Faccessibility-audit-fixes.md","從退件到通過：網站無障礙檢測的修正紀錄",{"type":8,"value":4434,"toc":5198},[4435,4438,4447,4450,4453,4464,4485,4488,4491,4582,4585,4588,4603,4655,4661,4672,4675,4678,4694,4758,4767,4770,4773,4831,4834,4837,4840,4899,4902,4909,4912,4915,4919,4922,4989,5029,5036,5071,5075,5082,5120,5126,5167,5170,5173,5176,5178,5195],[11,4436,4437],{},"這是我做過最長的一次修正：一個 Vue 3 + Vuetify 的網站要送無障礙檢測。專案前期已經用 FreeGo 自動檢測並修正過幾輪，我接手的是人工檢測結果和後續的退件修正，中間經歷多次退件，才取得 A 等級標章。",[11,4439,4440,4441,1161,4443,4446],{},"一開始我以為無障礙就是補上 ",[145,4442,714],{},[145,4444,4445],{},"aria-label","。實際做完才發現，它考驗的是整個網站的結構，而 FreeGo 這類自動檢測工具通過，不代表人工檢測會過。這篇依問題類型整理我修了什麼。",[20,4448,4449],{"id":4449},"語意和基本資訊",[594,4451,4452],{"id":4452},"頁面語言",[11,4454,4455,4456,4459,4460,4463],{},"螢幕報讀軟體會依 ",[145,4457,4458],{},"lang"," 決定用什麼語言朗讀，專案預設的 ",[145,4461,4462],{},"en"," 會讓中文內容被用錯誤的方式處理：",[175,4465,4467],{"className":814,"code":4466,"language":816,"meta":180,"style":180},"\u003Chtml lang=\"zh-Hant\">\n",[145,4468,4469],{"__ignoreMap":180},[184,4470,4471,4473,4475,4478,4480,4483],{"class":186,"line":187},[184,4472,823],{"class":190},[184,4474,816],{"class":826},[184,4476,4477],{"class":194}," lang",[184,4479,833],{"class":190},[184,4481,4482],{"class":201},"\"zh-Hant\"",[184,4484,871],{"class":190},[594,4486,4487],{"id":4487},"網頁標題要跟著頁面變",[11,4489,4490],{},"單頁應用切換路由時，標題不會自己改變。使用者聽不出換了頁面，所以我在路由設定標題，再統一更新：",[175,4492,4494],{"className":177,"code":4493,"language":179,"meta":180,"style":180},"{\n  path: '\u002Fmember-login',\n  meta: { title: '會員中心 > 會員登入' },\n}\n\nrouter.afterEach((to) => {\n  document.title = `${to.meta.title} | 網站名稱`\n})\n",[145,4495,4496,4500,4512,4529,4533,4537,4556,4578],{"__ignoreMap":180},[184,4497,4498],{"class":186,"line":187},[184,4499,2833],{"class":190},[184,4501,4502,4505,4507,4510],{"class":186,"line":222},[184,4503,4504],{"class":194},"  path",[184,4506,675],{"class":190},[184,4508,4509],{"class":201},"'\u002Fmember-login'",[184,4511,1077],{"class":190},[184,4513,4514,4517,4519,4521,4523,4526],{"class":186,"line":245},[184,4515,4516],{"class":194},"  meta",[184,4518,3862],{"class":190},[184,4520,4267],{"class":194},[184,4522,675],{"class":190},[184,4524,4525],{"class":201},"'會員中心 > 會員登入'",[184,4527,4528],{"class":190}," },\n",[184,4530,4531],{"class":186,"line":454},[184,4532,708],{"class":190},[184,4534,4535],{"class":186,"line":460},[184,4536,457],{"emptyLinePlaceholder":317},[184,4538,4539,4542,4545,4547,4550,4552,4554],{"class":186,"line":478},[184,4540,4541],{"class":190},"router.",[184,4543,4544],{"class":194},"afterEach",[184,4546,1197],{"class":190},[184,4548,4549],{"class":208},"to",[184,4551,212],{"class":190},[184,4553,216],{"class":215},[184,4555,219],{"class":190},[184,4557,4558,4561,4563,4565,4567,4569,4571,4573,4575],{"class":186,"line":319},[184,4559,4560],{"class":190},"  document.title ",[184,4562,833],{"class":215},[184,4564,2288],{"class":201},[184,4566,4549],{"class":190},[184,4568,2631],{"class":201},[184,4570,3824],{"class":190},[184,4572,2631],{"class":201},[184,4574,4267],{"class":190},[184,4576,4577],{"class":201},"} | 網站名稱`\n",[184,4579,4580],{"class":186,"line":519},[184,4581,592],{"class":190},[594,4583,4584],{"id":4584},"標題階層",[11,4586,4587],{},"這是退件次數最多的項目。檢測看的是文件大綱，不是字體大小。",[11,4589,4590,4591,4594,4595,4598,4599,4602],{},"Vuetify 的 ",[145,4592,4593],{},"text-h3"," 只是樣式，不會變成真的標題。很多畫面看起來是標題，其實是 ",[145,4596,4597],{},"div","；也有為了字大而直接用 ",[145,4600,4601],{},"\u003Ch1>"," 的地方。修正時要把視覺和語意分開：",[175,4604,4606],{"className":814,"code":4605,"language":816,"meta":180,"style":180},"\u003C!-- 語意由標籤決定，大小由 class 決定 -->\n\u003Ch1 class=\"text-h5\">會員中心\u003C\u002Fh1>\n\u003Ch2 class=\"text-h3\">會員登入\u003C\u002Fh2>\n",[145,4607,4608,4613,4635],{"__ignoreMap":180},[184,4609,4610],{"class":186,"line":187},[184,4611,4612],{"class":1031},"\u003C!-- 語意由標籤決定，大小由 class 決定 -->\n",[184,4614,4615,4617,4620,4623,4625,4628,4631,4633],{"class":186,"line":222},[184,4616,823],{"class":190},[184,4618,4619],{"class":826},"h1",[184,4621,4622],{"class":194}," class",[184,4624,833],{"class":190},[184,4626,4627],{"class":201},"\"text-h5\"",[184,4629,4630],{"class":190},">會員中心\u003C\u002F",[184,4632,4619],{"class":826},[184,4634,871],{"class":190},[184,4636,4637,4639,4641,4643,4645,4648,4651,4653],{"class":186,"line":245},[184,4638,823],{"class":190},[184,4640,20],{"class":826},[184,4642,4622],{"class":194},[184,4644,833],{"class":190},[184,4646,4647],{"class":201},"\"text-h3\"",[184,4649,4650],{"class":190},">會員登入\u003C\u002F",[184,4652,20],{"class":826},[184,4654,871],{"class":190},[11,4656,4657,4658,4660],{},"原則是每頁一個 ",[145,4659,4619],{},"，階層不能跳級。",[11,4662,4663,4664,4667,4668,4671],{},"另外我也移除了 ",[145,4665,4666],{},"\u003Ch1 role=\"alert\">"," 這種寫法。",[145,4669,4670],{},"alert"," 會讓報讀軟體立刻打斷使用者，不適合用在一般標題上。",[20,4673,4674],{"id":4674},"替代文字",[11,4676,4677],{},"替代文字不是每張圖都要寫：",[28,4679,4680,4686],{},[31,4681,4682,4685],{},[15,4683,4684],{},"有資訊的圖片","：描述內容。",[31,4687,4688,3414,4691,4693],{},[15,4689,4690],{},"純裝飾的圖片",[145,4692,714],{}," 設成空字串，報讀軟體就會跳過。",[175,4695,4697],{"className":814,"code":4696,"language":816,"meta":180,"style":180},"\u003C!-- 之前：報讀軟體會念出沒有意義的內容 -->\n\u003Cv-img alt=\"登入頁插圖\" src=\"login.svg\" \u002F>\n\n\u003C!-- 之後：裝飾圖片不打擾使用者 -->\n\u003Cv-img alt=\"\" src=\"login.svg\" \u002F>\n",[145,4698,4699,4704,4728,4732,4737],{"__ignoreMap":180},[184,4700,4701],{"class":186,"line":187},[184,4702,4703],{"class":1031},"\u003C!-- 之前：報讀軟體會念出沒有意義的內容 -->\n",[184,4705,4706,4708,4711,4713,4715,4718,4720,4722,4725],{"class":186,"line":222},[184,4707,823],{"class":190},[184,4709,4710],{"class":826},"v-img",[184,4712,855],{"class":194},[184,4714,833],{"class":190},[184,4716,4717],{"class":201},"\"登入頁插圖\"",[184,4719,830],{"class":194},[184,4721,833],{"class":190},[184,4723,4724],{"class":201},"\"login.svg\"",[184,4726,4727],{"class":190}," \u002F>\n",[184,4729,4730],{"class":186,"line":245},[184,4731,457],{"emptyLinePlaceholder":317},[184,4733,4734],{"class":186,"line":454},[184,4735,4736],{"class":1031},"\u003C!-- 之後：裝飾圖片不打擾使用者 -->\n",[184,4738,4739,4741,4743,4745,4747,4750,4752,4754,4756],{"class":186,"line":460},[184,4740,823],{"class":190},[184,4742,4710],{"class":826},[184,4744,855],{"class":194},[184,4746,833],{"class":190},[184,4748,4749],{"class":201},"\"\"",[184,4751,830],{"class":194},[184,4753,833],{"class":190},[184,4755,4724],{"class":201},[184,4757,4727],{"class":190},[11,4759,4760,4761,1161,4763,4766],{},"這點和直覺相反。不寫 ",[145,4762,714],{},[145,4764,4765],{},"alt=\"\""," 不一樣，前者可能會念出檔名。",[20,4768,4769],{"id":4769},"連結和按鈕要說清楚目的",[11,4771,4772],{},"「下載」、「\u003C」、「>」這類文字，單獨聽完全不知道作用。按鈕需要能單獨理解的名稱：",[175,4774,4776],{"className":814,"code":4775,"language":816,"meta":180,"style":180},"\u003Cv-btn icon=\"mdi-chevron-left\" aria-label=\"上一項說明\" \u002F>\n\u003Ca href=\"manual.pdf\" aria-label=\"下載手機版操作手冊 PDF\">下載\u003C\u002Fa>\n",[145,4777,4778,4803],{"__ignoreMap":180},[184,4779,4780,4782,4785,4788,4790,4793,4796,4798,4801],{"class":186,"line":187},[184,4781,823],{"class":190},[184,4783,4784],{"class":826},"v-btn",[184,4786,4787],{"class":194}," icon",[184,4789,833],{"class":190},[184,4791,4792],{"class":201},"\"mdi-chevron-left\"",[184,4794,4795],{"class":194}," aria-label",[184,4797,833],{"class":190},[184,4799,4800],{"class":201},"\"上一項說明\"",[184,4802,4727],{"class":190},[184,4804,4805,4807,4809,4812,4814,4817,4819,4821,4824,4827,4829],{"class":186,"line":222},[184,4806,823],{"class":190},[184,4808,2117],{"class":826},[184,4810,4811],{"class":194}," href",[184,4813,833],{"class":190},[184,4815,4816],{"class":201},"\"manual.pdf\"",[184,4818,4795],{"class":194},[184,4820,833],{"class":190},[184,4822,4823],{"class":201},"\"下載手機版操作手冊 PDF\"",[184,4825,4826],{"class":190},">下載\u003C\u002F",[184,4828,2117],{"class":826},[184,4830,871],{"class":190},[20,4832,4833],{"id":4833},"表單",[594,4835,4836],{"id":4836},"必填要說出來",[11,4838,4839],{},"只用紅色星號，報讀軟體不一定念得出來，所以我把「必填」寫進標籤文字：",[175,4841,4843],{"className":814,"code":4842,"language":816,"meta":180,"style":180},"\u003Cv-text-field>\n  \u003Ctemplate #label>\n    \u003Cspan>驗證碼(必填)\u003C\u002Fspan>\n  \u003C\u002Ftemplate>\n\u003C\u002Fv-text-field>\n",[145,4844,4845,4854,4867,4881,4890],{"__ignoreMap":180},[184,4846,4847,4849,4852],{"class":186,"line":187},[184,4848,823],{"class":190},[184,4850,4851],{"class":826},"v-text-field",[184,4853,871],{"class":190},[184,4855,4856,4859,4862,4865],{"class":186,"line":222},[184,4857,4858],{"class":190},"  \u003C",[184,4860,4861],{"class":826},"template",[184,4863,4864],{"class":194}," #label",[184,4866,871],{"class":190},[184,4868,4869,4872,4874,4877,4879],{"class":186,"line":245},[184,4870,4871],{"class":190},"    \u003C",[184,4873,184],{"class":826},[184,4875,4876],{"class":190},">驗證碼(必填)\u003C\u002F",[184,4878,184],{"class":826},[184,4880,871],{"class":190},[184,4882,4883,4886,4888],{"class":186,"line":454},[184,4884,4885],{"class":190},"  \u003C\u002F",[184,4887,4861],{"class":826},[184,4889,871],{"class":190},[184,4891,4892,4895,4897],{"class":186,"line":460},[184,4893,4894],{"class":190},"\u003C\u002F",[184,4896,4851],{"class":826},[184,4898,871],{"class":190},[594,4900,4901],{"id":4901},"標籤要和欄位關聯",[11,4903,4904,4905,4908],{},"每個欄位都要有對應的 ",[145,4906,4907],{},"label","。像驗證碼這類為了版面省略標籤的欄位，就很容易被抓出來。",[594,4910,4911],{"id":4911},"日期欄位要能用鍵盤操作",[11,4913,4914],{},"Vuetify 的日期選擇器需要補上鍵盤操作和焦點管理，這部分我另外寫在《讓 Vuetify 日期選擇器支援鍵盤與螢幕報讀器》。",[20,4916,4918],{"id":4917},"鍵盤使用者跳過區塊","鍵盤使用者：跳過區塊",[11,4920,4921],{},"只用鍵盤的人，每一頁都要 Tab 過整排選單才能到內容。所以每頁最上方要有一個連結，平常隱藏，Tab 聚焦時才出現：",[175,4923,4925],{"className":657,"code":4924,"language":659,"meta":180,"style":180},".skip-link {\n  position: absolute;\n  top: -100px;\n}\n\n.skip-link:focus {\n  top: 0;\n}\n",[145,4926,4927,4934,4946,4960,4964,4968,4975,4985],{"__ignoreMap":180},[184,4928,4929,4932],{"class":186,"line":187},[184,4930,4931],{"class":194},".skip-link",[184,4933,219],{"class":190},[184,4935,4936,4939,4941,4944],{"class":186,"line":222},[184,4937,4938],{"class":251},"  position",[184,4940,675],{"class":190},[184,4942,4943],{"class":251},"absolute",[184,4945,690],{"class":190},[184,4947,4948,4951,4953,4956,4958],{"class":186,"line":245},[184,4949,4950],{"class":251},"  top",[184,4952,675],{"class":190},[184,4954,4955],{"class":251},"-100",[184,4957,681],{"class":215},[184,4959,690],{"class":190},[184,4961,4962],{"class":186,"line":454},[184,4963,708],{"class":190},[184,4965,4966],{"class":186,"line":460},[184,4967,457],{"emptyLinePlaceholder":317},[184,4969,4970,4973],{"class":186,"line":478},[184,4971,4972],{"class":194},".skip-link:focus",[184,4974,219],{"class":190},[184,4976,4977,4979,4981,4983],{"class":186,"line":319},[184,4978,4950],{"class":251},[184,4980,675],{"class":190},[184,4982,1797],{"class":251},[184,4984,690],{"class":190},[184,4986,4987],{"class":186,"line":519},[184,4988,708],{"class":190},[175,4990,4992],{"className":177,"code":4991,"language":179,"meta":180,"style":180},"function skipToMain() {\n  document.getElementById('main-content')?.focus()\n}\n",[145,4993,4994,5005,5025],{"__ignoreMap":180},[184,4995,4996,4999,5002],{"class":186,"line":187},[184,4997,4998],{"class":215},"function",[184,5000,5001],{"class":194}," skipToMain",[184,5003,5004],{"class":190},"() {\n",[184,5006,5007,5010,5012,5014,5017,5020,5023],{"class":186,"line":222},[184,5008,5009],{"class":190},"  document.",[184,5011,3835],{"class":194},[184,5013,198],{"class":190},[184,5015,5016],{"class":201},"'main-content'",[184,5018,5019],{"class":190},")?.",[184,5021,5022],{"class":194},"focus",[184,5024,1694],{"class":190},[184,5026,5027],{"class":186,"line":245},[184,5028,708],{"class":190},[11,5030,5031,5032,5035],{},"主要內容要加上 ",[145,5033,5034],{},"tabindex=\"-1\"","，才能接收程式設定的焦點：",[175,5037,5039],{"className":814,"code":5038,"language":816,"meta":180,"style":180},"\u003Cmain id=\"main-content\" tabindex=\"-1\">...\u003C\u002Fmain>\n",[145,5040,5041],{"__ignoreMap":180},[184,5042,5043,5045,5048,5051,5053,5056,5059,5061,5064,5067,5069],{"class":186,"line":187},[184,5044,823],{"class":190},[184,5046,5047],{"class":826},"main",[184,5049,5050],{"class":194}," id",[184,5052,833],{"class":190},[184,5054,5055],{"class":201},"\"main-content\"",[184,5057,5058],{"class":194}," tabindex",[184,5060,833],{"class":190},[184,5062,5063],{"class":201},"\"-1\"",[184,5065,5066],{"class":190},">...\u003C\u002F",[184,5068,5047],{"class":826},[184,5070,871],{"class":190},[20,5072,5074],{"id":5073},"導盲磚快速移動到區塊","導盲磚：快速移動到區塊",[11,5076,5077,5078,5081],{},"導盲磚是頁面上的 ",[145,5079,5080],{},":::"," 標記，讓使用者用快捷鍵跳到主要區塊。常見的慣例是：",[81,5083,5084,5094],{},[84,5085,5086],{},[87,5087,5088,5091],{},[90,5089,5090],{},"快捷鍵",[90,5092,5093],{},"區塊",[100,5095,5096,5104,5112],{},[87,5097,5098,5101],{},[105,5099,5100],{},"Alt + U",[105,5102,5103],{},"上方區塊",[87,5105,5106,5109],{},[105,5107,5108],{},"Alt + C",[105,5110,5111],{},"中間主要內容",[87,5113,5114,5117],{},[105,5115,5116],{},"Alt + Z",[105,5118,5119],{},"下方區塊",[11,5121,5122,5123,3414],{},"實作上是用 ",[145,5124,5125],{},"accesskey",[175,5127,5129],{"className":814,"code":5128,"language":816,"meta":180,"style":180},"\u003Ca accesskey=\"C\" href=\"#C\" title=\"中間主要內容\">:::\u003C\u002Fa>\n",[145,5130,5131],{"__ignoreMap":180},[184,5132,5133,5135,5137,5140,5142,5145,5147,5149,5152,5155,5157,5160,5163,5165],{"class":186,"line":187},[184,5134,823],{"class":190},[184,5136,2117],{"class":826},[184,5138,5139],{"class":194}," accesskey",[184,5141,833],{"class":190},[184,5143,5144],{"class":201},"\"C\"",[184,5146,4811],{"class":194},[184,5148,833],{"class":190},[184,5150,5151],{"class":201},"\"#C\"",[184,5153,5154],{"class":194}," title",[184,5156,833],{"class":190},[184,5158,5159],{"class":201},"\"中間主要內容\"",[184,5161,5162],{"class":190},">:::\u003C\u002F",[184,5164,2117],{"class":826},[184,5166,871],{"class":190},[11,5168,5169],{},"語法不難，專案也已經有部分頁面做了導盲磚，真正花時間的是把它補齊。檢測要求每個頁面都要有導盲磚，但原本的頁面不是依照這個需求設計的，版面結構也各不相同，沒辦法加一個共用元件就結束。",[11,5171,5172],{},"我只能一頁一頁調整：有的要改標題區塊的排版，有的要用定位放進既有的版面，還要留意手機版會不會擠壓到內容。同時還要讓導盲磚在每一頁的位置和樣式保持一致，使用者才不會每換一頁就要重新找。",[11,5174,5175],{},"一次就改了二十個檔案。如果一開始就把上、中、下區塊做進共用的頁面版型，這件事只要改一個地方。操作說明也要寫清楚快捷鍵，使用者才知道怎麼用。",[20,5177,1580],{"id":1580},[28,5179,5180,5183,5186,5189,5192],{},[31,5181,5182],{},"無障礙是結構問題，不是最後補幾個屬性就好。越晚處理，要改的頁面越多。",[31,5184,5185],{},"FreeGo 這類自動工具只能抓到一部分問題，實際用鍵盤和報讀軟體走一次才準。",[31,5187,5188],{},"視覺和語意要分開思考，看起來是標題，不代表它就是標題。",[31,5190,5191],{},"共用元件先做對，後面就能少修很多頁。",[31,5193,5194],{},"退件不是壞事。每一次的退件原因，都讓我更清楚問題在哪裡。",[300,5196,5197],{},"html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}",{"title":180,"searchDepth":222,"depth":222,"links":5199},[5200,5205,5206,5207,5212,5213,5214],{"id":4449,"depth":222,"text":4449,"children":5201},[5202,5203,5204],{"id":4452,"depth":245,"text":4452},{"id":4487,"depth":245,"text":4487},{"id":4584,"depth":245,"text":4584},{"id":4674,"depth":222,"text":4674},{"id":4769,"depth":222,"text":4769},{"id":4833,"depth":222,"text":4833,"children":5208},[5209,5210,5211],{"id":4836,"depth":245,"text":4836},{"id":4901,"depth":245,"text":4901},{"id":4911,"depth":245,"text":4911},{"id":4917,"depth":222,"text":4918},{"id":5073,"depth":222,"text":5074},{"id":1580,"depth":222,"text":1580},"2026-05-13","網站送無障礙檢測時經歷多次退件，最後通過 A 等級。整理替代文字、標題階層、鍵盤操作、表單標籤、跳過區塊與導盲磚的實際修正方式。",{},"\u002Fblog\u002Faccessibility-audit-fixes",{"title":4432,"description":5216},"blog\u002Faccessibility-audit-fixes",[926,3484,5222],"Vuetify","K5LisXZf0ouXT-XPvv-wJkH9vv9DhC39D_F80anpRYk",{"id":5225,"title":5226,"body":5227,"category":918,"date":5796,"description":5797,"draft":314,"extension":315,"meta":5798,"navigation":317,"path":5799,"readingTime":478,"seo":5800,"stem":5801,"tags":5802,"__hash__":5804},"blog\u002Fblog\u002Fvuetify-datepicker-a11y.md","讓 Vuetify 日期選擇器支援鍵盤與螢幕報讀器",{"type":8,"value":5228,"toc":5788},[5229,5232,5235,5238,5241,5287,5290,5294,5301,5417,5420,5424,5427,5430,5441,5444,5448,5451,5456,5756,5763,5767,5772,5774,5785],[11,5230,5231],{},"依無障礙標準改寫 Vue 3 專案時，我遇到一個元件庫解決不了的問題：Vuetify 的日期選擇器，無法完全用鍵盤完成操作，螢幕報讀器的導讀也不完整。",[11,5233,5234],{},"日期欄位出現在多個表單裡，所以我把處理方式整理成一個共用的 mixin，讓每個頁面不用各寫一次。",[20,5236,5237],{"id":5237},"先列出使用者需要完成的操作",[11,5239,5240],{},"與其一個一個修問題，我先列出只用鍵盤的人要怎麼選日期：",[81,5242,5243,5253],{},[84,5244,5245],{},[87,5246,5247,5250],{},[90,5248,5249],{},"按鍵",[90,5251,5252],{},"預期行為",[100,5254,5255,5263,5271,5279],{},[87,5256,5257,5260],{},[105,5258,5259],{},"方向鍵",[105,5261,5262],{},"左右移動一天，上下移動一週",[87,5264,5265,5268],{},[105,5266,5267],{},"Enter",[105,5269,5270],{},"選定日期，並回到輸入框",[87,5272,5273,5276],{},[105,5274,5275],{},"Escape",[105,5277,5278],{},"關閉選擇器，並回到輸入框",[87,5280,5281,5284],{},[105,5282,5283],{},"切換月份或年份時",[105,5285,5286],{},"焦點移到清單中對應的項目，選完回到日期",[11,5288,5289],{},"這張表同時也是驗收標準。",[20,5291,5293],{"id":5292},"方向鍵移動的是日期不是-dom","方向鍵：移動的是日期，不是 DOM",[11,5295,5296,5297,5300],{},"日期的相鄰關係不該靠找隔壁的按鈕來判斷，因為跨月時隔壁的按鈕可能不存在。把按鍵換算成天數，再交給 ",[145,5298,5299],{},"Date"," 處理跨月和跨年，邏輯簡單很多：",[175,5302,5304],{"className":177,"code":5303,"language":179,"meta":180,"style":180},"const OFFSETS = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 }\n\nfunction moveDate(current, key) {\n  const next = new Date(current)\n  next.setDate(current.getDate() + OFFSETS[key])\n  return next\n}\n",[145,5305,5306,5343,5347,5365,5382,5406,5413],{"__ignoreMap":180},[184,5307,5308,5310,5313,5315,5318,5320,5323,5326,5328,5331,5333,5336,5339,5341],{"class":186,"line":187},[184,5309,967],{"class":215},[184,5311,5312],{"class":251}," OFFSETS",[184,5314,510],{"class":215},[184,5316,5317],{"class":190}," { ArrowLeft: ",[184,5319,2160],{"class":215},[184,5321,5322],{"class":251},"1",[184,5324,5325],{"class":190},", ArrowRight: ",[184,5327,5322],{"class":251},[184,5329,5330],{"class":190},", ArrowUp: ",[184,5332,2160],{"class":215},[184,5334,5335],{"class":251},"7",[184,5337,5338],{"class":190},", ArrowDown: ",[184,5340,5335],{"class":251},[184,5342,2327],{"class":190},[184,5344,5345],{"class":186,"line":222},[184,5346,457],{"emptyLinePlaceholder":317},[184,5348,5349,5351,5354,5356,5359,5361,5363],{"class":186,"line":245},[184,5350,4998],{"class":215},[184,5352,5353],{"class":194}," moveDate",[184,5355,198],{"class":190},[184,5357,5358],{"class":208},"current",[184,5360,489],{"class":190},[184,5362,1779],{"class":208},[184,5364,1787],{"class":190},[184,5366,5367,5369,5372,5374,5376,5379],{"class":186,"line":454},[184,5368,2060],{"class":215},[184,5370,5371],{"class":251}," next",[184,5373,510],{"class":215},[184,5375,1712],{"class":215},[184,5377,5378],{"class":194}," Date",[184,5380,5381],{"class":190},"(current)\n",[184,5383,5384,5387,5390,5393,5395,5398,5401,5403],{"class":186,"line":460},[184,5385,5386],{"class":190},"  next.",[184,5388,5389],{"class":194},"setDate",[184,5391,5392],{"class":190},"(current.",[184,5394,1947],{"class":194},[184,5396,5397],{"class":190},"() ",[184,5399,5400],{"class":215},"+",[184,5402,5312],{"class":251},[184,5404,5405],{"class":190},"[key])\n",[184,5407,5408,5410],{"class":186,"line":478},[184,5409,1240],{"class":215},[184,5411,5412],{"class":190}," next\n",[184,5414,5415],{"class":186,"line":319},[184,5416,708],{"class":190},[11,5418,5419],{},"超出可選範圍的日期不移動，其餘由呼叫方更新畫面並移動焦點。",[20,5421,5423],{"id":5422},"焦點管理畫面換了焦點要跟著走","焦點管理：畫面換了，焦點要跟著走",[11,5425,5426],{},"這是最花時間的部分。選擇月份後，月份清單會被日期格取代，原本有焦點的按鈕消失，焦點就會掉回頁面開頭，鍵盤使用者等於迷路了。",[11,5428,5429],{},"所以每次畫面切換，都要明確決定焦點的去向：",[28,5431,5432,5435,5438],{},[31,5433,5434],{},"開啟月份或年份清單：聚焦到目前日期所在的項目。",[31,5436,5437],{},"選完月份或年份：回到日期格，依序找相同的日期、已選定的日期、今天，最後才是第一個可用的日期。",[31,5439,5440],{},"按 Enter 或 Escape 關閉：回到原本的輸入框。",[11,5442,5443],{},"月份和年份清單的方向鍵也要另外處理，因為它們是多欄排列，上下鍵要跳過一整欄的數量，不是移動一格。",[20,5445,5447],{"id":5446},"報讀標籤用-mutationobserver-補上","報讀標籤：用 MutationObserver 補上",[11,5449,5450],{},"日期按鈕需要完整的名稱，報讀器才能念出「2026年11月2日 星期一」這樣的內容。但這些按鈕由 Vuetify 產生，換月份時會重新渲染，只在開啟時設定一次是不夠的。",[11,5452,5453,5454,3414],{},"我的做法是監看選擇器的變化，只替還沒有標籤的按鈕補上 ",[145,5455,4445],{},[175,5457,5459],{"className":177,"code":5458,"language":179,"meta":180,"style":180},"const WEEK = ['日', '一', '二', '三', '四', '五', '六']\n\nfunction labelDays(picker) {\n  picker.querySelectorAll('.v-date-picker-month__day button').forEach((btn) => {\n    const iso = btn.closest('.v-date-picker-month__day')?.dataset.vDate\n    if (!iso || btn.hasAttribute('aria-label')) return\n    const d = new Date(iso)\n    btn.setAttribute('aria-label', `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日 星期${WEEK[d.getDay()]}`)\n  })\n}\n\nconst observer = new MutationObserver(() => labelDays(picker))\nobserver.observe(picker, { childList: true, subtree: true })\n",[145,5460,5461,5508,5512,5526,5555,5578,5606,5622,5701,5705,5709,5713,5736],{"__ignoreMap":180},[184,5462,5463,5465,5468,5470,5473,5476,5478,5481,5483,5486,5488,5491,5493,5496,5498,5501,5503,5506],{"class":186,"line":187},[184,5464,967],{"class":215},[184,5466,5467],{"class":251}," WEEK",[184,5469,510],{"class":215},[184,5471,5472],{"class":190}," [",[184,5474,5475],{"class":201},"'日'",[184,5477,489],{"class":190},[184,5479,5480],{"class":201},"'一'",[184,5482,489],{"class":190},[184,5484,5485],{"class":201},"'二'",[184,5487,489],{"class":190},[184,5489,5490],{"class":201},"'三'",[184,5492,489],{"class":190},[184,5494,5495],{"class":201},"'四'",[184,5497,489],{"class":190},[184,5499,5500],{"class":201},"'五'",[184,5502,489],{"class":190},[184,5504,5505],{"class":201},"'六'",[184,5507,2253],{"class":190},[184,5509,5510],{"class":186,"line":222},[184,5511,457],{"emptyLinePlaceholder":317},[184,5513,5514,5516,5519,5521,5524],{"class":186,"line":245},[184,5515,4998],{"class":215},[184,5517,5518],{"class":194}," labelDays",[184,5520,198],{"class":190},[184,5522,5523],{"class":208},"picker",[184,5525,1787],{"class":190},[184,5527,5528,5531,5534,5536,5539,5541,5544,5546,5549,5551,5553],{"class":186,"line":454},[184,5529,5530],{"class":190},"  picker.",[184,5532,5533],{"class":194},"querySelectorAll",[184,5535,198],{"class":190},[184,5537,5538],{"class":201},"'.v-date-picker-month__day button'",[184,5540,541],{"class":190},[184,5542,5543],{"class":194},"forEach",[184,5545,1197],{"class":190},[184,5547,5548],{"class":208},"btn",[184,5550,212],{"class":190},[184,5552,216],{"class":215},[184,5554,219],{"class":190},[184,5556,5557,5559,5562,5564,5567,5570,5572,5575],{"class":186,"line":460},[184,5558,504],{"class":215},[184,5560,5561],{"class":251}," iso",[184,5563,510],{"class":215},[184,5565,5566],{"class":190}," btn.",[184,5568,5569],{"class":194},"closest",[184,5571,198],{"class":190},[184,5573,5574],{"class":201},"'.v-date-picker-month__day'",[184,5576,5577],{"class":190},")?.dataset.vDate\n",[184,5579,5580,5582,5584,5587,5590,5592,5594,5597,5599,5602,5604],{"class":186,"line":478},[184,5581,4203],{"class":215},[184,5583,3125],{"class":190},[184,5585,5586],{"class":215},"!",[184,5588,5589],{"class":190},"iso ",[184,5591,3830],{"class":215},[184,5593,5566],{"class":190},[184,5595,5596],{"class":194},"hasAttribute",[184,5598,198],{"class":190},[184,5600,5601],{"class":201},"'aria-label'",[184,5603,3843],{"class":190},[184,5605,3846],{"class":215},[184,5607,5608,5610,5613,5615,5617,5619],{"class":186,"line":319},[184,5609,504],{"class":215},[184,5611,5612],{"class":251}," d",[184,5614,510],{"class":215},[184,5616,1712],{"class":215},[184,5618,5378],{"class":194},[184,5620,5621],{"class":190},"(iso)\n",[184,5623,5624,5627,5630,5632,5634,5636,5639,5642,5644,5647,5650,5653,5655,5657,5660,5662,5664,5667,5670,5672,5674,5676,5678,5681,5684,5687,5689,5691,5694,5697,5699],{"class":186,"line":519},[184,5625,5626],{"class":190},"    btn.",[184,5628,5629],{"class":194},"setAttribute",[184,5631,198],{"class":190},[184,5633,5601],{"class":201},[184,5635,489],{"class":190},[184,5637,5638],{"class":201},"`${",[184,5640,5641],{"class":190},"d",[184,5643,2631],{"class":201},[184,5645,5646],{"class":194},"getFullYear",[184,5648,5649],{"class":201},"()",[184,5651,5652],{"class":201},"}年${",[184,5654,5641],{"class":190},[184,5656,2631],{"class":201},[184,5658,5659],{"class":194},"getMonth",[184,5661,5397],{"class":201},[184,5663,5400],{"class":215},[184,5665,5666],{"class":251}," 1",[184,5668,5669],{"class":201},"}月${",[184,5671,5641],{"class":190},[184,5673,2631],{"class":201},[184,5675,1947],{"class":194},[184,5677,5649],{"class":201},[184,5679,5680],{"class":201},"}日 星期${",[184,5682,5683],{"class":251},"WEEK",[184,5685,5686],{"class":201},"[",[184,5688,5641],{"class":190},[184,5690,2631],{"class":201},[184,5692,5693],{"class":194},"getDay",[184,5695,5696],{"class":201},"()]",[184,5698,2231],{"class":201},[184,5700,255],{"class":190},[184,5702,5703],{"class":186,"line":524},[184,5704,586],{"class":190},[184,5706,5707],{"class":186,"line":553},[184,5708,708],{"class":190},[184,5710,5711],{"class":186,"line":558},[184,5712,457],{"emptyLinePlaceholder":317},[184,5714,5715,5717,5720,5722,5724,5727,5729,5731,5733],{"class":186,"line":583},[184,5716,967],{"class":215},[184,5718,5719],{"class":251}," observer",[184,5721,510],{"class":215},[184,5723,1712],{"class":215},[184,5725,5726],{"class":194}," MutationObserver",[184,5728,1480],{"class":190},[184,5730,216],{"class":215},[184,5732,5518],{"class":194},[184,5734,5735],{"class":190},"(picker))\n",[184,5737,5738,5741,5744,5747,5749,5752,5754],{"class":186,"line":589},[184,5739,5740],{"class":190},"observer.",[184,5742,5743],{"class":194},"observe",[184,5745,5746],{"class":190},"(picker, { childList: ",[184,5748,252],{"class":251},[184,5750,5751],{"class":190},", subtree: ",[184,5753,252],{"class":251},[184,5755,1372],{"class":190},[11,5757,5758,5759,5762],{},"選擇器關閉時要記得 ",[145,5760,5761],{},"disconnect()","，否則觀察器會留在記憶體裡。「只補沒有標籤的按鈕」也不是多餘的判斷：這個回呼自己也會修改 DOM，如果每次都重寫，就容易造成不必要的重複處理。",[20,5764,5766],{"id":5765},"用-nvda-驗證不要只看程式碼","用 NVDA 驗證，不要只看程式碼",[11,5768,5769,5771],{},[145,5770,4445],{}," 寫了不代表念得對。修改後我實際開啟 NVDA，只用鍵盤走完整個流程：開啟選擇器、移動日期、切換月份和年份、選定、關閉，確認每一步的焦點位置和報讀內容都符合預期。",[20,5773,1580],{"id":1580},[28,5775,5776,5779,5782],{},[31,5777,5778],{},"元件庫不等於無障礙。它能省下很多工，但實際走過流程之前，不能假設它都處理好了。",[31,5780,5781],{},"無障礙不只是加屬性，焦點管理才是鍵盤操作體驗的核心。",[31,5783,5784],{},"這個做法依賴 Vuetify 的 class 名稱和 DOM 結構，升級版本時可能失效，所以每次升級都要重新驗證。",[300,5786,5787],{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}",{"title":180,"searchDepth":222,"depth":222,"links":5789},[5790,5791,5792,5793,5794,5795],{"id":5237,"depth":222,"text":5237},{"id":5292,"depth":222,"text":5293},{"id":5422,"depth":222,"text":5423},{"id":5446,"depth":222,"text":5447},{"id":5765,"depth":222,"text":5766},{"id":1580,"depth":222,"text":1580},"2026-04-22","無障礙改寫時遇到元件庫支援不足的日期選擇器，如何補上鍵盤操作、焦點管理與報讀標籤，並用 NVDA 驗證。",{},"\u002Fblog\u002Fvuetify-datepicker-a11y",{"title":5226,"description":5797},"blog\u002Fvuetify-datepicker-a11y",[926,5222,5803],"NVDA","VQ1s6P8wh_0ZD0ZvUheSRBB2Z8d_dbmyUnVIBPztjh8",{"id":5806,"title":5807,"body":5808,"category":311,"date":6409,"description":6410,"draft":314,"extension":315,"meta":6411,"navigation":317,"path":6412,"readingTime":478,"seo":6413,"stem":6414,"tags":6415,"__hash__":6419},"blog\u002Fblog\u002Fblank-page-router-init.md","頁面一片空白：路由 404、初始化 API 與重複登入",{"type":8,"value":5809,"toc":6400},[5810,5813,5817,5820,5827,5902,5913,5931,5935,5938,6024,6027,6041,6044,6182,6186,6195,6211,6217,6275,6287,6291,6294,6297,6368,6371,6375,6378,6380,6397],[11,5811,5812],{},"空白畫面是最難受的錯誤之一：沒有提示、沒有按鈕，使用者只能重新整理。我在維護一個 Vue 2 後台系統時，一次處理了幾個會造成空白畫面的情況。",[20,5814,5816],{"id":5815},"情況一進入不存在的路由","情況一：進入不存在的路由",[11,5818,5819],{},"輸入錯誤的網址時，頁面只有外框，內容完全空白。",[11,5821,5822,5823,5826],{},"原因很單純：Vue Router 找不到對應路由時不會報錯，只是 ",[145,5824,5825],{},"\u003Crouter-view>"," 什麼都不渲染。Vue Router 3 要自己加上萬用路由：",[175,5828,5830],{"className":177,"code":5829,"language":179,"meta":180,"style":180},"export function abort(code = 404) {\n  return {\n    name: 'FourOhFour',\n    path: '*',\n    component: () => error(code),\n  }\n}\n",[145,5831,5832,5852,5858,5868,5878,5894,5898],{"__ignoreMap":180},[184,5833,5834,5836,5838,5841,5843,5845,5847,5850],{"class":186,"line":187},[184,5835,1009],{"class":215},[184,5837,1760],{"class":215},[184,5839,5840],{"class":194}," abort",[184,5842,198],{"class":190},[184,5844,145],{"class":208},[184,5846,510],{"class":215},[184,5848,5849],{"class":251}," 404",[184,5851,1787],{"class":190},[184,5853,5854,5856],{"class":186,"line":222},[184,5855,1240],{"class":215},[184,5857,219],{"class":190},[184,5859,5860,5863,5866],{"class":186,"line":245},[184,5861,5862],{"class":190},"    name: ",[184,5864,5865],{"class":201},"'FourOhFour'",[184,5867,1077],{"class":190},[184,5869,5870,5873,5876],{"class":186,"line":454},[184,5871,5872],{"class":190},"    path: ",[184,5874,5875],{"class":201},"'*'",[184,5877,1077],{"class":190},[184,5879,5880,5883,5886,5888,5891],{"class":186,"line":460},[184,5881,5882],{"class":194},"    component",[184,5884,5885],{"class":190},": () ",[184,5887,216],{"class":215},[184,5889,5890],{"class":194}," error",[184,5892,5893],{"class":190},"(code),\n",[184,5895,5896],{"class":186,"line":478},[184,5897,1235],{"class":190},[184,5899,5900],{"class":186,"line":319},[184,5901,708],{"class":190},[11,5903,5904,5905,5908,5909,5912],{},"因為 ",[145,5906,5907],{},"*"," 會配對所有路徑，它一定要放在路由清單的最後。這個專案把它放在初始化完成後，用 ",[145,5910,5911],{},"addRoute"," 註冊：",[175,5914,5916],{"className":177,"code":5915,"language":179,"meta":180,"style":180},"router.addRoute(abort())\n",[145,5917,5918],{"__ignoreMap":180},[184,5919,5920,5922,5924,5926,5929],{"class":186,"line":187},[184,5921,4541],{"class":190},[184,5923,5911],{"class":194},[184,5925,198],{"class":190},[184,5927,5928],{"class":194},"abort",[184,5930,242],{"class":190},[20,5932,5934],{"id":5933},"情況二初始化-api","情況二：初始化 API",[11,5936,5937],{},"系統進站時要載入幾份基礎資料，原本是一支一支等：",[175,5939,5941],{"className":177,"code":5940,"language":179,"meta":180,"style":180},"async function getData() {\n  await store.dispatch('getFormatInGroups')\n  await store.dispatch('setCityLists')\n  await store.dispatch('setPaymentType')\n  await checkLogin()\n  await getUserRoles()\n}\n",[145,5942,5943,5954,5972,5987,6002,6011,6020],{"__ignoreMap":180},[184,5944,5945,5947,5949,5952],{"class":186,"line":187},[184,5946,492],{"class":215},[184,5948,1760],{"class":215},[184,5950,5951],{"class":194}," getData",[184,5953,5004],{"class":190},[184,5955,5956,5959,5962,5965,5967,5970],{"class":186,"line":222},[184,5957,5958],{"class":215},"  await",[184,5960,5961],{"class":190}," store.",[184,5963,5964],{"class":194},"dispatch",[184,5966,198],{"class":190},[184,5968,5969],{"class":201},"'getFormatInGroups'",[184,5971,255],{"class":190},[184,5973,5974,5976,5978,5980,5982,5985],{"class":186,"line":245},[184,5975,5958],{"class":215},[184,5977,5961],{"class":190},[184,5979,5964],{"class":194},[184,5981,198],{"class":190},[184,5983,5984],{"class":201},"'setCityLists'",[184,5986,255],{"class":190},[184,5988,5989,5991,5993,5995,5997,6000],{"class":186,"line":454},[184,5990,5958],{"class":215},[184,5992,5961],{"class":190},[184,5994,5964],{"class":194},[184,5996,198],{"class":190},[184,5998,5999],{"class":201},"'setPaymentType'",[184,6001,255],{"class":190},[184,6003,6004,6006,6009],{"class":186,"line":460},[184,6005,5958],{"class":215},[184,6007,6008],{"class":194}," checkLogin",[184,6010,1694],{"class":190},[184,6012,6013,6015,6018],{"class":186,"line":478},[184,6014,5958],{"class":215},[184,6016,6017],{"class":194}," getUserRoles",[184,6019,1694],{"class":190},[184,6021,6022],{"class":186,"line":319},[184,6023,708],{"class":190},[11,6025,6026],{},"這段有兩個問題：",[28,6028,6029,6035],{},[31,6030,6031,6034],{},[15,6032,6033],{},"串行","：總時間是每支 API 的時間相加。",[31,6036,6037,6040],{},[15,6038,6039],{},"沒有錯誤處理","：任何一支失敗，後面都不會執行，也沒有人接住錯誤。",[11,6042,6043],{},"這幾份資料彼此獨立，不需要等前一支完成，所以改成並行，並加上錯誤處理：",[175,6045,6047],{"className":177,"code":6046,"language":179,"meta":180,"style":180},"async function getData() {\n  try {\n    await Promise.all([\n      store.dispatch('getFormatInGroups'),\n      store.dispatch('setCityLists'),\n      store.dispatch('setPaymentType'),\n      checkLogin(),\n      getUserRoles(),\n    ])\n    router.addRoute(abort())\n  } catch (error) {\n    console.error('資料載入錯誤：', error)\n  }\n}\n",[145,6048,6049,6059,6066,6080,6093,6105,6117,6125,6132,6137,6150,6159,6174,6178],{"__ignoreMap":180},[184,6050,6051,6053,6055,6057],{"class":186,"line":187},[184,6052,492],{"class":215},[184,6054,1760],{"class":215},[184,6056,5951],{"class":194},[184,6058,5004],{"class":190},[184,6060,6061,6064],{"class":186,"line":222},[184,6062,6063],{"class":215},"  try",[184,6065,219],{"class":190},[184,6067,6068,6070,6072,6074,6077],{"class":186,"line":245},[184,6069,527],{"class":215},[184,6071,2564],{"class":251},[184,6073,2631],{"class":190},[184,6075,6076],{"class":194},"all",[184,6078,6079],{"class":190},"([\n",[184,6081,6082,6085,6087,6089,6091],{"class":186,"line":454},[184,6083,6084],{"class":190},"      store.",[184,6086,5964],{"class":194},[184,6088,198],{"class":190},[184,6090,5969],{"class":201},[184,6092,1939],{"class":190},[184,6094,6095,6097,6099,6101,6103],{"class":186,"line":460},[184,6096,6084],{"class":190},[184,6098,5964],{"class":194},[184,6100,198],{"class":190},[184,6102,5984],{"class":201},[184,6104,1939],{"class":190},[184,6106,6107,6109,6111,6113,6115],{"class":186,"line":478},[184,6108,6084],{"class":190},[184,6110,5964],{"class":194},[184,6112,198],{"class":190},[184,6114,5999],{"class":201},[184,6116,1939],{"class":190},[184,6118,6119,6122],{"class":186,"line":319},[184,6120,6121],{"class":194},"      checkLogin",[184,6123,6124],{"class":190},"(),\n",[184,6126,6127,6130],{"class":186,"line":519},[184,6128,6129],{"class":194},"      getUserRoles",[184,6131,6124],{"class":190},[184,6133,6134],{"class":186,"line":524},[184,6135,6136],{"class":190},"    ])\n",[184,6138,6139,6142,6144,6146,6148],{"class":186,"line":553},[184,6140,6141],{"class":190},"    router.",[184,6143,5911],{"class":194},[184,6145,198],{"class":190},[184,6147,5928],{"class":194},[184,6149,242],{"class":190},[184,6151,6152,6154,6156],{"class":186,"line":558},[184,6153,3741],{"class":190},[184,6155,3122],{"class":215},[184,6157,6158],{"class":190}," (error) {\n",[184,6160,6161,6164,6166,6168,6171],{"class":186,"line":583},[184,6162,6163],{"class":190},"    console.",[184,6165,3128],{"class":194},[184,6167,198],{"class":190},[184,6169,6170],{"class":201},"'資料載入錯誤：'",[184,6172,6173],{"class":190},", error)\n",[184,6175,6176],{"class":186,"line":589},[184,6177,1235],{"class":190},[184,6179,6180],{"class":186,"line":2790},[184,6181,708],{"class":190},[594,6183,6185],{"id":6184},"改成-promiseall-要注意什麼","改成 Promise.all 要注意什麼",[11,6187,6188,6191,6192,6194],{},[145,6189,6190],{},"Promise.all"," 不是把 ",[145,6193,3108],{}," 換掉就好：",[28,6196,6197,6203],{},[31,6198,6199,6202],{},[15,6200,6201],{},"有依賴關係的不能並行","。如果 B 要用 A 的結果，就要維持先後順序。",[31,6204,6205,6210],{},[15,6206,6207,6208],{},"一支失敗就會進 ",[145,6209,3122],{},"，其他成功的結果也拿不到。",[11,6212,6213,6214,3414],{},"如果希望部分失敗時，其他資料還能用，可以改成 ",[145,6215,6216],{},"Promise.allSettled",[175,6218,6220],{"className":177,"code":6219,"language":179,"meta":180,"style":180},"const results = await Promise.allSettled(tasks)\nconst failed = results.filter(r => r.status === 'rejected')\n",[145,6221,6222,6243],{"__ignoreMap":180},[184,6223,6224,6226,6229,6231,6233,6235,6237,6240],{"class":186,"line":187},[184,6225,967],{"class":215},[184,6227,6228],{"class":251}," results",[184,6230,510],{"class":215},[184,6232,1515],{"class":215},[184,6234,2564],{"class":251},[184,6236,2631],{"class":190},[184,6238,6239],{"class":194},"allSettled",[184,6241,6242],{"class":190},"(tasks)\n",[184,6244,6245,6247,6250,6252,6255,6258,6260,6263,6265,6268,6270,6273],{"class":186,"line":222},[184,6246,967],{"class":215},[184,6248,6249],{"class":251}," failed",[184,6251,510],{"class":215},[184,6253,6254],{"class":190}," results.",[184,6256,6257],{"class":194},"filter",[184,6259,198],{"class":190},[184,6261,6262],{"class":208},"r",[184,6264,2216],{"class":215},[184,6266,6267],{"class":190}," r.status ",[184,6269,2222],{"class":215},[184,6271,6272],{"class":201}," 'rejected'",[184,6274,255],{"class":190},[11,6276,6277,6278,6280,6281,6283,6284,6286],{},"上面的寫法還有一個可以再改進的地方：",[145,6279,5911],{}," 放在 ",[145,6282,3096],{}," 裡，初始化失敗時就不會註冊 404 路由。如果重做一次，我會把它移到 ",[145,6285,1477],{},"，或在路由建立時就註冊，讓 404 不受 API 影響。",[20,6288,6290],{"id":6289},"情況三重複登入","情況三：重複登入",[11,6292,6293],{},"已經登入的使用者再次登入時，API 回傳失敗訊息，但程式沒有處理這個情況，只顯示「缺少必要的權限信息」，接著畫面就空白了。",[11,6295,6296],{},"修法是區分失敗的原因：",[175,6298,6300],{"className":177,"code":6299,"language":179,"meta":180,"style":180},"if (result.message?.includes('已登入')) {\n  alert(result.message)\n  await router.push({ name: 'Dashboard' })\n} else {\n  alert('登入失敗，請稍後再試。')\n}\n",[145,6301,6302,6320,6328,6345,6353,6364],{"__ignoreMap":180},[184,6303,6304,6306,6309,6312,6314,6317],{"class":186,"line":187},[184,6305,1311],{"class":215},[184,6307,6308],{"class":190}," (result.message?.",[184,6310,6311],{"class":194},"includes",[184,6313,198],{"class":190},[184,6315,6316],{"class":201},"'已登入'",[184,6318,6319],{"class":190},")) {\n",[184,6321,6322,6325],{"class":186,"line":222},[184,6323,6324],{"class":194},"  alert",[184,6326,6327],{"class":190},"(result.message)\n",[184,6329,6330,6332,6335,6337,6340,6343],{"class":186,"line":245},[184,6331,5958],{"class":215},[184,6333,6334],{"class":190}," router.",[184,6336,3636],{"class":194},[184,6338,6339],{"class":190},"({ name: ",[184,6341,6342],{"class":201},"'Dashboard'",[184,6344,1372],{"class":190},[184,6346,6347,6349,6351],{"class":186,"line":454},[184,6348,3119],{"class":190},[184,6350,3744],{"class":215},[184,6352,219],{"class":190},[184,6354,6355,6357,6359,6362],{"class":186,"line":460},[184,6356,6324],{"class":194},[184,6358,198],{"class":190},[184,6360,6361],{"class":201},"'登入失敗，請稍後再試。'",[184,6363,255],{"class":190},[184,6365,6366],{"class":186,"line":478},[184,6367,708],{"class":190},[11,6369,6370],{},"用訊息文字判斷不是最理想的做法，後端有明確的錯誤代碼會更穩定，但在不動後端的情況下，這樣能先解決問題。",[20,6372,6374],{"id":6373},"順便補上密碼逾期","順便補上：密碼逾期",[11,6376,6377],{},"登入時也可能遇到密碼逾期。原本使用者只會卡住，所以我新增了一個設定新密碼的視窗：後端回傳逾期狀態時打開，更新成功後關閉，讓使用者不用離開登入流程。",[20,6379,1580],{"id":1580},[28,6381,6382,6385,6388,6391],{},[31,6383,6384],{},"空白畫面通常代表某個情況沒有被處理，不是程式壞掉，而是流程沒有終點。",[31,6386,6387],{},"每個流程都要想失敗時的畫面：找不到頁面、API 失敗、狀態不符。",[31,6389,6390],{},"串行改並行前，先確認資料之間的依賴關係。",[31,6392,6393,6394,6396],{},"錯誤被 ",[145,6395,3122],{}," 住只是第一步，還要讓使用者看得到結果。",[300,6398,6399],{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":180,"searchDepth":222,"depth":222,"links":6401},[6402,6403,6406,6407,6408],{"id":5815,"depth":222,"text":5816},{"id":5933,"depth":222,"text":5934,"children":6404},[6405],{"id":6184,"depth":245,"text":6185},{"id":6289,"depth":222,"text":6290},{"id":6373,"depth":222,"text":6374},{"id":1580,"depth":222,"text":1580},"2026-03-18","後台系統在幾種情況下會顯示空白畫面。記錄如何補上 404 路由、調整初始化 API 的載入方式，以及處理重複登入與密碼逾期的流程。",{},"\u002Fblog\u002Fblank-page-router-init",{"title":5807,"description":6410},"blog\u002Fblank-page-router-init",[6416,6417,6418],"Vue Router","Promise","Error Handling","cXyq2tzY4HffeIZK6aIzRqcHPAONIjCw8zH76oYlYkg",{"id":6421,"title":6422,"body":6423,"category":311,"date":7028,"description":7029,"draft":314,"extension":315,"meta":7030,"navigation":317,"path":7031,"readingTime":460,"seo":7032,"stem":7033,"tags":7034,"__hash__":7036},"blog\u002Fblog\u002Fredirect-after-login.md","登入後回到原本的頁面：把一堆旗標收斂成一個機制",{"type":8,"value":6424,"toc":7020},[6425,6428,6431,6435,6438,6481,6484,6560,6563,6566,6570,6573,6654,6657,6863,6866,6915,6918,6922,6925,6936,6939,6942,6981,6984,6987,6994,7001,7004,7006,7017],[11,6426,6427],{},"使用者點進需要登入的頁面時，會先被帶去登入。登入完應該回到原本要去的頁面，而不是回首頁。",[11,6429,6430],{},"這個需求很常見，但我接手的 Vue 2 專案只處理了其中幾個頁面，而且做法不好擴充。這篇記錄我怎麼重構它。",[20,6432,6434],{"id":6433},"原本的做法每個頁面一個旗標","原本的做法：每個頁面一個旗標",[11,6436,6437],{},"Vuex 裡有一組旗標，記錄使用者是從哪個功能過來的：",[175,6439,6441],{"className":177,"code":6440,"language":179,"meta":180,"style":180},"state: {\n  loginToCart: false,\n  loginToApplication: false,\n  \u002F\u002F ...\n}\n",[145,6442,6443,6450,6461,6472,6477],{"__ignoreMap":180},[184,6444,6445,6448],{"class":186,"line":187},[184,6446,6447],{"class":194},"state",[184,6449,2841],{"class":190},[184,6451,6452,6455,6457,6459],{"class":186,"line":222},[184,6453,6454],{"class":194},"  loginToCart",[184,6456,675],{"class":190},[184,6458,1363],{"class":251},[184,6460,1077],{"class":190},[184,6462,6463,6466,6468,6470],{"class":186,"line":245},[184,6464,6465],{"class":194},"  loginToApplication",[184,6467,675],{"class":190},[184,6469,1363],{"class":251},[184,6471,1077],{"class":190},[184,6473,6474],{"class":186,"line":454},[184,6475,6476],{"class":1031},"  \u002F\u002F ...\n",[184,6478,6479],{"class":186,"line":460},[184,6480,708],{"class":190},[11,6482,6483],{},"進入頁面前設定旗標，登入成功後再逐一判斷：",[175,6485,6487],{"className":177,"code":6486,"language":179,"meta":180,"style":180},"if (state.loginToCart) {\n  router.push({ name: 'Cart' })\n} else if (state.loginToApplication) {\n  router.push({ name: 'Application' })\n} else {\n  router.push({ name: 'Home' })\n}\n",[145,6488,6489,6496,6510,6522,6535,6543,6556],{"__ignoreMap":180},[184,6490,6491,6493],{"class":186,"line":187},[184,6492,1311],{"class":215},[184,6494,6495],{"class":190}," (state.loginToCart) {\n",[184,6497,6498,6501,6503,6505,6508],{"class":186,"line":222},[184,6499,6500],{"class":190},"  router.",[184,6502,3636],{"class":194},[184,6504,6339],{"class":190},[184,6506,6507],{"class":201},"'Cart'",[184,6509,1372],{"class":190},[184,6511,6512,6514,6516,6519],{"class":186,"line":245},[184,6513,3119],{"class":190},[184,6515,3744],{"class":215},[184,6517,6518],{"class":215}," if",[184,6520,6521],{"class":190}," (state.loginToApplication) {\n",[184,6523,6524,6526,6528,6530,6533],{"class":186,"line":454},[184,6525,6500],{"class":190},[184,6527,3636],{"class":194},[184,6529,6339],{"class":190},[184,6531,6532],{"class":201},"'Application'",[184,6534,1372],{"class":190},[184,6536,6537,6539,6541],{"class":186,"line":460},[184,6538,3119],{"class":190},[184,6540,3744],{"class":215},[184,6542,219],{"class":190},[184,6544,6545,6547,6549,6551,6554],{"class":186,"line":478},[184,6546,6500],{"class":190},[184,6548,3636],{"class":194},[184,6550,6339],{"class":190},[184,6552,6553],{"class":201},"'Home'",[184,6555,1372],{"class":190},[184,6557,6558],{"class":186,"line":319},[184,6559,708],{"class":190},[11,6561,6562],{},"這樣的問題是，新增一個要登入的頁面，就要多一個旗標、多一個 mutation、多一段判斷，還要記得在對的地方清除。沒有列進去的頁面，登入後就只能回首頁。",[11,6564,6565],{},"而且它只記得「哪個頁面」，記不住網址上的參數。",[20,6567,6569],{"id":6568},"改成記住使用者要去的路由","改成：記住使用者要去的路由",[11,6571,6572],{},"其實這些旗標都在表達同一件事：登入後要去哪裡。所以我把它們換成一個狀態：",[175,6574,6576],{"className":177,"code":6575,"language":179,"meta":180,"style":180},"state: {\n  redirectAfterLogin: null,\n},\nmutations: {\n  setRedirectAfterLogin(state, route) {\n    state.redirectAfterLogin = route\n  },\n  clearRedirectAfterLogin(state) {\n    state.redirectAfterLogin = null\n  },\n}\n",[145,6577,6578,6584,6596,6601,6608,6616,6626,6630,6638,6646,6650],{"__ignoreMap":180},[184,6579,6580,6582],{"class":186,"line":187},[184,6581,6447],{"class":194},[184,6583,2841],{"class":190},[184,6585,6586,6589,6591,6594],{"class":186,"line":222},[184,6587,6588],{"class":194},"  redirectAfterLogin",[184,6590,675],{"class":190},[184,6592,6593],{"class":251},"null",[184,6595,1077],{"class":190},[184,6597,6598],{"class":186,"line":245},[184,6599,6600],{"class":190},"},\n",[184,6602,6603,6606],{"class":186,"line":454},[184,6604,6605],{"class":194},"mutations",[184,6607,2841],{"class":190},[184,6609,6610,6613],{"class":186,"line":460},[184,6611,6612],{"class":194},"  setRedirectAfterLogin",[184,6614,6615],{"class":190},"(state, route) {\n",[184,6617,6618,6621,6623],{"class":186,"line":478},[184,6619,6620],{"class":190},"    state.redirectAfterLogin ",[184,6622,833],{"class":215},[184,6624,6625],{"class":190}," route\n",[184,6627,6628],{"class":186,"line":319},[184,6629,2880],{"class":190},[184,6631,6632,6635],{"class":186,"line":519},[184,6633,6634],{"class":194},"  clearRedirectAfterLogin",[184,6636,6637],{"class":190},"(state) {\n",[184,6639,6640,6642,6644],{"class":186,"line":524},[184,6641,6620],{"class":190},[184,6643,833],{"class":215},[184,6645,1449],{"class":251},[184,6647,6648],{"class":186,"line":553},[184,6649,2880],{"class":190},[184,6651,6652],{"class":186,"line":558},[184,6653,708],{"class":190},[11,6655,6656],{},"記錄的時機也不用分散在各個頁面，全部放進 Router Guard。發現使用者沒有登入時，把目標路由存下來：",[175,6658,6660],{"className":177,"code":6659,"language":179,"meta":180,"style":180},"router.beforeEach((to, from, next) => {\n  const isPageRefresh = from.name && from.path === to.path\n  const skip = ['Login', 'Register', 'Home']\n\n  if (\n    !isLoggedIn() &&\n    !store.state.redirectAfterLogin &&\n    !skip.includes(to.name) &&\n    from.name !== undefined &&\n    !isPageRefresh\n  ) {\n    store.commit('setRedirectAfterLogin', {\n      name: to.name,\n      params: to.params,\n      query: to.query,\n    })\n  }\n\n  \u002F\u002F ...\n})\n",[145,6661,6662,6688,6711,6736,6740,6747,6760,6769,6783,6796,6803,6808,6824,6829,6834,6839,6844,6848,6853,6858],{"__ignoreMap":180},[184,6663,6664,6666,6669,6671,6673,6675,6677,6679,6682,6684,6686],{"class":186,"line":187},[184,6665,4541],{"class":190},[184,6667,6668],{"class":194},"beforeEach",[184,6670,1197],{"class":190},[184,6672,4549],{"class":208},[184,6674,489],{"class":190},[184,6676,424],{"class":208},[184,6678,489],{"class":190},[184,6680,6681],{"class":208},"next",[184,6683,212],{"class":190},[184,6685,216],{"class":215},[184,6687,219],{"class":190},[184,6689,6690,6692,6695,6697,6700,6703,6706,6708],{"class":186,"line":222},[184,6691,2060],{"class":215},[184,6693,6694],{"class":251}," isPageRefresh",[184,6696,510],{"class":215},[184,6698,6699],{"class":190}," from.name ",[184,6701,6702],{"class":215},"&&",[184,6704,6705],{"class":190}," from.path ",[184,6707,2222],{"class":215},[184,6709,6710],{"class":190}," to.path\n",[184,6712,6713,6715,6718,6720,6722,6725,6727,6730,6732,6734],{"class":186,"line":245},[184,6714,2060],{"class":215},[184,6716,6717],{"class":251}," skip",[184,6719,510],{"class":215},[184,6721,5472],{"class":190},[184,6723,6724],{"class":201},"'Login'",[184,6726,489],{"class":190},[184,6728,6729],{"class":201},"'Register'",[184,6731,489],{"class":190},[184,6733,6553],{"class":201},[184,6735,2253],{"class":190},[184,6737,6738],{"class":186,"line":454},[184,6739,457],{"emptyLinePlaceholder":317},[184,6741,6742,6744],{"class":186,"line":460},[184,6743,1211],{"class":215},[184,6745,6746],{"class":190}," (\n",[184,6748,6749,6752,6755,6757],{"class":186,"line":478},[184,6750,6751],{"class":215},"    !",[184,6753,6754],{"class":194},"isLoggedIn",[184,6756,5397],{"class":190},[184,6758,6759],{"class":215},"&&\n",[184,6761,6762,6764,6767],{"class":186,"line":319},[184,6763,6751],{"class":215},[184,6765,6766],{"class":190},"store.state.redirectAfterLogin ",[184,6768,6759],{"class":215},[184,6770,6771,6773,6776,6778,6781],{"class":186,"line":519},[184,6772,6751],{"class":215},[184,6774,6775],{"class":190},"skip.",[184,6777,6311],{"class":194},[184,6779,6780],{"class":190},"(to.name) ",[184,6782,6759],{"class":215},[184,6784,6785,6788,6790,6793],{"class":186,"line":524},[184,6786,6787],{"class":190},"    from.name ",[184,6789,2132],{"class":215},[184,6791,6792],{"class":251}," undefined",[184,6794,6795],{"class":215}," &&\n",[184,6797,6798,6800],{"class":186,"line":553},[184,6799,6751],{"class":215},[184,6801,6802],{"class":190},"isPageRefresh\n",[184,6804,6805],{"class":186,"line":558},[184,6806,6807],{"class":190},"  ) {\n",[184,6809,6810,6813,6816,6818,6821],{"class":186,"line":583},[184,6811,6812],{"class":190},"    store.",[184,6814,6815],{"class":194},"commit",[184,6817,198],{"class":190},[184,6819,6820],{"class":201},"'setRedirectAfterLogin'",[184,6822,6823],{"class":190},", {\n",[184,6825,6826],{"class":186,"line":589},[184,6827,6828],{"class":190},"      name: to.name,\n",[184,6830,6831],{"class":186,"line":2790},[184,6832,6833],{"class":190},"      params: to.params,\n",[184,6835,6836],{"class":186,"line":2795},[184,6837,6838],{"class":190},"      query: to.query,\n",[184,6840,6841],{"class":186,"line":2800},[184,6842,6843],{"class":190},"    })\n",[184,6845,6846],{"class":186,"line":2807},[184,6847,1235],{"class":190},[184,6849,6851],{"class":186,"line":6850},18,[184,6852,457],{"emptyLinePlaceholder":317},[184,6854,6856],{"class":186,"line":6855},19,[184,6857,6476],{"class":1031},[184,6859,6861],{"class":186,"line":6860},20,[184,6862,592],{"class":190},[11,6864,6865],{},"登入成功後只要看這一個值：",[175,6867,6869],{"className":177,"code":6868,"language":179,"meta":180,"style":180},"const route = store.state.redirectAfterLogin\nstore.commit('clearRedirectAfterLogin')\nrouter.push(route ?? { name: 'Home' })\n",[145,6870,6871,6883,6897],{"__ignoreMap":180},[184,6872,6873,6875,6878,6880],{"class":186,"line":187},[184,6874,967],{"class":215},[184,6876,6877],{"class":251}," route",[184,6879,510],{"class":215},[184,6881,6882],{"class":190}," store.state.redirectAfterLogin\n",[184,6884,6885,6888,6890,6892,6895],{"class":186,"line":222},[184,6886,6887],{"class":190},"store.",[184,6889,6815],{"class":194},[184,6891,198],{"class":190},[184,6893,6894],{"class":201},"'clearRedirectAfterLogin'",[184,6896,255],{"class":190},[184,6898,6899,6901,6903,6906,6908,6911,6913],{"class":186,"line":245},[184,6900,4541],{"class":190},[184,6902,3636],{"class":194},[184,6904,6905],{"class":190},"(route ",[184,6907,1803],{"class":215},[184,6909,6910],{"class":190}," { name: ",[184,6912,6553],{"class":201},[184,6914,1372],{"class":190},[11,6916,6917],{},"現在新增要登入的頁面，不用再改任何跳轉邏輯。",[20,6919,6921],{"id":6920},"為什麼要存-params-和-query","為什麼要存 params 和 query",[11,6923,6924],{},"只存路徑名稱的話，帶參數的頁面回來後內容會不見。例如從訂單詳情或搜尋結果被帶去登入，回來應該還是同一筆資料。",[11,6926,6927,6928,155,6931,155,6933,6935],{},"所以我把 ",[145,6929,6930],{},"name",[145,6932,4172],{},[145,6934,2073],{}," 都存起來，回來時就能還原完整的頁面狀態。",[20,6937,6938],{"id":6938},"要排除的邊界情況",[11,6940,6941],{},"機制本身很簡單，大部分的條件都在處理例外：",[28,6943,6944,6950,6956,6969,6975],{},[31,6945,6946,6949],{},[15,6947,6948],{},"登入、註冊、首頁不記錄","：不然使用者登入後又跳回登入頁。",[31,6951,6952,6955],{},[15,6953,6954],{},"已經有值就不覆蓋","：使用者被帶去登入的途中，還可能經過其他路由，保留第一個才是原本的目的地。",[31,6957,6958,3414,6961,6964,6965,6968],{},[15,6959,6960],{},"第一次進站不記錄",[145,6962,6963],{},"from.name"," 是 ",[145,6966,6967],{},"undefined"," 時，代表直接開啟網址，不是站內導覽。",[31,6970,6971,6974],{},[15,6972,6973],{},"重新整理不記錄","：這時來源和目標是同一個路徑。",[31,6976,6977,6980],{},[15,6978,6979],{},"登出時要清除","：不然下一個人登入後，會跳到上一個使用者的頁面。",[11,6982,6983],{},"最後一點最容易忘記。兩個人共用一台電腦時，這不只是體驗問題，也可能看到不該看的頁面。",[20,6985,6986],{"id":6986},"為什麼不用網址參數",[11,6988,6989,6990,6993],{},"另一種常見做法是把目的地放在網址，例如 ",[145,6991,6992],{},"\u002Flogin?redirect=\u002Forders\u002F123","。它的好處是重新整理不會消失，也方便分享。",[11,6995,6996,6997,7000],{},"但要注意 Open Redirect：如果直接跳轉網址參數的值，攻擊者可以做出 ",[145,6998,6999],{},"?redirect=https:\u002F\u002Fevil.example"," 的連結，用你的網域騙使用者。使用這種做法時，要檢查目的地只能是站內路徑。",[11,7002,7003],{},"這個專案把路由物件存在 Vuex，目的地不來自外部輸入，所以沒有這個問題；代價是整頁重新整理後狀態會消失，回到首頁。",[20,7005,1580],{"id":1580},[28,7007,7008,7011,7014],{},[31,7009,7010],{},"看到一排相似的旗標時，先想它們是不是在表達同一件事。",[31,7012,7013],{},"共用邏輯放在 Router Guard，比散落在各頁面好維護。",[31,7015,7016],{},"重構後程式變少了，但邊界條件才是真正要花時間想清楚的地方。",[300,7018,7019],{},"html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}",{"title":180,"searchDepth":222,"depth":222,"links":7021},[7022,7023,7024,7025,7026,7027],{"id":6433,"depth":222,"text":6434},{"id":6568,"depth":222,"text":6569},{"id":6920,"depth":222,"text":6921},{"id":6938,"depth":222,"text":6938},{"id":6986,"depth":222,"text":6986},{"id":1580,"depth":222,"text":1580},"2026-03-10","原本每個頁面各用一個旗標記錄登入後要去哪裡，新增頁面就要多一個。記錄如何改用 Router Guard 統一處理，以及需要排除的邊界情況。",{},"\u002Fblog\u002Fredirect-after-login",{"title":6422,"description":7029},"blog\u002Fredirect-after-login",[6416,3302,7035],"Refactoring","zIJg6fkodYDyme14VbBYVysujYdVuyM7zQbmgUHG-E0",{"id":7038,"title":7039,"body":7040,"category":918,"date":7361,"description":7362,"draft":314,"extension":315,"meta":7363,"navigation":317,"path":7364,"readingTime":460,"seo":7365,"stem":7366,"tags":7367,"__hash__":7371},"blog\u002Fblog\u002Fself-host-cdn-assets.md","弱點掃描的提醒：為什麼不要用 CDN 的 @latest",{"type":8,"value":7041,"toc":7348},[7042,7045,7049,7052,7073,7076,7079,7083,7086,7089,7097,7102,7113,7116,7120,7123,7179,7191,7195,7198,7204,7207,7224,7231,7234,7237,7248,7254,7266,7273,7276,7279,7326,7329,7331,7345],[11,7043,7044],{},"公司專案做弱點掃描時，報告列出一個中風險項目：「跨網域設定錯誤」。我一開始以為是後端的 CORS 設定，追查後才發現，跟前端載入的一個圖示字型有關。",[20,7046,7048],{"id":7047},"問題出在一行-import","問題出在一行 @import",[11,7050,7051],{},"專案為了使用 Material Design Icons，寫了一個 CSS 檔，從 CDN 載入圖示：",[175,7053,7055],{"className":657,"code":7054,"language":659,"meta":180,"style":180},"@import url(\"https:\u002F\u002Fcdn.jsdelivr.net\u002Fnpm\u002F@mdi\u002Ffont@latest\u002Fcss\u002Fmaterialdesignicons.min.css\");\n",[145,7056,7057],{"__ignoreMap":180},[184,7058,7059,7062,7065,7067,7070],{"class":186,"line":187},[184,7060,7061],{"class":215},"@import",[184,7063,7064],{"class":251}," url",[184,7066,198],{"class":190},[184,7068,7069],{"class":201},"\"https:\u002F\u002Fcdn.jsdelivr.net\u002Fnpm\u002F@mdi\u002Ffont@latest\u002Fcss\u002Fmaterialdesignicons.min.css\"",[184,7071,7072],{"class":190},");\n",[11,7074,7075],{},"這行寫起來很方便，也很常見，但它有幾個問題。",[20,7077,7078],{"id":7078},"第三方資源的風險",[594,7080,7082],{"id":7081},"_1-你的網站依賴別人的伺服器","1. 你的網站依賴別人的伺服器",[11,7084,7085],{},"CDN 上的 CSS 和字型是從另一個網域載入的。只要 CDN 服務異常，或使用者的網路環境擋掉該網域，圖示就全部顯示不出來。",[11,7087,7088],{},"對於網路環境受限的單位，這點特別容易發生，而且開發時完全看不出來。",[594,7090,7092,7093,7096],{"id":7091},"_2-latest-代表內容隨時會變","2. ",[145,7094,7095],{},"@latest"," 代表內容隨時會變",[11,7098,7099,7101],{},[145,7100,7095],{}," 不是固定的檔案，而是「目前最新的版本」。這代表：",[28,7103,7104,7107,7110],{},[31,7105,7106],{},"套件發新版時，網站的內容會在你沒有部署的情況下改變。",[31,7108,7109],{},"如果新版調整了圖示名稱，畫面可能突然壞掉，程式碼卻完全沒動過。",[31,7111,7112],{},"上游被植入惡意內容時，你的網站也會直接載入。",[11,7114,7115],{},"CSS 雖然不能直接執行 JavaScript，但第三方內容能影響你的畫面，本身就是一個信任問題。",[594,7117,7119],{"id":7118},"_3-無法做完整性檢查","3. 無法做完整性檢查",[11,7121,7122],{},"瀏覽器有 Subresource Integrity（SRI），可以檢查檔案內容是否被改過：",[175,7124,7126],{"className":814,"code":7125,"language":816,"meta":180,"style":180},"\u003Clink\n  rel=\"stylesheet\"\n  href=\"https:\u002F\u002Fcdn.example.com\u002Flib@1.2.3\u002Fstyle.css\"\n  integrity=\"sha384-...\"\n  crossorigin=\"anonymous\"\n>\n",[145,7127,7128,7135,7145,7155,7165,7175],{"__ignoreMap":180},[184,7129,7130,7132],{"class":186,"line":187},[184,7131,823],{"class":190},[184,7133,7134],{"class":826},"link\n",[184,7136,7137,7140,7142],{"class":186,"line":222},[184,7138,7139],{"class":194},"  rel",[184,7141,833],{"class":190},[184,7143,7144],{"class":201},"\"stylesheet\"\n",[184,7146,7147,7150,7152],{"class":186,"line":245},[184,7148,7149],{"class":194},"  href",[184,7151,833],{"class":190},[184,7153,7154],{"class":201},"\"https:\u002F\u002Fcdn.example.com\u002Flib@1.2.3\u002Fstyle.css\"\n",[184,7156,7157,7160,7162],{"class":186,"line":454},[184,7158,7159],{"class":194},"  integrity",[184,7161,833],{"class":190},[184,7163,7164],{"class":201},"\"sha384-...\"\n",[184,7166,7167,7170,7172],{"class":186,"line":460},[184,7168,7169],{"class":194},"  crossorigin",[184,7171,833],{"class":190},[184,7173,7174],{"class":201},"\"anonymous\"\n",[184,7176,7177],{"class":186,"line":478},[184,7178,871],{"class":190},[11,7180,7181,7182,7184,7185,7187,7188,954],{},"但 SRI 比對的是固定內容的雜湊值，",[145,7183,7095],{}," 的內容會變，一更新就會比對失敗。而且用 CSS ",[145,7186,7061],{}," 載入時，也沒有地方可以寫 ",[145,7189,7190],{},"integrity",[20,7192,7194],{"id":7193},"解法改成自己託管","解法：改成自己託管",[11,7196,7197],{},"修法很單純：把 CSS 和字型檔放進專案，由自己的伺服器提供。",[175,7199,7202],{"className":7200,"code":7201,"language":567,"meta":180},[1640],"src\u002Fassets\u002F\n├── customized\u002F\n│   └── materialdesignicons.min.css\n└── fonts\u002F\n    ├── materialdesignicons-webfont.woff2\n    └── materialdesignicons-webfont.woff\n",[145,7203,7201],{"__ignoreMap":180},[11,7205,7206],{},"入口檔改成載入本地的 CSS：",[175,7208,7210],{"className":177,"code":7209,"language":179,"meta":180,"style":180},"\u002F\u002F main.js\nimport '.\u002Fassets\u002Fcustomized\u002Fmaterialdesignicons.min.css'\n",[145,7211,7212,7217],{"__ignoreMap":180},[184,7213,7214],{"class":186,"line":187},[184,7215,7216],{"class":1031},"\u002F\u002F main.js\n",[184,7218,7219,7221],{"class":186,"line":222},[184,7220,418],{"class":215},[184,7222,7223],{"class":201}," '.\u002Fassets\u002Fcustomized\u002Fmaterialdesignicons.min.css'\n",[11,7225,7226,7227,7230],{},"再把 CSS 裡 ",[145,7228,7229],{},"@font-face"," 的路徑指到本地字型。這樣所有資源都來自同一個來源，版本也固定了，要更新時才手動更換。",[20,7232,7233],{"id":7233},"代價是什麼",[11,7235,7236],{},"自己託管不是完全沒有成本：",[28,7238,7239,7242,7245],{},[31,7240,7241],{},"字型檔不小，專案也要多負責這些資源的快取和傳輸。",[31,7243,7244],{},"版本不會自動更新，要自己留意有沒有安全修正。",[31,7246,7247],{},"CSS 檔會進版本控制。這個檔案就有兩萬多行。",[11,7249,7250,7251,3414],{},"如果重新做一次，我會改用 npm 安裝固定版本的 ",[145,7252,7253],{},"@mdi\u002Ffont",[175,7255,7257],{"className":177,"code":7256,"language":179,"meta":180,"style":180},"import '@mdi\u002Ffont\u002Fcss\u002Fmaterialdesignicons.min.css'\n",[145,7258,7259],{"__ignoreMap":180},[184,7260,7261,7263],{"class":186,"line":187},[184,7262,418],{"class":215},[184,7264,7265],{"class":201}," '@mdi\u002Ffont\u002Fcss\u002Fmaterialdesignicons.min.css'\n",[11,7267,7268,7269,7272],{},"打包工具會幫忙處理字型路徑，版本寫在 ",[145,7270,7271],{},"package-lock.json","，比手動複製檔案好維護。如果只用到少量圖示，也可以改用 SVG，只打包需要的部分。",[11,7274,7275],{},"現代瀏覽器都支援 woff2，通常只保留 woff2 就夠了，不需要把 eot、ttf 都帶上。",[20,7277,7278],{"id":7278},"怎麼檢查專案裡還有沒有類似問題",[175,7280,7282],{"className":3362,"code":7281,"language":3364,"meta":180,"style":180},"# 找出程式碼裡的外部資源\ngrep -rnE \"https?:\u002F\u002F\" src index.html\n\n# 特別留意沒有固定版本的引入\ngrep -rn \"@latest\" src index.html\n",[145,7283,7284,7289,7304,7308,7313],{"__ignoreMap":180},[184,7285,7286],{"class":186,"line":187},[184,7287,7288],{"class":1031},"# 找出程式碼裡的外部資源\n",[184,7290,7291,7293,7296,7299,7301],{"class":186,"line":222},[184,7292,3371],{"class":194},[184,7294,7295],{"class":251}," -rnE",[184,7297,7298],{"class":201}," \"https?:\u002F\u002F\"",[184,7300,830],{"class":201},[184,7302,7303],{"class":201}," index.html\n",[184,7305,7306],{"class":186,"line":245},[184,7307,457],{"emptyLinePlaceholder":317},[184,7309,7310],{"class":186,"line":454},[184,7311,7312],{"class":1031},"# 特別留意沒有固定版本的引入\n",[184,7314,7315,7317,7319,7322,7324],{"class":186,"line":460},[184,7316,3371],{"class":194},[184,7318,3374],{"class":251},[184,7320,7321],{"class":201}," \"@latest\"",[184,7323,830],{"class":201},[184,7325,7303],{"class":201},[11,7327,7328],{},"也可以打開 DevTools 的 Network 面板，篩選第三方網域，看實際載入了哪些資源。",[20,7330,1580],{"id":1580},[28,7332,7333,7336,7339],{},[31,7334,7335],{},"弱點掃描的項目不一定在你想的地方，要追到實際的來源。",[31,7337,7338],{},"方便的一行引入，背後是對第三方的信任。上線前要想清楚值不值得。",[31,7340,7341,7342,7344],{},"不要在正式環境使用 ",[145,7343,7095],{},"，固定版本才能掌握網站實際載入的內容。",[300,7346,7347],{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}",{"title":180,"searchDepth":222,"depth":222,"links":7349},[7350,7351,7357,7358,7359,7360],{"id":7047,"depth":222,"text":7048},{"id":7078,"depth":222,"text":7078,"children":7352},[7353,7354,7356],{"id":7081,"depth":245,"text":7082},{"id":7091,"depth":245,"text":7355},"2. @latest 代表內容隨時會變",{"id":7118,"depth":245,"text":7119},{"id":7193,"depth":222,"text":7194},{"id":7233,"depth":222,"text":7233},{"id":7278,"depth":222,"text":7278},{"id":1580,"depth":222,"text":1580},"2026-03-06","弱點掃描回報中風險的跨網域設定問題，追查後發現來自一行 CDN 引入。記錄第三方資源的風險，以及改成自己託管的做法與取捨。",{},"\u002Fblog\u002Fself-host-cdn-assets",{"title":7039,"description":7362},"blog\u002Fself-host-cdn-assets",[7368,7369,7370],"Security","CDN","Vite","14hMgJGFSshCQERyEEfR1WtjTbtpGWvrvPdpauaVUVY",{"id":7373,"title":7374,"body":7375,"category":311,"date":7858,"description":7859,"draft":314,"extension":315,"meta":7860,"navigation":317,"path":7861,"readingTime":454,"seo":7862,"stem":7863,"tags":7864,"__hash__":7867},"blog\u002Fblog\u002Fdont-split-names.md","我寫了姓名拆解，兩個月後把它從註冊流程刪掉",{"type":8,"value":7376,"toc":7851},[7377,7391,7394,7397,7400,7414,7417,7617,7620,7623,7626,7708,7711,7715,7718,7721,7792,7801,7804,7807,7810,7823,7826,7829,7831,7848],[11,7378,7379,7380,7383,7384,1161,7387,7390],{},"這次的需求是：註冊時的姓名要能支援原住民的名字。原住民的名字不一定能分成一個字的姓和後面的名，有些還會用間隔號 ",[145,7381,7382],{},"·"," 連接，所以表單改成一個「姓名」欄位，但後端的資料還是要 ",[145,7385,7386],{},"first_name",[145,7388,7389],{},"last_name"," 兩個值。",[11,7392,7393],{},"我的第一個想法很直覺：寫一個函式，把使用者輸入的姓名拆開。兩個月後，我把註冊流程裡的這段邏輯刪了。",[20,7395,7396],{"id":7396},"當時的拆解規則",[11,7398,7399],{},"我依照常見的姓名格式寫了幾條規則：",[264,7401,7402,7408,7411],{},[31,7403,7404,7405,7407],{},"有間隔號 ",[145,7406,7382],{}," 或空白，就從第一個符號切開。",[31,7409,7410],{},"開頭是中文，第一個字當姓，其他當名。",[31,7412,7413],{},"其他情況，整串放進同一個欄位。",[11,7415,7416],{},"寫成程式大概是這樣：",[175,7418,7420],{"className":177,"code":7419,"language":179,"meta":180,"style":180},"function splitName(name) {\n  const trimmed = name.trim()\n  const index = trimmed.search(\u002F[·\\s]\u002F)\n\n  if (index !== -1) {\n    return {\n      surname: trimmed.slice(0, index),\n      givenName: trimmed.slice(index + 1),\n    }\n  }\n\n  if (\u002F^[一-龥]\u002F.test(trimmed)) {\n    return { surname: trimmed[0], givenName: trimmed.slice(1) }\n  }\n\n  return { surname: trimmed, givenName: '' }\n}\n",[145,7421,7422,7435,7452,7479,7483,7499,7505,7520,7536,7540,7544,7548,7572,7593,7597,7601,7613],{"__ignoreMap":180},[184,7423,7424,7426,7429,7431,7433],{"class":186,"line":187},[184,7425,4998],{"class":215},[184,7427,7428],{"class":194}," splitName",[184,7430,198],{"class":190},[184,7432,6930],{"class":208},[184,7434,1787],{"class":190},[184,7436,7437,7439,7442,7444,7447,7450],{"class":186,"line":222},[184,7438,2060],{"class":215},[184,7440,7441],{"class":251}," trimmed",[184,7443,510],{"class":215},[184,7445,7446],{"class":190}," name.",[184,7448,7449],{"class":194},"trim",[184,7451,1694],{"class":190},[184,7453,7454,7456,7459,7461,7464,7467,7469,7472,7475,7477],{"class":186,"line":245},[184,7455,2060],{"class":215},[184,7457,7458],{"class":251}," index",[184,7460,510],{"class":215},[184,7462,7463],{"class":190}," trimmed.",[184,7465,7466],{"class":194},"search",[184,7468,198],{"class":190},[184,7470,7471],{"class":201},"\u002F",[184,7473,7474],{"class":251},"[·\\s]",[184,7476,7471],{"class":201},[184,7478,255],{"class":190},[184,7480,7481],{"class":186,"line":454},[184,7482,457],{"emptyLinePlaceholder":317},[184,7484,7485,7487,7490,7492,7495,7497],{"class":186,"line":460},[184,7486,1211],{"class":215},[184,7488,7489],{"class":190}," (index ",[184,7491,2132],{"class":215},[184,7493,7494],{"class":215}," -",[184,7496,5322],{"class":251},[184,7498,1787],{"class":190},[184,7500,7501,7503],{"class":186,"line":478},[184,7502,3153],{"class":215},[184,7504,219],{"class":190},[184,7506,7507,7510,7513,7515,7517],{"class":186,"line":319},[184,7508,7509],{"class":190},"      surname: trimmed.",[184,7511,7512],{"class":194},"slice",[184,7514,198],{"class":190},[184,7516,1797],{"class":251},[184,7518,7519],{"class":190},", index),\n",[184,7521,7522,7525,7527,7530,7532,7534],{"class":186,"line":519},[184,7523,7524],{"class":190},"      givenName: trimmed.",[184,7526,7512],{"class":194},[184,7528,7529],{"class":190},"(index ",[184,7531,5400],{"class":215},[184,7533,5666],{"class":251},[184,7535,1939],{"class":190},[184,7537,7538],{"class":186,"line":524},[184,7539,2921],{"class":190},[184,7541,7542],{"class":186,"line":553},[184,7543,1235],{"class":190},[184,7545,7546],{"class":186,"line":558},[184,7547,457],{"emptyLinePlaceholder":317},[184,7549,7550,7552,7554,7556,7559,7562,7564,7566,7569],{"class":186,"line":583},[184,7551,1211],{"class":215},[184,7553,3125],{"class":190},[184,7555,7471],{"class":201},[184,7557,7558],{"class":215},"^",[184,7560,7561],{"class":251},"[一-龥]",[184,7563,7471],{"class":201},[184,7565,2631],{"class":190},[184,7567,7568],{"class":194},"test",[184,7570,7571],{"class":190},"(trimmed)) {\n",[184,7573,7574,7576,7579,7581,7584,7586,7588,7590],{"class":186,"line":589},[184,7575,3153],{"class":215},[184,7577,7578],{"class":190}," { surname: trimmed[",[184,7580,1797],{"class":251},[184,7582,7583],{"class":190},"], givenName: trimmed.",[184,7585,7512],{"class":194},[184,7587,198],{"class":190},[184,7589,5322],{"class":251},[184,7591,7592],{"class":190},") }\n",[184,7594,7595],{"class":186,"line":2790},[184,7596,1235],{"class":190},[184,7598,7599],{"class":186,"line":2795},[184,7600,457],{"emptyLinePlaceholder":317},[184,7602,7603,7605,7608,7611],{"class":186,"line":2800},[184,7604,1240],{"class":215},[184,7606,7607],{"class":190}," { surname: trimmed, givenName: ",[184,7609,7610],{"class":201},"''",[184,7612,2327],{"class":190},[184,7614,7615],{"class":186,"line":2807},[184,7616,708],{"class":190},[11,7618,7619],{},"測試時輸入「王小明」，結果完全正確，看起來沒有問題。",[20,7621,7622],{"id":7622},"問題在於規則永遠寫不完",[11,7624,7625],{},"姓名不是一種能用幾條規則描述的格式：",[81,7627,7628,7641],{},[84,7629,7630],{},[87,7631,7632,7635,7638],{},[90,7633,7634],{},"輸入",[90,7636,7637],{},"拆解結果",[90,7639,7640],{},"問題",[100,7642,7643,7654,7664,7675,7686,7697],{},[87,7644,7645,7648,7651],{},[105,7646,7647],{},"歐陽小明",[105,7649,7650],{},"歐 \u002F 陽小明",[105,7652,7653],{},"複姓被切開",[87,7655,7656,7659,7662],{},[105,7657,7658],{},"范姜小明",[105,7660,7661],{},"范 \u002F 姜小明",[105,7663,7653],{},[87,7665,7666,7669,7672],{},[105,7667,7668],{},"張李小明",[105,7670,7671],{},"張 \u002F 李小明",[105,7673,7674],{},"無法判斷是否為雙姓",[87,7676,7677,7680,7683],{},[105,7678,7679],{},"瓦旦·達路",[105,7681,7682],{},"瓦旦 \u002F 達路",[105,7684,7685],{},"順序不一定是「姓在前」",[87,7687,7688,7691,7694],{},[105,7689,7690],{},"John Smith",[105,7692,7693],{},"John \u002F Smith",[105,7695,7696],{},"英文是名在前",[87,7698,7699,7702,7705],{},[105,7700,7701],{},"Mary Ann Smith",[105,7703,7704],{},"Mary \u002F Ann Smith",[105,7706,7707],{},"中間名怎麼算",[11,7709,7710],{},"當然可以繼續加規則，例如放一份複姓清單，但「張李」這種情況，光看字串根本無法判斷。程式只是在猜，而猜錯的是使用者的名字。",[20,7712,7714],{"id":7713},"最後的修法團隊討論後決定不拆","最後的修法：團隊討論後決定不拆",[11,7716,7717],{},"原本的方向就是把姓名拆開存。後來團隊內部討論，對照資料庫的欄位後發現其實沒有必要拆分：後端收到姓和名之後，會直接接成一個完整姓名儲存。前端辛苦拆開，後端又接回去，拆分根本沒有帶來價值。",[11,7719,7720],{},"所以註冊時改成使用者打什麼，就存什麼：",[175,7722,7724],{"className":177,"code":7723,"language":179,"meta":180,"style":180},"\u002F\u002F 之前\nconst { surname, givenName } = splitName(form.name)\n\n\u002F\u002F 之後\nconst payload = {\n  last_name: form.name,\n  first_name: '',\n}\n",[145,7725,7726,7731,7754,7758,7763,7774,7779,7788],{"__ignoreMap":180},[184,7727,7728],{"class":186,"line":187},[184,7729,7730],{"class":1031},"\u002F\u002F 之前\n",[184,7732,7733,7735,7737,7740,7742,7745,7747,7749,7751],{"class":186,"line":222},[184,7734,967],{"class":215},[184,7736,1504],{"class":190},[184,7738,7739],{"class":251},"surname",[184,7741,489],{"class":190},[184,7743,7744],{"class":251},"givenName",[184,7746,1510],{"class":190},[184,7748,833],{"class":215},[184,7750,7428],{"class":194},[184,7752,7753],{"class":190},"(form.name)\n",[184,7755,7756],{"class":186,"line":245},[184,7757,457],{"emptyLinePlaceholder":317},[184,7759,7760],{"class":186,"line":454},[184,7761,7762],{"class":1031},"\u002F\u002F 之後\n",[184,7764,7765,7767,7770,7772],{"class":186,"line":460},[184,7766,967],{"class":215},[184,7768,7769],{"class":251}," payload",[184,7771,510],{"class":215},[184,7773,219],{"class":190},[184,7775,7776],{"class":186,"line":478},[184,7777,7778],{"class":190},"  last_name: form.name,\n",[184,7780,7781,7784,7786],{"class":186,"line":319},[184,7782,7783],{"class":190},"  first_name: ",[184,7785,7610],{"class":201},[184,7787,1077],{"class":190},[184,7789,7790],{"class":186,"line":519},[184,7791,708],{"class":190},[11,7793,7794,7795,7797,7798,7800],{},"這次改動刪掉了 40 多行，只留下兩行。資料庫的欄位沒有調整，所以完整姓名放在 ",[145,7796,7389],{},"，",[145,7799,7386],{}," 留空。",[11,7802,7803],{},"這不是最漂亮的資料結構，但系統不用再猜哪個部分是姓，原住民的名字也能完整保存。",[20,7805,7806],{"id":7806},"什麼時候才需要拆",[11,7808,7809],{},"資料還是存完整姓名，真的需要分開時才拆。例如要顯示「王先生您好」，就在使用的當下，依照調整後的規則自動拆分：",[28,7811,7812,7817,7820],{},[31,7813,7404,7814,7816],{},[145,7815,7382],{}," 或空白時，從第一個符號切開。",[31,7818,7819],{},"沒有符號的中文姓名，以第一個字當姓。",[31,7821,7822],{},"判斷不了的情況，就顯示完整姓名。",[11,7824,7825],{},"和原本最大的差別是，拆分結果只用來顯示，不寫回資料庫。規則就算猜錯，完整姓名也還在，之後調整規則就能修正，不會留下拆錯的資料。",[11,7827,7828],{},"如果姓和名會影響重要用途，例如要和證件資料比對，就不適合靠規則判斷，應該讓使用者自己分開填寫。",[20,7830,1580],{"id":1580},[28,7832,7833,7836,7839,7842,7845],{},[31,7834,7835],{},"測試資料全部通過，不代表規則是對的。我只想到了「王小明」。",[31,7837,7838],{},"碰到人名、地址這類真實世界的資料，要先懷疑自己的假設。",[31,7840,7841],{},"當規則需要一直補例外時，先問這段程式是不是根本不該存在。",[31,7843,7844],{},"動手前先和團隊確認資料實際怎麼使用，討論之後常常會發現不用做那麼多。",[31,7846,7847],{},"刪程式碼也是一種修法，而且常常是最好的那種。",[300,7849,7850],{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}",{"title":180,"searchDepth":222,"depth":222,"links":7852},[7853,7854,7855,7856,7857],{"id":7396,"depth":222,"text":7396},{"id":7622,"depth":222,"text":7622},{"id":7713,"depth":222,"text":7714},{"id":7806,"depth":222,"text":7806},{"id":1580,"depth":222,"text":1580},"2026-02-25","為了讓註冊姓名支援原住民的名字，我寫了一段拆解規則。記錄這些規則的問題，以及團隊討論後決定不拆、改存單一欄位的過程。",{},"\u002Fblog\u002Fdont-split-names",{"title":7374,"description":7859},"blog\u002Fdont-split-names",[7865,7035,7866],"Forms","Edge Cases","Lxt087WSWxMgDtfpoMaNGFDxWzOqKL58ylNWh4oQs-Y",{"id":7869,"title":7870,"body":7871,"category":8151,"date":8152,"description":8153,"draft":314,"extension":315,"meta":8154,"navigation":317,"path":8155,"readingTime":245,"seo":8156,"stem":8157,"tags":8158,"__hash__":8160},"blog\u002Fblog\u002Fdialog-esc-state-sync.md","Dialog 按 ESC 關閉後，再也打不開",{"type":8,"value":7872,"toc":8145},[7873,7876,7880,7883,7906,7916,7919,7976,7979,7983,7986,8016,8022,8025,8028,8038,8049,8129,8131,8142],[11,7874,7875],{},"有一個常見問題的 Dialog，用按鈕關閉完全正常，但按鍵盤 ESC 關閉之後，再點一次開啟按鈕，視窗就打不開了。",[20,7877,7879],{"id":7878},"原因兩個地方都記著開關狀態","原因：兩個地方都記著開關狀態",[11,7881,7882],{},"這個 Dialog 的開關是由 Vuex 控制的，外部用 action 打開：",[175,7884,7886],{"className":177,"code":7885,"language":179,"meta":180,"style":180},"store.dispatch('toggleFAQdialog', { action: true })\n",[145,7887,7888],{"__ignoreMap":180},[184,7889,7890,7892,7894,7896,7899,7902,7904],{"class":186,"line":187},[184,7891,6887],{"class":190},[184,7893,5964],{"class":194},[184,7895,198],{"class":190},[184,7897,7898],{"class":201},"'toggleFAQdialog'",[184,7900,7901],{"class":190},", { action: ",[184,7903,252],{"class":251},[184,7905,1372],{"class":190},[11,7907,7908,7909,7912,7913,7915],{},"按鈕關閉時會走 ",[145,7910,7911],{},"closeFAQ","，把狀態改回 ",[145,7914,1363],{},"。問題是 ESC 不會經過這條路。Vuetify 的 Dialog 內建 ESC 關閉，它自己把視窗收起來了，但我們的關閉邏輯沒有執行。",[11,7917,7918],{},"於是狀態變成這樣：",[81,7920,7921,7934],{},[84,7922,7923],{},[87,7924,7925,7928,7931],{},[90,7926,7927],{},"時機",[90,7929,7930],{},"Vuex 狀態",[90,7932,7933],{},"畫面",[100,7935,7936,7948,7961],{},[87,7937,7938,7941,7945],{},[105,7939,7940],{},"開啟",[105,7942,7943],{},[145,7944,252],{},[105,7946,7947],{},"顯示",[87,7949,7950,7953,7958],{},[105,7951,7952],{},"按 ESC",[105,7954,7955,7956],{},"仍然是 ",[145,7957,252],{},[105,7959,7960],{},"關閉",[87,7962,7963,7966,7973],{},[105,7964,7965],{},"再按開啟",[105,7967,7968,7970,7971],{},[145,7969,252],{}," → ",[145,7972,252],{},[105,7974,7975],{},"沒有變化",[11,7977,7978],{},"狀態沒有改變，Vue 也就不會觸發更新，畫面當然沒有反應。",[20,7980,7982],{"id":7981},"修法讓-esc-走同一條關閉流程","修法：讓 ESC 走同一條關閉流程",[11,7984,7985],{},"在 Dialog 上監聽 ESC，呼叫同一個關閉方法：",[175,7987,7989],{"className":814,"code":7988,"language":816,"meta":180,"style":180},"\u003Cv-dialog v-model=\"FAQdialog\" @keydown.esc=\"closeFAQ\">\n",[145,7990,7991],{"__ignoreMap":180},[184,7992,7993,7995,7998,8001,8003,8006,8009,8011,8014],{"class":186,"line":187},[184,7994,823],{"class":190},[184,7996,7997],{"class":826},"v-dialog",[184,7999,8000],{"class":194}," v-model",[184,8002,833],{"class":190},[184,8004,8005],{"class":201},"\"FAQdialog\"",[184,8007,8008],{"class":194}," @keydown.esc",[184,8010,833],{"class":190},[184,8012,8013],{"class":201},"\"closeFAQ\"",[184,8015,871],{"class":190},[11,8017,8018,8019,8021],{},"這樣不管從哪裡關閉，最後都會把狀態改回 ",[145,8020,1363],{},"，兩邊就一致了。",[20,8023,8024],{"id":8024},"其他會發生同樣問題的情況",[11,8026,8027],{},"只要元件有自己關閉的方式，就要確認狀態有同步：",[28,8029,8030,8032,8035],{},[31,8031,7952],{},[31,8033,8034],{},"點擊遮罩",[31,8036,8037],{},"路由切換",[11,8039,8040,8041,8044,8045,8048],{},"這也是 ",[145,8042,8043],{},"v-model"," 重要的原因：雙向綁定的 setter 要真的把值寫回狀態來源，元件內部的關閉才會同步到外面。如果綁定的是只有 getter 的 computed，或用單向的 ",[145,8046,8047],{},":value","，就很容易遇到這個問題。",[175,8050,8052],{"className":177,"code":8051,"language":179,"meta":180,"style":180},"computed: {\n  dialog: {\n    get() {\n      return this.$store.state.showDialog\n    },\n    set(value) {\n      this.$store.commit('setShowDialog', value)\n    },\n  },\n}\n",[145,8053,8054,8061,8068,8075,8086,8091,8099,8117,8121,8125],{"__ignoreMap":180},[184,8055,8056,8059],{"class":186,"line":187},[184,8057,8058],{"class":194},"computed",[184,8060,2841],{"class":190},[184,8062,8063,8066],{"class":186,"line":222},[184,8064,8065],{"class":194},"  dialog",[184,8067,2841],{"class":190},[184,8069,8070,8073],{"class":186,"line":245},[184,8071,8072],{"class":194},"    get",[184,8074,5004],{"class":190},[184,8076,8077,8080,8083],{"class":186,"line":454},[184,8078,8079],{"class":215},"      return",[184,8081,8082],{"class":251}," this",[184,8084,8085],{"class":190},".$store.state.showDialog\n",[184,8087,8088],{"class":186,"line":460},[184,8089,8090],{"class":190},"    },\n",[184,8092,8093,8096],{"class":186,"line":478},[184,8094,8095],{"class":194},"    set",[184,8097,8098],{"class":190},"(value) {\n",[184,8100,8101,8104,8107,8109,8111,8114],{"class":186,"line":319},[184,8102,8103],{"class":251},"      this",[184,8105,8106],{"class":190},".$store.",[184,8108,6815],{"class":194},[184,8110,198],{"class":190},[184,8112,8113],{"class":201},"'setShowDialog'",[184,8115,8116],{"class":190},", value)\n",[184,8118,8119],{"class":186,"line":519},[184,8120,8090],{"class":190},[184,8122,8123],{"class":186,"line":524},[184,8124,2880],{"class":190},[184,8126,8127],{"class":186,"line":553},[184,8128,708],{"class":190},[20,8130,1580],{"id":1580},[28,8132,8133,8136,8139],{},[31,8134,8135],{},"同一個狀態存在兩個地方時，遲早會不同步。",[31,8137,8138],{},"測試關閉功能時，不只要測按鈕，鍵盤和遮罩也要測。",[31,8140,8141],{},"「只有第二次失敗」的 Bug，通常代表狀態沒有被重設。",[300,8143,8144],{},"html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}",{"title":180,"searchDepth":222,"depth":222,"links":8146},[8147,8148,8149,8150],{"id":7878,"depth":222,"text":7879},{"id":7981,"depth":222,"text":7982},{"id":8024,"depth":222,"text":8024},{"id":1580,"depth":222,"text":1580},"vue","2025-12-05","Vuetify Dialog 用 ESC 關閉後，再次點擊卻打不開。問題出在元件內部狀態和外部狀態沒有同步，記錄原因與修正方式。",{},"\u002Fblog\u002Fdialog-esc-state-sync",{"title":7870,"description":8153},"blog\u002Fdialog-esc-state-sync",[5222,3302,8159],"State","VRzGbls9ZJzt0JXNqoDl9ZRXY-TXFd6xnF3bR_WytR4",{"id":8162,"title":8163,"body":8164,"category":311,"date":8689,"description":8690,"draft":314,"extension":315,"meta":8691,"navigation":317,"path":8692,"readingTime":478,"seo":8693,"stem":8694,"tags":8695,"__hash__":8699},"blog\u002Fblog\u002Fopenlayers-image-adjust.md","OpenLayers 影像調整：CSS filter 與彈窗拖曳的互動問題",{"type":8,"value":8165,"toc":8677},[8166,8169,8173,8176,8179,8305,8308,8361,8364,8384,8387,8394,8397,8400,8404,8407,8410,8413,8463,8466,8480,8483,8486,8489,8636,8646,8649,8652,8658,8660,8674],[11,8167,8168],{},"工作上有一個查看影像的彈窗，裡面用 OpenLayers 顯示影像，使用者可以平移和縮放。這次要加上調整明暗、對比和飽和度的功能，同時也修掉一個彈窗互動的問題。",[20,8170,8172],{"id":8171},"用-css-filter-調整影像","用 CSS filter 調整影像",[11,8174,8175],{},"OpenLayers 預設用 canvas 繪製圖層。要改變影像外觀，我一開始想到的是讀取像素、重新計算，但這樣要處理每個圖磚，平移和縮放時還要重來。",[11,8177,8178],{},"其實需求只是「看起來」不同，不需要改變資料，所以直接在 canvas 元素上套用 CSS filter 就好：",[175,8180,8182],{"className":177,"code":8181,"language":179,"meta":180,"style":180},"updateFilterStyle() {\n  const canvas = document.querySelector('#imageMap .ol-layer canvas')\n  if (!canvas) return\n\n  canvas.style.filter = `\n    contrast(${this.contrast \u002F 100})\n    saturate(${this.saturation \u002F 100})\n    brightness(${this.exposure \u002F 100})\n  `\n}\n",[145,8183,8184,8191,8212,8225,8229,8239,8260,8278,8296,8301],{"__ignoreMap":180},[184,8185,8186,8189],{"class":186,"line":187},[184,8187,8188],{"class":194},"updateFilterStyle",[184,8190,5004],{"class":190},[184,8192,8193,8195,8198,8200,8202,8205,8207,8210],{"class":186,"line":222},[184,8194,2060],{"class":215},[184,8196,8197],{"class":251}," canvas",[184,8199,510],{"class":215},[184,8201,2690],{"class":190},[184,8203,8204],{"class":194},"querySelector",[184,8206,198],{"class":190},[184,8208,8209],{"class":201},"'#imageMap .ol-layer canvas'",[184,8211,255],{"class":190},[184,8213,8214,8216,8218,8220,8223],{"class":186,"line":245},[184,8215,1211],{"class":215},[184,8217,3125],{"class":190},[184,8219,5586],{"class":215},[184,8221,8222],{"class":190},"canvas) ",[184,8224,3846],{"class":215},[184,8226,8227],{"class":186,"line":454},[184,8228,457],{"emptyLinePlaceholder":317},[184,8230,8231,8234,8236],{"class":186,"line":460},[184,8232,8233],{"class":190},"  canvas.style.filter ",[184,8235,833],{"class":215},[184,8237,8238],{"class":201}," `\n",[184,8240,8241,8244,8247,8249,8252,8255,8258],{"class":186,"line":478},[184,8242,8243],{"class":201},"    contrast(${",[184,8245,8246],{"class":251},"this",[184,8248,2631],{"class":201},[184,8250,8251],{"class":190},"contrast",[184,8253,8254],{"class":215}," \u002F",[184,8256,8257],{"class":251}," 100",[184,8259,592],{"class":201},[184,8261,8262,8265,8267,8269,8272,8274,8276],{"class":186,"line":319},[184,8263,8264],{"class":201},"    saturate(${",[184,8266,8246],{"class":251},[184,8268,2631],{"class":201},[184,8270,8271],{"class":190},"saturation",[184,8273,8254],{"class":215},[184,8275,8257],{"class":251},[184,8277,592],{"class":201},[184,8279,8280,8283,8285,8287,8290,8292,8294],{"class":186,"line":519},[184,8281,8282],{"class":201},"    brightness(${",[184,8284,8246],{"class":251},[184,8286,2631],{"class":201},[184,8288,8289],{"class":190},"exposure",[184,8291,8254],{"class":215},[184,8293,8257],{"class":251},[184,8295,592],{"class":201},[184,8297,8298],{"class":186,"line":524},[184,8299,8300],{"class":201},"  `\n",[184,8302,8303],{"class":186,"line":553},[184,8304,708],{"class":190},[11,8306,8307],{},"介面用滑桿綁定數值，範圍 50 到 200，預設 100：",[175,8309,8311],{"className":814,"code":8310,"language":816,"meta":180,"style":180},"\u003Cv-slider v-model=\"contrast\" min=\"50\" max=\"200\" step=\"10\" @input=\"updateFilterStyle\" \u002F>\n",[145,8312,8313],{"__ignoreMap":180},[184,8314,8315,8317,8320,8322,8324,8327,8330,8332,8335,8338,8340,8343,8346,8348,8351,8354,8356,8359],{"class":186,"line":187},[184,8316,823],{"class":190},[184,8318,8319],{"class":826},"v-slider",[184,8321,8000],{"class":194},[184,8323,833],{"class":190},[184,8325,8326],{"class":201},"\"contrast\"",[184,8328,8329],{"class":194}," min",[184,8331,833],{"class":190},[184,8333,8334],{"class":201},"\"50\"",[184,8336,8337],{"class":194}," max",[184,8339,833],{"class":190},[184,8341,8342],{"class":201},"\"200\"",[184,8344,8345],{"class":194}," step",[184,8347,833],{"class":190},[184,8349,8350],{"class":201},"\"10\"",[184,8352,8353],{"class":194}," @input",[184,8355,833],{"class":190},[184,8357,8358],{"class":201},"\"updateFilterStyle\"",[184,8360,4727],{"class":190},[11,8362,8363],{},"這個做法有幾個好處：",[28,8365,8366,8372,8378],{},[31,8367,8368,8371],{},[15,8369,8370],{},"效能好","：濾鏡由瀏覽器處理，不用重新繪製圖磚。",[31,8373,8374,8377],{},[15,8375,8376],{},"不影響資料","：原始影像完全不變，重設數值就能還原。",[31,8379,8380,8383],{},[15,8381,8382],{},"互動即時","：拖動滑桿時馬上看到結果。",[594,8385,8386],{"id":8386},"範圍要限定",[11,8388,8389,8390,8393],{},"選擇器一定要指定到彈窗內的地圖。頁面上還有主地圖，如果只寫 ",[145,8391,8392],{},".ol-layer canvas","，就可能改到別的地圖。",[594,8395,8396],{"id":8396},"什麼時候不適合",[11,8398,8399],{},"CSS filter 只改變顯示結果。如果要匯出調整後的影像，或是做像素分析，就要用 Canvas API 或 OpenLayers 的圖層處理。另外它會作用在整個 canvas 上，同一張 canvas 裡的所有圖層都會受到影響。",[20,8401,8403],{"id":8402},"修-bug操作地圖時後面的視窗跟著動","修 Bug：操作地圖時，後面的視窗跟著動",[11,8405,8406],{},"這個專案有好幾個可拖曳的彈窗。使用者先開啟「訂單」視窗，再開啟「查看影像」視窗，然後在地圖上拖曳時，後面的訂單視窗竟然也跟著移動；拖曳比例尺的縮放工具時也一樣。",[594,8408,8409],{"id":8409},"原因",[11,8411,8412],{},"拖曳功能是這樣註冊的：",[175,8414,8416],{"className":177,"code":8415,"language":179,"meta":180,"style":180},"document.addEventListener('mousedown', (e) => {\n  if (e.target.tagName !== 'CANVAS') dragMouseDown(e)\n})\n",[145,8417,8418,8439,8459],{"__ignoreMap":180},[184,8419,8420,8422,8424,8426,8429,8431,8433,8435,8437],{"class":186,"line":187},[184,8421,191],{"class":190},[184,8423,195],{"class":194},[184,8425,198],{"class":190},[184,8427,8428],{"class":201},"'mousedown'",[184,8430,205],{"class":190},[184,8432,209],{"class":208},[184,8434,212],{"class":190},[184,8436,216],{"class":215},[184,8438,219],{"class":190},[184,8440,8441,8443,8446,8448,8451,8453,8456],{"class":186,"line":222},[184,8442,1211],{"class":215},[184,8444,8445],{"class":190}," (e.target.tagName ",[184,8447,2132],{"class":215},[184,8449,8450],{"class":201}," 'CANVAS'",[184,8452,212],{"class":190},[184,8454,8455],{"class":194},"dragMouseDown",[184,8457,8458],{"class":190},"(e)\n",[184,8460,8461],{"class":186,"line":245},[184,8462,592],{"class":190},[11,8464,8465],{},"問題有兩個：",[28,8467,8468,8477],{},[31,8469,8470,8471,7797,8473,8476],{},"監聽器掛在 ",[145,8472,3789],{},[15,8474,8475],{},"頁面上任何位置按下滑鼠都會觸發","，不只是彈窗本身。",[31,8478,8479],{},"判斷條件只排除了 canvas。地圖上的縮放工具不是 canvas，按下去就被當成要拖曳視窗。",[11,8481,8482],{},"換句話說，程式檢查的是「按到的不是什麼」，而不是「按到的是不是我」。",[594,8484,8485],{"id":8485},"修正",[11,8487,8488],{},"改成確認事件真的發生在這個彈窗內，而且不是在地圖上：",[175,8490,8492],{"className":177,"code":8491,"language":179,"meta":180,"style":180},"document.addEventListener('mousedown', (e) => {\n  const popup = document.getElementById('orderPopup')\n  const imageMap = document.querySelector('.imageMap')\n\n  const isInPopup = popup?.contains(e.target)\n  const isInMap = imageMap?.contains(e.target)\n\n  if (isInPopup && !isInMap && e.target.tagName !== 'CANVAS') {\n    dragMouseDown(e)\n  }\n})\n",[145,8493,8494,8514,8534,8554,8558,8576,8592,8596,8621,8628,8632],{"__ignoreMap":180},[184,8495,8496,8498,8500,8502,8504,8506,8508,8510,8512],{"class":186,"line":187},[184,8497,191],{"class":190},[184,8499,195],{"class":194},[184,8501,198],{"class":190},[184,8503,8428],{"class":201},[184,8505,205],{"class":190},[184,8507,209],{"class":208},[184,8509,212],{"class":190},[184,8511,216],{"class":215},[184,8513,219],{"class":190},[184,8515,8516,8518,8521,8523,8525,8527,8529,8532],{"class":186,"line":222},[184,8517,2060],{"class":215},[184,8519,8520],{"class":251}," popup",[184,8522,510],{"class":215},[184,8524,2690],{"class":190},[184,8526,3835],{"class":194},[184,8528,198],{"class":190},[184,8530,8531],{"class":201},"'orderPopup'",[184,8533,255],{"class":190},[184,8535,8536,8538,8541,8543,8545,8547,8549,8552],{"class":186,"line":245},[184,8537,2060],{"class":215},[184,8539,8540],{"class":251}," imageMap",[184,8542,510],{"class":215},[184,8544,2690],{"class":190},[184,8546,8204],{"class":194},[184,8548,198],{"class":190},[184,8550,8551],{"class":201},"'.imageMap'",[184,8553,255],{"class":190},[184,8555,8556],{"class":186,"line":454},[184,8557,457],{"emptyLinePlaceholder":317},[184,8559,8560,8562,8565,8567,8570,8573],{"class":186,"line":460},[184,8561,2060],{"class":215},[184,8563,8564],{"class":251}," isInPopup",[184,8566,510],{"class":215},[184,8568,8569],{"class":190}," popup?.",[184,8571,8572],{"class":194},"contains",[184,8574,8575],{"class":190},"(e.target)\n",[184,8577,8578,8580,8583,8585,8588,8590],{"class":186,"line":478},[184,8579,2060],{"class":215},[184,8581,8582],{"class":251}," isInMap",[184,8584,510],{"class":215},[184,8586,8587],{"class":190}," imageMap?.",[184,8589,8572],{"class":194},[184,8591,8575],{"class":190},[184,8593,8594],{"class":186,"line":319},[184,8595,457],{"emptyLinePlaceholder":317},[184,8597,8598,8600,8603,8605,8607,8610,8612,8615,8617,8619],{"class":186,"line":519},[184,8599,1211],{"class":215},[184,8601,8602],{"class":190}," (isInPopup ",[184,8604,6702],{"class":215},[184,8606,1331],{"class":215},[184,8608,8609],{"class":190},"isInMap ",[184,8611,6702],{"class":215},[184,8613,8614],{"class":190}," e.target.tagName ",[184,8616,2132],{"class":215},[184,8618,8450],{"class":201},[184,8620,1787],{"class":190},[184,8622,8623,8626],{"class":186,"line":524},[184,8624,8625],{"class":194},"    dragMouseDown",[184,8627,8458],{"class":190},[184,8629,8630],{"class":186,"line":553},[184,8631,1235],{"class":190},[184,8633,8634],{"class":186,"line":558},[184,8635,592],{"class":190},[11,8637,8638,8641,8642,8645],{},[145,8639,8640],{},"element.contains(target)"," 可以判斷點擊的目標是否在某個元素裡面，比單看 ",[145,8643,8644],{},"tagName"," 可靠。",[11,8647,8648],{},"這次修正後，地圖的平移和縮放就和視窗拖曳分開了，使用者調整影像時，後面的視窗不會再亂跑。",[594,8650,8651],{"id":8651},"更好的寫法",[11,8653,8654,8655,8657],{},"如果重新設計，我不會把監聽器掛在 ",[145,8656,3789],{},"，而是掛在彈窗的標題列，只有抓著標題列才能拖曳。這樣根本不用判斷目標，也能避免監聽器在元件關閉後還留著。",[20,8659,1580],{"id":1580},[28,8661,8662,8665,8671],{},[31,8663,8664],{},"先想需求是改變資料，還是只改變顯示。後者通常有更簡單的做法。",[31,8666,8667,8668,8670],{},"事件綁在 ",[145,8669,3789],{}," 時，要特別小心影響範圍。",[31,8672,8673],{},"條件寫成「確認是我」，比「排除不是我的」安全，因為排除清單永遠列不完。",[300,8675,8676],{},"html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}",{"title":180,"searchDepth":222,"depth":222,"links":8678},[8679,8683,8688],{"id":8171,"depth":222,"text":8172,"children":8680},[8681,8682],{"id":8386,"depth":245,"text":8386},{"id":8396,"depth":245,"text":8396},{"id":8402,"depth":222,"text":8403,"children":8684},[8685,8686,8687],{"id":8409,"depth":245,"text":8409},{"id":8485,"depth":245,"text":8485},{"id":8651,"depth":245,"text":8651},{"id":1580,"depth":222,"text":1580},"2025-12-01","在 OpenLayers 地圖上用 CSS filter 做即時的明暗、對比、飽和調整，並記錄彈窗內操作地圖時，後方視窗跟著移動的問題如何修正。",{},"\u002Fblog\u002Fopenlayers-image-adjust",{"title":8163,"description":8690},"blog\u002Fopenlayers-image-adjust",[8696,8697,8698],"OpenLayers","Canvas","Events","gwt78IkkoRd9FfjnkME3_P5nuwVi0Y3w5xDT54o1K3I",{"id":8701,"title":8702,"body":8703,"category":179,"date":9246,"description":9247,"draft":314,"extension":315,"meta":9248,"navigation":317,"path":9249,"readingTime":460,"seo":9250,"stem":9251,"tags":9252,"__hash__":9255},"blog\u002Fblog\u002Fhasownproperty-prototype-chain.md","從一個 TypeError 看 hasOwnProperty 與原型鏈",{"type":8,"value":8704,"toc":9239},[8705,8708,8790,8793,8849,8852,8855,8864,8911,8920,8934,8938,8945,8950,8991,8996,9032,9038,9059,9066,9072,9087,9090,9096,9105,9118,9124,9127,9139,9142,9151,9157,9161,9164,9167,9215,9217,9236],[11,8706,8707],{},"有一次專案新增一種影像類型，功能做完後，處理查詢結果的地方開始報錯。這段程式會把 API 回傳的欄位名稱，依影像類型對應成畫面要用的名稱：",[175,8709,8711],{"className":177,"code":8710,"language":179,"meta":180,"style":180},"const mapFields = (obj) => {\n  for (const field in obj) {\n    if (obj.hasOwnProperty(field) && mappings[obj.imagetype][field]) {\n      obj[mappings[obj.imagetype][field]] = obj[field]\n    }\n  }\n}\n",[145,8712,8713,8733,8750,8768,8778,8782,8786],{"__ignoreMap":180},[184,8714,8715,8717,8720,8722,8724,8727,8729,8731],{"class":186,"line":187},[184,8716,967],{"class":215},[184,8718,8719],{"class":194}," mapFields",[184,8721,510],{"class":215},[184,8723,3125],{"class":190},[184,8725,8726],{"class":208},"obj",[184,8728,212],{"class":190},[184,8730,216],{"class":215},[184,8732,219],{"class":190},[184,8734,8735,8738,8740,8742,8745,8747],{"class":186,"line":222},[184,8736,8737],{"class":215},"  for",[184,8739,3125],{"class":190},[184,8741,967],{"class":215},[184,8743,8744],{"class":251}," field",[184,8746,3720],{"class":215},[184,8748,8749],{"class":190}," obj) {\n",[184,8751,8752,8754,8757,8760,8763,8765],{"class":186,"line":245},[184,8753,4203],{"class":215},[184,8755,8756],{"class":190}," (obj.",[184,8758,8759],{"class":194},"hasOwnProperty",[184,8761,8762],{"class":190},"(field) ",[184,8764,6702],{"class":215},[184,8766,8767],{"class":190}," mappings[obj.imagetype][field]) {\n",[184,8769,8770,8773,8775],{"class":186,"line":454},[184,8771,8772],{"class":190},"      obj[mappings[obj.imagetype][field]] ",[184,8774,833],{"class":215},[184,8776,8777],{"class":190}," obj[field]\n",[184,8779,8780],{"class":186,"line":460},[184,8781,2921],{"class":190},[184,8783,8784],{"class":186,"line":478},[184,8785,1235],{"class":190},[184,8787,8788],{"class":186,"line":319},[184,8789,708],{"class":190},[11,8791,8792],{},"修這個問題時，我把判斷改成了這樣：",[175,8794,8796],{"className":177,"code":8795,"language":179,"meta":180,"style":180},"if (\n  Object.prototype.hasOwnProperty.call(obj, field) &&\n  mappings[obj.imagetype] &&\n  mappings[obj.imagetype][field]\n) {\n  \u002F\u002F ...\n}\n",[145,8797,8798,8804,8825,8832,8837,8841,8845],{"__ignoreMap":180},[184,8799,8800,8802],{"class":186,"line":187},[184,8801,1311],{"class":215},[184,8803,6746],{"class":190},[184,8805,8806,8809,8811,8814,8817,8820,8823],{"class":186,"line":222},[184,8807,8808],{"class":251},"  Object",[184,8810,2631],{"class":190},[184,8812,8813],{"class":251},"prototype",[184,8815,8816],{"class":190},".hasOwnProperty.",[184,8818,8819],{"class":194},"call",[184,8821,8822],{"class":190},"(obj, field) ",[184,8824,6759],{"class":215},[184,8826,8827,8830],{"class":186,"line":245},[184,8828,8829],{"class":190},"  mappings[obj.imagetype] ",[184,8831,6759],{"class":215},[184,8833,8834],{"class":186,"line":454},[184,8835,8836],{"class":190},"  mappings[obj.imagetype][field]\n",[184,8838,8839],{"class":186,"line":460},[184,8840,1787],{"class":190},[184,8842,8843],{"class":186,"line":478},[184,8844,6476],{"class":1031},[184,8846,8847],{"class":186,"line":319},[184,8848,708],{"class":190},[11,8850,8851],{},"這裡改了兩件事，但它們解決的是不同的問題。我一開始把兩者混在一起，後來才理清楚。",[20,8853,8854],{"id":8854},"先搞懂原型鏈",[11,8856,8857,8858,8861,8862,954],{},"JavaScript 的物件在找屬性時，自己身上沒有，就會往上一層的原型找，一路找到 ",[145,8859,8860],{},"Object.prototype","，最後是 ",[145,8863,6593],{},[175,8865,8867],{"className":177,"code":8866,"language":179,"meta":180,"style":180},"const obj = { name: 'Elio' }\n\nobj.name            \u002F\u002F 自己的屬性\nobj.toString()      \u002F\u002F 自己沒有，來自 Object.prototype\n",[145,8868,8869,8885,8889,8897],{"__ignoreMap":180},[184,8870,8871,8873,8876,8878,8880,8883],{"class":186,"line":187},[184,8872,967],{"class":215},[184,8874,8875],{"class":251}," obj",[184,8877,510],{"class":215},[184,8879,6910],{"class":190},[184,8881,8882],{"class":201},"'Elio'",[184,8884,2327],{"class":190},[184,8886,8887],{"class":186,"line":222},[184,8888,457],{"emptyLinePlaceholder":317},[184,8890,8891,8894],{"class":186,"line":245},[184,8892,8893],{"class":190},"obj.name            ",[184,8895,8896],{"class":1031},"\u002F\u002F 自己的屬性\n",[184,8898,8899,8902,8905,8908],{"class":186,"line":454},[184,8900,8901],{"class":190},"obj.",[184,8903,8904],{"class":194},"toString",[184,8906,8907],{"class":190},"()      ",[184,8909,8910],{"class":1031},"\u002F\u002F 自己沒有，來自 Object.prototype\n",[11,8912,8913,8914,8916,8917,8919],{},"這就是原型鏈。",[145,8915,8759],{}," 也不是物件自己的方法，而是從 ",[145,8918,8860],{}," 繼承來的。",[11,8921,8922,8923,8926,8927,8930,8931,8933],{},"它的作用是判斷屬性是不是物件",[15,8924,8925],{},"自己的","，這在 ",[145,8928,8929],{},"for...in"," 裡很重要，因為 ",[145,8932,8929],{}," 也會走訪原型鏈上可列舉的屬性。",[20,8935,8937],{"id":8936},"為什麼不直接呼叫-objhasownproperty","為什麼不直接呼叫 obj.hasOwnProperty",[11,8939,8940,8941,8944],{},"一般情況下 ",[145,8942,8943],{},"obj.hasOwnProperty()"," 都能用，但有兩種情況會出問題。",[11,8946,8947],{},[15,8948,8949],{},"物件沒有原型：",[175,8951,8953],{"className":177,"code":8952,"language":179,"meta":180,"style":180},"const obj = Object.create(null)\nobj.hasOwnProperty('a') \u002F\u002F TypeError: obj.hasOwnProperty is not a function\n",[145,8954,8955,8975],{"__ignoreMap":180},[184,8956,8957,8959,8961,8963,8966,8969,8971,8973],{"class":186,"line":187},[184,8958,967],{"class":215},[184,8960,8875],{"class":251},[184,8962,510],{"class":215},[184,8964,8965],{"class":190}," Object.",[184,8967,8968],{"class":194},"create",[184,8970,198],{"class":190},[184,8972,6593],{"class":251},[184,8974,255],{"class":190},[184,8976,8977,8979,8981,8983,8986,8988],{"class":186,"line":222},[184,8978,8901],{"class":190},[184,8980,8759],{"class":194},[184,8982,198],{"class":190},[184,8984,8985],{"class":201},"'a'",[184,8987,212],{"class":190},[184,8989,8990],{"class":1031},"\u002F\u002F TypeError: obj.hasOwnProperty is not a function\n",[11,8992,8993],{},[15,8994,8995],{},"物件有同名欄位：",[175,8997,8999],{"className":177,"code":8998,"language":179,"meta":180,"style":180},"const obj = { hasOwnProperty: 'oops' }\nobj.hasOwnProperty('a') \u002F\u002F TypeError\n",[145,9000,9001,9017],{"__ignoreMap":180},[184,9002,9003,9005,9007,9009,9012,9015],{"class":186,"line":187},[184,9004,967],{"class":215},[184,9006,8875],{"class":251},[184,9008,510],{"class":215},[184,9010,9011],{"class":190}," { hasOwnProperty: ",[184,9013,9014],{"class":201},"'oops'",[184,9016,2327],{"class":190},[184,9018,9019,9021,9023,9025,9027,9029],{"class":186,"line":222},[184,9020,8901],{"class":190},[184,9022,8759],{"class":194},[184,9024,198],{"class":190},[184,9026,8985],{"class":201},[184,9028,212],{"class":190},[184,9030,9031],{"class":1031},"\u002F\u002F TypeError\n",[11,9033,9034,9035,9037],{},"資料來自 API 時，你無法保證欄位名稱，所以比較安全的寫法是直接用原型鏈上的方法，再用 ",[145,9036,8819],{}," 指定檢查對象：",[175,9039,9041],{"className":177,"code":9040,"language":179,"meta":180,"style":180},"Object.prototype.hasOwnProperty.call(obj, field)\n",[145,9042,9043],{"__ignoreMap":180},[184,9044,9045,9048,9050,9052,9054,9056],{"class":186,"line":187},[184,9046,9047],{"class":251},"Object",[184,9049,2631],{"class":190},[184,9051,8813],{"class":251},[184,9053,8816],{"class":190},[184,9055,8819],{"class":194},[184,9057,9058],{"class":190},"(obj, field)\n",[11,9060,9061,9062,9065],{},"這樣就不依賴物件自己身上有什麼。ESLint 的 ",[145,9063,9064],{},"no-prototype-builtins"," 規則檢查的就是這件事。",[11,9067,9068,9069,3414],{},"現在也可以直接使用 ",[145,9070,9071],{},"Object.hasOwn",[175,9073,9075],{"className":177,"code":9074,"language":179,"meta":180,"style":180},"Object.hasOwn(obj, field)\n",[145,9076,9077],{"__ignoreMap":180},[184,9078,9079,9082,9085],{"class":186,"line":187},[184,9080,9081],{"class":190},"Object.",[184,9083,9084],{"class":194},"hasOwn",[184,9086,9058],{"class":190},[20,9088,9089],{"id":9089},"真正造成錯誤的原因",[11,9091,9092,9093,9095],{},"不過，這次的 TypeError 其實不是 ",[145,9094,8759],{}," 造成的，而是這一段：",[175,9097,9099],{"className":177,"code":9098,"language":179,"meta":180,"style":180},"mappings[obj.imagetype][field]\n",[145,9100,9101],{"__ignoreMap":180},[184,9102,9103],{"class":186,"line":187},[184,9104,9098],{"class":190},[11,9106,9107,9108,6964,9111,9113,9114,9117],{},"新增的影像類型沒有對應的設定，",[145,9109,9110],{},"mappings[obj.imagetype]",[145,9112,6967],{},"，再往下讀 ",[145,9115,9116],{},"[field]"," 就會報錯：",[175,9119,9122],{"className":9120,"code":9121,"language":567,"meta":180},[1640],"TypeError: Cannot read properties of undefined\n",[145,9123,9121],{"__ignoreMap":180},[11,9125,9126],{},"所以修好錯誤的，是多加的這個判斷：",[175,9128,9130],{"className":177,"code":9129,"language":179,"meta":180,"style":180},"mappings[obj.imagetype] &&\n",[145,9131,9132],{"__ignoreMap":180},[184,9133,9134,9137],{"class":186,"line":187},[184,9135,9136],{"class":190},"mappings[obj.imagetype] ",[184,9138,6759],{"class":215},[11,9140,9141],{},"也可以寫成可選串連：",[175,9143,9145],{"className":177,"code":9144,"language":179,"meta":180,"style":180},"mappings[obj.imagetype]?.[field]\n",[145,9146,9147],{"__ignoreMap":180},[184,9148,9149],{"class":186,"line":187},[184,9150,9144],{"class":190},[11,9152,9153,9156],{},[145,9154,9155],{},"Object.prototype.hasOwnProperty.call"," 則是順手提升穩健性。兩個改動都是好的，但如果面試被問到「為什麼會壞」，答案是前者。",[20,9158,9160],{"id":9159},"跳過之外要不要提醒","跳過之外，要不要提醒",[11,9162,9163],{},"可選串連讓程式不會崩潰，但也可能把問題藏起來。新的影像類型如果忘了加設定，欄位會默默沒有對應，畫面只是少了資料。",[11,9165,9166],{},"如果這是「不應該發生」的情況，在開發環境留下警告會更好：",[175,9168,9170],{"className":177,"code":9169,"language":179,"meta":180,"style":180},"if (!mappings[obj.imagetype]) {\n  console.warn(`缺少影像類型對應：${obj.imagetype}`)\n  return\n}\n",[145,9171,9172,9183,9206,9211],{"__ignoreMap":180},[184,9173,9174,9176,9178,9180],{"class":186,"line":187},[184,9175,1311],{"class":215},[184,9177,3125],{"class":190},[184,9179,5586],{"class":215},[184,9181,9182],{"class":190},"mappings[obj.imagetype]) {\n",[184,9184,9185,9187,9190,9192,9195,9197,9199,9202,9204],{"class":186,"line":222},[184,9186,225],{"class":190},[184,9188,9189],{"class":194},"warn",[184,9191,198],{"class":190},[184,9193,9194],{"class":201},"`缺少影像類型對應：${",[184,9196,8726],{"class":190},[184,9198,2631],{"class":201},[184,9200,9201],{"class":190},"imagetype",[184,9203,2231],{"class":201},[184,9205,255],{"class":190},[184,9207,9208],{"class":186,"line":245},[184,9209,9210],{"class":215},"  return\n",[184,9212,9213],{"class":186,"line":454},[184,9214,708],{"class":190},[20,9216,1580],{"id":1580},[28,9218,9219,9224,9230,9233],{},[31,9220,9221,9223],{},[145,9222,8759],{}," 來自原型鏈，直接呼叫要看物件有沒有這個方法。",[31,9225,9226,9227,9229],{},"看到 TypeError 要先看是誰是 ",[145,9228,6967],{},"，不要只看那一行最顯眼的方法。",[31,9231,9232],{},"新增類型時，要找出所有依賴類型對照的地方。",[31,9234,9235],{},"不報錯不等於沒問題，要想清楚失敗時希望看到什麼。",[300,9237,9238],{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}",{"title":180,"searchDepth":222,"depth":222,"links":9240},[9241,9242,9243,9244,9245],{"id":8854,"depth":222,"text":8854},{"id":8936,"depth":222,"text":8937},{"id":9089,"depth":222,"text":9089},{"id":9159,"depth":222,"text":9160},{"id":1580,"depth":222,"text":1580},"2025-10-15","新增資料類型後，地圖資料處理突然報錯。追查時順便釐清原型鏈、hasOwnProperty 為什麼要透過 Object.prototype 呼叫，以及真正造成錯誤的原因。",{},"\u002Fblog\u002Fhasownproperty-prototype-chain",{"title":8702,"description":9247},"blog\u002Fhasownproperty-prototype-chain",[9253,9254,323],"JavaScript","Prototype","F1meT6OwyQiSFa3CTi4Q5yYTyr_zRG-5KRroPqXFyH0",{"id":9257,"title":9258,"body":9259,"category":8151,"date":10583,"description":10584,"draft":314,"extension":315,"meta":10585,"navigation":317,"path":10586,"readingTime":478,"seo":10587,"stem":10588,"tags":10589,"__hash__":10591},"blog\u002Fblog\u002Fvuex-vs-pinia.md","Vuex 與 Pinia 的寫法差異：從 Vue 2 到 Vue 3",{"type":8,"value":9260,"toc":10569},[9261,9264,9267,9270,9312,9315,9318,9321,9389,9392,9475,9479,9482,9659,9662,9784,9794,9915,9918,9922,9925,10070,10082,10086,10174,10177,10181,10188,10270,10274,10277,10302,10305,10361,10372,10376,10379,10382,10407,10417,10516,10522,10525,10529,10561,10563,10566],[11,9262,9263],{},"我的 side project「遊戲揪團平台」是用 Vue 3 和 Pinia 寫的，當時只覺得狀態管理就是這樣。進公司後，因為同時接觸 Vue 2 和 Vue 3 的專案，才第一次碰到 Vuex，也才開始回頭想：為什麼它要多一層 mutation？Pinia 又為什麼拿掉了？",[11,9265,9266],{},"這篇用同一個計數器範例比較兩者的寫法，並記錄我從這個差異看到的事。",[20,9268,9269],{"id":9269},"版本怎麼對應",[81,9271,9272,9281],{},[84,9273,9274],{},[87,9275,9276,9278],{},[90,9277,3299],{},[90,9279,9280],{},"支援的 Vue 版本",[100,9282,9283,9290,9297,9305],{},[87,9284,9285,9288],{},[105,9286,9287],{},"Vuex 3",[105,9289,3483],{},[87,9291,9292,9295],{},[105,9293,9294],{},"Vuex 4",[105,9296,3484],{},[87,9298,9299,9302],{},[105,9300,9301],{},"Pinia 2",[105,9303,9304],{},"Vue 2、Vue 3",[87,9306,9307,9310],{},[105,9308,9309],{},"Pinia 3",[105,9311,3484],{},[11,9313,9314],{},"換句話說，升級到 Vue 3 不一定要換掉 Vuex，Vuex 4 的 API 大致相同。但新專案建議直接使用 Pinia，Vuex 目前也處於維護狀態。",[20,9316,9317],{"id":9317},"建立與註冊",[11,9319,9320],{},"Vue 2 搭配 Vuex 3 是建立實例，再傳給根元件：",[175,9322,9324],{"className":177,"code":9323,"language":179,"meta":180,"style":180},"import Vue from 'vue'\nimport Vuex from 'vuex'\n\nVue.use(Vuex)\n\nexport default new Vuex.Store({ \u002F* ... *\u002F })\n",[145,9325,9326,9338,9350,9354,9364,9368],{"__ignoreMap":180},[184,9327,9328,9330,9333,9335],{"class":186,"line":187},[184,9329,418],{"class":215},[184,9331,9332],{"class":190}," Vue ",[184,9334,424],{"class":215},[184,9336,9337],{"class":201}," 'vue'\n",[184,9339,9340,9342,9345,9347],{"class":186,"line":222},[184,9341,418],{"class":215},[184,9343,9344],{"class":190}," Vuex ",[184,9346,424],{"class":215},[184,9348,9349],{"class":201}," 'vuex'\n",[184,9351,9352],{"class":186,"line":245},[184,9353,457],{"emptyLinePlaceholder":317},[184,9355,9356,9359,9361],{"class":186,"line":454},[184,9357,9358],{"class":190},"Vue.",[184,9360,1194],{"class":194},[184,9362,9363],{"class":190},"(Vuex)\n",[184,9365,9366],{"class":186,"line":460},[184,9367,457],{"emptyLinePlaceholder":317},[184,9369,9370,9372,9374,9376,9379,9382,9384,9387],{"class":186,"line":478},[184,9371,1009],{"class":215},[184,9373,2042],{"class":215},[184,9375,1712],{"class":215},[184,9377,9378],{"class":190}," Vuex.",[184,9380,9381],{"class":194},"Store",[184,9383,2506],{"class":190},[184,9385,9386],{"class":1031},"\u002F* ... *\u002F",[184,9388,1372],{"class":190},[11,9390,9391],{},"Vue 3 改成函式寫法，Vuex 4 和 Pinia 都一樣：",[175,9393,9395],{"className":177,"code":9394,"language":179,"meta":180,"style":180},"\u002F\u002F Vuex 4\nconst store = createStore({ \u002F* ... *\u002F })\ncreateApp(App).use(store).mount('#app')\n\n\u002F\u002F Pinia\ncreateApp(App).use(createPinia()).mount('#app')\n",[145,9396,9397,9402,9420,9443,9447,9452],{"__ignoreMap":180},[184,9398,9399],{"class":186,"line":187},[184,9400,9401],{"class":1031},"\u002F\u002F Vuex 4\n",[184,9403,9404,9406,9409,9411,9414,9416,9418],{"class":186,"line":222},[184,9405,967],{"class":215},[184,9407,9408],{"class":251}," store",[184,9410,510],{"class":215},[184,9412,9413],{"class":194}," createStore",[184,9415,2506],{"class":190},[184,9417,9386],{"class":1031},[184,9419,1372],{"class":190},[184,9421,9422,9425,9428,9430,9433,9436,9438,9441],{"class":186,"line":245},[184,9423,9424],{"class":194},"createApp",[184,9426,9427],{"class":190},"(App).",[184,9429,1194],{"class":194},[184,9431,9432],{"class":190},"(store).",[184,9434,9435],{"class":194},"mount",[184,9437,198],{"class":190},[184,9439,9440],{"class":201},"'#app'",[184,9442,255],{"class":190},[184,9444,9445],{"class":186,"line":454},[184,9446,457],{"emptyLinePlaceholder":317},[184,9448,9449],{"class":186,"line":460},[184,9450,9451],{"class":1031},"\u002F\u002F Pinia\n",[184,9453,9454,9456,9458,9460,9462,9465,9467,9469,9471,9473],{"class":186,"line":478},[184,9455,9424],{"class":194},[184,9457,9427],{"class":190},[184,9459,1194],{"class":194},[184,9461,198],{"class":190},[184,9463,9464],{"class":194},"createPinia",[184,9466,570],{"class":190},[184,9468,9435],{"class":194},[184,9470,198],{"class":190},[184,9472,9440],{"class":201},[184,9474,255],{"class":190},[20,9476,9478],{"id":9477},"定義-store最大的差別是沒有-mutations","定義 store：最大的差別是沒有 mutations",[11,9480,9481],{},"Vuex 規定修改 state 要透過 mutation，非同步要放在 action：",[175,9483,9485],{"className":177,"code":9484,"language":179,"meta":180,"style":180},"export default {\n  namespaced: true,\n  state: () => ({ count: 0 }),\n  getters: {\n    double: state => state.count * 2,\n  },\n  mutations: {\n    SET_COUNT(state, value) {\n      state.count = value\n    },\n  },\n  actions: {\n    async fetchCount({ commit }) {\n      const { count } = await api.getCount()\n      commit('SET_COUNT', count)\n    },\n  },\n}\n",[145,9486,9487,9495,9504,9521,9526,9547,9551,9556,9572,9582,9586,9590,9595,9610,9634,9647,9651,9655],{"__ignoreMap":180},[184,9488,9489,9491,9493],{"class":186,"line":187},[184,9490,1009],{"class":215},[184,9492,2042],{"class":215},[184,9494,219],{"class":190},[184,9496,9497,9500,9502],{"class":186,"line":222},[184,9498,9499],{"class":190},"  namespaced: ",[184,9501,252],{"class":251},[184,9503,1077],{"class":190},[184,9505,9506,9509,9511,9513,9516,9518],{"class":186,"line":245},[184,9507,9508],{"class":194},"  state",[184,9510,5885],{"class":190},[184,9512,216],{"class":215},[184,9514,9515],{"class":190}," ({ count: ",[184,9517,1797],{"class":251},[184,9519,9520],{"class":190}," }),\n",[184,9522,9523],{"class":186,"line":454},[184,9524,9525],{"class":190},"  getters: {\n",[184,9527,9528,9531,9533,9535,9537,9540,9542,9545],{"class":186,"line":460},[184,9529,9530],{"class":194},"    double",[184,9532,675],{"class":190},[184,9534,6447],{"class":208},[184,9536,2216],{"class":215},[184,9538,9539],{"class":190}," state.count ",[184,9541,5907],{"class":215},[184,9543,9544],{"class":251}," 2",[184,9546,1077],{"class":190},[184,9548,9549],{"class":186,"line":478},[184,9550,2880],{"class":190},[184,9552,9553],{"class":186,"line":319},[184,9554,9555],{"class":190},"  mutations: {\n",[184,9557,9558,9561,9563,9565,9567,9570],{"class":186,"line":519},[184,9559,9560],{"class":194},"    SET_COUNT",[184,9562,198],{"class":190},[184,9564,6447],{"class":208},[184,9566,489],{"class":190},[184,9568,9569],{"class":208},"value",[184,9571,1787],{"class":190},[184,9573,9574,9577,9579],{"class":186,"line":524},[184,9575,9576],{"class":190},"      state.count ",[184,9578,833],{"class":215},[184,9580,9581],{"class":190}," value\n",[184,9583,9584],{"class":186,"line":553},[184,9585,8090],{"class":190},[184,9587,9588],{"class":186,"line":558},[184,9589,2880],{"class":190},[184,9591,9592],{"class":186,"line":583},[184,9593,9594],{"class":190},"  actions: {\n",[184,9596,9597,9600,9603,9605,9607],{"class":186,"line":589},[184,9598,9599],{"class":215},"    async",[184,9601,9602],{"class":194}," fetchCount",[184,9604,2506],{"class":190},[184,9606,6815],{"class":208},[184,9608,9609],{"class":190}," }) {\n",[184,9611,9612,9615,9617,9620,9622,9624,9626,9629,9632],{"class":186,"line":2790},[184,9613,9614],{"class":215},"      const",[184,9616,1504],{"class":190},[184,9618,9619],{"class":251},"count",[184,9621,1510],{"class":190},[184,9623,833],{"class":215},[184,9625,1515],{"class":215},[184,9627,9628],{"class":190}," api.",[184,9630,9631],{"class":194},"getCount",[184,9633,1694],{"class":190},[184,9635,9636,9639,9641,9644],{"class":186,"line":2795},[184,9637,9638],{"class":194},"      commit",[184,9640,198],{"class":190},[184,9642,9643],{"class":201},"'SET_COUNT'",[184,9645,9646],{"class":190},", count)\n",[184,9648,9649],{"class":186,"line":2800},[184,9650,8090],{"class":190},[184,9652,9653],{"class":186,"line":2807},[184,9654,2880],{"class":190},[184,9656,9657],{"class":186,"line":6850},[184,9658,708],{"class":190},[11,9660,9661],{},"Pinia 拿掉了 mutations，action 可以直接修改 state，同步和非同步都寫在一起：",[175,9663,9665],{"className":177,"code":9664,"language":179,"meta":180,"style":180},"export const useCounterStore = defineStore('counter', {\n  state: () => ({ count: 0 }),\n  getters: {\n    double: state => state.count * 2,\n  },\n  actions: {\n    async fetchCount() {\n      const { count } = await api.getCount()\n      this.count = count\n    },\n  },\n})\n",[145,9666,9667,9688,9702,9706,9724,9728,9732,9740,9760,9772,9776,9780],{"__ignoreMap":180},[184,9668,9669,9671,9673,9676,9678,9681,9683,9686],{"class":186,"line":187},[184,9670,1009],{"class":215},[184,9672,1012],{"class":215},[184,9674,9675],{"class":251}," useCounterStore",[184,9677,510],{"class":215},[184,9679,9680],{"class":194}," defineStore",[184,9682,198],{"class":190},[184,9684,9685],{"class":201},"'counter'",[184,9687,6823],{"class":190},[184,9689,9690,9692,9694,9696,9698,9700],{"class":186,"line":222},[184,9691,9508],{"class":194},[184,9693,5885],{"class":190},[184,9695,216],{"class":215},[184,9697,9515],{"class":190},[184,9699,1797],{"class":251},[184,9701,9520],{"class":190},[184,9703,9704],{"class":186,"line":245},[184,9705,9525],{"class":190},[184,9707,9708,9710,9712,9714,9716,9718,9720,9722],{"class":186,"line":454},[184,9709,9530],{"class":194},[184,9711,675],{"class":190},[184,9713,6447],{"class":208},[184,9715,2216],{"class":215},[184,9717,9539],{"class":190},[184,9719,5907],{"class":215},[184,9721,9544],{"class":251},[184,9723,1077],{"class":190},[184,9725,9726],{"class":186,"line":460},[184,9727,2880],{"class":190},[184,9729,9730],{"class":186,"line":478},[184,9731,9594],{"class":190},[184,9733,9734,9736,9738],{"class":186,"line":319},[184,9735,9599],{"class":215},[184,9737,9602],{"class":194},[184,9739,5004],{"class":190},[184,9741,9742,9744,9746,9748,9750,9752,9754,9756,9758],{"class":186,"line":519},[184,9743,9614],{"class":215},[184,9745,1504],{"class":190},[184,9747,9619],{"class":251},[184,9749,1510],{"class":190},[184,9751,833],{"class":215},[184,9753,1515],{"class":215},[184,9755,9628],{"class":190},[184,9757,9631],{"class":194},[184,9759,1694],{"class":190},[184,9761,9762,9764,9767,9769],{"class":186,"line":524},[184,9763,8103],{"class":251},[184,9765,9766],{"class":190},".count ",[184,9768,833],{"class":215},[184,9770,9771],{"class":190}," count\n",[184,9773,9774],{"class":186,"line":553},[184,9775,8090],{"class":190},[184,9777,9778],{"class":186,"line":558},[184,9779,2880],{"class":190},[184,9781,9782],{"class":186,"line":583},[184,9783,592],{"class":190},[11,9785,9786,9787,9790,9791,9793],{},"Pinia 也可以用類似 Composition API 的寫法，",[145,9788,9789],{},"ref"," 就是 state，",[145,9792,8058],{}," 就是 getter，函式就是 action：",[175,9795,9797],{"className":177,"code":9796,"language":179,"meta":180,"style":180},"export const useCounterStore = defineStore('counter', () => {\n  const count = ref(0)\n  const double = computed(() => count.value * 2)\n\n  async function fetchCount() {\n    count.value = (await api.getCount()).count\n  }\n\n  return { count, double, fetchCount }\n})\n",[145,9798,9799,9821,9839,9863,9867,9878,9896,9900,9904,9911],{"__ignoreMap":180},[184,9800,9801,9803,9805,9807,9809,9811,9813,9815,9817,9819],{"class":186,"line":187},[184,9802,1009],{"class":215},[184,9804,1012],{"class":215},[184,9806,9675],{"class":251},[184,9808,510],{"class":215},[184,9810,9680],{"class":194},[184,9812,198],{"class":190},[184,9814,9685],{"class":201},[184,9816,471],{"class":190},[184,9818,216],{"class":215},[184,9820,219],{"class":190},[184,9822,9823,9825,9828,9830,9833,9835,9837],{"class":186,"line":222},[184,9824,2060],{"class":215},[184,9826,9827],{"class":251}," count",[184,9829,510],{"class":215},[184,9831,9832],{"class":194}," ref",[184,9834,198],{"class":190},[184,9836,1797],{"class":251},[184,9838,255],{"class":190},[184,9840,9841,9843,9846,9848,9850,9852,9854,9857,9859,9861],{"class":186,"line":245},[184,9842,2060],{"class":215},[184,9844,9845],{"class":251}," double",[184,9847,510],{"class":215},[184,9849,2378],{"class":194},[184,9851,1480],{"class":190},[184,9853,216],{"class":215},[184,9855,9856],{"class":190}," count.value ",[184,9858,5907],{"class":215},[184,9860,9544],{"class":251},[184,9862,255],{"class":190},[184,9864,9865],{"class":186,"line":454},[184,9866,457],{"emptyLinePlaceholder":317},[184,9868,9869,9872,9874,9876],{"class":186,"line":460},[184,9870,9871],{"class":215},"  async",[184,9873,1760],{"class":215},[184,9875,9602],{"class":194},[184,9877,5004],{"class":190},[184,9879,9880,9883,9885,9887,9889,9891,9893],{"class":186,"line":478},[184,9881,9882],{"class":190},"    count.value ",[184,9884,833],{"class":215},[184,9886,3125],{"class":190},[184,9888,3108],{"class":215},[184,9890,9628],{"class":190},[184,9892,9631],{"class":194},[184,9894,9895],{"class":190},"()).count\n",[184,9897,9898],{"class":186,"line":319},[184,9899,1235],{"class":190},[184,9901,9902],{"class":186,"line":519},[184,9903,457],{"emptyLinePlaceholder":317},[184,9905,9906,9908],{"class":186,"line":524},[184,9907,1240],{"class":215},[184,9909,9910],{"class":190}," { count, double, fetchCount }\n",[184,9912,9913],{"class":186,"line":553},[184,9914,592],{"class":190},[20,9916,9917],{"id":9917},"在元件裡使用",[594,9919,9921],{"id":9920},"options-api","Options API",[11,9923,9924],{},"Vue 3 專案不一定都改用 Composition API。使用 Options API 時，Vuex 和 Pinia 都有對應的輔助函式，寫法很接近：",[175,9926,9928],{"className":177,"code":9927,"language":179,"meta":180,"style":180},"\u002F\u002F Vuex\ncomputed: {\n  ...mapState('counter', ['count']),\n  ...mapGetters('counter', ['double']),\n},\nmethods: {\n  ...mapActions('counter', ['fetchCount']),\n},\n\n\u002F\u002F Pinia\ncomputed: {\n  ...mapState(useCounterStore, ['count', 'double']),\n},\nmethods: {\n  ...mapActions(useCounterStore, ['fetchCount']),\n},\n",[145,9929,9930,9935,9941,9962,9980,9984,9991,10009,10013,10017,10021,10027,10044,10048,10054,10066],{"__ignoreMap":180},[184,9931,9932],{"class":186,"line":187},[184,9933,9934],{"class":1031},"\u002F\u002F Vuex\n",[184,9936,9937,9939],{"class":186,"line":222},[184,9938,8058],{"class":194},[184,9940,2841],{"class":190},[184,9942,9943,9946,9949,9951,9953,9956,9959],{"class":186,"line":245},[184,9944,9945],{"class":215},"  ...",[184,9947,9948],{"class":194},"mapState",[184,9950,198],{"class":190},[184,9952,9685],{"class":201},[184,9954,9955],{"class":190},", [",[184,9957,9958],{"class":201},"'count'",[184,9960,9961],{"class":190},"]),\n",[184,9963,9964,9966,9969,9971,9973,9975,9978],{"class":186,"line":454},[184,9965,9945],{"class":215},[184,9967,9968],{"class":194},"mapGetters",[184,9970,198],{"class":190},[184,9972,9685],{"class":201},[184,9974,9955],{"class":190},[184,9976,9977],{"class":201},"'double'",[184,9979,9961],{"class":190},[184,9981,9982],{"class":186,"line":460},[184,9983,6600],{"class":190},[184,9985,9986,9989],{"class":186,"line":478},[184,9987,9988],{"class":194},"methods",[184,9990,2841],{"class":190},[184,9992,9993,9995,9998,10000,10002,10004,10007],{"class":186,"line":319},[184,9994,9945],{"class":215},[184,9996,9997],{"class":194},"mapActions",[184,9999,198],{"class":190},[184,10001,9685],{"class":201},[184,10003,9955],{"class":190},[184,10005,10006],{"class":201},"'fetchCount'",[184,10008,9961],{"class":190},[184,10010,10011],{"class":186,"line":519},[184,10012,6600],{"class":190},[184,10014,10015],{"class":186,"line":524},[184,10016,457],{"emptyLinePlaceholder":317},[184,10018,10019],{"class":186,"line":553},[184,10020,9451],{"class":1031},[184,10022,10023,10025],{"class":186,"line":558},[184,10024,8058],{"class":194},[184,10026,2841],{"class":190},[184,10028,10029,10031,10033,10036,10038,10040,10042],{"class":186,"line":583},[184,10030,9945],{"class":215},[184,10032,9948],{"class":194},[184,10034,10035],{"class":190},"(useCounterStore, [",[184,10037,9958],{"class":201},[184,10039,489],{"class":190},[184,10041,9977],{"class":201},[184,10043,9961],{"class":190},[184,10045,10046],{"class":186,"line":589},[184,10047,6600],{"class":190},[184,10049,10050,10052],{"class":186,"line":2790},[184,10051,9988],{"class":194},[184,10053,2841],{"class":190},[184,10055,10056,10058,10060,10062,10064],{"class":186,"line":2795},[184,10057,9945],{"class":215},[184,10059,9997],{"class":194},[184,10061,10035],{"class":190},[184,10063,10006],{"class":201},[184,10065,9961],{"class":190},[184,10067,10068],{"class":186,"line":2800},[184,10069,6600],{"class":190},[11,10071,10072,10073,10075,10076,10078,10079,954],{},"要注意 Pinia 沒有 ",[145,10074,9968],{}," 的必要，getter 也用 ",[145,10077,9948],{}," 取得。如果要在元件裡修改 state，要改用 ",[145,10080,10081],{},"mapWritableState",[594,10083,10085],{"id":10084},"composition-api","Composition API",[175,10087,10089],{"className":177,"code":10088,"language":179,"meta":180,"style":180},"\u002F\u002F Vuex 4\nconst store = useStore()\nconst count = computed(() => store.state.counter.count)\nstore.dispatch('counter\u002FfetchCount')\n\n\u002F\u002F Pinia\nconst counter = useCounterStore()\ncounter.count\ncounter.fetchCount()\n",[145,10090,10091,10095,10108,10125,10138,10142,10146,10159,10164],{"__ignoreMap":180},[184,10092,10093],{"class":186,"line":187},[184,10094,9401],{"class":1031},[184,10096,10097,10099,10101,10103,10106],{"class":186,"line":222},[184,10098,967],{"class":215},[184,10100,9408],{"class":251},[184,10102,510],{"class":215},[184,10104,10105],{"class":194}," useStore",[184,10107,1694],{"class":190},[184,10109,10110,10112,10114,10116,10118,10120,10122],{"class":186,"line":245},[184,10111,967],{"class":215},[184,10113,9827],{"class":251},[184,10115,510],{"class":215},[184,10117,2378],{"class":194},[184,10119,1480],{"class":190},[184,10121,216],{"class":215},[184,10123,10124],{"class":190}," store.state.counter.count)\n",[184,10126,10127,10129,10131,10133,10136],{"class":186,"line":454},[184,10128,6887],{"class":190},[184,10130,5964],{"class":194},[184,10132,198],{"class":190},[184,10134,10135],{"class":201},"'counter\u002FfetchCount'",[184,10137,255],{"class":190},[184,10139,10140],{"class":186,"line":460},[184,10141,457],{"emptyLinePlaceholder":317},[184,10143,10144],{"class":186,"line":478},[184,10145,9451],{"class":1031},[184,10147,10148,10150,10153,10155,10157],{"class":186,"line":319},[184,10149,967],{"class":215},[184,10151,10152],{"class":251}," counter",[184,10154,510],{"class":215},[184,10156,9675],{"class":194},[184,10158,1694],{"class":190},[184,10160,10161],{"class":186,"line":519},[184,10162,10163],{"class":190},"counter.count\n",[184,10165,10166,10169,10172],{"class":186,"line":524},[184,10167,10168],{"class":190},"counter.",[184,10170,10171],{"class":194},"fetchCount",[184,10173,1694],{"class":190},[11,10175,10176],{},"Pinia 少了字串路徑，打錯名稱時編輯器和 TypeScript 比較容易提示。",[20,10178,10180],{"id":10179},"最常踩的坑解構失去響應式","最常踩的坑：解構失去響應式",[11,10182,10183,10184,10187],{},"store 是 reactive 物件，直接解構 state 會失去響應式。要用 ",[145,10185,10186],{},"storeToRefs","，action 則可以直接解構：",[175,10189,10191],{"className":177,"code":10190,"language":179,"meta":180,"style":180},"const counter = useCounterStore()\n\nconst { count } = counter \u002F\u002F 錯誤：不會隨 store 更新\nconst { count, double } = storeToRefs(counter) \u002F\u002F 正確\nconst { fetchCount } = counter \u002F\u002F action 可以直接解構\n",[145,10192,10193,10205,10209,10227,10253],{"__ignoreMap":180},[184,10194,10195,10197,10199,10201,10203],{"class":186,"line":187},[184,10196,967],{"class":215},[184,10198,10152],{"class":251},[184,10200,510],{"class":215},[184,10202,9675],{"class":194},[184,10204,1694],{"class":190},[184,10206,10207],{"class":186,"line":222},[184,10208,457],{"emptyLinePlaceholder":317},[184,10210,10211,10213,10215,10217,10219,10221,10224],{"class":186,"line":245},[184,10212,967],{"class":215},[184,10214,1504],{"class":190},[184,10216,9619],{"class":251},[184,10218,1510],{"class":190},[184,10220,833],{"class":215},[184,10222,10223],{"class":190}," counter ",[184,10225,10226],{"class":1031},"\u002F\u002F 錯誤：不會隨 store 更新\n",[184,10228,10229,10231,10233,10235,10237,10240,10242,10244,10247,10250],{"class":186,"line":454},[184,10230,967],{"class":215},[184,10232,1504],{"class":190},[184,10234,9619],{"class":251},[184,10236,489],{"class":190},[184,10238,10239],{"class":251},"double",[184,10241,1510],{"class":190},[184,10243,833],{"class":215},[184,10245,10246],{"class":194}," storeToRefs",[184,10248,10249],{"class":190},"(counter) ",[184,10251,10252],{"class":1031},"\u002F\u002F 正確\n",[184,10254,10255,10257,10259,10261,10263,10265,10267],{"class":186,"line":460},[184,10256,967],{"class":215},[184,10258,1504],{"class":190},[184,10260,10171],{"class":251},[184,10262,1510],{"class":190},[184,10264,833],{"class":215},[184,10266,10223],{"class":190},[184,10268,10269],{"class":1031},"\u002F\u002F action 可以直接解構\n",[20,10271,10273],{"id":10272},"模組與-store-之間的呼叫","模組與 store 之間的呼叫",[11,10275,10276],{},"Vuex 只有一個 store，再用 modules 拆分，呼叫其他模組要靠字串路徑：",[175,10278,10280],{"className":177,"code":10279,"language":179,"meta":180,"style":180},"dispatch('user\u002FfetchProfile', null, { root: true })\n",[145,10281,10282],{"__ignoreMap":180},[184,10283,10284,10286,10288,10291,10293,10295,10298,10300],{"class":186,"line":187},[184,10285,5964],{"class":194},[184,10287,198],{"class":190},[184,10289,10290],{"class":201},"'user\u002FfetchProfile'",[184,10292,489],{"class":190},[184,10294,6593],{"class":251},[184,10296,10297],{"class":190},", { root: ",[184,10299,252],{"class":251},[184,10301,1372],{"class":190},[11,10303,10304],{},"Pinia 沒有 modules，每個 store 各自獨立，需要時直接引入：",[175,10306,10308],{"className":177,"code":10307,"language":179,"meta":180,"style":180},"actions: {\n  async checkout() {\n    const user = useUserStore()\n    await user.fetchProfile()\n  },\n}\n",[145,10309,10310,10317,10327,10341,10353,10357],{"__ignoreMap":180},[184,10311,10312,10315],{"class":186,"line":187},[184,10313,10314],{"class":194},"actions",[184,10316,2841],{"class":190},[184,10318,10319,10322,10325],{"class":186,"line":222},[184,10320,10321],{"class":190},"  async ",[184,10323,10324],{"class":194},"checkout",[184,10326,5004],{"class":190},[184,10328,10329,10331,10334,10336,10339],{"class":186,"line":245},[184,10330,504],{"class":215},[184,10332,10333],{"class":251}," user",[184,10335,510],{"class":215},[184,10337,10338],{"class":194}," useUserStore",[184,10340,1694],{"class":190},[184,10342,10343,10345,10348,10351],{"class":186,"line":454},[184,10344,527],{"class":215},[184,10346,10347],{"class":190}," user.",[184,10349,10350],{"class":194},"fetchProfile",[184,10352,1694],{"class":190},[184,10354,10355],{"class":186,"line":460},[184,10356,2880],{"class":190},[184,10358,10359],{"class":186,"line":478},[184,10360,708],{"class":190},[11,10362,10363,10364,10367,10368,10371],{},"要注意 ",[145,10365,10366],{},"useUserStore()"," 要在函式裡呼叫，不能寫在檔案最上層。在 router guard 這類元件外的地方也一樣，要等 ",[145,10369,10370],{},"app.use(pinia)"," 之後才能使用。",[20,10373,10375],{"id":10374},"我的體會vuex-為什麼比較繁瑣","我的體會：Vuex 為什麼比較繁瑣",[11,10377,10378],{},"在揪團平台裡，我用 Setup Store 管理多步驟的建立揪團表單，再搭配持久化外掛保留資料。要改狀態就直接改，寫起來很直覺。",[11,10380,10381],{},"接觸公司的 Vuex 專案後，我的第一個感覺就是繁瑣。同樣是修改一個值，Pinia 可以直接改：",[175,10383,10385],{"className":177,"code":10384,"language":179,"meta":180,"style":180},"const counter = useCounterStore()\ncounter.count++\n",[145,10386,10387,10399],{"__ignoreMap":180},[184,10388,10389,10391,10393,10395,10397],{"class":186,"line":187},[184,10390,967],{"class":215},[184,10392,10152],{"class":251},[184,10394,510],{"class":215},[184,10396,9675],{"class":194},[184,10398,1694],{"class":190},[184,10400,10401,10404],{"class":186,"line":222},[184,10402,10403],{"class":190},"counter.count",[184,10405,10406],{"class":215},"++\n",[11,10408,10409,10410,10412,10413,10416],{},"Vuex 則要先定義 mutation，再從元件呼叫它。使用 Options API 時，可以在 ",[145,10411,9988],{}," 裡對應進來，或直接用 ",[145,10414,10415],{},"this.$store.commit","，公司專案裡兩種寫法我都看過：",[175,10418,10420],{"className":177,"code":10419,"language":179,"meta":180,"style":180},"\u002F\u002F store\nmutations: {\n  INCREMENT(state) {\n    state.count++\n  },\n},\n\n\u002F\u002F 元件：用 mapMutations 對應到 methods\nmethods: {\n  ...mapMutations('counter', ['INCREMENT']),\n},\n\n\u002F\u002F 元件：或直接 commit\nthis.$store.commit('counter\u002FINCREMENT')\n",[145,10421,10422,10427,10433,10440,10447,10451,10455,10459,10464,10470,10488,10492,10496,10501],{"__ignoreMap":180},[184,10423,10424],{"class":186,"line":187},[184,10425,10426],{"class":1031},"\u002F\u002F store\n",[184,10428,10429,10431],{"class":186,"line":222},[184,10430,6605],{"class":194},[184,10432,2841],{"class":190},[184,10434,10435,10438],{"class":186,"line":245},[184,10436,10437],{"class":194},"  INCREMENT",[184,10439,6637],{"class":190},[184,10441,10442,10445],{"class":186,"line":454},[184,10443,10444],{"class":190},"    state.count",[184,10446,10406],{"class":215},[184,10448,10449],{"class":186,"line":460},[184,10450,2880],{"class":190},[184,10452,10453],{"class":186,"line":478},[184,10454,6600],{"class":190},[184,10456,10457],{"class":186,"line":319},[184,10458,457],{"emptyLinePlaceholder":317},[184,10460,10461],{"class":186,"line":519},[184,10462,10463],{"class":1031},"\u002F\u002F 元件：用 mapMutations 對應到 methods\n",[184,10465,10466,10468],{"class":186,"line":524},[184,10467,9988],{"class":194},[184,10469,2841],{"class":190},[184,10471,10472,10474,10477,10479,10481,10483,10486],{"class":186,"line":553},[184,10473,9945],{"class":215},[184,10475,10476],{"class":194},"mapMutations",[184,10478,198],{"class":190},[184,10480,9685],{"class":201},[184,10482,9955],{"class":190},[184,10484,10485],{"class":201},"'INCREMENT'",[184,10487,9961],{"class":190},[184,10489,10490],{"class":186,"line":558},[184,10491,6600],{"class":190},[184,10493,10494],{"class":186,"line":583},[184,10495,457],{"emptyLinePlaceholder":317},[184,10497,10498],{"class":186,"line":589},[184,10499,10500],{"class":1031},"\u002F\u002F 元件：或直接 commit\n",[184,10502,10503,10505,10507,10509,10511,10514],{"class":186,"line":2790},[184,10504,8246],{"class":251},[184,10506,8106],{"class":190},[184,10508,6815],{"class":194},[184,10510,198],{"class":190},[184,10512,10513],{"class":201},"'counter\u002FINCREMENT'",[184,10515,255],{"class":190},[11,10517,10518,10519,10521],{},"需要非同步時還要再多寫一個 action，由 action 去 ",[145,10520,6815],{},"。整個流程多了好幾個地方要寫。",[11,10523,10524],{},"後來我才理解這層規定的目的：把所有修改集中在 mutation，狀態的變化就有固定的入口，比較容易追蹤。Pinia 拿掉這層後寫起來更自由，但任何地方都能直接改 state，所以規範要由開發者自己維持，例如把複雜的修改集中在 action 裡。",[20,10526,10528],{"id":10527},"如果要從-vuex-換到-pinia","如果要從 Vuex 換到 Pinia",[28,10530,10531,10537,10540,10543,10548,10554],{},[31,10532,10533,10534,10536],{},"把 ",[145,10535,6815],{}," 改成直接修改 state，或呼叫 action。",[31,10538,10539],{},"把字串路徑改成 store 函式。",[31,10541,10542],{},"把一個大 store 的 modules 拆成多個獨立 store。",[31,10544,10545,10546,954],{},"解構 state 的地方都改用 ",[145,10547,10186],{},[31,10549,10550,10553],{},[145,10551,10552],{},"$reset()"," 只有 Option Store 內建，Setup Store 要自己寫重置函式。",[31,10555,10556,10557,10560],{},"持久化改用 ",[145,10558,10559],{},"pinia-plugin-persistedstate"," 這類 Pinia 的外掛。",[20,10562,295],{"id":295},[11,10564,10565],{},"Vuex 的重點是流程要統一，所有修改都走 mutation。Pinia 的重點是寫法更少、型別更好，把 store 當成一般的函式和資料來用。先用過 Pinia 再回頭看 Vuex，讓我理解到一個框架的「麻煩」，常常是在替團隊解決另一個問題。",[300,10567,10568],{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}",{"title":180,"searchDepth":222,"depth":222,"links":10570},[10571,10572,10573,10574,10578,10579,10580,10581,10582],{"id":9269,"depth":222,"text":9269},{"id":9317,"depth":222,"text":9317},{"id":9477,"depth":222,"text":9478},{"id":9917,"depth":222,"text":9917,"children":10575},[10576,10577],{"id":9920,"depth":245,"text":9921},{"id":10084,"depth":245,"text":10085},{"id":10179,"depth":222,"text":10180},{"id":10272,"depth":222,"text":10273},{"id":10374,"depth":222,"text":10375},{"id":10527,"depth":222,"text":10528},{"id":295,"depth":222,"text":295},"2025-08-20","比較 Vuex 3、Vuex 4 與 Pinia 在建立 store、元件取用、模組拆分上的寫法，並整理遷移時容易出錯的地方。",{},"\u002Fblog\u002Fvuex-vs-pinia",{"title":9258,"description":10584},"blog\u002Fvuex-vs-pinia",[3302,3305,10590],"Migration","iRV92aLVNj9tI-boGbSGmm4UehFmRa4U6koAMevoGOA",{"id":10593,"title":10594,"body":10595,"category":8151,"date":11243,"description":11244,"draft":314,"extension":315,"meta":11245,"navigation":317,"path":11246,"readingTime":524,"seo":11247,"stem":11248,"tags":11249,"__hash__":11250},"blog\u002Fblog\u002Fvue2-vs-vue3-components.md","Vue 2 與 Vue 3 元件生命週期與雙向綁定差異解析",{"type":8,"value":10596,"toc":11235},[10597,10600,10604,10619,10689,10695,10782,10785,10893,10904,10908,10931,10981,11098,11114,11118,11170,11173,11224,11226,11232],[11,10598,10599],{},"從 Vue 2 遷移到 Vue 3，最容易出問題的不是語法本身，而是「預設行為改變」：同樣的程式碼在新版仍能跑，但行為不一樣。這篇整理元件層級最常碰到的幾個差異，以及遷移時的檢查重點。",[20,10601,10603],{"id":10602},"響應式系統defineproperty-到-proxy","響應式系統：defineProperty 到 Proxy",[11,10605,10606,10607,10610,10611,10614,10615,10618],{},"Vue 2 用 ",[145,10608,10609],{},"Object.defineProperty"," 攔截屬性，所以有幾個限制：新增屬性、用索引改陣列元素，都偵測不到，必須使用 ",[145,10612,10613],{},"Vue.set","。Vue 3 改用 ",[145,10616,10617],{},"Proxy","，這些限制消失了。",[175,10620,10622],{"className":177,"code":10621,"language":179,"meta":180,"style":180},"\u002F\u002F Vue 2：新增屬性不會觸發更新\nthis.user.age = 30        \u002F\u002F 無響應\nthis.$set(this.user, 'age', 30)\n\n\u002F\u002F Vue 3：直接指定即可\nstate.user.age = 30\n",[145,10623,10624,10629,10644,10670,10674,10679],{"__ignoreMap":180},[184,10625,10626],{"class":186,"line":187},[184,10627,10628],{"class":1031},"\u002F\u002F Vue 2：新增屬性不會觸發更新\n",[184,10630,10631,10633,10636,10638,10641],{"class":186,"line":222},[184,10632,8246],{"class":251},[184,10634,10635],{"class":190},".user.age ",[184,10637,833],{"class":215},[184,10639,10640],{"class":251}," 30",[184,10642,10643],{"class":1031},"        \u002F\u002F 無響應\n",[184,10645,10646,10648,10650,10653,10655,10657,10660,10663,10665,10668],{"class":186,"line":245},[184,10647,8246],{"class":251},[184,10649,2631],{"class":190},[184,10651,10652],{"class":194},"$set",[184,10654,198],{"class":190},[184,10656,8246],{"class":251},[184,10658,10659],{"class":190},".user, ",[184,10661,10662],{"class":201},"'age'",[184,10664,489],{"class":190},[184,10666,10667],{"class":251},"30",[184,10669,255],{"class":190},[184,10671,10672],{"class":186,"line":454},[184,10673,457],{"emptyLinePlaceholder":317},[184,10675,10676],{"class":186,"line":460},[184,10677,10678],{"class":1031},"\u002F\u002F Vue 3：直接指定即可\n",[184,10680,10681,10684,10686],{"class":186,"line":478},[184,10682,10683],{"class":190},"state.user.age ",[184,10685,833],{"class":215},[184,10687,10688],{"class":251}," 30\n",[11,10690,10691,10692,954],{},"但 Proxy 帶來另一個陷阱：",[15,10693,10694],{},"解構會丟失響應性",[175,10696,10698],{"className":177,"code":10697,"language":179,"meta":180,"style":180},"import { reactive, toRefs } from 'vue'\n\nconst state = reactive({ count: 0 })\n\nconst { count } = state          \u002F\u002F count 只是一個普通數字，不再響應\nconst { count: c } = toRefs(state) \u002F\u002F c 是 ref，保持響應\n",[145,10699,10700,10711,10715,10734,10738,10756],{"__ignoreMap":180},[184,10701,10702,10704,10707,10709],{"class":186,"line":187},[184,10703,418],{"class":215},[184,10705,10706],{"class":190}," { reactive, toRefs } ",[184,10708,424],{"class":215},[184,10710,9337],{"class":201},[184,10712,10713],{"class":186,"line":222},[184,10714,457],{"emptyLinePlaceholder":317},[184,10716,10717,10719,10722,10724,10727,10730,10732],{"class":186,"line":245},[184,10718,967],{"class":215},[184,10720,10721],{"class":251}," state",[184,10723,510],{"class":215},[184,10725,10726],{"class":194}," reactive",[184,10728,10729],{"class":190},"({ count: ",[184,10731,1797],{"class":251},[184,10733,1372],{"class":190},[184,10735,10736],{"class":186,"line":454},[184,10737,457],{"emptyLinePlaceholder":317},[184,10739,10740,10742,10744,10746,10748,10750,10753],{"class":186,"line":460},[184,10741,967],{"class":215},[184,10743,1504],{"class":190},[184,10745,9619],{"class":251},[184,10747,1510],{"class":190},[184,10749,833],{"class":215},[184,10751,10752],{"class":190}," state          ",[184,10754,10755],{"class":1031},"\u002F\u002F count 只是一個普通數字，不再響應\n",[184,10757,10758,10760,10762,10764,10766,10769,10771,10773,10776,10779],{"class":186,"line":478},[184,10759,967],{"class":215},[184,10761,1504],{"class":190},[184,10763,9619],{"class":208},[184,10765,675],{"class":190},[184,10767,10768],{"class":251},"c",[184,10770,1510],{"class":190},[184,10772,833],{"class":215},[184,10774,10775],{"class":194}," toRefs",[184,10777,10778],{"class":190},"(state) ",[184,10780,10781],{"class":1031},"\u002F\u002F c 是 ref，保持響應\n",[20,10783,10784],{"id":10784},"生命週期對照",[81,10786,10787,10798],{},[84,10788,10789],{},[87,10790,10791,10793,10796],{},[90,10792,3483],{},[90,10794,10795],{},"Vue 3（Options API）",[90,10797,10085],{},[100,10799,10800,10820,10839,10859,10876],{},[87,10801,10802,10810,10813],{},[105,10803,10804,803,10807],{},[145,10805,10806],{},"beforeCreate",[145,10808,10809],{},"created",[105,10811,10812],{},"同左",[105,10814,10815,10816,10819],{},"直接寫在 ",[145,10817,10818],{},"setup()"," 內",[87,10821,10822,10830,10832],{},[105,10823,10824,803,10827],{},[145,10825,10826],{},"beforeMount",[145,10828,10829],{},"mounted",[105,10831,10812],{},[105,10833,10834,803,10837],{},[145,10835,10836],{},"onBeforeMount",[145,10838,3680],{},[87,10840,10841,10849,10851],{},[105,10842,10843,803,10846],{},[145,10844,10845],{},"beforeUpdate",[145,10847,10848],{},"updated",[105,10850,10812],{},[105,10852,10853,803,10856],{},[145,10854,10855],{},"onBeforeUpdate",[145,10857,10858],{},"onUpdated",[87,10860,10861,10866,10871],{},[105,10862,10863],{},[145,10864,10865],{},"beforeDestroy",[105,10867,10868],{},[145,10869,10870],{},"beforeUnmount",[105,10872,10873],{},[145,10874,10875],{},"onBeforeUnmount",[87,10877,10878,10883,10888],{},[105,10879,10880],{},[145,10881,10882],{},"destroyed",[105,10884,10885],{},[145,10886,10887],{},"unmounted",[105,10889,10890],{},[145,10891,10892],{},"onUnmounted",[11,10894,10895,10896,10899,10900,10903],{},"重點是 ",[145,10897,10898],{},"destroy"," 改名為 ",[145,10901,10902],{},"unmount","。遷移時如果漏改，舊名稱的鉤子在 Vue 3 不會執行，原本放在裡面的清理工作（移除監聽、清除計時器）就默默失效了。",[20,10905,10907],{"id":10906},"v-model-的改變","v-model 的改變",[11,10909,10910,10911,10913,10914,10916,10917,10920,10921,10924,10925,10928,10929,954],{},"Vue 2 的 ",[145,10912,8043],{}," 預設綁 ",[145,10915,9569],{}," prop 與 ",[145,10918,10919],{},"input"," 事件；Vue 3 改成 ",[145,10922,10923],{},"modelValue"," 與 ",[145,10926,10927],{},"update:modelValue","，並且支援多個 ",[145,10930,8043],{},[175,10932,10935],{"className":10933,"code":10934,"language":8151,"meta":180,"style":180},"language-vue shiki shiki-themes github-dark","\u003C!-- 父層 -->\n\u003CUserForm v-model:name=\"name\" v-model:email=\"email\" \u002F>\n",[145,10936,10937,10942],{"__ignoreMap":180},[184,10938,10939],{"class":186,"line":187},[184,10940,10941],{"class":1031},"\u003C!-- 父層 -->\n",[184,10943,10944,10946,10949,10951,10953,10955,10957,10960,10962,10964,10966,10968,10971,10973,10975,10977,10979],{"class":186,"line":222},[184,10945,823],{"class":190},[184,10947,10948],{"class":826},"UserForm",[184,10950,8000],{"class":194},[184,10952,1771],{"class":190},[184,10954,6930],{"class":194},[184,10956,833],{"class":190},[184,10958,10959],{"class":201},"\"",[184,10961,6930],{"class":190},[184,10963,10959],{"class":201},[184,10965,8000],{"class":194},[184,10967,1771],{"class":190},[184,10969,10970],{"class":194},"email",[184,10972,833],{"class":190},[184,10974,10959],{"class":201},[184,10976,10970],{"class":190},[184,10978,10959],{"class":201},[184,10980,4727],{"class":190},[175,10982,10984],{"className":10933,"code":10983,"language":8151,"meta":180,"style":180},"\u003C!-- 子層（Vue 3.4+）：defineModel 取代手動宣告 props 與 emit -->\n\u003Cscript setup>\nconst name = defineModel('name')\nconst email = defineModel('email')\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cinput v-model=\"name\">\n  \u003Cinput v-model=\"email\">\n\u003C\u002Ftemplate>\n",[145,10985,10986,10991,11003,11022,11040,11048,11052,11060,11075,11090],{"__ignoreMap":180},[184,10987,10988],{"class":186,"line":187},[184,10989,10990],{"class":1031},"\u003C!-- 子層（Vue 3.4+）：defineModel 取代手動宣告 props 與 emit -->\n",[184,10992,10993,10995,10998,11001],{"class":186,"line":222},[184,10994,823],{"class":190},[184,10996,10997],{"class":826},"script",[184,10999,11000],{"class":194}," setup",[184,11002,871],{"class":190},[184,11004,11005,11007,11010,11012,11015,11017,11020],{"class":186,"line":245},[184,11006,967],{"class":215},[184,11008,11009],{"class":251}," name",[184,11011,510],{"class":215},[184,11013,11014],{"class":194}," defineModel",[184,11016,198],{"class":190},[184,11018,11019],{"class":201},"'name'",[184,11021,255],{"class":190},[184,11023,11024,11026,11029,11031,11033,11035,11038],{"class":186,"line":454},[184,11025,967],{"class":215},[184,11027,11028],{"class":251}," email",[184,11030,510],{"class":215},[184,11032,11014],{"class":194},[184,11034,198],{"class":190},[184,11036,11037],{"class":201},"'email'",[184,11039,255],{"class":190},[184,11041,11042,11044,11046],{"class":186,"line":460},[184,11043,4894],{"class":190},[184,11045,10997],{"class":826},[184,11047,871],{"class":190},[184,11049,11050],{"class":186,"line":478},[184,11051,457],{"emptyLinePlaceholder":317},[184,11053,11054,11056,11058],{"class":186,"line":319},[184,11055,823],{"class":190},[184,11057,4861],{"class":826},[184,11059,871],{"class":190},[184,11061,11062,11064,11066,11068,11070,11073],{"class":186,"line":519},[184,11063,4858],{"class":190},[184,11065,10919],{"class":826},[184,11067,8000],{"class":194},[184,11069,833],{"class":190},[184,11071,11072],{"class":201},"\"name\"",[184,11074,871],{"class":190},[184,11076,11077,11079,11081,11083,11085,11088],{"class":186,"line":524},[184,11078,4858],{"class":190},[184,11080,10919],{"class":826},[184,11082,8000],{"class":194},[184,11084,833],{"class":190},[184,11086,11087],{"class":201},"\"email\"",[184,11089,871],{"class":190},[184,11091,11092,11094,11096],{"class":186,"line":553},[184,11093,4894],{"class":190},[184,11095,4861],{"class":826},[184,11097,871],{"class":190},[11,11099,11100,11101,803,11103,11105,11106,803,11108,11110,11111,11113],{},"自訂元件如果在 Vue 2 依賴 ",[145,11102,9569],{},[145,11104,10919],{},"，遷移時要改成 ",[145,11107,10923],{},[145,11109,10927],{},"，否則父層的 ",[145,11112,8043],{}," 會失效。",[20,11115,11117],{"id":11116},"插槽與事件-api","插槽與事件 API",[28,11119,11120,11137,11159],{},[31,11121,11122,11123,11126,11127,11130,11131,11134,11135,954],{},"Vue 3 把 ",[145,11124,11125],{},"$scopedSlots"," 併入 ",[145,11128,11129],{},"$slots","，所有插槽都是函式；範本語法統一用 ",[145,11132,11133],{},"v-slot"," 或 ",[145,11136,2339],{},[31,11138,11139,155,11142,155,11145,11148,11149,11152,11153,803,11156,954],{},[145,11140,11141],{},"$on",[145,11143,11144],{},"$off",[145,11146,11147],{},"$once"," 在 Vue 3 被移除。以往拿元件實例當 Event Bus 的寫法需要改用 ",[145,11150,11151],{},"mitt"," 之類的函式庫，或改成 props \u002F emits、",[145,11154,11155],{},"provide",[145,11157,11158],{},"inject",[31,11160,11161,11162,11165,11166,11169],{},"元件可以有多個根節點（Fragments）。這時 ",[145,11163,11164],{},"class"," 等 fallthrough 屬性不再自動套用到單一根節點，需要用 ",[145,11167,11168],{},"v-bind=\"$attrs\""," 指定位置。",[20,11171,11172],{"id":11172},"遷移檢查清單",[264,11174,11175,11188,11203,11214,11221],{},[31,11176,11177,11178,803,11180,11182,11183,11185,11186,954],{},"找出所有 ",[145,11179,9569],{},[145,11181,10919],{}," 風格的自訂 ",[145,11184,8043],{},"，改成 ",[145,11187,10923],{},[31,11189,11190,11191,155,11193,155,11195,155,11197,155,11199,155,11201,954],{},"搜尋 ",[145,11192,10865],{},[145,11194,10882],{},[145,11196,11141],{},[145,11198,11144],{},[145,11200,11125],{},[145,11202,10652],{},[31,11204,11205,11206,11209,11210,11213],{},"檢查所有解構 ",[145,11207,11208],{},"reactive"," 物件的地方，改用 ",[145,11211,11212],{},"toRefs"," 或直接存取屬性。",[31,11215,11216,11217,11220],{},"多根節點元件：確認 ",[145,11218,11219],{},"$attrs"," 套用在預期的元素上。",[31,11222,11223],{},"使用 UI 元件庫時，同步查閱該版本的遷移指南，因為元件 API 與預設樣式常有差異。",[20,11225,295],{"id":295},[11,11227,11228,11229,11231],{},"遷移的核心是「理解差異再改」，而不是把舊版寫法原封不動搬到新版。生命週期改名、",[145,11230,8043],{}," 協議、解構失去響應性這三項，最容易在沒有錯誤訊息的情況下造成行為異常，值得優先逐項檢查。",[300,11233,11234],{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}",{"title":180,"searchDepth":222,"depth":222,"links":11236},[11237,11238,11239,11240,11241,11242],{"id":10602,"depth":222,"text":10603},{"id":10784,"depth":222,"text":10784},{"id":10906,"depth":222,"text":10907},{"id":11116,"depth":222,"text":11117},{"id":11172,"depth":222,"text":11172},{"id":295,"depth":222,"text":295},"2025-07-15","從 v-model 演進、defineModel 到 reactive\u002Fref 踩坑實錄，整理元件實作的關鍵差異。",{},"\u002Fblog\u002Fvue2-vs-vue3-components",{"title":10594,"description":11244},"blog\u002Fvue2-vs-vue3-components",[3484,10590,8043],"ukERlUAiwHrNqTd_dl5URU4BpzyV3PFLTb3llo-ivNY",{"id":11252,"title":11253,"body":11254,"category":179,"date":12044,"description":12045,"draft":314,"extension":315,"meta":12046,"navigation":317,"path":12047,"readingTime":519,"seo":12048,"stem":12049,"tags":12050,"__hash__":12053},"blog\u002Fblog\u002Fjs-event-loop.md","JavaScript 事件循環、非同步與作用域理解",{"type":8,"value":11255,"toc":12036},[11256,11259,11262,11265,11293,11300,11413,11431,11435,11444,11526,11530,11533,11693,11696,11700,11741,11821,11824,11830,11925,11939,11942,12003,12005,12033],[11,11257,11258],{},"JavaScript 在瀏覽器裡是單執行緒：同一時間只有一段程式碼在呼叫堆疊（call stack）上執行。非同步並不是「同時執行」，而是把工作排進佇列，等堆疊清空後再依規則一個一個取出來。看懂這套規則，就能解釋大部分「為什麼順序跟我想的不一樣」的問題。",[20,11260,11261],{"id":11261},"事件循環的基本模型",[11,11263,11264],{},"一次循環大致是這樣：",[264,11266,11267,11274,11287,11290],{},[31,11268,11269,11270,11273],{},"從任務佇列（macrotask queue）取出一個任務執行，例如整段 script、",[145,11271,11272],{},"setTimeout"," 的回呼、使用者事件。",[31,11275,11276,11277,11280,11281,155,11284,954],{},"這個任務結束後，",[15,11278,11279],{},"清空整個微任務佇列","（microtask queue），例如 ",[145,11282,11283],{},"Promise.then",[145,11285,11286],{},"queueMicrotask",[31,11288,11289],{},"瀏覽器視情況進行渲染。",[31,11291,11292],{},"回到第 1 步，取下一個任務。",[11,11294,11295,11296,11299],{},"重點是第 2 步：微任務會在「下一個任務之前」全部執行完，所以它永遠比 ",[145,11297,11298],{},"setTimeout(fn, 0)"," 更早。",[175,11301,11303],{"className":177,"code":11302,"language":179,"meta":180,"style":180},"console.log('1')\nsetTimeout(() => console.log('2'), 0)\nPromise.resolve().then(() => console.log('3'))\nqueueMicrotask(() => console.log('4'))\nconsole.log('5')\n\n\u002F\u002F 輸出順序：1 5 3 4 2\n",[145,11304,11305,11318,11343,11372,11391,11404,11408],{"__ignoreMap":180},[184,11306,11307,11310,11312,11314,11316],{"class":186,"line":187},[184,11308,11309],{"class":190},"console.",[184,11311,228],{"class":194},[184,11313,198],{"class":190},[184,11315,578],{"class":201},[184,11317,255],{"class":190},[184,11319,11320,11322,11324,11326,11329,11331,11333,11336,11339,11341],{"class":186,"line":222},[184,11321,11272],{"class":194},[184,11323,1480],{"class":190},[184,11325,216],{"class":215},[184,11327,11328],{"class":190}," console.",[184,11330,228],{"class":194},[184,11332,198],{"class":190},[184,11334,11335],{"class":201},"'2'",[184,11337,11338],{"class":190},"), ",[184,11340,1797],{"class":251},[184,11342,255],{"class":190},[184,11344,11345,11347,11349,11351,11354,11357,11359,11361,11363,11365,11367,11370],{"class":186,"line":245},[184,11346,6417],{"class":251},[184,11348,2631],{"class":190},[184,11350,2634],{"class":194},[184,11352,11353],{"class":190},"().",[184,11355,11356],{"class":194},"then",[184,11358,1480],{"class":190},[184,11360,216],{"class":215},[184,11362,11328],{"class":190},[184,11364,228],{"class":194},[184,11366,198],{"class":190},[184,11368,11369],{"class":201},"'3'",[184,11371,2776],{"class":190},[184,11373,11374,11376,11378,11380,11382,11384,11386,11389],{"class":186,"line":454},[184,11375,11286],{"class":194},[184,11377,1480],{"class":190},[184,11379,216],{"class":215},[184,11381,11328],{"class":190},[184,11383,228],{"class":194},[184,11385,198],{"class":190},[184,11387,11388],{"class":201},"'4'",[184,11390,2776],{"class":190},[184,11392,11393,11395,11397,11399,11402],{"class":186,"line":460},[184,11394,11309],{"class":190},[184,11396,228],{"class":194},[184,11398,198],{"class":190},[184,11400,11401],{"class":201},"'5'",[184,11403,255],{"class":190},[184,11405,11406],{"class":186,"line":478},[184,11407,457],{"emptyLinePlaceholder":317},[184,11409,11410],{"class":186,"line":319},[184,11411,11412],{"class":1031},"\u002F\u002F 輸出順序：1 5 3 4 2\n",[11,11414,11415,11416,155,11418,11421,11422,155,11425,11428,11429,954],{},"同步的 ",[145,11417,5322],{},[145,11419,11420],{},"5"," 先跑完；接著清空微任務 ",[145,11423,11424],{},"3",[145,11426,11427],{},"4","；最後才輪到任務佇列裡的 ",[145,11430,678],{},[20,11432,11434],{"id":11433},"async-await-只是-promise-的語法","async \u002F await 只是 Promise 的語法",[11,11436,11437,11439,11440,11443],{},[145,11438,3108],{}," 之後的程式碼，等於放進 ",[145,11441,11442],{},".then()"," 的回呼，也就是微任務。",[175,11445,11447],{"className":177,"code":11446,"language":179,"meta":180,"style":180},"async function run() {\n  console.log('a')\n  await null\n  console.log('b') \u002F\u002F 微任務：等目前同步程式碼結束後才執行\n}\n\nrun()\nconsole.log('c')\n\u002F\u002F a c b\n",[145,11448,11449,11459,11471,11477,11493,11497,11501,11508,11521],{"__ignoreMap":180},[184,11450,11451,11453,11455,11457],{"class":186,"line":187},[184,11452,492],{"class":215},[184,11454,1760],{"class":215},[184,11456,3693],{"class":194},[184,11458,5004],{"class":190},[184,11460,11461,11463,11465,11467,11469],{"class":186,"line":222},[184,11462,225],{"class":190},[184,11464,228],{"class":194},[184,11466,198],{"class":190},[184,11468,8985],{"class":201},[184,11470,255],{"class":190},[184,11472,11473,11475],{"class":186,"line":245},[184,11474,5958],{"class":215},[184,11476,1449],{"class":251},[184,11478,11479,11481,11483,11485,11488,11490],{"class":186,"line":454},[184,11480,225],{"class":190},[184,11482,228],{"class":194},[184,11484,198],{"class":190},[184,11486,11487],{"class":201},"'b'",[184,11489,212],{"class":190},[184,11491,11492],{"class":1031},"\u002F\u002F 微任務：等目前同步程式碼結束後才執行\n",[184,11494,11495],{"class":186,"line":460},[184,11496,708],{"class":190},[184,11498,11499],{"class":186,"line":478},[184,11500,457],{"emptyLinePlaceholder":317},[184,11502,11503,11506],{"class":186,"line":319},[184,11504,11505],{"class":194},"run",[184,11507,1694],{"class":190},[184,11509,11510,11512,11514,11516,11519],{"class":186,"line":519},[184,11511,11309],{"class":190},[184,11513,228],{"class":194},[184,11515,198],{"class":190},[184,11517,11518],{"class":201},"'c'",[184,11520,255],{"class":190},[184,11522,11523],{"class":186,"line":524},[184,11524,11525],{"class":1031},"\u002F\u002F a c b\n",[20,11527,11529],{"id":11528},"常見問題一race-condition","常見問題一：Race Condition",[11,11531,11532],{},"使用者連續輸入搜尋字，較早送出的請求可能比較晚才回來，結果用舊資料蓋掉新資料。處理方式是在送出新請求前先取消舊的：",[175,11534,11536],{"className":177,"code":11535,"language":179,"meta":180,"style":180},"let controller\n\nasync function search(query) {\n  controller?.abort()\n  controller = new AbortController()\n\n  try {\n    const res = await fetch(`\u002Fapi\u002Fsearch?q=${encodeURIComponent(query)}`, {\n      signal: controller.signal,\n    })\n    render(await res.json())\n  }\n  catch (err) {\n    if (err.name !== 'AbortError') throw err\n  }\n}\n",[145,11537,11538,11545,11549,11564,11573,11587,11591,11597,11629,11634,11638,11653,11657,11665,11685,11689],{"__ignoreMap":180},[184,11539,11540,11542],{"class":186,"line":187},[184,11541,1441],{"class":215},[184,11543,11544],{"class":190}," controller\n",[184,11546,11547],{"class":186,"line":222},[184,11548,457],{"emptyLinePlaceholder":317},[184,11550,11551,11553,11555,11558,11560,11562],{"class":186,"line":245},[184,11552,492],{"class":215},[184,11554,1760],{"class":215},[184,11556,11557],{"class":194}," search",[184,11559,198],{"class":190},[184,11561,2073],{"class":208},[184,11563,1787],{"class":190},[184,11565,11566,11569,11571],{"class":186,"line":454},[184,11567,11568],{"class":190},"  controller?.",[184,11570,5928],{"class":194},[184,11572,1694],{"class":190},[184,11574,11575,11578,11580,11582,11585],{"class":186,"line":460},[184,11576,11577],{"class":190},"  controller ",[184,11579,833],{"class":215},[184,11581,1712],{"class":215},[184,11583,11584],{"class":194}," AbortController",[184,11586,1694],{"class":190},[184,11588,11589],{"class":186,"line":478},[184,11590,457],{"emptyLinePlaceholder":317},[184,11592,11593,11595],{"class":186,"line":319},[184,11594,6063],{"class":215},[184,11596,219],{"class":190},[184,11598,11599,11601,11604,11606,11608,11611,11613,11616,11619,11621,11623,11625,11627],{"class":186,"line":519},[184,11600,504],{"class":215},[184,11602,11603],{"class":251}," res",[184,11605,510],{"class":215},[184,11607,1515],{"class":215},[184,11609,11610],{"class":194}," fetch",[184,11612,198],{"class":190},[184,11614,11615],{"class":201},"`\u002Fapi\u002Fsearch?q=${",[184,11617,11618],{"class":194},"encodeURIComponent",[184,11620,198],{"class":201},[184,11622,2073],{"class":190},[184,11624,1904],{"class":201},[184,11626,2231],{"class":201},[184,11628,6823],{"class":190},[184,11630,11631],{"class":186,"line":524},[184,11632,11633],{"class":190},"      signal: controller.signal,\n",[184,11635,11636],{"class":186,"line":553},[184,11637,6843],{"class":190},[184,11639,11640,11643,11645,11647,11649,11651],{"class":186,"line":558},[184,11641,11642],{"class":194},"    render",[184,11644,198],{"class":190},[184,11646,3108],{"class":215},[184,11648,1344],{"class":190},[184,11650,1357],{"class":194},[184,11652,242],{"class":190},[184,11654,11655],{"class":186,"line":583},[184,11656,1235],{"class":190},[184,11658,11659,11662],{"class":186,"line":589},[184,11660,11661],{"class":215},"  catch",[184,11663,11664],{"class":190}," (err) {\n",[184,11666,11667,11669,11672,11674,11677,11679,11682],{"class":186,"line":2790},[184,11668,4203],{"class":215},[184,11670,11671],{"class":190}," (err.name ",[184,11673,2132],{"class":215},[184,11675,11676],{"class":201}," 'AbortError'",[184,11678,212],{"class":190},[184,11680,11681],{"class":215},"throw",[184,11683,11684],{"class":190}," err\n",[184,11686,11687],{"class":186,"line":2795},[184,11688,1235],{"class":190},[184,11690,11691],{"class":186,"line":2800},[184,11692,708],{"class":190},[11,11694,11695],{},"不能取消的情境（例如第三方 SDK），可以改用「請求序號」：只接受最新一次請求的結果。",[20,11697,11699],{"id":11698},"常見問題二promiseall-與-allsettled","常見問題二：Promise.all 與 allSettled",[81,11701,11702,11715],{},[84,11703,11704],{},[87,11705,11706,11709,11712],{},[90,11707,11708],{},"方法",[90,11710,11711],{},"其中一個失敗時",[90,11713,11714],{},"適合情境",[100,11716,11717,11729],{},[87,11718,11719,11723,11726],{},[105,11720,11721],{},[145,11722,6190],{},[105,11724,11725],{},"立刻 reject，丟掉其他結果",[105,11727,11728],{},"全部成功才有意義",[87,11730,11731,11735,11738],{},[105,11732,11733],{},[145,11734,6216],{},[105,11736,11737],{},"等全部結束，逐一回報成功或失敗",[105,11739,11740],{},"部分失敗仍要顯示其他資料",[175,11742,11744],{"className":177,"code":11743,"language":179,"meta":180,"style":180},"const results = await Promise.allSettled([loadProfile(), loadOrders(), loadCoupons()])\nconst ok = results.filter(r => r.status === 'fulfilled').map(r => r.value)\n",[145,11745,11746,11782],{"__ignoreMap":180},[184,11747,11748,11750,11752,11754,11756,11758,11760,11762,11765,11768,11771,11774,11776,11779],{"class":186,"line":187},[184,11749,967],{"class":215},[184,11751,6228],{"class":251},[184,11753,510],{"class":215},[184,11755,1515],{"class":215},[184,11757,2564],{"class":251},[184,11759,2631],{"class":190},[184,11761,6239],{"class":194},[184,11763,11764],{"class":190},"([",[184,11766,11767],{"class":194},"loadProfile",[184,11769,11770],{"class":190},"(), ",[184,11772,11773],{"class":194},"loadOrders",[184,11775,11770],{"class":190},[184,11777,11778],{"class":194},"loadCoupons",[184,11780,11781],{"class":190},"()])\n",[184,11783,11784,11786,11789,11791,11793,11795,11797,11799,11801,11803,11805,11808,11810,11812,11814,11816,11818],{"class":186,"line":222},[184,11785,967],{"class":215},[184,11787,11788],{"class":251}," ok",[184,11790,510],{"class":215},[184,11792,6254],{"class":190},[184,11794,6257],{"class":194},[184,11796,198],{"class":190},[184,11798,6262],{"class":208},[184,11800,2216],{"class":215},[184,11802,6267],{"class":190},[184,11804,2222],{"class":215},[184,11806,11807],{"class":201}," 'fulfilled'",[184,11809,541],{"class":190},[184,11811,1851],{"class":194},[184,11813,198],{"class":190},[184,11815,6262],{"class":208},[184,11817,2216],{"class":215},[184,11819,11820],{"class":190}," r.value)\n",[20,11822,11823],{"id":11823},"閉包與作用域",[11,11825,11826,11827,3414],{},"閉包是函式「記住」它被建立時所在的作用域。經典例子是迴圈裡的 ",[145,11828,11829],{},"var",[175,11831,11833],{"className":177,"code":11832,"language":179,"meta":180,"style":180},"for (var i = 0; i \u003C 3; i++) setTimeout(() => console.log(i)) \u002F\u002F 3 3 3\nfor (let i = 0; i \u003C 3; i++) setTimeout(() => console.log(i)) \u002F\u002F 0 1 2\n",[145,11834,11835,11884],{"__ignoreMap":180},[184,11836,11837,11840,11842,11844,11847,11849,11852,11855,11857,11860,11863,11866,11868,11870,11872,11874,11876,11878,11881],{"class":186,"line":187},[184,11838,11839],{"class":215},"for",[184,11841,3125],{"class":190},[184,11843,11829],{"class":215},[184,11845,11846],{"class":190}," i ",[184,11848,833],{"class":215},[184,11850,11851],{"class":251}," 0",[184,11853,11854],{"class":190},"; i ",[184,11856,823],{"class":215},[184,11858,11859],{"class":251}," 3",[184,11861,11862],{"class":190},"; i",[184,11864,11865],{"class":215},"++",[184,11867,212],{"class":190},[184,11869,11272],{"class":194},[184,11871,1480],{"class":190},[184,11873,216],{"class":215},[184,11875,11328],{"class":190},[184,11877,228],{"class":194},[184,11879,11880],{"class":190},"(i)) ",[184,11882,11883],{"class":1031},"\u002F\u002F 3 3 3\n",[184,11885,11886,11888,11890,11892,11894,11896,11898,11900,11902,11904,11906,11908,11910,11912,11914,11916,11918,11920,11922],{"class":186,"line":222},[184,11887,11839],{"class":215},[184,11889,3125],{"class":190},[184,11891,1441],{"class":215},[184,11893,11846],{"class":190},[184,11895,833],{"class":215},[184,11897,11851],{"class":251},[184,11899,11854],{"class":190},[184,11901,823],{"class":215},[184,11903,11859],{"class":251},[184,11905,11862],{"class":190},[184,11907,11865],{"class":215},[184,11909,212],{"class":190},[184,11911,11272],{"class":194},[184,11913,1480],{"class":190},[184,11915,216],{"class":215},[184,11917,11328],{"class":190},[184,11919,228],{"class":194},[184,11921,11880],{"class":190},[184,11923,11924],{"class":1031},"\u002F\u002F 0 1 2\n",[11,11926,11927,11929,11930,11933,11934,11936,11937,954],{},[145,11928,11829],{}," 是函式作用域，三個回呼共用同一個 ",[145,11931,11932],{},"i","；",[145,11935,1441],{}," 是區塊作用域，每一圈都有自己的 ",[145,11938,11932],{},[11,11940,11941],{},"閉包也會讓被參考的物件一直無法被回收。常見的隱性洩漏來源有：忘記移除的事件監聽、未清除的計時器，以及長期存活的物件持有大型資料的參考。元件銷毀時記得清理它們：",[175,11943,11945],{"className":177,"code":11944,"language":179,"meta":180,"style":180},"const onResize = () => { \u002F* ... *\u002F }\nwindow.addEventListener('resize', onResize)\n\n\u002F\u002F 元件卸載時\nwindow.removeEventListener('resize', onResize)\n",[145,11946,11947,11966,11981,11985,11990],{"__ignoreMap":180},[184,11948,11949,11951,11954,11956,11958,11960,11962,11964],{"class":186,"line":187},[184,11950,967],{"class":215},[184,11952,11953],{"class":194}," onResize",[184,11955,510],{"class":215},[184,11957,495],{"class":190},[184,11959,216],{"class":215},[184,11961,1504],{"class":190},[184,11963,9386],{"class":1031},[184,11965,2327],{"class":190},[184,11967,11968,11971,11973,11975,11978],{"class":186,"line":222},[184,11969,11970],{"class":190},"window.",[184,11972,195],{"class":194},[184,11974,198],{"class":190},[184,11976,11977],{"class":201},"'resize'",[184,11979,11980],{"class":190},", onResize)\n",[184,11982,11983],{"class":186,"line":245},[184,11984,457],{"emptyLinePlaceholder":317},[184,11986,11987],{"class":186,"line":454},[184,11988,11989],{"class":1031},"\u002F\u002F 元件卸載時\n",[184,11991,11992,11994,11997,11999,12001],{"class":186,"line":460},[184,11993,11970],{"class":190},[184,11995,11996],{"class":194},"removeEventListener",[184,11998,198],{"class":190},[184,12000,11977],{"class":201},[184,12002,11980],{"class":190},[20,12004,295],{"id":295},[28,12006,12007,12015,12020,12023],{},[31,12008,12009,12010,12012,12013,954],{},"微任務在下一個任務之前全部執行完，所以 ",[145,12011,11283],{}," 早於 ",[145,12014,11298],{},[31,12016,12017,12019],{},[145,12018,3108],{}," 之後的程式碼是微任務。",[31,12021,12022],{},"處理非同步 UI 時，要同時想「失敗」與「過期的結果」：取消舊請求，或只接受最新結果。",[31,12024,640,12025,803,12027,12029,12030,12032],{},[145,12026,1441],{},[145,12028,967],{}," 取代 ",[145,12031,11829],{},"，並在元件卸載時釋放監聽與計時器。",[300,12034,12035],{},"html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}",{"title":180,"searchDepth":222,"depth":222,"links":12037},[12038,12039,12040,12041,12042,12043],{"id":11261,"depth":222,"text":11261},{"id":11433,"depth":222,"text":11434},{"id":11528,"depth":222,"text":11529},{"id":11698,"depth":222,"text":11699},{"id":11823,"depth":222,"text":11823},{"id":295,"depth":222,"text":295},"2025-03-20","梳理 Event Loop、Promise 微任務隊列與閉包機制的實務踩坑心得，建立清晰的底層執行模型。",{},"\u002Fblog\u002Fjs-event-loop",{"title":11253,"description":12045},"blog\u002Fjs-event-loop",[12051,6417,12052],"Event Loop","Closure","IYirq9JQo5CDMjt9e2wK-hHKQZZdo7XNhWuzAVfsYtI"]