模組五|閱讀端與規模化(Day 26–29)
昨天講章節頁怎麼生成。今天講首頁——一個有 Three.js 粒子背景、滾動動效、角色卡片、燈箱、行動版抽屜的網站,沒有任何建置流程。
規模:
index.html 397 行 22,169 bytes
js/main.js 945 行 78,181 bytes
js/audio.js 7,521 bytes
css/style.css 784 行 47,580 bytes
─────────────
約 155 KB
沒有 package.json、沒有 node_modules、沒有 bundler、沒有 TypeScript、沒有 CI。git clone 之後直接開瀏覽器就是完成品。
Three.js 從 CDN 載,用 importmap。index.html 裡是三個 script 標籤:第一個 type="importmap",內容只有一行對應表;第二個 type="module" 載入 js/main.js;第三個掛 defer 載入 js/audio.js。importmap 的內容長這樣:
{ "imports": { "three": "https://unpkg.com/three@0.160.0/build/three.module.js" } }
然後 main.js 第一行:
import * as THREE from 'three';
這行跟你在有 bundler 的專案裡寫的一模一樣。 importmap 讓瀏覽器知道 'three' 這個裸模組名該去哪裡拿。
沒有 build,但寫法沒有退化。

three@0.160.0
不是 @latest,不是 @^0.160。一個精確的版本號。
這是整個選擇裡最重要的一個決定。
用 CDN 最大的恐懼是「哪天它自己更新了,我的網站就壞了」。釘死版本號就沒有這個問題:unpkg.com/three@0.160.0/ 這個 URL 指向的內容是不可變的。
代價是我不會自動拿到修正和新功能。 但對一個概念網站來說,這個交易非常划算:我要的是它兩年後還能跑,不是它永遠是最新的。
Day 20 講過理由,這裡把它講完整。
個人專案最大的死因不是寫不完,是環境爛掉。
想像兩年後我想改首頁一行文字。有 build step 的版本,我要面對:
npm install 會不會因為某個 transitive dependency 消失而失敗?每一關都可能花掉一個晚上,而我原本只是要改一行字。
而零 build step 的版本,我打開檔案、改字、存檔、重新整理瀏覽器。兩年後跟今天一樣。
**這不是效能取捨,是存活率取捨。**一個改起來要花一個晚上的專案,就是一個不會被改的專案。
onerror fallback。 角色卡的圖片如果載不到:
onerror="this.style.background='linear-gradient(160deg,#161c22,#0c1013)';this.removeAttribute('src')"
不顯示破圖,直接變成一塊漸層。在一個沒有建置檢查、圖片路徑靠人手維護的專案裡,這行很值得。
資料在同一個檔案裡。 main.js 裡有三個陣列:
const UNITS = [ ... ] // 矽魂角色
const ANOMALIES = [ ... ] // 名冊之內的異常
const FIGURES = [ ... ] // 名冊之外
然後 buildUnitCards() / buildAnomalyCards() / buildFigureCards() 把它們注入 DOM。
新增一個角色 = 在陣列裡加一筆。不用改 HTML。
代價一:945 行在一個檔案裡。
main.js 從第 1 行的 import 到第 945 行,裡面有:角色資料、卡片建構、Three.js 初始化、粒子系統、滾動處理、動畫迴圈、載入動畫、reveal 動效、header、行動版導覽、標籤動效、里程碑計數⋯⋯
十幾個互不相干的關注點,共用一個檔案。
我可以拆成多個 ES module,importmap 完全支援。我沒有拆,因為每拆一個檔案就多一次 HTTP 請求,而在沒有 bundler 的情況下,那是真的多一次來回。
這是零 build step 的直接代價:檔案切分會產生實際的效能成本,所以我傾向不切。
代價二:我又犯了一次 Day 12 的錯。
UNITS、ANOMALIES、FIGURES 三個陣列,是寫死在 .js 裡的內容資料。
昨天我才發現章節生成腳本犯了這個錯,今天發現首頁也是。同一個專案,同一個錯,第三個地方。
差別在於這次我有理由:抽成 JSON 要多一次 fetch,而角色卡是首屏內容,我不想讓它等一次網路來回。
這是一個真實的取捨,不是疏忽。 但我要誠實:我當初沒有做這個權衡,我只是順手寫在那裡。事後找到的理由,跟當初的決策過程不是同一回事。
代價三:沒有型別、沒有 lint、沒有測試。
78 KB 的 JavaScript,唯一的檢查機制是「打開瀏覽器看看有沒有壞」。
我踩過的實際案例:行動版的漢堡按鈕完全沒有事件處理器,而導覽列在 860px 以下是隱藏的——所以手機上根本沒有導覽。
這個 bug 存在了一段時間才被發現,因為我平常都在桌機上開。任何一個 lint 或一個最基本的 e2e 測試都會抓到它。
零 build step 買到的存活率,是用這種 bug 換的。
代價四:檔案沒有壓縮。
78 KB 的 main.js 是未壓縮的原始碼,含註解。上 minifier 大概能砍一半。
我接受這個,因為總量 155 KB 在現代網路上不痛。但這條在專案長大時會反轉。
我覺得誠實的說法是:這是一個有明確有效期的決定。
它現在成立,因為:
任何一條改變,結論就要重算:
| 如果⋯⋯ | 那麼 |
|---|---|
| 多一個人一起改 | 需要 lint 與型別來取代口頭約定 |
| 程式碼到 300 KB | 需要 tree-shaking 與壓縮 |
| 要用 TypeScript | 就有 build step 了,那不如全套上 |
| 變成每天都在改 | 開發體驗的成本超過環境維護的成本 |
我不是在主張「不要用 bundler」。我是在說這個決定要對照專案的實際形狀,而不是對照業界預設值。

一、選型要問「兩年後我還改得動嗎」,不只問「現在好不好寫」。
大部分技術選型的討論集中在開發體驗:寫起來順不順、熱重載快不快、生態豐不豐富。
但個人專案、side project、內部工具的真正死因是環境腐化:你回來想改一行字,卻要先花一個晚上修好建置環境,於是你不改了。
判斷句:這個專案放著不動一年,我回來之後多久可以改一行字並看到結果?
答案是「五分鐘」還是「一個晚上」,會決定這個專案的實際壽命。
二、用 CDN 就要釘死版本號。
@latest 是把你的網站的正確性外包給別人的發布節奏。
釘死版本 = 那個 URL 的內容不可變 = 你的網站兩年後跟今天一樣。
代價是不會自動拿到修正,但那是可控的(你想升就改一個數字),而自動更新壞掉是不可控的。
三、零 build step 的代價要具體算,不要籠統地說「可以接受」。
我的代價清單:
這四條我都認,但我是逐條認的,不是一句「小專案沒差」帶過。
如果你發現自己在替某個選擇辯護時只有一句「這個規模沒差」,那代表你還沒把代價列出來。
四、事後找到的理由,跟當初的決策,不是同一回事。
我今天可以替「資料寫死在 main.js」找到一個合理的效能理由。但事實是我當初沒有做這個權衡,我只是順手寫的。
這兩者的差別很重要:真的權衡過的決定,你知道什麼條件下要改;事後合理化的決定,你會一直守著它,因為你以為它有理由。
值得養成的習慣:問自己「我是當初就這樣想的,還是現在才想出來的?」誠實回答的話,你會發現後者比想像的多。
明天 Day 28,講一個大規模重構的決定:20 章的長篇,怎麼變成 200 回的短篇連載,以及一條讓這次重構成本大幅降低的原則——封面不改。