在 React 中将列表正确性设为默认
Source: Dev.to
Stop rewriting the same list boilerplate over and over.
在大规模时,重复不仅仅令人恼火——它是正确性规则衰退的方式,关键的 bug、静默回退以及破坏语义的情况会悄然渗入 React 代码库。
This post is about a small React abstraction that makes list correctness the default behavior, without hiding React’s rules, adding dependencies, or reinventing anything.
本文介绍一种小型的 React 抽象,使列表正确性成为默认行为,不隐藏 React 的规则、不添加依赖,也不重新发明任何东西。
问题不在 .map,而在它周围的一切
在大多数 React 代码库中,列表渲染往往会变成下面这种模板的某种变体:
{items?.length === 0 ? (
Empty
) : (
{items.map(item => (
- {item.title}
))}
)}
单独看这段代码并没有问题,但在规模化时,它会变得脆弱。
随着时间推移,列表渲染的逻辑会散布到整个代码库:
- Key 处理不统一
- 同一套常用列表逻辑出现冗余
- 重构时不经意引入 index 作为回退
- 空状态处理不一致
即使是最基本的列表语义也会逐渐退化为:
- 正确性依赖于记住各种规则
大多数团队尝试通过 lint 规则和约定来解决:
- “始终添加
key” - “避免使用 index 作为回退”
- “倾向使用语义化列表”
- “正确处理空状态”
这些做法在代码库规模不大时还能起作用——但当代码库增长后就会失效。
随着复杂度提升
- 新加入的贡献者会遗漏边缘情况
- 重构会使既有假设失效
- 评审者关注业务逻辑,而忽视列表的正确性
- 微妙的 reconciler(调和)错误会悄然出现
问题并不在于 React 本身,也不是开发者不懂规则,而是 开发者体验 + 强制执行 的问题。
不同的做法:在 UI 边界设置护栏
与其让开发者“记住”正确性,我构建了一个小型抽象,使正确的做法变得容易,并在无法保证正确性时大声报错。
npx @luk4x/list
它不是运行时依赖;它是一个将组件复制到你的代码库中的 CLI。完整实现和文档在此: github.com/luk4x/list ↗
从宏观上看,它实现了两件事:
- 集中常见的列表渲染逻辑
- 强制或安全推断稳定的键
上述相同的例子可以写成:
<List items={items} fallback={<Empty />}>
{item => <li>- {item.title}</li>}
</List>
“key 在哪儿?” 在此示例中可以安全推断。请参阅 keyExtractor 文档了解具体规则。
清晰的思维模型:UI 列表中的显式标识
在 React 中渲染列表时,必须遵循一条规则:
每个列表项必须拥有一个稳定且唯一的属性来表示其身份。
实际上,当 UI 数据使用显式标识进行建模 时,这一规则的执行效果最佳。
示例
不要依赖某个隐式的唯一属性:
const profileTabs = [
{ tab: 'settings-tab', label: 'Settings', Icon: SettingsIcon },
{ tab: 'security-tab', label: 'Security', Icon: ShieldCheckIcon },
{ tab: 'billing-tab', label: 'Billing', Icon: CreditCardIcon },
];
而是将身份显式化:
const profileTabs = [
{ id: 'settings-tab', label: 'Settings', Icon: SettingsIcon },
{ id: 'security-tab', label: 'Security', Icon: ShieldCheckIcon },
{ id: 'billing-tab', label: 'Billing', Icon: CreditCardIcon },
];
这里,tab 本身已经是身份标识。将其重命名为 id 只是明确了这一点,并且在使用 List 时省去了键值处理的繁琐。
<List items={profileTabs} keyExtractor={item => item.id}>
{({ id, label, Icon }) => (
<button onClick={() => onSelectTab(id)}>
{label}
</button>
)}
</List>
这种思维模型并非哲学,而是将以下内容清晰对齐的一种方式:
- 你的数据
- 你的 UI
- React 的规则
Scope matters
这并非适用于所有数据;它是一种 UI‑边界的思维模型,用于映射到渲染列表中的数据。
在该边界上,你有两个有效的选项:
- 保留领域特定的字段并使用
keyExtractor - 将身份标准化为
id并去除键的仪式
两者都是正确的——选择在你的代码库中更易读的那一种。
在 profileTabs 示例中,将 tab 重命名为 id 并不会抹去含义;上下文仍然清晰地表明该值代表什么。区别在于 你和 React 都可以在没有额外仪式的情况下推断出 profileTabs 的身份。
抽象实际是如何工作的
在运行时,组件正是这样工作的:
- 默认渲染一个
<ul>(或你指定的元素) - 遍历
items - 将每个渲染后的子元素包裹在
React.Fragment中 - 为该片段分配经过验证的
key(通过keyExtractor或推断) - 如果无法确定稳定的
key,则抛出错误
概念上
<ul>
{items.map((item, index, array) => (
<React.Fragment key={keyExtractor ? keyExtractor(item) : inferKey(item)}>
{children(item, index, array)}
</React.Fragment>
))}
</ul>
- 除了键的处理之外,不会尝试验证子元素的结构
- 不会强加任何样式或布局决策
- 不会“修复”不稳定或形状不佳的数据
- 它 不 隐藏 React 的行为
它的设计故意保持简洁,只有一个目标:让正确的列表渲染成为默认行为。
为什么强制执行胜过建议
Lint 规则有帮助;它们捕获明显错误并防止最糟糕的失误。
但 lint 本质上是建议性的:
- 它可以警告
key缺失或重复 - 它无法保证
key的稳定性 - 它无法强制身份建模
最重要的是,它不能让正确的模式成为最容易使用的。
Lint 规则在 运行时模型之外 工作:它们对代码进行评论,但不影响代码的行为方式。
这就是 List 抽象会大声报错的原因。如果无法推断出稳定的 key 且未提供 keyExtractor,它会在运行时抛异常。没有静默回退——要么保证正确,要么被拒绝。
正确性被移到渲染边界本身,在那里错误变得难以出现,而不是仅仅通过指南来劝阻。
Lint 规则仍然重要;它们在 结构性防护措施 的旁边工作效果最佳,而不是取代它们。