JS精进系列之你必须知道的对象操作(包含最新特性optional chaining)

3,022 阅读12分钟

2019年8月的已经进入stage3的提案 optional chaining非常的nice, 它改变了我们访问深层次对象的方式。

在我们业务开发中, 遇到的最常见的复杂数据类型是 ___。

答案: 对象 (plain object)

无论是restful API获取服务端JSON数, 或者是配置, 再或者是初始化时候的optional属性, 都是一个复杂的对象, 里面可以有数组, 字符串, 也可以嵌套很多层。

const bigObject = {
  // ...
  prop1: {
    //...
    prop2: {
      // ...
      value: 'Some value'
    }
  }
};

有这种对象时候, 开发起来最讨厌没有之一的事情是逐级检查属性是不是存在,

if (bigObject && 
    bigObject.prop1 != null && 
    bigObject.prop1.prop2 != null) {
  let result = bigObject.prop1.prop2.value;
}

一个不检查就可能会导致 TypeError: Cannot read property 'name' of undefined. 尤其是服务端数据给的不准确时, 系统是很脆弱的。 但问题是这个代码很繁。

最新的JS特性optional chaining就是解决这个问题的, 下面这种判断是我们目前的惯用手法

const movie = {
    director:{
        name: 'evle'
    }
}

const name = movie.director && movie.director.name;

使用optional chaining特性后

const name = movie.director?.name

还有一种很常见的嵌套结构是对象里面是个数组, 我们不仅要判断它是不是null还要判断length是否大于0来判断它是不是数组

const movie = {
    director:[{name: 'evle'}, {name:'max'}]
}

const name = movie.director &&  movie.director.length > 0 && movie.director[0].name

简直可怕, 使用optional channing后事情变得简单了

const name = movie.director?.[0]?.name

如果name我们访问不到会返回undefined, 通常我们会设置默认值, 比如

const name = movie.director &&  movie.director.length > 0 && movie.director[0].name || 'default name'

||符号可读性不好, 引发我们多余的逻辑思考的运算符都会导致代码可读性变差, optional channing提供了??操作符来明确的给定默认值

const name = movie.director?.[0]?.name ?? 'default name'

介绍完 optioanl channing 的使用我们来归纳下它的使用场景

// 对象中的属性是基本类型
const object = null;
object?.property; 

// 对象中的属性是方法
const object = null;
object?.method('Some value'); 

// 对象中的属性是数组
const array = null;
array?.[0]; // => undefined

// 对象中的属性是动态属性
const object = null;
const name = 'property';
object?.[name]; // => undefined

// 对象中的属性有多层嵌套
const value = object.maybeUndefinedProp?.maybeNull()?.[propName];

是不是想立马试试这个新特性? 赶快配置一个babel插件体验一下

// .babelrc
{
    "plugins": ["transform-optional-chaining"]
}

插件地址: babel-plugin-transform-optional-chaining

除了最新的对象操作 optional chaining 外我们来精进下已有的对象操作方法:

遍历 array-like object (类数组对象)

遍历array或者array-like object, 最常见的方式之一就是使用forEach(), 那么精通forEach的特性是我们用好forEach遍历array-like 对象的关键。

forEach中的this

array.forEach(callback, thisArgument)

从forEach的函数签名可以看出, 它第二个参数可以改变this的指向, 先来看一个简单的this指向问题

const letters = ['a', 'b', 'c'];

function iterate(letter) {
  console.log(this === window); 
}

letters.forEach(iterate); 

log的信息一定是true, 因为iterate的调用是在浏览器环境下, this === window, 也就是说 forEach的callback中的this指向的是window 那么再看下面这个例子

class Unique {
  constructor(items) {
    this.items = items;
  }
  
  append(newItems) {
    newItems.forEach(function(newItem) {
      if (!this.items.includes(newItem)) {
        this.items.push(newItem);
      }
    });    
  }
}

既然forEach中的this指向window, 那如果想实现我们预期的功能, 我们就可以使用第二个参数, 改变forEach的callback中的this指向。

...
 newItems.forEach(function(newItem) {
      if (!this.items.includes(newItem)) {
        this.items.push(newItem);
      }
    }, this);  // 这里将callback中的this改变为Unique    
...

除了改变this指向外, 还可以使用 arrrow function

 newItems.forEach(newItem => {
      if (!this.items.includes(newItem)) {
        this.items.push(newItem);
      }
    }); 

