側邊欄從 200px 變成 48px:一行指向空氣的 grid-area

Read this article in English →

側邊欄從 200px 變成 48px:一行指向空氣的 grid-area

手機版打開自己的網站,內容欄被壓成細細一條,中文一個字一個字往下換行,整頁高度爆到五千多像素。桌機版看起來完全正常。RWD 的 media query 明明寫著「小螢幕改單欄」,而且那段 CSS 從頭到尾沒有被改過。

檢查了字型、檢查了 word-break、檢查了 flex 的 min-width: auto 經典陷阱——全都不是。真正的兇手是一行看起來完全無害、而且已經失效很久的宣告:

.tag-sidebar {
  grid-area: sidebar;
}

父層的 .layout-grid 從來沒有定義過 grid-template-areas。也就是說,這個 sidebar 區域根本不存在。

直覺上,引用一個不存在的具名區域應該跟寫錯屬性名稱一樣——瀏覽器看不懂,就當作沒看到。但 CSS Grid 不是這樣運作的。 它不會忽略,它會「盡力滿足你」,而滿足的方式是憑空長出新的軌道。

先看數字:同一份 CSS,差一行的差別

與其描述,不如量。下面是一份最小重現:兩欄 grid,父層 grid-template-columns: minmax(0, 1fr) 200px,@media (max-width: 1023px) 時改成單欄 1fr。唯一的變數是子元素有沒有那行 grid-area: sidebar。

用 Playwright 讀 getComputedStyle(layout).gridTemplateColumns,得到的是瀏覽器實際算完的軌道,而不是你寫進 CSS 的那串字:

視窗寬度grid-area實際算出的軌道側欄寬度
1280px沒有1056px 200px200px
1280pxsidebar960px 200px 0px 48px48px
390px沒有390px390px
390pxsidebar294px 0px 48px48px

兩件事值得停下來看。

第一,桌機版從 2 條軌道變成 4 條。你寫了兩條,瀏覽器給了你四條,多出來的是 0px 和 48px。側邊欄沒有落在你以為的第二條軌道上,它被丟到第四條,寬度 48px——那是它裡面最長的一個不可斷詞撐出來的寬度。

第二,也是更違反直覺的:手機版的 media query 等於失效了。CSS 裡明明白白寫著 grid-template-columns: 1fr,結果算出來是 294px 0px 48px 三條軌道。你去 DevTools 看 Styles 面板,那行 1fr 好端端地生效著、沒有被劃掉、沒有被覆寫;問題不在你的宣告被蓋掉,而在瀏覽器在你的宣告之外另外加了東西。

這就是為什麼肉眼看截圖只會覺得「排版很怪」,卻抓不到方向——你會往字型、往換行、往 flex 去找,因為版面欄數看起來就是不對,而你寫的欄數明明是對的。只有去量 computed style,才看得到那兩條你沒寫的軌道。

為什麼會這樣:規格上的三層轉換

這不是 Chromium 的 bug。同一份重現分別在 Chromium、Firefox、WebKit 三個引擎上跑過,三者算出來的軌道字串一模一樣——桌機同樣是 960px 200px 0px 48px,手機同樣是 294px 0px 48px。三個獨立實作同時「錯」成同一個樣子,通常就代表這不是錯,而是規格要求的行為。

它是 CSS Grid 規格三條規則串起來的結果,每一條單獨看都合理。

第一層:grid-area 的展開方式。 根據 MDN,當 grid-area 只給一個 <custom-ident> 時,四個 longhand 全部拿到這個值:

grid-area: sidebar;
/* 等同於 */
grid-row-start: sidebar;
grid-column-start: sidebar;
grid-row-end: sidebar;
grid-column-end: sidebar;

注意這裡已經開始不妙了:一行簡寫,變成四個獨立的定位宣告,橫向與縱向各兩個。

第二層:找不到具名線時的退路。 每個 longhand 會先去找一條叫做 sidebar-start(或 sidebar-end)的具名線。grid-template-areas 會自動替每個具名區域生出這組線,但我們根本沒定義 grid-template-areas,所以找不到。規格對這種情況的處理是:

If there is a named line with the name <custom-ident>-start, it contributes the first such line to the grid item’s placement. Otherwise, this is treated as if the integer 1 had been specified along with the <custom-ident>.

也就是退化成 grid-column-start: sidebar 1——「第 1 條叫做 sidebar 的線」。

第三層,關鍵的一層:連 sidebar 這個名字的線都不存在時怎麼辦。 規格沒有說「放棄」,它說的是:

If a name is given as a <custom-ident>, only lines with that name are counted. If not enough lines with that name exist, all implicit grid lines are assumed to have that name for the purpose of finding this position.

隱式格線(implicit grid lines)會被假定擁有這個名字。瀏覽器為了讓 sidebar 1 這個要求有東西可以對應,就往外長出隱式軌道去湊。你要第 1 條名叫 sidebar 的線,它就把隱式區域的線當成那條線給你。

這三層加起來,結果就是:一個指向空氣的名字,被規格忠實地翻譯成「請在顯式格線之外再開幾條軌道」。而且因為第一層把它展開成了橫向縱向各兩個宣告,這件事在兩個軸上同時發生。

