现代 JavaScript 从头再来(六):对象

0 阅读10分钟

对象

创建对象

有两种方式创建对象:

let obj = new Object(); // 构造函数语法
let obj1 = {}; // 字面量语法

属性

对象存储属性(键值对),其中:

  • 属性的键必须是字符串或者 symbol(通常是字符串)
  • 值可以是任何类型

访问属性:

  • 点符号: obj.property
  • 方括号 obj["property"],方括号允许从变量中获取键

其他操作:

  • 删除属性:delete obj.prop

  • 检查是否存在给定键的属性:"key" in obj

    let obj = {
    	name: undefined
    }
    console.log(obj.name); // undefined
    console.log("name" in obj); // true
    
  • 遍历对象:for(let key in obj) 循环

属性值简写:

function makeUser(name, age) {
  return {
    name, // 与 name: name 相同
    age,  // 与 age: age 相同
    sex: 'male'
  };
}

对象的顺序

整数属性会被进行排序,其他属性则按照创建的顺序显示

let codes = {
  "500": "error",
  "200": "ok",
  "404": "not found",
  "101": "switch"
}

整数属性是指与一个整数进行相互转换而不发生变化的字符串

对象引用和复制

对象与原始类型的根本区别之一是,对象是通过引用存储和复制

赋值了对象的变量存储的是对象在内存中的地址(引用)

// 原始类型复制
// 两个独立的变量:fruit 和 phone,两者都存储着字符串 'apple'
let fruit = 'apple';
let phone = fruit;

// 对象复制
// 变量 man 中存储的实际上是对象 { name: 'Tom' } 的地址
let man = { name: 'Tom' };
let woman = man; // 将该对象的地址赋值给变量 woman,两个变量都指向同一个对象

通过 const 声明的对象是可以被修改的:

const fruit = {
  name: 'apple'
};
fruit.name = 'orange';

console.log(fruit.name); // orange

通过引用来比较

两个变量都引用同一对象:

let obj = {};
let obj1 = obj;

console.log(obj === obj1); // true

两个独立的对象:

let obj = {};
let obj1 = {};

console.log(obj === obj1); // false

浅拷贝和深拷贝

假如有一个对象被赋值给了一个变量,直接将这个变量赋值给另一个变量,由于变量存储的是对象的引用,改变一个变量的值另一个变量也会跟着改变

浅拷贝

要复制对象可以创建一个新对象,通过遍历已有对象的属性,并在原始类型值的层面复制它们:

let man = {
  name: 'Tom',
  age: 3
};

let woman = {};
for (let key in man) {
  woman[key] = man[key];
}

woman.name = 'Jerry';

// man 和 woman 互相独立
console.log(man.name); // Tom
console.log(woman.name); // Jerry

也可以使用 Object.assign() 方法,将所有源对象的属性拷贝到目标对象中:

// 拷贝 srcObj1, srcObj2 的属性到 targetObj 中并返回 targetObj
Object.assign(targetObj, srcObj1, srcObj2, ...);

如果被拷贝的属性名已经存在则会覆盖:

let man = { name: 'Tom' };
let woman = Object.assign(man, { name: 'Jerry' }, { age: 3 });

console.log(man); // { name: 'Jerry', age: 3 }
console.log(woman); // { name: 'Jerry', age: 3 }
console.log(man === woman); // true

可以用 Object.assign 代替 for..in 循环来进行简单克隆:

let man = { name: 'Tom', age: 3 };
let woman = Object.assign({}, man);

man.woman = 'Jerry';

console.log(man); // { name: 'Tom', age: 3 }
console.log(woman); // { name: 'Jerry', age: 3 }
深拷贝

对象的属性也可以是对其他对象的引用,如果一个对象的属性不全是原始类型,那么想要复制一个对立的对象,通过之前的方式已经行不通了

需要递归遍历对象的属性,如果是对象,则要复制它的结构

let man = {
  name: 'Tom',
  age: 3,
  friend: {
    name: 'Jerry',
    age: 2
  }
};

// 如果不使用深拷贝
let cloneMan = Object.assign({}, man);
cloneMan.friend.name = 'Jack';
console.log(man.friend.name); // 'Jack'
console.log(cloneMan.friend.name); // 'Jack'
// 简单实现深拷贝
function deepClone(obj) {
  let newObj = {};
  for (let key in obj) {
    // 判断属性值是否为对象
    if (typeof obj[key] === 'object') {
      newObj[key] = deepClone(obj[key]);
    } else {
      // 将原始类型直接复制
      newObj[key] = obj[key];
    }
  }
  return newObj;
}

let cloneMan = deepClone(man);
cloneMan.friend.name = 'Jack';

console.log(man.friend.name); // Jerry
console.log(cloneMan.friend.name); // Jack

垃圾回收

js 中的垃圾回收是自动完成的,不能强制执行或是阻止执行

可达性

js 中主要的内存管理概念:可达性(Reachability)

