手機開起來一片空白,LINE 卻正常:一次挖到底的除錯日誌


貓空聚會的邀請函上線一陣子了。某天收到一句回報:

用手機的 Chrome 開起來一片空白,但 LINE 的瀏覽器又正常。

這句話裡有一個很珍貴的東西:對照組。同一個網址、同一支手機、兩個瀏覽器,一個死一個活。有對照組的 bug 比「它壞了」好查一百倍。

結果這條線挖下去,總共挖出四個彼此無關的問題,其中一個是我在修前三個的時候自己造出來的。這篇按照實際發生的順序記錄,包含我猜錯的部分。

先講結論

問題 症狀 為什麼難查
SPA rewrite 吃掉 404 舊快取指向已刪除的 JS,伺服器回 200 text/html module 被 MIME 檢查擋掉,不執行也不報錯
沒有 Error Boundary 任何 render 例外 → React 卸載整棵樹 手機沒有 devtools,白畫面等於零資訊
localStorage 會 throw 瀏覽器封鎖 site data 時 getItem 丟 SecurityError 錯在 useEffect 裡,直接炸掉整頁
z-fighting 桌面第三道山一直閃 我在自己機器上永遠重現不了

一、Vercel 的 SPA rewrite 是靜默失敗製造機

vercel.json 原本長這樣,是每個 SPA 教學都會叫你貼的那段:

{
  "rewrites": [
    { "source": "/(.*)", "destination": "/index.html" }
  ]
}

它的用途是讓 /gallery、/rsvp 這種前端路由不要 404。但 /(.*) 的意思是所有路徑,包含 /assets/index-abc123.js。

於是我試著打一個不存在的檔案:

curl -sI https://<站台>/assets/index-OLDHASH.js
HTTP/2 200
content-type: text/html; charset=utf-8

200,而且 content-type 是 text/html。 內容是 index.html。

這就是空白頁的機制。瀏覽器留著舊版的 index.html,裡面寫著上一次部署的檔名雜湊;那個檔已經被刪了,伺服器把首頁當成 JS 餵回去。而 <script type="module"> 有嚴格的 MIME 檢查:拿到 text/html 就直接拒絕執行。

不執行。不報錯。畫面全白。

我伸手去要一份腳本,櫃台遞出來的是一張網頁,還蓋章說成功

LINE 的內建瀏覽器有自己獨立的快取,第一次開就是全新載入,拿到的是正確的檔名——所以它好好的。對照組解釋完畢。

修法是把 /assets 排除在 rewrite 之外,讓不存在的檔案回真正的 404:

{
  "rewrites": [
    { "source": "/((?!assets/).*)", "destination": "/index.html" }
  ],
  "headers": [
    {
      "source": "/assets/(.*)",
      "headers": [
        { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
      ]
    }
  ]
}

順手把 /assets 標成 immutable——檔名本來就帶內容雜湊,永遠不需要重新驗證。Vercel 預設給的是 max-age=0, must-revalidate,等於每次都多一趟往返。

這個修正沒辦法在本機驗證(起不了 Vercel 的路由層),只能部署後 curl 確認。上線後回 404 text/plain,同時 /gallery 還是回 200 text/html——兩邊都要驗,只驗一邊很容易改壞路由。

二、白畫面的真正問題是它不會說話

上面那個修好了,但我意識到更嚴重的事:這個網站沒有任何機制能告訴使用者出了什麼事。

React 只要在 render 或 lifecycle 裡拋出例外,就會卸載整棵樹。手機上沒有 devtools,使用者看到的永遠是一片白,回報回來也永遠是「打不開」。

所以加了兩層。第一層是 React 的 Error Boundary,這個很標準。第二層比較少見,但它才是真正救到這次的關鍵——一段跑在 module 之前的 classic script:

<script>
  (function () {
    var booted = false;
    var timer = null;

    function show(title, detail) { /* 把訊息塞進 #root */ }

    // main.tsx 執行到就會呼叫,代表 module 成功載入
    window.__maokongBooted = function () {
      booted = true;
      if (timer) clearTimeout(timer);
    };

    window.addEventListener('error', function (e) {
      var t = e.target;
      // 資源載入失敗(script/link)沒有 e.error,只有 target
      if (t && t !== window && (t.tagName === 'SCRIPT' || t.tagName === 'LINK')) {
        show('資源載入失敗', '無法載入:' + (t.src || t.href));
        return;
      }
      show('程式發生錯誤', (e.error && e.error.stack) || e.message);
    }, true);

    timer = setTimeout(function () {
      show('頁面沒有載入完成', '主程式在 10 秒內沒有啟動。\nUA: ' + navigator.userAgent);
    }, 10000);
  })();
</script>

關鍵在它必須是 classic script。第一個問題的失敗模式是 module 根本沒被執行——這時候 Error Boundary 連載入都還沒載入,救不了任何東西。只有一段不依賴 module 系統的程式碼才攔得到。

順便把 UA 印出來。使用者截個圖,我就知道是哪個瀏覽器哪個版本。

我裝了一個土炮計時器,十秒內沒動靜就自動把原因蓋印到那面白板上

三、localStorage 是會拋例外的

這是查第二層的時候順手發現的。原本的程式碼:

const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) { /* ... */ }
try {
  setAttendees(JSON.parse(stored));
} catch {
  setAttendees(seed);
}