forEach可以跳过空元素 但是不跳过假值(null、undefined)元素

const array = [1, undefined, , , , , 3];

array.forEach(el => console.log(el)) // => 1, undefined, 3

forEach遍历array-like对象

有这样一个类数组对象

const arrayLikeColors = {
  "0": "blue",
  "1": "green",
  "2": "white",
  "length": 3
};

因为是对象, 所以不具有forEach的方法, 但是通过 不那么直接 的forEach调用方法可以遍历类数组。

function iterate(item) {
  console.log(item);
}

Array.prototype.forEach.call(arrayLikeColors, iterate);
// logs "blue"
// logs "green"
// logs "white"

除了这个方法外, 最直接的方法就是将array-like对象转换成array, 使用Array.from()

Array.from(arrayLikeColors).forEach(iterate);

forEach的缺点

不能终止

forEach的最佳实践是用来遍历数组中的每一项元素, 因为forEach是不支持循环终止的即:break无效, 通过强制抛出异常来停止循环太丑陋了。

这个例子能暴露forEach的缺点

let allEven = true;

const numbers = [22, 3, 4, 10];

numbers.forEach(function(number) {
  if (number % 2 === 1) {
    allEven = false;
    // 已经得出结果了 却不能停止 继续遍历损耗性能
  }
});

console.log(allEven); // => false

如果需要遍历到某一项停止的话最佳的解决方案是for...of, 或者以下这些ES6中提供的现代方法:

  • array.map()
  • array.reduce()
  • array.every()
  • array.some()

下面让我们使用every来改造一下上面的代码, 提高性能并且保持我们代码的优雅。

const allEven = numbers.every(function(number) {
  return number % 2 === 0;
});

副作用

forEach不会像array.map()或者array.filter()之类的返回一份拷贝, 而是可以直接操作元素

const inputs = document.querySelectorAll('input[type="text"]');

inputs.forEach(function(input) {
  input.value = '';
});

input的值被意外的改变了, 也就是说forEach的callback会产生副作用, 违背了高阶函数 no-side-effects 的原则, 当确实需要使用它的side effects时一定要注意。此外forEach是没有返回值的(undefined)。

遍历对象 Object.entries

最早从2016年6月就提出了遍历对象的新方法:Object.values(), Object.entries(), 但使用率却不是很高, 下面让我们来探索这两个新方法与for...of的结合产生的更优雅的遍历对象方式吧。

在介绍这两个方法之前我们 Object.keys 已经可以闭着眼睛写出

Object.keys(obj).forEach(key => {
    // obj[key]
})

这样遍历对象的方式, 那Object.keys有什么特点呢? 在了解它的特点之前我们先要明确一个概念: 遍历的是什么, Object.keys 仅仅返回自身的和可枚举属性, 弄个例子来说明下:

let Cat = {
  color: 'black'
};

let Dog = {
  color: 'white',
  age: 15
};

Object.setPrototypeOf(Cat, Dog);
Object.keys(Cat); // => black'

虽然猫从狗上继承了白色 但是我们使用Object.keys并没有遍历出来, 但是如果使用 for...in会遍历出来继承的属性

let enumerableKeys = [];
for (let key in Cat) {
  enumerableKeys.push(key);
}
enumerableKeys; // => ['color', 'age']

for...in把我们从狗身上继承的age属性也遍历出来了, 所以你要清晰有清晰的认知:你要遍历这个对象本身的属性, 还是要遍历它本身以及继承来的属性

还有个问题:Object.keys可遍历的还有可枚举属性, 那我们试试

Object.defineProperty(Cat, 'name', {
  enumerable: false, // Make the property non-enumerable
  value: 'cheese'
});

现在Cat具有了一个额外的自身属性name但它是不可枚举的, 现在的Cat: {color: "black", name: "cheese"}, 接下来让我们遍历一下:

Object.keys(Cat) // ['color']

我们可以看到name属性并没有遍历出来, 当我们明白了Object.keys()后其实Object.values()Object.entires()也是一个特性, 仅遍历出自身和可枚举的属性。

Object.entries() 的返回是属性的keyvalue:

[[key1, value1], [key1, value2]]

第一眼看到就没啥用, 但是配合ES6的解构使用那就很舒服

