SPA 網頁要注意的事:給 UIUX 和 PM 的單頁式應用檢查清單
本文重點
- SPA 網頁只載入一份 HTML,之後靠 JavaScript 換內容。使用者看到的是「換了畫面」,瀏覽器知道的是「同一份文件」。這個落差讓上一頁、分享連結、搜尋引擎、GA4、螢幕閱讀器各自出一種狀況,這篇一節講一種,每種都有例子。
- 路由:每一個畫面都要有自己的網址和自己的歷史紀錄。篩選條件按上一頁就消失、貼給同事的連結打開是首頁,都是網址沒有把狀態帶著走。
- SEO:Googlebot 會執行 JavaScript,但每一頁都要先排進渲染佇列,而且不是所有機器人都會跑 JavaScript,所以官方仍然建議伺服器先把 HTML 組好。我們團隊 2021 年改用 ReactJS 時就撞到這件事,多個 BU 共用一套前端,這一兩年把 CSR 轉成 SSR,內容才進到第一份回應裡。
- GA4:使用者點了二十個畫面,報表只有一次瀏覽,因為頁面沒有重新載入。開加強型評估的歷程事件就會補上。同一次切換如果有兩套機制都送 page_view,例如 Google Tag Manager 加上自動設定,瀏覽數會變兩倍。
- 無障礙與版本:整頁重載時瀏覽器替你換標題、把焦點放回開頭、朗讀新頁面,SPA 換畫面後這三件要自己做,框架會幫一部分。長開的分頁還會一直跑舊版程式,本站用 version.json 偵測新版。
這篇給誰看
給 UIUX 設計師和 PM。你不需要會寫程式,但你會在會議上聽到 SPA、SSR、路由、canonical 這些字,而且你交出去的設計稿和需求,會因為產品是不是 SPA 而少掉幾個狀態。這篇把每個狀況用一個你看過的畫面講一次,再告訴你要多交什麼、要問工程什麼、怎麼驗收。技術名詞第一次出現時都配一句白話。
1. SPA 是什麼,跟 MPA 差在哪
場景。 你打開一個後台,點左邊選單切換「訂單」「客戶」「報表」,畫面換好,網址列的字也跟著變,但分頁的重新整理圖示沒有轉,文件沒有重新載入。這就是 SPA。再打開一個老一點的購物網站,每點一次分類整頁重新載入,重新整理圖示轉一下、進度列跑一下。這是 MPA。
發生了什麼。 MDN 的定義是,單頁式應用(single-page application)只載入一份網頁文件,之後要顯示不同內容時,用 JavaScript 去更新那份文件的內容。好處是不用每次都向伺服器要整頁,切換快、互動可以做得更細。MDN 同一段也把代價列出來:SEO、維持狀態要花更多力氣、導覽要自己實作、效能監測變得不好做。MPA(multi-page application)則是每個網址對應一份伺服器回的 HTML,每次切換整頁重載。
會議上還會聽到三個字,白話如下。CSR(客戶端渲染):伺服器回的 HTML 幾乎是空的,畫面由瀏覽器跑 JavaScript 組出來。SSR(伺服器端渲染):伺服器先把完整 HTML 組好回給瀏覽器,瀏覽器再把 JavaScript 接上去讓它能互動,接上去那一步叫 hydration。SSG(靜態渲染):建置時就把 HTML 組好存成檔案。web.dev 的 Rendering on the Web 把這些做法排成一條線,純 SPA 在 CSR 那一端。
很多 React 專案不會是純 CSR。本站的設定是 Next.js 在建置時先把頁面輸出成 HTML,瀏覽器載入後再接上 JavaScript,之後在站內點連結走的是客戶端導覽,不重載整頁。Next.js 也支援每次請求才渲染的做法,所以這是本站的選擇,不是框架一律如此。混合做法讓第一次載入有內容、後續切換像 SPA,而下面每一節講的事,只要產品有「換畫面不換文件」這個行為,就都會遇到。
- 切換:MPA 每次整頁重載,SPA 換畫面不換文件。
- 瀏覽器幫你做的事:MPA 的上一頁、標題、焦點、搜尋引擎看到的內容,由瀏覽器和伺服器給。SPA 每一樣都要自己做,下面的示意圖列了七件。
- 適合的產品:內容為主、要被搜尋到的偏 MPA 或伺服器渲染,登入後操作密集的後台偏 SPA,很多專案是混合。
跟工程怎麼說。 「這個產品第一次打開是伺服器給 HTML,還是瀏覽器自己組?之後切換畫面有沒有重新載入文件?」這兩個答案決定你接下來要多交哪些東西。
2. 路由與網址:上一頁、重新整理、分享連結
場景一。 使用者在訂單列表篩了「本月、已付款、台北店」,點進第三筆訂單看明細,按上一頁,回到列表,三個篩選條件全部不見了,要重篩。 場景二。 業務把後台報表的網址貼給有權限的客戶,客戶登入後落在首頁,不是那張報表。 場景三。 使用者在手機上按返回鍵想關掉剛打開的篩選面板,結果整個網站被關掉,回到上一個網站。
發生了什麼。 三個場景都是同一件事:畫面換了,網址和歷史紀錄沒有跟著走。使用者對網址有三個不會說出口的期待:按上一頁會回到剛才的畫面,重新整理會留在原地,把網址貼給同事會打開一樣的東西。整頁重載的網站這三件事是瀏覽器給的,SPA 要自己做:每換一個值得回去的畫面,程式要替它加一筆歷史紀錄並更新網址,面板關掉時也要把那筆紀錄用掉,上一頁才會關掉面板,使用者留在網站裡。場景二多一層:登入之後要記得使用者本來要去哪裡。
Google Search Central 的 JavaScript SEO 文件講得很直接:用 History API 做客戶端路由,不要用 # 片段載入不同內容,因為 Googlebot 沒辦法可靠地解析片段網址。# 片段也能加歷史紀錄,所以上一頁通常還能用,差別在搜尋引擎會把它當成同一頁。同一份文件還有一句話設計師要記住:Google 只能從帶 href 的 <a> 元素發現連結。一張用 <div> 做成、點了會跳轉的卡片,對搜尋引擎、對「在新分頁開啟」、對滑鼠中鍵,都是不存在的連結。你可以自己測:在卡片上按滑鼠右鍵,選單裡沒有「在新分頁中開啟連結」,它就不是連結。
捲動位置也屬於這一節。GA4 的 SPA 文件特別寫了一段:SPA 切換畫面時瀏覽器通常保留原本的捲動位置,使用者看不到新畫面的開頭,捲動深度的追蹤也會錯,建議每次切換後把捲動位置歸零。相反的情況也存在:從明細回到列表,使用者期待回到原本的位置。瀏覽器有 scrollRestoration 這個開關可以控制,但兩個方向都要有人先決定。
跟工程怎麼說。 「這個畫面有自己的網址嗎?篩選條件、分頁、打開的面板要不要進網址?上一頁會回到哪裡?換畫面後捲動要歸零還是保留?」把答案寫進需求,設計稿每個畫面標上網址。驗收用三個動作:把網址貼到新分頁要打得開同一個畫面、重新整理要留在原地、列表進明細再回來要看到原本的篩選和位置。
3. 載入、錯誤、404:三種狀態各自要畫
場景一。 使用者按「產生報表」,畫面三秒沒有任何變化,他以為沒按到,再按一次,系統產生了兩份報表。 場景二。 活動結束半年了,活動頁的連結還打得開,畫面寫「找不到活動」,伺服器卻回 200。 場景三。 我審查自己用 AI 做的後台原型時記過一條:選「自訂期間」之後出現「選擇日期」四個字,日期選擇器一直沒有出現。那個畫面看起來像在等,也像沒有資料,使用者分不出是哪一種。
發生了什麼。 整頁重載的網站有瀏覽器的進度列,使用者知道在等。SPA 切換畫面時什麼都沒有,等待是看不見的,除非設計師把它畫出來。Next.js 把這件事做成檔案慣例,loading.js 定義一段路由切換時先顯示的介面,底層是 React 的 Suspense,error.js 在一段路由出錯時接住錯誤,讓其他部分繼續活著。對設計師的意思是,每個主要畫面要多交兩份:載入中長什麼樣,出錯時長什麼樣、能不能重試。場景一的解法除了載入狀態,還要請工程把「產生報表」做成按兩次只會產生一份,這個詞叫冪等(idempotent),在 API 那篇講過。場景三要分清楚三個狀態:還在等、沒有資料、控制項壞了,三個長得不能一樣。
場景二叫「軟 404」。Google 的文件寫明,客戶端路由常常沒辦法回真正的 404 狀態碼。頁面看起來是找不到、伺服器卻說 200 的時候,Google 會看內容,可能把它判成軟 404,也可能當成一頁內容很薄的頁面收進索引,兩種都不是你要的。文件給兩個解法:用 JavaScript 轉到一個伺服器真的會回 404 的網址(例如 /not-found),或者用 JavaScript 替錯誤頁加上 noindex。設計師要做的是確認產品真的有一個「找不到」的畫面,而且它跟「載入中」和「沒有資料」三個長得不一樣。
還有一種錯誤只有長開的 SPA 分頁會遇到:部署之後,舊程式去要它自己那一版的程式碼片段,新部署已經把那些檔案刪掉了。這件事在第 9 節講。
跟工程怎麼說。 「每個主要畫面我會交載入中、出錯、找不到三個狀態。找不到的頁面伺服器會回 404 嗎?不會的話用哪一個解法?」驗收時把一個不存在的網址貼進新分頁,看畫面和狀態碼各是什麼。
4. SPA SEO:Googlebot 會跑 JavaScript,但頁面要先排隊
場景。 行銷做了一個新的產品頁,上線兩週,用 Google 搜產品名找不到。工程說「Google 會跑 JavaScript 啊」。你打開那個頁面按 Ctrl+U 看原始碼,整份 HTML 只有一個空的 <div id="root"> 和幾支 script,標題是公司名,沒有產品名。這還不能證明原因,但它是第一個要排查的方向。
發生了什麼。 常見的說法是「SPA 的 SEO 比較差」,官方文件的說法更精確。Google Search Central 寫明 Googlebot 用常青版的 Chromium 執行 JavaScript,流程是先爬取、把回 200 的頁面全部排進渲染佇列、用無頭 Chromium 渲染後才索引。頁面可能在佇列裡等幾秒,也可能更久。同一份文件接著說,伺服器端渲染或預先渲染仍然是好主意,因為它對使用者和爬蟲都比較快,而且不是所有機器人都會執行 JavaScript。場景裡那份空的 HTML 就是純 CSR 的樣子:Google 排到隊之後會渲染它,但其他搜尋引擎、社群分享的預覽、AI 的爬蟲未必會跑 JavaScript。
從這份文件能整理出 SPA 的 SEO 檢查項目,後面寫了各自用什麼看:
- 每個畫面要有不同的
<title>和 meta description,用 JavaScript 設定是可以的,但能寫在 HTML 裡最好。看法:Ctrl+U 看原始 HTML。 - canonical(告訴 Google 這一頁的正式網址是哪一個)可以用 JavaScript 設,但不能跟原始 HTML 裡的值不一樣。看法:Ctrl+U,再用 Search Console 的網址檢查看「Google 選擇的標準網址」。
- 狀態碼要有意義。找不到就回 404,要登入就回 401,搬家就用轉址。客戶端路由做不到的,用第 3 節的兩個解法避開軟 404。看法:開發人員工具的 Network 面板。
- 連結要是
<a href>,路由要用 History API。看法:按右鍵有沒有「在新分頁中開啟連結」。 - 被 robots.txt 擋住的檔案,Google 不會渲染裡面的 JavaScript。Google 最後看到什麼、收錄了什麼,要用 Search Console 的網址檢查看渲染後的畫面和已檢索的 HTML。
本站把 title、description、canonical 做成每個頁面的 metadata,建置時就寫進 HTML,所以用 Ctrl+U 看,渲染前的 HTML 就已經有完整的標題和內文。Next.js 也支援動態渲染和動態 metadata,這是本站的設定,不是框架唯一的做法。純 CSR 的專案要另外安排預先渲染或伺服器渲染,這是開案時就要問的事,上線後再補會很貴。
跟工程怎麼說。 「Ctrl+U 看到的 HTML 裡有標題和內文嗎?每個畫面的標題和描述是誰填的、填在哪裡?上線後誰用 Search Console 看 Google 收錄了什麼?」
5. 多 BU 共用一套 SPA,SEO 要分開呈現
場景。 公司有三個 BU,三個品牌,共用同一套前端程式。有一天 A 品牌的業務說,用 Google 搜 A 品牌的「關於我們」,出來的是 B 品牌的頁面。再查一下,三個品牌的「關於我們」用同一段文案,只換了品牌名,Google 把它們當成同一份內容,挑了一個當代表。確認的方法是 Search Console 的網址檢查,看「Google 選擇的標準網址」指到哪一個。
發生了什麼。 這一節是我們團隊自己撞到的。2021 年起,團隊把前端改用 ReactJS,畫面切換變成 SPA。我在設計端第一個問的問題就是上一節那件事:搜尋引擎爬得到頁面上的內容嗎?純客戶端渲染回給爬蟲的 HTML 幾乎是空的,內容要等 JavaScript 跑完才有。這個狀況跟著我們好幾年,這一兩年團隊才用 Google 文件建議的那一個答案把它關掉:把客戶端渲染(CSR)轉成伺服器端渲染(SSR),伺服器先回完整的 HTML,瀏覽器再接上 JavaScript。渲染佇列照樣會排,但爬蟲拿到的第一份回應裡就有內容,收錄不再依賴 JavaScript 有沒有跑完。
第二個問題比第一個難:同一套前端程式要服務多個 BU,每個 BU 有自己的品牌,SEO 也要各自呈現。程式是一套,搜尋引擎看到的必須是好幾個網站。我在 React 重構那篇寫過 BU override 的機制,顏色、Logo、文案可以照 BU 覆寫,SEO 的欄位要用同一套機制管,而且清單比想像中長:
- 標題與描述:每個 BU 的每個畫面都要有自己的
<title>和 meta description,用 BU 的名字和市場的說法。共用模板本身不算重複內容,主體內容一樣才算,所以場景裡的三個「關於我們」,標題和前兩段就要寫成各自的。 - canonical:Google 的 canonical 文件說,遇到重複或很相似的頁面,Google 會挑一個當代表,
rel="canonical"是你告訴它偏好的方式。它是建議訊號,Google 不保證採用,但沒寫就等於把選擇交給 Google。每個 BU 指向自己的網址,驗收時用網址檢查看 Google 選擇的標準網址。 - sitemap 與 robots.txt:我們的 BU 有兩種長法,有的是自己的網域,有的跟主站同網域、只差路徑後綴。Google 的 sitemap 文件說,列在 sitemap 裡的網址要和它所在的主機一致,跨主機要透過 Search Console 或 robots.txt 做交叉提交,robots.txt 則是每個主機一份。所以自己網域的 BU 通常各有一份 sitemap 和 robots.txt,或者集中放一處再交叉提交。只差路徑的 BU 共用主站那一份,sitemap 裡每個 BU 的網址要齊。
- hreflang:Google 的本地化文件說 hreflang 是用來標明「同一份內容」的語言或地區版本。我們的 BU 語言可以不同,所以要先分清楚:同一個品牌的中文版和英文版互為版本,要標 hreflang。兩個不同品牌各用不同語言,彼此並不是版本,不要互標。文件同時提醒,內容沒翻譯的版本會被當成重複。
- Search Console:一種常見的管理方式是每個 BU 一個資源。Search Console 的文件分兩種資源:網域資源涵蓋整個網域和所有子網域,網址前置字元資源可以限定到路徑。自己網域的 BU 開網域資源,只差路徑的 BU 開網址前置字元資源。上線後用網址檢查工具看「已檢索的網頁」和「測試線上網址」,確認渲染後的畫面和 HTML 裡有那個 BU 自己的標題與內容,不是共用模板的預設值。
有一個以前常見的捷徑現在不建議了。動態渲染(偵測到爬蟲就回預先渲染好的 HTML,一般使用者回 JavaScript)在 Google 的文件裡已經標成「權宜做法」,文件直接寫它不是長期方案,建議改用伺服器端渲染、靜態渲染或 hydration。
設計端能做的是把 SEO 欄位當成 BU 設定的一部分交付。開一個 BU 時,顏色和 Logo 旁邊就放標題、描述、社群分享圖和 canonical 的規則,工程照同一套 override 機制接,之後每加一個 BU 只是多填一份設定。這件事在 2021 年我們是邊上線邊補的,現在我會在第一張設定表就放進去。
跟工程怎麼說。 「每個 BU 的標題、描述、canonical 是從 BU 設定來的嗎?自己網域的 BU 的 sitemap 和 robots.txt 放哪裡?哪些頁面互為語言版本要標 hreflang?Search Console 怎麼分?」驗收用網址檢查:每個 BU 挑一頁,看 Google 選擇的標準網址和渲染後的標題。
6. GA4 SPA 追蹤:page_view 不重載怎麼送
場景一。 上線第一週看 GA4,每個使用者平均瀏覽 1.0 頁。但客服回報使用者一次會看二十幾個畫面。 場景二。 另一個專案剛好相反,瀏覽數是實際的兩倍,每個畫面都被算了兩次。
發生了什麼。 GA4 的 page_view 預設自動收集,加強型評估的文件把它列為在那個開關裡關不掉的事件。Measure pageviews 那頁則寫了另一條路:在 Google 代碼裡把 send_page_view 設成 false,就停掉頁面載入時的自動瀏覽,之後自己送。場景一的原因是預設只在「頁面載入」和「歷程變化」兩種時機送,而第二種要另外開:Measure single-page applications 文件建議的做法,就是開加強型評估裡的進階選項「依瀏覽器歷程事件計算的網頁變更」,白話是:只要程式有正式替畫面加歷史紀錄(第 2 節那件事),GA4 就送一次 page_view。這也是為什麼第 2 節的網址要做對,追蹤才會對。
場景二是同一份文件加粗提醒的事:如果標籤是用 Google Tag Manager 設定的,不要同時開 GA4 的自動歷程追蹤,會重複計算頁面瀏覽。真正要檢查的是同一次切換有沒有兩套機制都送了 page_view,有 GTM 本身不會自動變兩倍。這是我在追蹤規格會議上一定會先問的事,因為兩份對不起來的數字比沒有數字更難處理。
驗證方法文件也寫了:開 debug 模式,在 DebugView 裡一路點過 SPA 的畫面,每切到一個新的虛擬畫面就應該多一個 page_view 事件,而且要比對前後兩個 page_view 的參數,文件特別點名 page_location,我會連 page_title 一起看。本站用 gtag.js 直接接 GA4,沒有經過 GTM,page_view 就交給加強型評估的歷程事件處理,自己送的只有自動收集看不到的那些動作。
GA4 這份文件最後還提醒了捲動位置歸零,跟第 2 節講的是同一件事。頁面切換、捲動歸零、page_view 的參數,三件事在 SPA 裡是綁在一起的。
跟工程怎麼說。 「page_view 是誰送的,歷程事件還是我們自己?同一次切換會不會有兩套都送?上線前我們一起在 DebugView 點一輪,每個畫面剛好一次。」
7. 無障礙:換畫面後的標題、焦點和朗讀
場景一。 用螢幕閱讀器的使用者在申辦流程按了「下一步」,畫面換到第二步,他聽到的還是第一步的標題,不知道要往哪裡去。 場景二。 鍵盤使用者每切換一個畫面,都要按三十幾次 Tab 穿過左邊的選單和上面的導覽列,才碰得到內容。 場景三。 表單送出成功,畫面右上角彈出一秒鐘的「已儲存」,螢幕閱讀器什麼都沒念。
發生了什麼。 整頁重載時,瀏覽器替使用者做三件事:分頁標題換成新頁面的標題,鍵盤焦點回到文件開頭,螢幕閱讀器從頭朗讀新頁面。SPA 換畫面時這三件事要自己做,框架會補一部分:Next.js 內建路由朗讀器,客戶端切換時會把新頁的 document.title 念給輔助科技聽。沒有這類機制的產品,使用者聽到的、停留的位置,都還在上一個畫面。
WCAG 2.4.2 Page Titled 的理解文件直接點名 SPA:當多個畫面都從同一個網址提供、內容用程式動態替換時,頁面標題也應該動態更新,反映目前的內容或主題。這是最容易做、最常忘的一條。本站每個頁面的標題由框架在建置時寫好,客戶端導覽時也會跟著換。
焦點的部分,W3C 的 APG 鍵盤介面指南和 WCAG 2.4.3 Focus Order 講的原則是:焦點的順序要符合意義和操作順序。換到一個新的主要畫面時,常見做法是把主標題設成可以程式化聚焦(tabindex="-1"),導覽完成後把焦點放上去。篩選、從明細回到列表、關掉面板是另外的情況,要各自決定焦點留在原地還是回到打開它的按鈕。本站的文章小標都帶 id 和 tabIndex={-1},從目錄點過去時焦點會落在那個小標上,而且不會變成 Tab 鍵的停靠點。場景一的解法就是這個:換到第二步時,焦點放到「第二步」的標題上,標題同時更新。
朗讀的部分用 ARIA live region。MDN 的說明是:live region 讓輔助科技在內容動態更新時主動通知使用者,而不用使用者自己去找。做法是區域先存在,訊息再放進去。場景三的「已儲存」放進這樣的區域,螢幕閱讀器就會念。本站複製按鈕的結果訊息和版本更新提示都用了 role="status"。
場景二對應 WCAG 2.4.1 Bypass Blocks 要求的跳過區塊,也就是「跳至主要內容」的連結。SPA 的導覽列固定不動,每次換畫面鍵盤使用者都要重新穿過它,這個連結在 SPA 裡比在傳統網站更重要。本站的版面把它放在鍵盤第一個會碰到的位置。
跟工程怎麼說。 交一張表,四欄:畫面、標題文字、換畫面後焦點落在哪、哪些結果要朗讀。再加一句「跳至主內容的連結在鍵盤第一個位置」。驗收用鍵盤和螢幕閱讀器各走一次主流程。
8. 效能:第一次載入和後面的切換
場景。 首頁放了 3D 展示、即時圖表和一個聊天小工具。第一次打開要等六秒,之後點哪裡都很快。老闆說「很快啊」,因為他的分頁開一整天。新客戶說「打不開」,因為他是第一次來。
發生了什麼。 SPA 的效能分兩段。第一次載入要下載主程式包,之後的切換通常只抓資料,但拆過包的專案在切到新區域時還是會下載那一塊的程式片段。web.dev 的 Rendering on the Web 用三個指標描述第一段:TTFB 是伺服器回第一個 byte 的時間,FCP 是第一次畫出內容的時間,INP 是互動到畫面回應的時間。伺服器端渲染通常 FCP 快但 TTFB 慢,客戶端渲染相反,而且 JavaScript 越大,主執行緒越忙,INP 越差。
設計端能影響的是「第一個畫面要多少東西」。首頁塞滿圖表、動畫和第三方元件,JavaScript 就跟著變大。Next.js 的 Link 會預先抓取視窗內連結的目標,所以後續切換快,但第一次載入的成本是真的。web.dev 的 Web Vitals 文件把 LCP、INP、CLS 列為核心指標並給了門檻值。Chrome 的 Lighthouse 在本機量的是載入指標,像 FCP、LCP、CLS。INP 要看真實使用者的資料,Search Console 的核心網頁指標報表就是一個來源。開需求的時候把目標值寫進去,比上線後再吵要省力。
還有一件跟上一頁有關的事。瀏覽器的往返快取(bfcache)能讓按上一頁時整頁瞬間恢復,但 web.dev 的文件寫明它只對瀏覽器管理的導覽有效,SPA 內部的「軟導覽」用不到。從別的網站回到 SPA 時還是有幫助。所以 SPA 內部的上一頁要自己處理狀態和捲動位置,這又回到第 2 節。
跟工程怎麼說。 「第一個畫面要載多少 JavaScript?首頁這三個重的東西能不能等使用者捲到才載?我們的 LCP 和 INP 目標是多少,上線後從哪裡看真實數據?」
9. 版本:長開的分頁會一直跑舊程式
場景。 客服打給你:「我這邊看到的按鈕跟你說的不一樣,我沒有『匯出』那個鍵。」你看了一下,她的分頁從早上開到現在,中午部署的新版她沒拿到。更糟的版本是,她點了某個畫面,畫面直接白掉,因為早上那版程式去要它自己的程式碼片段,中午的部署已經把那些檔案刪掉了。
Get Karenya Lin’s stories in your inbox
Join Medium for free to get updates from this writer.
發生了什麼。 整頁重載的網站,每一次請求通常都拿到最新的程式,快取除外。SPA 的分頁開著一整天,用的還是早上載入的那一包,部署了新版它不會知道,直到它去要一個已經被刪掉的程式碼片段,然後壞掉。
本站選的做法寫在 版本偵測那篇,它是其中一種解法,這裡只講要決定的事。頁面載入時讀一次 /version.json 記下目前的版本,分頁從背景切回來時再讀一次,另外定時讀。發現版本不同時先顯示一行說明,再重載,並防止邊緣節點還在回舊版本時無限重載。付款那條規則是這樣的:有付款步驟進行中就先不重載,只有這次更新改到付款那一段時才重載並說明原因,因為那正是付款中的人最不能錯過的更新。本站沒有結帳,這條規則留給有結帳的產品。
設計要決定的是那一行說明寫什麼、使用者能不能延後、延後的時候工作怎麼保存、哪些狀態不能被打斷。表單填到一半、付款進行中、檔案上傳中,這三種狀態要先列出來,再去談自動更新。
跟工程怎麼說。 「部署新版之後,開著的分頁多久會知道?會先提示還是直接重載?能延後嗎?填表、付款、上傳這三種狀態不能被打斷,我列給你。」
10. 安全與到期:token 是工程的事,到期的畫面是設計的事
場景。 使用者填了二十分鐘的申請表,按送出,被踢回登入頁。重新登入後回到首頁,剛才填的二十分鐘不見了。
發生了什麼。 SPA 常常把登入狀態放在瀏覽器裡。OWASP 的 HTML5 安全備忘錄寫明:不要把 session 識別碼放在 local storage,因為 JavaScript 永遠讀得到它,cookie 加上 httpOnly 可以降低這個風險。這條是工程的事,設計師知道就好。
設計師要管的是到期之後的體驗。SPA 的分頁開一整天,session 在中間過期,使用者按下儲存才發現要重新登入,剛填的東西還在不在,取決於有沒有人把它設計進去。要問的問題有三個:到期前會不會提醒?重新登入之後回到原本的畫面還是首頁?填到一半的內容留不留?這三個問題在整頁重載的網站也存在,分頁開得越久,遇到的次數就越多。
跟工程怎麼說。 「到期前幾分鐘提醒?重新登入後回到原本的畫面。填到一半的內容留在瀏覽器裡多久?密碼和卡號不留。」
11. 檢查清單:SPA 專案開案時要問的十二件事
- 每個畫面有自己的網址和歷史紀錄嗎?篩選、分頁、面板算不算狀態?
- 上一頁會回到哪裡?換畫面後捲動位置歸零還是保留?
- 連結是
<a href>嗎?按右鍵有「在新分頁中開啟」嗎? - 每個主要畫面有載入狀態、錯誤狀態、找不到狀態嗎?三種長得不一樣嗎?
- Ctrl+U 看到的 HTML 裡有標題和內文嗎?第一次載入是伺服器渲染、預先渲染,還是純客戶端?上線後誰用 Search Console 看收錄?
- 多個 BU 或品牌共用一套前端時,每個 BU 的標題、描述、canonical 是自己的嗎?sitemap、robots.txt、hreflang、Search Console 怎麼分?
- SEO 欄位有沒有放進 BU 的設定表,跟顏色和 Logo 一起交付?
- GA4 的 page_view 是誰送的?同一次切換會不會有兩套機制都送?
- 換畫面後標題會換嗎?焦點落在哪?哪些結果要朗讀?跳至主內容的連結在哪?
- 第一個畫面的 JavaScript 有多大?LCP 和 INP 的目標寫了嗎?真實數據從哪裡看?
- 部署新版之後,開著的分頁怎麼知道?能延後嗎?哪些狀態不能被重載打斷?
- session 到期前會提醒嗎?重新登入後回到哪裡?填到一半的內容留不留?
這十二題我用在每一個 React 和 Next.js 的案子開頭。有些題目對某個產品會是「不適用」,那也是一個答案。還答不出來的,就是開案時要先決定的事。
SPA 網頁常見問題
SPA 網頁是什麼?React SPA 是什麼意思?
單頁式應用(single-page application)只載入一份 HTML,之後用 JavaScript 換內容,不重新載入文件。你點選單時畫面換了、分頁的重新整理圖示沒有轉,就是它。React、Vue、Angular 做出來的網站大多是這種形式,React SPA 就是用 React 做的單頁式應用。Next.js 這類框架會在建置時先輸出 HTML,站內切換仍然走客戶端導覽,所以這篇講的事它一樣會遇到。
SPA 和 MPA 怎麼比較、怎麼選?
MPA 每個網址對應一份伺服器回的頁面,切換整頁重載,上一頁、標題、焦點和搜尋引擎看到的內容由瀏覽器和伺服器給。SPA 切換快、互動細,但這些都要自己補。內容為主、要被搜尋到的網站偏 MPA 或伺服器渲染,登入後操作密集的後台偏 SPA。很多專案是混合:第一次載入由伺服器或建置時輸出,之後客戶端導覽。
SPA 的 SEO 真的比較差嗎?
Google 會用常青版 Chromium 執行 JavaScript 再索引,但每一頁都要先排進渲染佇列,而且不是所有機器人都會跑 JavaScript,所以官方仍然建議伺服器端渲染或預先渲染。自己檢查分三層:Ctrl+U 看伺服器回的 HTML 有沒有標題和內文,開發人員工具看狀態碼,Search Console 的網址檢查看 Google 渲染後看到什麼、選了哪個標準網址。要做到的事有四件:每個畫面有不同的標題和描述、canonical 跟 HTML 一致、狀態碼有意義(避免軟 404)、連結用 <a href> 和 History API。
GA4 在 SPA 要怎麼追蹤 page_view?
開加強型評估裡「依瀏覽器歷程事件計算的網頁變更」,程式每次替畫面加歷史紀錄,GA4 就送一次 page_view。要完全自己送的話,在 Google 代碼裡把 send_page_view 設成 false 再手動送。兩套機制不能同時對同一次切換送,用 Google Tag Manager 的歷程變更觸發條件就要關掉自動的那一邊,不然瀏覽數會變兩倍。驗證方式是在 DebugView 裡一路點過畫面,每個畫面剛好一個 page_view,比對前後的 page_location。報表裡每人只有一頁瀏覽,通常就是歷程事件沒開。
SPA 的上一頁為什麼會壞?
因為換畫面時沒有替它加歷史紀錄,瀏覽器的歷史裡沒有東西可以回去,或者面板打開時加了紀錄、關掉時沒有用掉,上一頁就把使用者帶離網站。用 # 片段做路由上一頁通常還能用,但 Google 文件說它沒辦法可靠地把片段當成不同頁面。用 <div> 當連結則會失去「在新分頁開啟」和被爬蟲發現的能力。檢查方法很簡單:每個畫面都按一次上一頁、再重新整理一次、再把網址貼到新分頁,看三次各回到哪裡。
多個 BU 或品牌共用一套前端,SEO 怎麼分開?
把 SEO 欄位當成 BU 設定的一部分:每個 BU 的每個畫面各有標題和描述,canonical 指向自己的網址(它是建議訊號,Google 不保證採用,但沒寫就是讓 Google 自己挑),自己網域的 BU 有自己的 sitemap 和 robots.txt 或集中後交叉提交,只差路徑的 BU 共用主站那份但網址要齊,同一份內容的不同語言版本才標 hreflang,Search Console 常見的做法是每個 BU 一個資源。驗收用網址檢查看 Google 選擇的標準網址和渲染後的標題。動態渲染這條捷徑 Google 已經標成權宜做法,改用伺服器端或靜態渲染。
設計師做 SPA 專案,要比以前多交哪些畫面?
每個主要畫面多三份:載入中、出錯(能不能重試)、找不到。另外整站多三份:session 到期的提醒、新版本更新的提示、跳至主內容的連結。載入中和空狀態要長得不一樣,不然使用者分不出是還在等,還是真的沒有資料。這些狀態可以和一般畫面放在同一份設計稿裡,用固定編號交給工程,省掉一來一回問「這裡沒資料要顯示什麼」。
SPA 換畫面時,標題和焦點要怎麼設計?
每個畫面定一個標題文字,格式和整頁網站一樣(畫面名稱加站名),換畫面時寫進 document.title。換到新的主要畫面時,焦點移到它的主標題,主標題設成可以程式化聚焦但不進 Tab 順序(tabindex="-1")。篩選、回列表、關面板另外決定焦點留在原地或回到打開它的按鈕。送出、儲存、切換成功這類結果用事先存在的 live region 朗讀。設計稿裡用一張表交付:畫面、標題文字、焦點落點、要朗讀的句子。
彈出面板和燈箱要不要有自己的網址?按上一頁要關掉它嗎?
看它能不能被分享。商品詳情、篩選結果這類使用者會想貼給別人的,進網址,上一頁回到列表。確認對話框、提示訊息這類不會被分享的,不進網址,但要替它加一筆歷史紀錄並在關閉時用掉,上一頁才會關掉它,使用者留在網站裡。手機上的使用者按返回鍵的預期和桌面一樣,這條在手機網頁版更重要。
表單填到一半遇到重載或到期,內容怎麼辦?
先決定三件事:到期前多久提醒、重新登入後回到原本的畫面還是首頁、填到一半的內容留不留。留的話要說清楚留多久、留在哪裡(瀏覽器本機或帳號裡)、哪些欄位不留(密碼、卡號)。新版本自動更新的那次重載也要套同一套規則,付款進行中和上傳中的狀態列為不可打斷。
社群分享的預覽為什麼抓不到標題和圖?
分享預覽靠的是 HTML <head> 裡的 Open Graph 標籤。純客戶端渲染的 SPA 把這些標籤留給 JavaScript 去填,抓預覽的機器人不一定會執行 JavaScript,Google 的文件也說不是所有機器人都會。解法和 SEO 一樣:標題、描述、分享圖寫在伺服器回的 HTML 裡,每個畫面、每個 BU 各自一份。交付時把分享圖的尺寸和文案一起放進 BU 設定表。
參考文獻
- MDN,SPA (Single-page application)
- MDN,History API
- MDN,History: scrollRestoration property
- MDN,Navigation API
- MDN,ARIA live regions
- web.dev,Rendering on the web
- web.dev,Back/forward cache
- web.dev,Web Vitals
- Google Search Central,Understand JavaScript SEO Basics
- Google Search Central,Dynamic Rendering as a workaround
- Google Search Central,How to specify a canonical URL
- Google Search Central,Build and Submit a Sitemap
- Google Search Central,Robots.txt Introduction and Guide
- Google Search Central,Localized Versions of your Pages
- Search Console 說明,URL Inspection tool
- Search Console 說明,Add a website or platform property
- Google Analytics,Measure single-page applications
- Google Analytics 說明,Enhanced measurement events
- Google Analytics,Measure pageviews
- W3C WAI,Understanding SC 2.4.1 Bypass Blocks
- W3C WAI,Understanding SC 2.4.2 Page Titled
- W3C WAI,Understanding SC 2.4.3 Focus Order
- W3C WAI,Developing a Keyboard Interface(ARIA Authoring Practices Guide)
- Next.js,Linking and Navigating
- Next.js,loading.js
- Next.js,error.js
- Next.js,Accessibility(路由朗讀)
- React,Suspense
- OWASP,HTML5 Security Cheat Sheet
- Chrome for Developers,Introduction to Lighthouse
本文原刊於 karenyalin.com,發表於 2026 年 10 月 30 日。英文版在 Medium。










