Typescript 中的对象剩余和扩展属性

2020-11-16

Typescript 2.1 实现了在 ES2018 中标准化的对象剩余和扩展属性(Object Rest And Spread Properties)。所以你可以以类型安全的方式使用剩余和扩展属性,编译器最终会将这两个特性降级为 ES3 代码。

对象剩余属性

假设你声明了一个简单的对象字面量,包含三个属性:

const marius = {
  name: "Marius Schulz",
  website: "https://mariusschulz.com/",
  twitterHandle: "@mariusschulz",
};

你可以使用 ES2015 的解构赋值语法将这些属性的值保存在几个局部变量中。Typescript 会正确地推断每个变量的类型:

const { name, website, twitterHandle } = marius;

name; // Type string
website; // Type string
twitterHandle; // Type string

上面的用法完全正确,但并没有什么新东西。现在我们将解构赋值和对象剩余属性这两个语言特性结合起来使用:你除了可以解构出你感兴趣的一组属性,你还可以通过 ... 语法将其他的属性解构到一个剩余的元素中:

const { twitterHandle, ...rest } = marius;

twitterHandle; // Type string
rest; // Type { name: string; website: string; }

Typescript 能够正确判断出所有局部变量的类型。twitterHandle 是一个普通的字符串类型,rest 变量是一个对象,包含了剩下的两个没有被单独解构的属性。

对象扩展属性

假设你想使用 fetch() API 来发送 HTTP 请求。它接受两个参数:一个 URL 和一个选项对象,你可以通过这个选项对象给请求添加其他自定义的设置。

在你的应用中,你可能封装了对 fetch() 的调用,并且提供了默认选项,同时支持用特定的设置来覆盖默认选项。这些选项对象大概像下面这样:

const defaultOptions = {
  method: "GET",
  credentials: "same-origin",
};

const requestOptions = {
  method: "POST",
  redirect: "follow",
};

使用对象扩展操作,你可以将这两个对象合并到一个新的对象中,然后传递给 fetch() 方法:

// Type { method: string; redirect: string; credentials: string; }
const options = {
  ...defaultOptions,
  ...requestOptions,
};

对象扩展操作会创建一个新对象,按照从左到右的顺序,先从 defaultOptions 中复制所有的属性,然后从 requestOptions 复制所有的属性:

console.log(options);
// {
//   method: "POST",
//   credentials: "same-origin",
//   redirect: "follow"
// }

注意顺序是重要的。如果一个属性在两个对象中都出现了,后一个会覆盖前一个。这就是为什么我们把 defaultOptions 放到 requestOptions 前面,如果顺序相反,那么没有可能覆盖默认的值。

当然,Typescript 理解这种顺序。因此,当多个扩展对象定义了同一个属性,这个属性的类型是由最后一个包含该属性的对象决定的,因为它会覆盖前面所有对这个属性的赋值:

const obj1 = { prop: 42 };
const obj2 = { prop: "Hello World" };

const result1 = { ...obj1, ...obj2 }; // Type { prop: string }
const result2 = { ...obj2, ...obj1 }; // Type { prop: number }

简单说:最后一次赋值胜出。

对象的浅拷贝

对象扩展操作可以用于对象的浅拷贝。假如你想创建一个和已存在的对象属性完全一致的 todo 对象,使用对象扩展,就是一行代码的事:

const todo = {
  text: "Water the flowers",
  completed: false,
  tags: ["garden"],
};

const shallowCopy = { ...todo };

可以看到,你的确创建了一个新对象,并且复制了所有的属性:

console.log(todo === shallowCopy);
// false

console.log(shallowCopy);
// {
//   text: "Water the flowers",
//   completed: false,
//   tags: ["garden"]
// }

现在可以修改 text 属性而不会影响到原始的对象:

shallowCopy.text = "Mow the lawn";

console.log(shallowCopy);
// {
//   text: "Mow the lawn",
//   completed: false,
//   tags: ["garden"]
// }

console.log(todo);
// {
//   text: "Water the flowers",
//   completed: false,
//   tags: ["garden"]
// }

然而,新的 todo 对象拥有和第一个对象一样的 tags 引用。所以这里并没有深拷贝。因此,修改这个数组会对两个对象都产生影响:

shallowCopy.tags.push("weekend");

console.log(shallowCopy);
// {
//   text: "Mow the lawn",
//   completed: false,
//   tags: ["garden", "weekend"]
// }

console.log(todo);
// {
//   text: "Water the flowers",
//   completed: false,
//   tags: ["garden", "weekend"]
// }

如果你想对一个可序列化的对象进行深拷贝,你可以考虑用 JSON.parse(JSON.stringify(obj)) 或其他办法。就像 Obeject.assign(),对象扩展只会复制属性的值,所以如果不注意引用类型的属性,可能会产生意料之外的行为。

值得一提的是,上面所有的代码片段都不包含任何的 Typescript 类型标记,或其他 Typescript 特定的构造结构。所有代码都只是普通的使用了对象剩余或扩展属性的 Javascript 代码。类型推断居功至伟!

郑超的独立博客