ARTICLE DETAIL

资讯详情

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

让JS对象也能for-of:迭代器与生成器实战解析

让JS对象也能for-of:迭代器与生成器实战解析 做前端的兄弟应该都有过这种被卡住的瞬间拿一个普通对象存了一批配置数据想挨个取出来处理习惯性写了一行 for-of控制台却直接砸过来一行红字TypeError: obj is not iterable。我第一次撞上这个报错的时候满脑子都是“这对象不是挺标准的吗凭什么不能遍历”。后来把 ES6 的可迭代协议啃明白了才意识到不是对象不能遍历而是我没给对象装上迭代器。今天就把这层窗户纸捅破聊聊怎么让 JS 对象也能走 for-of顺便把背后的迭代器原理、几种落地姿势和实战里的坑一并说清楚。这篇文章适合所有写过for (const item of arr)但被对象遍历卡住过的前端同学看完你就能在自己的项目里给普通对象加上可迭代能力。1. 为什么对象天生不能 for-of先搞懂可迭代协议1.1 一次“对象不能遍历”的翻车现场先还原一下最常见的踩坑场景。假设后端接口返回了这么一坨数据const userProfile { name: 张三, age: 30, city: 北京, hobby: 摄影 };然后你想把每个字段和值都打出来。很多同学第一反应就是写for (const item of userProfile) { console.log(item); }结果浏览器直接抛错TypeError: userProfile is not iterable。这时候有人会开始怀疑是不是数据有问题或者抱怨 JS 怎么这么不给力。其实 JS 一点没错问题是for...of的底层要求不是“你是一个对象”而是“你身上有一个可供调用的迭代器”。普通对象身上恰恰没有这个迭代器所以它就理直气壮地告诉你 not iterable。要理解这件事得先分清两个非常像、但完全不一样的东西可迭代协议和迭代器协议。1.2 可迭代协议和迭代器协议名字像但别搞混这两个词在 MDN 文档里挨得很近刚接触的人很容易混为一谈。我用自己的话拆开说可迭代协议说的是一个对象必须要有Symbol.iterator这个特殊方法。这个方法是“生产迭代器的工厂”JS 引擎拿到它之后会调用它得到一个迭代器对象。迭代器协议说的是这个被生产出来的迭代器对象必须实现一个next()方法。每次调用next()它返回一个形如{ value: xxx, done: false }的对象done表示遍历是否结束。打个比方可迭代协议相当于一台自动贩卖机的投币口迭代器协议相当于贩卖机内部吐饮料的齿轮结构。投币口存在你才有资格往里投币齿轮结构正常你才能一瓶一瓶地把饮料拿到手。你往普通对象上硬塞硬币跑 for-of却发现它根本没有投币口自然什么都出不来。for...of的实际工作过程用伪代码看是这样的const iterator obj[Symbol.iterator](); let result iterator.next(); while (!result.done) { // 取出 result.value 进行处理 result iterator.next(); }所以只要让普通对象拥有Symbol.iterator方法并且这个方法返回一个符合迭代器协议的对象那 for-of 就完全能跑了。1.3 数组天生能 for-of凭的是什么数组之所以可以直接 for-of是因为数组的原型链上早就挂好了Symbol.iterator。你可以自己验证一下const arr [1, 2, 3]; console.log(typeof arr[Symbol.iterator]); // function字符串也一样String.prototype上也有Symbol.iterator所以字符串能 for-of。而Object.prototype上没有任何迭代器方法普通对象自然就“裸奔”了。还有一点经常被忽略并不是只有显式实现Symbol.iterator的对象才能被迭代。凡是通过迭代器协议暴露数据的内置结构比如Set、Map、TypedArray、arguments、NodeList全都支持 for-of。所以你应该建立这样一个直觉能不能 for-of取决于对象自身或者它的原型链上有没有Symbol.iterator跟它是不是“对象”没有关系。2. 三种让对象可迭代的落地姿势2.1 方案一手动挂 Symbol.iterator自己写 next理解了协议之后最朴素的做法就是给对象老老实实挂一个Symbol.iterator方法亲手把它里面的 next 写出来。const userProfile { name: 张三, age: 30, city: 北京, [Symbol.iterator]() { const keys Object.keys(this); let index 0; return { next: () { if (index keys.length) { const key keys[index]; return { value: { key, value: this[key] }, done: false }; } return { value: undefined, done: true }; } }; } }; for (const item of userProfile) { console.log(${item.key}: ${item.value}); }这段代码里有几个细节值得注意。第一Object.keys(this)在next函数外面先取好避免每次调用都重新遍历一遍属性。第二next用了箭头函数保证this仍然是对象本身。如果写成普通 functionthis就指向迭代器对象了拿不到原对象的属性。第三迭代器对象里数组越界之后要返回{ value: undefined, done: true }注意done必须是true否则 for-of 会一直循环下去。这种手写方式的好处是你能清楚感知每次遍历都发生了什么坏处是代码偏啰嗦尤其在多个对象都要可迭代的情况下大量重复的 next 逻辑会让人抓狂。所以实际项目中我更推荐下面第二种方案。2.2 方案二生成器函数最优雅的写法生成器函数是 ES6 里另一个好东西它的出现让迭代器手写的繁琐程度直线下降。你只需要在Symbol.iterator的位置放一个生成器函数然后在函数体里用yield把每个值吐出来JS 引擎会自动帮你生成符合迭代器协议的对象。直接看效果const userProfile { name: 张三, age: 30, city: 北京, *[Symbol.iterator]() { for (const [key, value] of Object.entries(this)) { yield { key, value }; } } }; for (const { key, value } of userProfile) { console.log(${key}: ${value}); }一个*号加一个yield整个迭代器就写完了。这段代码做了什么Object.entries(this)返回一个包含[key, value]的二维数组数组本身可迭代所以外层 for-of 能跑。每跑一轮yield就把一对键值吐给外部 for-of 的循环体。外部 for-of 每次拿到一个{ key, value }结构解构出来直接用。生成器函数是“惰性求值”的也就是说你每次调用 next它才会往下走一步不会像Object.entries(this)一样一次性把所有键值对全生成出来。当然在这个场景里Object.entries本来就会全量生成数组所以差别不大但如果你后面开始用生成器处理更复杂的遍历逻辑这种惰性特性会非常有用。我个人在后端开发里还喜欢把生成器用在数据分批拉取、按条件筛选等场景它不会把整批数据塞进内存而是边算边给内存占用会温和很多。这个理念放到前端也一样只是平时前端数据量没那么大体现不明显。2.3 方案三Proxy 统一拦截不用侵入原对象如果对象是别人写的或者是从某个库的实例上拿到的你不想改它的结构那可以用 Proxy 来做一层无侵入包装。Proxy 的get拦截器能够在访问Symbol.iterator时“临时伪造”一个迭代器这样原对象完全不被修改包装后的对象却能 for-of。function makeIterable(obj) { return new Proxy(obj, { get(target, prop, receiver) { if (prop Symbol.iterator) { return function* () { for (const [key, value] of Object.entries(target)) { yield { key, value }; } }; } return Reflect.get(target, prop, receiver); } }); } const sourceObj { name: 李四, age: 25 }; const iterableObj makeIterable(sourceObj); for (const { key, value } of iterableObj) { console.log(key, value); }注意这段代码里生成器函数内部用的是target而不是receiver。如果你用receiver在这个场景下它指向 Proxy 本身Object.entries(receiver)会触发更多的 Proxy 拦截逻辑性能上有损耗还可能造成递归陷阱。直接用target拿到的是原始对象干净利落。Proxy 方案最大的价值在于“不改原对象”。比如你拿到一个第三方的数据模型对象不想破坏它的原型链又想在某个局部逻辑里把它当可迭代集合用那 Proxy 包一层就是零侵入的选择。不过该方案的缺点也明显Proxy 的性能比直接访问属性要差而且这层包装增加了调试时的心智负担你看到的是代理后的对象行为却来自原始对象排错会多绕一步。3. 完整实操把配置对象改造成可迭代数据结构3.1 需求拆解从“数据结构”到“遍历语义”理论说了不少落到实际项目里最典型的需求就是“配置对象遍历”。我举一个很常见的前端场景应用里有一堆配置项来源于用户偏好设置、接口返回的默认配置等存在一个对象里。后面要做的事情包括给每个配置项做日志输出把配置序列化成表单字段批量校验每个配置项的值是否合法如果用Object.keys加 forEach 也能做但代码写起来总感觉差一口气因为“批量处理配置”这个动作本质上就是遍历而遍历这件事 for-of 表达得最自然。这里我选择用一个类把配置封装起来并在类内部实现Symbol.iterator这样调用方可以完全无感知地使用 for-of、展开运算符等语法。3.2 用生成器实现 ConfigStore 的完整代码先看完整的类实现class ConfigStore { #config {}; constructor(initial {}) { this.#config { ...initial }; } get(key) { return this.#config[key]; } set(key, value) { this.#config[key] value; return this; } has(key) { return key in this.#config; } snapshot() { return { ...this.#config }; } *[Symbol.iterator]() { for (const [key, value] of Object.entries(this.#config)) { yield { key, value }; } } } const store new ConfigStore({ theme: dark, lang: zh-CN, cache: true }); store.set(retry, 3); for (const { key, value } of store) { console.log(配置项 ${key} ${value}); } // 还能配合展开运算符 const allItems [...store]; console.log(allItems);这段代码把配置存放在私有字段#config里外部不能直接碰想遍历只能走我们暴露的迭代口子。Symbol.iterator用了生成器语法所以[...store]也能正常工作。这里有个很实用的设计既然Symbol.iterator存在那么所有依赖迭代协议的 JS 语法糖都会自动生效你不需要为展开、解构、Array.from单独写任何方法。再补充一个小技巧store.set返回this是为了实现链式调用。你这样写的时候特别舒服store.set(theme, light).set(lang, en).set(cache, false);3.3 处理边界情况Symbol 键、不可枚举属性、继承属性上了生成器之后很多人第一反应是“这不就是把对象的属性遍历一遍吗跟 for-in 有什么本质区别”。其实区别很大因为这里我用的是Object.entries而它只会拿可枚举的自有字符串属性。如果你对遍历范围有特殊要求需要注意三类边界情况Symbol 键Object.entries、Object.keys、Object.values全部忽略 Symbol 键。如果你需要把 Symbol 键也遍历出来得用Object.getOwnPropertySymbols手动合并。不可枚举属性比如通过Object.defineProperty(obj, key, { enumerable: false, value: 1 })定义的属性不会被Object.entries包含。继承属性原型链上的属性不会被Object.entries包含这一点和 for-in 很不一样。for-in 会把继承的可枚举属性一路撸到底而Object.entries只关心自有属性。如果你希望迭代语义是“全量遍历所有自有属性”那可以把Object.entries换成Reflect.ownKeys不过那时候 value 就要用Object.getOwnPropertyDescriptor去取代码复杂度和理解成本都会上升。大多数工程场景下Object.entries的语义反而更符合直觉我只管这个对象自己身上的、看得见的、常规的键值对。4. for-of 和它的兄弟们选型要看场景4.1 for-of vs for-in别再“万物皆可 for-in”很多老前端项目里遍历对象一直用的是 for-inconst obj { a: 1, b: 2 }; for (const key in obj) { console.log(key, obj[key]); }for-in 能跑通但它的语义里“继承了原型链上的可枚举属性”这一条经常会在意外的地方炸出问题。如果对象的原型链上有人为添加的可枚举方法或属性for-in 会把它们一并遍历出来然后你可能在里面对一个根本不想处理的值执行了某个逻辑结果就翻车了。虽然可以用hasOwnProperty去守卫但这明显多了一道防线代码也不干净。相比之下for-of 配合我们实现的迭代器遍历的内容完全由我们自己控制。你想遍历自有哪些属性就遍历哪些想倒序遍历就倒序 yield想过滤掉_开头的内部字段也完全可以。这种“遍历什么由我决定”的可控性是 for-in 给不了的。*[Symbol.iterator]() { for (const [key, value] of Object.entries(this.#config)) { if (key.startsWith(_)) continue; // 跳过内部字段 yield { key, value }; } }4.2 for-of vs Object.entries 的数组遍历有人会说费这么大劲给对象写迭代器我不如直接用Object.entries(obj)返回数组再 for-of不也一样吗for (const [key, value] of Object.entries(obj)) { console.log(key, value); }确实这招在很多场景下完全够用而且我平时也经常这么写。但区别在于Object.entries会一次生成一个完整的二维数组数据量大的时候内存开销相对高而且它是一个“快照”对象后续的变化要重新调用才拿得到。而自己实现的迭代器是惰性的实现了可迭代协议的对象本身就成为了一等的数据结构可以直接传给任何期待可迭代对象的函数比如new Map(iterableObj)、Array.from(iterableObj)、Promise.all(iterableObj)等。工程上的价值在于你封装了一个可迭代对象整个团队都知道它可以直接遍历而不是每次写代码都要想起来“哦这个地方要先 Object.entries 一下”。可迭代能力是接口层面的能力不应该是调用方临时拼凑出来的。4.3 什么时候不值得给对象加迭代器不是所有对象都值得大动干戈去实现可迭代协议。如果你的对象只是临时用一下或者只有一个地方需要遍历那直接Object.entries搞定别过度设计。加迭代器的收益主要出现在以下几种场景对象会被多个模块反复遍历对象的遍历逻辑有定制需求过滤、排序、转换对象本身就是核心数据结构希望对外暴露统一的遍历接口想让对象兼容展开运算符、Array.from、new Map等迭代协议依赖者反过来只在一个函数里用一次for...of的临时对象顺手写个Object.entries就够了。任何设计都有成本可迭代协议给了你便利但也要求你维护好迭代逻辑。5. 常见报错与排查技巧实录5.1 TypeError: xxx is not iterable 的三种成因新手最容易卡死的报错就是is not iterable。我总结下来基本是三类原因一是对象根本没有 Symbol.iterator这就是普通对象直接 for-of 的报错。解决办法是按第 2 节的方式补上迭代器。二是从函数或异步函数里 return 了一个普通对象调用方拿这个结果直接 for-of。这种情况的本质也一样只是藏得深一些你需要检查返回值的结构和原型链。三是对 null 或 undefined 执行了 for-of。function getData() { return null; } for (const item of getData()) { // TypeError: getData() is not iterable }报错不会直接说“你遍历了 null”而是说“它不是可迭代的”原因在于 for-of 第一步就会尝试访问Symbol.iterator而这个属性访问在 null/undefined 上直接抛错。排查的时候先确认对象真的存在再确认它身上真的有迭代器。5.2 迭代器返回值写错value 和 done 的问题手写迭代器时最隐蔽的坑是next()返回值的结构写错。有些同学会写成return { value: item, done: index length };如果done提前变成true那么最后一个元素会被悄悄跳过如果done始终是falsefor-of 就变成死循环。正确写法是只要还有元素可返回done就为false只有当没有更多元素了才返回{ value: undefined, done: true }。另外value和done这两个字段的名字不能随便改。迭代器协议规定就是用这两个字段你用{ val: xxx, finished: false }JS 引擎不认识照样是死循环或者跳过。这个协议的格式就好比接口文档没按文档传参后端就是不给你认账。5.3 Symbol.iterator 被覆盖、丢失、序列化的问题用扩展运算符拷贝或 JSON 序列化时Symbol.iterator也可能悄悄跑丢。展开运算符{ ...obj }只会复制自有可枚举属性Symbol 属性默认不复制JSON.stringify更是完全忽略 Symbol 键。所以如果你把实现了迭代器的对象序列化存进 localStorage再读出来还原得到的是个没有迭代器的普通对象想再 for-of 就得重新包装一次。遇到这种情况最好的做法是不要把整个可迭代对象直接序列化而是只序列化它的核心数据恢复时再用数据重新构造一个新对象。好比我第 3 节的 ConfigStore真要持久化就把store.snapshot()的结果存起来恢复时new ConfigStore(saved)这样迭代器永远在类上不跟着数据走读取时一定可用。5.4 一个用生成器做数据校验的实战小技巧既然配置对象可迭代了最实用的玩法是配合生成器做批量校验。比如你有一堆配置项每个都有自己的校验规则你可以把校验逻辑揉进迭代过程里class ConfigStore { // ...省略之前的代码 *validate() { for (const { key, value } of this) { if (typeof value undefined) { yield { key, ok: false, message: ${key} 的值为 undefined }; } else if (typeof value number Number.isNaN(value)) { yield { key, ok: false, message: ${key} 不是有效数字 }; } else { yield { key, ok: true }; } } } } const store new ConfigStore({ timeout: NaN, theme: light }); for (const result of store.validate()) { console.log(result); }这个validate()本身是一个生成器方法它用了for (const { key, value } of this)去遍历配置对象又通过 yield 把校验结果吐出来。调用方拿到的是一串校验结果可以随时中断也可以收集成数组。整个过程没有新增数组没有回调嵌套纯靠迭代器把两层遍历串在一起可读性相当舒服。6. 踩过几次坑之后的个人心得多花点时间理解迭代器和生成器绝对值回票价。刚开始我也觉得这只是“给对象加个方法”的小技巧但当你发现一个对象只要实现Symbol.iterator就能无缝接入所有依赖迭代协议的语法和 API 时你会慢慢把遍历这件事从“怎么用 forEach 处理数据”的思维里解放出来。我再分享一个自己特别常用的模式写一个通用的iterableObject包装函数在多个项目之间复用。不管是配置对象、表单模型还是接口返回的数据映射包一下就都能 for-of。但别把包装函数放得太深太复杂核心逻辑就是一行把Object.entries换成生成器 yield。简单的东西才容易维护复杂的设计放在业务里只会变成负担。如果你今天只能带走一句话那就是记住普通对象不支持 for-of并不是 JS 的缺陷而是它给了你自定义遍历逻辑的入口钥匙。用好了这个“缺陷”反而是最灵活的地方。
返回列表