ES2016(ES7)小而美:includes和指数运算符深度指南
2026/9/15 10:31:15 网站建设 项目流程

ES7(ES2016)可能是我见过最容易被“误会”的一个JavaScript版本。你说它没存在感吧,面试题里三天两头出现Array.prototype.includes;你说它重要吧,整份规范拢共就俩新特性,和后面ES2017一口气塞进async/awaitObject.values()String.padStart()的大礼包完全不是一个量级。但恰恰是这种“小而精”的更新,特别适合拿来理解JavaScript语言往后的演进节奏。这篇文章我就以实际开发者的视角,把ES2016这两个特性拆开揉碎,顺带把配套的工程化落地方案也一并讲清楚。无论你是刚学完ES6准备往新版本看的初学者,还是在项目里要做语法升级的老手,都能从这份总结里拿到可以直接用的东西。

1. 项目概述与整体设计思路

1.1 一次“小而美”的规范更新,到底解决了什么问题

ES2016(也就是大家习惯叫的ES7)在2016年6月正式定稿,它是JavaScript改用“年份命名法”的第一个版本。有意思的是,整份规范最终只收录了两个新特性:Array.prototype.includes和指数运算符**。第一次看到这个清单的人多半会愣一下,觉得这也太“寒酸”了,但如果你回头看TC39的工作流程,就会明白这不是偷懒,而是刻意为之——从ES2015这样憋了六年大招的巨型版本,过渡到“每年都能安全落地新功能”的稳定节奏,必须有人先走一步,用一次小体量发布来验证这套流程跑不跑得通。

这两个特性其实都算不上“发明创造”,更像是把社区里反复出现的需求给正式扶正了。includes解决的问题非常具体:判断一个数组里有没有某个值,这个操作太常见了,但老办法indexOf用起来总有点别扭,一个是语义不直观,另一个是它压根判断不了NaN。而**运算符则是把Math.pow()这个函数调用直接提升成了语言层面的语法糖。两者看起来都不起眼,但恰恰因为它们足够克制,才能以零争议的姿态快速走完提案流程,在当年6月就发布定稿。

1.2 为什么ES2017、ES2018反而比它“火”,ES2016真的过时了吗

很多人容易产生一个错觉,觉得ES2016出了等于没出,反正后面版本特性更多更亮眼。但实际写代码的时候你会发现,includes**的使用频率一点也不低,尤其是includes,它在处理权限判断、状态过滤、搜索匹配这些场景里出镜率极高。我自己的项目里,ES2016的这两个特性几乎每天都在用,反而某些ES2017、ES2018的大特性,可能一整周都碰不到一次。

另外还有一个容易混淆的点:Object.values()Object.entries()async/await这些看起来“好像也是ES7”的东西,实际上全部属于ES2017(ES8)。包括很多老文章里误把String.prototype.padStart归到ES7里,也是同样的错误。所以读这篇文章之前,先把认知校准一下:ES2016 =includes+**,就这两样,后面那些是隔壁ES2017的事。

2. 核心特性解析:Array.prototype.includes

2.1 从API设计看includes的设计哲学,远不止“语法糖”这么简单

Array.prototype.includes的完整签名是:

arr.includes(valueToFind, fromIndex)

第一个参数是要查找的值,第二个参数是起始搜索索引。它返回一个布尔值,表示数组中是否存在该元素。这个API看起来就是indexOf的一个“布尔版”,但仔细品一下,它其实是把“找位置”和“判断存在性”这两件事彻底分开了。

indexOf干了太多活:找不到要返回-1,找到了要返回下标,然后你还得在调用方把-1这种反人类的返回值再翻译成“不存在”。而includes干脆不关心位置,直接给出“是或否”的答案。从代码可读性角度来说,if (arr.includes(x))if (arr.indexOf(x) !== -1)直观太多了,读代码的人不需要再心算一遍“-1是什么来着”。

在设计哲学上,includes有两个值得单独拎出来说的点。第一,它对NaN的处理是正确的,数组里有NaN时用includes能查到,但indexOf永远返回-1,这在处理一些包含脏数据的场景时非常致命。第二,includes对稀疏数组的空位是按undefined处理的,这一点和indexOf一致,不会因为数组里有空洞就直接跳过导致漏判。

2.2 从零手写一个includes,顺便看看fromIndex的各种边界情况

既然说它是语法糖,那我自己动手实现一个同功能的函数,能更直观地看清它的边界行为。一个相对完整的polyfill版本是这样的:

function includes(arr, valueToFind, fromIndex) { if (arr == null) { throw new TypeError('"this" is null or not defined') } const o = Object(arr) const len = o.length >>> 0 if (len === 0) { return false } const n = Number(fromIndex) || 0 const k = Math.max(n >= 0 ? n : len + n, 0) while (k < len) { if (o[k] === valueToFind || (valueToFind !== valueToFind && o[k] !== o[k])) { return true } k++ } return false }

注意看几个核心逻辑:fromIndex如果是负数,会从数组末尾开始倒数定位,比如arr.includes('a', -2)会从倒数第二个元素往右查;如果fromIndex的绝对值大于数组长度,会被Math.max(..., 0)强行拉回0;如果fromIndex本身就大于等于数组长度,循环体压根不会执行,直接返回false

至于valueToFind !== valueToFind这个看起来诡异的条件,就是在用“自己不等于自己”来识别NaN,这是indexOf时代做NaN判断的经典手法。看完这段代码你就明白,includes之所以比indexOf好用,不是凭空变出来的魔法,而是在规范层面上就把这些坑都堵死了。

2.3 includes的“看不见”的细节:对象比较与引用陷阱

有一点需要特别注意:includes做的是“同值零算法”比较,基本类型比较值,但引用类型只比较引用地址。这意味着如果你有一个对象数组,用includes去查一个“长得一模一样但不同引用”的对象,结果永远是false

const users = [{ name: 'Alice' }, { name: 'Bob' }] users.includes({ name: 'Alice' }) // false

这个坑我在项目里踩过一次。当时是从接口拉了一组选中的对象ID,本想把它们和全量列表里的对象做匹配,直接写了list.includes(selectedObj),结果怎么匹配都是空。后来排查了半天才意识到,接口返回的selectedObj是重新解析出来的新对象,和列表里那个对象在内存中根本不是同一个引用。这种情况下正确的做法是改用some()

list.some(item => item.id === selectedObj.id) // true

所以includes适合判断的是原始类型数组,或者是明确复用同一个引用的场景。如果你的比对逻辑是“按内容相等”,那还是老老实实用some加比较函数,别指望includes帮你做深比较。

3. 核心特性解析:指数运算符与赋值扩展

3.1 指数运算符的语法细节和运算优先级,这可能才是真正的雷区

2 ** 10等于1024,这个写法取代了Math.pow(2, 10),简洁程度肉眼可见。但如果你以为它只是Math.pow的语法替换,那接下来这个例子可能会让你愣一下:

2 ** 3 ** 2 // 512,而不是64

为什么不是64?因为指数运算符是右结合的,表达式会先计算右边的3 ** 2得到9,再计算2 ** 9得到512。这一点和数学上的习惯一致,但写代码的时候很容易凭直觉认为它应该从左往右算。类似的优先级问题还有很多,官方文档里明确写了指数运算符的优先级要高于乘法,但低于一元运算符:

2 * 3 ** 2 // 18,先算 3 ** 2 得9,再乘2 -2 ** 2 // 报错!SyntaxError (-2) ** 2 // 4,必须加括号

-2 ** 2报错这事尤其需要警惕。因为一元负号的优先级比指数运算符高,规范里不允许直接把-**这样结合,强制要求你写括号。我记得有个同事在算法题里写坐标映射的时候踩过这个坑,编译直接红一片,排查了好一会儿才反应过来是优先级的问题。另外**运算符也支持对应的赋值写法**=,比如循环里做逐步衰减:

let scale = 2 scale **= 3 // scale = 8

3.2 从Math.pow到**,实际项目中到底省了什么

光说语法简洁可能还不够直观,给你看一个实际的例子。假设你在做一个复利计算器,年利率是5%,本金10000元,存10年,原来的写法是:

const total = 10000 * Math.pow(1 + 0.05, 10)

换成指数运算符之后:

const total = 10000 * (1 + 0.05) ** 10

后者读起来几乎和数学表达式一模一样。再比如写判断两个坐标点距离的代码时:

const distance = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)

这种写法在可读性上的提升是实打实的,写的人不用再纠结Math.pow那串冗长的函数调用,看的人一眼就能抓住公式结构。

还有一点容易被忽略,**的性能并不比Math.pow差。现代JavaScript引擎对**做了专门的优化,在某些场景下甚至比Math.pow调用更快。当然这个差距微乎其微,除非你在做大量科学计算,否则不用把性能当作选型依据。真正值得关心的还是代码可读性和表达力,在这个维度上**毫无疑问是更优的选择。

3.3 指数运算符的隐性坑位:精度问题与非常规底数

