很多新手学 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 是原型继承的语法糖,底层机制没有变。

三个练习帮你巩固:

💡 在 DevTools 控制台打印任意对象,展开后能看到 [[Prototype]] 字段——沿着它一层层点下去,你就在"亲眼"观察原型链,比任何文档都直观。