claude.ai 首頁原始碼載入效能
人類撰寫區域
Claude 網頁初始化速度近期優化很多,一進入就可以直接輸入無須等完整的 React 初始化,心血來潮就把 raw HTML 原始碼丟給 Claude,讓他自己分析網頁版都做了哪些優化。簡而言之,最重要的就是用原生 HTML/JS/CSS 手刻一個最基礎的元素外觀骨架(跟 React 完全無關的純手寫 DOM),這時用戶就可以直接輸入了,React 會在後台初始化,初始化完成後接管所有東西,包含你剛才在輸入框輸入的內容、輸入的狀態等等,把假骨架換成真正的 React 元件,用戶就能在 React 框架下得到快速反應的體驗,無須等待整包 React 下載、初始化完成。
同樣也給 Claude 看了 ChatGPT 網頁的做法,也是看 raw HTML 原始碼沒看 JS,ChatGPT 的做法完全不同:SSR,伺服器直接把 React 元件渲染成完整 HTML 傳給瀏覽器,因此是畫面顯示後立即可動,用戶看到是真正的的畫面,不是假骨架。之後 React 在前端 hydrate 這些既有 DOM,接上互動邏輯,而不是像 Claude 那樣把假的換成真的。
兩者都是讓使用者不用等 React 完整初始化就能使用,但底層技術完全不同。對於用戶使用體驗,兩者都是畫面顯示後即可馬上互動無須等待,都做到極快的初始化,然而 ChatGPT 的實現(截至 2026/08)會直接把敏感資訊寫在 HTML 裡面,我貼上的時候還被 Claude 罵了,這些敏感資訊包含:
根據你之前貼的那份 ChatGPT HTML,client-bootstrap 這個 <script type="application/json"> 區塊裡硬編碼的敏感 key 包括:
accessToken: 完整 JWT,可直接用於呼叫api.openai.com,內含 scope 權限(model.request、organization.write等)sessionToken: 加密格式的 session tokenemail: 使用者信箱name: 使用者顯示名稱id/userID: 使用者帳號 IDaccount.id(workspace/organization ID)sessionId: session 識別碼webDeploymentAttestation: 另一個 JWT 格式的簽章cfConnectingIp: 使用者真實 IPcfIpLatitude/cfIpLongitude/cfIpCity: 精確到城市級的地理定位customIDs底下的多組裝置與帳號識別碼(WebAnonymousCookieID、DeviceId、stableID、ads_segment_id等)
其中 accessToken 和 sessionToken 是最嚴重的,這兩個是可以直接拿去冒充登入身份的憑證,其餘屬於個資與追蹤識別碼等級。
Claude 回話內容
以下是直接複製 Claude 的回話,完全不是我寫的,上面的段落才是我寫的,下面是 Claude 寫的。
主要分成兩大段落:Claude 分析 Claude 網頁版的內容,以及Claude 分析 ChatGPT 網頁版的內容
Claude 分析 Claude 網頁版的首頁載入效能優化
展開
1. <head> 最前面:連線預熱
<link rel="preconnect" href="https://assets-proxy.anthropic.com" crossorigin>
<link rel="dns-prefetch" href="https://assets-proxy.anthropic.com">
在解析任何內容之前,先把靜態資源網域的 DNS 查詢和 TLS 握手做掉。
2. <meta charset> 後面:i18n catalog 資訊標籤
<meta name="i18n-catalogs"
data-i18n-base="https://.../i18n/"
data-i18n-catalogs="de-DE:xxx.yyy.zzz,fr-FR:...">
這只是資料標籤,不執行任何動作,但後面的 i18n 預載腳本會讀這裡的 dataset 來組出正確的翻譯檔網址。
3. <style>html.cds-root{--cds-page-bg:...}</style>
<style>
html.cds-root { --cds-page-bg: var(--cds-surface-1); }
</style>
極小的內聯樣式,先定義一個 CSS 變數,避免背景色閃爍。
4. Desktop boot placeholder 偵測(第一個 <script>)
<script>
var placeholder = globalThis.desktopBootFeatures?.bootPlaceholder;
if (placeholder?.status === 'supported') {
document.documentElement.setAttribute('data-desktop-boot-placeholder', '');
setTimeout(() => {
if (!document.documentElement.hasAttribute('data-boot-ui-ready')) {
document.documentElement.setAttribute('data-boot-ui-ready', 'timeout');
}
}, 10000);
}
</script>
<style>
html[data-desktop-boot-placeholder]:not([data-boot-ui-ready]) [data-boot-surface],
html[data-desktop-boot-placeholder]:not([data-boot-ui-ready]) body {
background: transparent !important;
}
</style>
這是給桌面版 app 用的:如果是桌面殼層(desktop shell)已經先畫好一個 placeholder 畫面,網頁本身就先透明,避免疊加閃爍,並設一個 10 秒逾時保險機制。
5. URL 清理與 magic link 跳轉處理
<script>
if (document.head.hasAttribute('data-ion-clean-url-hop')) {
let path = decodeURIComponent(location.pathname).toLowerCase().replace(/\/+$/, '');
let hash = location.hash.slice(1);
if (path === '/magic-link' && hash) {
sessionStorage.setItem('__ml_handoff', JSON.stringify({ hash, at: Date.now() }));
let cleanUrl = location.origin + location.pathname + location.search;
history.replaceState(null, '', cleanUrl);
location.replace(cleanUrl);
}
}
</script>
處理登入用的 magic link:把 URL hash 裡的敏感 token 先存進 sessionStorage,再清掉網址列上的 hash,避免 token 留在瀏覽器歷史紀錄或分享出去的網址裡。
6. React Query cache 從 IndexedDB 提前讀取
<script>
let dbPromise = new Promise((resolve) => {
let req = indexedDB.open('keyval-store');
req.onsuccess = () => {
let db = req.result;
let tx = db.transaction('keyval', 'readonly')
.objectStore('keyval')
.get('react-query-cache');
tx.onsuccess = () => resolve(tx.result);
tx.onerror = () => resolve(undefined);
};
req.onerror = () => resolve(undefined);
});
window.__PRELOADED_IDB_CACHE__ = dbPromise;
</script>
在 React 啟動前,先把上次快取的 react-query 資料從 IndexedDB 撈出來,等 React Query 初始化時直接拿現成資料用。
7. 首頁路徑重寫(/ 導向 /new)
<script>
function getCookie(name) {
let match = document.cookie.match(new RegExp(name + '=([^;]*)'));
return match ? decodeURIComponent(match[1]) : undefined;
}
if (location.pathname === '/' &&
(getCookie('sessionKeyLC') || getCookie('sessionKeyV3LC'))) {
history.replaceState(history.state, '', '/new' + location.search + location.hash);
}
</script>
如果使用者已登入且造訪根路徑 /,直接在瀏覽器端把網址改成 /new,不用等 SPA router 載入完才做這件事。
8. Bootstrap API 提前 fetch
<script>
function getCookie(name) {
let match = document.cookie.match(new RegExp(name + '=([^;]*)'));
return match ? decodeURIComponent(match[1]) : undefined;
}
let orgId = getCookie('lastActiveOrg');
let sessionKey = getCookie('sessionKeyLC') ?? getCookie('sessionKeyV3LC');
let isValidOrg = orgId && /^[0-9a-f-]{36}$/i.test(orgId);
let url = (sessionKey && isValidOrg)
? `/edge-api/bootstrap/${orgId}/app_start?statsig_hashing_algorithm=djb2`
: `/edge-api/bootstrap?statsig_hashing_algorithm=djb2`;
let headers = {
'anthropic-client-sha': document.documentElement.dataset.gitHash,
'anthropic-client-version': document.documentElement.dataset.version,
'anthropic-client-platform': 'web_claude_ai'
};
window.__BOOTSTRAP_PRELOAD__ = {
promise: fetch(url, { credentials: 'include', headers }),
path: url
};
</script>
在任何 JS bundle 載入前,就先組好 headers(包含版本資訊、device id 等)並發出應用程式初始化用的 API 請求,等真正的 SPA 啟動時直接複用這個 promise,省下一次等待時間。
9. i18n 語言包提前 fetch
<script>
let locale = (() => {
try { return localStorage.getItem('spa:locale'); }
catch { return null; }
})();
if (locale && locale !== 'en-US') {
let base = `/i18n/${locale}.json`;
let dynamic = `/i18n/dynamic/${locale}.json`;
let overrides = `/i18n/${locale}.overrides.json`;
window.__I18N_CATALOG_PRELOAD__ = {
locale,
responses: {
[base]: fetch(base),
[dynamic]: fetch(dynamic),
[overrides]: fetch(overrides)
}
};
}
</script>
同樣的模式:依使用者上次選的語言,提前把翻譯 JSON 檔案抓下來,等 i18n 函式庫初始化時直接使用。
10. 主要 JS bundle 載入
<script type="module" crossorigin src="/assets/index-De_VDnUg.js"></script>
<link rel="modulepreload" crossorigin href="/assets/vendor-zod-CIzVg8oX.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-query-BTpb5jOU.js">
<link rel="modulepreload" crossorigin href="/assets/shared-0-_X71AkJG.js">
<!-- ... 還有幾十個 modulepreload -->
真正的應用進入點是 type="module" 的 index-*.js,其餘一大串 modulepreload 是它依賴的 vendor 和 shared chunk,讓瀏覽器提早平行下載,減少之後 import 時才發現要下載的延遲。
11. 大量內聯 Critical CSS
<style data-static-critical-css>
@layer properties, cds-card, theme, base, components, utilities;
@font-face { font-family: anthropic-sans; src: url(...) format("woff2"); }
:root { --cds-radius: 6px; --cds-surface-1: #fcfcfb; }
.flex { display: flex; }
/* ... 大量 Tailwind utility class 與設計變數 */
</style>
把首屏必要的字型宣告、CSS 變數、常用 utility class 直接寫進 HTML,不用等外部 CSS 檔案下載解析完才有樣式可用。這一段對應原始碼裡最長的那個 <style> 區塊。
12. <body> 開頭:靜態文案的多語系翻譯資料
<script type="application/json" id="static-composer-translations">
{"de-DE": {"greeting": "Du bist da!", "placeholder": "..."}, "ja-JP": {...}}
</script>
純資料,不是可執行程式碼,稍後的 static composer 腳本會讀這裡來套用對應語言的文案。
13. SEO 結構化資料
<script type="application/ld+json">
{ "@context": "https://schema.org", "@graph": [...] }
</script>
給搜尋引擎看的結構化資料,跟載入效能無關。
14. Static composer HTML 骨架 + 內聯樣式
<div id="static-composer" hidden>
<style>#static-composer[hidden]{display:none}</style>
<div id="static-composer-box">
<textarea id="static-composer-input" disabled rows="1"
placeholder="How can I help you today?"></textarea>
</div>
</div>
先把假輸入框的 DOM 結構和樣式畫出來,此時還是 hidden 且 disabled,等最後的腳本判斷條件通過才會顯示、啟用。
15. Sidebar 骨架預繪
<script>
if (location.pathname === '/new') {
let sidebarState = JSON.parse(localStorage.getItem('dframe-store') || '{}');
let collapsed = sidebarState?.state?.collapsed;
let width = sidebarState?.state?.sidebarWidth || 288;
document.documentElement.setAttribute('data-sidebar-chrome', collapsed ? 'none' : 'rail');
document.documentElement.style.setProperty('--static-sidebar-width', width + 'px');
}
</script>
<div id="static-sidebar-skeleton"></div>
根據使用者上次的 sidebar 展開/收合狀態和寬度(存在 localStorage),提前設定好對應的 CSS 屬性,讓 sidebar 骨架一開始就是正確尺寸,避免版面跳動。
16. <div id="root">:React 掛載點
<div id="root"></div>
<div id="portal-root"></div>
真正的 React app 最終會掛載在這裡,取代上面所有的 static 內容。
17. 字型低優先度預載
<link rel="preload" href="/fonts/c0f671921-DiY3GvqQ.woff2" as="font"
type="font/woff2" crossorigin fetchpriority="low">
這個放在很後面,且明確標 fetchpriority="low",代表是次要字型(可能是某些不常用的圖示字型或補充字重),不搶首屏關鍵資源的頻寬。
18. Static composer 主邏輯(最長的 IIFE)
<script>
(() => {
function getCookie(name) { /* ... */ }
// 條件檢查:必須是 /new 路徑、已登入、沒有 query string 內容、不在 iframe 內
if (location.pathname !== '/new') throw new Error();
let sessionKey = getCookie('sessionKeyLC') ?? getCookie('sessionKeyV3LC');
if (!sessionKey) throw new Error();
if (window.self !== window.top) throw new Error();
let box = document.getElementById('static-composer');
let input = document.getElementById('static-composer-input');
// 還原草稿文字
let draftRaw = localStorage.getItem('static-composer-draft-data');
if (draftRaw) {
let draft = JSON.parse(draftRaw);
if (draft?.text) input.value = draft.text;
}
// 套用當前語言的翻譯文案
let translations = JSON.parse(
document.getElementById('static-composer-translations').textContent
);
let locale = localStorage.getItem('spa:locale') || 'en-US';
let t = translations[locale];
if (t?.placeholder) input.setAttribute('placeholder', t.placeholder);
// IME 組字判斷,避免中文/日文輸入法按 Enter 誤送出
let compositionEndTime = -1e8;
input.addEventListener('compositionend', (e) => { compositionEndTime = e.timeStamp; });
input.addEventListener('keydown', (e) => {
let isSubmit = e.key === 'Enter' && !e.shiftKey && !e.isComposing;
if (isSubmit && e.timeStamp - compositionEndTime < 500) return;
if (isSubmit) { e.preventDefault(); /* 標記要送出 */ }
});
box.hidden = false;
input.disabled = false;
input.focus();
})();
</script>
這是整份 HTML 最核心、最長的一段,做的事情包括:條件判斷(登入狀態、路徑、是否在 iframe)、還原草稿、套用多語系文案、處理輸入法組字避免誤觸發送出、最後才解除 hidden 和 disabled 讓使用者可以打字。等真正的 React app 完成 hydrate 後,會接手這個輸入框,把使用者已經打的字無縫轉移過去。
19. Client health / 版本檢查(頁面最下方)
<script>
function scheduleHealthCheck() {
fetch('/edge-api/client-health/check?platform=web&bundle=' +
document.documentElement.dataset.buildTimestamp)
.then(res => res.json())
.then(data => {
if (data.control?.type === 'reload') {
location.reload();
}
});
setTimeout(scheduleHealthCheck, 1800000); // 每 30 分鐘檢查一次
}
setTimeout(scheduleHealthCheck, 5000);
</script>
放在最後,5 秒後才開始執行,定期檢查目前載入的版本是否過舊,過舊就強制重新整理。這跟首屏載入速度無關,是背景執行的版本控管機制,故意排在最低優先度,不搶佔任何關鍵資源。
整體看下來,順序上的安排本身就是一種效能策略:越早執行的腳本,影響的東西越關鍵(連線、URL、資料預抓),越晚執行的(static composer 細節、client health)則是錦上添花或背景維護性質,就算慢一點載入或執行也不影響首屏體驗。
Claude 網頁版如何決定是否是裝面還是行動裝置的
展開
從你貼的 HTML 裡看得到幾個不同層面的判斷方式,分別用在不同用途上。
1. CSS media query:pointer: coarse
@media (pointer: coarse) {
.cds-root { --cds-font-size-text-entry-floor: 16px; }
}
這是最主要的判斷依據之一。pointer: coarse 偵測的是「主要輸入裝置是不是不精準的指標」,觸控螢幕會符合這個條件,滑鼠不會。這裡用途很實際:iOS Safari 有個行為,如果 <input> 或 <textarea> 的字體小於 16px,聚焦時會自動放大整個頁面(避免使用者看不清楚打字)。設定 font-size: max(16px, ...) 就是為了避免這個惱人的自動縮放。
2. JS 判斷:matchMedia + maxTouchPoints
在 static composer 那段邏輯裡:
let a = !("function" == typeof matchMedia &&
matchMedia("(pointer: coarse)").matches &&
navigator.maxTouchPoints > 0);
翻成白話:
const isCoarsePointer = matchMedia("(pointer: coarse)").matches;
const hasTouch = navigator.maxTouchPoints > 0;
const isDesktopLike = !(isCoarsePointer && hasTouch);
a 代表「是不是桌面型裝置」,兩個條件都要符合(粗指標 且 有觸控點)才判定為行動裝置,單一條件不夠準(例如有些筆電螢幕支援觸控但主要輸入是滑鼠鍵盤)。這個 a 後面用來決定要不要自動 focus() 輸入框:
if (a) {
// 桌面:自動聚焦,方便直接打字
input.focus({ preventScroll: true });
}
// 行動裝置:不自動聚焦,因為會跳出虛擬鍵盤、佔掉螢幕空間,體驗不好
3. User Agent 字串判斷(用於偵測是不是桌面 App,而非行動裝置)
let d = /claude(nest|gov)?\/[^ ]/i.test(navigator.userAgent) && !!window.claudeAppBindings,
s = d && (/windows/i.test(navigator.userAgent) ||
/linux|x11/i.test(navigator.userAgent) && !/android|cros/i.test(navigator.userAgent));
這段其實不是在分「手機 vs 電腦」,而是在分「這是不是 Claude 的桌面殼層 App(Electron 類)」:
- 先看 UA 字串裡有沒有
claude/、claudenest/、claudegov/這類自訂識別字,加上window.claudeAppBindings是否存在(桌面 App 才會注入這個橋接物件) - 再進一步判斷是不是 Windows / Linux(
x11),並排除android、cros(Chrome OS),因為桌面 App 理論上只會跑在 Windows/Linux/macOS 桌面環境,不會是 Android 版
這個判斷結果用來決定要不要顯示視窗控制列(data-sidebar-desktop-window-controls-overlay),這是給「自訂視窗標題列」用的,跟響應式版面無關。
4. Viewport 寬度斷點(CSS breakpoint)
@media (min-width: 701px) {
html[data-sidebar-chrome=rail] #static-sidebar-skeleton { display: block; }
}
@media (min-width: 1024px) {
html[data-sidebar-chrome=classic] #static-sidebar-skeleton { display: block; }
}
@media not all and (width >= 48rem) {
.max-md\:pt-4 { padding-top: 1rem; }
}
這是最傳統的 responsive design 做法,純粹依螢幕寬度切版面,跟裝置類型無關,是手機也好、小視窗的桌面瀏覽器也好,寬度夠窄就套用行動版排版。
5. innerWidth 即時讀取(JS)
let l = window.innerWidth;
在 sidebar 骨架預繪那段,直接讀當下視窗寬度,判斷要不要顯示 sidebar:
let h = ((e) => {
const showSidebar = !e.collapsed && e.viewportWidth > 700;
return { showSidebar, ... };
})({ collapsed: ..., viewportWidth: l });
整體歸納
Claude 網頁其實不是靠單一個「是不是 mobile」的布林值來判斷,而是依用途分別使用不同訊號:
| 判斷目的 | 用什麼方法 |
|---|---|
| 要不要自動放大輸入框字體防止 iOS 自動縮放 | pointer: coarse (CSS) |
| 要不要自動 focus 輸入框 | matchMedia("pointer: coarse") + maxTouchPoints (JS) |
| 是不是桌面殼層 App(非瀏覽器) | User Agent + window.claudeAppBindings |
| 版面該用寬版還窄版 | viewport 寬度 media query 斷點 |
| Sidebar 預設要不要展開 | innerWidth 加上 localStorage 存的使用者偏好 |
這種拆分方式比只看單一個 UA 字串或單一個寬度數字更準確,因為「觸控裝置」「小螢幕」「桌面 App」這三件事其實是互相獨立的變數(例如觸控螢幕筆電是桌面但支援觸控,平板可能開瀏覽器版但螢幕不算小)。
Claude 分析 ChatGPT 網頁版的首頁效能載入優化
展開
先說一個重要事情:這份 HTML 裡包含真實的使用者資料,包括完整的 access token、session token、email、device ID 等敏感資訊。這些是可以直接拿去做身份驗證的憑證,如果這是你自己的帳號,強烈建議你更換密碼並讓所有裝置的 session 失效;如果不是你自己的帳號,就更不該貼出來討論。
我只針對前端載入技術本身回答,不會引用或分析裡面任何 token、email 等個資內容。
跟 Claude 網頁版比起來,架構上的根本差異
Claude 走的是「先用 vanilla JS 畫一個假 UI,React 再接手」的路線。ChatGPT 這份完全是另一套思路:React Router v7 的 SSR(伺服器端渲染)加上 Streaming。整個 <body> 裡的 sidebar、composer、header 全部都是伺服器端就已經渲染好的完整 HTML,不是空殼子。
1. Server-Side Rendering 直接輸出完整 DOM
<div id="thread">...</div>
<form autoComplete="off" class="group/composer w-full" ...>
<textarea class="wcDTda_fallbackTextarea" ...></textarea>
</form>
跟 Claude 的 static composer 不同,這裡的 textarea、sidebar、按鈕全部是伺服器渲染出來的真實 React 元件,不是臨時的 vanilla JS 假殼。使用者一打開就看到完整介面,不用等 JS 執行完才有畫面。這是傳統的 SSR 好處:首次內容繪製(FCP)非常快。
2. React Router 的 Streaming SSR(漸進式串流渲染)
<script>
window.__reactRouterContext = {
ssr: true,
...
};
window.__reactRouterContext.stream = new ReadableStream({
start(controller) {
window.__reactRouterContext.streamController = controller;
}
}).pipeThrough(new TextEncoderStream());
</script>
這是 React 18+ 的 Server Components Streaming 手法。伺服器不是一次吐出完整 HTML 再結束,而是先送出骨架,資料還沒準備好的部分用 <template> 佔位符(id="B:0"、id="B:1")卡著,資料準備好後再透過內嵌的 <script> 把資料塞進 stream:
<template id="B:0"></template>
<script>
window.__reactRouterContext.streamController.enqueue("...loaderData...");
</script>
配合這段替換邏輯:
$RC = function(a, b) {
if (b = document.getElementById(b))
(a = document.getElementById(a)) ? (a.previousSibling.data = "$~", $RB.push(a, b), ...)
: b.parentNode.removeChild(b)
};
$RC("B:0", "S:0")
$RC/$RV 這組函式是 React 的 Suspense boundary 替換機制:先渲染一個 fallback(loading UI),資料到齊後透過這段 script 把 <template> 裡的內容換到真正的位置上,不需要重新請求整個頁面。這跟 Claude 靠 vanilla JS 手刻的 static composer 完全不同層級,是框架原生支援的漸進式渲染。
3. 統一的 client-bootstrap 資料注入
<script type="application/json" id="client-bootstrap">
{"authStatus":"logged_in","session":{...}, ...}
</script>
這點跟 Claude 的 bootstrap fetch 手法異曲同工,但做法更直接:ChatGPT 乾脆把整包使用者 session、認證資料、feature flag 全部序列化成 JSON 直接嵌進 HTML 裡(用 type="application/json",瀏覽器不會執行它,只是資料容器)。React 啟動時直接讀這個 script 標籤的內容當作初始狀態,完全不用等第一個 API request 打完。比 Claude 的「提前 fetch」更進一步,是「根本不用 fetch,資料已經在 HTML 裡了」。
4. 巨型 Feature Flag 系統(Statsig)
"statsigPayload": "{\"feature_gates\":{...超大量 flag...}}"
這是 ChatGPT 特別誇張的地方:把整套 A/B 測試與 feature flag 系統(Statsig)的評估結果,全部在伺服器端算好,序列化成一個巨大字串塞進 HTML。這樣做的好處是前端完全不用等一次額外的 API 呼叫去問「這個使用者該看哪個版本的功能」,一開始就知道。壞處是這段資料肥大到誇張(你這份文件裡光 Statsig payload 就佔了絕大部分篇幅),會拖慢 HTML 本身的下載與解析時間,這是用「省一次 round trip」換「HTML 體積暴增」的取捨。
5. Deferred(延遲)資料載入 Promise
"prefetchSearchPromises": {
"shouldPrefetchModels": false,
"shouldPrefetchHistory": false,
...
}
這些開關控制哪些資料要在伺服器端就順便抓好、哪些留到前端才抓。跟 Claude 全部用同一招「提前 fetch」不同,ChatGPT 這裡是可以細粒度控制每一種資料源要不要參與 SSR 階段的預抓,更有彈性但也更複雜。
6. defer_startup_imports_until_composer_ttfi
"deferStartupImportsUntilComposerTTFI": true
這個 flag 名字很直白:「延後啟動時的 import,直到 composer 達到 TTFI(Time To First Interaction,第一次可互動時間)」。意思是刻意把非必要的 JS 模組載入延後,先確保輸入框能用,其他次要功能的程式碼晚點才載入。這跟 Claude 的「static composer 先讓你打字」目標一致,但做法是在 bundler/module loading 層級去控制,而不是額外做一層假 UI。
7. modulepreload 但份量更精簡
<link rel="modulepreload" href="/cdn/assets/manifest-87173f84.js">
<link rel="modulepreload" href="/cdn/assets/entry.client-h12km76z.js">
跟 Claude 那長串 modulepreload 手法一樣,但 ChatGPT 這邊的 chunk 數量少很多,可能是因為程式碼分割策略不同,或是這份 HTML 只是首頁不含太多子路由的 bundle。
8. Datadog RUM(Real User Monitoring)
"datadogRumProxyEnabled": true
跟 Claude 用的監控廠商不同(Claude 用自己的 client-health API),ChatGPT 用 Datadog 做前端效能監控,而且透過 proxy 轉發(避免直接打 Datadog 網域,可能是為了規避廣告攔截器或提升可靠性)。
9. wcDTda_ 前綴的 CSS class(CSS Modules Scoping)
<textarea class="wcDTda_fallbackTextarea" ...>
這種帶隨機字串前綴的 class name 是 CSS Modules 的編譯產物,用來避免樣式衝突(scoped CSS)。這跟 Claude 大量用 Tailwind utility class 的做法不同,是另一種 CSS 架構選擇,跟載入效能沒有直接關係,但反映出兩邊前端技術棧不同。
10. performance.mark 埋點
performance.mark(t) // t = "composer.first-prompt-input"
跟 Claude 到處灑 performance.mark 一樣的手法,用來量測「使用者第一次在輸入框打字」這個關鍵時間點,供內部效能監控使用。
兩邊策略總結對比
| Claude | ChatGPT | |
|---|---|---|
| 核心策略 | 先用 vanilla JS 假殼頂住,React 晚點接手 | 伺服器直接 SSR 出完整真實 DOM |
| 資料預載 | 提前 fetch API,存 Promise 待用 | 資料直接序列化嵌入 HTML(不用 fetch) |
| 漸進渲染 | 無此機制 | React Router Streaming SSR + Suspense boundary 替換 |
| Feature flag | 沒特別看到大型系統 | Statsig,整包評估結果內嵌,體積龐大 |
| 輸入框可用時機 | 假殼提前顯示,之後無縫換成真元件 | 一開始就是真元件(因為是 SSR) |
簡單說,Claude 走的是「輕量假殼子 + 前端接管」,ChatGPT 走的是「伺服器全量算好 + 串流式補完」,兩種都是為了解決同一個問題(首屏可互動時間),但手段完全不同,各有取捨:ChatGPT 的做法首屏內容更完整真實,但 HTML 本身重很多(你這份文件光 Statsig 那段 JSON 就非常龐大);Claude 的做法 HTML 精簡很多,但需要額外一層「交接」邏輯確保換手時不閃爍。
比較兩者的優缺點
Claude(手刻 vanilla JS 骨架 + 事後交接)
優點
- HTML 體積小,下載快:整份文件的 critical path 內容精簡,沒有把大量使用者狀態或 feature flag 塞進 HTML,首包(TTFB 之後的 payload)輕量。
- 邏輯與後端解耦:靜態骨架完全是前端獨立運作,不需要伺服器做任何額外運算或渲染工作,CDN 快取這份 HTML 幾乎不會過期太快(配合 cookie 判斷走條件分支)。
- 可以做精細的裝置適應判斷:例如
pointer: coarse決定要不要自動 focus,這種「純前端才知道的即時裝置狀態」在 SSR 階段伺服器端根本拿不到,Claude 的做法反而更適合處理這類判斷。 - 交接失敗的風險可控:就算 static composer 邏輯完全失效(例如條件判斷全部沒過),使用者頂多是等 React 正常初始化,不會整頁壞掉,屬於漸進增強,向下相容性好。
- 維護分離:static composer 是獨立一小段程式碼,跟 React 元件庫、版型邏輯完全脫鉤,改動 React 元件不會影響這段程式碼,反之亦然。
缺點
- 雙重實作成本:同一個輸入框,工程團隊等於要維護兩套邏輯(vanilla JS 版 + React 版),未來 UI 改版時兩邊都要同步修改,容易出現不一致或遺漏。
- 交接瞬間有風險:焦點轉移、草稿內容轉移、視覺對齊,任何一個環節沒做好都會讓使用者感覺到「畫面跳動」或「打的字不見了」,這是額外的工程複雜度和 QA 負擔。
- 只解決了輸入框,其他內容仍是空的:對話串內容、sidebar 的聊天紀錄清單這些仍然要等 React 完全初始化並發 API 才會出現,所以「可互動」的範圍其實很窄,只有composer 這個小區塊。
- SEO 與可訪問性較弱:如果 JS 被停用或執行失敗,畫面幾乎是空的(只有一個不能用的 disabled textarea),對搜尋引擎爬蟲或極端環境(企業內網封鎖 JS)不友善。
- 偵錯與訊號機制較隱晦:
__claudeStaticComposerSignals這類全域變數式的通訊機制,比起框架原生的狀態管理更難追蹤與測試。
ChatGPT(SSR + Hydration)
優點
- 首屏內容完整且真實:不只是輸入框,整個 sidebar、conversation 歷史、header 全部都是真實可見、可互動的內容(連結可以點、按鈕語意正確),不是空殼。
- SEO 與無 JS 環境更友善:因為 HTML 本身就是完整內容,即使 JS 完全跑不動,使用者仍能看到頁面樣貌,搜尋引擎也能直接抓到內容索引。
- 框架原生支援,維護成本較低:SSR + hydration 是 React Router/Next.js 這類框架的標準機制,不需要額外手刻一套平行邏輯,長期維護與框架升級的相容性較好。
- Streaming SSR 讓「還沒準備好的資料」不會拖慢首屏:用 Suspense boundary(
<template>佔位 +$RC/$RV替換)讓部分資料延遲送達,不用整包等到最慢的那個 API 才能開始渲染,這比「全部等齊才吐出 HTML」更靈活。 - 理論上沒有「交接瞬間跳動」的問題:hydration 是接管既有 DOM 並附加事件監聽,理論上畫面不會像 Claude 那樣經歷「假的換成真的」這個明顯的替換過程。
缺點
- HTML 體積暴增:把大量使用者狀態、Statsig feature flag 評估結果整包序列化塞進 HTML,這份文件的 payload 大小遠超過 Claude 那份,下載與解析這份巨大 HTML 本身就要花時間,某種程度上抵銷了「首屏內容完整」帶來的優勢。
- 伺服器運算成本高:每次請求都要在伺服器端跑一次 React 渲染邏輯(加上算 feature flag),比起 Claude 那種「回傳靜態檔案就好」的做法,伺服器負擔重很多,擴展成本更高。
- Hydration mismatch 風險:SSR 渲染出來的 HTML 跟前端 hydrate 時重新計算出來的狀態,如果哪裡對不上(常見於用到
Date.now()、隨機數、瀏覽器專屬 API 等),React 會丟出 hydration 錯誤或整段重新渲染,這是 SSR 架構特有的一類 bug,Claude 的做法完全不會遇到這種問題。 - TTFB(首位元組時間)較慢:因為伺服器要先完成渲染運算才能開始回應,比起 Claude 那種直接吐靜態檔案,第一個 byte 抵達使用者手上的時間通常會比較久,尤其伺服器忙碌或渲染邏輯複雜時更明顯。
- 快取策略較複雜:因為 HTML 內容因人而異(含個人化資料、feature flag),沒辦法像純靜態頁面一樣直接被 CDN 大範圍快取,每個使用者的首頁 HTML 幾乎都要重新產生,這對邊緣快取(edge caching)不友善。
- 對「快速裝置判斷」較不靈活:伺服器端不容易即時知道使用者當下的螢幕尺寸、pointer 類型這類即時前端狀態,這類判斷通常得延後到前端 JS 執行時才處理,某種程度上失去了「SSR 就決定好版面」的部分優勢。
綜合比較表
| 面向 | Claude(手刻骨架) | ChatGPT(SSR + Hydration) |
|---|---|---|
| 首屏 HTML 大小 | 小 | 大(甚至肥大) |
| 首屏內容完整度 | 低(只有輸入框可用) | 高(整頁真實可互動) |
| 伺服器運算成本 | 低(純靜態) | 高(每次要渲染) |
| TTFB | 快 | 較慢 |
| CDN 快取友善度 | 高 | 低(因人而異) |
| SEO/無 JS 相容性 | 差 | 好 |
| 交接/hydration 風險 | 有(DOM 替換的視覺跳動風險) | 有(hydration mismatch 風險) |
| 工程維護複雜度 | 雙重邏輯需同步維護 | 框架原生支援,較統一 |
| 適合場景 | 高互動性、對話類應用,追求極致啟動速度 | 內容型/需要 SEO、對完整性要求高的應用 |
一句話總結
Claude 用的是「犧牲首屏完整度、換取更快更輕量的可互動時間」的策略;ChatGPT 用的是「犧牲 HTML 體積與伺服器成本、換取首屏內容完整與框架相容性」的策略。兩者沒有絕對的優劣,是針對不同產品需求(Claude 更強調「快速開始打字」的核心互動、ChatGPT 更強調「完整頁面體驗與 SEO」)做出的合理取捨。
回到人類撰寫區域
後來又請 Claude 比較兩者的優缺點,寫的很爛沒有閱讀意義就不貼上來了,基本上看完這坨內容,ChatGPT 的 SSR 做法幾乎沒有優點,Claude 的假骨架方案卻是實實在在的優點。