ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Array.prototype.at polyfill:从ES3到ES2022的兼容方案

Array.prototype.at polyfill:从ES3到ES2022的兼容方案 简介这是一份面向JavaScript开发者的标准库兼容补丁资源针对较新ES规范中的Array.prototype.at方法提供符合提案的polyfill与shim实现。它能在ES3这样的老旧运行环境中安全使用适合需要保持兼容性的工具库作者或维护存量项目的工程师。资源包含30个文件以js和mjs源码为主配合yml工作流配置、md说明文档以及license、测试文件等整体仅17KB核心实现、模块入口、测试用例与CI配置分层清晰可快速集成到npm项目中。目前已有567人学习下载。通过阅读源码可掌握原生方法提案的具体行为理解polyfill在实现索引解析、绑定this并回退到原生方法时的边界处理思路同时提供了ESM与CommonJS两种模块格式便于在各类构建工具中选用也为自行编写其他数组方法兼容层提供了可参照的工程结构。 Array.prototype.at 这个 API 是那种“平时用着爽、一上线就缺”的类型。它最核心的价值是用arr.at(-1)取代写了很多年的arr[arr.length - 1]让代码的意图更清楚也更不容易多一个 off-by-one bug。但到了不支持 ES2022 的老 WebView、某些嵌入式浏览器内核、或者一个被锁定版本的老 Node 环境里Array.prototype.at可能完全不存在。所以我需要补一份能跑在 ES3 环境里的 polyfill/shim——不是简单返回arr[index length]的 hack而是要尽量贴近 ECMAScript 规范描述的行为。这篇文章会从 API 痛点、规范算法、完整实现、测试避坑一条线讲下来也会顺带解释最近搜索时常带出来的bad shim signature/shim sbat到底是什么——它们和 JavaScript 的 shim 同名但完全是两个世界。适合给旧项目做兼容层、写 SDK 或者维护前端基础设施的同行参考。1. 为什么需要给 Array.prototype.at 写 polyfill1.1 从 arr[arr.length - 1] 到 arr.at(-1)在 ES2022 之前想取数组最后一个元素只有两条笨路arr[arr.length - 1]或者arr.slice(-1)[0]。前者的问题是每次都要把 length 拿出来做一次减法后者虽然语义上支持负索引但会临时创建新数组在热路径上会有额外性能开销。.at()把负索引正式引入数组 API它的计算规则很简单index如果是负数就从数组末尾往前数arr.at(-2)等于倒数第二个元素。但是这个 API 的普及速度没有想象中快。V8 到 Node 16.6 以后才开放Array.prototype.atiOS WKWebView、旧版 Android System WebView、部分企业浏览器控件的支持时间更晚。很多还在维护中的项目运行环境并不比 2015 年先进多少。于是给.at做兼容层是绕不开的需求。1.2 规范里的关键行为索引到底是怎么算的如果不把规范算法展开很容易写出一个“看起来能用”的错误实现。规范对.at(index)的行为可以抽象成三步先把index转成整数大于等于零就用它本身小于零就加上数组长度然后判断算出来的下标在不在[0, length)范围内不在就返回undefined在就直接返回对应下标元素。举个实例[a,b,c].at(-1)len是 3index是 -1相对位置是3 (-1) 2返回c。[a,b,c].at(-4)len (-4) -1小于 0返回undefined。这跟arr[-1]那种直接访问不存在的属性完全不是一回事。这里容易忽略的是参数和length的类型转换。规范用了ToIntegerOrInfinity和ToLength两个抽象操作。index如果是undefined、字符串、小数都要先转成“整数值”array.length如果是个字符串或者小数也要做同样转换。所以一个符合规范的 polyfill必须把类型转换也做对而不能依赖this.length原样参与运算。1.3 什么时候会遇到“原生不存在”常见触发场景主要有三类老版本 WebView 或混合应用容器被锁版本的企业级浏览器为了体积把 ES 运行时裁剪得很小的嵌入式 JS 引擎。在这些环境里直接调用.at会抛TypeError: arr.at is not a function而且错误在压缩后很难定位。防御方式是在应用/库的入口最早阶段做 feature detectiontypeof Array.prototype.at ! function。检测通过就不动检测失败才补实现。这也是 polyfill 的标准姿势。2. shim 还是 polyfill先把概念剥清楚2.1 两者定义差异这个词在博客圈经常被混用。简单说polyfill 是“缺什么补什么”针对环境没有的 API按照标准实现一份并放到对应位置shim 则更偏向“已经有但行为不对或被刻意替换”你通过注入一层代理或修补函数让整体行为符合预期。区别在于是不是动已存在 API 的奶酪。我经常用一张小表格帮别人区分术语触发条件常见动作polyfill目标 API 不存在往Array.prototype上新增方法shim目标 API 存在但不够/不正确替换方法、包装方法、拦截调用参数混合方案不存在就补存在且正确就不碰先检测后决定Array.prototype.at的兼容层通常属于混合方案先检测如果不存在新增如果存在不替换。这样的好处是尽量不破坏原生实现也避免在已经支持.at的环境里引入多余的枚举属性和行为变化。2.2 我们实现的到底属于哪一种严格说我们写的这段代码是一个“polyfill 式 shim”。因为它在旧环境里把缺失的 API 补到原型上而在新环境里直接退出。它不改变原生.at的任何行为也不算劫持。但如果你打包时遇到一些还在讨论“shim 签名”的文章它们多半是在说另一类问题比如函数的参数个数、返回类型、是否支持被call到类数组对象上。这提醒我们无论是 polyfill 还是 shim最终都要以“签名行为符合规范”作为验收标准。3. 一个能跑到 ES3 的符合规范的实现3.1 兼容性核心Object.defineProperty 存在才用要兼容到 ES3第一个绕不开的问题是Object.defineProperty。ES5 以后我们可以用Object.defineProperty(Array.prototype, at, descriptor)把新方法定义为不可枚举、不可配置这样for...in不会扫到它第三方库的依赖检查也更干净。但 ES3 环境没有Object.defineProperty也没有Object.defineProperties。这时候唯一的办法就是直接赋值Array.prototype.at at。代价是方法变成可枚举属性而且可以被普通赋值覆盖。我们不能要求老掉牙的引擎跑出现代属性语义只能妥协。为了让现代环境尽量干净代码会先判断Object.defineProperty是否存在存在就 try 一次失败再走直接赋值。为什么用 try因为部分老版 Safari 或 WebKit 对数组原型上的defineProperty支持得很别扭直接调用可能抛异常。3.2 完整代码实现下面是我在实际项目里用的版本函数式写法不依赖 ES6 语法理论上可以直接塞进 ES3 script 标签。(function () { use strict; if (typeof Array.prototype.at function) { return; } var MAX_SAFE_INTEGER 9007199254740991; function toIntegerOrInfinity(value) { var number Number(value); if (number ! number) { return 0; } if (number 0 || number Infinity || number -Infinity) { return number; } return (number 0 ? -1 : 1) * Math.floor(Math.abs(number)); } function toLength(value) { var len toIntegerOrInfinity(value); if (len 0) { return 0; } if (len Infinity) { return MAX_SAFE_INTEGER; } return Math.min(len, MAX_SAFE_INTEGER); } function at(index) { var O Object(this); var len toLength(O.length); var relative toIntegerOrInfinity(index); var k relative 0 ? relative : len relative; if (k 0 || k len) { return undefined; } return O[k]; } var descriptor { value: at, writable: true, configurable: true, enumerable: false }; if (Object.defineProperty) { try { Object.defineProperty(Array.prototype, at, descriptor); return; } catch (error) { // 某些老引擎无法通过 defineProperty 修改数组原型忽略后走赋值 } } Array.prototype.at at; })();3.3 代码细节逐段拆解先看toIntegerOrInfinity。规范里的ToIntegerOrInfinity要求NaN和0都转成0Infinity和-Infinity保持原样其他数字采用向零截断的整数。ES3 没有Math.trunc所以我用(number 0 ? -1 : 1) * Math.floor(Math.abs(number))来模拟向零截断。number ! number比isNaN(number)更稳因为老的实现里isNaN可能会被全局覆盖而且对 BigInt 之类的类型会炸但这里我们不需要考虑 BigInt用Number(value)处理完已经是 number 类型。toLength则对应规范里的ToLength先做整数化再 clamp 到0和MAX_SAFE_INTEGER之间。这里有个冷知识arr.length其实不一定是个普通的非负整数它可以是字符串、小数甚至是Infinity虽然实际数组不会但类数组对象可以。所以 polyfill 需要老老实实走转换不能写var len O.length 0一了百了因为无符号位移对小数和 Infinity 的行为跟规范不一致。最后是at本体。Object(this)的作用是把原始类型的this包装成对象规范里Array.prototype.at是通用的可以call到字符串、类数组对象上。比如Array.prototype.at.call(hello, -1)应该返回o如果直接用原始值this在非严格模式下会被自动装箱其实也能用但显式Object(this)更安全也能和规范算法保持一致。算完k之后先检查范围再取元素不满足条件就返回undefined。3.4 为什么这样写才是“符合规范”很多人写 polyfill 容易只盯着负索引这一个点把at实现成Array.prototype.at function (index) { return index 0 ? this[index] : this[this.length index]; };这个版本对常见数组确实没问题但没有做参数和length的规范转换遇到字符串索引、小数、Infinity、类数组对象时行为不对。比如[1,2,3].at(1)在规范里应该返回2而那个简化版本因为1 0成立this[1]也是2误打误撞是对的。但[1,2,3].at(-0.5)规范结果是1简化版本则访问this[2.5]得到undefined。这种差异在复杂业务里非常隐蔽。所以“符合规范”意味着我们要把 ECMAScript 的抽象操作翻译成 ES3 能跑的代码而不是只做到“看起来能用”。我在这份实现里尽量让每个函数对应规范里的一个抽象操作也方便后续审计和维护。4. 实际使用、测试与避坑4.1 在老环境怎么接入接入方式取决于构建体系。如果你还在用纯script标签把这段代码放在任何业务脚本之前确保它第一时间执行。如果走 npm/bundler可以把它做成一个at-polyfill.js并作为第一个 import 副作用模块引入例如在入口的import ./polyfills/at。不要重复打补丁。如果项目里同时用了core-js它会通过Object.defineProperty安装 polyfill你再执行这段代码因为有 feature detection会安全退出。但要注意执行顺序假如某个第三方库在自己作用域里已经缓存了Array.prototype.at的引用之后 polyfill 才生效那缓存引用的地方仍然拿不到新方法。所以 polyfill 必须尽早加载。4.2 边界用例测试接入后我建议至少在目标环境跑一遍下面这些用例覆盖参数转换、负索引、类对象和 this 绑定。调用预期结果原因[1,2,3].at(-1)3负数从末尾取[1,2,3].at(1)2正数直接取[].at(0)undefined越界返回 undefined[1,2,3].at(-4)undefined负数倒推后越界[1,2,3].at(Infinity)undefined巨大索引越界Array.prototype.at.call(hello, 1)e通用方法字符串被包装Array.prototype.at.call({0:a, length:1}, 0)a支持类数组对象Array.prototype.at.call(null, 0)抛 TypeError不能把 null 装箱4.3 需要注意的坑第一个坑不要用if (!Array.prototype.at)做检测。因为如果某个环境里Array.prototype.at被赋成了undefined这个条件成立但typeof更安全它不会因为变量被赋值为 falsy 值而误判。第二个坑老 WebKit 里Object.defineProperty(Array.prototype, at, descriptor)可能会成功定义但访问时却发现没有甚至抛异常。所以 try/catch 是必须的。第三个坑如果业务代码把整个数组原型 freeze 了Array.prototype.at at在严格模式下会抛错。为了不拖垮整个页面也可以把赋值放在 try/catch 里但这属于极端场景普通项目可以不处理。5. 热搜里的“shim”bad shim signature 和 SBAT 是怎么回事5.1 引导过程中的 shim写完 JavaScript 兼容层肯定会有同行因为搜索shim被带进一个完全不同的世界UEFI 环境下安全启动链里也有一个叫shim的组件。它是一段很薄的引导程序跑在固件和真正的系统引导加载器之间负责校验后续加载的引导加载器是否被允许启动。这个shim和 JavaScript 里的 polyfill/shim 只是同名没有任何代码上的共同点。5.2 三条报错分别代表什么如果你看到的报错是bad shim signature意思是当前环境想要加载的shim程序签名校验没通过通常是下载/打包的 shim 文件不完整或被篡改也可能固件里的信任链没认它。failed to start shim: unsupported shim version则是 shim 的版本号不满足当前固件的策略要求常见于系统升级后旧的 shim 被新策略淘汰。verifiying shim sbat是什么情况里提到的 sbat 是 Secure Boot Advanced Targeting一套带版本信息的元数据机制用来在安全启动过程中按版本决定某个 shim 是否被信任、是否需要被撤销。这套机制本身是个纯技术设计但对很多习惯图形界面的人来说突然看到“verifying shim sbat”会以为是电脑坏了。5.3 和 JS polyfill 的关系如果只是在做前端遇到这些关键词可以不用管。它们是引导和系统安全域的错误不是 JavaScript 错误。不过倒可以利用这个“同名梗”来反查自己写的兼容层在 JS 里面“bad shim signature”可以翻译成“polyfill 函数签名不符合规范”。比如at的参数类型转换没做对、返回值在越界时没返回undefined、不能正确call到类数组上这些都是 shim 签名问题。如果你在调试自己的Array.prototype.atpolyfill 时遇到异常先别怀疑什么“unsupported version”先把边界用例跑一遍。我自己踩过最深的一个坑是在一个被冻结的旧 WebView 里没加typeof检测直接把函数赋给Array.prototype.at结果某个依赖遍历了原型方法导致线上出现一堆奇怪字符串。后来我把 polyfill 改成Object.defineProperty优先并保证不可枚举再在那个 webview 上验证才消停。希望大家接入时也注意这一点能定义成不可枚举就不要用普通赋值这是老环境最容易被忽略的差异。另外如果你维护的是 npm 包建议不要把 polyfill 打进自己的模块里而是做成独立入口文件让使用者自行引入或者干脆写在 peerDependencies 的说明里。一个包改了全局原型很容易和其他依赖打架这是兼容层最容易引发的连锁反应。真要做就在项目入口集中管理别散落在各个模块里。本文还有配套的精品资源点击获取
返回列表