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()
的返回是属性的key
和value
:
[[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...of
和Object.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提供的values
和entries
就是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...of
与Object.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个属性: name
和 age
, 如果我们现在想为对象添加一个hi
属性, 但是又不想影响这个对象也就是:即使我加了hi
属性, 使用Object.keys得到的结果仍然是['name', 'age']
, 为了解决这个问题, ECMAScript 委员会 (TC39) 提出: 创建一种新的数据类型Symbol
来解决这个问题。
Symbol定义在对象中有2个特性:
- 不会对原对象产生副作用, 比如之前提到的不会影响Object.keys遍历结果
- 防止重名, 如果和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()
方法, 并且返回一个对象里面包含value
和done
两个属性。
根据上面的协议规定, 下面我们来实现一个迭代协议
// 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的值。
迭代对象的内容希望大家多动手敲敲, 悟一悟。
既然都看到这里了, 点个赞吧 💗