前言
在《初中级前端 JavaScript 自测清单 - 1》部分中,和大家简单过了一遍 JavaScript 基础知识,没看过的朋友可以回顾一下😁
本系列文章是我在我们团队内部的“现代 JavaScript 突击队”,第一期学习内容为《现代 JavaScript 教程》系列的第二部分输出内容,希望这份自测清单,能够帮助大家巩固知识,温故知新。
本部分内容,以 JavaScript 对象为主,大致包括以下内容:
一、对象
JavaScript 有八种数据额类型,有七种原始类型,它们值只包含一种类型(字符串,数字或其他),而对象是用来保存键值对和更复杂实体。 我们可以通过使用带有可选属性列表的花括号 **{...}**
来创建对象,一个属性就是一个键值对 {"key" : "value"}
,其中键( key
)是一个字符串(或称属性名),值( value
)可以是任何类型。
1. 创建对象
我们可以使用 2 种方式来创建一个新对象:
// 1. 通过“构造函数”创建 let user = new Object(); // 2. 通过“字面量”创建 let user = {};
2. 对象文本和属性
创建对象时,可以初始化对象的一些属性:
let user = { name : 'leo', age : 18 }
然后可以对该对象进行属性对增删改查操作:
// 增加属性 user.addr = "China"; // user => {name: "leo", age: 18, addr: "China"} // 删除属性 delete user.addr // user => {name: "leo", age: 18} // 修改属性 user.age = 20; // user => {name: "leo", age: 20} // 查找属性 user.age; // 20
3. 方括号的使用
当然对象的键( key
)也可以是多词属性,但必须加引号,使用的时候,必须使用方括号( []
)读取:
let user = { name : 'leo', "my interest" : ["coding", "football", "cycling"] } user["my interest"]; // ["coding", "football", "cycling"] delete user["my interest"];
我们也可以在方括号中使用变量,来获取属性值:
let key = "name"; let user = { name : "leo", age : 18 } // ok user[key]; // "leo" user[key] = "pingan"; // error user.key; // undefined
4. 计算属性
创建对象时,可以在对象字面量中使用方括号,即 计算属性 :
let key = "name"; let inputKey = prompt("请输入key", "age"); let user = { [key] : "leo", [inputKey] : 18 } // 当用户在 prompt 上输入 "age" 时,user 变成下面样子: // {name: "leo", age: 18}
当然,计算属性也可以是表达式:
let key = "name"; let user = { ["my_" + key] : "leo" } user["my_" + key]; // "leo"
5. 属性名简写
实际开发中,可以将相同的属性名和属性值简写成更短的语法:
// 原本书写方式 let getUser = function(name, age){ // ... return { name: name, age: age } } // 简写方式 let getUser = function(name, age){ // ... return { name, age } }
也可以混用:
// 原本书写方式 let getUser = function(name, age){ // ... return { name: name, age: 18 } } // 简写方式 let getUser = function(name, age){ // ... return { name, age: 18 } }
6. 对象属性存在性检测
6.1 使用 in 关键字
该方法可以判断对象的自有属性和继承来的属性是否存在。
let user = {name: "leo"}; "name" in user; //true,自有属性存在 "age" in user; //false "toString" in user; //true,是一个继承属性
6.2使用对象的 hasOwnProperty() 方法。
该方法只能判断自有属性是否存在,对于继承属性会返回 false
。
let user = {name: "leo"}; user.hasOwnProperty("name"); //true,自有属性中有 name user.hasOwnProperty("age"); //false,自有属性中不存在 age user.hasOwnProperty("toString"); //false,这是一个继承属性,但不是自有属性
6.3 用 undefined 判断
该方法可以判断对象的自有属性和继承属性。
let user = {name: "leo"}; user.name !== undefined; // true user.age !== undefined; // false user.toString !== undefined // true
该方法存在一个问题,如果属性的值就是 undefined
的话,该方法不能返回想要的结果:
let user = {name: undefined}; user.name !== undefined; // false,属性存在,但值是undefined user.age !== undefined; // false user.toString !== undefined; // true 复制代码
6.4 在条件语句中直接判断
let user = {}; if(user.name) user.name = "pingan"; //如果 name 是 undefined, null, false, " ", 0 或 NaN,它将保持不变 user; // {}
7. 对象循环遍历
当我们需要遍历对象中每一个属性,可以使用 for...in
语句来实现
7.1 for...in 循环
for...in
语句以任意顺序遍历一个对象的除 Symbol
以外的可枚举属性。 注意 : for...in
不应该应用在一个数组,其中索引顺序很重要。
let user = { name : "leo", age : 18 } for(let k in user){ console.log(k, user[k]); } // name leo // age 18
7.2 ES7 新增方法
ES7中新增加的 Object.values()
和Object.entries()
与之前的Object.keys()
类似,返回数组类型。
1. Object.keys()
返回一个数组,成员是参数对象自身的(不含继承的)所有可遍历属性的健名。
let user = { name: "leo", age: 18}; Object.keys(user); // ["name", "age"]
2. Object.values()
返回一个数组,成员是参数对象自身的(不含继承的)所有可遍历属性的键值。
let user = { name: "leo", age: 18}; Object.values(user); // ["leo", 18]
如果参数不是对象,则返回空数组:
Object.values(10); // [] Object.values(true); // []
3. Object.entries()
返回一个数组,成员是参数对象自身的(不含继承的)所有可遍历属性的键值对数组。
let user = { name: "leo", age: 18}; Object.entries(user); // [["name","leo"],["age",18]]
手动实现Object.entries()
方法:
// Generator函数实现: function* entries(obj){ for (let k of Object.keys(obj)){ yield [k ,obj[k]]; } } // 非Generator函数实现: function entries (obj){ let arr = []; for(let k of Object.keys(obj)){ arr.push([k, obj[k]]); } return arr; }
4. Object.getOwnPropertyNames(Obj)
该方法返回一个数组,它包含了对象 Obj
所有拥有的属性(无论是否可枚举)的名称。
let user = { name: "leo", age: 18}; Object.getOwnPropertyNames(user); // ["name", "age"]
二、对象拷贝
1. 赋值操作
首先回顾下基本数据类型和引用数据类型:
- 基本类型
概念:基本类型值在内存中占据固定大小,保存在栈内存
中(不包含闭包
中的变量)。 常见包括:undefined,null,Boolean,String,Number,Symbol
- 引用类型
概念:引用类型的值是对象,保存在堆内存
中。而栈内存存储的是对象的变量标识符以及对象在堆内存中的存储地址(引用),引用数据类型在栈中存储了指针,该指针指向堆中该实体的起始地址。当解释器寻找引用值时,会首先检索其在栈中的地址,取得地址后从堆中获得实体。 常见包括:Object,Array,Date,Function,RegExp等
1.1 基本数据类型赋值
在栈内存中的数据发生数据变化的时候,系统会自动为新的变量分配一个新的之值在栈内存中,两个变量相互独立,互不影响的。
let user = "leo"; let user1 = user; user1 = "pingan"; console.log(user); // "leo" console.log(user1); // "pingan"
1.2 引用数据类型赋值
在 JavaScript 中,变量不存储对象本身,而是存储其“内存中的地址”,换句话说就是存储对其的“引用”。 如下面 leo
变量只是保存对user
对象对应引用:
let user = { name: "leo", age: 18}; let leo = user;
其他变量也可以引用 user
对象:
let leo1 = user; let leo2 = user;
但是由于变量保存的是引用,所以当我们修改变量 leo
\ leo1
\ leo2
这些值时,也会改动到引用对象user
,但当 user
修改,则其他引用该对象的变量,值都会发生变化:
leo.name = "pingan"; console.log(leo); // {name: "pingan", age: 18} console.log(leo1); // {name: "pingan", age: 18} console.log(leo2); // {name: "pingan", age: 18} console.log(user); // {name: "pingan", age: 18} user.name = "pingan8787"; console.log(leo); // {name: "pingan8787", age: 18} console.log(leo1); // {name: "pingan8787", age: 18} console.log(leo2); // {name: "pingan8787", age: 18} console.log(user); // {name: "pingan8787", age: 18}
这个过程中涉及变量地址指针指向问题,这里暂时不展开讨论,有兴趣的朋友可以网上查阅相关资料。
2. 对象比较
当两个变量引用同一个对象时,它们无论是 ==
还是 ===
都会返回 true
。
let user = { name: "leo", age: 18}; let leo = user; let leo1 = user; leo == leo1; // true leo === leo1; // true leo == user; // true leo === user; // true
但如果两个变量是空对象 {}
,则不相等:
let leo1 = {}; let leo2 = {}; leo1 == leo2; // false leo1 === leo2; // false
3. 浅拷贝
3.1 概念
概念:新的对象复制已有对象中非对象属性的值和对象属性的引用。也可以理解为:一个新的对象直接拷贝已存在的对象的对象属性的引用,即浅拷贝。
浅拷贝只对第一层属性进行了拷贝,当第一层的属性值是基本数据类型时,新的对象和原对象互不影响,但是如果第一层的属性值是复杂数据类型,那么新对象和原对象的属性值其指向的是同一块内存地址。
通过示例代码演示没有使用浅拷贝场景:
// 示例1 对象原始拷贝 let user = { name: "leo", skill: { JavaScript: 90, CSS: 80}}; let leo = user; leo.name = "leo1"; leo.skill.CSS = 90; console.log(leo.name); // "leo1" console.log(user.name); // "leo1" console.log(leo.skill.CSS); // 90 console.log(user.skill.CSS);// 90 // 示例2 数组原始拷贝 let user = ["leo", "pingan", {name: "pingan8787"}]; let leo = user; leo[0] = "pingan888"; leo[2]["name"] = "pingan999"; console.log(leo[0]); // "pingan888" console.log(user[0]); // "pingan888" console.log(leo[2]["name"]); // "pingan999" console.log(user[2]["name"]); // "pingan999"
从上面示例代码可以看出: 由于对象被直接拷贝,相当于拷贝 引用数据类型 ,所以在新对象修改任何值时,都会改动到源数据。
接下来实现浅拷贝,对比以下。
3.2 实现浅拷贝
1. Object.assign()
语法: Object.assign(target, ...sources)
ES6中拷贝对象的方法,接受的第一个参数是拷贝的目标target,剩下的参数是拷贝的源对象sources(可以是多个)。 详细介绍,可以阅读文档《MDN Object.assign》。
// 示例1 对象浅拷贝 let user = { name: "leo", skill: { JavaScript: 90, CSS: 80}}; let leo = Object.assign({}, user); leo.name = "leo1"; leo.skill.CSS = 90; console.log(leo.name); // "leo1" ⚠️ 差异! console.log(user.name); // "leo" ⚠️ 差异! console.log(leo.skill.CSS); // 90 console.log(user.skill.CSS);// 90 // 示例2 数组浅拷贝 let user = ["leo", "pingan", {name: "pingan8787"}]; let leo = Object.assign({}, user); leo[0] = "pingan888"; leo[2]["name"] = "pingan999"; console.log(leo[0]); // "pingan888" ⚠️ 差异! console.log(user[0]); // "leo" ⚠️ 差异! console.log(leo[2]["name"]); // "pingan999" console.log(user[2]["name"]); // "pingan999"
从打印结果可以看出,浅拷贝只是在根属性(对象的第一层级)创建了一个新的对象,但是对于属性的值是对象的话只会拷贝一份相同的内存地址。
Object.assign()
使用注意:
- 只拷贝源对象的自身属性(不拷贝继承属性);
- 不会拷贝对象不可枚举的属性;
- 属性名为
Symbol
值的属性,可以被Object.assign拷贝; undefined
和null
无法转成对象,它们不能作为Object.assign
参数,但是可以作为源对象。
Object.assign(undefined); // 报错 Object.assign(null); // 报错 Object.assign({}, undefined); // {} Object.assign({}, null); // {} let user = {name: "leo"}; Object.assign(user, undefined) === user; // true Object.assign(user, null) === user; // true
2. Array.prototype.slice()
语法: arr.slice([begin[, end]])
slice()
方法返回一个新的数组对象,这一对象是一个由 begin
和 end
决定的原数组的浅拷贝(包括 begin
,不包括end
)。原始数组不会被改变。 详细介绍,可以阅读文档《MDN Array slice》。
// 示例 数组浅拷贝 let user = ["leo", "pingan", {name: "pingan8787"}]; let leo = Array.prototype.slice.call(user); leo[0] = "pingan888"; leo[2]["name"] = "pingan999"; console.log(leo[0]); // "pingan888" ⚠️ 差异! console.log(user[0]); // "leo" ⚠️ 差异! console.log(leo[2]["name"]); // "pingan999" console.log(user[2]["name"]); // "pingan999"
3. Array.prototype.concat()
语法: var new_array = old_array.concat(value1[, value2[, ...[, valueN]]])
concat()
方法用于合并两个或多个数组。此方法不会更改现有数组,而是返回一个新数组。 详细介绍,可以阅读文档《MDN Array concat》。
let user = [{name: "leo"}, {age: 18}]; let user1 = [{age: 20},{addr: "fujian"}]; let user2 = user.concat(user1); user1[0]["age"] = 25; console.log(user); // [{"name":"leo"},{"age":18}] console.log(user1); // [{"age":25},{"addr":"fujian"}] console.log(user2); // [{"name":"leo"},{"age":18},{"age":25},{"addr":"fujian"}]
Array.prototype.concat
也是一个浅拷贝,只是在根属性(对象的第一层级)创建了一个新的对象,但是对于属性的值是对象的话只会拷贝一份相同的内存地址。
4. 拓展运算符(...)
语法: var cloneObj = { ...obj };
扩展运算符也是浅拷贝,对于值是对象的属性无法完全拷贝成2个不同对象,但是如果属性都是基本类型的值的话,使用扩展运算符也是优势方便的地方。
let user = { name: "leo", skill: { JavaScript: 90, CSS: 80}}; let leo = {...user}; leo.name = "leo1"; leo.skill.CSS = 90; console.log(leo.name); // "leo1" ⚠️ 差异! console.log(user.name); // "leo" ⚠️ 差异! console.log(leo.skill.CSS); // 90 console.log(user.skill.CSS);// 90
3.3 手写浅拷贝
实现原理:新的对象复制已有对象中非对象属性的值和对象属性的引用,也就是说对象属性并不复制到内存。
function cloneShallow(source) { let target = {}; for (let key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } return target; }
- for in
for...in语句以任意顺序遍历一个对象自有的、继承的、可枚举的
、非Symbol的属性。对于每个不同的属性,语句都会被执行。
- hasOwnProperty
该函数返回值为布尔值,所有继承了 Object 的对象都会继承到 hasOwnProperty
方法,和 in
运算符不同,该函数会忽略掉那些从原型链上继承到的属性和自身属性。 语法:obj.hasOwnProperty(prop)
prop
是要检测的属性字符串名称或者Symbol
。
4. 深拷贝
4.1 概念
复制变量值,对于引用数据,则递归至基本类型后,再复制。深拷贝后的对象与原来的对象完全隔离,互不影响,对一个对象的修改并不会影响另一个对象。
4.2 实现深拷贝
1. JSON.parse(JSON.stringify())
其原理是把一个对象序列化成为一个JSON字符串,将对象的内容转换成字符串的形式再保存在磁盘上,再用JSON.parse()
反序列化将JSON字符串变成一个新的对象。
let user = { name: "leo", skill: { JavaScript: 90, CSS: 80}}; let leo = JSON.parse(JSON.stringify(user)); leo.name = "leo1"; leo.skill.CSS = 90; console.log(leo.name); // "leo1" ⚠️ 差异! console.log(user.name); // "leo" ⚠️ 差异! console.log(leo.skill.CSS); // 90 ⚠️ 差异! console.log(user.skill.CSS);// 80 ⚠️ 差异!
JSON.stringify()
使用注意:
- 拷贝的对象的值中如果有函数,
undefined
,symbol
则经过JSON.stringify()
`序列化后的JSON字符串中这个键值对会消失; - 无法拷贝不可枚举的属性,无法拷贝对象的原型链;
- 拷贝
Date
引用类型会变成字符串; - 拷贝
RegExp
引用类型会变成空对象; - 对象中含有
NaN
、Infinity
和-Infinity
,则序列化的结果会变成null
; - 无法拷贝对象的循环应用(即
obj[key] = obj
)。
2. 第三方库
4.3 手写深拷贝
核心思想是递归,遍历对象、数组直到里边都是基本数据类型,然后再去复制,就是深度拷贝。 实现代码:
const isObject = obj => typeof obj === 'object' && obj != null; function cloneDeep(source) { if (!isObject(source)) return source; // 非对象返回自身 const target = Array.isArray(source) ? [] : {}; for(var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { if (isObject(source[key])) { target[key] = cloneDeep(source[key]); // 注意这里 } else { target[key] = source[key]; } } } return target; }
该方法缺陷: 遇到循环引用,会陷入一个循环的递归过程,从而导致爆栈。 其他写法,可以阅读《如何写出一个惊艳面试官的深拷贝?》 。
5. 小结
浅拷贝:将对象的每个属性进行依次复制,但是当对象的属性值是引用类型时,实质复制的是其引用,当引用指向的值改变时也会跟着变化。
深拷贝:复制变量值,对于引用数据,则递归至基本类型后,再复制。深拷贝后的对象与原来的对象完全隔离,互不影响,对一个对象的修改并不会影响另一个对象。
深拷贝和浅拷贝是针对复杂数据类型来说的,浅拷贝只拷贝一层,而深拷贝是层层拷贝。
三、垃圾回收机制(GC)
垃圾回收(Garbage Collection,缩写为GC)是一种自动的存储器管理机制。当某个程序占用的一部分内存空间不再被这个程序访问时,这个程序会借助垃圾回收算法向操作系统归还这部分内存空间。垃圾回收器可以减轻程序员的负担,也减少程序中的错误。垃圾回收最早起源于LISP语言。 目前许多语言如Smalltalk、Java、C#和D语言都支持垃圾回收器,我们熟知的 JavaScript 具有自动垃圾回收机制。
在 JavaScript 中,原始类型的数据被分配到栈空间中,引用类型的数据会被分配到堆空间中。
1. 栈空间中的垃圾回收
当函数 showName
调用完成后,通过下移 ESP(Extended Stack Pointer)指针,来销毁 showName
函数,之后调用其他函数时,将覆盖掉旧内存,存放另一个函数的执行上下文,实现垃圾回收。
2. 堆空间中的垃圾回收
堆中数据垃圾回收策略的基础是:代际假说(The Generational Hypothesis)。即:
- 大部分对象在内存中存在时间极短,很多对象很快就不可访问。
- 不死的对象将活得更久。
这两个特点不仅仅适用于 JavaScript,同样适用于大多数的动态语言,如 Java、Python 等。 V8 引擎将堆空间分为新生代(存放生存时间短的对象)和老生代(存放生存时间长的对象)两个区域,并使用不同的垃圾回收器。
- 副垃圾回收器,主要负责新生代的垃圾回收。
- 主垃圾回收器,主要负责老生代的垃圾回收。
不管是哪种垃圾回收器,都使用相同垃圾回收流程:标记活动对象和非活动对象,回收非活动对象的内存,最后内存整理。 **
1.1 副垃圾回收器
使用 Scavenge 算法处理,将新生代空间对半分为两个区域,一个对象区域,一个空闲区域。 图片来自《浏览器工作原理与实践》
执行流程:
- 新对象存在在对象区域,当对象区域将要写满时,执行一次垃圾回收;
- 垃圾回收过程中,首先对对象区域中的垃圾做标记,然后副垃圾回收器将存活的对象复制并有序排列到空闲区域,相当于完成内存整理。
- 复制完成后,将对象区域和空闲区域翻转,完成垃圾回收操作,这也让新生代中两块区域无限重复使用。
当然,这也存在一些问题:若复制操作的数据较大则影响清理效率。 JavaScript 引擎的解决方式是:将新生代区域设置得比较小,并采用对象晋升策略(经过两次回收仍存活的对象,会被移动到老生区),避免因为新生代区域较小引起存活对象装满整个区域的问题。
1.2 主垃圾回收器
分为:标记 - 清除(Mark-Sweep)算法,和标记 - 整理(Mark-Compact)算法。
a)标记 - 清除(Mark-Sweep)算法过程:
- 标记过程:从一组根元素开始遍历整个元素,能到达的元素为活动对象,反之为垃圾数据;
- 清除过程:清理被标记的数据,并产生大量碎片内存。(缺点:导致大对象无法分配到足够的连续内存)
图片来自《浏览器工作原理与实践》
b)标记 - 整理(Mark-Compact)算法过程:
- 标记过程:从一组根元素开始遍历整个元素,能到达的元素为活动对象,反之为垃圾数据;
- 整理过程:将所有存活的对象,向一段移动,然后清除端边界以外的内容。
图片来自《浏览器工作原理与实践》
3. 拓展阅读
1.《图解Java 垃圾回收机制》 2.《MDN 内存管理》
四、对象方法和 this
1. 对象方法
具体介绍可阅读 《MDN 方法的定义》 。 将作为对象属性的方法称为“对象方法”,如下面 user
对象的 say
方法:
let user = {}; let say = function(){console.log("hello!")}; user.say = say; // 赋值到对象上 user.say(); // "hello!"
也可以使用更加简洁的方法:
let user = { say: function(){} // 简写为 say (){console.log("hello!")} // ES8 async 方法 async say (){/.../} } user.say();
当然对象方法的名称,还支持计算的属性名称作为方法名:
const hello = "Hello"; let user = { ['say' + hello](){console.log("hello!")} } user['say' + hello](); // "hello!"
另外需要注意的是:所有方法定义不是构造函数,如果您尝试实例化它们,将抛出TypeError
。
let user = { say(){}; } new user.say; // TypeError: user.say is not a constructor
2. this
2.1 this 简介
当对象方法需要使用对象中的属性,可以使用 this
关键字:
let user = { name : 'leo', say(){ console.log(`hello ${this.name}`)} } user.say(); // "hello leo"
当代码 user.say()
执行过程中, this
指的是 user
对象。当然也可以直接使用变量名 user
来引用 say()
方法:
let user = { name : 'leo', say(){ console.log(`hello ${user.name}`)} } user.say(); // "hello leo"
但是这样并不安全,因为 user
对象可能赋值给另外一个变量,并且将其他值赋值给 user
对象,就可能导致报错:
let user = { name : 'leo', say(){ console.log(`hello ${user.name}`)} } let leo = user; user = null; leo.say(); // Uncaught TypeError: Cannot read property 'name' of null
但将 user.name
改成 this.name
代码便正常运行。
2.2 this 取值
this
的值是在 代码运行时计算出来 的,它的值取决于代码上下文:
let user = { name: "leo"}; let admin = {name: "pingan"}; let say = function (){ console.log(`hello ${this.name}`) }; user.fun = say; admin.fun = say; // 函数内部 this 是指“点符号前面”的对象 user.fun(); // "hello leo" admin.fun(); // "hello pingan" admin['fun'](); // "hello pingan"
规则:如果 obj.fun()
被调用,则 this
在 fun
函数调用期间是 obj
,所以上面的 this
先是 user
,然后是 admin
。
但是在全局环境中,无论是否开启严格模式, this
都指向全局对象
console.log(this == window); // true let a = 10; this.b = 10; a === this.b; // true
2.3 箭头函数没有自己的 this
箭头函数比较特别,没有自己的 this
,如果有引用 this
的话,则指向外部正常函数,下面例子中, this
指向 user.say()
方法:
let user = { name : 'leo', say : () => { console.log(`hello ${this.name}`); }, hello(){ let fun = () => console.log(`hello ${this.name}`); fun(); } } user.say(); // hello => say() 外部函数是 window user.hello(); // hello leo => fun() 外部函数是 hello
2.4 call / apply / bind
详细可以阅读《js基础-关于call,apply,bind的一切》 。 当我们想把 this
值绑定到另一个环境中,就可以使用 call
/ apply
/ bind
方法实现:
var user = { name: 'leo' }; var name = 'pingan'; function fun(){ return console.log(this.name); // this 的值取决于函数调用方式 } fun(); // "pingan" fun.call(user); // "leo" fun.apply(user); // "leo"
注意:这里的 var name = 'pingan';
需要使用 var
来声明,使用 let
的话, window
上将没有 name
变量。
三者语法如下:
fun.call(thisArg, param1, param2, ...) fun.apply(thisArg, [param1,param2,...]) fun.bind(thisArg, param1, param2, ...)