JavaScript Class 全面解析:从原型链到私有字段的实战指南

📅 发布时间:2026/9/16 0:00:22
JavaScript Class 全面解析:从原型链到私有字段的实战指南
1. 先搞清楚Class 到底是什么为什么大家都在用第一次接触 JavaScript 的时候我脑子里对“面向对象”的印象一直停留在“用函数封装状态和行为”这个层面。后来 ES6 正式引入了 Class 语法社区里争论过一阵子“JavaScript 到底需不需要 Class”。说实话Class 不是一种全新的能力它更像是一层语法糖把 JavaScript 里原本基于原型链的写法包装成了更直观、更接近主流面向对象语言的形式。很多人听到“语法糖”三个字会觉得这东西不高级。但我的看法刚好相反如果一种语法能让初学者快速上手、能让团队协作时减少理解偏差、能让代码意图更清晰那它就有足够的存在价值。Class 解决的不是“能不能实现”的问题而是“能不能一眼看懂”的问题。举个例子以前我们要创建一个“人”的对象用函数构造器和原型链这样写function Person(name, age) { this.name name; this.age age; } Person.prototype.sayHello function () { console.log(大家好我是 this.name 今年 this.age 岁); }; const p1 new Person(小明, 18); p1.sayHello();这段代码逻辑没问题但对新手来说“prototype”是什么、为什么要往 prototype 上挂方法、new 的时候到底发生了什么每一个点都能劝退一批人。Class 写法则要直观得多class Person { constructor(name, age) { this.name name; this.age age; } sayHello() { console.log(大家好我是${this.name}今年${this.age}岁); } } const p1 new Person(小明, 18); p1.sayHello();两段代码做的事情一模一样但后者对阅读者友好得多。我们可以专注于“这个类有哪些属性、哪些行为”而不是被底层机制的细节绊住手脚。这篇文章适合刚接触 JavaScript 不久、对 Class 似懂非懂的读者也适合那些写过一阵子 function 但一直没系统梳理过 Class 知识点的同学。我会从最基础的概念讲起一步步拆到继承、私有字段、静态成员再结合我在真实项目中踩过的坑帮你把这块知识补完整。2. 从“工厂模式”到 Class理解对象创建方式的演变2.1 为什么需要“模板”来创建对象在日常开发里我们经常会遇到这种情况需要创建多个结构相似的对象。比如一个购物车里有好几个商品每个商品都有名称、价格、数量还需要能计算小计的方法。最笨的办法是写几个对象字面量const product1 { name: 手机, price: 2999, count: 1, subtotal() { return this.price * this.count; }, }; const product2 { name: 耳机, price: 499, count: 2, subtotal() { return this.price * this.count; }, };如果只有两个商品这么写还能接受但一旦商品数量增加到十个、五十个代码就会变得非常臃肿而且很容易出现“某个商品忘写 subtotal 方法”之类的低级错误。这个时候我们需要的是一套可复用的“模板”只要传不同的参数进去就能得到不同内容、但结构完全一致的对象。这个“模板”思维其实就是 Class 的核心思想。Class 定义的是对象的结构和行为框架new 关键字负责按照框架创建出具体的实例。每个实例都有自己的属性值但共享同一套行为定义这样既节省了内存也统一了逻辑。2.2 函数构造器与 Class 的本质联系很多 JavaScript 老手喜欢强调“Class 本质还是函数”这句话是对的。我们可以在浏览器控制台里做个小实验class Person {} console.log(typeof Person); // function看到没typeof 输出的是 function。Class 本质上就是一个构造函数只是它的写法更规范、更严格。比如 Class 声明和函数声明有几个明显区别Class 声明不会提升no hoisting在声明之前使用会报错Class 内部的代码默认运行在严格模式下Class 不可以直接被调用必须用 new 来创建实例这里我特别说一下第一点。函数声明有个特性叫“提升”你可以在声明之前调用它sayHello(); function sayHello() { console.log(hello); }但 Class 不行const p new Person(); // ReferenceError: Cannot access Person before initialization class Person { constructor() { this.name 张三; } }这个区别在实际开发中很重要。如果你习惯了函数的书写顺序突然换用 Class 时很容易踩到“变量未初始化”的报错。我的经验是把 Class 定义放在文件靠前的位置或者单独抽成一个模块导出避免依赖提升特性。3. 手写一个 Class核心结构逐块拆解3.1 constructor每次 new 都在这里初始化constructor 是 Class 里最先要被理解的成员。它的作用是在创建实例时执行一些初始化逻辑。简单说你每次调用 new Person()constructor 里的代码就会自动执行一遍。class Person { constructor(name, age) { console.log(constructor 被调用了参数是, name, age); this.name name; this.age age; } } const p1 new Person(小明, 18); // constructor 被调用了参数是 小明 18 console.log(p1.name); // 小明 console.log(p1.age); // 18constructor 里最常见的操作就是把传入的参数绑定到新创建的对象上。注意这里用的是 this它指向的就是新创建的那个实例。如果 constructor 不写Class 会给一个默认的空构造器等价于 constructor() {}对象仍然能被创建只是没有初始化任何属性。有一个细节值得注意constructor 里可以 return 一个对象。如果返回的是对象类型new 的结果就会变成那个对象如果返回的是原始类型那么 new 仍然返回 this 指向的实例。这个特性用的场景很少但面试偶尔会问到理解了“constructor 可以拦截 new 的结果”这一点就足够了。class Foo { constructor() { return { custom: true }; } } const instance new Foo(); console.log(instance.custom); // true console.log(instance instanceof Foo); // false3.2 实例属性与方法哪些属于“每一个对象”constructor 里通过 this.name name 创建的属性叫做实例属性。每个实例都有一份独立的副本。比如两个 Person 实例p1.name 改掉不会影响 p2.name。除了在 constructor 里定义属性ES2022 开始也支持直接在类的顶层声明属性字段class Person { name 张三; // 直接写字段名 初始值 age 0; constructor(name, age) { if (name) this.name name; if (age) this.age age; } } const p1 new Person(); console.log(p1.name); // 张三 const p2 new Person(李四, 25); console.log(p2.name); // 李四这种写法更直观也更容易一眼看出类有哪些“状态”。而且顶层字段初始化发生在 constructor 执行之前如果你想在 constructor 里覆盖字段值是可以直接覆盖的。类的方法则不同它们定义在 prototype 上被所有实例共享。这意味着不管创建多少个实例方法只有一份拷贝不会浪费内存。你可以在 console 里验证一下class Person { constructor(name) { this.name name; } sayHello() { return hello, ${this.name}; } } const p1 new Person(张三); const p2 new Person(李四); console.log(p1.sayHello p2.sayHello); // true这里的输出是 true因为 p1 和 p2 的 sayHello 都是同一个函数引用。如果想给某个实例单独加方法直接赋值就行但除非有特殊需求一般不建议这么做因为会破坏实例之间的一致性。3.3 原型链的一次直观映射Class 方法的真实存放位置理解了方法存在 prototype 上我们再往底层走一步。用 Class 定义的类其实和“构造函数 原型方法”的经典写法是对应的class Person { constructor(name) { this.name name; } sayHello() {} } // 等价于 function Person(name) { this.name name; } Person.prototype.sayHello function () {};Object.getPrototypeOf(p1)拿到的原型对象和Person.prototype是同一个对象。这也带来一个实际的用途如果你要给一个类批量添加方法可以直接改它的原型class Person {} Person.prototype.walk function () { return ${this.name} 正在走路; };这种方式在维护老代码时偶尔会遇到理解 Class 和原型的映射关系你在排查“为什么方法在实例上不存在”这类问题时就能快速反应过来。4. 继承与 super子类如何优雅地复用父类逻辑4.1 extends一个项目里最常用的复用方式继承是面向对象里绕不开的话题。在 JavaScript 里用 extends 关键字实现类与类之间的继承关系。父类的公共属性和方法子类可以自动继承下来不用再写一遍。class Animal { constructor(name) { this.name name; } eat() { return ${this.name} 正在吃东西; } } class Dog extends Animal { bark() { return ${this.name} 正在汪汪叫; } } const dog new Dog(旺财); console.log(dog.eat()); // 旺财 正在吃东西 console.log(dog.bark()); // 旺财 正在汪汪叫这里 Dog 没有写 constructor创建实例的时候会默认调用父类的 constructor把参数透传过去。如果你需要在子类里增加新的初始化逻辑就必须写 constructor并且必须在 this 被使用之前调用 super()。4.2 super 的两个使用场景调用父类构造函数与调用父类方法super 有两种用法一种是当函数调用另一种是当对象访问属性。当函数调用时super() 指的是父类的构造函数。它只能在子类 constructor 中使用而且必须在 this 出现之前调用class Dog extends Animal { constructor(name, breed) { super(name); // 调用 Animal 的 constructor初始化 name this.breed breed; } }如果子类写了 constructor但没调 super()使用 this 时会直接报错class Dog extends Animal { constructor(name, breed) { this.breed breed; // ReferenceError: Must call super constructor in derived class before accessing this or returning from derived constructor super(name); } }另一种用法是 super.methodName()在子类方法里显式调用父类的方法这种模式在“扩展某个功能而不是重写整个方法”时非常实用class Dog extends Animal { eat() { const result super.eat(); // 先执行父类逻辑 return ${result}并且还吃骨头; } }4.3 继承链上的 this 指向与“父类拿不到子类属性”的迷思有些初学者会有个困惑子类实例调用父类方法时父类方法里的 this 到底指向谁答案很简单无论方法定义在哪个类里只要最终是某个实例调用的this 就指向那个实例。class Animal { whoAmI() { return this.name; } } class Dog extends Animal { constructor(name) { super(); this.name name; } } const dog new Dog(旺财); console.log(dog.whoAmI()); // 旺财这里的 whoAmI 是父类的方法但调用者是 dog 实例所以 this.name 能取到子类初始化过的值。这个特性为“继承链上的多态”提供了基础子类可以重写父类方法然后在父类方法里通过 this 动态调用到子类的版本。实际项目中我很少设计特别深的继承链因为层级一多代码的可读性和可调试性都会下降。更多时候我用继承去抽象“具有相同基础能力”的模型比如“所有动物都能吃”这类共性很强的场景。当你发现某两个类之间是“有一种”的关系时优先考虑组合而不是继承这是很多老开发总结出来的经验。5. 静态成员、私有字段与 getter/setter接触实用的进阶特性5.1 static属于类而不属于实例的方法和属性有些情况下某个行为并不依赖具体实例。比如创建一个“计算两个数的和”的工具方法或者管理这个类一共创建了多少个实例。这种场景下可以用 static 关键字定义静态成员。class Person { static totalCount 0; constructor(name) { this.name name; Person.totalCount; } } const p1 new Person(张三); const p2 new Person(李四); console.log(Person.totalCount); // 2 console.log(p1.totalCount); // undefined静态属性和静态方法挂在类本身而不是挂在实例上。所以要用 类名.属性名 来访问。静态方法里如果想调用其他静态方法直接用 this 是可行的因为 this 指向类本身但要注意别和实例方法的 this 搞混。一个常见的应用是工具类封装。比如封装日期格式化、请求统一处理等都可以用静态方法实现class DateUtils { static formatNow() { const now new Date(); return now.toISOString(); } } console.log(DateUtils.formatNow());另外静态属性也可以被继承。子类可以通过 父类名 或 this 访问到继承来的静态成员这个特性在某些框架源码里很常见。5.2 私有字段 #真正的“外部不可访问”终于来了JavaScript 的私有字段语法用 # 开头它比约定俗成的下划线 _ 更严格是真正的语言层面的保护。class BankAccount { #balance 0; constructor(initialBalance) { this.#balance initialBalance; } deposit(amount) { if (amount 0) { throw new Error(存款金额必须大于0); } this.#balance amount; } getBalance() { return this.#balance; } } const account new BankAccount(100); account.deposit(50); console.log(account.getBalance()); // 150 console.log(account.#balance); // SyntaxError: Private field #balance must be declared in an enclosing class在类外部访问 #balance 会直接报语法错误这种保护机制对维护大型项目非常有价值。以前用 _ 开头只是“约定”大家靠自觉去遵守现在有了 #可以从语言层面阻止误操作。私有字段有几个特性值得记住它不能在类外部读取或赋值它不会被Object.keys()之类的内置方法枚举它也不能通过instance.#field在类外被动态访问。还有一点私有字段只能在声明它的类内部通过 this.#xxx 访问子类也不可以直接访问父类的私有字段与私有方法。如果子类确实需要用可以在父类暴露一个受保护的方法来间接访问。5.3 getter 和 setter让属性拥有“副作用”能力Class 里的 getter 和 setter 语法可以让你像读取普通属性一样去执行一段函数逻辑。典型的使用场景是在赋值或取值时做校验、格式化或者派发事件。class User { #firstName ; #lastName ; constructor(firstName, lastName) { this.#firstName firstName; this.#lastName lastName; } get fullName() { return ${this.#firstName} ${this.#lastName}; } set fullName(value) { const [firstName, lastName] value.split( ); if (!firstName || !lastName) { throw new Error(fullName 格式不正确需要包含空格); } this.#firstName firstName; this.#lastName lastName; } } const user new User(张, 三); console.log(user.fullName); // 张 三 user.fullName 李 四; console.log(user.firstName); // undefined注意我在 User 里没有定义 firstName 属性所以外部访问 user.firstName 是 undefined。如果希望它能被读取需要在类里加上对应的 getter。getter 和 setter 配合私有字段使用是一种很干净的数据保护方式。从使用体验来看用 getter/setter 封装内部状态可以在“外部看起来像访问普通属性”的同时内部做好各种防御性检查。这比暴露一个 setName/getName 方法更符合面向对象的设计直觉。6. 高频报错与真实踩坑记录把这些坑提前填平6.1 忘记 new 直接调用 ClassClass 和普通函数不一样它不能被直接调用。如果你在代码里写下Person()浏览器会报 “Class constructor Person cannot be invoked without new”。这个报错很容易发生在“参数是一个函数回调里直接传入类名”的场景。比如class Person {} [a, b].map(Person); // TypeError: Class constructor Person cannot be invoked without new因为 map 的回调是直接调用传入的函数并不会加 new 关键字。解决办法就是显式包一层[a, b].map(item new Person(item));6.2 this 指向丢失方法提取后 undefinedClass 方法默认不会自动绑定 this。如果你把方法从实例上取出来单独调用this 就会丢失。class Counter { count 0; increment() { this.count; console.log(this.count); } } const counter new Counter(); const fn counter.increment; fn(); // TypeError: Cannot read properties of undefined (reading count)解决这个问题的常见方案有三种绑定函数到实例属性、使用箭头函数定义方法、调用时用 bind/call/apply。如果想让方法在作为回调时依然保持正确的 this最推荐的方式是在 constructor 里绑定class Counter { count 0; constructor() { this.increment this.increment.bind(this); } increment() { this.count; console.log(this.count); } }或者直接使用类字段和箭头函数class Counter { count 0; increment () { this.count; console.log(this.count); }; }箭头函数没有自己的 this它会继承作用域链上最近的 this也就是实例对象。这种方式在 React 类组件里曾经非常流行现在虽然函数组件是主流但理解这个机制仍然很重要。6.3 子类里漏写 super() 导致初始化失败前面提过子类只要写了 constructor就必须在第一行调用 super()。漏写常见的报错是“Must call super constructor in derived class before accessing this”。这个坑的隐蔽之处在于有时候你可能不是直接访问 this而是通过某个方法间接访问。比如class Animal { constructor() { this.init(); } init() { this.name animal; } } class Dog extends Animal { constructor() { super(); // 如果漏写this.name 赋值时会报错 } }写代码的时候一定要形成条件反射有 extends 就有 super()。我通常会先写好 super()再写其他逻辑避免后面忘了。6.4 私有字段的跨类访问限制私有字段 # 的访问权限限制很严格子类无法直接访问父类的私有字段。我一开始以为“私有字段会不会像私有属性一样继承时会被子类继承”不会。子类方法里访问 this.#parentField 会直接报语法错误。如果你确实需要子类操作父类的某个内部状态可以提供受保护的方法。这个设计看似死板其实对封装性有正向作用父类的内部实现细节不会被随意篡改。6.5 浏览器兼容性带来的意外报错Class 的相关语法在主流浏览器里已经很成熟了但如果你的项目需要跑在旧版浏览器上比如某些企业内部系统的 WebView私有字段 #、静态属性这些较新的特性可能会直接报语法错误。遇到这种情况有两个选择一是使用 Babel 等编译工具把新语法转换成兼容版本二是在项目里约定规范尽量避免使用太新的特性。我的建议是如果项目不是特别需要私有字段可以先不用保持代码最大兼容性如果确实用了必须确认构建链路的转译配置没问题。7. 从面试题到真实项目Class 该怎么用才不显得“过度设计”7.1 面试常考的几个 Class 相关知识点面试中关于 Class 的问题通常会集中在几个方向Class 和 function 构造器有什么区别继承的实现原理以及 super 关键字执行顺序私有字段与闭包实现私有变量的区别把类方法作为回调时怎么解决 this 丢失Class 上的方法为什么不能用 Object.keys 枚举回答这些问题关键不在于背结论而在于能否从原型链的角度解释清楚。比如“Class 方法为什么不能被 Object.keys 枚举”是因为方法定义在 prototype 上而 Object.keys 只获取可枚举的自身属性。这些点理解了面试时就能从容应对。7.2 什么时候应该用 Class什么时候不应该虽然我在前面用了大量篇幅讲 Class 的用法但实际项目里我并不建议任何场景都无脑使用 Class。原因很简单Class 适合承载一组“有状态、有约定、有复用关系”的业务模型但并不适合当纯工具函数库。如果你只是想把几个数学计算函数放一起用对象字面量或者模块导出各个函数就够了export function add(a, b) { return a b; } export function subtract(a, b) { return a - b; }如果有一组数据需要同时维护属性和行为并可能产生多个实例Class 就是合适的工具。比如订单、用户、购物车这类领域模型用 Class 可以把数据属性和业务操作紧密绑定在一起职责划分更清晰。团队协作时Class 还有一个隐性好处它提供了一种“结构约束”。大家看到 class 名称就能推断出这个模块具备哪些属性和行为代码组织更规范。对于大型项目而言这种统一性带来的维护效率提升是很可观的。7.3 一个综合小例子用 Class 做一个简单的任务清单为了把前面讲的特性串起来我写一个简单但完整的示例把 constructor、实例属性、私有字段、静态方法、继承和 getter 都融入进去class Task { static taskCount 0; #status pending; constructor(title) { this.title title; Task.taskCount; } get status() { return this.#status; } set status(newStatus) { if (newStatus ! pending newStatus ! completed) { throw new Error(status 只能是 pending 或 completed); } this.#status newStatus; } complete() { this.status completed; } describe() { return 任务${this.title}状态${this.status}; } } class UrgentTask extends Task { constructor(title, deadline) { super(title); this.deadline deadline; } describe() { const base super.describe(); return ${base}截止时间${this.deadline}; } } const task1 new Task(写周报); task1.complete(); console.log(task1.describe()); const task2 new UrgentTask(修复线上 Bug, 2025-01-10 18:00); console.log(task2.describe()); console.log(Task.taskCount); // 2这段代码里Task 维护了标题和状态状态通过私有字段加 getter/setter 控制读写校验。UrgentTask 继承了 Task并在 describe 方法里用 super.describe() 复用了父类逻辑。静态属性 taskCount 追踪创建的任务数。把这个例子跑一遍你对 Class 的全局认识会清晰很多。7.4 深入实践建议自己动手写几个真实类看再多示例都不如自己动手写几遍。我建议你去实现这几个小功能做完之后 Class 的基础就算打牢了写一个 Stack 类支持 push、pop、peek、isEmpty 方法内部用数组存储数据写一个 EventEmitter 类支持 on、off、emit 方法用来做简单的事件发布订阅写一个 Animal 类派生 Cat 和 Dog 子类重写各自的声音方法每个小练习都会让你更熟悉 Class 的语法和设计思维。过程中遇到报错不用慌对照这篇文章里的常见问题去排查基本都能解决。8. 写在最后我的真实体会是……接触 JavaScript Class 这两年多我最大的体会是语法只是表面真正值钱的是面向对象的设计思维。Class 给了我们一种更自然的表达方式但能不能设计出好用的类仍然取决于你对业务场景的理解。如果在实际项目中遇到“这个功能到底要不要抽成 Class”的纠结我会先问自己三个问题它有没有自己的状态它需不需要被创建多个实例它和现有代码之间有没有稳定的复用关系如果有至少两个答案是肯定的那用 Class 通常是合适的。如果只是单纯想封装几个函数那就不要硬上 Class。最后再分享一个小技巧写 Class 的时候尽量让 constructor 只做“接收参数 初始化属性”这两件事把复杂的业务逻辑抽到方法里。这样类的结构会非常清晰其他人读起来也轻松。你可以在自己的代码里刻意练习一下坚持一段时间会发现类设计越来越顺手。