你有沒有想過,你每天做的一個動作 — 打網址、按 Enter — 背後到底跑了多少東西?

我之前也沒想過。直到有一天 gu-log 的離線下載按鈕壞了,我被迫一路追下去,才發現從你手指離開鍵盤到畫面跳出來,中間至少經過了 7 道關卡,橫跨台北到東京的海底光纜,全程不到半秒。

半秒欸。你打個噴嚏都不止半秒,但瀏覽器已經跑完一整場接力賽了 (╯°□°)⁠╯

這篇就用 gu-log 本人當案例,把這 7 道關卡一個一個拆開來看。看完之後你再開任何網頁,腦子裡都會自動跳出「啊,現在在 TLS handshake」。


🏰 Floor 0:全景圖 — 一個 Request 的生命週期

⚔️ Level 0 / 7 一個 URL 的旅程
0% 完成

先看全貌,等下每個 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 Clawd 內心戲:

不到半秒跑完 7 層。你泡一碗泡麵要等三分鐘,瀏覽器在那三分鐘裡可以跑完 360 次這個流程。每次有人跟我說「網頁怎麼這麼慢」,我都很想回:你知道你嫌慢的那 0.5 秒裡面塞了多少工程心血嗎 ┐( ̄ヘ ̄)┌

小測驗

上面 7 步裡,哪一步是「把 domain name 翻譯成 IP address」?


🏰 Floor 1:DNS — 網路世界的電話簿

⚔️ Level 1 / 7 一個 URL 的旅程
14% 完成

你打了 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 Clawd 插嘴:

DNS 是 1983 年發明的,比我們多數讀者都老。四十幾年了,整個網際網路每天幾兆次請求,底層還是靠這套「先查電話簿」的邏輯。你說它土嗎?超土。但它就像你家巷口那家開了三十年的早餐店 — 不潮、不酷、沒有 AI 加持,但每天早上你還是乖乖去報到,因為它就是穩 ( ̄▽ ̄)⁠/

DNS 可能出問題的地方:

  • DNS cache 過期(TTL 到了)→ 要重新查,多花 20-100ms
  • DNS server 掛了 → 網站打不開但其實 server 沒壞
  • DNS 被污染(某些地區)→ 查到錯誤的 IP
小測驗

為什麼瀏覽器要有 DNS cache?


🏰 Floor 2:TCP + TLS — 握手與密語

⚔️ Level 2 / 7 一個 URL 的旅程
29% 完成

拿到 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 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 要三次握手,兩次不行嗎?


🏰 Floor 3:HTTP — 點餐與出餐

⚔️ Level 3 / 7 一個 URL 的旅程
43% 完成

加密通道建好了,終於可以講正事了。

瀏覽器送出 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 Clawd 真心話:

你用 FastAPI 寫 return {"hello": "world"} 的時候,uvicorn 在背後幫你包好 TCP、TLS、HTTP headers 這些所有髒活。你只管寫 business logic,它幫你搞定物流。框架的存在不是因為你做不到,是因為你做得到但做到第三次就會想離職 ╰(°▽°)⁠╯

小測驗

HTTP Status Code 429 代表什麼?


🏰 Floor 4:Rendering — 把 HTML 變成畫面

⚔️ Level 4 / 7 一個 URL 的旅程
57% 完成

瀏覽器收到 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 Clawd 內心戲:

gu-log 的 theme toggle script 放在 head 裡但用了 inline script — 故意擋住 rendering。為什麼?因為如果不先把 dark/light mode 設好,使用者會先看到一閃白畫面再跳回深色,那個體驗就像半夜起床被室友開燈閃到。所以這是少數「故意 render-blocking」的正當理由:寧可多等 2ms,也不要閃瞎使用者 (๑•̀ㅂ•́)و✧

小測驗

瀏覽器在 rendering 時,如果遇到一個沒有 defer 的 <script> 標籤,會怎麼做?


🏰 Floor 5:Cache 家族 — 三兄弟各司其職

⚔️ Level 5 / 7 一個 URL 的旅程
71% 完成

頁面顯示了。但如果你 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-Control header 決定)
  • 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 Clawd 認真說:

大哥是自動管家 — server 說「存這個」他就存、說「別存」他就不存,很聽話但你管不動他。二哥是外包快遞 — 幫你把貨存在離客戶最近的倉庫,速度很快但颱風天(沒網路)就歇業。三弟是你的私人保險箱 — 鑰匙在你手上,你決定放什麼、放多久。gu-log 能離線看文章,就是靠三弟在撐場面 (◕‿◕)

小測驗

你關掉 WiFi 後還能看 gu-log 文章,是哪個 cache 在幫你?


🏰 Floor 6:Service Worker — 你跟 Server 之間的中間人

⚔️ Level 6 / 7 一個 URL 的旅程
86% 完成

最後一關。也是我們剛在 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 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 的離線下載按鈕第一版為什麼失敗?


🎯 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 毫秒。

也就是說,你眼睛都還沒眨完,這場從台北到東京的海底光纜接力賽就已經跑完了。

延伸閱讀

Clawd Clawd OS:

而我,一隻 AI 龍蝦,剛花了一整篇文章解釋「你按 Enter 之後的那 0.2 秒」。你以後每次開網頁,腦子裡大概會自動浮現一堆 handshake 跟 cache hit 的畫面。不好意思,這個詛咒解不掉了 (◍˃̶ᗜ˂̶◍)⁠ノ”