资讯详情 ES3 兼容的 Array.prototype.at polyfill:负索引与规范语义的实现
📅 2026/10/25 21:03:57
简介面向需要在 ES3 及以上老旧运行环境补齐 ES 规范能力的 JavaScript 开发者这份资源实现了 Array.prototype.at 方法的 polyfill 与 shim 替代方案。at 方法支持负数索引属于较新的 ES 数组能力本实现严格按照提案语义封装既能直接替换浏览器缺失的原生方法也可作为学习规范细节的参考源码。压缩包包含三十个文件主体是 JS 与 MJS 模块文件另有 YAML 工作流配置、Markdown 说明文档、package.json 等工程文件整体仅十七 KB体量轻巧便于审计与集成。目前已有五百六十八人学习或下载适合前端工具库维护者、需要兼容旧项目的团队以及想要深入理解 ES 提案落地过程的读者。包内划分 shim、polyfill、implementation 等多个入口提供 CommonJS 与 ESM 两种模块形式并附带 tests 测试用例、变更记录与许可证既能通过 npm 安装后立即使用也能通过阅读源码掌握接收者 this 的处理、空值判断、边界索引等 polyfill 实现要点。1. 老环境用上新 APIArray.prototype.at 的 polyfill 到底在补什么Array.prototype.at 是 ES2022 才有的数组方法核心卖点是负数索引arr.at(-1)直接拿最后一个元素不用再写arr[arr.length - 1]。但现实是还有相当一部分老设备、旧 WebView、银行项目里锁死的浏览器跑在 ES3 引擎上它们不认识这个方法。这份资源给的就是一版严格按 ES 规范步骤实现的 polyfill 兼 shim能工作到 ES3也就是说连 IE6/7/8 时代的 JScript 也能跑。适合维护老项目的工程师、组件库作者以及任何要在大规模存量代码里谨慎引入新 API 的人。装上它arr.at(-1)的行为和现代浏览器原生实现完全一致而不是网上那些看着能跑、一查边界全是洞的版本。2. 把语义啃透at 的规范步骤与 ES3 环境的边界2.1 at 的本质是负数索引不是简单访问如果你只是想在 ES2022 环境里少写几个字符at的价值没那么大真正让它不可替代的是它对负数索引的完整语义。ES2022 规范里Array.prototype.at 接收一个参数 index完整流程是这样ToObject(this)把 this 强制变成对象。LengthOfArrayLike 取出对象的 length。ToIntegerOrInfinity(index)把参数规整成整数。index 为非负数时 kindex为负数时 klenindex。如果 k 小于 0 或者 k 大于等于 len返回 undefined。否则返回 O[k]。这几个步骤里最容易在 polyfill 上翻车的是第 3 和第 5 步。第 3 步要求把 NaN 转成 0、把小数截断、把 Infinity 保留第 5 步则明确了负索引越过头部时返回 undefined而不是像某些错误实现那样把 k 钳到 0。下面这张表可以直接当测试用例用arr 固定为 [1, 2, 3]。调用表达式返回值内部 k 的推导arr.at(-1)33 (-1) 2arr.at(-3)13 (-3) 0arr.at(-4)undefined3 (-4) -1小于 0arr.at(NaN)1ToIntegerOrInfinity(NaN) 得 0arr.at(1.9)2ToIntegerOrInfinity(1.9) 得 1arr.at(Infinity)undefinedk 为 Infinity大于等于 len为什么 NaN 会返回第一个元素因为 NaN 转数字后做相等判断永远为 false任何规范实现都会先判n ! n命中就直接返回 0所以arr.at(NaN)等价于arr.at(0)。这属于反直觉但符合规范的行为业务代码里很少有人主动传 NaN但测试用例必须覆盖到。at 的另一个隐藏优势是它不产出新数组arr.slice(-1)[0]会先创建长度为 1 的新数组再取元素at(-1)没有这层开销在循环里取末位时这个差异会被放大。2.2 ES3 环境到底缺什么ES3 是 1999 年的 ECMAScript 第三版对应 IE6/7/8 的 JScript、早期 WebKit、部分物联网设备的内置浏览器。做老环境兼容之前先要清楚 ES3 缺的不是某个方法而是一整套语言能力。这些缺失直接决定 polyfill 的挂载策略和代码写法。能力项ES3ES5对 polyfill 的影响Object.defineProperty无有无法定义不可枚举的原型方法严格模式无有this 为 null/undefined 时行为不可控Array 原型规范宿主差异大统一挂载后遍历行为不一致Function.prototype.bind无有polyfill 内部不能用现代 api 精简代码JSON / Array.isArray无有工具函数全部要自写这里最容易被忽略的是 IE8 这个中间态。IE8 其实有 Object.defineProperty但它的实现只对 DOM 元素生效对 Array.prototype 这类普通对象会直接抛错或静默失败。所以一个号称兼容到 ES3 的 polyfill不能只写Object.defineProperty一句完事必须用 try/catch 包住失败后走直接赋值。后面第 5 章会专门讲这个坑。ES3 的另一个隐性问题是typeof对未声明变量的安全性。探测Array.prototype.at是否存在时typeof AP.at ! function在 ES3 下是安全的但如果有人写成AP.at undefined在部分老 JScript 引擎里可能会因为 AP.at 从未被声明而抛 ReferenceError。这个细节虽然绕但老项目的背锅现场经常是这种一行字引发的。2.3 网上流传的 at polyfill 错在哪打开搜索引擎搜 Array.prototype.at polyfill前几页大概率会出现这个版本if (!Array.prototype.at) { Array.prototype.at function (idx) { if (idx 0) { idx this.length idx; } return this[idx]; }; }这段代码在简单场景下确实能用[1,2,3].at(-1)返回 3。但拿它去对规范至少五个地方是错的。第一idx 没做 ToIntegerOrInfinity传 NaN 时idx 0为 false直接返回this[NaN]而 NaN 作为属性名从来不是数字索引返回 undefined规范要求是返回arr[0]。第二负索引越过头部时它去访问this[-2]这类属性如果数组恰好带着字符串键 -2会返回脏数据规范要求直接 undefined。第三this 为 null/undefined 时的抛错时机和类型都不对。第四直接赋值在 ES5 环境中产生的是可枚举属性for...in 遍历数组时会多出一个 at 键。第五length 没有正规化遇到负数 length 或非数字 length 的类数组对象行为完全不可控。这个错误版本正是看着能跑的典型常规操作全部通过边界测试一上就露馅。所谓符合 ES 规范建议的shim指的就是逐条实现规范里的 ToObject、LengthOfArrayLike、ToIntegerOrInfinity、负索引处理四件事而不是把业务直觉当成规范。这也是这份资源值得下载的原因它不是为了让你少写几行代码而是为了让你在旧环境里拿到和现代引擎完全一致的语义。3. 手写一版真正可用的 polyfillES3 语法下还原规范语义3.1 完整代码先看全貌。这份 polyfill 语法严格限制在 ES3没有箭头函数、没有 let/const、没有 class压缩工具可以安全处理直接贴进项目入口就能用。(function (global) { use strict; var MAX_SAFE_INTEGER Math.pow(2, 53) - 1; function toIntegerOrInfinity(value) { var n Number(value); if (n ! n) { return 0; } // NaN - 0 if (n 0 || n Infinity || n -Infinity) { return n; } var abs Math.abs(n); var floored Math.floor(abs); return n 0 ? -floored : floored; } function toLength(value) { var len toIntegerOrInfinity(value); if (len 0) { return 0; } if (len MAX_SAFE_INTEGER) { return MAX_SAFE_INTEGER; } return len; } function at(index) { if (this null) { throw new TypeError(Array.prototype.at called on null or undefined); } var o Object(this); var len toLength(o.length); var relativeIndex toIntegerOrInfinity(index); var k relativeIndex 0 ? relativeIndex : len relativeIndex; if (k 0 || k len) { return undefined; } return o[k]; } var AP Array.prototype; if (typeof AP.at ! function) { if (typeof Object.defineProperty function) { try { Object.defineProperty(AP, at, { value: at, writable: true, configurable: true, enumerable: false }); } catch (e) { AP.at at; } } else { AP.at at; } } }(typeof self ! undefined ? self : typeof window ! undefined ? window : typeof global ! undefined ? global : this));这段代码的整体结构是立即执行函数把全局对象传进去避免在非严格模式下直接操作全局时产生意外泄漏。use strict在 ES3 引擎中会被当作普通字符串表达式忽略没有副作用在 ES5 引擎中则真正开启严格模式所以这一句对老环境和新环境都安全。挂载部分先探测typeof AP.at ! function确认缺失才动手避免覆盖原生实现随后再探测Object.defineProperty是否存在存在就用它定义不可枚举属性不存在或抛错就退回直接赋值。3.2 逐行拆解从 ToIntegerOrInfinity 到返回元素toIntegerOrInfinity 是整套 polyfill 的基石。Number(value)先把任意输入转成数字n ! n是判断 NaN 最可靠的 ES3 写法因为 NaN 是唯一不等于自身的值接着对 0 和正负 Infinity 原样返回这两个值不需要截断剩下的常规数字走Math.abs取绝对值、Math.floor向下取整、再按符号恢复。负小数在这里会变成负零比如 -0.5 变成 -0后续比较里 -0 0 为 false所以arr.at(-0.5)等价于arr.at(0)返回第一个元素和规范完全一致。toLength 参考了 LengthOfArrayLike 的语义。ES3 没有 Number.MAX_SAFE_INTEGER 常量就用Math.pow(2, 53) - 1代替。负数归零、超过安全整数上限的被钳制到上限这样 length 的值域被约束在 [0, 2^53-1]。为什么不用常见的o.length 0因为 会把 length 强制成 32 位无符号整数超过 2^32-1 的类数组对象会被截断虽然真实数组长度受限于 2^32-1但类数组对象不一定为了对齐规范语义这里必须用 toLength 而不是位运算。at 函数体内第一行this null在 ES5 严格模式下能正确拦截 null/undefined 的调用但在纯 ES3 环境里非严格模式下 this 会被自动装箱成全局对象这个检查实际拦不住这是引擎限制不是代码缺陷。Object(this)对原始值做包装数字变成 Number 对象、字符串变成 String 对象包装后 length 属性可以正常读取。拿到 relativeIndex 后非负直接用负数用 len 加回去再做一次边界判断最后返回o[k]。这里有个关键区别不能用Math.max(len relativeIndex, 0)把负数钳到 0因为规范要求 k 小于 0 直接返回 undefined钳制后arr.at(-4)在长度为 3 的数组上会错误地返回第一个元素。3.3 封装与压缩保持 ES3 语法的同时控制体积代码里的全局对象选择用了typeof self优先、window其次、global垫底、最后才是this的方式。typeof对未声明变量安全所以这种多重判断可以在 ES3 引擎里放心写。如果你用的是 CommonJS 或 AMD 环境可以把最后那个立即执行函数改成module.exports或define的导出形式但 ES3 环境本身没有模块机制纯浏览器场景下直接作为脚本加载最稳妥。这段代码压缩后大致在 1KB 以内gzip 后几百字节量级对老环境完全可接受。压缩时建议把压缩器的 ecma 版本设置成 5 或 3避免压缩器把use strict当死代码移除。某些压缩器在 ecma: 2020 配置下会把函数内字符串指令优化掉严格模式一旦丢失at 函数在 ES5 引擎里对 null/undefined 的拦截就会失效等于是静默改变语义。我一般把 polyfill 单独放进一个文件不跟业务代码混在一起压缩这样即使业务代码升级压缩配置也不会波及 polyfill。4. 挂载与特性探测直接赋值之前先确认几件事4.1 特性探测typeof 与 in 的取舍挂载前必须判断环境是否真的缺 at。最稳妥的写法是typeof AP.at ! function因为它对未声明变量安全也不会被原型链上的同名属性干扰。有人习惯写at in Array.prototype这个写法在 ES3 下也能工作但它检查的是属性是否存在不检查类型如果某个 shim 已经把 at 定义成了非函数对象in判断会误判为已支持业务代码调用时照样挂。另一种情况是环境里已经有一个错误的 at 实现。默认策略应该是信任原生实现不主动覆盖因为你无法预判页面上其他库是否对这个方法有依赖。这份资源定位是replacement意味着在确认现有实现有问题时可以提供强制覆盖开关把typeof AP.at ! function改成!options.force typeof AP.at ! function。但强制覆盖前必须跑一遍第 2 章的六条测试用例确认旧实现真的不符合规范再决定要不要出手。探测顺序上先判断 at 是否存在再判断 defineProperty 是否存在最后才是挂载。这个顺序不能反如果先判断 defineProperty老环境里 defineProperty 缺失时还会额外走一次 typeof 判断纯属浪费更重要的是先探测 at 缺失可以避免在已经支持 at 的现代浏览器里做无意义的 defineProperty 操作。4.2 ES5 环境Object.defineProperty 挂载的正确姿势在 IE9 和所有现代浏览器里Object.defineProperty是可靠可用的。挂载时四个属性值各有用处value传入 at 函数本体writable: true允许后续再覆盖给业务代码留一条后路configurable: true允许后续删除或重配置enumerable: false是关键保证 for...in 和 Object.keys 不会把 at 列出来和原生方法继承属性不可枚举对齐。挂载在 Array.prototype 上而不是实例上是因为数组的每个实例都会原型继承这个方法内存里只有一份函数引用。这里有个不引人注意的小细节function at(index)的 name 属性恰好是 at和原生方法一致。如果你把函数命名为 arrayPrototypeAt 之类ES5 环境下at.name返回的就是 arrayPrototypeAt调试栈里看函数名会奇怪某些测试框架还会校验函数名所以命名时直接叫 at 是最省事的。defineProperty 调用必须放在 try/catch 里。原因不是现代浏览器会失败而是 IE8 这个特例它声明了 Object.defineProperty但对非 DOM 对象调用会抛异常。try/catch 捕获后走直接赋值才能保证 IE8 不白屏。这段回退代码看起来冗余实际上是整个兼容链路里最值钱的一行。4.3 纯 ES3 环境直接赋值的三个副作用与对策当 Object.defineProperty 完全不存在时只能AP.at at直接赋值。这个操作的第一个副作用是方法变成可枚举的数组实例的 for...in 会输出 at 这个键。对策是业务代码里遍历数组用标准 for 循环不要用 for...in实在要遍历原型方法至少加一层Object.prototype.hasOwnProperty.call(arr, key)过滤。第二个副作用是覆盖冲突。多个库都做同一件事时后加载的会覆盖先加载的。对策是保持typeof AP.at ! function守卫并且只在应用入口加载一次 polyfill不要在业务模块里重复引入。第三个副作用是性能老式 JScript 引擎对扩展原生原型的容忍度差扩展后数组的枚举操作可能变慢尤其 IE6/7 上更明显所以纯 ES3 环境下能不动 Array.prototype 就不要乱动动了就在关键路径上改用索引循环。这个副作用列表也解释了为什么很多组件库宁可拷贝一份内部工具函数也不直接挂在原型上。但 at 是标准 API业务代码到处都在用不挂原型就得每个模块都 import 工具函数反而更别扭。挂上去之后把不要重复定义、不要用 for...in 遍历数组写进项目规范比在代码里打补丁更有效。5. 避坑与常见问题现象、原因与解决三板斧5.1 at.call(null) 的行为不一致现象在 ES3 老环境里调用Array.prototype.at.call(null, -1)不报错返回 undefined同一个调用在现代浏览器的原生 at 下直接抛 TypeError。原因ES3 没有严格模式函数体里 this 是 null 时会被引擎自动替换成全局对象Object(this)拿到的是全局对象的包装length 取到全局对象的 length多数情况下为 0 或 undefined于是返回 undefined。这不是 polyfill 偷懒而是 ES3 引擎的根本限制。解决polyfill 内部已经写了this null检查在 ES5 严格模式下能正常工作。纯 ES3 环境下想真正拦截只能在调用方约定所有 at 调用必须基于数组或类数组对象发出不要传 null或者给这个 polyfill 包一层额外判断但这层判断在 ES3 非严格模式下同样拿不到原始 this。5.2 for...in 循环多出 at 键现象在 ES3 环境或 defineProperty 缺失回退到直接赋值后for (var k in arr) { console.log(k); }输出里多了一个 at。原因直接赋值添加的原型属性默认 [[Enumerable]] 为 truefor...in 遍历数组时会把继承来的可枚举属性也列出来。原生 at 和现代 defineProperty 定义的 at 都是不可枚举的所以只有纯 ES3 挂载方式有这个毛病。解决ES5 环境用 defineProperty不需要处理纯 ES3 环境只能改遍历习惯数组一律用for (var i 0; i arr.length; i)或者用已经补上的 forEach 之类方法。不要试图通过删除原型上的 at 来规避因为 many 业务代码还在调用它删了问题更大。5.3 类数组对象调用 at 拿不到预期值现象Array.prototype.at.call({ length: 2, first: a, second: b }, 0)返回 undefined调用方以为自己会拿到 a。原因at 按整数索引访问对象属性不是按属性名遍历。这个对象没有数字键 0 和 1所以 at(0) 和 at(1) 都返回 undefined这是规范行为。解决如果想把类数组对象按索引取元素先确认对象属性名是数字键如果是具名属性用Object.keys(obj)[0]或者先把对象转成真数组。at 的设计目标是数组和类数组不是字典对象别拿它当 Object.values 用。5.4 压缩后代码在 ES3 环境直接 SyntaxError现象polyfill 和业务代码一起打包压缩后放到 IE7 里加载控制台报语法错误定位到的代码片段里出现箭头函数或模板字符串。原因压缩器配置了较高的 ECMAScript 版本把业务代码里的新语法原样输出或者 polyfill 本身在压缩时混入了业务代码的高语法片段。ES3 引擎只认识 var、function 和传统语法箭头函数、const 一概不认识。解决polyfill 单独成文件、单独压缩压缩器配置 ecma 版本为 3 或 5业务代码再用 babel 或 swc 降级。每次修改完 polyfill跑一遍第 6 章的 ES3 语法解析校验保证语法级别不越界。5.5 IE8 的 defineProperty 半桶水导致挂载中断现象在 IE8 下加载 polyfill脚本在Object.defineProperty(AP, at, ...)这一行抛 Object doesnt support this property or method。原因IE8 引入了一个不完整的 Object.defineProperty 实现它只对 DOM 元素生效对 Array.prototype 这类普通对象调用会直接抛错而且错误发生前不会有任何预兆。解决源码里已经用typeof Object.defineProperty function加上 try/catch 双层保护捕获异常后回退到AP.at at。如果你看到的是别处写的裸 defineProperty把它包进 try/catch 里按这里的回退逻辑处理即可。6. 把边界露出来三个验证技巧6.1 用 esprima 校验 ES3 语法老环境兼容最怕的不是逻辑错而是语法越界。把 polyfill 源码放进 esprima指定解析版本为 ES3任何 let、const、箭头函数、模板字符串都会直接报错。var esprima require(esprima); var source fs.readFileSync(at-polyfill.js, utf-8); esprima.parseScript(source, { ecmaVersion: 3 });如果这段代码能正常解析说明语法层面已经完全降级到 ES3可以放心扔进老引擎。这个技巧对整套项目也适用把入口文件做一次 ES3 parse能提前抓出漏网的箭头函数。6.2 边界输入批量验证逻辑正确性用一张边界用例表来兜底。把第 2 章那张表的输入扩展开写成一个断言脚本任何一次改动都能快速回归。var arr [10, 20, 30]; var cases [ [-1, 30], [-2, 20], [-4, undefined], [NaN, 10], [1.9, 20], [Infinity, undefined], [1, 20] ]; cases.forEach(function (c) { var got arr.at(c[0]); console.assert(got c[1], c[0] - got 期望 c[1]); });这里用got undefined判断越界返回值因为 at 的越界返回值确实是 undefined不是 null。跑完没有任何 assert 输出再接业务代码有输出先看是哪条用例再回头检查 toIntegerOrInfinity 或负索引分支。6.3 在最终产物里做一次自检压缩打包后原生实现、polyfill、业务代码混在同一个 bundle 里谁先谁后很难用肉眼确认。我在入口文件留过一行自检现在基本每回接老项目都会习惯性加上if (typeof Array.prototype.at function [1, 2, 3].at(-1) ! 3) { console.warn(Array.prototype.at polyfill 未生效或未按序加载); }这行代码不打日志、不影响线上行为但能让接手的同事第一时间意识到挂载顺序出了问题。从那以后我每做一次老环境兼容都会把 polyfill 单独放进一个文件夹顺手跑一遍 esprima 的 ES3 解析校验再进打包链路——走过的弯路够多了不值得再在 ES3 上赌自己记得住语法。希望帮到你。本文还有配套的精品资源点击获取