很多新手学 JavaScript 时,函数、数组、对象都能用,但一被问到"原型链是什么"就卡壳。其实对象没你想的那么神秘:它就是一组键值对,而原型链就是对象之间"借方法"的机制。本文从对象字面量讲起,一路讲到 class,把这块最难啃的骨头彻底嚼碎,读完你就能自信地跟人解释"一切皆对象"到底是什么意思。
1. 对象字面量:最常用的创建方式
用花括号直接写出来的对象叫对象字面量。属性值可以是任意类型——数字、字符串、数组,甚至函数(这种属性叫方法)。对象在 JavaScript 里是绝对的"C 位",数组、函数、Map 底层都是对象:
const user = {
name: '林晚',
age: 24,
skills: ['JavaScript', 'Python'],
greet: function () {
return '你好,我是 ' + this.name;
}
};
console.log(user.name);
console.log(user['age']);
user.city = '杭州';
delete user.age;
console.log(user.greet());
访问属性有两种方式:点语法和方括号。方括号里可以是变量或带特殊字符的键名,比如 user['first name'],这点是点语法做不到的。方法里的 this 指向调用它的对象,所以 user.greet() 里 this 就是 user——理解 this 的指向,是读懂对象方法的前提。
说到"一切皆对象",有个历史包袱值得知道:typeof null 返回 "object",其实是早期实现的 bug,但为了兼容一直保留到现在。另外,用 new Object() 创建的对象和字面量没有任何区别,字面量只是更短的写法——日常开发里统一用字面量就够了。
2. 属性的检查、遍历与删除
对象是动态的:随时可以加属性、改属性、删属性。判断一个属性是否存在,要用 in 操作符而不是直接比较 undefined——因为"属性存在但值为 undefined"的情况很常见,直接比较会误判:
const config = { theme: 'dark', lang: undefined };
console.log('theme' in config);
console.log('lang' in config);
console.log(config.lang === undefined);
console.log(Object.keys(config));
console.log(Object.values(config));
for (const key of Object.keys(config)) {
console.log(key, '=', config[key]);
}
Object.keys、Object.values、Object.entries 返回的都是数组,可以用 for...of 直接遍历。注意它们只遍历"自有且可枚举"的属性,从原型继承来的属性不会出现——这在后面讲原型链时很关键。
如果想同时拿到键和值,Object.entries 配合解构最顺手:for (const [key, value] of Object.entries(config)),比分别调 keys 和 values 少一次遍历。另外注意 in 操作符会连原型链一起查——想只查自有属性,用 hasOwnProperty 或 ES2022 新增的 Object.hasOwn,后者更安全,不会被对象上自定义的同名方法覆盖。
3. 引用传递与拷贝
对象是引用类型:变量里存的不是数据本身,而是数据的地址。把对象赋给另一个变量,两个变量指向同一份数据,改一个另一个也跟着变——这是新手最容易踩的坑,也是 bug 大户:
const a = { score: 90, detail: { level: 'A' } };
const b = a;
b.score = 60;
console.log(a.score);
const shallow = { ...a };
shallow.detail.level = 'B';
console.log(a.detail.level);
const deep = structuredClone(a);
deep.detail.level = 'C';
console.log(a.detail.level);
展开运算符 ... 只能做一层浅拷贝:顶层属性被复制了,但嵌套对象仍然共享。要完整复制嵌套结构,现代浏览器直接用 structuredClone 就行,不用再手写递归——它连 Date、Map 这类特殊对象都能正确克隆。
顺带一提,JSON.parse(JSON.stringify(obj)) 曾是深拷贝的经典写法,但它会丢掉 undefined、函数、Date 等类型,遇到循环引用直接报错。structuredClone 没有这些限制,是现在的正确选择——除非你明确知道数据里只有普通对象和数组。
4. getter 与 setter
有时候你希望读取属性时动态计算、赋值时做点检查。getter/setter 就是挂在属性上的"读写钩子",调用时和普通属性一模一样,不需要加括号:
const user = {
firstName: '林',
lastName: '晚',
get fullName() {
return this.firstName + ' ' + this.lastName;
},
set fullName(value) {
[this.firstName, this.lastName] = value.split(' ');
}
};
console.log(user.fullName);
user.fullName = '周明远';
console.log(user.firstName, user.lastName);
setter 里用了数组解构:'周明远'.split(' ') 得到 ['周明远'],只给 firstName 赋了值,lastName 变成 undefined。这个例子也提醒你:setter 适合做数据校验和格式化,但别在 setter 里干重活,比如触发网络请求——那会让代码极难调试。
getter 的另一个常见用途是"惰性计算":一个值第一次被读取时才计算,之后可以缓存起来。比如用户对象的全名、价格对象含税后的金额,用 getter 封装后,调用方永远只需要 obj.fullName,内部怎么算都可以随时改——这就是封装的意义:外部不关心实现,只关心接口。
5. 原型链:对象如何"借"方法
每个对象都有一个隐藏属性 [[Prototype]](通常叫 __proto__),指向另一个对象。当你访问一个属性,对象自己身上没有,JS 就会沿着这条链往上找——这就是原型链。用 Object.create 可以显式指定新对象的原型:
const animal = {
speak: function () {
return this.name + ' 发出了叫声';
}
};
const dog = Object.create(animal);
dog.name = '旺财';
console.log(dog.speak());
console.log(dog.hasOwnProperty('speak'));
console.log(animal.isPrototypeOf(dog));
dog 自己没有 speak 属性,但调用 dog.speak() 能成功,就是因为沿着原型链找到了 animal。hasOwnProperty 返回 false 也印证了这一点:speak 是"借来的",不是自己的。原型链查找是运行时行为,即使之后给 animal 加新方法,dog 也能立刻用上。
原型链的尽头是 null:几乎每个对象最终都会链到 Object.prototype,那里躺着 toString、hasOwnProperty 这些通用方法——这就是为什么任意对象都能调用 obj.toString()。如果你打印 Object.getPrototypeOf(Object.prototype),会得到 null,链条到此为止。理解这条"终点",比背下链条结构更有用。
在 class 出现之前,人们用"构造函数 + prototype"模拟面向对象。每个构造函数都有一个 prototype 属性,new 出来的实例会自动把它作为原型:
function Animal(name) {
this.name = name;
}
Animal.prototype.speak = function () {
return this.name + ' 发出了叫声';
};
function Dog(name) {
Animal.call(this, name);
}
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;
const dog = new Dog('旺财');
console.log(dog.speak());
console.log(dog instanceof Animal);
这段代码就是"手动实现继承":Animal.call(this) 借用父构造函数初始化属性,Object.create(Animal.prototype) 让 Dog 的实例能沿链找到 Animal 的方法。instanceof 检查的正是原型链上有没有这个构造函数——链上找得到就返回 true。
这种手写继承的写法现在基本被 class 取代了,但面试常考,而且大量老代码库还在用——能读懂它,你就真正理解了原型继承的本质,以后再看到各种"继承变体"都不会慌。
6. class:更清爽的语法糖
ES6 的 class 没有引入新的继承机制,它只是把上面那套原型操作的写法包装得更直观。用 extends 继承,用 super 调用父类构造函数:
class Animal {
constructor(name) {
this.name = name;
}
speak() {
return this.name + ' 发出了叫声';
}
}
class Dog extends Animal {
constructor(name) {
super(name);
this.legs = 4;
}
fetch() {
return this.name + ' 叼回了球';
}
}
const dog = new Dog('旺财');
console.log(dog.speak());
console.log(dog.fetch());
console.log(dog instanceof Animal);
注意:子类构造函数里必须先调用 super() 才能使用 this,这是语法强制要求,否则直接报错。class 里的方法会自动挂到原型上,所以 dog.speak() 和上一节的 prototype 写法效果完全一样——只是好看多了,也更符合其他语言用户的直觉。
class 里还能写静态方法(static 关键字),它挂在构造函数上而不是实例上,适合放工厂函数,比如 Dog.create(name)。字段声明也值得用起来:legs = 4 写在构造函数外面,等价于在 constructor 里赋值,代码更短。最后再强调一遍:class 只是语法糖,它没有引入"类"这种新的运行时实体——实例之间依然是靠原型链共享方法,底层机制从未改变。
7. 总结与练习
对象就是键值对集合,属性可以随时增删改查;对象按引用传递,拷贝要分清浅拷贝和 structuredClone;访问不存在的属性会沿着原型链向上找;class 是原型继承的语法糖,底层机制没有变。
三个练习帮你巩固:
- 用
Object.create实现一个"鸟类"对象继承"动物",再验证原型链的完整查找过程。 - 写一个深拷贝函数
deepCopy(obj)处理嵌套对象和数组,再和structuredClone对比结果。 - 给一个计数器对象加上 getter/setter:读取时返回格式化后的数字,赋值时自动取整并拒绝负数。
💡 在 DevTools 控制台打印任意对象,展开后能看到 [[Prototype]] 字段——沿着它一层层点下去,你就在"亲眼"观察原型链,比任何文档都直观。