Polar 前端实战:用toSorted()取代sort(),根治 React 状态与 Props 的可变性 Bug
【免费下载链接】polarPolar — A billing platform for the intelligence era项目地址: https://gitcode.com/GitHub_Trending/po/polar
toSorted()是 ES2023 引入的不可变排序方法,它返回一个新数组而绝不修改原数组。本文以 Polar 前端仓库(.agents/skills/vercel-react-best-practices/rules/js-tosorted-immutable.md)中的工程规范为主线,结合 order.ts、product.ts、CheckoutProductSwitcher.tsx 等真实源码,讲清楚为什么在 React/Next.js 项目里应禁用原地sort()、toSorted()的正确用法、浏览器兼容与降级方案,以及同一族不可变数组方法的完整图谱。读完你将掌握一套可直接落地到 Polar 这类计费/结账业务的不可变排序实践。
一、问题本质:.sort()会原地修改数组
JavaScript 数组的Array.prototype.sort()默认就地排序(in-place)——它直接改变调用它的数组本身,并返回同一个数组引用。这意味着任何持有该数组引用的代码(父组件、状态、闭包)都会在不知不觉间看到被改写后的数据。
在 Polar 的 React 前端中,组件接收的props与useState/useReducer管理的状态都是数组的常见来源。一旦在渲染链路中调用users.sort(...),被修改的数组可能恰好就是usersprop 引用的那份数据,从而污染上游:
// 错误示范:原地修改了传入的 users prop 数组 function UserList({ users }: { users: User[] }) { // users.sort() 直接改写了 props 数组本身! const sorted = useMemo( () => users.sort((a, b) => a.name.localeCompare(b.name)), [users] ) return <div>{sorted.map(renderUser)}</div> }这段代码的问题在于:users.sort()在执行时已经悄悄篡改了usersprop。即使外面包了useMemo,它只能控制"何时重算",无法阻止排序动作本身对原数组的破坏。
二、正确姿势:用.toSorted()返回全新数组
Array.prototype.toSorted()与sort()接受完全相同的比较函数签名,唯一区别是它返回一个排序后的新数组,原数组保持不变:
// 正确示范:创建新数组,原数组不受影响 function UserList({ users }: { users: User[] }) { const sorted = useMemo( () => users.toSorted((a, b) => a.name.localeCompare(b.name)), [users] ) return <div>{sorted.map(renderUser)}</div> }从源码结构看,Polar 的 checkout 包已经把这一规范落到了实处。CheckoutProductSwitcher.tsx 在渲染按席位定价的分档信息时,对来自 API 的seat_tiers做了不可变排序:
const sortedTiers = (seatPrice.seat_tiers?.tiers ?? []).toSorted( (a, b) => a.min_seats - b.min_seats, ) const basePricePerSeat = sortedTiers[0]?.price_per_seat ?? 0注意这里seatPrice是 React 组件 props 的一部分,tiers数组直接来自服务端 schema。若使用sort(),每次渲染都会改写 props 中的数据,导致父级与子级看到的分档顺序不一致;toSorted()则保证每次渲染都基于原始顺序生成一份新的有序副本。
三、为什么在 React 中这件事尤其致命
规则文件(impact: MEDIUM-HIGH)将这条规范评级为中高影响,理由如下:
破坏 React 的不可变模型:React 假定 props 与 state 是只读的。以
Object.is为核心的浅比较、React.memo、useEffect依赖数组,全都依赖"引用不变则内容不变"这一契约。原地排序改变了数组内容但引用未变,浅比较会漏判更新,造成 UI 不刷新或渲染脏数据。引发陈旧闭包(stale closure)Bug:在回调、事件处理器、effects 中捕获数组引用后,若其他代码路径对该数组执行
sort(),闭包内后续读取到的是被改写后的内容,行为难以预测且极难排查。与并发特性(Concurrent Features)冲突:React 18+ 的并发渲染允许一次渲染被中断、重放。渲染期间对共享数组的原地修改,在渲染被中断或重放时会留下半排序的中间状态,进一步放大不确定性。
Polar 的 web 应用中仍存在一些直接调用.sort()的场景,例如 CostsPage.tsx/dashboard/[organization]/(header)/analytics/costs/CostsPage.tsx#L135) 中对成本数据的排序。这类代码若排序对象是组件内新构造的派生数组则风险较低,但一旦数组源自 props 或共享状态,就应改为toSorted()——这正是本规则在代码评审中最常见的整改点。
四、浏览器兼容性与降级方案
.toSorted()属于 ES2023(Change Array by Copy 提案)的一部分,可用性如下:
- Chrome 110+(2023 年 2 月)
- Safari 16+(2022 年 9 月)
- Firefox 115+(2023 年 7 月)
- Node.js 20+
对于需要兼容更老环境的场景,用展开运算符手动拷贝一份再排序,语义完全等价:
// 旧浏览器降级写法 const sorted = [...items].sort((a, b) => a.value - b.value)在团队代码中也可以封装一个工具函数,统一降级逻辑:优先使用toSorted,否则退回[...arr].sort,并把比较函数透传下去。
五、Change Array by Copy 家族:一套不可变方法
toSorted()并非孤例,ES2023 同期引入了一整套"返回副本、绝不修改原数组"的方法族:
| 方法 | 对应原地版本 | 行为 |
|---|---|---|
.toSorted(compareFn) | .sort(compareFn) | 返回排序后的新数组 |
.toReversed() | .reverse() | 返回逆序后的新数组 |
.toSpliced(start, deleteCount, ...items) | .splice(...) | 返回增删元素后的新数组 |
.with(index, value) | 直接赋值arr[i] = v | 返回替换指定下标元素后的新数组 |
toReversed()在 Polar 中同样有使用场景:toSorted()后再配合toReversed()即可无损地实现降序展示,全程不触碰原数组。测试代码 bulk.test.ts 中甚至直接把三者链式组合用于断言:
const account = <T>(result: {...}) => [ ...result.succeeded.map((entry) => entry.item), ...result.failed.map((entry) => entry.item), ...result.cancelled, ].toSorted()可见这套不可变方法已被 Polar 团队作为日常工具在测试与业务代码中广泛采用。
六、Polar 仓库中的真实应用:从工具函数到测试断言
Polar 前端对toSorted()的使用贯穿了工具层、组件层与测试层,是这条规范最有说服力的落地证据:
1. 业务工具函数——order.ts
订单恢复判断逻辑需要找出最近一次失败的付款记录,先过滤再按created_at降序排列,取第一条:
export function getLatestFailedPayment( payments: schemas['Payment'][], ): schemas['Payment'] | null { return ( payments .filter((payment) => payment.status === 'failed') .toSorted( (a, b) => parseISO(b.created_at).getTime() - parseISO(a.created_at).getTime(), )[0] ?? null ) }这里payments是调用方传入的数组,filter()本就产生新数组,toSorted()再叠加一次不可变操作,整条链路零副作用。
2. 计量计费工具——product.ts
计量型价格(metered tiers)的档位需要按边界值排序,同时把"无上界"的档位排到末尾:
export const getMeteredTiers = (price: MeteredPrice): MeteredTier[] => price.amount_type === 'metered_tiers' ? price.tiers.tiers.toSorted( (a, b) => Number(a.bound == null) - Number(b.bound == null) || (a.bound ?? 0) - (b.bound ?? 0), ) : []这段代码展示了toSorted()配合复杂比较函数的典型用法:先用Number(a.bound == null)让无上界档位排后,再用(a.bound ?? 0) - (b.bound ?? 0)对具体边界升序排列。price来源于 checkout 组件的 props 与 hooks 状态,不可变排序保证了多次调用、多次渲染结果一致。
3. 测试断言——bulk.test.ts
expect(result.cancelled.toSorted()).toEqual([2, 3, 4])无参调用toSorted()即按默认字典序比较,适合在断言前对结果做归一化,避免依赖执行顺序。
这些用例的共同点:排序的输入要么来自 props/API 数据,要么是需要保持原样的共享数组,而输出总是被消费的新数组——这正是toSorted()被设计出来的场景。
七、落地清单:团队如何执行这条规范
结合 Vercel React Best Practices 技能(SKILL.md)中js-类别的定位(JavaScript Performance 类别,中等优先级),建议在代码评审与自动检查中遵循以下要点:
- 默认使用不可变方法:新代码一律写
toSorted(),仅在确实需要原地排序且明确持有数组所有权时才用sort()。 - 警惕
useMemo内的sort():useMemo不能豁免原地修改,它只控制重算时机。 - 关注数据来源:props 透传的数组、hooks 返回的共享数组、闭包捕获的数组,三者都是高风险对象,排序必须走副本。
- 降级方案统一封装:老环境用
[...arr].sort(compareFn)兜底,避免散落各处写法不一。 - 测试中利用不可变特性:断言前用
toSorted()归一化结果(参考 bulk.test.ts),既简洁又能验证数据的完整性。
八、总结
toSorted()用一个最小的 API 差异(多一个to前缀),消除了 React 前端中一类隐蔽且难以复现的可变性 Bug:props/state 被意外改写、浅比较失效、陈旧闭包读取脏数据、并发渲染下的中间态。Polar 仓库在订单恢复、计量计费分档、结账组件与测试断言中已经全面采用这一模式,配合toReversed()、toSpliced()、with()组成的不可变方法族,可以让数组操作在任何 React/Next.js 代码路径上都保持无副作用。把"默认toSorted()"写进团队规范,是投入产出比极高的工程决策。
【免费下载链接】polarPolar — A billing platform for the intelligence era项目地址: https://gitcode.com/GitHub_Trending/po/polar
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考