iPhone 上操作時這個部落格能夠被縮到 100% 以下 — 用 overflow-wrap 與表格捲動容器來解決
在 iPhone 上向內捏時,這個部落格會縮到 100% 以下。WebKit 用內容寬度決定最小縮放比例,所以解決方法是 overflow-wrap 與表格的捲動容器。

本頁目錄
引言
我在手機上對自己的文章用手指內捏,結果整頁縮得比原本的尺寸還小,這感覺不太對所以我決定調查這個問題。
在首頁上沒有這個問題,無論怎麼向內捏頁面最小就維持在 100%;這兩種頁面用的是同一個 <meta name="viewport">,證明問題並非出在這個標籤。
在 iPhone 使用瀏覽器時 WebKit 是用文件實際的內容寬度決定向內捏的最小比例,而且從 iOS 10 起就會忽略 minimum-scale,所以根本沒辦法設定縮放下限。真正的問題是 頁面超出了手機螢幕的寬度,導致瀏覽器為了顯示完整的頁面而縮得比實際大小來得小。
解決方法是兩條 CSS 規則:行內程式碼加上 overflow-wrap: anywhere,以及替任何比內文寬度更寬的表格加上捲動容器。我另外添加了一個檢查,會用手機寬度量測每一篇文章,讓超出螢幕的頁面在上線前就報錯。

