成为全栈·Next.js 网站前台篇·响应式、可访问性与错误状态:内容站不能只验桌面首页
设计稿在 1440px 正常,只能证明一张静态画面。真实内容站还要面对 320px、四级菜单、键盘焦点、宽表格、重复标题、空数据、404 和服务端故障。

前言
响应式经常被简化成"加几个媒体查询",可访问性则被简化成"补 aria-label"。真正验收时,问题往往发生在组合路径:手机展开四级分类后能否关闭,代码块横向滚动会不会带动整页,错误按钮能不能真的恢复服务端数据。
本项目不只检查桌面首页,而是把视口、输入方式、内容边界和系统状态组成一张矩阵。
响应式验收不是设备型号清单
| 宽度 | 主要观察 |
|---|---|
| 320 | 最窄主体、标题换行、按钮与表单 |
| 375 | 常见手机、导航与文章目录 |
| 768 | 单栏到多栏的临界布局 |
| 1024 | 侧栏、首页焦点与菜单空间 |
| 1440 | 最大内容宽度与信息密度 |
现有样例在五种宽度下未发现主体横向溢出,但这不代表任意长标题和表格永远安全。正式内容接入后还要抽取最长标题、最深分类和最宽代码重新测。

四级导航优先使用原生 disclosure
tsx
const CategoryItem = ({ node, depth = 0 }: Props) =>
node.children?.length ? (
<details className={`nav-branch depth-${depth}`}>
<summary>
{node.name}<span aria-hidden="true"> ▾</span>
</summary>
<div className="drop">
<Link href={`/categories/${node.slug}`}>{node.name} · 全部</Link>
{node.children.map((child) => (
<CategoryItem key={child.id} node={child} depth={depth + 1} />
))}
</div>
</details>
) : <Link href={`/categories/${node.slug}`}>{node.name}</Link>
details/summary 提供基础键盘展开语义,但还需要处理路由切换、外部点击与 Escape。
tsx
useEffect(() => {
if (event.key === 'Escape') {
const active = document.activeElement
ref.current?.querySelectorAll('details[open]').forEach((item) => {
item.open = false
})
active?.closest('details')?.querySelector('summary')?.focus()
setOpen(false)
}
}, [])
关闭后把焦点送回触发展开的位置,键盘用户才知道自己在哪里。
手机菜单按钮必须暴露真实状态
tsx
<button
type="button"
aria-expanded={open}
aria-controls="site-navigation"
onClick={() => setOpen(!open)}
>
栏目 ☰
</button>
tsx
<nav
id="site-navigation"
className={`headnav ${open ? 'open' : ''}`}
aria-label="主导航"
>
样式隐藏与语义状态要同步。路由变化后还会关闭所有 details,避免新页面保留旧菜单遮挡内容。
跳过链接让键盘直接进入正文
tsx
<a className="skip-link" href="#main-content">
跳到主要内容
</a>
<main id="main-content">{children}</main>
页头包含品牌、四级导航、搜索和会员菜单,键盘用户每页都逐项经过会很疲惫。跳过链接在获得焦点时显示,是低成本但实际有用的结构。
宽内容只在自己的边界内滚动
tsx
table: ({ children }) => (
<div className="table-scroll">
<table>{children}</table>
</div>
)
代码块同样由 CodeBlock 管理 overflow。不要给整篇 article 设置横向滚动,否则标题、段落和目录都会跟着左右移动。长 URL 更适合断行,表格与代码才使用局部滚动。
Loading、Empty、Failure、404 必须分开
tsx
export const Skeleton = () => (
<div role="status" aria-busy="true" aria-label="正在加载">
<span className="sr-only">正在加载内容</span>
<SkeletonRows />
</div>
)
tsx
export const Failure = ({ retry, message }: Props) => (
<div className="hint" role="alert">
<p>{message}</p>
<button type="button" onClick={retry}>重新加载</button>
</div>
)
| 状态 | 意义 | 合理动作 |
|---|---|---|
| Loading | 结果尚未确定 | 等待,保留结构 |
| Empty | 请求成功但没有内容 | 浏览其他入口或创建内容 |
| Failure | 请求未成功 | 重试当前模块或页面 |
| 404 | 资源明确不存在 | 返回索引/首页 |
500 不能被 catch 成空数组后显示"暂无文章",否则用户无法判断内容是否真的为空。
服务端错误恢复需要真正重新请求
tsx
'use client'
export default function ErrorPage() {
return (
<div role="alert">
<h1>内容暂时没有加载出来</h1>
<button onClick={() => window.location.reload()}>
重新加载
</button>
</div>
)
}
项目曾使用错误边界 reset,但恢复后端后服务端内容没有重新获取。完整 reload 经故障注入验证有效。若未来错误页包着未保存长表单,则要重新设计细粒度恢复,不能机械照搬。
焦点与状态不能只靠颜色
tsx
<Link
href="/articles"
aria-current={pathname === '/articles' ? 'page' : undefined}
>
全部文章
</Link>
tsx
<button aria-pressed={liked}>{liked ? '已赞同' : '赞同'}</button>
当前页面、轮播指示点、点赞状态都需要语义属性和可见文本。CSS 的 selected 色只是视觉辅助。表单错误用 role="alert",操作成功用 role="status",避免所有消息都打断读屏。
一套组合验收路径
text
1. 320px 键盘打开手机菜单,展开到第四级,Escape 关闭并检查焦点
2. 打开含宽表格和长代码的文章,只允许局部横向滚动
3. 点击第二个重复中文目录标题,检查正确锚点与当前章节
4. 禁用 JavaScript,确认公开标题、正文和分页仍可读
5. 停止后端,区分搜索错误、文章错误与真实 404
6. 恢复后端后点击重新加载,确认内容和登录状态恢复
7. 使用空收藏、空搜索、空分类,核对各自下一步文案
8. 仅用键盘完成搜索、导航、登录和评论输入

自动工具可以发现缺少 label、对比度和部分语义问题,却不能证明焦点顺序合理、菜单不会困住用户、错误按钮真的恢复数据。最终仍需浏览器操作证据。
适用边界
本文完成的是现有页面和测试内容下的本地验收,不代表完整 WCAG 审计,也没有覆盖真实手机硬件、所有读屏软件和跨浏览器矩阵。
内容和运营图片变化会制造新问题,因此可访问性与响应式不是一次性验收章,而是持续回归条件。
小结
内容站的质量藏在正常截图之外:最窄视口、最深菜单、最宽内容、键盘焦点和失败恢复共同决定页面是否可用。
把 Loading、Empty、Failure 与 404 分开,再用真实操作覆盖组合路径,才能让"响应式和可访问"从样式口号变成证据。
延伸阅读
如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:
🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html
📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer
