ARTICLE DETAIL

资讯详情

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

扩展运算符与Rest参数:同一个...符号的两种角色,前端必懂

扩展运算符与Rest参数:同一个...符号的两种角色,前端必懂 1. 先搞懂一件事这俩到底是不是一回事先说结论扩展运算符Spread Syntax和 Rest 参数Rest Parameters用的是同一套符号——三个点...但它们俩在 JavaScript 里的角色完全是反着来的。很多前端新人第一次看到...的时候脑子里只有一个模糊的印象哦这是展开用的。结果学到后面发现...还能收集参数、还能合并对象、还能拷贝数组越学越混乱。我自己带新人的时候几乎每个人都会在这个点上卡住而且卡住之后还不自知——因为他们觉得反正代码能跑管它是展开还是收集呢。这种能跑就行的心态在面试和实际项目里特别容易翻车。比如面试官问你function foo(...args) {}和const arr [...args]里的...有什么区别你要是回答都是展开那基本就凉了。再比如你在项目里写了const newObj { ...obj, name: xxx }结果发现嵌套对象被浅拷贝了bug 找半天找不到原因——这也是因为你对...的底层行为理解不到位。所以我写这篇东西就是想帮你把...的两种身份彻底掰扯清楚。不只是告诉你一个是展开一个是收集而是从底层原理、实际场景、常见坑点三个维度让你以后看到...就能条件反射地判断出它在这行代码里扮演的是什么角色。适合谁看刚入行一两年、在项目里用过...但说不清原理的前端新人以及准备前端面试、想要把基础概念整理透彻的朋友。如果你已经是熟练工可以重点看第 4 节的坑点清单我保证有些坑你大概率踩过。2. 两个概念的底层逻辑符号相同方向相反2.1 扩展运算符把整体拆成零件扩展运算符的核心语义是展开、拆散。它的作用对象是可迭代对象比如数组、字符串、Set、Map或者对象本身作用结果是把这些东西里面的每一项拿出来逐个摊开。写个最简单的例子const arr [1, 2, 3]; console.log(...arr); // 1 2 3你看...arr等于把[1, 2, 3]外面的方括号脱掉了变成了1, 2, 3三个独立的值。这就像你从超市买了一整袋苹果...就是把这袋苹果倒出来一个个摆在桌上。它最常见的三个用途函数调用时传参Math.max(...arr)等价于Math.max(1, 2, 3)数组/对象拷贝与合并const newArr [...arr1, ...arr2]把类数组转成真数组const arr [...arguments]这里有个关键点扩展运算符只能用在需要多个值或多个元素的地方。比如函数调用的参数列表里、数组字面量里、对象字面量里。你不能在赋值语句的左侧写...arr因为左侧需要的是变量名而不是展开后的值。2.2 Rest 参数把零件收成整体Rest 参数的语义和扩展运算符正好相反它是收集、聚合。它只能用在函数声明的参数列表里作用是把调用函数时传入的一堆参数收集成一个数组。function foo(...args) { console.log(args); } foo(1, 2, 3); // [1, 2, 3]你看调用foo时传了三个独立的值但args拿到的是一个数组。这就像你往收纳盒里扔零钱...args就是那个收纳盒把所有散落的硬币收集到一起。Rest 参数有几个值得注意的细节第一它必须放在参数列表的最后一位否则会报语法错误// 合法 function foo(a, b, ...others) {} // 报错Rest parameter must be last formal parameter function foo(...others, a, b) {}第二它收集的是剩余的所有参数所以名字叫 Rest剩余。如果你写了function foo(a, ...rest)那么第一个参数由a接收剩下的全部进rest。第三在箭头函数里(...args) {}的args也是一个真正的数组而不再是arguments那种类数组。这一点在 ES6 之后写箭头函数时非常常用因为它可以直接用数组方法不用再Array.prototype.slice.call(arguments)了。2.3 一句话记忆法看符号出现在哪一侧我教新人的时候会让他们记住一个非常简单的判断方法...出现在赋值/传参的右侧一般就是扩展运算符拆开出现在函数声明的形参列表里一般就是 Rest 参数收集。举两个对比案例// 案例 A扩展运算符 const source [1, 2, 3]; const copy [...source]; // ...在数组字面量右侧是展开 // 案例 BRest 参数 function collect(...items) { console.log(items); } collect(1, 2, 3); // ...在函数形参位置是收集你把这个判断逻辑刻进脑子里以后再看到...就不会迷路。当然还有更进阶的判断方法比如看它和可迭代对象的关系后面我会展开讲。3. 核心细节解析与实操要点3.1 扩展运算符的三大高频场景拷贝、合并、传参场景一数组拷贝const original [1, 2, 3]; const copy [...original];这里要特别注意一个高频面试考点这是浅拷贝不是深拷贝。如果数组里都是基本类型数字、字符串、布尔值那确实互不影响。但如果数组里是对象或嵌套数组copy里的元素和original里的元素引用的是同一个对象。你改copy里某个对象的属性original里那个对象也会跟着变。我见过一个真实项目里踩的坑从接口拿到一个列表每项是个对象前端想做个编辑后撤销功能于是先const backup [...list]存了个备份。结果用户改了表单里的某个字段backup 里的对应对象也跟着变了撤销功能直接失效。这个问题的解法只有两种要么用structuredClone做深拷贝现代浏览器都支持要么用JSON.parse(JSON.stringify(list))做序列化拷贝有函数/循环引用时不能用。我个人建议对象层级不超过两层且数据量不大时JSON大法最省事层级深或者有特殊类型Date、Map、Set时上structuredClone。场景二数组合并const arr1 [1, 2]; const arr2 [3, 4]; const merged [...arr1, ...arr2]; // [1, 2, 3, 4]这比concat写起来直观多了还能在合并时顺便往任意位置塞元素const merged [...arr2.slice(0, 1), 中间值, ...arr2.slice(1)];场景三函数调用传参const nums [5, 10, 3, 8]; Math.max(...nums); // 10在 ES5 时代这一步得用Math.max.apply(null, nums)写法丑且容易忘传null。...的出现彻底解放了这种写法。前端面试里很喜欢出一道经典的求数组最大值题你直接用Math.max(...nums)一行搞定顺带还能展示你对 ES6 的熟练度。3.2 Rest 参数的三个典型用法可变参数、部分收集、解构用法一可变参数函数function sum(...numbers) { return numbers.reduce((total, n) total n, 0); } sum(1, 2, 3); // 6 sum(1, 2, 3, 4, 5); // 15这个场景在写工具函数时特别常见。比如封装一个debounce、一个throttle、一个事件处理器你都需要把原函数收到的所有参数原样转发。用 Rest 参数收下来再用扩展运算符传出去function debounce(fn, wait) { let timer null; return function (...args) { if (timer) clearTimeout(timer); timer setTimeout(() fn(...args), wait); }; }你注意这里...出现了两次第一次在function(...args)是 Rest 参数收集调用时传入的参数第二次在fn(...args)是扩展运算符把收集到的参数展开传给fn。这是前端开发里最经典的收集 展开组合拳面试时如果你能主动讲出这两者的身份转换面试官对你的评价绝对会上一个档次。用法二固定参数 剩余参数收集function log(level, ...messages) { console.log([${level}], ...messages); } log(INFO, 用户登录成功, userId: 123);这个写法在写日志工具、埋点 SDK、插件封装时非常有用。固定参数用具名变量接收语义清晰剩余参数统一收集方便处理数量不定的参数。用法三解构赋值中的 Restconst [first, second, ...rest] [1, 2, 3, 4, 5]; console.log(first); // 1 console.log(second); // 2 console.log(rest); // [3, 4, 5]对象解构也支持const user { name: 张三, age: 18, email: zhangsanexample.com }; const { name, ...others } user; console.log(name); // 张三 console.log(others); // { age: 18, email: zhangsanexample.com }这个剥离属性的技巧在 React/前端业务代码里极其常用。比如你需要从 props 里把className拿出来单独用其余的传给子组件直接写const { className, ...restProps } props。剩下的restProps通过{...restProps}展开传给下层组件——又是收集 展开的组合。3.3 它们和 arguments 的关系为什么推荐用 Rest 参数在 ES6 之前函数内部获取所有参数要靠arguments。但arguments有两个明显的缺点第一它是一个类数组不是真正的数组。它虽然有length属性也能通过下标访问元素但它没有map、filter、reduce等方法。想用数组方法得先转换Array.prototype.slice.call(arguments)或者Array.from(arguments)。第二箭头函数里没有自己的arguments。如果你在箭头函数里写arguments它会向上层作用域查找非常容易引发 bug。所以现代前端开发中我强烈建议凡是要处理不确定数量的参数的场景一律用 Rest 参数替代arguments。一个简单的对比让大家彻底记住// 老写法不推荐 function oldStyle() { const args Array.prototype.slice.call(arguments); return args.join(,); } // 新写法推荐 function newStyle(...args) { return args.join(,); }newStyle里的args从一开始就是真正的数组直接调join、map、filter没有任何问题。这也是前端面试里高频的ES6 新特性考察点之一。4. 实操过程与核心环节手写一个简易版参数转发器讲概念再多不如动手做一个小工具来得实在。我给大家安排一个非常贴近真实业务的小任务写一个参数转发器它能把接收到的所有参数原封不动地传给另一个函数并且在转发前后插入自己的逻辑。这一步会综合用到 Rest 参数和扩展运算符做完之后你对收集、展开的理解会非常透彻。4.1 需求拆解假设我们有一个现成的业务函数sendRequest它接收三个参数url、method、data。function sendRequest(url, method, data) { console.log(发送请求${method} ${url}, data); }现在我们需要封装一个withLogging高阶函数它接收一个原始函数返回一个新函数。新函数在被调用时先打印日志再调用原始函数并保证参数原样传递。4.2 核心实现function withLogging(fn) { return function (...args) { console.log(转发前日志即将调用函数参数个数为, args.length); const result fn(...args); console.log(转发后日志函数调用完成); return result; }; } const wrappedRequest withLogging(sendRequest); wrappedRequest(/api/users, GET, null);代码逻辑是这样的return function (...args)里的...args是Rest 参数它把wrappedRequest(/api/users, GET, null)传入的三个参数收集成一个数组[/api/users, GET, null]。fn(...args)里的...args是扩展运算符它把数组[/api/users, GET, null]展开成三个独立参数再传给sendRequest。sendRequest按顺序接收到url /api/users、method GET、data null正常工作。整个过程中...先收集、后展开身份切换干净利落。4.3 参数透传背后的为什么这里有一个值得深入思考的问题为什么我们必须用...来转发参数直接return fn(arguments)不行吗我们来做个对比实验function badWrapper() { // 注意这里 arguments 是一个类数组对象不是数组 return sendRequest(arguments); } badWrapper(/api/users, GET, null);运行结果是sendRequest只收到一个参数arguments这个类数组对象本身。url变成了整个 arguments 对象method和data都是undefined。因为sendRequest期望的是三个独立参数你给它一个装着一堆参数的盒子它只会把盒子当作第一个参数接住。这就解释了为什么要用 Rest 参数收集 扩展运算符展开Rest 负责把散落的参数装进数组扩展负责把数组拆回散落的参数。两者配合才能做到参数原样透传。4.4 实战延伸实现一个简易版事件发布订阅很多前端项目里会用到事件总线Event Bus它的核心方法emit和on天然适合展示扩展运算符和 Rest 参数的组合用法。class EventBus { constructor() { this.events {}; } on(eventName, callback) { if (!this.events[eventName]) { this.events[eventName] []; } this.events[eventName].push(callback); } emit(eventName, ...payload) { const callbacks this.events[eventName] || []; callbacks.forEach((cb) cb(...payload)); } } const bus new EventBus(); bus.on(msg, (title, content) { console.log(收到消息, title, content); }); bus.emit(msg, 通知, 你好这是一条测试消息);在这个例子里emit(eventName, ...payload)的...payload是 Rest 参数把msg后面的所有参数收集成数组[通知, 你好这是一条测试消息]。cb(...payload)是扩展运算符把数组里的两个元素拆开传给回调函数。回调函数通过形参title、content分别接收。如果你写过 Vue 的$emit、$on或者写过 Node.js 的EventEmitter你会发现底层逻辑都是一模一样的。这个例子做完你基本就把...的两种身份吃透了。5. 常见问题与排查技巧实录5.1 问题扩展运算符拷贝对象却改了原对象现象const user { name: 张三, address: { city: 杭州 } }; const copy { ...user }; copy.address.city 上海; console.log(user.address.city); // 上海原对象被改了原因扩展运算符做的是浅拷贝。copy里的address和user里的address指向同一个内存对象改一个必然影响另一个。解决思路如果对象层级只有一层{ ...user }完全够用。如果有多层嵌套需要使用深拷贝const copy structuredClone(user); // 现代浏览器写法处理 Date、Map、Set 也没问题 // 或者兼容性写法 const copy JSON.parse(JSON.stringify(user)); // 注意函数和 undefined 会被丢弃我的经验写业务代码前先判断这个对象会不会被二次修改。如果只是用来传参展示浅拷贝足够如果要做编辑、回滚直接上深拷贝。不要省这一步后面排查引用共享的问题极其费时。5.2 问题Rest 参数的length属性变化现象function normal(a, b, c) {} console.log(normal.length); // 3 function rest(a, ...others) {} console.log(rest.length); // 1原因函数的length属性表示形参个数但 Rest 参数不参与计数。而且...之前的默认参数也会影响length的计算规则。这个问题在我面试别人的时候偶发考到如果你不清楚规则很容易答错。在项目里的影响有些库会通过fn.length来判断函数的参数个数比如某些函数式编程库的 curry 封装。如果你的业务里依赖length用 Rest 参数后就可能出现参数个数判断偏差。不过日常业务代码里用到length的场景非常少了解即可。5.3 问题字符串和 Unicode 字符被拆坏现象const str ; // 这是一个四字节的汉字 console.log([...str].length); // 2而不是 1原因字符串是可迭代对象...按 Unicode 码点进行展开。一些四字节的字符Emoji、生僻汉字在 JavaScript 内部占两个码元简单展开会被拆成两个独立码元。不过现代 JS 引擎会尽量按码点处理真正遇到极端情况时可以使用Array.from更可靠Array.from(str).length; // 1这个问题在日常业务中遇到得少但如果你做过表情输入、富文本编辑器、中文生僻字校验就有机会踩到。提前留意面试时能答上来也是加分项。5.4 问题Set、Map 与扩展运算符的组合场景数组去重const arr [1, 2, 3, 3, 2, 1]; const unique [...new Set(arr)]; console.log(unique); // [1, 2, 3]场景合并 Map 的时候要注意...对 Map 展开得到的是键值对数组const map new Map([[name, 张三], [age, 18]]); console.log([...map]); // [[name, 张三], [age, 18]]如果你直接{ ...map }得到的是{}因为对象展开并不会自动调用 Map 的迭代器。要转成纯对象需要Object.fromEntries(map)。这个细节在面试里经常作为进阶题出现我这里给大家列个速查表数据类型扩展运算符的结果经典用途数组逐项展开数组合并、拷贝对象逐属性展开对象合并、拷贝字符串逐字符展开拆分字符Set逐元素展开去重Map逐条目展开键值对数组转数组5.5 问题在 Node.js/浏览器实际运行时的性能小坑很多文章会说...比concat和apply快其实在现代引擎里它们性能差距微乎其微。真正值得关注的是大数据量拷贝时的内存占用。如果你要对一个几十万条记录的数组做[...arr]它会完整复制一遍内存消耗是明摆着的。工作里我见过有人对超大列表反复展开、合并导致页面卡顿。如果遇到高频操作优先检查是否可以复用原数组或者使用不可变数据结构的库来避免重复拷贝。5.6 独家避坑解构赋值里 Rest 参数的健壮性问题对象解构出来一个值为null或undefined的字段再想用 Rest 收集会怎样const user { name: 张三, address: null }; const { name, ...other } user; console.log(other); // { address: null }这里没问题但如果你先解构一个不存在的变量再对空值做扩展就会翻车const items null; const { ...rest } items; // TypeError: Cannot destructure所以在实际业务里对可能为空的接口数据做解构展开前务必加兜底const safeItems items || {};然后const { ...rest } safeItems;。这个习惯能帮你避免很多线上报错。6. 面试官视角如何精准回答扩展运算符与 Rest 参数的区别把这个话题放到前端面试里几乎百分之百会考。我作为面试官会怎么问、想听到什么答案这里给大家做个完整拆解。6.1 最常见的三种问法问法一请解释扩展运算符和 Rest 参数的区别标准思路是三层符号相同都是...但作用方向相反扩展是展开、拆散Rest 是收集、聚合。使用位置不同扩展运算符用于函数调用的参数列表、数组/对象字面量Rest 参数用于函数声明的形参列表。扩展运算符的作用对象是可迭代对象或对象本身Rest 参数的作用对象是调用函数时传入的多个参数。问法二下面代码的输出是什么function fn(a, ...rest) { console.log(a); console.log(rest); } fn(1, 2, 3, 4);答案1和[2, 3, 4]。注意rest是数组不是类数组。问法三如何用一行代码实现数组去重答案[...new Set(arr)]。面试官想听到的不只是答案而是你能不能解释new Set(arr)做了什么、...在这里做了什么。只要你能把每一步的作用拆清楚说明你真的理解了。6.2 深度加分回答从迭代协议层面理解扩展运算符如果你想让面试官对你刮目相看还可以补充一个深层次原理扩展运算符之所以能展开数组、字符串、Set是因为它们在 JavaScript 内部都实现了可迭代协议[Symbol.iterator]方法。...会调用对象的迭代器逐个取出值。所以严格来说只有可迭代对象才能被展开成独立值。而对象{}本身不是可迭代的但对象字面量里的...是 ES2018 加入的对象展开特性走的是另一套属性拷贝机制本质上类似Object.assign。这一段话能展示你是understand how it works而不是know how to use it在面试里非常加分。尤其是在问你为什么{...null}不会报错但{...undefined}也不会报错这种细节题时你可以回答对象展开会跳过null和undefined而普通数组展开遇到null会直接报错。这也是两者的重要区别之一。6.3 关键区别总结表对比维度扩展运算符Rest 参数使用位置函数调用参数、数组字面量、对象字面量函数声明形参必须在最后一位核心动作展开散开收集聚合作用目标可迭代对象 / 普通对象调用时传入的多个参数返回结果多个独立值一个真数组常见场景拷贝、合并、传参、去重可变参数函数、转发参数、解构剥离属性7. 实际业务里最容易用对也用错的三个进阶场景7.1 React/Vue 组件传参{...props}的展开陷阱在前端框架开发里{...props}是高频写法。const Button (props) { const { type, children, ...restProps } props; return button type{type} {...restProps}{children}/button; };这里的...restProps是把剩余属性展开成 DOM 属性。看起来很顺手但有两个坑第一restProps里可能包含未预期的属性比如你在父组件不小心传了>const compose (...fns) (...args) { return fns.reduceRight((acc, fn) fn(acc), ...args); };第一次看这个代码新人会懵为什么...fns既能收集函数数组...args又能收集初始参数原因就是前者是 Rest 参数收集多个函数后者同样是 Rest 参数收集调用的初始参数。而fn(acc)每次执行时acc作为单一参数直接传入。整个过程通过 Rest 参数把多个东西变一个集合又通过变量传递把一个集合变小变大。不过老实说这类纯函数组合写法在日常业务里不是天天用我更推荐大家在写通用工具函数时先体会收集 展开的转手逻辑而不是一上来就背 compose 的实现。基础扎实了高阶用法自然水到渠成。7.3 前端工程化里的参数透传设计写工具函数、规范前端 SDK、封装请求库时你经常需要接收一堆参数原样转发给另一个函数。比如封装一个request方法function request(url, options {}) { const defaultOptions { method: GET, headers: {} }; const finalOptions { ...defaultOptions, ...options }; return fetch(url, finalOptions); } // 更灵活的封装额外参数也可以透传 function requestWithRetry(url, ...restArgs) { // 这里 restArgs 收集了 options 等参数 return request(url, ...restArgs); }这种设计比argumentsapply的老写法可读性高好几个档次。也是当代前端 SDK 开发中的常见模式。8. 我对这个知识点的总结与扩展建议我个人带新人这些年最深的感受是扩展运算符和 Rest 参数不是一个知识点的两面而是同一个符号在两个语法环境里的两个身份。你只有把它们放到具体代码里去辨别才能真正形成条件反射。我的建议是你不用刻意背扩展运算符是拆、Rest 是收这种口诀多在日常代码里做标注练习随便打开一个项目文件凡是遇到...停下来问自己两件事这一行里...在赋值左侧还是右侧它是在收还是在拆连续做两三天这个知识点就会刻进肌肉记忆里。如果还想做进一步练习我留三个小题目用扩展运算符实现数组的尾部插入和头部插入写在一行里。封装一个merge函数接受多个对象参数返回合并后的新对象过程中只使用...。把一个function(a, b, c, ...rest)的函数改造成箭头函数保持参数透传行为一致并思考箭头函数里arguments缺位时 Rest 参数为什么是最好的替代方案。这三个题目做完你对...的掌控基本就炉火纯青了。前端基础不复杂但很多坑都藏在这些看起来很简单的语法细节里。希望你读完这篇之后面试遇到...能笑着答完整。
返回列表