可达值是指那些以某种方式可访问或可用的值,它们被存储在内存中:

  • 当前执行的函数,它的局部变量和参数
  • 当前嵌套调用链上的其他函数、它们的局部变量和参数
  • 全局变量
  • (还有一些其他的,内部实现)

这些值被称作 根(roots)

如果一个值可以从根通过引用或者引用链进行访问,则认为该值是可达的

let man = {
  name: 'Tom'
};

// 如果 man 的值被重写了,man 对 { name: 'Tom'} 对象的引用就没了
man = null; 

内部算法

js 引擎中有一个垃圾回收器在后台执行,它监控着所有对象的状态,并删除掉那些已经不可达的

垃圾回收的基本算法被称为 mark-and-sweep

垃圾回收器会定期执行以下“垃圾回收”步骤:

  • 标记所有的

  • 遍历并“标记”来自它们的所有引用

  • 遍历标记的对象并标记它们的引用。所有被遍历到的对象都会被记住,以免将来再次遍历到同一个对象

……如此操作,直到所有可达的(从根部)引用都被访问到,没有被标记的对象都会被删除

当对象是可达状态时,它一定是存在于内存中的

this

作为对象属性的函数称为方法

let man = {
  name: 'Tom',
  age: 3,
  eat: function () {
    console.log('eat mouse');
  },
  
  // 也可以简写,推荐
  sayHi() {
  	console.log('hello');
  }
};

this 的值是在代码运行时计算出来的,它取决于代码上下文,通过点符号被作为方法调用,this 的值就是点符号前的对象

let man = {
  name: 'Tom',
  age: 3,
  sayName() {
    console.log(this.name); // this 就是当前调用 sayName 方法的 man
  }
};

man.sayName();

在没有对象的情况下:

严格模式:thisundefined

非严格模式:this 将会是 全局对象(浏览器中为 window)

箭头函数中的 this

箭头函数没有自己的 this,如果在箭头函数中引用 this,this 的值取决于外部非箭头函数:

let man = {
  name: 'Tom',
  sayName: () => {
    console.log(this.name); // 严格模式下为 undefined
  }
};
man.sayName();

构造器和操作符 new

构造器约定以大写字母开头并只能通过 new 操作符创建,**除了箭头函数 **任何函数都可以通过 new 创建,主要用于重复创建对象

function Man(name, age) {
  this.name = name;
  this.age = age;
}

let tom = new Man('Tom', 3);

当一个函数被使用 new 操作符执行时,它按照以下步骤:

  1. 一个新的空对象被创建并分配给 this
  2. 函数体执行。通常它会修改 this,为其添加新的属性
  3. 返回 this 的值
function Man(name, age) {
  // this = {};(隐式创建)

  // 添加属性到 this
  this.name = name;
  this.age = age;

  // return this;(隐式返回)
}

如果没有参数,可以省略 new 后的括号:

let date = new Date;

在一个函数内部,可以使用 new.target 属性来检查它是否被使用 new 进行调用了:

function Man(name) {
  if (!new.target) {
    return new Man(name);
  }

  this.name = name;
}

let man = Man('Tom');
console.log(man.name); // 'Tom'

不建议省略 new 关键字

如果一个函数返回一个对象,那么 new 返回那个对象而不是 this

let obj = {};
function A() {
  return obj;
}
function B() {
  return obj;
}

let a = new A();
let b = new B();

console.log(a == b); // true

new function() {}

// 创建一个函数并立即使用 new 调用
let man = new (function () {
  this.name = 'Tom';
  this.age = 3;
})();

这个构造函数不能被再次调用,因为它不保存在任何地方,只是被创建和调用

可选链 ?.

可选链 ?. 不是一个运算符,而是一个特殊的语法结构,用于安全地访问嵌套对象属性

有三种语法形式:

  1. obj?.prop —— 如果 obj 存在则返回 obj.prop,否则返回 undefined
  2. obj?.[prop] —— 如果 obj 存在则返回 obj[prop],否则返回 undefined
  3. obj.method?.() —— 如果 obj.method 存在则调用 obj.method(),否则返回 undefined
let user = {
  name: 'Jack',
  address: {
    province: 'province',
    'local-street': 'local street'
  },
  sayHi() {
    console.log('hi');
  }
};

console.log(user.address?.province); // 'province'
console.log(user.address?.['local-street']); // 'local street'
user.sayHi?.(); // 'hi'

// 以前的方式
user.address && user.address.province

symbol

只有两种原始类型可以用作对象属性键:

  • 字符串类型
  • symbol 类型

如果使用了别的原始类型作为对象属性,会被自动转换为字符串,但 symbol 类型不会:

let obj = {
  13: 'number', // obj['13']
  true: 'boolean' // obj['true']
};

let id = Symbol('id');
alert(id); // Error: Cannot convert a Symbol value to a string

alert(id.toString()); // 'Symbol(id)'

