JavaScript对象深度剖析:从创建到继承与克隆

作者:小括号

时间:2026-10-22

浏览:594

本文深入解析JavaScript对象机制,涵盖字面量、工厂、构造函数及原型模式的创建方式。详细剖析__proto__与prototype关系,对比简单原型链、组合继承与ES6 Class继承的优劣。同时介绍Object API及深度克隆的多种实现,帮助开发者彻底理解JS对象模型。

文章目录

这篇文章经过长时间的酝酿,终于与大家见面。

JavaScript是一门基于对象的语言,不涉及传统意义上的类概念。从入门到精通的过程中,对象机制始终是一个核心且常被讨论的话题。

以往的文章多聚焦于开发中遇到的具体问题及最佳解决方案,此次决定专门撰写一篇关于基础概念的深度解析文章。

本文将围绕以下几个核心问题展开探讨,读者可根据需求选取阅读,欢迎提出批评与指正:

1、创建对象的各种方式 2、__proto__与prototype的区别与联系 3、继承机制与原型链原理 4、对象的深度克隆实现 5、常用Object方法及其注意事项 6、ES6新增特性详解

下文将反复提及“实例对象”与“原型对象”。通过构造函数使用new关键字创建的对象称为实例对象;构造函数的prototype属性所指代的对象称为原型对象。

创建对象的方式

创建对象

字面量的方式:


var myHonda = {color: "red", wheels: 4, engine: {cylinders: 4, size: 2.2}}

这相当于new Object()的语法糖,两者在功能上完全一致。

工厂模式:


function createCar(){ 
 var oTemp = new Object(); 
 oTemp.name = arguments[0];
 //直接给对象添加属性,每个对象都有直接的属性 
 oTemp.age = arguments[1]; 
 oTemp.showName = function () {  
 alert(this.name); 
 };//每个对象都有一个 showName 方法版本 
 return oTemp;
};
var myHonda = createCar('honda', 5)

该模式仅是对new Object()进行封装,旨在方便批量创建对象,本质并无区别,可视为一种创建对象的方式。

构造函数:


function Person(name, age, sex) {
 this.name = name;
 this.age = age;
 this.sex = sex;
 this.getName = function() {
return this.name;
 };
}
var rand = new Person("Rand McKinnon", 33, "M");

若在构造函数内部直接定义getName方法,每次实例化都会新建该函数对象,并形成在此场景下无用的闭包。因此,建议将方法定义在原型上,工厂模式添加方法时也应采用此方式以避免重复创建函数对象。


function Person(name, age, sex) {
 this.name = name;
 this.age = age;
 this.sex = sex;
 this.getName = getName
}
function getName() {
 return this.name;
};

构造函数创建对象的过程与工厂模式类似,主要区别在于隐藏了创建新对象和返回该对象这两步操作。在构造函数内部,this指向新建的对象,其余逻辑并无不同。

最大不同点在于:构造函数创建的对象,其constructor属性指向该构造函数;而工厂模式创建的对象,其constructor属性指向function Object(){...}。

构造函数相当于在原型链上增加了一环,因为构造函数拥有自己的prototype属性;而工厂模式只是一个普通函数。此前提到的观点存在漏洞:工厂模式的constructor指向取决于第一句new操作的对象。

若构造函数直接调用而不使用new,则取决于调用时this的指向。直接调用会将属性绑定到window对象;通过call或apply绑定到其他对象作用域,则属性会添加到该对象上。

原型模式:

构造函数虽然在原型链上加了一环,但该环初始为空,这似乎与工厂模式无异。然而,原型模式的意义在于此。


function Car(){} 
//用空构造函数设置类名
Car.prototype.color = "blue";//每个对象都共享相同属性
Car.prototype.doors = 3;
Car.prototype.drivers = new Array("Mike","John");
Car.prototype.showColor = function(){  
 alert(this.color);
};//每个对象共享一个方法版本,省内存。
//构造函数的原型属性可以通过字面量来设置,别忘了通过 Object.defineProperty()设置 constructor 为该构造函数
function Car(){} 
Car.prototype = {
 color:"blue",
 doors:3,
 showColor:function(){  
alert(this.color);
 }
}
Object.defineProperty(Car.prototype, "constructor", { enumerable:false, value:Car })
//(不设置 constructor 会导致 constructor 不指向构造函数,直接设置 constructor 会导致 constructor 可枚举)

使用原型模式需注意动态性。通过构造函数实例化的对象,其原型对象是构造函数的prototype。若在该原型对象上增加或删除方法,该对象会继承这些修改。例如,先通过构造函数A实例化出对象a,再给A.prototype添加方法,a可继承该方法。但若给A.prototype赋值新对象,a不会继承新对象的属性和方法。这听起来复杂:修改A.prototype相当于直接修改a的原型对象,a自然继承修改;但重新给A.prototype赋值,修改的是构造函数的原型,并未影响a的原型对象!a创建后原型对象已确定,除非直接修改该原型对象(或其原型对象),否则a不会继承这些修改!

Object.create()

该方法传入要创建对象实例的原型对象,与原型模式类似,相当于在原型链上加了一环。区别在于,这种方式创建的对象没有构造函数。这种方式相当于:


function object(o){
function F(){}
F.prototype = o;
return new F()
}

相当于构造函数仅短暂存在,创建出来的对象的constructor指向原型对象o的constructor!

混合模式:

使用原型模式时,若给实例对象设置专属属性,该实例对象会忽略原型链中的同名属性。但当原型链中的属性是引用类型值时,操作不当可能会直接修改原型对象的属性,进而影响所有使用该原型对象的实例对象!

大多数情况下,实例对象的多数方法是共有的,多数属性是私有的。因此,属性在构造函数中设置,方法在原型中设置是合适的做法,构造函数与原型结合使用是通常的做法。

还有其他方法,无非是工厂模式、构造函数与原型模式的互相结合,在生成过程和this指向上做一些小变化。

class 方式:

详见下方ES6 class部分,这只是一个语法糖,本质上与构造函数并无区别,但继承方式有所不同。

__proto__与prototype的关系

__proto__与prototype

这两者到底是什么关系?搞清楚实例对象、构造函数、原型对象的三角关系,这两个属性的用法就自然清晰了,顺便说下constructor。

构造函数创建的实例对象的constructor指向该构造函数(但实际上constructor是对应的原型对象上的一个属性!所以实例对象的constructor是继承来的,这一点要注意。如果利用原型链继承,constructor将有可能指向原型对象的构造函数甚至更上层的构造函数,其他重写构造函数prototype的行为也会造成constructor指向问题,都需要重设constructor)。构造函数的prototype指向对应的原型对象,实例对象的__proto__指对应的原型对象,__proto__是浏览器的实现,并没有出现在标准中,可以用constructor.prototype代替。考虑到Object.create()创建的对象,更安全的方法是Object.getPrototypeOf()传入需要获取原型对象的实例对象。

我自己都感觉说的有点乱,但是他们就是这样的,上一张图,看看能不能帮你更深刻理解这三者关系。


创建对象的方式

继承与原型链

继承与原型链

当访问一个对象的属性时,如果在对象本身找不到,就会去搜索对象的原型,原型的原型,知道原型链的尽头null,那原型链是怎么链起来的?

把实例对象、构造函数、原型对象视为一个小组,上面说了三者互相之间的关系,构造函数是函数,可实例对象和原型对象可都是普通对象啊,这就出现了这样的情况:

这个小组的原型对象,等于另一个小组实例对象,而此小组的原型对象又可能是其他小组的实例对象,这样一个个的小组不就连接起来了么。举个例子:


function Super(){
 this.val = 1;
 this.arr = [1];
}
function Sub(){
 // ...
}
Sub.prototype = new Super(); 

Sub是一个小组,Super是一个小组,Sub的原型对象链接到了Super的实例对象。

基本上所有对象顺着原型链爬到头都是Object.prototype,而Object.prototype就没有原型对象,原型链就走到头了。

判断构造函数和原型对象是否存在于实例对象的原型链中:

实例对象instanceof构造函数,返回一个布尔值,原型对象.isPrototypeOf(实例对象),返回一个布尔值。

上面是最简单的继承方式了,但是有两个致命缺点:

所有Sub的实例对象都继承自同一个Super的实例对象,我想传参数到Super怎么办?