我的直覺錯了:viewport meta 標籤無法鎖定縮放比例
沒有任何 viewport 的值可以阻止頁面被縮小。 看到那張截圖,我的直覺是 meta 標籤少了一個下限,但其實這個網站每個頁面用的都是同一個簡單的設定:
<meta name="viewport" content="width=device-width, initial-scale=1" />在那一行加上 minimum-scale=1,正是我一開始想試的修法,但它什麼用也沒有。從 iOS 10 起,WebKit 就完全忽略 user-scalable、minimum-scale 與 maximum-scale。當時的發布公告 講得很直白:
而且這個直覺本身就瞄錯了方向:當網頁內容超出螢幕寬度時,縮小就是讀者的應對方式。 禁止縮小的頁面只會帶來更多問題。
線索在首頁 — 同樣的 meta 標籤,不同的內容
首頁完全不會縮小,而它和每一篇文章來自同一套模板。同一個標籤、兩種行為,所以變因不是標籤,而是兩種頁面各自的內容。
這件事每頁用一個運算式就能確認,而兩邊的數字立刻就差開了:
輸入document.documentElement.scrollWidth輸出390輸入document.documentElement.scrollWidth輸出591兩邊的 clientWidth 都是 390。 390 的可視區域裡裝著 591 寬的文件,也就是有 201px 的內容超出了螢幕外面,只是平常沒有注意到。
同一篇文章不論視窗是 390 還是 500,回報的都是 591,問題就是從這裡開始和螢幕脫鉤的。決定寬度的是文章裡面的某個東西,而一個根本不受可視區域控制的數字,當然也不可能靠調整可視區域來解決。
縮放下限就是文件自己的寬度
WebKit 以 device-width 排版,當它發現內容比它還寬,就會挑一個能把全部內容放進螢幕的比例;用 WebKit 自己的說法,這在概念上等同於讀者一路向內捏到看得見全部內容為止。
所以 最小比例其實是頁面拿自己的尺寸算出來的商:螢幕寬度除以內容寬度。
以上都是在 iPhone 的 Chrome 上實測的結果,截圖也來自那裡。這個行為屬於引擎而不是瀏覽器:iOS 上的瀏覽器一律用 WKWebView 算繪畫面,所以 Safari 和其他瀏覽器同樣繼承了這個行為。
截圖裡的每個數字都是這個商算出來的:591px 的文件配上 430pt 的螢幕,下限是 0.73,而這個網站最糟的一頁文件有 1028px 寬,下限因此掉到了 0.38。需要調整的從來就不是縮放,而是這個商的分母。
掃過全站每一篇文章之後
就算修復了單獨一頁的溢出,也只能改善那一頁,所以下一步是找出這件事到底發生在哪些頁面上。於是我把正式站台網站地圖裡的 356 個頁面全部用 390 × 844 載入,再拿 scrollWidth 和 clientWidth 比較:
輸入node --input-type=module -e "…" # playwright over every route in the sitemap輸出routes measured: 356 overflowing: 63把右緣越過可視區域的元素依標籤分類後是這樣:
| 元素 | 出現在多少個溢出的頁面上 |
|---|---|
code | 63 |
table 與它的儲存格 | 52 |
a | 8 |
strong | 6 |
行內程式碼在 63 篇文章上全部出現,表格則是 52 篇。 我當時在看的那一篇剛好是表格造成的,而全站最糟的那篇文章裡連一個表格也沒有:段落中以行內 code 排版的一段 Windows 登錄檔路徑寬就達到了 1009px。
輸入node scripts/check-overflow.ts輸出 right=1009 div.page > main > article > div.prose > p > code text: "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnloc"Expressive Code 的行 span 幾乎在每一篇文章都越過可視區域,卻完全沒有出現在上面那張表裡。它為什麼沒出現,正是兩個修法背後的整套機制。
方框裝不下的東西,並不會被藏起來。 預設的 overflow: visible 為:內容比方框寬的時候,超出的部分會被算進「這一頁可以往右捲多少」裡面。外面那一層方框照單全收,再外面一層也一樣,一路傳到整份文件。所以段落裡那串 1009px 的文字,最後就讓整份文件變成 1009px 寬。
overflow-x: auto 能夠收納超出頁面內容。 它會使方框變成捲動容器:超出的部分改成在方框裡面捲動。外面的方框只看得到方框本身的寬度,多出來的部分不會傳過去。文字還是一樣寬,只是計算整份文件寬度不再把超出的部分算進去。
所以 這些 overflow-x: auto 容器內的行 span 並不會撐寬整個頁面。它們所在的 <pre> 本來就會捲動,而不會超出方框外;而一個只要看到元素越過可視區域就回報的檢查,會在每一篇文章上沒完沒了地報這些 span。
讓程式碼換行:用 overflow-wrap,不是 break-word
瀏覽器不會去斷一個沒有斷點的字,所以如果行內程式碼裡有一段很長的字串,就會直接把整份文件撐到跟它一樣寬。解決方法是在這個網站本來就有的選擇器上新增一行 overflow-wrap: anywhere:
:not(pre) > code { background: var(--bg-2); border: 1px solid var(--border); border-radius: 4px; padding: 0.1em 0.35em; overflow-wrap: anywhere;}在這裡我用 anywhere 而不是 break-word,因為 只有 anywhere 會縮小元素的 min-content 寬度。
因此它的作用不只是讓段落裡那段長字串換行。每一個含有行內程式碼的表格儲存格也會跟著變窄,在這裡儲存格佔絕大多數,因此表格問題也就跟著少掉一件。
把表格裝進容器:兩個候選做法,以及 DevelopersIO 的做法
一個寬到會溢出的表格有兩種修法,而我想知道每天都在發表格的網站是怎麼處理的。DevelopersIO 的日文技術文章裡到處都是表格,所以我用同樣的方法實測了它的其中一頁:
| dev.classmethod.jp | 這個部落格(修正前) | |
|---|---|---|
| viewport meta | width=device-width, initial-scale=1 | 完全相同 |
390 下的 documentElement.scrollWidth | 390 | 591 到 1028 |
表格的 display 計算值 | block | table |
表格的 overflow-x 計算值 | auto | visible |
| 外層的包裝元素 | 沒有 | 沒有 |
行內程式碼的 overflow-wrap 計算值 | break-word | 未設定 |
他們採用的是純 CSS 的修法,整份頁面沒有任何包裝元素:把表格變成區塊方框並讓它捲動。這行得通也不需要建置步驟,而且只有兩行,但這有個代價。
display: block,以及窄表格為此付出的代價
代價落在那些本來就不寬的表格上。 內容比內文寬度還窄的表格不再撐滿整行,而是縮成只有自己文字那麼寬,那一行其餘的部分就空著。用這個網站自己的版面量:712px 的內文寬度裡,原本是 711px,之後變成 139px。
display: block 只留下自己文字的寬度;本來就比內文寬的表格,兩種寫法看起來一樣。DevelopersIO 選擇接受這個代價。 我實測了他們 8 篇文章,7 個表格裡有 4 個比內文寬度還窄,因此縮在左邊顯示。內容比內文寬的表格則看不出差別。
包裝元素,以及這邊選它的理由
另一種做法是把表格包進一個會捲動的元素,表格本身完全不動。每個表格都維持 display: table,所以畫面上什麼都沒變,只有太寬的那些會多出捲軸。而我發現我其實早就寫好了一條規則卻從來沒使用過:
/* Wide content must scroll in its own box, never the page body. */.table-scroll { overflow-x: auto;}我使用了一個 rehype 外掛,在建立語法樹時把每個 Markdown 表格包起來:
if (child.type === "element" && child.tagName === "table") { children[i] = { type: "element", tagName: "div", properties: { className: ["table-scroll"], tabIndex: 0 }, children: [child], };}markdown.rehypePlugins 已經被標為淘汰:
[astro] `markdown.remarkPlugins`, `markdown.rehypePlugins`, and`markdown.remarkRehype` are deprecated. Pass them to `unified({...})` from`@astrojs/markdown-remark` directly instead.import { unified } from "@astrojs/markdown-remark";
markdown: { processor: unified({ rehypePlugins: [rehypeTableScroll] }),},unified() 就是 Markdown 處理器本身,負責把 Markdown 解析成 HTML。Astro 基於這個處理器之上讓我們能夠把外掛清單加上去:remark 外掛作用在 Markdown 樹上,rehype 外掛作用在轉換之後的 HTML 樹上。rehypeTableScroll 包的是 <table> 元素,而它要等 HTML 樹出現以後才會出現。
傳進去的選項會被套用,而沒傳的部分則沿用預設值,所以只註冊一個 rehype 外掛不會覆寫其他設定。GFM 因此仍然運作著,而 GFM 正是替 Markdown 補上生成表格的語法擴充。少了它就無法正確生成表格。
tabIndex: 0 讓讀者可以用 Tab 鍵移到捲動容器,再用方向鍵把寬表格右邊的欄位捲出來。
新增的檢查關卡:每篇文章都在 390px 下量測
這次的普查後來變成一支指令碼 pnpm check:overflow,它會用 390 × 844 載入所有語系的每篇文章與工具頁面,並把任何 scrollWidth 超過 clientWidth 的頁面判為失敗:
輸入pnpm check:overflow輸出Nothing overflows. 211 route(s) measured at 390px.我在尚未修改的網站做了同樣的測試:
輸入FIT_BASE_URL=https://oharu121.com pnpm check:overflow輸出63 route(s) wider than the viewport, of 191 measured.只要某個元素外面已經有方框會捲動或裁切,也就是 overflow-x 的計算值是 auto、scroll 或 hidden,這個元素就算合格。這讓 Expressive Code 的 span 不會被報錯,而表格和段落仍然會被報出來。
這個檢查還會 實際去探測列表頁的後續分頁,而不是用算的。 這找出了 13 條從 POSTS_PER_PAGE 推算會漏掉的文章,讓受測範圍從 198 變成了 211。
這個檢查沒有放進 pnpm check,因為它需要一個正在執行的伺服器,這也正是這個 repo 的圖表檢查同樣被排除在外的理由。改由發布步驟去跑,對象是它自己已經啟動的開發伺服器。
總結
在 iPhone 上能被向內捏到 100% 以下的頁面,是因為它為了顯示內容完整的寬度,而不是少了某個 viewport 的值。WebKit 用螢幕寬度除以內容寬度決定比例下限,並且忽略了 minimum-scale。
我使用了兩條規則來解決這個問題。一是行內程式碼的 overflow-wrap: anywhere;用 anywhere 而不是 break-word,min-content 也會跟著縮小,含有程式碼的表格儲存格因此變窄。
另一個是替寬表格加上捲動容器,做成包裝元素而不是在表格本身用 display: block,這樣本來就放得下的表格仍然填滿整行。包裝元素帶著 tabindex="0",讀者可以用 Tab 鍵移到那裡,再用方向鍵把表格右邊的欄位捲出來。
剩下的就是實測:在手機寬度下拿 scrollWidth 和 clientWidth 比較,對象是每一篇文章而不是引發問題的那一頁,並且排除捲動容器內的元素,因為它們的溢出根本不會影響文件的縮放。

