Explicit accessibility contracts make React components more portable
Explicit accessibility contracts make React components more portable
明确的无障碍契约让 React 组件更具可移植性
A component that works in a React app doesn’t automatically work when you reuse it elsewhere in the React ecosystem. I moved a design-system layout component from a React app into a Next project and it broke. The problem wasn’t that Next couldn’t render the component; it was that the component assumed a particular ownership and composition model. That assumption affected its accessibility too.
在一个 React 应用中能正常工作的组件,并不意味着在 React 生态系统的其他地方重用时也能自动生效。我曾将一个设计系统的布局组件从一个 React 应用迁移到 Next.js 项目中,结果它崩溃了。问题不在于 Next.js 无法渲染该组件,而在于该组件预设了特定的所有权和组合模型。这种预设同时也影响了它的无障碍性(Accessibility)。
The component and its contract
组件及其契约
The component was a layout component. It provided a skip link, a header with a main menu, and a <main> containing the page title and body. Its accessibility depended on a concrete relationship. The skip link targeted the page’s h1, the h1 had a matching id and tabIndex={-1} so it could receive programmatic focus, and the heading sat inside <main>. The skip link was intentionally targeting the page heading rather than the <main> element. The desired focus destination was the beginning of the page’s meaningful content, where the page title provided an immediate orientation point. Activate the skip link and focus lands on the page title, past the persistent header and menu. A clear page-level heading, the correct landmark, and focus where it should be. That worked, and it kept working until I tried to reuse the component.
该组件是一个布局组件。它提供了一个跳转链接(skip link)、一个带有主菜单的页眉,以及一个包含页面标题和正文的 <main> 标签。其无障碍性依赖于一种具体的关联关系:跳转链接指向页面的 h1,该 h1 拥有匹配的 id 和 tabIndex={-1} 以便接收程序化焦点,且标题位于 <main> 内部。跳转链接特意指向页面标题而非 <main> 元素。预期的焦点落点是页面有意义内容的开头,页面标题在此处提供了一个即时的定位点。激活跳转链接后,焦点会落在页面标题上,跳过持久化的页眉和菜单。清晰的页面级标题、正确的地标(landmark)以及准确的焦点位置——这在原项目中运行良好,直到我尝试重用该组件。
Reuse exposed the composition assumption
重用揭示了组合假设
In the original React app, the layout owned everything in one composition. The persistent chrome, skip link, header, and <main>, and the per-page content, the h1 and body, lived together. That is perfectly reasonable when the application controls how the whole tree is composed. Next doesn’t compose that way. In Next’s App Router, the persistent layout and the route-specific page have different ownership and lifecycle boundaries. The layout persists while the page content changes between routes. The layout receives that route-specific content through children. So I couldn’t use the component unchanged as the Next layout because it assumed it owned both sides of that boundary. Trying to work around that assumption created an awkward choice. Either the persistent layout had to know about the page-specific heading, or the page content had to somehow reach back into the layout to establish the accessibility relationship. Neither was a good component contract. The problem wasn’t simply that the component was “incompatible with Next.” Its composition assumptions didn’t survive a different rendering model. And when composition assumptions include accessibility relationships, those relationships can break along with the composition.
在最初的 React 应用中,布局在单一组合中拥有所有权。持久化的外壳、跳转链接、页眉、<main> 以及页面特定的内容(h1 和正文)共存。当应用控制整个树的组合方式时,这是完全合理的。但 Next.js 的组合方式不同。在 Next.js 的 App Router 中,持久化布局和路由特定的页面具有不同的所有权和生命周期边界。布局保持不变,而页面内容在路由切换时发生变化。布局通过 children 接收这些路由特定的内容。因此,我无法直接使用该组件作为 Next.js 的布局,因为它预设了自己拥有边界两侧的所有权。试图绕过这一假设会导致尴尬的选择:要么持久化布局必须了解页面特定的标题,要么页面内容必须以某种方式回溯到布局中以建立无障碍关联。这两种都不是好的组件契约。问题不仅仅是组件“与 Next.js 不兼容”,而是它的组合假设无法在不同的渲染模型中存续。当组合假设包含无障碍关联时,这些关联会随着组合方式的改变而失效。
The contract has to become an interface
契约必须转化为接口
The fix was to stop treating the layout as one indivisible thing. It became two components: a shell and the content that fills it. The shell owns the skip link, the header, and the <main> landmark. The content owns the page heading and body.
解决方法是停止将布局视为一个不可分割的整体。它被拆分为两个组件:外壳(shell)和填充它的内容(content)。外壳拥有跳转链接、页眉和 <main> 地标;内容拥有页面标题和正文。
// Shell: owns the skip link and the <main> landmark, and renders a slot.
export const Layout = ({ children, mainMenu, headerActions, headingId = "content-heading", }) => (
<div className={styles.layout}>
<SkipLink label="Skip to Content" targetId={headingId} className={styles.skip} />
<header>
{mainMenu}
{headerActions}
</header>
<main className={styles.main}>
{children}
</main>
</div>
);
// Content: owns the focusable heading the skip link resolves to.
export const PageContent = ({ pageTitle, children, headingId = "content-heading", }) => (
<div className={styles.content_container}>
<h1 id={headingId} tabIndex={-1} className={styles.content_heading} >
{pageTitle}
</h1>
<div className={styles.content_body}>
{children}
</div>
</div>
);
The important change isn’t just that there are now two components. It’s that the boundary between them is explicit. The skip link needs a focus target. The focus target needs a stable identity. The target needs to be the page heading. And that heading needs to be inside the main content. Those are accessibility requirements of the composition, not implementation details hidden inside one component. The shared ID used to be a magic string hardcoded in both places. Now that ID represents the interface between the two halves, so it belongs in the API. Both components use the same default: headingId = "content-heading" which means they line up without additional configuration. If a consumer needs a different ID, it can provide one to both components. That makes the relationship visible rather than relying on a convention that consumers have to discover.
重要的改变不仅仅是现在有了两个组件,而是它们之间的边界变得明确了。跳转链接需要一个焦点目标,焦点目标需要一个稳定的标识,目标必须是页面标题,且该标题必须位于主要内容内部。这些是组合的无障碍需求,而不是隐藏在单个组件内部的实现细节。共享的 ID 过去是一个硬编码在两处的“魔法字符串”。现在,该 ID 代表了两个部分之间的接口,因此它属于 API 的一部分。两个组件都使用相同的默认值 headingId = "content-heading",这意味着它们无需额外配置即可对齐。如果使用者需要不同的 ID,可以同时提供给两个组件。这使得关联关系变得可见,而不是依赖于使用者必须自行发现的约定。
There is still a limitation: an API that exposes a contract doesn’t necessarily enforce it. A consumer can pass different IDs to the two components, or render multiple instances with the same default ID. A more sophisticated design-system implementation could enforce the relationship structurally, for example, by generating the ID in the shell and providing it to the content through context. But even when the contract remains a consumer responsibility, making it explicit is a significant improvement over hiding it in two components that happen to agree on a magic string.
当然,这仍有一个局限性:暴露契约的 API 并不一定能强制执行它。使用者可能会给两个组件传递不同的 ID,或者渲染多个使用相同默认 ID 的实例。更复杂的设计系统实现可以在结构上强制执行这种关系,例如,在 Shell 中生成 ID 并通过 Context 提供给 Content。但即使契约仍然是使用者的责任,将其显式化也比隐藏在两个恰好约定了“魔法字符串”的组件中要好得多。
Why this makes the component more portable
为什么这让组件更具可移植性
React-based frameworks ultimately compose components into a rendered DOM tree, but they differ in how they establish the boundaries between persistent UI and route-specific content. That’s where the refactoring helps. In a plain React application, you can compose the components yourself:
基于 React 的框架最终会将组件组合成渲染后的 DOM 树,但它们在建立持久化 UI 和路由特定内容之间的边界方式上有所不同。这正是重构发挥作用的地方。在普通的 React 应用中,你可以自行组合组件:
<Layout>
<PageContent pageTitle="Dashboard">
...
</PageContent>
</Layout>
In Next’s App Router, the persistent shell can live in layout.tsx:
在 Next.js 的 App Router 中,持久化外壳可以放在 layout.tsx 中:
import { Layout } from "@/design-system";
export default function RootLayout({ children }) {
return (
<Layout>
{children}
</Layout>
);
}
while the route-specific content lives in page.tsx:
而路由特定的内容则放在 page.tsx 中:
export default function Page() {
return (
<PageContent pageTitle="Dashboard">
...
</PageContent>
);
}
The same separation works with React Router. A parent route can own the persistent shell and render an <Outlet />, while the child route provides the page-specific content. The composition mechanism is different, but the boundary is the same.
同样的拆分方式也适用于 React Router。父路由可以拥有持久化外壳并渲染 <Outlet />,而子路由提供页面特定的内容。组合机制虽然不同,但边界是一致的。