ES3兼容的Array.prototype.at polyfill实现与边界处理 📅 发布时间:2026/9/3 3:36:36 👁 浏览次数: 简介这是一份为Array.prototype.at方法提供的标准polyfill/shim实现面向需要在ES3级旧环境中使用ESnext数组索引特性的前端开发者解决现代浏览器与老旧运行环境之间API缺失的兼容问题。压缩包共30个文件体积仅17KB其中8个js与5个mjs分别提供CommonJS与ES Module两套入口7个yml用于驱动自动化测试流程2个md文档提供说明与变更日志另有eslint、nyc等质量工具配置整体结构清爽。目前已有567人学习下载这套代码对需要做低版本兼容的前端项目很有价值对于想深入理解Polyfill原理的开发者也是一份精炼的参考。资源内含implementation.js、polyfill.js、shim.js、auto.js等模块既可npm安装后按需调用也能在支持的环境中自动挂载到Array.prototype配套tests.js测试用例覆盖负数索引与越界访问等关键边界场景便于验证实现正确性也为二次改造提供了清晰基准。 这年头还在为一个“已经进入 ES2022 标准”的 API 写 shim大概率是在维护老掉牙的项目。你手头可能还有必须支持 IE 10 甚至更早内核的业务系统也可能只是想让一段公共组件代码在远古运行环境里保持行为一致。不管哪种Array.prototype.at这个 API 确实值得补因为它解决了数组负索引访问的老大难问题。但网上流传的很多 polyfill 实现其实都默认了 ES5 环境真正要求“可工作到 ES3”时你会在挂载方式、参数转换、边界处理这些地方踩出一堆坑。这里我不打算贴一堆 MDN 译文而是把我最终在项目里落地的一份实现拆开讲清楚包括它为什么这样写、哪些地方必须和规范对齐、哪些边界 case 最容易翻车。1. 为什么还需要一个 at 的补丁负索引问题的历史包袱1.1 老代码里 arr[arr.length - 1] 的三个问题在at()出现之前取数组最后一项的写法基本是arr[arr.length - 1]。这行代码本身没错但它有三个很别扭的地方负索引只能靠手工计算。arr[-1]在 JavaScript 里不会像 Python 那样返回最后一项它只是给对象加了一个名为-1的属性结果通常是undefined而且这个属性会被真正挂到数组对象上。排错时经常看到这种莫名多出来的-1自有属性。代码可读性差。arr[arr.length - 1]在语义上绕了一圈审查代码时每次都要在脑子里算一下“这是倒数第一个还是倒数第二个”。如果是arr[arr.length - 2]更容易看走眼。每次都要处理空数组。[].at(-1)返回undefined但[][0]也是undefined很多时候没法区分“越界”和“恰好是 undefined 值”只能额外判断arr.length。at()的引入本质上是把“越界返回undefined”和“支持负索引”这两件事标准化了。数组取值从“手算索引”变成了“像 Python 一样直接写负号”这对回调函数里高频出现的“取末尾”“取倒数第二个”非常友好。1.2 at() 从 TC39 提案到 ES2022 的语义定稿Array.prototype.at最初是 TC39 的一个提案提案阶段讨论过不少边界细节最终进入 ES2022 标准的语义有几点值得注意它是泛型方法不要求this是数组类数组对象、字符串包装对象都能用。Array.prototype.at.call(hello, -1)会返回o。索引参数会经过ToIntegerOrInfinity所以传undefined、null、true、1、NaN这些值时都有确定行为不会像老代码那样直接拿原始值做数组索引。索引越界或长度为 0 时统一返回undefined不会抛RangeError。但this为null或undefined时规范要求先执行ToObject(this)这一步会抛TypeError。看到这里你应该能理解一个符合规范的 polyfill 不只是在原型上挂一个function (index) { return this[index 0 ? this.length index : index]; }那么简单参数转换和越界判断必须严格走规范链路。2. 写给 ES3 的安装器真正的难点不在 at而在怎么挂载2.1 ES3 与 ES5 之间的能力落差既然目标写着“可工作到 ES3”就要先认清 ES3 环境缺什么。ES3 是 1999 年定稿的 ECMAScript 标准对应 IE5.5、IE6 这类古董浏览器。和 ES5 相比至少这几样东西在 ES3 里是没有的Object.defineProperty不存在无法精确控制属性是否可枚举。Array.isArray不存在判断数组只能靠Object.prototype.toString。Math.trunc不存在对一个数字做向零取整得自己写。JSON、Function.prototype.bind等一堆东西都不存在。没有严格模式的完整保留字处理但字符串字面量use strict本身不会造成语法错误可以安全地写。更坑的是即便在 IE8 这种“半 ES5”环境里Object.defineProperty也存在但它只对原生 DOM 对象有效对普通 JS 对象直接调用会抛异常。现代 polyfill 里那句Object.defineProperty(Array.prototype, at, {...})在 IE8 上会遇到“能解析但运行时报错”的情况所以必须做降级。2.2 不可枚举安装的降级方案理想的挂载方式是用Object.defineProperty把at定义为enumerable: false这样for...in遍历数组时不会看到这个补丁方法。但如果环境不支持或者调用抛错就只能退化为直接赋值function installAt(proto, force) { if (force ! true typeof proto.at function) { return proto.at; } var hasDefineProperty (typeof Object.defineProperty function); function tryDefine() { if (!hasDefineProperty) { return false; } try { Object.defineProperty(proto, at, { value: at, writable: true, configurable: true, enumerable: false }); return true; } catch (e) { return false; } } if (!tryDefine()) { proto.at at; } return at; }这里的tryDefine用try/catch包住整个defineProperty调用不是小题大做。老环境的defineProperty可能因为属性不可配置、对象类型不支持等原因抛出各种异常一个typeof Object.defineProperty function判断完全不够。2.3 为什么不能直接照抄现代 polyfill 的 defineProperty 写法网上你能搜到的大部分Array.prototype.atpolyfill开头都是这样的if (!Array.prototype.at) { Object.defineProperty(Array.prototype, at, { ... }); }这段代码在真正的 ES3 环境有两个致命问题Object.defineProperty根本不存在执行到就报TypeError。如果已经存在一个行为不正确的at比如某个老库自己挂了乱七八糟的atif (!Array.prototype.at)会直接跳过安装错误的实现继续生效。这其实对应了前一段时间网上讨论的“bad shim”问题一个垫片方法本身签名错了却没有机制去校验和替换它。所以我在安装器里加了一个force参数。默认不覆盖已有方法但如果你明确知道现有实现是坏的可以传入force true强制执行替换。这个设计在真实项目里很有用尤其是你无法控制第三方代码加载顺序的时候。3. 逐行实现一套与规范对齐的 toObject / toIntegerOrInfinity / toLength 链路3.1 完整代码下面这份实现我刻意没有使用任何 ES5 语法所有变量用var函数用普通声明逻辑上完全可以在 ES3 引擎解析执行(function (global) { use strict; var ArrayProto Array.prototype; var defineProperty Object.defineProperty; function toObject(value) { if (value null || value undefined) { throw new TypeError(Cannot convert undefined or null to object); } return Object(value); } function toIntegerOrInfinity(value) { var n Number(value); if (n ! n) { return 0; } if (n Infinity || n -Infinity) { return n; } if (n 0) { return Math.ceil(n); } return Math.floor(n); } function toLength(value) { var len toIntegerOrInfinity(value); if (len 0) { return 0; } if (len 9007199254740991) { return 9007199254740991; } return len; } function at(index) { var o toObject(this); var len toLength(o.length); var relativeIndex toIntegerOrInfinity(index); var k; if (relativeIndex 0) { k relativeIndex; } else { k len relativeIndex; } if (k 0 || k len) { return undefined; } return o[k]; } function tryDefineProperty(proto, name, fn) { if (typeof defineProperty ! function) { return false; } try { defineProperty(proto, name, { value: fn, writable: true, configurable: true, enumerable: false }); return true; } catch (e) { return false; } } if (!tryDefineProperty(ArrayProto, at, at)) { if (typeof ArrayProto.at ! function) { ArrayProto.at at; } } if (typeof String.prototype.at ! function) { String.prototype.at at; } })(typeof globalThis ! undefined ? globalThis : typeof self ! undefined ? self : typeof window ! undefined ? window : typeof global ! undefined ? global : this);3.2 参数转换的等价实现toIntegerOrInfinity是整个补丁最核心的部分它要模拟规范里的ToIntegerOrInfinity抽象操作。规范逻辑是先ToNumber如果是NaN返回0如果是Infinity返回Infinity否则向零取整。ES3 没有Math.trunc所以我用n 0 ? Math.ceil(n) : Math.floor(n)实现向零取整。举例Number(3.7)得到3.7Math.floor后是3即[1,2,3,4].at(3.7)返回4。Number(-2.9)得到-2.9Math.ceil后是-2即[1,2,3,4].at(-2.9)返回3。Number(undefined)是NaN所以arr.at()等价于arr.at(0)。Number(null)是0所以arr.at(null)也返回第一项。注意这里有个很微妙的点规范里的ToIntegerOrInfinity对BigInt参数会抛TypeError因为 BigInt 无法安全转为 Number。ES3 环境没有 BigInt但我为了兼容现代环境可以在toIntegerOrInfinity开头加一行检查if (typeof value bigint || typeof value symbol) { throw new TypeError(Cannot convert BigInt or Symbol to number); }加上这一行在 ES3 引擎里也不会出错因为它只是字符串比较不会因为bigint类型不存在而语法报错。3.3 属性读取与 Proxy / getter 的一致性at函数最后用return o[k]获取结果这是故意的。规范里定义的是Get(O, ToString(k))也就是把数字k转成字符串属性名再读取。JavaScript 里o[k]在k是数字时也会自动转成字符串所以两者行为一致。这个选择对带有 getter 的对象或 Proxy 很重要。如果你在 polyfill 里写成return o[String(k)]效果一样但如果你图省事先转成数组再取值就会丢失 Proxy 的 get trap 语义。一个符合规范的做法是始终通过原始对象读取属性而不是复制一份数组。对Array.prototype.at.call({ length: 2, 0: a, 1: b }, -1)这种情况toObject(this)会返回原对象对象本身就是对象toLength(o.length)得到2相对索引k 2 (-1) 1最后o[1]返回b和规范完全一致。4. 边界条件清单最容易写错的十几个 case4.1 参数与 length 的怪异组合速查表写 polyfill 时很多人只测了正常整数参数遇到undefined、1、NaN、Infinity就开始放飞。下面这张表是我在项目里跑差分测试时固定使用的用例每一行都对比过原生实现的输出调用结果原因[1,2,3].at(1)2正常正索引[1,2,3].at(-1)3负索引转len (-1)[1,2,3].at(-4)undefinedk 3 (-4) -1小于 0[1,2,3].at(3)undefinedk len[1,2,3].at()1undefined转成0[1,2,3].at(undefined)1undefined转成0[1,2,3].at(null)1null转成0[1,2,3].at(1)2字符串转数字[1,2,3].at(true)2true转成1[1,2,3].at(false)1false转成0[1,2,3].at(NaN)1NaN转成0[1,2,3].at(Infinity)undefinedk是Infinity大于len[1,2,3].at(-Infinity)undefinedk 3 - Infinity -Infinity[1,2,3].at(1.9)2向零取整为1[1,2,3].at(-1.9)3向零取整为-1Array.prototype.at.call(hello, -1)o泛型方法作用于字符串包装对象Array.prototype.at.call({length: -1, 0: a}, 0)undefinedToLength将负数转为 0Array.prototype.at.call(null, 0)TypeErrorToObject(null)抛错Array.prototype.at.call({length: 2, 0: a, 1: b}, 0)alength 字符串转数字4.2 this 与类数组对象的处理很多初版 polyfill 会先判断Array.isArray(this)如果不是数组就直接返回 undefined 或者干脆不处理。这是一个明显的规范理解错误。at()是泛型方法规范里this先被ToObject包装成对象再读取length属性。这意味着字符串原语能工作因为Object(hello)会生成一个带索引属性的字符串包装对象。arguments对象能工作它也有length和数字索引属性。普通对象只要带length也能工作。length会被ToLength处理所以2、true、null、-1这几种怪异值都有确定结果。真正的实现里我甚至不需要写“是否数组”的判断只要toObject(this)不抛错后面所有逻辑都是通用的。这也让这份 shim 可以顺带补到String.prototype上因为语义完全兼容。4.3 BigInt / Symbol 以及强制替换场景在支持 BigInt 和 Symbol 的现代环境里[1,2,3].at(1n)会抛TypeError[1,2,3].at(Symbol())也会抛TypeError。原因都是ToIntegerOrInfinity内部执行ToNumber时无法处理这两种类型。我的toIntegerOrInfinity里加了显式检查行为一致。另外如果你的项目里已经存在一个Array.prototype.at但它是某个旧补丁挂的、行为不符合规范默认安装器不会覆盖它。这时候installAt(Array.prototype, true)的force参数就派上用场了。实际维护老项目时这种“显式替换坏补丁”的需求比想象中常见。5. 测试与落地让这个 shim 真正成为项目的一部分5.1 用小规模差分测试覆盖边界只跑几个正常用例根本测不出 polyfill 的问题。我的建议是在支持原生的现代浏览器或 Node 环境里把 polyfill 安装成另一个名字然后和原生实现做差分测试。比如var polyfillAt at; // 假设 at 是上面那份实现 var cases []; for (var i -10; i 10; i) { cases.push(i); } cases.push(undefined, null, true, false, NaN, Infinity, -Infinity, 1, abc, 1.9, -1.9); var arr [1, 2, 3, 4, 5]; for (var j 0; j cases.length; j) { var index cases[j]; var nativeResult, polyfillResult; try { nativeResult arr.at(index); } catch (e) { nativeResult THROW; } try { polyfillResult polyfillAt.call(arr, index); } catch (e) { polyfillResult THROW; } if (nativeResult ! polyfillResult) { console.log(Mismatch:, index, nativeResult, polyfillResult); } }比较时不要用严格相等判断NaN因为NaN ! NaN。如果你要处理的是完整规范语义可以用Object.is但 ES3 里没有所以测试代码至少要在现代环境跑或者自己写一个isSameValue。5.2 加载顺序与老项目集成的注意事项老项目引入补丁最大的坑不是补丁本身而是加载顺序。理想情况下这个文件应该放在所有业务代码之前执行并且只加载一次。如果项目没有模块系统直接用 IIFE 挂到全局即可。注意代码里我通过typeof globalThis ! undefined ? globalThis : ...这种链式判断拿到全局对象这比直接写window更通用Node 老版本也能用。另一个容易忽略的点如果你把它挂到Array.prototype时使用的是降级赋值proto.at at那么这个属性是可枚举的。在 for...in 遍历数组时会看到at这可能让一些老代码意外多遍历出东西。解决方法是尽量让环境满足defineProperty可用否则就要接受这个副作用。你真的需要 ES3 兼容时这通常是一笔值得的取舍因为 ES3 时代本来也没有多少人会用 for...in 遍历数组。5.3 顺便澄清另一个领域的 bad shim signature 与 JS shim 无关写这篇分享时我注意到网上同时也在讨论“bad shim signature”“failed to start shim: unsupported shim version”一类的内容。这里必须澄清一个术语歧义那些讨论里的 “shim” 是引导加载程序领域的概念和 JavaScript 的 shim/polyfill 完全是两码事。JS 生态里的 shim 本质就是一个普通函数没有签名验证也没有版本约束所以才容易出现“坏补丁覆盖好补丁”或者“好补丁不覆盖坏补丁”的问题。理解这一点对你判断项目中是否要加force替换逻辑会有帮助。回到落地本身。一份真正可用的 ES3 级Array.prototype.atpolyfill重点从来不是那几行取值逻辑而是参数转换是否严格对齐规范、挂载方式是否做了降级、边界条件是否有差分测试兜底。把这些都处理完它才能在一个老到掉渣的环境里稳定跑上几年不给你惹麻烦。本文还有配套的精品资源点击获取