
很多人学JavaScript的时候走到面向对象这一步就开始犯迷糊了。倒不是概念本身有多难而是网上教程要么讲得太深一上来就是原型链、constructor、[[Prototype]]把人绕晕要么讲得太浅把class语法一摆说“这就是面向对象”结果遇到this丢失、继承设计混乱照样懵。这篇教程我想换个方式从“面向对象到底解决了什么问题”说起再把原型、构造函数、class这些核心概念一个个掰开揉碎最后用一段实际代码带你把流程走一遍。内容更适合已经能熟练写函数、但对“对象化”还没有完整认知的同学也适合想补一补底层原理、面试前想系统梳理一遍的开发者。1. 面向对象的核心价值从解决问题的方式开始1.1 面向过程写法到底痛点在哪先别急着聊语法我请你回忆一下自己写的第一个“有点复杂”的JavaScript程序。大概率长这样定义几个全局变量写几个操作这些变量的函数然后按顺序调用。比如做一个购物车let items []; let total 0; function addItem(name, price) { items.push({ name, price }); total price; render(); } function removeItem(index) { total - items[index].price; items.splice(index, 1); render(); }这段代码逻辑上没问题但只要你继续往下加需求痛点就会一个个冒出来。比如页面里有两份独立的购物车一份是“普通购物车”一份是“待结算清单”你总不能把items拆成items1和items2再把每个函数都复制一份改成操作items2吧这就是面向过程写法最大的问题数据是数据行为是行为两者之间的联系全靠你在脑子里记住“哪个函数改哪个变量”。代码一旦超过几百行记住的负担就会超过大脑处理能力。另一个痛点是全局状态互相污染。total放在全局万一另一个模块也定义了一个叫total的变量两个就打架了。你可能会说用模块化啊ES Module确实能解决一部分作用域问题但它解决不了“同一种业务逻辑需要多个独立实例”的问题。这时候就需要把“数据 操作数据的函数”打包在一起打包出来的东西就叫对象。1.2 对象化思维的转变从“执行步骤”到“一群协作的角色”面向对象并不是什么高深理论它只是换了一种组织代码的视角。面向过程的视角是“我要做什么”于是拆成步骤先取数据再判断再更新再渲染。面向对象的视角是“我的程序里有哪些角色”每个角色负责自己的数据和自己的行为。还是购物车的例子用对象思维来看程序里有一个角色叫Cart它自己维护一份items和total它自己知道怎么addItem、怎么removeItem。页面里需要两个购物车就创建两个Cart实例互不干扰。这个转变听起来很简单但它会改变你拆解需求的方式拿到需求先问“这里面有几个对象每个对象要管哪些数据对象之间怎么协作”而不是先问“第一步执行什么”。你还可以用一个类比来理解面向过程就像你在厨房里自己把所有菜切好、配好、按顺序下锅面向对象更像是你雇了一个厨师团队每个厨师只负责自己的灶台和食材你只需要告诉“凉菜师傅”和“热菜师傅”各自准备什么。协作方式变了复杂程度高了以后后者明显更容易管理。1.3 从代码复用看面向对象的真正优势很多人以为面向对象只是为了把代码写好看其实它的核心收益是复用和隔离。复用有两个层面同一份逻辑能创建多个互不影响的实例能基于已有的类扩展出新类。隔离则体现在对象内部的状态不该被外部随意修改对外只暴露必要的接口。举个例子团队里有一套日志上报逻辑既有控制台输出又需要上报到某统计平台。如果把这段逻辑封装成一个Logger对象那么任何模块要打日志只需要logger.info(xxx)至于这条日志是写控制台还是走网络调用方完全不需要关心。这就是封装的价值。而后续要在Logger基础上扩展一个带缓冲批量上报的BufferedLogger用继承或者组合都能做到基于已有能力扩展新能力这就是复用的价值。很多初学者容易陷入一个误区觉得“写个class才算面向对象”。其实在JavaScript里你完全可以用对象字面量、工厂函数甚至普通函数与闭包来实现同样的封装效果。面向对象是一种思想class只是它的表现工具之一。这一点在你阅读下文时会越来越清楚。2. 原型与原型链JavaScript面向对象的底层逻辑2.1 为什么JavaScript面向对象的底子是原型而不是类如果你接触过Java或C会习惯“类-实例”这种静态模型先定义类再根据类创建实例类和实例是“图纸与产品”的关系。JavaScript刚诞生的时候为了蹭Java的热度语法上采用了class关键字但底层却设计了一套完全不同的机制——原型。Brendan Eich在短短十几天里设计语言他选择原型链一部分原因是它更灵活另一部分原因是实现起来更轻量。原型模型的核心思想是“对象与对象之间可以通过关联来共享能力”。不是先有图纸后有产品而是“先有一个对象其他对象以它为模板”。这个模板对象就是原型prototype。在JavaScript里每个对象都有一个隐藏的关联[[Prototype]]当你访问一个对象不存在的属性时引擎会顺着这个关联去找直到找到为止。这一条链式查找路径就是原型链。这带来一个跟class语言完全不同的特性你可以在运行时动态地给原型添加方法所有关联到该原型的对象立刻就能用上。这在Java里是无法想象的。早期很多库就是利用这个特性给内置对象扩展方法虽然现在不推荐这么做但你必须理解为什么当时可以这么玩。2.2 prototype、proto、constructor三者到底什么关系这一节是面试高频区也是很多人绕晕的第一站。我直接给你一张“关系地图”然后逐条解释。function Animal(name) { this.name name; } const dog new Animal(旺财); // 它们各自指向什么 Animal.prototype.constructor Animal; // true dog.__proto__ Animal.prototype; // true Animal.prototype.__proto__ Object.prototype; // true解释一下Animal是一个函数函数也是对象它身上自带一个prototype属性这个属性指向一个普通对象叫“原型对象”。原型对象默认有一个constructor属性指回Animal本身。然后dog是一个实例对象实例对象没有prototype属性但它内部有[[Prototype]]在浏览器环境可以通过__proto__访问。dog.__proto__指向的就是Animal.prototype。所以你只需要记住三条线函数与原型对象函数.prototype- 原型对象原型对象.constructor- 函数实例与原型对象实例.__proto__- 原型对象原型对象之间的链条Animal.prototype.__proto__-Object.prototype-null这个结构意味着什么当访问dog.name时引擎先在dog自身找找到了就用访问dog.sleep时dog自身没有就顺着__proto__去Animal.prototype找找到了就用如果再找不到就去Object.prototype找最终都找不到就返回undefined。需要额外提醒一点__proto__并不是标准规范里的正式API只是浏览器实现的便捷访问器。标准做法是用Object.getPrototypeOf(obj)来获取原型用Object.setPrototypeOf(obj, proto)来设置原型。写库的时候尽量别直接操作__proto__因为它在极端场景下性能有损耗而且语义上不推荐。2.3 new一个对象时底层到底执行了什么new是JavaScript里最容易被误解的关键字。很多人以为new就是“调用一下构造函数返回一个对象”其实new干的事比这多几步。我拆给你看function Animal(name) { this.name name; } // 等价于手动执行以下步骤 function myNew(Constructor, args) { // 1. 创建一个新对象该对象的原型指向构造函数的prototype const obj {}; Object.setPrototypeOf(obj, Constructor.prototype); // 2. 执行构造函数this指向新对象并传入参数 const result Constructor.apply(obj, args); // 3. 如果构造函数显式返回了对象则返回该对象否则返回新对象 return typeof result object result ! null ? result : obj; } const dog myNew(Animal, [旺财]);这就是new的全部秘密。第一步创建对象并关联原型很多人会忽略但正是这一步让实例能通过原型链访问到方法。第二步执行构造函数给新对象添加自己的属性。第三步是一个容易忽略的细节如果构造函数里返回了一个对象那么new的结果会变成那个对象而不是新创建的对象。比如构造函数里写了return { a: 1 }你拿到的实例就没有原型的关联。这个坑在封装类库时偶尔会遇到值得留意。还有一点ES6之后new.target可以让你判断当前函数是否通过new调用。如果直接调用构造函数比如Animal(旺财)在没有new的情况下this指向window而且不会创建实例对象。更糟糕的是严格模式下会直接报错。我见过不少新手在这里踩坑构造函数写成普通函数调用结果属性全部挂到全局对象上bug极其隐蔽。2.4 属性查找、遮蔽与hasOwnProperty原型链带来一个非常有意思的问题属性遮蔽。假设原型Animal.prototype上有个sayName方法实例dog自己也有一个sayName属性访问dog.sayName时引擎先找到实例自身的属性直接返回不会继续去原型上找。这就是遮蔽就像你家自己装了水龙头就不会去小区公共水龙头接水。判断属性到底是“实例自身的”还是“继承来的”有两个工具hasOwnProperty和in。它们的行为完全不同sayName in dog; // truein操作符会沿着原型链查找 dog.hasOwnProperty(sayName); // false只检查自身这个区别在遍历对象、做数据序列化时特别重要。比如for...in循环会把原型上可枚举的属性也遍历出来这往往不是你想要的结果。所以处理数据对象时最好用Object.keys配合hasOwnProperty过滤或者干脆用Object.entries。我最初学原型链的时候总觉得抽象后来想通了一个类比原型链就像人类的知识传承你不是天生什么都会但你身边的“长辈对象”原型链上的祖先会你调用某个能力时自己不会就去找长辈长辈再不会就去找更年长的一直找到最顶端的智者Object.prototype再找不到就只能告诉你“不存在”。这一条路就是原型链。3. 从构造函数到class继承、封装与语法糖3.1 class到底是不是一颗糖ES6引入的class让JavaScript的面向对象写法一下子清爽了很多。但你心里要清楚它底层依然是原型链只是换了一层更友好的皮。看下面这个对比// 老写法 function Animal(name) { this.name name; } Animal.prototype.sayName function() { console.log(this.name); }; // class写法 class Animal { constructor(name) { this.name name; } sayName() { console.log(this.name); } }两种写法几乎等价但class有几个隐藏差异。第一class的方法默认不可枚举而构造函数原型上的方法用for...in能遍历到。第二class必须用new调用直接当函数调用会抛错。第三class内部默认启用严格模式避免了this指向全局的尴尬。这些差异在平时影响不大但面试被问到“class和构造函数有什么区别”时你需要能说出个一二三。我还要提醒一点class声明没有“变量提升”。你可以在定义之前调用普通函数因为函数声明会被提升但class不行。如果你写了new Foo()然后再定义class Foo会得到一个ReferenceError。这在调试代码时很容易误判先记住这个特性能省不少排查时间。3.2 extends与super继承的底层拆解extends写起来很简单但很多人不理解它背后做了什么。看看这个例子class Animal { constructor(name) { this.name name; } eat() { console.log(${this.name} 吃东西); } } class Dog extends Animal { constructor(name, breed) { super(name); this.breed breed; } play() { console.log(${this.name} 玩球); } }子类Dog自动做了两件事第一将Dog.prototype的原型指向Animal.prototype第二创建一个Dog实例时会先执行父类的构造函数来初始化父类层面的状态。第一点在原型链层面的表现是Object.getPrototypeOf(Dog.prototype) Animal.prototype第二点则是super存在的意义。记住一条铁律有extends的子类构造函数里必须先调super()才能使用this。这个规则并不是没理由的子类实例的初始化依赖于父类先行完成自己的初始化。如果你不写super()子类实例就处于一个“未完成”的状态访问this会报错。很多同学一上来就写constructor() { this.breed ... }结果报“Must call super constructor in derived class before accessing this”就是这个原因。另外一个细节是方法重写与super调用。子类可以重写父类方法但如果你还希望保留父类逻辑可以在子类方法里用super.xxx()调用。这会沿着原型链找到父类原型上的方法同时this依然是当前实例不会丢。3.3 静态方法、私有字段与getter/setter进阶的class特性里有几个经常在实际项目里用到我一次讲清楚。静态方法static关键字定义的方法挂在“类本身”上而不是实例上。比如Dog.createPuppy()这样的工厂方法你不需要创建实例就能调用但它拿不到实例的this。适合放一些工具方法、构造函数前的预处理逻辑。私有字段JavaScript从ES2022开始正式支持#开头的私有字段。注意不是private而是#name这种写法class User { #password; constructor(name, password) { this.name name; this.#password password; } checkPassword(input) { return this.#password input; } }#password在类外部完全访问不到连Object.keys、JSON.stringify都会忽略它。这是语言层面真正意义上的私有过去靠“下划线约定 闭包 WeakMap”模拟的私有其实都各有漏洞。实际开发中涉及敏感数据的字段强烈建议用#私有字段。getter/setter用get和set定义访问器属性可以在读取和赋值时插入逻辑class Cart { get total() { return this.items.reduce((sum, i) sum i.price, 0); } }这样cart.total看起来像一个普通属性但实际上每次访问都会实时计算。好处是调用方不需要写方法语义上更自然。但注意别在getter里做太重的计算否则每次访问都会有性能开销。还有个容易被忽略的点私有字段必须在声明时就初始化不能像普通属性那样在constructor里通过this.xxx 动态创建。这一点和TypeScript的private关键字也不同TS只是编译期检查而#是运行时的真正保护。4. 实战重构把一段过程式代码改造成面向对象4.1 模拟场景一个任务清单逻辑这一节我不讲虚的用一个贴近实际的小功能走一遍重构流程。假设我们要写一个任务清单模块支持添加任务、切换完成状态、按状态过滤、清空已完成任务。数据很简单但逻辑里涉及多个操作和共享状态非常适合演示面向对象的收益。先用面向过程的思路写一版。这里我会故意写得“正常但原始”很多新手写完就停了所以我们还能对比着看问题在哪。4.2 面向过程版本能跑但不好养let tasks []; let filter all; function addTask(title) { tasks.push({ title, done: false }); renderList(); } function toggleTask(index) { tasks[index].done !tasks[index].done; renderList(); } function setFilter(type) { filter type; renderList(); } function getVisibleTasks() { if (filter all) return tasks; if (filter done) return tasks.filter(t t.done); return tasks.filter(t !t.done); } function clearDone() { tasks tasks.filter(t !t.done); renderList(); }这个版本看起来清晰但有两个明显问题。第一所有状态都在全局如果页面上有两个任务面板分别需要独立的任务列表这一套代码根本没法复用。你不得不再定义一套tasks2、filter2和一组.2后缀函数。第二renderList里读的tasks是外部变量一旦代码模块多了你很难判断某个时刻tasks被谁改过排查问题时得把整个文件从头读到尾。4.3 面向对象版本状态自治行为内聚重构后的目标不是炫技而是让“任务清单”成为一个自治的角色。所有状态挂在实例上所有方法通过实例访问自己的状态。class TaskList { constructor() { this.tasks []; this.filter all; } addTask(title) { this.tasks.push({ title, done: false }); } toggleTask(index) { this.tasks[index].done !this.tasks[index].done; } setFilter(type) { this.filter type; } getVisibleTasks() { const all this.tasks; if (this.filter all) return all; if (this.filter done) return all.filter(t t.done); return all.filter(t !t.done); } clearDone() { this.tasks this.tasks.filter(t !t.done); } get totalCount() { return this.tasks.length; } get doneCount() { return this.tasks.filter(t t.done).length; } }使用起来就非常自然了const workBoard new TaskList(); const lifeBoard new TaskList(); workBoard.addTask(完成季度报表); workBoard.addTask(评审代码); lifeBoard.addTask(买菜); workBoard.toggleTask(0); console.log(workBoard.getVisibleTasks()); // [{ title: 完成季度报表, done: true }, { title: 评审代码, done: false }] console.log(lifeBoard.getVisibleTasks()); // [{ title: 买菜, done: false }]两个实例互不干扰每个实例的方法都只操作自己的数据。将来要增加“修改任务标题”“拖拽排序”等功能也只需要在TaskList类里加对应方法不会牵扯到页面里其他变量。测试起来也更方便你只需要构造一个TaskList调用方法断言状态不需要先清理全局数据。这里有一个设计上的小技巧值得说一下我把doneCount和totalCount做成了getter而不是普通方法。调用方写board.doneCount比写board.getDoneCount()更符合直觉尤其在模板渲染时读属性比调方法更简洁。这也是我在实际项目中比较喜欢的封装风格。4.4 进阶演化多态与组合任务清单需求稳定之后可能还会演化出一个新的需求不同类型的任务有不同的优先级展示。比如“普通任务”按创建时间排序“紧急任务”要置顶且标红。这种差异化逻辑如果塞进TaskList里类会越来越臃肿。一个更稳妥的处理方式是把“排序与展示策略”抽成独立对象然后在主类里组合它们。class PriorityStrategy { sort(tasks) { return [...tasks].sort((a, b) (a.urgent ? -1 : 0) - (b.urgent ? -1 : 0)); } } class TaskList { constructor() { this.tasks []; this.strategy new PriorityStrategy(); } getVisibleTasks() { return this.strategy.sort(this.tasks); } }这段代码的价值在于后续要换成“按截止时间排序”的策略只需要新增一个实现同样sort方法的类替换掉this.strategy即可TaskList本身完全不用动。这就是组合优于继承的典型实践。很多人一听到“面向对象”就想到extends其实在实际项目里组合用得远比继承多因为它不会强绑定类之间的层次关系改动时风险更小。4.5 什么时候不该强行对象化我必须泼一盆冷水并不是所有场景都适合class。如果你只是写一个页面里瞬间使用的回调函数函数式写法反而更简洁如果业务数据只是一堆JSON配置你硬塞进class里反而多了一层无意义的方法包裹。判断的标准很简单这个状态和行为的组合是否需要被创建多个实例是否需要跨模块传递是否会被扩展三个问题全是是才值得做面向对象设计有一个否就可以斟酌简化。我带过的一个项目里有个同事把每个接口的返回值都包成一个class结果每个接口响应还得手动new一次代码量翻倍纯粹讨好概念。工程师要有自己的判断力工具为人服务不是人为工具服务。5. 常见问题与排查技巧实录5.1 this丢失面向对象编程头号噩梦写class不到一周你大概率会碰上一个报错Cannot read properties of undefined (reading xxx)或者方法里的this变成了undefined。最常见的是下面这种场景class Utils { constructor() { this.name 工具; } handle() { console.log(this.name); } } const u new Utils(); setTimeout(u.handle, 1000); // 报错或输出undefined问题出在你把u.handle当独立函数传给了setTimeout它不在“u.handle()”这种调用方式里this自然不会再指向u。修复方式有几种// 方式一箭头函数包装 setTimeout(() u.handle(), 1000); // 方式二bind绑定 setTimeout(u.handle.bind(u), 1000); // 方式三构造器里绑定 constructor() { this.name 工具; this.handle this.handle.bind(this); } // 方式四类字段箭头函数最推荐 handle () { console.log(this.name); }方式四需要在class里用“类字段”语法把方法定义成箭头函数定义时this就固定为实例。它在React的class组件时代非常常见不过要注意类字段方式定义的方法不在原型上每个实例都会创建一份函数实例很多时内存开销略高。一般组件类实例数量有限这点开销可以接受。排查这类问题我有一个习惯先在报错处加一行console.log(this)看看它到底指向谁。指向undefined基本就是丢失实例绑定指向window或globalThis则通常是普通函数调用。定位方向明确后修复就很快了。5.2 instanceof、原型链判断与类型检测判断一个对象是不是某个类的实例instanceof是最常用的方案。它的原理不复杂obj instanceof Constructor会沿着obj的原型链查找看是否存在Constructor.prototype。const d new Dog(); d instanceof Dog; // true d instanceof Animal; // true因为Dog.prototype的proto指向Animal.prototype但有几种情况要注意。第一跨作用域或跨框架时不同全局环境下的Array不是同一个引用arr instanceof Array可能返回false。第二instanceof检查的是原型链而不是构造过程你完全可以通过Object.setPrototypeOf修改原型来“伪装”身份。更可靠的检测方式是用Object.prototype.toString.call(obj)Object.prototype.toString.call([]); // [object Array] Object.prototype.toString.call(new Date()); // [object Date]还有一点Object.create(null)创建的对象没有原型链对它调用hasOwnProperty会直接报错访问原型方法也全都不存在。处理外部数据时最好用Object.prototype.hasOwnProperty.call(obj, key)这种“借用函数”的方式避免对象属性被篡改导致的安全问题。5.3 继承设计的三个避坑建议第一浅继承容易深层次别硬来。三层以上的继承会让代码阅读成本急剧上升一个方法不知道是从哪一层来的修改父类可能影响所有子类。实际上大部分业务场景继承两层就够了更复杂的复用请优先考虑组合。第二别在构造器里调用可被子类重写的方法。这是Java里很经典的问题JavaScript也适用。父类构造器执行时调用了一个虚方法如果子类重写了这个方法且子类的初始化顺序还没完成就很容易拿到半初始化的状态。这里的代码会非常难排查因为你看到的调用栈在构造阶段但bug藏在子类的字段初始化顺序里。第三静态方法不会被实例继承但子类可以通过子类名继承调用。这句话有点绕具体说就是Dog.create()可以调用父类的静态方法但d.create()不行因为静态方法挂在构造函数上不在实例原型链上。很多人写工具方法时习惯性的this.xxx()在静态方法里this指向的是类本身而不是实例逻辑完全不同写之前先想清楚。class Animal { static create() { return new Animal(默认); } } class Dog extends Animal {} const d Dog.create(); // 可以 d.create(); // TypeError: d.create is not a function这个区别在写工厂方法、单例模式时尤其容易踩坑我建议你在自己的项目里遇到“通过类创建实例”的需求时先明确方法定位是静态还是实例方法再动手。定位清晰了代码设计基本就稳了一半。我在实际工作中有一个习惯每写一个class先反问自己“这个类有多少个外部调用方它们使用这个类时最可能犯什么错”如果调用方总是需要翻源码才能知道方法怎么调、属性怎么读那说明接口设计还不够清晰。面向对象的封装不是为了藏而是为了让使用者更省心原型链这些底层机制你不需要每次写代码都回忆一遍但真到排查问题的时候理解它和不懂它效率和判断力是完全不同的。