Polar 前端性能实践:数组比较先做长度检查(Early Length Check)的正确姿势
2026/9/15 12:59:02 网站建设 项目流程

Polar 前端性能实践:数组比较先做长度检查(Early Length Check)的正确姿势

【免费下载链接】polarPolar — A billing platform for the intelligence era项目地址: https://gitcode.com/GitHub_Trending/po/polar

本文基于 Polar 仓库内.agents/skills/vercel-react-best-practices技能包中的js-length-check-first规则,系统讲解在 JavaScript/TypeScript 中比较两个数组时,为何应当先进行 O(1) 的长度检查再执行昂贵比较,并结合 Polar 前端(React + Next.js)的真实源码与测试,展示该优化在事件处理、表单变更检测、渲染循环等热路径上的落地方式。读完本文,你将掌握一套可复制的"长度优先"数组比较写法,以及与之配套的不可变排序与提前返回优化。

规则出处与本仓库中的定位

该规则位于仓库的 js-length-check-first.md,属于vercel-react-best-practices技能包(SKILL.md)中JavaScript Performance(js-前缀)类别的一条规则,标定影响等级为MEDIUM-HIGH,其影响描述为"当长度不同时避免昂贵操作(avoids expensive operations when lengths differ)"。

在该技能包的优先级排序中,js-类规则位于第七档(LOW-MEDIUM),但其中不少规则(包括本规则)在热路径代码中的实际收益非常可观。SKILL.md 中对该规则的速查描述为:

js-length-check-first- Check array length before expensive comparison

这条规则与同一类别下的js-early-exit(提前返回)、js-tosorted-immutable(用toSorted()保证不可变排序)天然互补,经常成组出现在同一次代码审查或重构中。

问题本质:昂贵的比较操作不该无条件执行

在真实业务代码中,判断"两个数组是否相等"是极常见的需求,例如:用户编辑了某个集合(已启用的功能 ID、商品价格 ID、多选标签)后,需要判断内容是否发生变化,从而决定是否弹"未保存更改"提示或是否触发提交。

最常见的偷懒写法是"先排序、再拼接成字符串、再比较字符串":

// ❌ 错误:无论长度是否相等,都执行昂贵的比较 function hasChanges(current: string[], original: string[]) { // 长度不同时也会执行排序和 join return current.sort().join() !== original.sort().join() }

这段代码的问题在于:current.length为 5、original.length为 100 时,两轮sort()(各为 O(n log n))依然会完整执行,随后还有拼接字符串、比较字符串的额外开销——而这一切本可以避免,因为长度不同的数组必然不相等

具体开销可拆解为:

  • 两次sort()排序,时间复杂度各为 O(n log n);
  • 两次join()拼接,需要分配与数组元素总量成正比的字符串内存(大数组下不可忽视);
  • 一次字符串全量比较。

正确实现:O(1) 长度短路 + 有序逐元素比较

规则给出的正确写法如下:

// ✅ 正确:先做 O(1) 长度检查,再在长度相等时逐元素比较 function hasChanges(current: string[], original: string[]) { // 长度不同,直接提前返回 if (current.length !== original.length) { return true } // 仅在长度相同时才排序 const currentSorted = current.toSorted() const originalSorted = original.toSorted() for (let i = 0; i < currentSorted.length; i++) { if (currentSorted[i] !== originalSorted[i]) { return true } } return false }

该实现的关键点与收益:

  1. 长度检查是 O(1) 的短路length是数组的内建属性,读取成本恒定。长度不等直接返回true,两个数组(无论多大)之间的排序、拼接、比较全部跳过;
  2. 只在必要时排序:长度相等才进入排序分支,且元素比较采用"逐项短路",发现第一个不同就返回,无需等到全部比完;
  3. 零字符串内存开销:不再构造join()产生的中间字符串,对包含大量元素(如上千个 ID)的数组尤为重要;
  4. 不修改原数组toSorted()返回新数组(详见下文与js-tosorted-immutable规则的呼应),避免对传入的 props/state 造成意外变更;
  5. 提前返回:循环中命中差异立即return true,体现js-early-exit规则的精神。

从"数组相等"到"集合是否变更":本仓库中的实战印证

Polar 前端代码库(clients/目录,React + Next.js)中就有多处"长度优先"的数组比较实践,可作为该规则的最佳注脚。

印证一:i18n 工具库的通用arraysEqual

clients/packages/i18n/scripts/utils.ts 中定义了一个通用的数组相等判断:

/** * Check if two arrays are equal (same elements in same order) */ export function arraysEqual(a: string[], b: string[]): boolean { if (a.length !== b.length) return false return a.every((val, i) => val === b[i]) }

注意这里的判断是"相同顺序下的相等"(与规则的"无序相等"场景略有不同),但其骨架完全一致:先比较长度,长度不等直接return false,长度相等才用every逐项比较every本身也是短路求值——遇到第一个不相等元素即停止。这是规则"长度检查优先"最直接、最干净的通用形态。