let meals = {
  mealA: 'Breakfast',
  mealB: 'Lunch',
  mealC: 'Dinner'
};
for (let [key, value] of Object.entries(meals)) {
  console.log(key + ':' + value);
}
// 'mealA:Breakfast' 'mealB:Lunch' 'mealC:Dinner'

以后遍历对象的方式多了一种for...ofObject.entries的新标准对不对?

只是遍历这么简单吗? 新东西结合新东西才能发挥最大的作用, Object.entries()Map() 的组合是天生的搭档, 因为Map本来也是键值对, Object.entries()返回的也是键值对可以直接传入Map的构造函数生成Map

Map的好处以及什么时候使用可以看之前写的一篇文章 是时候使用map代替object了

let greetings = {
  morning: 'Good morning',
  midday: 'Good day',
  evening: 'Good evening'
};
let greetingsMap = new Map(Object.entries(greetings));
greetingsMap.get('morning'); // => 'Good morning'
greetingsMap.get('midday');  // => 'Good day'
greetingsMap.get('evening'); // => 'Good evening'

除了可以直接像把Object.entires的值传给Map的构造函数外, 其实Map提供的valuesentries就是Object.values()Object.entries(), 他们是同一个东西。

// ...
[...greetingsMap.values()];
// => ['Good morning', 'Good day', 'Good evening']
[...greetingsMap.entries()];
// => [ ['morning', 'Good morning'], ['midday', 'Good day'], 
//      ['evening', 'Good evening'] ]
// 可用熟悉的for...of + 解构来遍历

接下来说一下Object.values(), 在以前使用for...ofObject.keys()的组合遍历对象时

let meals = {
  mealA: 'Breakfast',
  mealB: 'Lunch',
  mealC: 'Dinner'
};
for (let key of Object.keys(meals)) {
  let mealName = meals[key];
  // ... do something with mealName
  console.log(mealName);
}

我们需要使用meals[key]这样的方式获取到属性的值, 但是使用Object.values()的话我们直接就可以取到值了

let meals = {
  mealA: 'Breakfast',
  mealB: 'Lunch',
  mealC: 'Dinner'
};
for (let mealName of Object.values(meals)) {
  console.log(mealName);
}
// 'Breakfast' 'Lunch' 'Dinner'

多句嘴: JS对象中你不能保证对象属性的顺序, 别依靠遍历顺序写任何逻辑代码, 请使用数组或者Set代替

对象合并

以前对象合并我们一般会使用extend工具函数, 比如使用lodash里面的

 _.extend(target, [sources]) 

此外我们还会使用Object.assign(), 早期Redux应用写reducer合并对象时候全是Object.assign()方法。

但是出现了 spread 展开符合并简单就像呼吸一样简单了, 但是我们也要精通它的特性, 使用起来才游刃有余。

合并的规则

latter property wins

latter property wins

latter property wins

重要的事情说三遍, 记住这个规则就可以合并时候不会纠结谁覆盖谁了, 谁在后谁厉害, 比如

const max = {name: 'max', age: 27}
const evle = {name: 'evle', age: 27}

{...evle, ...max} // max在后面 所以当合并对象重名的时, max的属性的值会覆盖其他对象的

// => {name: "max", age: 27}

拷贝枚举属性

spread 这个操作符不能拷贝不可枚举属性

let person = {name: 'evle'}

Object.defineProperty(person, 'age', {
  enumerable: false, // Make the property non-enumerable
  value: 25
});
console.log(person['age']); // => 25

下面让我们试试

const clone = {
  ...person
};
console.log(clone);  // {name: "evle"}

实践证明, 不可枚举的属性是无法拷贝到的

拷贝对象(深浅拷贝?)

使用 ... 拷贝对象很简单

let clone = {...person}

但是使用 spread 操作符拷贝的对象只有自身的属性被拷贝了, 其内部嵌入的对象没有拷贝一份新的, 还仅仅是拷贝对象的引用而已, 因此属于 浅拷贝

const laptop = {
  name: 'MacBook Pro',
  screen: {
    size: 17,
    isRetina: true
  }
};
const laptopClone = {
  ...laptop
};

console.log(laptop === laptopClone);               // => false
console.log(laptop.screen === laptopClone.screen); // => true

那么如果想彻底拷贝一个新的出来怎么办, 包括它的子对象? 那就继续使用 ... 拷贝嵌套的内容