如果Super里有引用类型的值,比如上面例子中我给Sub的实例对象中的arr属性push一个值,岂不是牵一发动全身?

下面说一种最常用的组合继承模式,先举个例子:


function Super(value){
 // 只在此处声明基本属性和引用属性
 this.val = value;
 this.arr = [1];
}
// 在此处声明函数
Super.prototype.fun1 = function(){};
Super.prototype.fun2 = function(){};
//Super.prototype.fun3...
function Sub(value){
 Super.call(this,value); // 核心
 // ...
}
Sub.prototype = new Super(); // 核心

过程是这样的,在简单的原型链继承的基础上,Sub的构造函数里运行Super,从而给Sub的每一个实例对象一份单独的属性,解决了上面两个问题,可以给Super传参数了,而且因为是独立的属性,不会因为误操作引用类型值而影响其他实例了。不过还有个小缺点: Sub中调用的Super给每个Sub的实例对象一套新的属性,覆盖了继承的Super实例对象的属性,那被覆盖的的那套属性不就浪费了?岂不是白继承了?最严重的问题是Super被执行了两次,这不能忍(其实也没多大问题)。下面进行一下优化,把上面例子最后一行替换为:


Sub.prototype = Object.create(Super.prototype);
// Object.create() 给原型链上添加一环,否则 Sub 和 Super 的原型就重叠了。
Sub.prototype.constructor = Sub;

到此为止,继承非常完美。

其他还有各路继承方式无非是在简单原型链继承 --> 优化的组合继承路程之间的一些思路或者封装。

通过class继承的方式:

通过class实现继承的过程与ES5完全相反,详细见下方ES6 class的继承部分。

对象的深度克隆

对象的深度克隆

JavaScript的基础类型是值传递,而对象是引用传递,这导致一个问题:

克隆一个基础类型的变量的时候,克隆出来的变量是和旧的变量完全独立的,只是值相同而已。

而克隆对象的时候就要分两种情况了,简单的赋值会让两个变量指向同一块内存,两者代表同一个对象,甚至算不上克隆克隆。但我们常常需要的是两个属性和方法完全相同但却完全独立的对象,称为深度克隆。我们接下来讨论几种深度克隆的方法。

说几句题外的话,业界有一个非常知名的库immutable,个人认为很大程度上解决了深度克隆的痛点,我们修改一个对象的时候,很多时候希望得到一个全新的对象(比如Redux每次都要用一个全新的对象修改状态),由此我们就需要进行深度克隆。而immutable相当于产生了一种新的对象类型,每一次修改属性都会返回一个全新的immutable对象,免去了我们深度克隆的工作是小事,关键性能特别好。

历遍属性


function clone(obj){
 var newobj = obj.constructor === Array ? [] : {}; // 用 instanceof 判断也可
 if(typeof obj !== 'object' || obj === null ){
return obj
 } else {
for(var i in obj){
 newobj[i] = typeof obj[i] === 'object' ? cloneObj(obj[i]) : obj[i]; 
 // 只考虑 对象和数组, 函数虽然也是引用类型,但直接赋值并不会产生什么副作用,所以函数类型无需深度克隆。
}
 }
 return newobj;
};

原型式克隆


function clone(obj){
 function F() {};
 F.prototype = obj;
 var f = new F();
 for(var key in obj)
 {
 if(typeof obj[key] =="object")
 {
  f[key] = clone(obj[key])
 }
 }
 return f ;
}

这种方式不能算严格意义上的深度克隆,并没有切断新对象与被克隆对象的联系,被克隆对象作为新对象的原型存在,虽然新对象的改变不会影响旧对象,但反之则不然!而且给新对象属性重新赋值的时候只是覆盖了原型中的属性,在历遍新对象的时候也会出现问题。这种方式问题重重,除了实现特殊目的可以酌情使用,通常情况应避免使用。 json序列化


var newObj = JSON.parse(JSON.stringify(obj)); 

这是我最喜欢的方式了!简短粗暴直接!但是最大的问题是,毕竟JSON只是一种数据格式所以这种方式只能克隆属性,不能克隆方法,方法在序列化以后就消失了。。。

Object方法与注意事项

一些Object的方法与需要注意的点

Object自身的方法:

设置属性,Object.defineProperty(obj, prop, descriptor)根据descriptor定义obj的prop属性(值,是否可写可枚举可删除等)。

Object.getOwnPropertyDescriptor(obj, prop)返回obj的prop属性的描述。

使对象不可拓展,Object.preventExtensions(obj),obj将不能添加新的属性。

判断对像是否可拓展,Object.isExtensible(obj)。

密封一个对象,Object.seal(obj),obj将不可拓展且不能删除已有属性。

判断对象是否密封,Object.isSealed(obj)。

冻结对象,Object.freeze(obj)obj将被密封且不可修改。

判断对象是否冻结,Object.isFrozen(obj)。

获取对象自身属性(包括不可枚举的),Object.getOwnPropertyNames(obj),返回obj所有自身属性组成的数组。

获取对象自身属性(不包括不可枚举的),Object.keys(obj),返回obj所有自身可枚举属性组成的数组。

当使用for in循环遍历对象的属性时,原型链上的所有可枚举属性都将被访问。

只关心对象本身时用Object.keys(obj)代替for in,避免历遍原型链上的属性。

获取某对象的原型对象,Object.getPrototypeOf(object),返回object的原型对象。

设置某对象的原型对象,Object.setPrototypeOf(obj, prototype),ES6新方法,设置obj的原型对象为prototype,该语句比较耗时。

Object.prototype上的方法:

检查对象上某个属性是否存在时(存在于本身而不是原型链中),obj.hasOwnProperty()是唯一可用的方法,他不会向上查找原型链,只在obj自身查找,返回布尔值。

检测某对象是否存在于参数对象的原型链中,obj.isPrototypeOf(obj2),obj是否在obj2的原型链中,返回布尔值。

检测某属性是否是对象自身的可枚举属性,obj.propertyIsEnumerable(prop),返回布尔值。

对象类型,obj.toString(),返回"[object type]"type可以是Date,Array,Math等对象类型。

obj.valueOf(),修改对象返回值时的行为,使用如下:


function myNumberType(n) {
this.number = n;
}
myNumberType.prototype.valueOf = function() {
return this.number;
};
myObj = new myNumberType(4);
myObj + 3; // 7

ES6新增特性

ES6新增特性

判断两个值是否完全相等,Object.is(value1, value2),类似于===但是可以用来判断NaN。

属性和方法简写:


// 属性简写
var foo = 'bar';
var baz = {foo};
baz // {foo: "bar"}
// 等同于
var baz = {foo: foo};
// 方法简写
function f(x, y) {
 return {x, y};
}
// 等同于
function f(x, y) {
 return {x: x, y: y};
}
f(1, 2) // Object {x: 1, y: 2}

合并对象:

Object.assign(target, [...source]);

将source中所有和枚举的属性复制到target。

多个source对象有同名属性,后面的覆盖前面的。


var target = { a: 1 };
var source1 = { b: 2 };
var source2 = { c: 3 };
Object.assign(target, source1, source2);
target // {a:1, b:2, c:3}

注意一点,该命令执行的是浅克隆,如果source中有属性是对象,target中会复制该对象的引用。

常用于给对象添加属性和方法(如给构造函数的原型添加方法),克隆、合并对象等。

获取对象自身的值或键值对(做为Object.keys(obj)的补充不包括不可枚举的):

Object.keys(obj)返回obj自身所有可枚举属性的值组成的数组。

Object.entries(obj)返回obj自身所有可枚举键值对数组组成的数组,例如:


var obj = { foo: 'bar', baz: 42 };
Object.entries(obj)
// [ ["foo", "bar"], ["baz", 42] ]
// 可用于将对象转为 Map 结构
var obj = { foo: 'bar', baz: 42 };
var map = new Map(Object.entries(obj));
map // Map { foo: "bar", baz: 42 }

拓展运算符:

取出对象所有可历遍属性,举例:


let z = { a: 3, b: 4 };
let n = { ...z };
n // { a: 3, b: 4 }
// 可代替 Object.assign()
let ab = { ...a, ...b };
// 等同于
let ab = Object.assign({}, a, b);

可用于解构赋值中最后一个参数:


let { x, y, ...z } = { x: 1, y: 2, a: 3, b: 4 };
x // 1
y // 2
z // { a: 3, b: 4 }
// 可以这样理解,把 z 拆开以后就等于后面对象未被分配出去的键值对。

Null传导运算符:


const firstName = message?.body?.user?.firstName || 'default';
// 代替
const firstName = (message
 && message.body
 && message.body.user
 && message.body.user.firstName) || 'default';

class:

ES6引入了class关键字,但并没有改变对象基于原型继承的原理,只是一个语法糖,让他长得像传统面向对象语言而已。

以下两个写法完全等价:


function Point(x, y) {
 this.x = x;
 this.y = y;
}
Point.prototype.toString = function () {
 return '(' + this.x + ', ' + this.y + ')';
};
//定义类
class Point {
 constructor(x, y) {
this.x = x;
this.y = y;
 }
 toString() {
return '(' + this.x + ', ' + this.y + ')';
 }
}
// 类中定义的方法就是在原型上

有两点区别,class中定义的方法是不可枚举的,class必须通过new调用不能直接运行。

class不存在变量提升,使用要在定义之后。

class中的方法前加static关键字定义静态方法,只能通过class直接调用不能被实例继承。

如果静态方法包含this关键字,这个this指的是class,而不是实例。注意下面代码:


class Foo {
 static bar () {
 this.baz();
 }
 static baz () {
 console.log('hello');
 }
 baz () {
 console.log('world');
 }
}
Foo.bar() // hello

父类的静态方法,可以被子类继承,目前class内部无法定义静态属性。

设置静态属性与实例属性新提案:

class的实例属性可以用等式,写入类的定义之中。

静态属性直接前面加static即可。


class MyClass {
 myProp = 42;
 static myStaticProp = 42;
}

class的继承:

class通过extends实现继承,注意super关键字


class ColorPoint extends Point {
 constructor(x, y, color) {
super(x, y); // 调用父类的constructor(x, y)
this.color = color;
 }
 toString() {
return this.color + ' ' + super.toString(); // 调用父类的toString()
 }
}

extends可以继承其他类或任何有prototype属性的函数。

super会从父类获取各路信息绑定到子类的this。

子类自己没有this对象,要先继承父类的实例对象然后再进行加工,所以要在constructor里调用super继承this对象后才能使用this。

ES5的继承,实质是先创造子类的实例对象this,然后再将父类的方法添加到this上面(Parent.apply(this))。ES6的继承机制完全不同,实质是先创造父类的实例对象this(所以必须先调用super方法创建和继承这个this,并绑定到子类的this),然后再用子类的构造函数修改this。

这条理由也是造成了ES6之前无法继承原生的构造函数(Array Function Date等)的原型对象,而使用class可以。因为ES5中的方法是先实例化子类,再把父类的属性添加上去,但是父类有很多不能直接访问的属性或方法,这就糟了,而通过class继承反其道而行之先实例化父类,这就自然把所有属性和方法都继承了。

super作为对象时,在普通方法中,指向父类的原型对象;在静态方法中,指向父类。

通过super调用父类的方法时,super会绑定子类的this。

constructor方法会被默认添加:


class ColorPoint extends Point {
}
// 等同于
class ColorPoint extends Point {
 constructor(...args) {
super(...args);
 }
}

Object.getPrototypeOf(object),获取某对象的原型对象,也可以获取某类的原型类。

class的__proto__与prototype

子类的__proto__属性,表示构造函数的继承,总是指向父类。

子类prototype属性的__proto__属性,表示方法的继承,总是指向父类的prototype属性。

相当于子类本身继承父类,子类的原型对象继承自父类的原型对象。

new.target:

用在构造函数或者class内部,指向调用时new的构造函数或者class。

以上这篇js对象实例详解(JavaScript对象深度剖析,深度理解js对象)就是小编分享给大家的全部内容了,希望能给大家一个参考,也希望大家多多支持本站。

实用工具推荐

电子包浆

绿化、压缩、噪点和虚拟水印,自由调节做旧程度。

使用 ›
图片压缩

选择画质和输出格式,预览压缩前后的大小。

使用 ›
相关文章