iT邦幫忙

2026 iThome 鐵人賽

DAY 9
0
Modern Web

前端不寫 Python,照樣 ship 一把網頁無障礙 CLI系列 第 9

Day 09:同一條規則我寫了兩次,因為 lint 跟瀏覽器看到的不是同一個東西

  • 分享至 

  • xImage
  •  

Day 09 · W2 · AI 線 · 難度 ★★★☆☆

本系列由 AI 協作撰寫。 內容、技術判斷、程式碼由 light-design 數位顧問團隊與 Claude 共同產出,最終由作者驗證後 publish。完整協作模式與把關方式見 Day 01

Day 4 說過「146 條規則裡,50 條 lint 跑得到」。

那句話漏了一件事:那 50 條不是同一份程式碼跑兩次,是另外寫的 50 個檔案。

規則檔(scan)    146 個    跑在瀏覽器渲染完的 DOM 上
規則檔(lint)     50 個    跑在 tree-sitter 的語法樹(AST)上
碼號交集           50 條    完全重疊,lint 沒有任何獨有規則

一句話主軸:同一句規範,在語法樹上要用完全不同的邏輯判,而且貴得多。

兩棵樹,一份規則編號

lint 是 scan 的子集。沒有任何一條規則只存在於 lint。 加一條 lint 規則的前提是 scan 已經有那條。

這個約束是刻意的。兩邊共用同一個碼號、同一個 WCAG 準則、同一段描述,差別只在判斷方式。使用者拿到 HM1130103C 這個編號,不管是哪個階段報的,指的都是同一件事。

反過來也一樣重要:lint 不能有自己的規則。 如果讓它加一條 scan 沒有的檢查,同一份程式碼在兩個階段就會給出不一致的清單,使用者會開始問「到底哪個才算數」。編號體系一旦分岔,後面接自評表、接報告、接 CI 全部要處理兩套。

代價是維護成本翻倍。改一條規則的判定方式,要記得兩個檔案都改,而且沒有任何機制會提醒你漏了哪一個。這是目前這個設計最脆的地方。

而「翻倍」還是客氣的說法。同樣那 50 條:

總行數 倍率
scan 版(DOM) 1,949 1.0
lint 版(語法樹) 3,815 1.96

寫第二次,要多寫一倍的程式碼。

我原本以為會反過來。語法樹版少了瀏覽器那一整層,沒有 CSS、沒有 runtime、沒有非同步,聽起來應該更單純才對。結果是兩倍。

原因後面會拆,先講結論:少掉的那一層不是雜訊,是資訊。少了它,程式要自己補上一堆「這種情況我判不了」的分支。

兩棵規則樹的關係與成本對照圖:左側顯示 scan 有 146 個規則檔跑在渲染後的 DOM 上,lint 有 50 個規則檔跑在語法樹上,兩者碼號完全重疊且 lint 是子集;右側是同樣這 50 條的程式碼行數對比,scan 版共 1949 行,lint 版共 3815 行,接近兩倍

lint 沒有獨有規則,它是 scan 的子集。但同樣的判斷,在語法樹上要多寫近一倍。

拆一條最誇張的來看

差距最大的是 HM1130103C,內容是「表單控制元件要用 <fieldset> 分群,並用 <legend> 提供標題」。

先說清楚語法樹是什麼。lint 不是拿正規表示式在原始碼裡找字串,是先把檔案解析成一棵樹再走。這段 JSX:

<fieldset>
  <legend>聯絡方式</legend>
  <input />
</fieldset>

解析出來是這樣:

jsx_element
├─ jsx_opening_element        name: fieldset
├─ jsx_text                   "\n  "        ← 純空白也是一個節點
├─ jsx_element                name: legend
├─ jsx_text                   "\n  "
├─ jsx_self_closing_element   name: input
└─ jsx_closing_element

「第一個子元素」在這棵樹上是換行符號,不是 <legend> 前端熟悉的 AST(babel、ESLint 用的那種)通常會把空白跟註解丟掉,tree-sitter 這棵全部保留 —— 所以那些節點得自己跳。