JSON.parse 有包 try/catch,getItem 沒有。但 getItem 本身就會拋:瀏覽器封鎖該站 site data 時(Chrome 的「封鎖所有 Cookie」、某些企業政策、沙箱化的 iframe),它丟的是 SecurityError,不是回傳 null。

而這行在 useEffect 裡。一拋,整棵樹卸載,白畫面。

改成一律走安全層:

export function readStorage(key: string): string | null {
  try {
    return localStorage.getItem(key);
  } catch (err) {
    console.warn(`[safeStorage] 無法讀取 ${key},改用記憶體模式:`, err);
    return null;
  }
}

讀失敗回 null,寫失敗靜默略過。 功能降級(勾選狀態不會保留),但頁面必須活著。裝飾性的持久化不值得拿整頁去換。

四、手機根本不該跑那個 WebGL 背景

到這裡使用者提出另一個方向:會不會是效能問題?

量了才知道有多誇張:

dist/assets/index-xxxx.js   1,049.42 kB │ gzip: 309.62 kB

單一 chunk 1 MB,其中三分之二是 three.js。 而且每一台裝置——包含所有手機——都會下載它、解析它,然後跑一個全螢幕的 WebGL render loop。低階 Android 上,這個組合足以讓分頁被系統回收。回收之後看到的是什麼?一片白。

使用者的要求很明確:手機不用這麼 fancy,桌面保留。這是對的取捨,而且做法很直接。

先把判斷抽出來:

export function shouldRenderWebGLScene(): boolean {
  if (typeof window === 'undefined') return false;
  try {
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return false;

    const conn = (navigator as any).connection;
    if (conn?.saveData === true) return false;
    if (typeof conn?.effectiveType === 'string' && /(^|-)2g$/.test(conn.effectiveType)) return false;

    // 觸控 + 窄螢幕 = 手機
    const isPhone =
      window.matchMedia('(pointer: coarse)').matches &&
      window.matchMedia('(max-width: 900px)').matches;
    if (isPhone) return false;

    const mem = (navigator as any).deviceMemory;
    if (typeof mem === 'number' && mem <= 4) return false;
    if (typeof navigator.hardwareConcurrency === 'number' && navigator.hardwareConcurrency <= 4) {
      return false;
    }
    return true;
  } catch {
    return false;
  }
}

偵測失敗一律回 false。 寧可少一層裝飾,也不要因為讀不到某個非標準 API 就讓頁面掛掉。

然後讓 three.js 成為獨立 chunk,手機根本不會去要它:

const SceneBackground = lazy(() => import('./SceneBackground'));

// ...
{use3D ? (
  <Suspense fallback={<StaticSceneBackground mode={weatherMode} />}>
    <SceneBackground mode={weatherMode} />
  </Suspense>
) : (
  <StaticSceneBackground mode={weatherMode} />
)}

StaticSceneBackground 是一張 inline SVG,構圖跟 3D 版完全一致——四道山、太陽、山霧,共用同一組色票,晴雨切換用 CSS transition。手機看到的不是「陽春版」,是同一個設計的靜態版本。零額外 JS、零 render loop。

同一份 SVG 也當 Suspense 的 fallback,所以桌面在載入 three.js 那段期間,背景不會先空一塊再跳出來。

順手把四個頁面改成 route-level lazy。切分結果:

index.js            403.44 kB │ gzip: 129.32 kB   ← 手機只下載這個
SceneBackground.js  496.28 kB │ gzip: 127.00 kB   ← 桌面才要
GalleryPage.js       90.94 kB │ gzip:  35.92 kB
ItineraryPage.js     38.28 kB │ gzip:  14.01 kB
RsvpPage.js          22.00 kB │ gzip:   5.86 kB

手機首屏 JS:1049 kB → 403 kB(gzip 309 → 129 kB,減 58%)。

驗證方式是用 CDP 開真正的行動裝置模擬(390×844、touch、Pixel UA),確認網路請求裡沒有 three.js 那個 chunk。不是看程式碼推論,是看實際發出的請求。

五、然後我自己造了一個 regression

改效能的時候我順手做了這件事:

// 「高 DPR 螢幕本身就有足夠取樣密度,MSAA 邊際效益低但成本高」
const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
renderer = new THREE.WebGLRenderer({ antialias: dpr < 1.5, /* ... */ });

聽起來很合理。它是錯的。

回報馬上來了:桌面山巒開始閃。查線上實際的 context 屬性:

{"antialias": false, "SAMPLES": 0, "backing": [2160, 1350], "dpr": 2}