為什麼不會有任何警告

這一段比 bug 本身更值得記住。

CSS 沒有「引用了不存在的東西」這個錯誤類別。屬性名稱拼錯(grid-are: sidebar)會被丟掉,這是解析層的事;但 grid-area: sidebar 語法完全合法——<custom-ident> 就是一個合法的值型別,sidebar 就是一個合法的識別字。解析器沒有任何理由拒絕它。至於這個名字指向什麼,那是版面演算法在跑的時候才處理的事,而版面演算法的規格明確規定了「找不到就用隱式線湊」,所以它也不算失敗。

結果是:

  • Linter 不會抓。 stylelint 沒有辦法在檢查一個檔案時,知道另一個選擇器(甚至另一個檔案)有沒有定義對應的 grid-template-areas。這需要跨選擇器的版面推理,不是 lint 的工作範圍。
  • DevTools 不會標紅。 那行宣告是有效的,Styles 面板會正常顯示它。
  • Build 不會失敗。 對打包工具來說這只是一段合法的 CSS 文字。
  • Console 全乾淨。

整條工具鏈都覺得沒事,只有實際算出來的版面知道有事。這類「安靜地做了別的事」的失敗,比會噴錯的失敗難抓一個數量級,因為你連「該去哪裡找」的線索都沒有。

這行 CSS 是怎麼活下來的

值得補充的是它的來歷,因為這決定了怎麼預防。

一個合理的猜測是:版面原本用具名區域寫,後來改成 grid-template-columns,父層的 grid-template-areas 被刪掉,子元素這行忘了跟著刪。這種「刪掉定義、忘了刪引用」的故事很常見。

但翻了 commit 紀錄之後,事實不是這樣——父層從來沒有定義過 grid-template-areas,一次都沒有。 這個檔案的歷史上只有一個 commit,也就是說這行 grid-area: sidebar 從被寫下的那一刻起就是錯的。它不是後來才失效的遺留物,它從頭到尾沒有對應過任何東西。

這其實比「忘了刪」更值得警惕。如果是刪定義忘了刪引用,至少還存在一個「曾經正確」的版本,code review 時也比較有機會發現不一致。但這個案例是:一行從未正確過的 CSS,被寫進去、通過 review、通過 build、部署上線、在桌機上看起來正常,然後安靜地待著,直到有人在窄螢幕上實測才爆出來。中間沒有任何一個環節有機會攔下它,因為前面說過——整條工具鏈都覺得這行沒問題。

教訓是:grid-template-areas 和子元素的 grid-area 是一組的,要嘛整組用、要嘛整組不用。 寫下 grid-area: 某個名字 的時候,當下就要回頭確認父層真的有這個具名區域;刪掉父層的具名區域定義時,同一個 commit 也要把所有引用它的 grid-area 一起清掉。這跟函式與呼叫點的關係是一樣的,差別只在於 CSS 不會像編譯器那樣告訴你有一邊不見了。

怎麼抓:把「看畫面」換成「量版面」

如果版面歪掉而你找不到原因,這個順序很有用:

第一步,不要再看截圖了。 截圖只告訴你「結果不對」,不會告訴你「哪一層不對」。字型、換行、寬度計算、軌道數量,在截圖上長得都一樣。

第二步,量 computed style,而不是讀你寫的 CSS。 一行就夠:

getComputedStyle(document.querySelector('.layout-grid')).gridTemplateColumns;

回傳的是解析完的實際軌道尺寸(例如 "960px 200px 0px 48px"),不是你寫的 minmax(0, 1fr) 200px。兩者一比對,軌道數量對不上就立刻知道問題出在 grid 定位層,而不是字型或內容寬度。

這一步是整個排查過程的轉捩點。在量到 0px 48px 這兩條之前,所有假設都是錯的方向;量到之後,答案幾乎是自己跳出來的。

第三步,如果要自動化,把它變成回歸測試。 這類 bug 修好之後很容易再犯(下一次有人改版面時),而它剛好非常適合寫成斷言——軌道數量是一個乾淨的數字:

const tracks = await page.evaluate(
  () => getComputedStyle(document.querySelector('.layout-grid')).gridTemplateColumns.split(' ').length,
);
// 窄螢幕應該是單欄
expect(tracks).toBe(1);

比起截圖比對(容易因為字型、抗鋸齒、內容更新而誤報),量軌道數量穩定得多,而且失敗時的訊息直接指向原因。

一句話帶走

CSS 沒有 undefined。你引用一個不存在的具名區域,得到的不是「無效、忽略」,而是規格明確定義的另一種行為——長出隱式軌道。所有工具都會告訴你這段 CSS 沒問題,因為就規格而言它確實沒問題;只有 computed style 知道它做了什麼。

下次遇到「CSS 看起來對、畫面就是不對」,先量再猜。

關於作者

我是 Ryan,RyanOps 的站主。平日的工作是軟體開發與自動化,在這裡整理 AI 模型、開發工具與軟體工程的重要變化,也記錄自己實際除錯、實作過的技術筆記。

關於本站與編輯流程 →