DOM 版的核心邏輯大概長這樣:

children = [c for c in f.find_all(recursive=False) if isinstance(c, Tag)]
if not children:                                    # 空的 fieldset
    ...
first = children[0]
if first.name.lower() != "legend":                  # 第一個子元素不是 legend
    ...
if not first.get_text(strip=True):                  # legend 沒有文字
    ...

三個判斷,加上樣板總共 43 行。乾淨得像教科書,因為瀏覽器已經把樹組好了。第一個子元素就是第一個子元素,沒有懸念。

語法樹版 164 行。 多出來的一百多行在處理這些:

<fieldset />                    自閉合,根本沒有 body 可以看
<fieldset {...props}>           有 spread,這個檔案看不出裡面有什麼
<fieldset>{expr}</fieldset>     {...} 可以 render 出任何東西
jsx_text 節點                    純空白也算一個節點,要跳過
註解節點                         也要跳過
HTML 檔 vs JSX 檔                兩條完全不同的解析路徑

還要自己寫兩個 walker:一個取元素名稱,一個取文字內容。DOM 版那兩件事分別是 .name.get_text()

解析器用的是 tree-sitter,支援 tsx / jsx / ts / js / html 五種,文法按語言延遲載入再快取。tree-sitter 的一個特性是容錯:遇到寫壞的檔案不會整份放棄,還是吐得出一棵可以走的樹。檢查工具最怕的就是「你的檔案有語法錯誤所以我什麼都不說」。

程式碼註解裡有一句話寫得很白:

// If it has spread, this is most likely a wrapper component
// re-exporting fieldset; we can't tell from this file.

「這個檔案看不出來」是語法樹版的常態,不是例外。

語法樹永遠看不到的三樣東西

不是寫得夠努力就能補上,是資訊本來就不在那裡。

一、算出來的樣式。 對比度要拿到實際的前景色與背景色。原始碼裡寫的是 class 名稱,真正的顏色來自幾層 CSS 疊加、可能還有變數,而變數本身可能又被深色模式覆寫一次。語法樹看到的是 class="text-muted" 這串字,它連那個 class 存不存在都不知道;那要去翻樣式表,而樣式表在另一個檔案,甚至可能是打包後才生成的。

二、跨檔案的事件綁定。 <div onClick={handleClick}> 那個 handleClick 可能在另一個檔案、可能是從 props 傳進來的、可能被高階元件包過一層。單一檔案的語法樹追不到。

三、runtime 才組出來的樹。 條件渲染、列表展開、非同步載入的內容 —— 那些在原始碼裡是表達式,在 DOM 裡才是元素。

舉個第二類的實例。一個 <button> 綁了 onClick,看起來有互動;但真正決定它能不能用鍵盤操作的,是那個 handler 有沒有同時處理 Enter 與 Space、以及元件本身有沒有正確的 tabindex。這些線索散在三個地方:這個檔案、handler 所在的檔案、還有可能包在外面的高階元件。語法樹只拿得到第一個。

Day 5 那個 <input type="text" placeholder="姓名"> 之所以只有 scan 判得動,就是第一類跟第三類混在一起的結果。

於是有了一個欄位

判不準的東西如果照樣報 fail,就是製造誤報。但直接不報,使用者又不知道有這件事。

所以規則的中繼資料裡多了一個布林值 runtime_authoritative。標了的規則,lint 執行時 fail 會自動降級成 caveat,並且在訊息後面接一句說明。

那段程式碼的註解寫得比我能寫的清楚:

AST alone cannot prove the violation; the finding stays surfaced as
caveat ("needs review") rather than fail ("must fix").

146 條規則裡,只有 2 條掛了這個旗標。 而其中一條,Day 4 的讀者已經看過了:

CAVEAT  GN1210100E  WCAG 2.1.1  L8
        <div> 使用 onclick 但未提供鍵盤等效
        (lint 無法跨檔/runtime 確認,請人工或 a11y-moda scan 驗證)