問題在那個 Math.min(..., 1.5)。DPR 被鎖在 1.5,而 Retina 原生是 2——backing store 是 2160×1350,要放大到 2880×1800 的裝置像素。畫面本來就已經是不足取樣了。 山稜是高對比硬邊、又隨視差持續移動,MSAA 正是在壓這個。我把唯一的補償拿掉了。

改回 antialias: true。而且手機現在走 SVG,WebGL 只在桌面跑,MSAA 的成本完全付得起。

教訓不是「不要關 MSAA」,是我把一個沒有量過的假設寫成了註解。那句「高 DPR 取樣密度已足夠」讀起來像根據,其實是我腦補的——而且旁邊三行就寫著 Math.min(dpr, 1.5),證據就在原地,我沒看。

六、真正的元凶:我永遠重現不了的那個

MSAA 修好部署上線,回報是:還是在閃。

這時候我做了整個過程中唯一正確的一件事:停止猜測,開始量。

先排除掛載問題。用 CDP 在 25 個取樣點上記錄:

檢查 結果
canvas 數量 恆為 1
canvas 節點 沒被重建
context lost 0 次
console 錯誤 無
靜止逐幀變動 平均 0.11%,最大 0.13%
捲動頁面高度 恆定,視差無跳動

我測不到那個閃。 在我的環境裡它不存在。

這是很多人會開始亂改的時刻——反正改一改推上去問使用者好了。但每次「猜一個、推上去、等回報」的循環都很貴,而且會累積一堆沒有根據的改動。

所以我改成問,而且問能夠分辨假設的問題:是邊緣毛毛的像在爬,還是整片山突然變色?靜止時會不會?捲動才會?

回答只有一句,但它值一千次猜測:

由上而下第三個山有時候會一閃一閃。完全靜止不動時也會。

第三個。 打開座標表:

const RIDGES = [
  { depth: -18, ... },  // 由上而下第一道
  { depth: -12, ... },  // 第二道
  { depth:  -7, ... },  // 第三道 ← 就是它
  { depth:  -3, ... },
];

再看山霧:

m.position.set(x, -2 - i * 0.8, -4 - i * 3);
// i=0 → z = -4
// i=1 → z = -7   ← 跟第三道山完全共面
// i=2 → z = -10

第二片山霧和第三道山脊都在 z = -7,完全共面。

而且兩者都設了 transparent: true,所以都跑進 three.js 的 transparent pass,共用同一套深度排序與深度測試。24-bit 的深度緩衝分不開兩個共面的表面——重疊處就開始 z-fighting。

山霧會持續橫向飄移、山脊本身還有一個 Math.sin 的微幅呼吸位移,所以就算使用者完全不動,重疊區域也一直在變。這解釋了「靜止時也會閃」。也解釋了為什麼晴天雨天都閃——這是幾何問題,跟色盤無關。

至於為什麼我測不到:我用的是 headless 的軟體渲染,深度精度比實體 GPU 高。這種 bug 只在真的 GPU 上發生。

兩處都改,各解一半:

// 山巒改成不透明:transparent:true + opacity:0.98 視覺上跟不透明沒差別,
// 卻把山脊丟進 transparent pass 跟山霧混在一起
const mat = new THREE.MeshBasicMaterial({ color: pal0.ridges[i] });

// 山霧 z 避開 RIDGES 的 -18 / -12 / -7 / -3
m.position.set(x, -2 - i * 0.8, -5.5 - i * 2.6);  // → -5.5 / -8.1 / -10.7

新的間距最小 1.1 個單位。而山脊改成不透明之後,它們走 opaque pass(由近到遠、正常寫深度),完全退出那個排序,根本不再有機會跟山霧爭。

opacity: 0.98 這種「幾乎不透明」是很經典的陷阱:視覺上你看不出 2% 的差別,但它足以把物件丟進完全不同的渲染路徑。 半透明不是一個視覺參數,是一個架構決定。

這次學到什麼

一、讓失敗大聲。 三個問題裡有兩個的症狀是同一片白畫面。Error Boundary 和那段 bootstrap 看門狗沒有修好任何 bug,但它們把「未知的白畫面」變成「畫面上寫著原因和 UA」。這個轉換的價值比任何單一修正都高。

二、重現不了就不要改。 我在測不到的情況下改了 MSAA,結果是製造第四個 bug。等到真的量了 25 個取樣點、確認自己環境裡沒有這個現象,才去問使用者要區辨性資訊——「第三個山」這五個字直接把問題定位到一行座標。

三、註解裡的理由要能被檢查。 「高 DPR 取樣密度已足夠」寫在程式碼裡看起來很專業,實際上是我沒有驗證的假設,而且推翻它的證據就在同一個函式裡。註解應該寫為什麼這樣做,但那個「為什麼」本身要站得住。

四、有對照組的回報是禮物。 「手機 Chrome 空白但 LINE 正常」和「由上而下第三個山」,這兩句話各自省掉我好幾個小時。使用者不需要懂技術,但他們能提供你在自己機器上永遠拿不到的觀測。