langfuse 前端实践:用 toSorted() 替代 sort() 保证 React 状态不可变
langfuse 前端实践:用 toSorted() 替代 sort() 保证 React 状态不可变
在 React 与 Next.js 应用中,数组排序是最高频的派生数据操作之一。原生 Array.prototype.sort() 会原地修改数组,一旦作用于 React 的 props、state 或 useMemo 依赖,就可能引发难以排查的界面 bug。本文以 langfuse 仓库中收录的 Vercel React 最佳实践规则(js-tosorted-immutable.md)为核心,讲解为何要用 toSorted() 替代 sort(),并深入 langfuse 前端源码,看这套不可变排序实践在真实业务(CodeMirror 编辑器装饰排序、评估器模板画廊)中如何落地,同时给出完整的兼容性降级方案与配套的不可变数组方法族。
一、问题根源:sort() 的原地修改特性
.sort() 会原地(in place)修改数组,而不是返回一个新数组。这在 React 场景下是危险的:组件里看似"派生"的排序结果,实际上悄悄篡改了上游数据。以下这段代码就是典型的错误示范(出自规则文档原文):
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>
}
useMemo 的执行体直接对 users prop 调用了 sort(),副作用是把父组件传入的数组也排好了序。一旦父组件内部还持有对该数组的其他引用(例如同时渲染"原始顺序"列表),界面就会错乱。
二、正确写法:toSorted() 返回新数组
Array.prototype.toSorted() 是 ES2023 新增的不可变排序方法:它返回一个新的已排序数组,原数组保持不变。同样传入比较器函数,语义与 sort() 完全一致:
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>
}
在 langfuse 的 Web 端源码中,这套写法已被实际采用。例如 CodeMirror 代码编辑器扩展里,对搜索高亮装饰(decoration)按文档位置排序时,就用 toSorted 生成了新数组再交给 decos.update():
// web/src/components/editor/CodeMirrorEditor.tsx#L312
decos = decos.update({
add: [
...(previousSelectedRange
? [searchMatchMark.range(previousSelectedRange.from, previousSelectedRange.to)]
: []),
selectedSearchMatchMark.range(effect.value.from, effect.value.to),
].toSorted((a, b) => a.from - b.from),
});
这里的装饰集合是在每次编辑器更新时从不可变更新接口(update)重新派生的,用 toSorted 保证不污染上一次的装饰快照,符合 CodeMirror 的不可变状态模型。类似的用法还出现在评估器模板画廊的准备函数中——对"推荐模板"按优先级 Map 排名后排序输出:
// web/src/features/evals/v2/fns/templateGallery/prepareEvaluatorGallery.ts#L46-L55
const orderedTemplates =
category.key === "recommended"
? templatesInCategory.toSorted(
(left, right) =>
(RECOMMENDED_TEMPLATE_RANK.get(left.key) ??
Number.MAX_SAFE_INTEGER) -
(RECOMMENDED_TEMPLATE_RANK.get(right.key) ??
Number.MAX_SAFE_INTEGER),
)
: templatesInCategory;
这段代码展示了 toSorted 的典型实战场景:从受管目录服务拉取模板列表,按推荐排序生成新的有序数组,而原始 templatesInCategory 继续被其他分类复用,二者互不影响。
三、为什么这在 React 中至关重要
规则文档明确给出了两条核心理由:
-
Props/state 的变更破坏 React 的不可变模型:React 期望 props 与 state 始终被当作只读数据。原地修改等于绕过 React 的更新机制直接改数据,导致
shouldComponentUpdate/React.memo的浅比较失效、渲染结果与数据不同步。 -
闭包中的修改引发陈旧闭包(stale closure)bug:在回调、effect、定时器等闭包里对捕获的数组做
sort(),会悄无声息地改变闭包外的数据。后续渲染依赖该数据时,得到的是被"历史闭包"改过的意外结果,排查成本极高。
这两点共同决定了:任何在 render 过程中派生排序结果的代码,都必须使用不可变方式。这也是 Vercel 将该规则标记为 MEDIUM-HIGH 影响级别、归入 JavaScript Performance 类别的原因——它不只是一个编码风格问题,而是直接影响状态正确性的缺陷预防手段。
3.1 与 useMemo 搭配时的隐蔽陷阱
把 sort() 放进 useMemo 并不能免疫问题——useMemo 只是缓存了返回值,而 sort() 的返回值正是被修改后的原数组引用。也就是说,users 被排序后,useMemo 的依赖 [users] 的引用本身没变(还是同一个数组),但内容变了,依赖比较自然"失效"。换成 toSorted() 后,每次返回全新数组,依赖追踪、React.memo 浅比较、父组件的派生逻辑全部恢复正常。
四、浏览器与运行环境兼容性及降级方案
规则文档给出的支持范围是:Chrome 110+、Safari 16+、Firefox 115+、Node.js 20+。langfuse 的 Web 端是 Next.js(React 19)+ TypeScript 项目(见 web/tsconfig.json),构建产物运行于现代浏览器与 Node.js 20+ 的服务端环境,toSorted 可直接使用;服务端测试(如 evals-trpc.servertest.ts 中对 id 数组做 [first.id, second.id, third.id].toSorted() 后与期望值比对)也依赖 Node 20+ 的原生支持。
对于仍需兼容旧环境的场景,用展开运算符 + sort() 即可获得等价语义:
// Fallback for older browsers
const sorted = [...items].sort((a, b) => a.value - b.value)
展开运算符先拷贝出一份新数组,再对新数组原地排序,原数组不受影响。若需要统一的 polyfill 策略,可引入 core-js 的 esnext.array.to-sorted 等模块,或在工具函数层做能力检测:
const sortImmutable = <T>(arr: T[], cmp?: (a: T, b: T) => number): T[] =>
typeof arr.toSorted === "function"
? arr.toSorted(cmp)
: [...arr].sort(cmp);
五、不可变数组方法族:toSorted / toReversed / toSpliced / with
规则文档明确指出,toSorted 只是 ES2023 不可变数组方法族的一员。完整清单如下:
| 方法 | 对应可变版本 | 作用 | 返回 |
|---|---|---|---|
.toSorted() |
.sort() |
不可变排序 | 新数组 |
.toReversed() |
.reverse() |
不可变反转 | 新数组 |
.toSpliced() |
.splice() |
不可变删除/插入 | 新数组 |
.with() |
下标赋值 arr[i] = x |
不可变替换单个元素 | 新数组 |
langfuse 的测试代码里恰好有一个三连用的示例,直观展示了这套 API 的链式能力(code-eval-template-validation.clienttest.ts):
const copiedTokens = tokens.toSorted().toReversed().toSpliced(0, 1, "copy");
对 tokens 依次做排序、反转、替换首元素,每一步都生成新数组,原始 tokens 全程不被修改——这正是不可变方法族的核心价值:任意链式组合都不会产生副作用。而传统写法若用 sort() + reverse() + splice() 链式操作,会在第一步就污染原数组。
5.1 与 RSC 序列化去重的关联
在 langfuse 使用的 Next.js App Router 场景中,toSorted 还有一个容易被忽略的性能维度:RSC 到客户端的 props 序列化按引用去重(见 AGENTS.md)。服务端组件里对同一个数组做 toSorted() 会生成新引用,导致整个数组被重复序列化传输;而 filter、map、展开运算符等任何产生新引用的操作同理。因此最佳实践是:
- 服务端只传一份原始数组;
- 排序、过滤等变换尽量放在客户端组件内用
useMemo + toSorted完成。
// 服务端(RSC):只传一次,避免重复序列化
<ClientList usernames={usernames} />
// 客户端:在本地派生排序结果
'use client'
const sorted = useMemo(() => usernames.toSorted(), [usernames])
需要说明的是,string[]/number[] 这类原始类型数组重复序列化影响最大(数组与全部元素都会重复),而对象数组因内部对象按引用去重影响相对较小——但无论如何,能少传就少传。
六、在测试中验证不可变语义
排序逻辑的正确性最终要靠测试兜底。langfuse 的测试代码体现了两个值得借鉴的验证思路:
- 顺序无关断言:服务端测试中,先用
toSorted()对实际结果和期望值分别排序再比较,规避数据库返回顺序的不确定性(见 queryBuilder.servertest.ts):
expect(result.data.map((row) => row.name).toSorted()).toEqual([/* 期望值 */]);
- 不可变性断言:对于自己的排序工具函数,应同时断言"返回数组已排序"与"原数组引用与内容均未变化"(例如
expect(original).toEqual(originalSnapshot)),把不可变语义固化为回归测试。
七、速查清单
- 在
useMemo、render 函数、event handler 中对 props/state 数组排序时,一律使用toSorted(); - 需要在旧浏览器运行时,用
[...items].sort(cmp)降级,或统一封装工具函数; - 链式数组变换优先组合
toSorted/toReversed/toSpliced/with,全程无副作用; - 服务端组件避免把
toSorted()等变换结果与原始数组同时传给客户端,防止 RSC 序列化重复传输; - 为排序工具函数补充不可变性测试,防止后续重构重新引入
sort()原地修改。
参考来源
- 规则原始文档:js-tosorted-immutable.md
- 技能总览(规则分类与优先级):SKILL.md
- 完整编译版规则(7.12 节及 3.2 节 RSC 序列化说明):AGENTS.md
- 仓库实际用例:CodeMirrorEditor.tsx、prepareEvaluatorGallery.ts、code-eval-template-validation.clienttest.ts