// 或者获取 symbol.description 属性,只显示描述
alert(id.description); // 'id'

symbol 是带有可选描述的“原始唯一值”,可以通过 Symbol() 创建:

let id = Symbol();

let id1 = Symbol('id'); // id 是可选的
let id2 = Symbol('id');

console.log(id1 === id2); // false

“隐藏”属性

如果要使用第三方库的对象,添加或修改属性,为了不影响库里面的东西,可以使用 symbol 给对象添加”隐藏“属性:

// 如果这个 user 是第三方库暴露出来的对象
user = { name: 'Tom' };

// 通过 symbol 这种方式是不会对 user 里面的属性产生影响的
let name = Symbol('name');
user[name] = 'Jerry';

// 但如果是这种方式就会直接修改 user 里面的 name 了
// user.name = 'Jerry'

console.log(user); // { name: 'Tom', [Symbol(name)]: 'Jerry' }

如果要在对象字面量 {...} 中使用 symbol,则需要使用方括号把它括起来:

let id = Symbol("id");

let user = {
  name: "Tom",
  [id]: 1
};

for...in 和 Object.keys() 不会遍历 symbol 属性

但是 Object.assign() 会同时复制字符串和 symbol 属性:

let id = Symbol("id");
let user = {
  [id]: 123
};

let clone = Object.assign({}, user);

alert( clone[id] ); // 123

全局 symbol

通常所有的 symbol 都是不同的,即使他们有相同的名字。但有时需要相同名字的 symbol 具有相同的实体,就需要使用全局 symbol 注册表

使用 Symbol.for(key) 从注册表中读取(不存在则创建)symbol:

// 从全局注册表中读取
let id = Symbol.for('id'); // 如果该 symbol 不存在,则创建它

let id2 = Symbol.for('id'); // 再次读取

console.log(id === id2); // true

相反,使用 Symbol.keyFor(sym) 通过全局 symbol 返回一个名字:

let sym = Symbol.for('id'); // 通过 name 获取 symbol

let symName = Symbol.keyFor(sym); // 通过 symbol 获取 name

console.log(sym); // Symbol(id)
console.log(symName); // id

系统 symbol

js 使用了许多系统 symbol,可以使用 Symbol.* 访问,可以使用它们来改变一些内建行为如使用 Symbol.iterator 来进行 迭代 操作,使用 Symbol.toPrimitive 来设置 对象原始值的转换 等等

有一个内建方法 Object.getOwnPropertySymbols(obj) 可以获取所有的 symbol

还有一个 Reflect.ownKeys(obj) 方法可以返回一个对象的 所有 键,包括 symbol

对象类型转换

对象在参与运算时会被自动转换为原始值,然后对这些原始值进行运算,并得到运算结果(也是一个原始值)

有三种转换规则,也被成为 "hint":

  • ”string“:对象到字符串的转换
  • ”number“:对象到数字的转换
  • ”default“:当运算符“不确定”期望值的类型时

二元加法 + 可用于字符串(连接),也可以用于数字(相加),“不确定”期望值,使用 "default" hint

如果对象被用于与字符串、数字或 symbol 进行 == 比较,这时到底应该进行哪种转换也不是很明确,因此使用 "default" hint

<> 这种比较运算符,虽然和二元加一样也可以同时用于字符串和数字,但是他们使用 “number” hint,而不是 “default”(历史原因)

为了进行转换,js 会尝试查找并调用三个对象方法:

  • Symbol.toPrimitive(hint),如果该方法存在
  • 否则,如果 hint 是 "string",尝试调用 obj.toString()obj.valueOf(),优先调用 obj.toString(),若两种方法都不存在
  • 否则,如果 hint 是 "number",尝试调用 obj.valueOf()obj.toString(),优先调用 valueOf 方法

这些方法必须返回一个原始值,而不是对象

let man = {
  name: 'Tom',
  age: 3,
  [Symbol.toPrimitive](hint) {
    console.log('hint:', hint);
    return hint === 'string' ? this.name : this.age;
  }
};

console.log(String(man)); // hint: string Tom
console.log(+man); // hint: number 3
console.log(man + 1); // hint: default 4

默认情况下,普通对象具有 toStringvalueOf 方法:

  • toString 方法返回一个字符串 "[object Object]"
  • valueOf 方法返回对象自身
let man = {
  name: 'Tom',
  age: 3,

  toString() {
    return this.name;
  },

  valueOf() {
    return this.age;
  }
};

console.log(String(man)); // Tom
console.log(+man); // 3,
console.log(man + 1); // 4

实际开发通常只实现 obj.toString() 作为字符串转换的“全能”方法就够了:

let man = {
  name: 'Tom',

  toString() {
    return this.name;
  }
};

console.log(String(man)); // 'Tom'
console.log(+man); // NaN,首先尝试将对象转换为原始值,然后再参与运算
console.log(man + 250); // 'Tom250'