印证二:商品编辑页的"权益是否变更"检测

clients/apps/web/src/components/Products/EditProductPage.tsx 在表单场景中判断启用的权益 ID 列表是否发生变化:

const hasBenefitsChanged = useMemo( () => enabledBenefitIds.length !== originalBenefitIds.length || enabledBenefitIds.some((id, index) => id !== originalBenefitIds[index]), [enabledBenefitIds, originalBenefitIds], )

这里hasBenefitsChanged被用于useEffect中驱动"未保存更改"提醒(第113-115行):

useEffect(() => { alertOnUnsavedChanges(formState.isDirty || hasBenefitsChanged) }, [formState.isDirty, hasBenefitsChanged, alertOnUnsavedChanges])

由于enabledBenefitIdsoriginalBenefitIds都是按索引对应比较,长度不等必然意味着发生了增删,因此length !==的判断被放在||的最左侧作为第一道短路。这段代码运行在每次表单状态变化触发的渲染路径上(useMemo依赖变更即重算),正是规则所说"hot paths"(热路径)的典型——提前用 O(1) 判断挡掉绝大多数无需逐项比较的情况。

印证三:商品选择器的新价格判断

clients/apps/web/src/components/Products/ProductCombobox.tsx 中的hasNewPricing采用了同样的模式:

const hasNewPricing = ( product: schemas['Product'], currentPriceIds: string[], ) => { const productPriceIds = product.prices.map(({ id }) => id) if (productPriceIds.length !== currentPriceIds.length) return true return !productPriceIds.every((id) => currentPriceIds.includes(id)) }

同样地,先把"数量不等"这一最廉价、最强信号的判断放在最前面;数量相等才进入every的逐项包含性检查。这再次印证:长度检查是数组比较中最划算的第一步判断,因为它以恒定成本直接区分了"必然不等"与"可能相等"两个分支。

与配套规则的协同:toSorted()与不可变比较

正确示例中使用的current.toSorted()/original.toSorted()本身即另一条规则js-tosorted-immutable的核心主张,详见 js-tosorted-immutable.md:

  • .sort()会原地修改数组。在 React 中,对 props 或 state 数组调用.sort()属于破坏不可变模型的变更操作,可能引发陈旧闭包、意外副作用等 bug;
  • .toSorted()返回新数组、原数组不变,适合在useMemo、事件回调等场景中安全使用;
  • 浏览器兼容基线为 Chrome 110+、Safari 16+、Firefox 115+、Node.js 20+;对更老的运行环境可用展开运算符兜底:[...items].sort(...)

Polar 代码库同样大量采用toSorted():例如 clients/apps/web/src/utils/bulk.test.ts 中,测试代码用[...].toSorted()构造排序后的期望值,并在第 72 行用expect(result.cancelled.toSorted()).toEqual([2, 3, 4])进行断言。这些测试用例同时验证了并发批量执行(runBulk)在成功、失败、取消三种路径下的结果集合,排序仅为断言服务,绝不允许污染原始结果数组——这正是"不可变排序"在真实工程中的价值体现。

何时适用、何时慎用:边界与注意事项

  • 无序相等(集合比较):若比较的是"包含相同元素即可"的集合,需先排序再逐元素比较,本文示例正是这种形态;
  • 有序相等(序列比较):若顺序本身有意义(如 i18n 工具中的arraysEqual),则完全不需要排序,直接length检查 + 逐项比较即可,成本更低;
  • 不要过早优化:长度检查本身没有坏处,但对只有两三个元素的微型数组,其收益可忽略;真正值得落地的场景是比较发生在渲染、事件回调、表单校验等高频热路径,且数组规模可能较大的场景;
  • 结合js-early-exit:长度检查本质上是"函数入口处最早的提前返回",编写时请保持函数单一职责,让短路分支一目了然,便于审查者确认"长度不等 ⇒ 必然不同"这一不变式成立;
  • 注意引用与变更语义:长度检查只能说明"元素数量相同",不能说明"元素内容相同",因此长度相等后的逐项比较不可省略。

小结

js-length-check-first规则给出了一条简单而高频有效的优化原则:在比较两个数组之前,先用 O(1) 的length判断拦截"必然不等"的分支。它可以:

  • 避免长度不等时仍执行 O(n log n) 排序与字符串拼接;
  • 避免大数组下为拼接字符串分配多余内存;
  • 配合toSorted()避免原数组被原地修改;
  • 配合短路逐项比较,在发现第一个差异时立即返回。

Polar 仓库中 utils.ts、EditProductPage.tsx、ProductCombobox.tsx 三处实现,分别覆盖了"通用工具函数""表单变更检测""选择器状态判断"三类真实场景,可以作为团队内落地该规则的参考范例;更完整的规则集合可查阅 SKILL.md 与 AGENTS.md。

【免费下载链接】polarPolar — A billing platform for the intelligence era项目地址: https://gitcode.com/GitHub_Trending/po/polar

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询