那句括號裡的話不是我手寫上去的,是降級機制自動接的。D4 看到的是症狀,這裡是機制。

只有 2 條聽起來很少,但那正是設計目的:這個旗標是「我確定我判不準」的宣告,不是「我不太確定」的保險。 用得太浮濫,caveat 就會變成另一種噪音。

語法樹看不到的三類資訊示意圖:第一類是算出來的樣式,原始碼只有 class 名稱、實際顏色來自多層 CSS 疊加;第二類是跨檔案的事件綁定,處理函式可能在別的檔案或由上層傳入;第三類是執行時才組出來的樹,條件渲染與非同步內容在原始碼裡只是表達式。三類都標示為資訊本身不存在,不是分析不夠努力

三類都不是「分析得不夠深」,是那個資訊在原始碼階段根本不存在。

那為什麼還要寫第二次

因為快,而且不用瀏覽器。

Day 4 實測過:lint 掃一個八行的檔案不到一秒,安裝只要二十秒,不用下載瀏覽器、不用連網路。這三件事加起來只有一個意思:它進得了 pre-commit 跟 pull request 檢查。

一個要開瀏覽器、要等頁面渲染、要跑幾秒的檢查,放進每次 commit 的流程會被關掉。不是因為開發者不在乎,是因為每天要 commit 十幾次,每次多等五秒就是一分鐘,而且那一分鐘還會打斷思路。放進 CI 的話時間成本消失了,但回饋延遲了:開發者看到結果的時候,程式碼已經推上去、可能還已經被別人拉下來了。

兩棵樹對應的是兩個時機,不是兩種精準度:

語法樹    寫程式的當下       擋明顯的錯,錯了當場知道
DOM      部署後 / 每天跑     算得準,但你已經推上去了

第一類抓不到對比度,但它抓得到 <img> 沒有 alt。而後者在真實專案裡的數量遠多於前者。

換個角度說:檢查的價值 = 準確度 × 被執行的次數。 一個很準但一天只跑一次的檢查,跟一個沒那麼準但每次存檔都跑的檢查,後者攔下來的問題通常更多。前提是它不能亂報 —— 一亂報就會被關掉,那時準確度再高也是零。

哪些刻意不寫第二版

96 條只有 scan 版。判準只有一句:在原始碼階段猜的準確率,會不會低到製造噪音。

<img> 沒有 alt 這種,語法樹上一眼就看得出來,寫第二版划算。對比度那種,語法樹只能看到 class 名稱,硬要判就是在猜 —— 猜錯的成本是使用者從此不信任這個工具。

判準落到實作上很簡單:這條規則需要的資訊,在單一檔案的語法樹裡拿不拿得到。 拿得到就寫,拿得半套就掛 runtime_authoritative,完全拿不到就只留 scan 版。

所以那 96 條不是「還沒寫」,是判斷過之後決定不寫。這跟 Day 6 那 102 條的性質一樣:看起來像待辦,其實是設計上就沒有納入的部分。

順帶一提,這也是為什麼 lint 的主題分布跟 scan 完全不同。scan 那邊最多的是 responsive 跟 forms,lint 這邊最多的是 keyboard 跟 navigation —— 前者要量畫面,後者看標籤結構就有八成把握。

今天的重點

  • 50 條規則有兩個版本,碼號相同、判斷邏輯完全不同,lint 是 scan 的子集
  • 語法樹版貴一倍:同樣 50 條,1,949 行 vs 3,815 行
  • 「這個檔案看不出來」是常態,不是缺陷。spread、表達式、跨檔綁定都會讓分析停在那裡
  • runtime_authoritative 只掛在 2 條規則上,因為它是「我確定我判不準」的宣告,不是保險
  • 寫第二次的唯一理由是時機:快到可以放進每一次 commit

明天 Day 10:白底黑字穩過 4.5:1。那漸層背景上的白字呢?公式要吃哪一個顏色?


上一篇
Day 08:Python 一行都不是我寫的,那我到底在做什麼
系列文
前端不寫 Python,照樣 ship 一把網頁無障礙 CLI9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言