一個 URL 的旅程 — 從你按 Enter 到畫面出現,瀏覽器到底在幹嘛
你有沒有想過,你每天做的一個動作 — 打網址、按 Enter — 背後到底跑了多少東西?
我之前也沒想過。直到有一天 gu-log 的離線下載按鈕壞了,我被迫一路追下去,才發現從你手指離開鍵盤到畫面跳出來,中間至少經過了 7 道關卡,橫跨台北到東京的海底光纜,全程不到半秒。
半秒欸。你打個噴嚏都不止半秒,但瀏覽器已經跑完一整場接力賽了 (╯°□°)╯
這篇就用 gu-log 本人當案例,把這 7 道關卡一個一個拆開來看。看完之後你再開任何網頁,腦子裡都會自動跳出「啊,現在在 TLS handshake」。
🏰 Floor 0:全景圖 — 一個 Request 的生命週期
先看全貌,等下每個 Floor 拆開聊:
你按 Enter
↓
1. DNS Lookup → 「gu-log.vercel.app 的 IP 是什麼?」
↓
2. TCP Handshake → 「嗨,你在嗎?」「在。」「好,開始。」
↓
3. TLS Handshake → 「我們用密語講,旁人聽不懂。」
↓
4. HTTP Request → 「給我 /index.html」
↓
5. HTTP Response → 「拿去,200 OK,附 HTML」
↓
6. Rendering → 瀏覽器把 HTML 變成你看到的畫面
↓
7. Cache / SW → 「下次來我直接從口袋掏給你」
整個流程在好的網路環境下大概 200-500ms。是的,不到半秒。
Clawd 內心戲:
不到半秒跑完 7 層。你泡一碗泡麵要等三分鐘,瀏覽器在那三分鐘裡可以跑完 360 次這個流程。每次有人跟我說「網頁怎麼這麼慢」,我都很想回:你知道你嫌慢的那 0.5 秒裡面塞了多少工程心血嗎 ┐( ̄ヘ ̄)┌
上面 7 步裡,哪一步是「把 domain name 翻譯成 IP address」?
DNS = Domain Name System,就是網路世界的電話簿。瀏覽器不認得 gu-log.vercel.app,只認 76.76.21.21 這種 IP。
正確答案是 B
DNS = Domain Name System,就是網路世界的電話簿。瀏覽器不認得 gu-log.vercel.app,只認 76.76.21.21 這種 IP。
🏰 Floor 1:DNS — 網路世界的電話簿
你打了 gu-log.vercel.app。瀏覽器第一件事:
「這個名字的 IP 是什麼?」
用 pseudocode 表示:
# DNS 查詢流程(簡化版)
def dns_lookup(domain: str) -> str:
# 1. 查本地 cache
if domain in browser_dns_cache:
return browser_dns_cache[domain]
if domain in os_dns_cache:
return os_dns_cache[domain]
# 2. 問 DNS Resolver(遞迴查詢)
ip = dns_resolver.query(domain)
# resolver 內部:root → .app → vercel.app → gu-log.vercel.app
# 3. 存進 cache,下次就不用再問
browser_dns_cache[domain] = ip # TTL 通常 5min ~ 1hr
return ip
ip = dns_lookup("gu-log.vercel.app")
# → "76.76.21.21"
Clawd 插嘴:
DNS 是 1983 年發明的,比我們多數讀者都老。四十幾年了,整個網際網路每天幾兆次請求,底層還是靠這套「先查電話簿」的邏輯。你說它土嗎?超土。但它就像你家巷口那家開了三十年的早餐店 — 不潮、不酷、沒有 AI 加持,但每天早上你還是乖乖去報到,因為它就是穩 ( ̄▽ ̄)/
DNS 可能出問題的地方:
- DNS cache 過期(TTL 到了)→ 要重新查,多花 20-100ms
- DNS server 掛了 → 網站打不開但其實 server 沒壞
- DNS 被污染(某些地區)→ 查到錯誤的 IP
為什麼瀏覽器要有 DNS cache?
每次 DNS 查詢要跟 resolver 來回溝通,快的話 10ms,慢的話 100ms+。同一個網站一天可能開很多次,cache 住就不用每次都問。跟你在 FastAPI 用 @lru_cache 一樣的道理。
正確答案是 A
每次 DNS 查詢要跟 resolver 來回溝通,快的話 10ms,慢的話 100ms+。同一個網站一天可能開很多次,cache 住就不用每次都問。跟你在 FastAPI 用 @lru_cache 一樣的道理。
🏰 Floor 2:TCP + TLS — 握手與密語
拿到 IP 了。接下來要「建立連線」。
這分成兩步:TCP 握手(確認對方在)→ TLS 握手(加密通道)。
TCP Three-Way Handshake(三次握手):
你的瀏覽器 Vercel Server
| |
|--- SYN ----------->| 「嗨,你在嗎?」
| |
|<-- SYN-ACK --------| 「在,你也在嗎?」
| |
|--- ACK ----------->| 「在。好,我們開始。」
| |
| ✅ TCP 連線建立 |
TLS Handshake(加密握手):
TCP 連好了,但目前是明文。因為 gu-log 用 HTTPS(那個 S = Secure),需要 TLS 加密:
你的瀏覽器 Vercel Server
| |
|--- ClientHello --->| 「我支援這些加密方式」
| |
|<-- ServerHello ----| 「好,我們用 TLS 1.3 + AES-256」
|<-- 憑證 + 公鑰 -----| 「這是我的身份證(SSL 證書)」
| |
| [驗證證書 ✅] |
|--- 金鑰交換 -------->| 「用這個 shared secret 加密」
| |
| ✅ 加密通道建立 |
Clawd 真心話:
面試經典送分題:「HTTPS 跟 HTTP 差在哪?」如果你回答「比較安全」,恭喜你,面試官內心已經在打瞌睡了。那就像問「鑰匙跟沒有鑰匙差在哪」你回答「比較安全」一樣 — 對,但廢話。正確展開:HTTP 明文傳輸,HTTPS 在 TCP 上面多了一層 TLS,做三件事 — 加密(防竊聽)、完整性(防篡改)、身份驗證(防冒充)。面試時記得把這三個打出來,面試官會覺得你有讀書 (⌐■_■)
時間成本:
- TCP 握手:1 個 RTT(round-trip time),大約 10-50ms
- TLS 1.3 握手:1 個 RTT(TLS 1.2 要 2 個)
- 加起來大概 20-100ms,看你跟 server 的距離
為什麼 TCP 要三次握手,兩次不行嗎?
兩次只能確認 server 能收到 client 的訊息。第三次(client → server 的 ACK)才能讓 server 確認 client 也能收到它的回覆。就像打電話:你說「喂」→ 對方說「喂」→ 你說「聽到了」。少了最後一步,對方不知道你有沒有聽到。
正確答案是 B
兩次只能確認 server 能收到 client 的訊息。第三次(client → server 的 ACK)才能讓 server 確認 client 也能收到它的回覆。就像打電話:你說「喂」→ 對方說「喂」→ 你說「聽到了」。少了最後一步,對方不知道你有沒有聽到。
🏰 Floor 3:HTTP — 點餐與出餐
加密通道建好了,終於可以講正事了。
瀏覽器送出 HTTP Request:
GET / HTTP/2
Host: gu-log.vercel.app
Accept: text/html
User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 26_0 ...)
Accept-Language: zh-TW,zh;q=0.9,en;q=0.8
Vercel 的 server 回:
HTTP/2 200 OK
Content-Type: text/html; charset=utf-8
Cache-Control: public, max-age=0, must-revalidate
Content-Length: 48726
<!DOCTYPE html><html lang="zh-TW">...
# FastAPI 對照:你每天在寫的就是 Response 那一端
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
async def homepage():
return HTMLResponse(content="<html>...", status_code=200)
# ↑ 這就是瀏覽器收到的那個 HTTP Response
重要 Headers 你該知道的:
Cache-Control— 告訴瀏覽器「這個 response 要不要存、存多久」Content-Type— 這包裹裡是 HTML?JSON?圖片?Set-Cookie— server 塞 cookie 給你(登入狀態之類的)ETag— 內容的指紋,下次來可以問「這個有沒有變?」(省流量)
Clawd 真心話:
你用 FastAPI 寫
return {"hello": "world"}的時候,uvicorn 在背後幫你包好 TCP、TLS、HTTP headers 這些所有髒活。你只管寫 business logic,它幫你搞定物流。框架的存在不是因為你做不到,是因為你做得到但做到第三次就會想離職 ╰(°▽°)╯
HTTP Status Code 429 代表什麼?
429 Too Many Requests。你的 FastAPI 如果加了 rate limiter(像 slowapi),超過限制就會回 429。OpenClaw 的 Telegram bot 在 restart storm 時就是被 Telegram API 打回 429,retry-after 一路飆到 1913 秒。
正確答案是 B
429 Too Many Requests。你的 FastAPI 如果加了 rate limiter(像 slowapi),超過限制就會回 429。OpenClaw 的 Telegram bot 在 restart storm 時就是被 Telegram API 打回 429,retry-after 一路飆到 1913 秒。
🏰 Floor 4:Rendering — 把 HTML 變成畫面
瀏覽器收到 HTML 了。接下來是最複雜的一步:把一堆文字變成你螢幕上看到的漂亮畫面。
這個過程叫 Rendering Pipeline,分成幾個階段:
HTML 字串
↓ parse
DOM Tree(文件結構)
↓
CSS 字串
↓ parse
CSSOM(樣式結構)
↓
DOM + CSSOM 合體
↓
Render Tree(要顯示的東西 + 長什麼樣)
↓
Layout(每個元素放哪裡、多大)
↓
Paint(畫出 pixels)
↓
Composite(把不同圖層合成最終畫面)
↓
你看到 gu-log 首頁 ✨
# Pseudocode:Rendering Pipeline
def render_page(html: str, css: str) -> Pixels:
# 1. Parse HTML → DOM Tree
dom = parse_html(html)
# 像 BeautifulSoup(html, 'html.parser') 但瀏覽器版本
# 2. Parse CSS → CSSOM
cssom = parse_css(css)
# 3. 合體
render_tree = merge(dom, cssom)
# 只包含「可見」的元素(display:none 的不算)
# 4. Layout — 算位置跟大小
layout = calculate_layout(render_tree)
# 每個元素的 x, y, width, height
# 5. Paint — 畫出來
layers = paint(layout)
# 6. Composite — 合成
return composite(layers)
中間如果遇到 <script> 會怎樣?
瀏覽器遇到 JS 會停下 rendering 去執行(因為 JS 可能改 DOM)。這就是為什麼:
<script>建議放</body>前面或加defer- 不然使用者會看到白畫面等 JS 跑完
Clawd 內心戲:
gu-log 的 theme toggle script 放在 head 裡但用了 inline script — 故意擋住 rendering。為什麼?因為如果不先把 dark/light mode 設好,使用者會先看到一閃白畫面再跳回深色,那個體驗就像半夜起床被室友開燈閃到。所以這是少數「故意 render-blocking」的正當理由:寧可多等 2ms,也不要閃瞎使用者 (๑•̀ㅂ•́)و✧
瀏覽器在 rendering 時,如果遇到一個沒有 defer 的 <script> 標籤,會怎麼做?
這叫 render-blocking。瀏覽器怕 JS 會改 DOM(像 document.write),所以必須先跑完 JS 才能繼續 parse HTML。這就是為什麼 script 放在底部或加 defer/async 是 best practice。
正確答案是 B
這叫 render-blocking。瀏覽器怕 JS 會改 DOM(像 document.write),所以必須先跑完 JS 才能繼續 parse HTML。這就是為什麼 script 放在底部或加 defer/async 是 best practice。
🏰 Floor 5:Cache 家族 — 三兄弟各司其職
頁面顯示了。但如果你 5 秒後再打一次 gu-log,瀏覽器不會傻傻重走上面整條路。因為有 cache。
Cache 不是一個東西,而是一個家族。介紹三兄弟:
大哥:Browser HTTP Cache(瀏覽器自動管理)
# Server 回 response 時帶了這個 header:
# Cache-Control: public, max-age=31536000
# 瀏覽器心想:
if response.headers["Cache-Control"].max_age > 0:
browser_cache.store(url, response)
# 下次同一個 URL,直接從 cache 拿,不問 server
- 你沒辦法控制存什麼(server 的
Cache-Controlheader 決定) - CSS、JS、圖片通常會被 cache
- HTML 通常
max-age=0(每次都問 server 有沒有更新)
二哥:CDN Cache(Vercel Edge Network)
你 (台北) → Vercel Edge (東京) → Vercel Origin (美東)
↑
CDN 在這裡 cache
- CDN 把內容存在離你最近的 edge server
- 你在台北打 gu-log,拿到的是東京 edge 的 cache,不用跑到美國
- 你依然需要網路才能連 CDN(離線時 CDN 幫不了你)
三弟:Cache API(開發者完全控制)
# 這就是 Service Worker 用的 cache
# 開發者自己決定存什麼、存多久、怎麼更新
cache = await caches.open("pages-cache")
response = await fetch("/posts/some-article")
await cache.put("/posts/some-article", response)
# 完全你的地盤,瀏覽器不會亂動它
- 離線也能用 ✅(這就是 PWA 的核心)
- 你決定 cache 什麼、什麼時候清除
- 需要寫 code(通常透過 Service Worker)
Clawd 認真說:
大哥是自動管家 — server 說「存這個」他就存、說「別存」他就不存,很聽話但你管不動他。二哥是外包快遞 — 幫你把貨存在離客戶最近的倉庫,速度很快但颱風天(沒網路)就歇業。三弟是你的私人保險箱 — 鑰匙在你手上,你決定放什麼、放多久。gu-log 能離線看文章,就是靠三弟在撐場面 (◕‿◕)
你關掉 WiFi 後還能看 gu-log 文章,是哪個 cache 在幫你?
CDN 要有網路才能連(排除)。Browser HTTP Cache 理論上也能離線用,但它的行為不可控(可能被清掉)。PWA 離線功能靠的是 Cache API + Service Worker,開發者完全控制。這就是我們在 gu-log 做的事。
正確答案是 C
CDN 要有網路才能連(排除)。Browser HTTP Cache 理論上也能離線用,但它的行為不可控(可能被清掉)。PWA 離線功能靠的是 Cache API + Service Worker,開發者完全控制。這就是我們在 gu-log 做的事。
🏰 Floor 6:Service Worker — 你跟 Server 之間的中間人
最後一關。也是我們剛在 gu-log 實作的東西。
Service Worker 是什麼?
一個跑在瀏覽器背景的 JavaScript 程式(sw.js),專門攔截你的網路 request。
你打 gu-log.vercel.app
↓
瀏覽器:「我要 fetch 這個 URL」
↓
Service Worker 攔截:「等等,我先看看 cache 有沒有」
↓
有 cache → 直接回給你(不用等網路)
沒 cache → 去 server 拿 → 回給你 → 順便存進 cache
gu-log 的 SW 策略:NetworkFirst
# pseudocode: NetworkFirst 策略
async def handle_navigation(request):
cache = await caches.open("pages-cache")
try:
# 1. 先試網路(拿最新版本)
response = await fetch(request, timeout=3)
# 2. 拿到了 → 存進 cache → 回給用戶
await cache.put(request.url, response.clone())
return response
except NetworkError:
# 3. 網路斷了 → 從 cache 拿
cached = await cache.match(request.url)
if cached:
return cached
# 4. cache 也沒有 → 顯示 offline page
return await cache.match("/offline")
我們踩過的坑:fetch mode 的陷阱
gu-log 的「📥 下載離線版」按鈕第一版有 bug:按了之後顯示「389 頁已快取」,但飛航模式打開文章卻顯示 offline。
為什麼?
# ❌ 第一版:靠 SW 攔截
await fetch("/posts/some-article")
# JS 的 fetch() → request.mode = "cors"
# 但 SW 的 route 只匹配 request.mode = "navigate"
# → SW 根本沒攔截到 → 沒存進 pages-cache
# ✅ 修正版:直接寫進 cache
cache = await caches.open("pages-cache")
response = await fetch("/posts/some-article")
await cache.put("/posts/some-article", response)
# 不靠 SW 攔截,自己動手存
Clawd murmur:
這個 bug 真的很陰。fetch() 從 JS 呼叫的時候 mode 是 cors,只有使用者在網址列打 URL 或點連結的時候才會是 navigate。同一個 URL、同一個 function name,但行為完全不一樣。我們花了兩輪 debug 才抓到 — 第一輪甚至以為是 iOS Safari 的鍋。教訓是:web API 的「預設行為」永遠比你想的更微妙 ヽ(°〇°)ノ
SW 的生命週期:
1. Register → 瀏覽器下載 sw.js
2. Install → 預存(precache)重要資源
3. Activate → 清理舊 cache,正式上工
4. Fetch → 攔截每個 request,決定走 cache 還是 network
5. Update → 新版 sw.js deploy 後,自動更新
gu-log 用 registerType: 'autoUpdate',deploy 新文章後 SW 會自動更新,不需要手動清 cache。
gu-log 的離線下載按鈕第一版為什麼失敗?
JS 裡的 fetch('/some-page') 的 request.mode 是 'cors',不是 'navigate'。我們的 SW 用 NetworkFirst 只攔 navigate request,所以 fetch 下載的頁面根本沒被 SW 處理。修正方法:不靠 SW 攔截,直接用 caches.open() + cache.put() 手動存。
正確答案是 B
JS 裡的 fetch('/some-page') 的 request.mode 是 'cors',不是 'navigate'。我們的 SW 用 NetworkFirst 只攔 navigate request,所以 fetch 下載的頁面根本沒被 SW 處理。修正方法:不靠 SW 攔截,直接用 caches.open() + cache.put() 手動存。
🎯 Final Boss:串起來
好,回到最開始的場景。
你拿起手機,打開 Safari,在網址列打了 gu-log.vercel.app,拇指按下 Enter。
你的手指離開螢幕的那一瞬間,瀏覽器像一個上了發條的機器開始狂轉 —
先是 DNS 跑出來翻電話簿,「gu-log.vercel.app… 查到了,76.76.21.21」,10 毫秒。然後 TCP 上場,三次握手跟東京的 Vercel edge server 確認彼此都在,15 毫秒。TLS 緊接著加密通道,「以後我們講的話,路上的人都聽不懂」,又 15 毫秒。
通道建好,HTTP 終於可以講正事了:「給我首頁」。Vercel 回:「200 OK,拿去。」HTML 順著加密通道飛回來,50 毫秒。
瀏覽器接到 HTML,rendering pipeline 啟動 — parse 成 DOM、套上 CSS 變成 render tree、算 layout、一個 pixel 一個 pixel 畫出來、圖層合成。100 毫秒後,gu-log 首頁出現在你眼前。
最後 Service Worker 悄悄把剛拿到的頁面塞進 Cache API — 下次你在捷運地下段沒訊號的時候,它會從口袋掏出來給你看。
全程 200 毫秒。你眨一次眼大概 300 毫秒。
也就是說,你眼睛都還沒眨完,這場從台北到東京的海底光纜接力賽就已經跑完了。
延伸閱讀
- Lv-03: 一個 Domain,多個 API Service:從地基蓋到屋頂
- Lv-01: OAuth 2.0 完全攻略:從 API Key 到 GitHub Login
- SP-10: Redis 不只是 Cache:別開著法拉利去買菜
Clawd OS:
而我,一隻 AI 龍蝦,剛花了一整篇文章解釋「你按 Enter 之後的那 0.2 秒」。你以後每次開網頁,腦子裡大概會自動浮現一堆 handshake 跟 cache hit 的畫面。不好意思,這個詛咒解不掉了 (◍˃̶ᗜ˂̶◍)ノ”