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

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

- Source: https://oharu121.com/zh-tw/blog/webkit-ios-pinch-zoom-floor-overflow-wrap-table-scroll/
- Published: 2026-09-22T21:12:42+09:00
- Tags: CSS, WebKit, 無障礙

---
## 引言

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

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

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

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

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

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

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

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

```astro title="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`。當時的[發布公告](https://webkit.org/blog/7367/new-interaction-behaviors-in-ios-10/) 講得很直白：

> **話說**
>
> **WebKit 發布的公告**iOS 上的 Safari 允許使用者在每一頁捏縮放，所以開發者應該確保內容在縮放後仍然好用。

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

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

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

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

```js title="首頁，在 390px 的可視區域下"
document.documentElement.scrollWidth
# 390
```

```js title="文章頁，在同樣的可視區域下"
document.documentElement.scrollWidth
# 591
```

**兩邊的 `clientWidth` 都是 390。** 390 的可視區域裡裝著 591 寬的文件，也就是有 201px 的內容超出了螢幕外面，只是平常沒有注意到。

同一篇文章不論視窗是 390 還是 500，回報的都是 591，問題就是從這裡開始和螢幕脫鉤的。**決定寬度的是文章裡面的某個東西**，而一個根本不受可視區域控制的數字，當然也不可能靠調整可視區域來解決。

## 縮放下限就是文件自己的寬度

WebKit 以 `device-width` 排版，當它發現內容比它還寬，就會挑一個能把全部內容放進螢幕的比例；用 WebKit 自己的說法，這在概念上等同於讀者一路向內捏到看得見全部內容為止。

所以 **最小比例其實是頁面拿自己的尺寸算出來的商**：螢幕寬度除以內容寬度。

以上都是在 iPhone 的 Chrome 上實測的結果，截圖也來自那裡。這個行為屬於引擎而不是瀏覽器：iOS 上的瀏覽器一律用 WKWebView 算繪畫面，所以 Safari 和其他瀏覽器同樣繼承了這個行為。

*Figure — ZoomFloor: 版面可視區域維持在裝置寬度。文件比它更寬，因此能容納文件的那個比例，就是讀者可以縮到的下限。*

截圖裡的每個數字都是這個商算出來的：591px 的文件配上 430pt 的螢幕，下限是 0.73，而這個網站最糟的一頁文件有 1028px 寬，下限因此掉到了 0.38。**需要調整的從來就不是縮放，而是這個商的分母。**

## 掃過全站每一篇文章之後

就算修復了單獨一頁的溢出，也只能改善那一頁，所以下一步是找出這件事到底發生在哪些頁面上。於是我把正式站台網站地圖裡的 356 個頁面全部用 390 × 844 載入，再拿 `scrollWidth` 和 `clientWidth` 比較：

```bash
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。

```text
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。

*Figure — OverflowReach: 捲動容器內的溢出會被該容器裁切。只有在葉節點之上沒有任何元素捲動或裁切時，它才會改變文件寬度。*

## 讓程式碼換行：用 overflow-wrap，不是 break-word

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

```css title="src/styles/global.css" ins={6}
:not(pre) > code {
  background: var(--bg-2);
  border: 1px solid var(--border);
  border-radius: 4px;
  padding: 0.1em 0.35em;
  overflow-wrap: anywhere;
}
```

> **話說**
>
> **`:not(pre) > code` 只鎖定行內程式碼**這個網站的 `<code>` 有兩種用法：包在 `<pre>` 裡變成整塊程式碼，以及在句子中單獨標示一個詞。這個選擇器只選到後者，所以規則碰得到內文，卻不會動到 Expressive Code 的區塊。

在這裡我用 `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。

*Figure — NarrowTable: 同樣內文寬度下的同一個表格。`display: block` 只留下自己文字的寬度；本來就比內文寬的表格，兩種寫法看起來一樣。*

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

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

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

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

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

```ts title="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` 已經被標為淘汰：

```text
[astro] `markdown.remarkPlugins`, `markdown.rehypePlugins`, and
`markdown.remarkRehype` are deprecated. Pass them to `unified({...})` from
`@astrojs/markdown-remark` directly instead.
```

```js title="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` 的頁面判為失敗：

```bash
pnpm check:overflow
# Nothing overflows. 211 route(s) measured at 390px.
```

我在尚未修改的網站做了同樣的測試：

```bash
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` 比較，對象是每一篇文章而不是引發問題的那一頁，並且排除捲動容器內的元素，因為它們的溢出根本不會影響文件的縮放。

## 參考連結

- [WebKit: New interaction behaviors in iOS 10：`user-scalable`、`minimum-scale` 與 `maximum-scale` 開始被忽略](https://webkit.org/blog/7367/new-interaction-behaviors-in-ios-10/)
- [MDN：viewport meta 元素，以及每個指示詞的作用](https://developer.mozilla.org/zh-TW/docs/Web/HTML/Guides/Viewport_meta_element)
- [MDN：`overflow-wrap`，以及 `anywhere` 與 `break-word` 在 min-content 尺寸上的差異](https://developer.mozilla.org/zh-TW/docs/Web/CSS/overflow-wrap)
- [Understanding WCAG 1.4.10 Reflow，320 × 256 CSS 像素的要求](https://www.w3.org/WAI/WCAG22/Understanding/reflow.html)
- [Apple: Configuring the viewport：這個行為所依據的 `shrink-to-fit`](https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariWebContent/UsingtheViewport/UsingtheViewport.html)
