` 取代 `sort()`,根治 React 状态与 Props 的可变性 Bug)
Polar 前端实战用toSorted()取代sort()根治 React 状态与 Props 的可变性 Bug【免费下载链接】polarPolar — A billing platform for the intelligence era项目地址: https://gitcode.com/GitHub_Trending/po/polartoSorted()是 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 closureBug在回调、事件处理器、effects 中捕获数组引用后若其他代码路径对该数组执行sort()闭包内后续读取到的是被改写后的内容行为难以预测且极难排查。与并发特性Concurrent Features冲突React 18 的并发渲染允许一次渲染被中断、重放。渲染期间对共享数组的原地修改在渲染被中断或重放时会留下半排序的中间状态进一步放大不确定性。Polar 的 web 应用中仍存在一些直接调用.sort()的场景例如 CostsPage.tsx/dashboard/[organization]/(header)/analytics/costs/CostsPage.tsx#L135) 中对成本数据的排序。这类代码若排序对象是组件内新构造的派生数组则风险较低但一旦数组源自 props 或共享状态就应改为toSorted()——这正是本规则在代码评审中最常见的整改点。四、浏览器兼容性与降级方案.toSorted()属于 ES2023Change Array by Copy 提案的一部分可用性如下Chrome 1102023 年 2 月Safari 162022 年 9 月Firefox 1152023 年 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.tsexpect(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 前端中一类隐蔽且难以复现的可变性 Bugprops/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),仅供参考