ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ES3 兼容的 Array.prototype.at polyfill:负索引与规范语义的实现

ES3 兼容的 Array.prototype.at polyfill:负索引与规范语义的实现

简介:面向需要在 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. 老环境用上新 API:Array.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,完整流程是这样:

  1. ToObject(this),把 this 强制变成对象。
  2. LengthOfArrayLike 取出对象的 length。
  3. ToIntegerOrInfinity(index),把参数规整成整数。
  4. index 为非负数时 k=index,为负数时 k=len+index。
  5. 如果 k 小于 0 或者 k 大于等于 len,返回 undefined。
  6. 否则返回 O[k]。

这几个步骤里,最容易在 polyfill 上翻车的是第 3 和第 5 步。第 3 步要求把 NaN 转成 0、把小数截断、把 Infinity 保留;第 5 步则明确了负索引越过头部时返回 undefined,而不是像某些错误实现那样把 k 钳到 0。下面这张表可以直接当测试用例用,arr 固定为 [1, 2, 3]。

调用表达式返回值内部 k 的推导
arr.at(-1)33 + (-1) = 2
arr.at(-3)13 + (-3) = 0
arr.at(-4)undefined3 + (-4) = -1,小于 0
arr.at(NaN)1ToIntegerOrInfinity(NaN) 得 0
arr.at(1.9)2ToIntegerOrInfinity(1.9) 得 1
arr.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. 手写一版真正可用的 polyfill:ES3 语法下还原规范语义

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]] 为 true,for...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 doesn't 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 上赌自己记得住语法。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进