ZCode 前端实践:用 toSorted() 替代 sort() 守护 React 状态不可变性
ZCode 前端实践:用 toSorted() 替代 sort() 守护 React 状态不可变性
ZCode 是智谱 AI 开源的 AI 编程工作台,其桌面端 UI(packages/ui)与 Agent CLI 的适配层(apps/zcode-cli/packages/adapters)中有大量基于 React/TypeScript 的组件与工具代码,其中大量数据以数组形式驱动列表、工作区任务与设置面板的渲染。在维护这类状态时,.sort() 这类原地修改数组的方法会悄然破坏 React 的不可变性模型,引发渲染异常与难以排查的 Bug。本文以仓库内置规则文档 rules/js-tosorted-immutable.md 为主体,结合 ZCode 源码中的真实调用,讲解如何用 ES2023 的 .toSorted() 及其同类不可变方法彻底规避这类问题,并给出兼容降级与自查清单,帮助你写出既正确又高效的排序代码。
规则速览:一条影响级别为 MEDIUM-HIGH 的 JavaScript 规则
该规则文件位于 rules/js-tosorted-immutable.md,属于仓库 .agents/skills/react-best-practices 技能包中“JavaScript Performance”类目(js- 前缀)的 14 条规则之一,其 frontmatter 元数据如下:
- title:Use toSorted() Instead of sort() for Immutability
- impact:MEDIUM-HIGH
- impactDescription:prevents mutation bugs in React state(防止 React 状态中的可变性 Bug)
- tags:javascript, arrays, immutability, react, state, mutation
在 SKILL.md 的 Quick Reference 中,它被概括为 js-tosorted-immutable - Use toSorted() for immutability。整个技能包按影响度排序,js- 类目虽然整体是 LOW-MEDIUM 优先级,但本规则因直接威胁 React 状态正确性而被单独标注为 MEDIUM-HIGH。
为什么 .sort() 是隐患:原地修改带来的连锁反应
.sort() 是 JavaScript 最早期的数组方法之一,它的核心语义是原地排序:调用后直接重排原数组并返回同一引用。这在普通函数式逻辑里可能无伤大雅,但在 React 组件中,数组往往来自 props 或 state,属于必须保持只读的数据源:
- props/state 被当作只读:React 的不可变性模型要求 props 和 state 在渲染期间不被修改。对
props.users这类数组调用.sort(),等同于在渲染流程中改写父级数据,React 的对比与更新逻辑将基于被篡改的数据运行,产生不可预期的 UI 状态。 useMemo缓存失效:useMemo依赖的是引用比较(Object.is)。若在 memo 计算函数内对依赖数组原地排序,数组引用不变,React 会认为依赖未变化、跳过重算,但数组内容已被修改,导致缓存结果与实际数据不一致。- 闭包中的陈旧值问题:在回调、事件处理器或 effect 闭包内对捕获的数组做原地排序,会让捕获的旧快照与后续读取到的新内容错位,引发“看起来随机”的行为。
从源码结构看,ZCode 之所以把这条规则编入 React 最佳实践技能包,正是因为其 UI 代码大量依赖数组驱动渲染,例如任务列表排序、插件市场排序、MCP 服务器分组等场景(下文会给出具体文件佐证)。
反例与正例:一段代码看懂差异
规则文档给出了一个高度浓缩的对比示例,这里完整保留并逐行注释:
不正确的写法(原地修改了 props 数组):
function UserList({ users }: { users: User[] }) {
// Mutates the users prop array!
const sorted = useMemo(
() => users.sort((a, b) => a.name.localeCompare(b.name)),
[users]
)
return <div>{sorted.map(renderUser)}</div>
}
问题在于:users.sort(...) 直接重排了父组件传入的 users 数组。由于 users 的引用没有变化,useMemo 的依赖比较不会触发重算,但数组内容已经被改写——父组件再次渲染时会发现数据“莫名其妙”变了顺序。
正确的写法(创建新数组,原数组不变):
function UserList({ users }: { users: User[] }) {
// Creates new sorted array, original unchanged
const sorted = useMemo(
() => users.toSorted((a, b) => a.name.localeCompare(b.name)),
[users]
)
return <div>{sorted.map(renderUser)}</div>
}
.toSorted() 与 .sort() 接受完全相同的比较函数参数((a, b) => number),语义上等价,但返回的是一个排序后的新数组,原数组保持原样。这样 users 的引用与内容都未变化,useMemo 缓存安全,父组件数据不受污染。
两种写法的比较函数完全一致,因此从 .sort() 迁移到 .toSorted() 通常只是方法名替换,改动成本极低、收益明确。
React 不可变性模型的三个关键影响
规则文档明确列出两条核心原因,这里结合 React 渲染机制展开:
- props/state 的修改破坏 React 不可变性模型:React 期望 props 和 state 在整个生命周期内被当作只读数据。任何在渲染期或 memo 计算中对它们的原地修改,都会让 React 的变更检测失真——可能表现为组件不更新、重复渲染或父子数据互相污染。
- 闭包内修改导致陈旧闭包 Bug:在回调(callback)、effect 等闭包中对捕获数组调用
.sort(),会让闭包持有的旧引用“共享”了新的排序结果。若闭包后续还依赖未排序的原始顺序(例如通过索引访问),就会出现逻辑错乱。 - (结合 ZCode 代码可推断的第三点)依赖数组与派生状态失配:当排序结果作为另一个
useMemo、useEffect的依赖项时,原地排序会因引用不变而跳过下游更新,派生状态停留在旧值上。这正是 rules/rerender-split-combined-hooks.md 中反复使用.toSorted()拆分派生计算的原因。
浏览器与运行时支持:现代环境全覆盖,旧环境用降级方案
规则文档给出了明确的兼容性边界,这也是实践中判断“能否直接使用”的依据:
.toSorted()在所有现代浏览器中均可用(Chrome 110+、Safari 16+、Firefox 115+、Node.js 20+)。对于更老的环境,请使用展开运算符:
// Fallback for older browsers
const sorted = [...items].sort((a, b) => a.value - b.value);
.toSorted() 属于 ES2023(ECMAScript 2023)的数组新增方法,与 .toReversed()、.toSpliced()、.with() 一起被纳入标准库。ZCode 的桌面端基于 Electron,运行时 Node.js 版本远高于 20,且渲染进程使用现代 Chromium,因此可以在仓库代码中直接使用 .toSorted()——这与仓库实测调用(见下文)一致。若项目需要兼容老旧浏览器或低版本 Node,用 [...items].sort(...) 即可获得等价的不可变语义。
其他不可变数组方法:一套完整的“to- 前缀”家族
规则文档末尾列出了完整的不可变方法家族,它们是 ES2023 一次性引入的,与各自的可变版本一一对应:
| 不可变方法 | 对应可变方法 | 行为 |
|---|---|---|
.toSorted() |
.sort() |
返回排序后的新数组 |
.toReversed() |
.reverse() |
返回倒序后的新数组 |
.toSpliced() |
.splice() |
返回增删元素后的新数组 |
.with() |
直接赋值 arr[i] = v |
返回替换指定索引元素后的新数组 |
使用时的统一心智模型是:所有 “to-” 前缀方法都不修改原数组,而是返回一份新的副本,从而在链式调用、派生状态计算中始终保持不可变语义。例如在一个 memo 中同时排序和截断:
const topUsers = useMemo(
() => users.toSorted((a, b) => b.score - a.score).slice(0, 10),
[users]
);
注意 .slice() 本身就是不可变的,因此与 to- 方法可以放心链式组合。
ZCode 仓库中的真实应用:源码级佐证
规则并非纸上谈兵——ZCode 的 UI 与 CLI 适配层已经在大量场景使用 .toSorted()。以下是仓库中可直接查阅的实例(仅列举少量代表性文件):
- 插件市场排序:packages/ui/src/settings/pluginStoreListing.ts 中,市场列表排序(第 168 行附近
marketplaces.toSorted(...))、按最近更新时间分组排序(第 200 行附近groups.toSorted(...))、商店条目排序(第 371 行附近items.toSorted(...))均使用.toSorted(),排序依据来自官方市场顺序映射与本地化语言环境。 - 任务工作区视图:packages/ui/src/hooks/workspaceTaskListOptimisticOverlay.ts、packages/ui/src/lib/buildGroupedTaskViewFromSessions.ts 等文件在处理会话分组与乐观更新叠加时也采用
.toSorted(),避免污染原始会话数组。 - 设置面板与消息协议:packages/ui/src/settings/McpSettingsSection.tsx、packages/ui/src/v4/conversationStatusPanelModel.ts 同样使用了该不可变方法。
- CLI 适配层:apps/zcode-cli/packages/adapters/src/commands/index.ts(命令索引排序)、apps/zcode-cli/packages/adapters/src/mcp/pool.ts(MCP 池排序)也遵循同一约定。
这些文件分布在桌面渲染进程与 CLI Agent 两侧,说明 ZCode 团队把 .toSorted() 作为跨端一致的默认选择。若你在 ZCode 或自己的项目中做代码审查,可以直接用 \.sort\( 检索,凡是对 props、state 或外部传入数组的排序调用,都应替换为 .toSorted()(或先展开再排序的降级写法)。
与其他规则的联动:toSorted 的“出圈”用法
在完整技能包 AGENTS.md(该规则对应其中的 7.14 节)中,.toSorted() 还被以下相邻规则作为示例工具反复引用,理解这些联动可以让你在一次重构中同时解决多个问题:
- RSC props 序列化去重(rules/server-dedup-props.md):React Server Components 向客户端序列化 props 时按引用去重。若把
usernames和usernames.toSorted()两个数组同时传给客户端组件,字符串数组会被整体重复序列化(如['a','b']会发送 4 个字符串)。规则建议:在服务端只传原始数组,把.toSorted()等变换放到客户端("use client"组件内的useMemo)执行,既保证不可变性又减少网络载荷。 - 拆分组合 Hooks 的派生计算(rules/rerender-split-combined-hooks.md):先用
products.filter(...)得到中间数组,再filteredProducts.toSorted(...)按排序方向排序,两个useMemo各自依赖更窄,避免sortOrder变化时连带重算过滤逻辑。这里toSorted是让“派生数组再派生”不污染中间结果的关键。 - 比较前先查长度(rules/js-length-check-first.md):在比较两个数组是否“内容等价”时,先比较
length再各自.toSorted()后逐元素比较——因为排序产生的是新数组,比较过程不会破坏原数组,可以放心用于 effect 依赖或变更检测。 - 用循环求 min/max 而非排序(
js-min-max-loop规则):当只需要最大/最小值时,O(n)的单次遍历比完整排序更优;这条规则与toSorted互补——前者避免“为取极值而排序”,后者保证“确实需要排序时不破坏数据”。
实践自查清单
把规则落到日常开发中,可以按以下清单自查:
- 凡是排序,先问数据来源:数组来自 props、state、外部传入参数或已缓存派生结果?是,则不可原地修改。
- 方法名检查:对上述数据一律使用
.toSorted();只有对刚创建的局部新数组(如[...arr]、Array.from(...)、arr.filter(...)的返回值)才可考虑.sort(),因为此时没有“原数组被污染”的问题。 - memo 依赖对齐:
useMemo内对依赖数组的排序必须返回新数组,否则引用比较会让缓存失真。 - 兼容性确认:目标运行环境在 Chrome 110+ / Safari 16+ / Firefox 115+ / Node.js 20+ 之内可直接使用;否则用
[...items].sort(...)降级。 - RSC 边界:服务端组件不要把“原始数组 + 排序后数组”同时传给客户端,变换交给客户端执行(见 server-dedup-props.md)。
- 顺带升级同类调用:
.reverse()→.toReversed()、.splice()→.toSpliced()、索引赋值 →.with(),让整套数组操作都保持不可变。
小结
toSorted() 以近乎零成本的语法迁移,换来了 React 状态不可变性的完整保障——它既保留了 sort() 的全部排序能力(包括 localeCompare 字符串排序、数值比较器、稳定性语义),又杜绝了 props/state 污染与闭包陈旧值这两类高频 Bug。在 ZCode 这样的多端 React 代码库中,它已经作为默认约定落地于任务列表、插件市场、MCP 配置等关键排序路径。记住规则文档给出的核心句式:需要排序结果,就用 .toSorted();只有明确想丢弃原始数组时,才用 .sort()。