const laptopDeepClone = {
  ...laptop,
  screen: {
     ...laptop.screen
  }
};

console.log(laptop === laptopDeepClone);               // => false
console.log(laptop.screen === laptopDeepClone.screen); // => false

拷贝丢失

使用 ... 我们可以拷贝基本数据类型的属性, 也可以拷贝复杂数据类型比如对象, 但是无法拷贝函数, 会出现拷贝丢失的情况。

class Game {
  constructor(name) {
    this.name = name;
  }

  getMessage() {
    return `I like ${this.name}!`;
  }
}

const doom = new Game('Doom');
console.log(doom instanceof Game); // => true
console.log(doom.name);            // => "Doom"
console.log(doom.getMessage());    // => "I like Doom!"

const doomClone = {
  ...doom
};

console.log(doomClone instanceof Game); // => false
console.log(doomClone.name);            // => "Doom"
console.log(doomClone.getMessage());
// TypeError: doomClone.getMessage is not a function

getMessage() 丢失了, 有没有想过为什么拷贝不到函数?

因为doomClone是一个普通的JS plain object, 它是继承自Object.prototype的, 如果想拥有getMessage()方法的话要继承的是Game.prototype。所以要手动改变它prototype的指向。

const doomFullClone = {
  ...doom,
  __proto__: Game.prototype
};

console.log(doomFullClone instanceof Game); // => true
console.log(doomFullClone.name);            // => "Doom"
console.log(doomFullClone.getMessage());    // => "I like Doom!"

但文档中__proto__已经是个降级的东西了,以后可能不用了, 所以还是推荐以下方法 克隆一个class的方法

const doomFullClone = Object.assign(new Game(), doom);

console.log(doomFullClone instanceof Game); // => true
console.log(doomFullClone.name);            // => "Doom"
console.log(doomFullClone.getMessage());    // => "I like Doom!"

immutable 对象更新

immutable 数据结构不产生副作用, 当一个对象在多处都要共享的时候, 最怕的就是不小心被直接改了导致的副作用, 所以redux 之类各种状态管理的解决方案变得很有必要, 追踪改动是件很重要的事情。

在我们写程序时候也有一个好的实践是 使操作immutable, 这样的话即使很复杂的应用场景, 也不会出现意外原始变量被改的情况。

使用 spread 操作符就很方便的使操作immutable

const book = {
  name: 'JavaScript: The Definitive Guide',
  author: 'David Flanagan',
  edition: 5,
  year: 2008
};

这书第5版, 你想改成第6版用, 但又不想改动原始数据

const newerBook = {
  ...book,
  edition: 6,  // <----- Overwrites book.edition
  year: 2011   // <----- Overwrites book.year
};

拷贝基本数据类型会发生什么

一般都是用来拷贝对象和数组的, 好奇心作怪让我们试试拷贝基本类型的结果是什么?

const nothing = undefined;
const missingObject = null;
const two = 2;
const hello = 'hello';

console.log({ ...nothing });       // => { }
console.log({ ...missingObject }); // => { }
console.log({ ...two });           // => { }
console.log({ ...hello });         // => {0: "h", 1: "e", 2: "l", 3: "l", 4: "o"}

结论:别拷贝基本类型

给对象默认的值

有些对象都是运行时候生成的, 你也不知道最终参数是啥, 比如配置, 用户只要指定一下核心属性, 没指定的用默认值就好了, 这个太常见了在各种框架或者库中, 编写个multiline(str, config)实践一下这个常用手法。

multiline('Hello World!');
// => 'Hello Worl\nd!'

multiline('Hello World!', { width: 6 });
// => 'Hello \nWorld!'

multiline('Hello World!', { width: 6, newLine: '*' });
// => 'Hello *World!'

multiline('Hello World!', { width: 6, newLine: '*', indent: '_' });
// => '_Hello *_World!'
function multiline(str, config = {}) {
  const defaultConfig = {
    width: 10,
    newLine: '\n',
    indent: ''
  };
  const safeConfig = {
    ...defaultConfig,
    ...config
  };
  let result = '';
  // Implementation of multiline() using
  // safeConfig.width, safeConfig.newLine, safeConfig.indent
  // ...
  return result;
}

在合并对象时候 ...Object.assign()更优的地方 在于 它更新大的对象方便, 性能好, 因为是可以局部更新。

