JavaScript class 是语法糖:原型链才是核心

发布时间:2026/6/22 7:26:35
JavaScript class 是语法糖:原型链才是核心 1. 项目概述JavaScript 中的类不是“类”而是“糖”“Grundlegendes zu Klassen in JavaScript”——德语直译是“JavaScript 中类的基础知识”。但如果你刚从 Java、C# 或 Python 转来看到class关键字就下意识认为“JavaScript 终于有了真正的面向对象”那我得先泼一盆常温水JavaScript 里没有传统意义上的类只有原型prototype而class语法是 ES6 引入的、彻头彻尾的 syntaktischer Zucker德语语法糖。这个词在标题里就出现了但它绝不是修辞而是核心事实。我带过十几期前端新人训练营几乎每期都有人卡在这个认知拐点上写完class Person { constructor(name) { this.name name; } }就以为自己创建了一个“类模板”可以像 Java 那样生成“实例对象”甚至开始琢磨“类继承怎么绕过 prototype chain”。结果调试时发现Person.prototype.constructor Person是对的但Person.__proto__指向Function.prototype而Person.prototype.__proto__又指向Object.prototype——这串链式结构根本不像“类定义”倒像一张精心编织的网。这就是真相class不是新机制它是你写原型链的快捷键是编译器帮你自动补全的functionprototypeObject.defineProperty的组合体。为什么这个区别如此关键因为一旦你误以为class是“真实类”就会在三个地方栽跟头一是继承逻辑混乱比如super()到底调用谁的构造函数二是静态方法和实例方法的绑定时机搞错static method其实只是挂载在函数对象上的属性三是遇到this绑定问题时死磕bind()却忘了class内部方法默认不绑定this——这不是 bug是设计使然。这篇文章不讲“怎么用 class”而是带你亲手拆开它看清楚糖衣下面的function、prototype和[[Prototype]]是如何咬合运转的。适合所有正在学 ES6 的开发者尤其适合那些已经会写class但调试时总被原型链绕晕的人。你不需要懂德语但需要愿意放下“类”的执念重新理解 JavaScript 的本质运行机制。2. 核心设计思路为什么 ES6 要加这层糖不是为了改变而是为了收敛2.1 历史包袱从 function 构造函数到 Object.create 的演进阵痛在 ES6 之前JavaScript 的“模拟类”写法五花八门每种都带着自己的坑。最原始的是 function 构造函数function Person(name) { this.name name; } Person.prototype.sayHello function() { return Hello, Im ${this.name}; };这写法的问题在于Person.prototype是个公开可修改的对象别人Person.prototype.sayHello null就能干掉你的方法更麻烦的是继承——想让Student继承Person得手动设置Student.prototype new Person()但这样Student.prototype.constructor就指向Person了还得再Student.prototype.constructor Student补一刀如果Person构造函数里有副作用比如发请求、改全局状态new Person()这一步就直接执行了纯属灾难。后来有人用Object.create(Person.prototype)替代new Person()function Student(name, grade) { Person.call(this, name); // 手动调用父构造 this.grade grade; } Student.prototype Object.create(Person.prototype); Student.prototype.constructor Student; // 修复 constructor Student.prototype.study function() { return ${this.name} is studying for grade ${this.grade}; };这确实安全了但代码量翻倍call、Object.create、constructor三连击新手看一眼就头皮发紧。社区里还流行过寄生组合式继承、ES5 的Object.setPrototypeOf甚至用闭包模拟私有变量……写法太多标准太散团队协作时光是统一构造函数风格就能吵半小时。提示ES6 的class不是为了解决“能不能继承”而是解决“怎么让继承写法统一、可读、不易出错”。它强制你用extends和super()把上述三连击封装成两个关键字背后依然是Object.createcall但你不用写了。2.2 语法糖的底层契约class 必须严格遵循原型链规则class语法糖之所以能“甜”是因为它和原型链签订了不可违背的契约。这个契约有三条铁律第一class 声明必须是函数对象且不可被new.target绕过。你写class Person {}引擎内部等价于const Person (function() { use strict; function Person(name) { if (new.target undefined) { throw new TypeError(Class constructor Person cannot be invoked without new); } this.name name; } // 后续 prototype 设置... return Person; })();所以Person(Alice)直接报错而new Person(Alice)才行。这是class对构造函数调用方式的硬性约束目的是防止this指向全局对象这种低级错误。而普通 function 构造函数没这限制Person(Alice)会默默把name挂到window.name上极难排查。第二所有 class 方法都是不可枚举的non-enumerable。对比一下// function 方式 function Person() {} Person.prototype.sayHello function() {}; // 可枚举 for (let key in new Person()) console.log(key); // 输出 sayHello // class 方式 class Person { sayHello() {} // 不可枚举 } for (let key in new Person()) console.log(key); // 无输出这个细节至关重要。for...in遍历只关心可枚举属性JSON.stringify()也只序列化可枚举属性。class方法默认不可枚举意味着你不会意外把方法序列化进 API 请求体也不会在遍历时污染业务逻辑。这是Object.defineProperty在背后默默工作的结果——class把每个方法都定义为enumerable: false。第三class 内部存在严格的词法作用域和暂时性死区TDZ。你不能在class声明前使用它哪怕只是 typeofconsole.log(typeof Person); // ReferenceError: Cannot access Person before initialization class Person {}而 function 声明有提升hoistingconsole.log(typeof Person)会输出function。class的 TDZ 是刻意为之它强迫你按声明顺序组织代码避免“先用后定义”导致的隐式依赖。这在大型项目中是救命稻草——模块加载顺序出错时ReferenceError 比 undefined 更早暴露问题。注意这三条契约不是“特性”而是class作为语法糖的生存基础。一旦你试图用eval()动态拼接class字符串或用Function构造器生成class这些契约就会失效结果就是class退化成普通函数失去所有保护机制。所以class只能在顶层或块级作用域静态声明这是它甜味的代价。2.3 为什么说“prototypbasierte Sprache”基于原型的语言是 JavaScript 的灵魂很多教程把“原型链”讲成一个需要背诵的概念但其实它是一套极其精巧的运行时查找机制。想象一下当你访问person.sayHello()JavaScript 引擎不是去person对象里翻箱倒柜找sayHello而是启动一个“委托查找”流程先查person自身有没有sayHello属性自有属性没有那就去person.__proto__即Person.prototype里找还没有继续去Person.prototype.__proto__即Object.prototype里找最后到Object.prototype.__proto__值为null查找终止返回undefined。这个链条就是[[Prototype]]链它不是__proto__这个可写的属性而是对象内部的隐藏引用。class语法糖的所有魔法都建立在这个查找机制之上。extends关键字做的唯一一件事就是把子类的prototype.__proto__指向父类的prototype从而让查找链自然延伸。super()做的唯一一件事就是在子构造函数里调用父构造函数并把this绑定到当前实例上。所以当你看到class Student extends Person不要想“Student 类继承了 Person 类”要想“Student 的实例在查找属性时会先查 Student.prototype再查 Person.prototype最后查 Object.prototype”。这才是 JavaScript 的思维方式。语法糖再甜也不能掩盖这个底层事实。3. 核心细节解析手把手拆解 class 的每一层糖衣3.1 class 声明 vs class 表达式它们生成的到底是什么class有两种写法声明式class Person {}和表达式const Person class {}。很多人以为它们只是书写位置不同其实它们在作用域和提升行为上有本质差异。class 声明式受 TDZ 约束不可提升但具有命名。// ❌ 错误TDZ console.log(Person); // ReferenceError class Person { static getName() { return Person; } } // ✅ 正确声明后可用 console.log(Person.getName()); // Person console.log(Person.name); // Person —— 命名自动赋予class Person的Person是一个具名函数Person.name返回Person。这个名称不仅用于调试堆栈跟踪里显示Person而不是anonymous还用于递归调用——比如在静态方法里Person.getName()是合法的。class 表达式可赋值给变量支持匿名但无 TDZ 保护变量本身有。// ✅ 合法表达式可提前声明变量 let Person; console.log(Person); // undefined不是 ReferenceError Person class { static getName() { return Anonymous; } }; // ❌ 但 class 表达式本身仍有 TDZ let Student class { constructor() { console.log(Student.name); // ReferenceError! Student 在 class 内部不可访问 } };这里的关键洞察是class表达式生成的函数对象是匿名的Student.name是除非你显式命名class Student {}。而class Student {}这种写法其实是 class 表达式的语法糖等价于const Student class Student {}。所以class Student extends Person中的Student是命名它让Student.name为Student也让Student.toString()输出class Student extends Person { ... }这对调试极其友好。实操心得在模块导出时优先用 class 声明式export class Person {}因为它自带名称tree-shaking 工具能更好识别在需要动态创建类的场景如工厂函数用命名的 class 表达式const Clazz class Clazz { ... }避免匿名函数带来的调试困难。3.2 constructor不是“构造函数”而是“初始化钩子”constructor方法常被称作“构造函数”但这容易误导。它既不是创建对象的函数new操作符才是也不是必须存在的方法省略时引擎自动插入空constructor() { super(); }。它的本质是当new操作符完成对象创建、并把this绑定到新对象后立即执行的初始化钩子。我们来对比三种写法的等价性// 写法1显式 constructor class Person { constructor(name) { this.name name; } } // 写法2省略 constructor等价于 class Person { constructor(...args) { if (this.constructor.super_ typeof this.constructor.super_ function) { this.constructor.super_.apply(this, args); } } } // 写法3function 构造函数完全等价 function Person(name) { this.name name; }注意class的constructor里this已经是一个全新的、空的对象{}它的[[Prototype]]已被设为Person.prototype。你只需要往this上添加属性无需return thisclass构造函数强制返回this即使你return 123也会被忽略。而function构造函数里this的指向取决于调用方式new Person()时this是新对象Person()时this是全局对象非严格模式或undefined严格模式。class通过 TDZ 和new.target彻底封死了后一种可能。注意事项constructor里不能访问this以外的实例属性因为它们还没被定义。比如class Person { constructor(name) { this.name name; console.log(this.fullName); // undefinedfullName 是实例属性但此时还未定义 } fullName this.name Smith; // 这是 public class fieldES2022 提案不在 constructor 内执行 }public class field如fullName ...是在constructor执行前由引擎在this上预设的但它不是constructor的一部分而是独立的初始化步骤。3.3 方法定义实例方法、静态方法、getter/setter 的原型归属class里的方法根据声明位置被挂载到不同的对象上。这是理解class本质的关键。方法类型声明位置挂载目标是否可被实例调用是否可被类调用底层实现实例方法class { method() {} }ClassName.prototype✅obj.method()❌ClassName.method()Object.defineProperty(ClassName.prototype, method, { value: fn, enumerable: false })静态方法class { static method() {} }ClassName函数对象本身❌obj.method()✅ClassName.method()Object.defineProperty(ClassName, method, { value: fn, enumerable: false })getter/setterclass { get prop() {} set prop(v) {} }ClassName.prototype✅obj.prop,obj.prop v❌Object.defineProperty(ClassName.prototype, prop, { get: fn, set: fn, configurable: true })看一个具体例子class Person { constructor(name) { this._name name; } // 实例方法挂到 Person.prototype sayHello() { return Hello, ${this._name}; } // 静态方法挂到 Person 函数对象 static createDefault() { return new Person(Unknown); } // getter挂到 Person.prototype get name() { return this._name; } set name(value) { this._name value.toUpperCase(); } } // 验证挂载位置 console.log(Person.prototype.sayHello); // function console.log(Person.createDefault); // function console.log(Person.prototype.name); // undefinedgetter 不是属性是 accessor console.log(Object.getOwnPropertyDescriptor(Person.prototype, name)); // { get: f, set: f, configurable: true }这里有个经典误区static method是“类方法”但它和 Java 的static有本质区别。Java 的static属于类不参与多态而 JavaScript 的static method是函数对象的属性它本身也可以被继承class Student extends Person后Student.createDefault()是可以调用的因为Student.__proto__ Person查找链会走到Person.createDefault。实操心得静态方法适合放工具函数如createDefault,fromJSON但要小心继承污染。如果某个静态方法绝对不该被子类继承就别用static改用普通函数或模块级函数。3.4 extends 和 super继承的语法糖背后全是 prototype 操作extends和super是class语法糖里最易误解的部分。很多人以为extends Parent是“让 Student 类拥有 Parent 类的所有方法”其实它只做了一件事设置Student.prototype.__proto__指向Parent.prototype。我们用原生 JS 模拟class Student extends Person// ES6 class 写法 class Person { constructor(name) { this.name name; } sayHello() { return Hello, ${this.name}; } } class Student extends Person { constructor(name, grade) { super(name); // 等价于 Person.call(this, name) this.grade grade; } study() { return ${this.name} is studying; } } // 等价的 ES5 写法手写 function Student(name, grade) { Person.call(this, name); // super(name) 的等价操作 this.grade grade; } // 关键设置原型链 Student.prototype Object.create(Person.prototype); Student.prototype.constructor Student; // 添加子类方法 Student.prototype.study function() { return ${this.name} is studying; };super()在构造函数里就是Parent.call(this, ...args)在方法里就是Parent.prototype.methodName.call(this, ...args)。super不是一个对象而是一个关键字它在不同上下文指向不同东西在constructor中指向父类的构造函数Parent在实例方法中指向父类的prototypeParent.prototype在静态方法中指向父类本身Parent。验证一下class Animal { constructor(name) { this.name name; } speak() { return ${this.name} makes a sound.; } static getType() { return Animal; } } class Dog extends Animal { constructor(name, breed) { super(name); // 调用 Animal 构造函数 this.breed breed; } speak() { return super.speak() Woof!; // 调用 Animal.prototype.speak } static getType() { return Dog ( super.getType() ); // 调用 Animal.getType } } console.log(new Dog(Buddy, Golden).speak()); // Buddy makes a sound. Woof! console.log(Dog.getType()); // Dog (Animal)注意super只能在class内部使用且必须在this被初始化后即constructor里super()必须在this访问前。这是class的硬性规定违反就报错。而手动写Person.call(this)没这限制你可以this.name xxx后再Person.call(this)但class不允许因为它要保证this的一致性。4. 实操过程从零构建一个可调试的 class 系统看清每一步发生了什么4.1 创建一个可追踪的 Person 类注入日志观察原型链我们不直接写class而是用function构造函数 Object.defineProperty手动模拟每一步都加日志看清class语法糖到底做了什么。// Step 1: 创建构造函数并记录创建过程 console.log(--- Step 1: Defining constructor ---); function Person(name) { console.log([Person constructor] called with name${name}); console.log([Person constructor] this is ${this} (should be new object)); this.name name; console.log([Person constructor] set this.name ${name}); } // Step 2: 设置 prototype并添加方法带日志 console.log(\n--- Step 2: Setting up prototype ---); Person.prototype { constructor: Person, // 显式设置 constructor sayHello() { console.log([Person.prototype.sayHello] this.name ${this.name}); return Hello, ${this.name}; } }; console.log([Step 2] Person.prototype.constructor Person? ${Person.prototype.constructor Person}); // Step 3: 验证原型链 console.log(\n--- Step 3: Verifying prototype chain ---); const person new Person(Alice); console.log([Step 3] person.__proto__ Person.prototype? ${person.__proto__ Person.prototype}); console.log([Step 3] Person.prototype.__proto__ Object.prototype? ${Person.prototype.__proto__ Object.prototype}); console.log([Step 3] person.sayHello(): ${person.sayHello()});运行这段代码你会看到清晰的日志流--- Step 1: Defining constructor --- --- Step 2: Setting up prototype --- [Step 2] Person.prototype.constructor Person? true --- Step 3: Verifying prototype chain --- [Person constructor] called with nameAlice [Person constructor] this is [object Object] (should be new object) [Person constructor] set this.name Alice [Step 3] person.__proto__ Person.prototype? true [Step 3] Person.prototype.__proto__ Object.prototype? true [Person.prototype.sayHello] this.name Alice [Step 3] person.sayHello(): Hello, Alice现在我们用class重写观察日志是否一致console.log(\n--- Class version ---); class PersonClass { constructor(name) { console.log([PersonClass constructor] called with name${name}); console.log([PersonClass constructor] this is ${this}); this.name name; } sayHello() { console.log([PersonClass.sayHello] this.name ${this.name}); return Hello, ${this.name}; } } const personClass new PersonClass(Bob); console.log([Class] personClass.sayHello(): ${personClass.sayHello()});日志几乎一样证明class确实只是语法糖。但注意class版本里PersonClass.prototype.constructor是自动设置的你不用手动写而且sayHello方法是不可枚举的for...in遍历时不会出现。4.2 实现一个带私有字段的 Student 类用 # 语法和闭包双方案对比ES2022 引入了#私有字段语法但很多人不知道它和闭包实现的区别。我们用两种方式实现Student并对比内存和性能。方案一# 私有字段推荐现代标准class Student { #grade; // 私有字段只能在 class 内部访问 #school MIT; // 私有字段可带默认值 constructor(name, grade) { this.name name; this.#grade grade; } getGrade() { return this.#grade; } setGrade(newGrade) { if (newGrade 0 newGrade 100) { this.#grade newGrade; } } getSchool() { return this.#school; } } const stu new Student(Charlie, 95); console.log(stu.getGrade()); // 95 console.log(stu.#grade); // SyntaxError: Private field #grade must be declared in an enclosing class#语法的私有字段是语言级的#grade在编译时就被解析无法通过任何反射手段如Object.getOwnPropertyNames访问真正做到了“私有”。方案二闭包模拟私有兼容旧环境function createStudent(name, grade) { let _grade grade; // 闭包变量外部无法访问 const _school MIT; // 常量 return { name, getGrade() { return _grade; }, setGrade(newGrade) { if (newGrade 0 newGrade 100) { _grade newGrade; } }, getSchool() { return _school; } }; } const stu2 createStudent(David, 87); console.log(stu2.getGrade()); // 87 console.log(stu2._grade); // undefined —— 闭包变量不可访问闭包方案的问题是每次createStudent调用都会创建一个新对象而#字段是实例属性共享同一个prototype内存更优。#字段还支持私有方法#privateMethod()闭包方案要模拟就得把方法也塞进返回对象里代码臃肿。实操心得新项目一律用#私有字段。如果要兼容 IE 或老 Node.js用 Babel 编译它会把#编译成 WeakMap 存储原理是WeakMap.set(this, { #grade: value })比闭包更接近原生语义。4.3 调试 class 继承用浏览器开发者工具实时查看原型链这是最实用的技巧。打开 Chrome DevTools执行以下代码class Animal { constructor(name) { this.name name; } speak() { return ${this.name} makes a sound.; } } class Cat extends Animal { constructor(name, color) { super(name); this.color color; } meow() { return ${this.name} says meow!; } } const myCat new Cat(Luna, Black);然后在 Console 里输入// 查看 myCat 的原型链 myCat.__proto__; // Cat.prototype myCat.__proto__.__proto__; // Animal.prototype myCat.__proto__.__proto__.__proto__; // Object.prototype myCat.__proto__.__proto__.__proto__.__proto__; // null // 查看方法来源 myCat.meow; // function meow() { ... } —— 来自 Cat.prototype myCat.speak; // function speak() { ... } —— 来自 Animal.prototype myCat.toString; // function toString() { ... } —— 来自 Object.prototype // 查看 constructor myCat.constructor; // Cat myCat.constructor.prototype; // Cat.prototype myCat.constructor.prototype.constructor; // Cat在 Sources 面板里把断点打在myCat.speak()调用处进入 debugger按 F10 单步你会看到执行流从Cat.prototype-Animal.prototype-Object.prototype这就是[[Prototype]]查找的实时过程。class语法糖没有改变这个过程它只是让你不用手动写Object.create。5. 常见问题与排查技巧实录那些年我们踩过的 class 坑5.1 “Uncaught ReferenceError: Must call super constructor in derived class before accessing this or returning from derived constructor”这是class继承中最常见的报错。原因很简单子类构造函数里this的初始化依赖于super()的调用。super()不仅调用父构造函数还负责给this绑定正确的原型即this.__proto__ Child.prototype。如果你在super()前访问this引擎就不知道this该指向哪个原型所以直接报错。错误写法class Student extends Person { constructor(name, grade) { console.log(this.name); // ❌ ReferenceErrorthis 还未初始化 super(name); this.grade grade; } }正确写法class Student extends Person { constructor(name, grade) { super(name); // ✅ 必须第一行 console.log(this.name); // ✅ 此时 this 已初始化 this.grade grade; } }排查技巧在 VS Code 里装 ESLint 插件规则no-this-before-super: error会实时标红。Webpack/Babel 编译时也会检查但 runtime 报错更直观。5.2 “Uncaught TypeError: Class constructor Person cannot be invoked without new”这个错说明你试图像普通函数一样调用class。常见于React 事件处理中忘记 bindclass MyComponent extends React.Component { handleClick() { console.log(this.props); // this 是 undefined因为 handleClick 被当作普通函数调用 } render() { return button onClick{this.handleClick}Click/button; // ❌ this.handleClick 丢失 this } }解决用箭头函数onClick{() this.handleClick()}或在 constructor 里this.handleClick this.handleClick.bind(this)。第三方库回调中传入 class 方法const timer setTimeout(Person.sayHello, 1000); // ❌ sayHello 的 this 是 undefined // 正确setTimeout(() new Person(A).sayHello(), 1000)根本原因class构造函数的[[Construct]]内部方法被调用时会检查new.target。而setTimeout这类异步回调调用方式是fn()new.target为undefined触发报错。5.3 “Cannot set property xxx of undefined” —— this 绑定失效的连锁反应class方法默认不绑定this这是为了性能避免每次实例化都bind。但这也导致this容易丢失。典型场景class Counter { constructor() { this.count 0; } increment() { this.count; // this 是 undefined 如果 increment 被单独调用 } render() { // ❌ 错误increment 被当作普通函数传入 return button onclick${this.increment}Count: ${this.count}/button; } }onclick${this.increment}会把increment方法转成字符串function increment() { this.count; }点击时执行this指向windowwindow.count是undefined所以后是NaN。解决方案对比方案代码优点缺点箭头函数推荐increment () { this.count; }this永远绑定实例写法简洁每个实例都存一份函数内存稍高bind 在 constructorconstructor() { this.increment this.increment.bind(this); }一次绑定复用constructor 里代码变多事件内联绑定onclick${() this.increment()}不改 class 定义模板字符串里写函数可读性差实操心得对于 React/Vue 等框架用箭头函数是最佳实践。对于纯 JS 项目如果方法会被频繁传递如addEventListener在 constructor 里bind更高效。5.4 “Maximum call stack size exceeded” —— 继承链过长或 super 调用错误当super()调用形成循环时会爆栈。最隐蔽的是静态方法继承class A { static foo() { return A; } } class B extends A { static foo() { return B super.foo(); // ✅ 正常 } } class C extends B { static foo() { return C super.foo(); // ✅ 正常 } } // ❌ 错误在 A 里调用 super.foo() class A { static foo() { return A super.foo(); // ❌ super.foo() 是 undefined但调用 undefined() 就报错 } }super在静态方法里指向父类但如果父类没有该静态方法super.foo是undefinedundefined()就是TypeError不是RangeError。真正的爆栈发生在super调用自身时class Recursive { constructor() { super(); // ❌ 没有父类super() 报错但如果是 class Recursive extends Recursive 呢 } } // class Recursive extends Recursive {} // ❌ 语法错误不允许自继承所以爆栈更多来自get/set的无限递归class Bad { get value() { return this.value; // ❌ 无限递归 } }排查技巧在 Chrome DevTools 的 Sources 面板开启 “Async Stack Traces”它会显示完整的调用链一眼看出哪一层在重复。5.5 class 与 JSON 序列化的陷阱方法不会被序列化但 getter 会JSON.stringify()只序列化对象的可枚举自有属性own enumerable properties。class的实例方法是不可枚举的所以不会出现在 JSON 里这是好事。但getter是个例外class Person { constructor(name) { this.name name; } get fullName() { return ${this.name} Smith; } } const p new Person(John); console.log(JSON.stringify(p)); // {name:John} —— fullName getter 没出现 console.log(p.fullName); // John Smith // 但如果 getter 里有副作用呢 class PersonWithSideEffect { get data() { console.log(Fetching data...); // 这个 log 会在 JSON.stringify 时触发 return { id: 1 }; } } const p2 new PersonWithSideEffect(); JSON.stringify(p