iPhone 上操作時這個部落格能夠被縮到 100% 以下 — 用 overflow-wrap 與表格捲動容器來解決

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

白色卡片上的 WebKit 標誌,藍、黃、橘三個圓角方形疊在一起,最上層的藍色方形中有白色輪廓的指北針,旁邊是黑色的 WebKit 字樣
本頁目錄

引言

我在手機上對自己的文章用手指內捏,結果整頁縮得比原本的尺寸還小,這感覺不太對所以我決定調查這個問題。

在首頁上沒有這個問題,無論怎麼向內捏頁面最小就維持在 100%;這兩種頁面用的是同一個 <meta name="viewport">,證明問題並非出在這個標籤。

在 iPhone 使用瀏覽器時 WebKit 是用文件實際的內容寬度決定向內捏的最小比例,而且從 iOS 10 起就會忽略 minimum-scale,所以根本沒辦法設定縮放下限。真正的問題是 頁面超出了手機螢幕的寬度,導致瀏覽器為了顯示完整的頁面而縮得比實際大小來得小。

解決方法是兩條 CSS 規則:行內程式碼加上 overflow-wrap: anywhere,以及替任何比內文寬度更寬的表格加上捲動容器。我另外添加了一個檢查,會用手機寬度量測每一篇文章,讓超出螢幕的頁面在上線前就報錯。

iPhone 的 Chrome 上縮到最小比例的文章頁面,網址列顯示 oharu121.com,內文欄位約占螢幕寬度的四分之三,右側留著一條深色的空白帶

螢幕寬 430pt,文件寬 591px,所以瀏覽器允許的最小比例是 430/591,剩下的 27% 就是一片空白。

我的直覺錯了:viewport meta 標籤無法鎖定縮放比例

沒有任何 viewport 的值可以阻止頁面被縮小。 看到那張截圖,我的直覺是 meta 標籤少了一個下限,但其實這個網站每個頁面用的都是同一個簡單的設定:

src/layouts/BaseLayout.astro
<meta name="viewport" content="width=device-width, initial-scale=1" />

在那一行加上 minimum-scale=1,正是我一開始想試的修法,但它什麼用也沒有。從 iOS 10 起,WebKit 就完全忽略 user-scalable、minimum-scale 與 maximum-scale。當時的發布公告 講得很直白:

而且這個直覺本身就瞄錯了方向:當網頁內容超出螢幕寬度時,縮小就是讀者的應對方式。 禁止縮小的頁面只會帶來更多問題。

線索在首頁 — 同樣的 meta 標籤,不同的內容

首頁完全不會縮小,而它和每一篇文章來自同一套模板。同一個標籤、兩種行為,所以變因不是標籤,而是兩種頁面各自的內容。

這件事每頁用一個運算式就能確認,而兩邊的數字立刻就差開了:

首頁,在 390px 的可視區域下
輸入
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 和其他瀏覽器同樣繼承了這個行為。

最小縮放比例是螢幕寬度除以內容寬度兩個面板並排。左側是以 scale 1 排版的狀態,螢幕寬 390px 而文件寬 1028px,因此有 638px 落在右邊界之外。右側是瀏覽器改用能讓整份文件塞進螢幕的比例,此時內文只占大約三分之一的寬度,其餘都是空白頁面。兩者之間標示 390 / 1028 = 0.38 的計算。以 scale 1 排版在最小縮放比例時螢幕 390px文件 1028px638px在螢幕外螢幕 390px文件 1028px內文空白頁面390 / 1028 = 0.38最小縮放比例 = 螢幕寬度 / 內容寬度
版面可視區域維持在裝置寬度。文件比它更寬,因此能容納文件的那個比例,就是讀者可以縮到的下限。

截圖裡的每個數字都是這個商算出來的: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

把右緣越過可視區域的元素依標籤分類後是這樣:

元素出現在多少個溢出的頁面上
code63
table 與它的儲存格52
a8
strong6

行內程式碼在 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。

捲動容器內的溢出不會影響到文件兩組巢狀方框並排。左邊的 span 超出了它的 pre 元素,但 pre 有 overflow-x: auto,因此溢出在該處停住,文件寬度不變,這不是缺陷。右邊 div.prose 裡的段落中,行內程式碼超出了段落,而路徑上沒有任何元素捲動或裁切,因此溢出一路延伸到頁面外並把文件撐寬,這是缺陷。在捲動框被裁切div.expressive-codepre (overflow-x: auto)span一路影響到文件div.prosepcode不是缺陷是缺陷只有在所有祖先都不捲動也不裁切時,葉節點才會改變文件寬度
捲動容器內的溢出會被該容器裁切。只有在葉節點之上沒有任何元素捲動或裁切時,它才會改變文件寬度。

讓程式碼換行:用 overflow-wrap,不是 break-word

瀏覽器不會去斷一個沒有斷點的字,所以如果行內程式碼裡有一段很長的字串,就會直接把整份文件撐到跟它一樣寬。解決方法是在這個網站本來就有的選擇器上新增一行 overflow-wrap: anywhere:

src/styles/global.css
: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 metawidth=device-width, initial-scale=1完全相同
390 下的 documentElement.scrollWidth390591 到 1028
表格的 display 計算值blocktable
表格的 overflow-x 計算值autovisible
外層的包裝元素沒有沒有
行內程式碼的 overflow-wrap 計算值break-word未設定

他們採用的是純 CSS 的修法,整份頁面沒有任何包裝元素:把表格變成區塊方框並讓它捲動。這行得通也不需要建置步驟,而且只有兩行,但這有個代價。

display: block,以及窄表格為此付出的代價

代價落在那些本來就不寬的表格上。 內容比內文寬度還窄的表格不再撐滿整行,而是縮成只有自己文字那麼寬,那一行其餘的部分就空著。用這個網站自己的版面量:712px 的內文寬度裡,原本是 711px,之後變成 139px。

display: block 讓窄表格付出的代價兩列圖,各自在同樣的內文寬度裡放同一個表格。上面那列是 display: table,在 712px 的寬度中佔滿 711px。下面那列是 display: block,只佔左邊的 139px,其餘的寬度留白。內文寬度,712pxdisplay: table狀態數量撐滿整行 · 711pxdisplay: block留白狀態數量縮成自己文字的寬度 · 139px
同樣內文寬度下的同一個表格。display: block 只留下自己文字的寬度;本來就比內文寬的表格,兩種寫法看起來一樣。

DevelopersIO 選擇接受這個代價。 我實測了他們 8 篇文章,7 個表格裡有 4 個比內文寬度還窄,因此縮在左邊顯示。內容比內文寬的表格則看不出差別。

包裝元素,以及這邊選它的理由

另一種做法是把表格包進一個會捲動的元素,表格本身完全不動。每個表格都維持 display: table,所以畫面上什麼都沒變,只有太寬的那些會多出捲軸。而我發現我其實早就寫好了一條規則卻從來沒使用過:

src/styles/global.css
/* Wide content must scroll in its own box, never the page body. */
.table-scroll {
overflow-x: auto;
}

我使用了一個 rehype 外掛,在建立語法樹時把每個 Markdown 表格包起來:

src/lib/rehype-table-scroll.ts
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.
astro.config.mjs
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 比較,對象是每一篇文章而不是引發問題的那一頁,並且排除捲動容器內的元素,因為它們的溢出根本不會影響文件的縮放。

參考連結

分享這篇文章