const box = {
  color: 'red',
  size: {
    width: 200, 
    height: 100 
  },
  items: ['pencil', 'notebook']
};

// 比如只想改size
const biggerBox = {
  ...box,
  size: {
    ...box.size,
    height: 200
  }
};
console.log(biggerBox);

对象的继承 (super call的使用)

我们现在有一个对象 myProto, 并包含一个方法propertyExists()

var myProto = {
  propertyExists: function(name) {
    return name in this;
  },
};

如果想继承这个myProto, 我们需要借助一个函数: Object.create()

var myNumbers = Object.create(myProto);
myNumbers['array'] = [1, 6, 7];
myNumbers.propertyExists('array'); // => true
myNumbers.propertyExists('collection'); // => false

myProto.isPrototypeOf(myNumbers); // true

myNumbers继承自myProto, 并且定义了自身属性array, 现在我们可以根据官方建议的另一个语义化更明确的函数 setPrototypeOf() 来指定prototype了。

var obj = {};

Object.setPrototypeOf(obj, myProto) // oo具有myProto的属性和方法

// 还有一种降级的使用方法(官方不推荐)
var myNumbers = {
  __proto__: myProto,  // 直接设置__proto__属性值
  array: [1, 6, 7],
};

继承后, 可以使用super来访问继承的属性

var calc = {
  numbers: null,
  sumElements() {
    return this.numbers.reduce(function(a, b) {
      return a + b;
    });
  },
};

var numbers = {
  __proto__: calc,
  numbers: [4, 6, 7],
  sumElements() {
    // Verify if numbers is not null or empty
    if (this.numbers == null || this.numbers.length === 0) {
      return 0;
    }
    return super.sumElements();
  },
};
numbers.sumElements(); // => 17

计算属性

计算属性就是动态属性, 即: 在写这个对象时候并不知道对象的属性叫什么

function prefix(prefStr, name) {
  return prefStr + '_' + name;
}
var object = {};
object[prefix('number', 'pi')] = 3.14;
object[prefix('bool', 'false')] = false;
object; // => { number_pi: 3.14, bool_false: false }

比如上面我们就动态生成了2个属性, 这就叫做动态属性, 现在ES标准给了我们更优雅的解决方案

function prefix(prefStr, name) {
  return prefStr + '_' + name;
}
var object = {
  [prefix('number', 'pi')]: 3.14,
  [prefix('bool', 'false')]: false,
};
object; // => { number_pi: 3.14, bool_false: false }

我们不必通过定义对象后再设置属性的方式添加动态属性, 可以在定义对象的时候添加动态属性。

添加动态属性我们掌握了, 也要学会解构动态属性, 静态属性解构很简单

const movie = { title: 'Heat' };

const { title } = movie;

title; // => 'Heat'

因为我们知道movie里面有个属性叫做title, 所以我们直接解出来title就好了, 但是对于动态属性, 我们不知道我们要解出来属性的名字, 我们只需要这样: 不需要知道属性名叫啥, 给它来个别名代替。

function greet(obj, nameProp) {
 // 配合别名 + 默认值 代码数量比以前不知道少了多少行!
 const { [nameProp]: name = 'Unknown' } = obj;
 return `Hello, ${name}!`;
}

greet({ name: 'Batman' }, 'name'); // => 'Hello, Batman!'
greet({ }, 'name'); // => 'Hello, Unknown!'

可迭代对象

回归spread的本质, ...能展开对象和数组其实是依靠迭代协议, ...使用了迭代协议去遍历了对象或者数组, 使用迭代协议要求对象必须包含一个特殊属性Symbol.iterator并且它的值是一个函数, 返回一个迭代器, 那Symbol.iterator中的Symbol又是什么?

对象中出现Symbol的意义

js对象可以定义3种属性:

  • 键值对 {name: value}
  • Getters {get name(){...}} 和 Setters {set name(val){...}}
  • Symbol

Symbol的出现是很有必要的, 举例

const obj = {
    name: 'evle',
    age: 15
}

Object.keys(obj) // ['name', 'age']

现在obj对象有2个属性: nameage, 如果我们现在想为对象添加一个hi属性, 但是又不想影响这个对象也就是:即使我加了hi属性, 使用Object.keys得到的结果仍然是['name', 'age'], 为了解决这个问题, ECMAScript 委员会 (TC39) 提出: 创建一种新的数据类型Symbol来解决这个问题。

