1. 为什么“构造函数里的方法”值得单独写一篇
很多人学JavaScript的时候,对构造函数的第一印象就是 function Person(name) { this.name = name; },然后 new Person('张三') 创建一个对象。但是等到真正写业务、看源码、或者是准备面试的时候,突然发现“构造函数”这个看似基础的概念,居然能延展出好几种方法的定义方式,而且每种方式的行为还不太一样。
我见过不少开发者在项目里把方法直接写在构造函数内部,过一阵子发现每个实例都维护了一份函数副本,内存开销上去了;也有人一律把所有方法都怼到 prototype 上,结果碰到需要访问实例私有变量的时候傻了眼;还有一部分人直接用 ES6 的 class 语法,却搞不清楚 class 里的方法到底是“自己的”还是“原型上的”。
这篇文章我就围绕“构造函数模式下的方法定义”这件事,把最常见的三种方式——构造函数内部使用 this 定义方法、原型 prototype 上定义方法、ES6 class 中定义方法——掰开揉碎讲清楚。它们是什么、怎么写、内存表现如何、什么时候用哪种、面试官一般怎么追问,全部放在一篇文章里。内容适合刚开始学 JavaScript 基础的朋友,也适合准备前端面试、或者想把自己代码写得更有章法的开发者参考。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先搞懂一件事:构造函数里的 this 到底指向谁
要理解三种方法定义方式的区别,第一步不是看代码怎么写,而是要先把 new 关键字背后发生的事情想明白。很多人背了“new 会创建一个新对象”这句话,但不知道这个对象是怎么和 this 挂钩的。
当你写出 const p1 = new Person('张三') 的时候,JavaScript 引擎内部做了四件事:
- 创建一个全新的空对象;
- 把这个空对象的原型(
__proto__)指向Person.prototype; - 让
Person函数体内的this指向这个新对象; - 如果构造函数没有显式返回一个对象,就把这个新对象作为
new表达式的结果返回。
第 2 步和第 3 步是最关键的。第 2 步决定了“挂在原型上的方法,实例能通过原型链找到”;第 3 步决定了“写在构造函数里的 this.xxx,会直接挂到新对象自己身上”。
所以你会发现,“构造函数内部用 this 定义方法”和“在 prototype 上定义方法”,本质上是在两个不同的位置存放函数。前者放到了每个实例自己的内存里,后者放到了所有实例共享的原型对象上。这个差异直接带来三个深远影响:内存占用、方法是否能访问实例私有变量、子类继承时的行为。
我见过不少初学者把这两个位置搞混,写出来的代码逻辑没错,但从内存和设计角度怎么看都不对劲。所以后面每一个方法定义方式,我都会从“它到底把函数放哪了”这个底层视角开始讲。
3. 三种方法定义方式逐一拆解
3.1 第一种:构造函数内部通过 this 定义方法
最直观的一种写法,就是在构造函数体里面给 this 挂一个函数:
javascript复制function Person(name, age) {
this.name = name;
this.age = age;
this.sayName = function () {
console.log(`我是 ${this.name}`);
};
}
const p1 = new Person('张三', 20);
const p2 = new Person('李四', 22);
p1.sayName(); // 我是 张三
p2.sayName(); // 我是 李四
console.log(p1.sayName === p2.sayName); // false
最后一行比较结果 false,这就是这种写法的核心特征:每次 new 一个实例,都会重新创建一份 sayName 函数。哪怕这个函数逻辑完全一样,它在内存里也是两个独立的函数对象。
优点是什么呢?好处是每个实例都拥有一份完全独立的方法,如果方法内部需要访问构造函数的局部变量(不是 this 上的属性),就只能靠这种写法捕获。举个例子:
javascript复制function Person(name) {
// 局部变量,外部无法直接访问
let secret = '秘密';
this.name = name;
this.getSecret = function () {
return secret;
};
}
const p1 = new Person('张三');
console.log(p1.getSecret()); // 秘密
这种“闭包私有变量”的能力,是原型方法做不到的。因为原型方法在执行时,this 指向调用它的实例,但函数体的作用域链是从原型对象那里继承的,它拿不到构造函数内部的局部变量。
缺点也很明显:每个实例都保存一份函数,实例越多,冗余的函数对象就越多。假如你 new 了一万个用户对象,那么内存里就有一万个 sayName 函数,哪怕它们每个都长得一模一样。这在现代浏览器里一般不算致命问题,但如果实例数量级很大,或者函数体非常庞大,这就是实打实的浪费。
3.2 第二种:通过 prototype 定义方法
为了规避“每个实例都有一份函数”的浪费,经典做法是把方法放到构造函数的 prototype 属性上。这样无论 new 多少个实例,方法都只存在一份,所有实例通过原型链去共享它。
javascript复制function Person(name, age) {
this.name = name;
this.age = age;
}
Person.prototype.sayName = function () {
console.log(`我是 ${this.name}`);
};
Person.prototype.sayAge = function () {
console.log(`我今年 ${this.age} 岁`);
};
const p1 = new Person('张三', 20);
const p2 = new Person('李四', 22);
p1.sayName(); // 我是 张三
p2.sayName(); // 我是 李四
console.log(p1.sayName === p2.sayName); // true
这里 p1.sayName === p2.sayName 是 true,两个实例调用的是同一个函数对象,内存上非常经济。
原理就是前面提到的:new 的时候,引擎会把实例的 __proto__ 指向 Person.prototype。实例自身没有 sayName 这个属性,但通过原型链查找,最终在 Person.prototype 上找到了这个方法,然后以 p1 为 this 去调用它。
这种写法的优点很突出:所有实例共享方法,节省内存;方法定义集中在原型对象上,代码结构也比较清晰。
但有一个地方必须注意:原型方法访问不了构造函数内部的局部变量,因为函数定义时的作用域链里根本没有那些局部变量。如果某个方法需要“每个实例独立且不可共享”的数据,用闭包配合 this 定义是唯一的选择。
再补充一个小技巧:如果你一次性给原型添加多个方法,可以直接用一个对象字面量赋值,但要注意千万别直接整体覆盖 Person.prototype,否则会丢掉 constructor 的指向:
javascript复制// 这样是安全的,只是添加方法
Person.prototype.sayName = function () {};
Person.prototype.sayAge = function () {};
// 这样是危险的,整个原型对象被替换
Person.prototype = {
sayName: function () {},
sayAge: function () {},
};
console.log(Person.prototype.constructor === Person); // false,constructor 指向了 Object
如果确实要用整体赋值的方式,记得手动补回 constructor:
javascript复制Person.prototype = {
constructor: Person,
sayName: function () {},
sayAge: function () {},
};
3.3 第三种:ES6 class 中定义方法
ES6 的 class 是 JavaScript 的语法糖,但它改变的不仅是写法,还带来了一些行为上的差异。在 class 中定义方法最常见的两种位置是:类体中直接写方法(等价于原型方法) 和 构造函数 constructor 中通过 this 赋值(等价于实例自己的方法)。
javascript复制class Person {
constructor(name, age) {
this.name = name;
this.age = age;
// 实例自己的方法
this.sayAge = function () {
console.log(`我今年 ${this.age} 岁`);
};
}
// 原型方法,等价于 Person.prototype.sayName
sayName() {
console.log(`我是 ${this.name}`);
}
}
const p1 = new Person('张三', 20);
const p2 = new Person('李四', 22);
p1.sayName(); // 我是 张三
p2.sayName(); // 我是 李四
console.log(p1.sayName === p2.sayName); // true
console.log(p1.sayAge === p2.sayAge); // false
先说结论:class 里写在 constructor 之外的普通方法,最终会被挂到 Person.prototype 上;写在 constructor 里通过 this.xxx = function 定义的,才会成为实例自己的属性。
但是 class 和普通构造函数有一个很关键的区别:类中所有方法都是不可枚举的。普通构造函数的 Person.prototype.sayName = function 这种方式定义的方法,是可枚举的,for...in 会把方法遍历出来;而 class 中定义的方法,枚举属性默认是 false。
javascript复制function PersonFn(name) {
this.name = name;
}
PersonFn.prototype.sayName = function () {};
const pFn = new PersonFn('张三');
for (const key in pFn) {
console.log(key); // name, sayName 会被遍历到
}
class PersonClass {
constructor(name) {
this.name = name;
}
sayName() {}
}
const pClass = new PersonClass('李四');
for (const key in pClass) {
console.log(key); // 只有 name,sayName 不会被遍历到
}
另外,class 定义的方法不能用 new 去调用,它没有 constructor 属性,不能作为构造函数使用。普通构造函数的原型方法其实就是普通函数,你用 new Person.prototype.sayName() 也不会报错(虽然业务上没人这么干),但 class 方法直接 new 会抛 TypeError: X is not a constructor。
4. 三种方式的核心区别对照表
前面分别讲了三种方式的特点,这里我整理了一张表,方便对照记忆,也是面试时候最容易考的点:
| 对比维度 | this 内部定义 | prototype 定义 | ES6 class |
|---|---|---|---|
| 函数存储位置 | 每个实例自己身上 | 构造函数的 prototype 对象上 | 普通方法在 prototype,constructor 中的 this 方法在实例上 |
| 多个实例是否共享函数 | 不共享 | 共享 | 普通方法共享,this 方法不共享 |
| 能否访问构造函数内部局部变量 | 能(通过闭包) | 不能 | constructor 里的 this 方法能,类体普通方法不能 |
| 内存占用 | 实例越多,占用越大 | 固定一份 | 普通方法固定一份,this 方法实例越多占用越大 |
| 方法是否可枚举 | 可枚举 | 可枚举 | 类体中的方法不可枚举 |
| 继承行为 | 实例属性,子类不自动继承 | 原型链继承,子类可用 | 通过 extends 继承,行为类似 prototype |
| ES5 兼容性 | 支持 | 支持 | 需要转译(Babel)或现代环境 |
能看懂表里的差异,基本就掌握了三种方式的精髓。接下来我再展开讲讲每一行的差异在实际开发中会造成什么影响,因为光记住“共享/不共享”还不够,你得知道它什么时候会咬你一口。
4.1 “共享函数”带来的性能与行为差异
如果你写的是一个工具函数库,里面有大量纯逻辑操作,比如日期格式化、字符串截断,那么用 prototype 方式最合适。因为这些方法不依赖实例的特殊状态,只有一份公共定义就够了,内存最省。
如果是业务模型,比如用户对象、订单对象,同一个方法在逻辑上是公共的,但方法体内部偶尔需要访问实例自己的状态,比如 this.status、this.amount。这种情况下用 prototype 方式完全没问题,因为方法是通过 this 去读实例数据,不需要闭包捕获。
真正需要 this 内部定义方法的场景,是方法内部需要访问“构造函数作用域里的私有变量”。如果你没有这个需求,一律建议用 prototype 或 class 方式,这是我在实际项目里反复验证过的结论。
4.2 “闭包私有变量”是 this 定义方法的独家优势
看一段代码你就明白了:
javascript复制function Counter() {
let count = 0;
this.increment = function () {
count++;
return count;
};
this.getCount = function () {
return count;
};
}
const c1 = new Counter();
c1.increment();
c1.increment();
console.log(c1.getCount()); // 2
const c2 = new Counter();
console.log(c2.getCount()); // 0,c1 和 c2 的 count 互不影响
如果尝试用 prototype 方式实现同样的功能,会发现 count 根本访问不到,因为原型方法看不到 Counter 函数内部的局部变量。所以如果你有“封装私有状态”的需求,this 内部定义(配合闭包)就是唯一的选择。
在 ES2022 之后,class 支持了 # 私有字段,也能解决一部分私有变量需求,但那是另一个层面的话题,原理和闭包不太一样。这里提醒一下,避免踩坑:class 的私有字段只能用 # 声明,不能用 this._x 代替真正的私有。
5. 实际项目里怎么选:决策建议与工程场景分析
说了这么多原理,最后落实到工程里,你应该怎么选呢?我的建议分几个场景来看。
5.1 场景一:老项目 ES5 环境,大量实例对象
不需要私有变量的前提下,优先 prototype 方式。代码写起来虽然不如 class 简洁,但兼容性最好,性能也最稳定。给 prototype 上添加方法时,注意批量添加要用 Object.defineProperty 或者直接单个赋值,避免整体覆盖原型导致 constructor 丢失。
javascript复制// 推荐,安全且直观
Person.prototype.sayName = function () {
console.log(this.name);
};
5.2 场景二:现代项目,使用 ES6+ / TypeScript
优先 class。class 让继承变得更直观,extends + super 的组合比“构造函数原型链继承”好理解太多。在 constructor 里需要的话再用 this.method = ... 定义闭包私有方法,其他地方统一写在类体里。
javascript复制class Person {
constructor(name) {
this.name = name;
// 只有真的需要闭包才这么写
this.formatName = () => this.name.trim().toLowerCase();
}
sayName() {
console.log(this.formatName());
}
}
注意,如果给实例方法使用箭头函数(this.formatName = () => ...),这个箭头函数的 this 在定义时就被绑定到了实例上,不会因为调用上下文的变化而改变。这在事件回调场景特别好用,但也意味着它和“原型方法”行为有明显差异,面试时经常被拿出来追问,下面细说。
5.3 场景三:面试题里的高频追问
面试官考察“构造函数模式的方法定义方式”,通常会从这几个角度入手:
- 问:实例方法在内存里存在几份?——答:this 定义方式每实例一份,prototype 定义方式一份。
- 问:为什么用原型方法?——答:共享、节省内存、便于原型链继承。
- 问:class 里定义的方法在什么位置?——答:普通方法在 prototype,constructor 里的 this 方法在实例上。
- 问:class 方法能 new 吗?——答:不能,类的实例方法不是构造函数。
- 问:闭包能实现私有变量吗?——答:能,但这个私有变量是“实例私有”,不是“类私有”。
这几个问题看着简单,但每个问题背后都藏着大量的行为细节。我见过很多简历上写着“熟悉 JavaScript”的人,栽在第一个问题上,因为从结果上看 p1.sayName() 都能正常调用,他们压根没想过函数到底存放在哪个内存区域。
6. 常见报错、隐蔽 Bug 与排查技巧
定义方法本身很简单,但生产环境里真正让人头疼的是“方法写对了,运行时报错”的情况。我把这几年见到的高频问题整理一下,一个一个问题说。
6.1 this 指向丢失的坑
最常见的报错就是 Cannot read properties of undefined (reading 'xxx'),或者 this is undefined。在 React 类组件、原生事件监听、定时器回调里,函数的 this 可能会丢失对实例的指向。
javascript复制class Person {
constructor(name) {
this.name = name;
this.sayName = this.sayName.bind(this); // 方法一:绑定 this
}
sayName() {
console.log(this.name);
}
}
const p = new Person('张三');
const fn = p.sayName;
fn(); // 如果不 bind,这里会报错或得到 undefined
以前的原生构造函数也有同样的问题,但 class 的严格模式让 this 变成 undefined,报错更明显。解决手段有三招:
- 在 constructor 里 bind;
- 用箭头函数定义实例方法;
- 调用时使用箭头函数包一层(
onClick={() => p.sayName()})。
6.2 原型方法访问不到私有变量的错觉
还有一种隐蔽情况:在 prototype 方法里引用构造函数内部的变量,结果得到 ReferenceError 或者 undefined,然后怀疑变量是不是没定义。其实原型方法的作用域链根本不包含构造函数内部作用域,这是设计使然,不是 Bug。
6.3 class 静态方法和实例方法混淆
有些新手会把 static 方法和实例方法搞混。静态方法是挂在类本身上的,实例访问不到;实例方法通过实例调用。最常见的报错场景是写了:
javascript复制class Person {
static sayName() {
console.log('静态方法');
}
}
const p = new Person();
p.sayName(); // TypeError: p.sayName is not a function
这是 ES6 class 里非常典型的错误,因为静态方法存在于 Person.sayName,而 p 的原型链上根本找不到它。
6.4 给原型方法整体赋值后 constructor 指向错乱
前面提过,整体覆盖 Person.prototype = {...} 会让 constructor 属性指向 Object。这会产生一个非常难排查的 Bug:明明 p instanceof Person 是 true,但 p.constructor === Person 却是 false。如果你代码里依赖 constructor 做类型判断、或者有些库内部会读取 constructor,这会引发连锁反应。排查办法很简单,打印一下就知道了:
javascript复制console.log(Person.prototype.constructor === Person); // false,因为被整体覆盖了
如果确实要整体赋值,加一行 constructor: Person 并不可耻。
6.5 常见问题与排查技巧速查表
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 事件回调中 this 变成 undefined 或 window | 方法没有绑定实例 | 在回调处打印 this | bind / 箭头函数 |
| 实例方法互相不共享,内存增长 | 方法定义在 constructor 内部 | 对比两个实例的方法是否全等 | 改为 prototype / class 类体方法 |
| class 代码在旧浏览器报语法错误 | 浏览器不支持 ES6 class | 使用 Babel 转译 | 配置构建工具 |
| for...in 遍历出原型方法 | prototype 直接赋值的方法可枚举 | 打印 key 列表 | 用 Object.defineProperty 或 class |
| p.constructor 指向 Object | 原型对象被整体覆盖 | 打印 Person.prototype.constructor | 手动补 constructor |
| 静态方法无法通过实例调用 | 对 class 静态方法理解有误 | 检查方法定义是否带 static | 通过类名调用 |
7. 个人经验收尾收场
文章写到这,最后分享我个人在实际项目里验证过的一个结论:写库、写框架、写基础组件,优先用 class;写老版本兼容代码或者性能极致敏感的场景,优先 prototype;只有在真正需要闭包私有变量的极少数情况下,才用 constructor 内部 this 定义方法。
我在带团队做代码评审的时候,见到最多的问题不是“不会写”,而是“不知道方法写在哪”。一个方法应该属于所有实例共享的行为,那就放到原型或类体里;只有它和某个实例的私有状态强绑定,才考虑放到构造函数内部。写代码之前先想清楚这个问题,很多设计层面的 Bug 其实根本不会发生。
另外再教你一个小技巧:在浏览器控制台里直接输入 p1.hasOwnProperty('sayName'),返回 true 说明方法是实例自己的,返回 false 说明方法是原型链上的。这一招在排查“方法到底挂在哪”的问题上非常好用,比查文档快得多。
