JavaScript对象深度剖析:从创建到继承与克隆
本文深入解析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对象)就是小编分享给大家的全部内容了,希望能给大家一个参考,也希望大家多多支持本站。
JS语法错误会阻止加载吗?区分SyntaxError与资源加载失败
2026-11-05 16:08:46
JavaScript常见SyntaxError语法错误排查与修复方法
2026-11-05 13:58:44
JS中slice、substring和substr截取字符串的区别
2026-11-05 13:43:47
JS 转 TS 手动迁移最佳实践:从配置 tsconfig 到消除 any 类型
2026-11-05 11:53:32
为什么JS数组push后得到的是数字而不是数组?
2026-11-05 09:59:18