Symbol定义在对象中有2个特性:

  1. 不会对原对象产生副作用, 比如之前提到的不会影响Object.keys遍历结果
  2. 防止重名, 如果和prototype上的方法重名的话, 会调用自己的, 也不影响prototype

Symbol的作用不仅如此, 这里只讨论和对象相关的特性。

迭代器和迭代协议

ES6提出了 Iterators 和 Iterables 的概念, 迭代器(iterator) 在JS中的含义是: Once (仅一次), 其实很好理解, 就相当于for...of循环中仅遍历了一次就停下来了。迭代器(iterator)定义我们如何从一个对象中获取值。

interface Iterable {
  [Symbol.iterator]() {
    //...
    return Iterator;
  }
}

Iterable 协议规定了: 当你想将一个对象变为可迭代对象时, 应为对象定义一个 [Symbol.iterator]属性, 该属性的值返回一个 Iterator

interface Iterator {
  next() {
     //...
     return {
        value: <value>,
        done: <boolean>
     };
  };
}

Iterator 协议规定了该对象必须包含next()方法, 并且返回一个对象里面包含valuedone两个属性。

根据上面的协议规定, 下面我们来实现一个迭代协议

// 1. 先定义一个对象 foo
const foo = {
    // 2. 定义 Symbol.iterator属性
    [Symbol.iterator]: () => ({
        items: ['h', 'e', 'l', 'l', 'o'],
        // 3. 实现next方法
        next(){
            return {
                done: this.items === 0,     // 返回true和false的表达式 决定什么时候停止
                value: this.items.shift()   // 当前迭代的值 
            }
        }
    })
}

根据迭代协议实现了迭代对象后, 我们可以遍历一下它, 前面说了遍历迭代协议可以使用for...of...操作符

for (let word of foo) {
  console.log(word)
    // 'h'
    // 'e' 
    // 'l' 
    // 'l'
    // 'o'
}

console.log([...foo]) // ['h', 'e', 'l', 'l', 'o']

一说起遍历, 我们已经有了for...of...这两个遍历神器了, 为什么还需要有迭代器这种东西?

答:有些东西还是没法遍历, 比如:

class Users {
    constructor(users){
        this.users = users;
    }
    get(){
        return this.users;
    }
}

const allUsers = new Users([{name: 'evle'}, {name:'max'}])

for(const user of allUsers){
    console.log(user)
}

[...allUsers];

当我们觉得上面这种解构应当是可以遍历的时候, JS没那么智能, 只能以异常回应我们 TypeError: xx is no iterable

但是Symbol的出现让我们可以自定义它是可以被遍历的:

class Users{
    constructor(users){
        this.users = users;
    }
    [Symbol.iterator](){
        let i = 0;
        let user = this.users;
        return {
            next(){
                if(i < users.length){
                    return {done: false, value: users[i++]}
                }
                return {done:true}
            }
        }
    }
}

然后我们就可以使用for...of或者...来遍历users

for(const user of allUsers){
    console.log(user.name)
}

[...allUsers]

应用迭代协议

让我们做一个练习, 给对象应用一个迭代协议: 比如我们只想使用 ...获取对象的keys, 不需要获取values

var object = {
   number1: 14,
   number2: 15,
   string1: 'hello',
   string2: 'world',
   [Symbol.iterator]: function *() {
     var own = Object.getOwnPropertyNames(this),
       prop;
     while(prop = own.pop()) {
       yield prop;
     }
   }
}

[...object]; // => ['number1', 'number2', 'string1', 'string2']

字符串迭代器

想让字符串变得可迭代比对象容易的多

const str = 'hi';
const iterator = str[Symbol.iterator]();
iterator.toString(); // => '[object String Iterator]'
iterator.next();     // => { value: 'h', done: false }
iterator.next();     // => { value: 'i', done: false }
iterator.next();     // => { value: undefined, done: true }
[...str];            // => ['h', 'i']

使用str[Symbol.iterator]()将str转为一个迭代器, 可以通过next()来一项一项迭代str的内容, 还可以使用...访问str的值。

迭代对象的内容希望大家多动手敲敲, 悟一悟。

既然都看到这里了, 点个赞吧 💗