langfuse 前端实践:用 toSorted() 替代 sort() 保证 React 状态不可变

原创2026-09-09 23:48:471,941 阅读
文章标签:AI应用LLMOps可观测性后端前端模型评测

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 中至关重要

规则文档明确给出了两条核心理由:

  1. Props/state 的变更破坏 React 的不可变模型:React 期望 props 与 state 始终被当作只读数据。原地修改等于绕过 React 的更新机制直接改数据,导致 shouldComponentUpdate/React.memo 的浅比较失效、渲染结果与数据不同步。

  2. 闭包中的修改引发陈旧闭包(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 的测试代码体现了两个值得借鉴的验证思路:

  1. 顺序无关断言:服务端测试中,先用 toSorted() 对实际结果和期望值分别排序再比较,规避数据库返回顺序的不确定性(见 queryBuilder.servertest.ts):
expect(result.data.map((row) => row.name).toSorted()).toEqual([/* 期望值 */]);
  1. 不可变性断言:对于自己的排序工具函数,应同时断言"返回数组已排序"与"原数组引用与内容均未变化"(例如 expect(original).toEqual(originalSnapshot)),把不可变语义固化为回归测试。

七、速查清单

  • 在 useMemo、render 函数、event handler 中对 props/state 数组排序时,一律使用 toSorted();
  • 需要在旧浏览器运行时,用 [...items].sort(cmp) 降级,或统一封装工具函数;
  • 链式数组变换优先组合 toSorted / toReversed / toSpliced / with,全程无副作用;
  • 服务端组件避免把 toSorted() 等变换结果与原始数组同时传给客户端,防止 RSC 序列化重复传输;
  • 为排序工具函数补充不可变性测试,防止后续重构重新引入 sort() 原地修改。

参考来源

登录后查看全文
langfuse