这里必须提一嘴浮点精度问题。很多人以为**是整数运算,实际上它完全遵循IEEE 754浮点数规则。你在控制台敲一下0.1 ** 2,得到的结果是0.010000000000000002,和0.01不相等。这不是**的bug,而是所有浮点数运算的通病。

另外要注意负数底数和分数指数的组合,比如(-8) ** (1/3),理论上等于-2,但在JavaScript里会得到NaN。原因是JS的指数运算本质上是通过对数函数实现的,而负数取对数在实数范围内没有定义。如果你需要计算这类分数次幂,建议还是绕回Math.pow或者自己实现特殊逻辑。这些边界情况文档里不写,但实际写代码总会撞上,提前记住能省不少排查时间。

4. 实操过程与典型场景落地

4.1 权限角色判断:includes让代码从“绕弯子”回归“说人话”

权限判断是includes最典型的应用场景。假设登录用户的角色存在一个数组里,现在要判断它是否有管理员权限,老写法是:

const role = 'editor' const roles = ['admin', 'editor', 'visitor'] if (roles.indexOf(role) !== -1) { // 有权限 }

换成includes之后:

const role = 'editor' const roles = ['admin', 'editor', 'visitor'] if (roles.includes(role)) { // 有权限 }

这两段代码逻辑一模一样,但第二段的意图表述明显更接近自然语言。如果一个项目里有几十处权限判断,全部改用includes之后,整体代码的可维护性会提升一个档次。同理,黑名单过滤、搜索关键词匹配、表单选项校验,只要是“数组中是否包含某个值”的判断,都可以无脑用includes

不过这里有个性能细节值得说一下。如果数组规模很小(比如角色就三五个),includesSet的性能差异是可以忽略不计的。但如果你要在一个几千上万条记录的数组里做高频查重,老老实实先转成Set再用has()Set.prototype.has的时间复杂度是O(1),而includes本质是线性遍历O(n)。在我自己做的数据清洗工具里,用Set重构后,处理两万元素去重从40毫秒降到了不到5毫秒,这个优化效果是很直观的。

4.2 表驱动与状态机:指数运算符在业务代码里的巧用

**写业务场景似乎没includes那么频繁,但有一个我非常喜欢的应用方式是表驱动计算。比如做游戏数值系统,人物的伤害减免公式是“基础伤害的n次方衰减”,你可以这样写:

const baseDamage = 1000 const debuffLevel = 3 const finalDamage = baseDamage * 0.85 ** debuffLevel

再比如设计一个“卡片升级费用”系统,费用随着等级的提升以指数增长:

const upgradeCost = level => Math.floor(100 * 1.2 ** level)

这类公式用**表达最为清晰,而且改参数非常方便,想调整成长曲线只需要改底数和系数。如果你在写图表坐标轴刻度、声音频率计算、颜色亮度映射这类偏“数学”的逻辑,**也比一串串Math.pow嵌套看起来清爽得多。

4.3 工程化落地实践:Babel插件、TypeScript配置与Node环境

虽然ES2016已经很老了,现在的主流浏览器和Node.js版本基本都原生支持这两个特性,但如果你还要兼容IE或老版本安卓WebView,那该做的编译和polyfill工作一样都少不了。拿Babel来说,最省事的方案是使用@babel/preset-env配合useBuiltIns

npm install --save-dev @babel/preset-env npm install --save core-js

然后在.babelrc里这样配置:

{ "presets": [ [ "@babel/preset-env", { "useBuiltIns": "usage", "corejs": 3, "targets": { "ie": "11" } } ] ] }

useBuiltIns: "usage"会让Babel按需引入polyfill,只把你代码里用到但目标环境不支持的API给打进包。如果你不想引入整个core-js,针对includes这种量级的小特性,也可以自己写个几行的polyfill,照着第2.2节那个版本改造就行。指数运算符**是语法层面的东西,没法用polyfill垫出来,只能做语法转换,这个工作@babel/plugin-transform-exponentiation-operator插件就能完成,它会自动把x ** y转换成Math.pow(x, y)

TypeScript项目也要注意lib配置项。如果你的tsconfig.jsontarget设的是es5,而lib里没有手动加es2016或更高版本,编译器可能会对includes报类型错误。我在一个老项目里就遇到过这个问题,明明运行环境支持,但编译报错找不到includes定义,后来在compilerOptions.lib里加上"es2016"才消停。

4.4 一份可以直接抄的ES2016新特性知识速查表

这里整理一份速查表,方便你在团队内部做分享或者自己复习的时候快速对照:

特性示例等价替代方案注意事项
Array.prototype.includes[1, 2, NaN].includes(NaN)indexOf无法检测NaN引用类型只比较引用地址,不按内容比较
includes 的 fromIndex[1, 2, 3].includes(3, -1)负数从末尾倒数;大于等于数组长度则直接false
指数运算符2 ** 10Math.pow(2, 10)右结合,2 ** 3 ** 2得512
指数赋值运算符x **= 2x = Math.pow(x, 2)注意负底数需要加括号,如(-2) ** 2
浮点精度0.1 ** 2浮点运算有精度误差,比较时需用epsilon

综合下来,ES2016这两个特性的学习成本约等于零,但收益是长期且稳定的。

5. 兼容性分析与多版本类比

5.1 现存JavaScript引擎与运行时环境兼容速查

先看一组兼容性数据。我基于长期维护老项目的经验,整理了下面这几个常见环境的支持情况:

环境最低支持版本
Chrome / Edge52+ 完全支持
Firefox48+ 完全支持
Safari10+ 完全支持
Node.js7.0.0+ 完全支持,6.x需要开启harmony标志
IE11不支持,需要polyfill与Babel转换

这个兼容性表在2025年看已经是“全员绿灯”的状态了。Node.js从7.0.0开始就原生支持includes**,如今主流的16、18、20版本更是完全没有任何兼容压力。唯一需要上心的是老旧的浏览器内核,比如某些银行客户用的旧版IE或老式安卓TV设备,这些场景跑线上代码就必须打包编译,不能指望原生支持。

5.2 不同语言版本更新节奏的横向对比:ES2016 vs JDK 8 vs C++新标准

最后聊点有意思的横向对比。你在搜索ES2016新特性的时候,很可能同时刷到“JDK8新特性”“C++新特性”这类热词。这三者虽然都是编程语言的版本迭代,但节奏和风格差异很明显。JDK 8在2014年发布,引入了Lambda表达式、Stream API、Optional这些革命性内容,它对Java社区的影响和ES2015对JavaScript社区的影响是一回事,都属于“憋大招型”的版本。而C++11更是把现代C++的语法树重新犁了一遍,自动类型推导、右值引用、智能指针,每一刀都砍在要害上。

ES2016和它们相比,本质上是完全不同的演进哲学:Java和C++倾向于一次性发布一个包含大量互相关联特性的重量级版本,让开发者花一整年去消化;而JavaScript从ES2016开始,则认准了“每年少量可落地的特性”这条路。你不能说哪个更好,只能说不同语言的生命周期和社区形态决定了不同的发布策略。JavaScript拥有世界上最庞大的开发者基数,如果每年都搞一次JDK8级别的发布,那TC39委员会和Babel生态都会不堪重负,下游框架的适配也会永远追不上。

理解了这层背景,再回头看ES2016这两个“小特性”,心态就会平和很多。它不是无所谓的一次例行更新,而是JavaScript语言从“大版本焦虑”走向“稳定输出”的转折点。之后的ES2017、ES2018、ES2019之所以能保持稳定的发布频率,很多流程经验都是从ES2016这次尝试里积累出来的。

5.3 项目升级决策建议:能直接用就直接用,别为了兼容委屈自己

在实际项目里做技术选型时,我的建议是:除非你的目标用户群体明确要求兼容IE11或更老的WebView,否则直接使用includes**没问题。理由有两个:一是转译配置本身就轻量,Babel两步就搞定,没必要为了规避两个特性给自己立一堆“不写新语法”的规矩;二是现在的代码审查工具和工程化流水线已经非常成熟,ESLint的env配置里加上es2021,再配一套ecmaVersion,写起来根本不会有任何心理负担。

我见过一些团队非常谨慎,升级target字段时把es2016es2017全列上,但代码里还在用indexOf !== -1的写法,纯粹是自己给自己找麻烦。技术的价值在于提高表达效率,既然标准已经定了这么多年,兼容性也早就不是问题,那就放心用、大胆写。

6. 常见问题与避坑心得

6.1 最容易踩进去的五个隐藏陷阱

第一个坑是includesfromIndex传负数时,好多人的第一直觉以为是“从倒数第n个元素开始到倒数第1个”,结果实际行为是“从倒数第n个元素的位置向右查到数组末尾”。所以[1, 2, 3].includes(3, -1)true,因为-1定位到索引2,向右查到了3。但如果你想“从倒数第n个元素向左查”,includes做不到,得自己用slice手动处理。

第二个坑是对象数组,前面已经说过,includes按引用比较,相同内容的对象查不到。出于保险,凡是遇到对象数组的存在性判断,直接写some,不要犹豫。

第三个坑是**和一元负号连用会直接甩语法错误,比如-3 ** 2,这是规范层面的禁止,不是运行时行为。如果你要表达“负三的平方”,必须写(-3) ** 2;如果你要表达“负的三的平方”,那就写-(3 ** 2)

第四个坑是假装includes能解决所有“判断存在”的需求。对于字符串,虽然String.prototype.includes也存在,但它的语义是“子串包含”而不是“字符序列精确匹配”。写"hello".includes("ell")会返回true,如果你需要做的是“字符列表里是否包含某个字符”,记得用数组而不是字符串。

第五个坑和性能有关。在大数组上高频使用includes会导致明显的卡顿,尤其是数据清洗和去重场景。解决办法很简单:用Set。下面给一个对比示例:

// 慢:3万条数据里查1000个元素 const arr = Array.from({ length: 30000 }, (_, i) => i) const targets = Array.from({ length: 1000 }, (_, i) => i * 2) targets.filter(t => arr.includes(t)).length // 很慢 // 快:先转Set再has const set = new Set(arr) targets.filter(t => set.has(t)).length // 快到起飞

复杂度从O(n*m)降到了O(n+m),数据量越大差距越夸张。这个优化技巧通用于所有使用includes做高频查询的场合。

6.2 一份可以直接背下来的常见问题排查表

现象可能原因解决方案
includes查找NaN返回false数组里根本没有NaN,或你误用了indexOf确认数据源;改用includes,它支持NaN判断
对象数组用includes查不到目标引用地址不同改用some(item => item.id === target.id)
-2 ** 2编译报错一元负号与指数运算符的优先级冲突加括号:(-2) ** 2
0.1 ** 2 !== 0.01IEEE 754浮点精度问题Math.abs(a - b) < Number.EPSILON比较
大数组上includes明显卡顿线性查找复杂度O(n)改用Sethas()
低版本浏览器报includes is not a function缺少polyfilluseBuiltIns: "usage"自动垫入core-js
TypeScript报找不到includes方法lib配置不够tsconfig.jsonlib里加"es2016"

6.3 一个真实案例复盘:数据清洗工具中的性能从40ms优化到5ms

我之前写过一个处理CSV数据的内部工具,里面有一段代码是过滤掉已经处理过的记录ID。刚开始图省事,用一个数组存历史ID,然后history.includes(currentId)做判断。数据量小的时候完全没问题,后来用户导入的CSV文件越来越大,ID记录到了两万条以后,整个页面开始有明显的卡顿感,每次过滤要40毫秒上下。

后来我用Set重构:

const historySet = new Set(historyIds) const remaining = allRecords.filter(record => !historySet.has(record.id))

同样的数据量,耗时直接掉到5毫秒以内。这个案例完美说明了includes的适用边界:低频、小数组,随便用;高频、大数组,先转Set再操作。这也算是我自己在实践中反复踩过之后总结出来的一个通用原则,放到任何“数组里查值”的场景都成立。

7. 写在最后的实操心得

ES2016的新特性虽然只有两个,但背后透露出来的思路很值得借鉴:“少而精”地推进语言演进,每一点改进都解决真实开发痛点,不追求大而全的轰动效应。这种设计哲学也影响了我的编码习惯:在项目里写工具函数时,优先考虑“一个函数只把一件事做好”,如果现有的API已经能覆盖需求,就不额外封装一层多余的抽象。

我个人在实际开发中最受益的一点,是includes让我重新审视了大量的条件判断逻辑。以前写“判断是对的还是错的”,总喜欢用!== -1这种处处透着别扭的方式,现在includes就像一个专门为“判断存在性”设计的开关,代码读起来明快多了。至于指数运算符,虽然日常业务代码里用得不如includes频繁,但在算法题、数据处理、图表计算这些小角落里,它总能在关键时刻让公式表达变得干净利落。

最后再分享一个小技巧:如果你的团队在代码规范上还没有对ES2016这两个特性做明确要求,可以拉一次代码扫描,把仓库里所有indexOf !== -1的写法搜出来,逐个评估能否换成includes。通常一次重构能让很多模块的可读性上一个台阶,而这个改造的成本几乎为零。ES2016或许不是最闪耀的一次JavaScript版本更新,但它绝对是最适合作为“性能/可读性双收益改造”切入点的版本。

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

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

立即咨询