JavaScript学到对象这一章,很多人会进入一个迷茫期:明明用花括号就能创建对象,为什么还要引入构造函数这么复杂的东西?这个困惑我太熟悉了,带过的每个新人几乎都问过同样的问题。这篇是JS进阶系列里的关键内容,把创建对象、构造函数、实例成员与静态成员、内置构造函数这几个概念串起来讲透,最后用一个综合案例把这堆知识落地到真实场景。刚学完函数和对象基础、正在往进阶爬的同学会非常受用,准备前端笔试面试、想把这几个概念从"会用"升级成"理解"的人也值得认真读一遍。
1. 从对象字面量到构造函数的进化过程
1.1 对象字面量的重复代码问题
很多人在学完基础语法后,创建对象的第一反应就是字面量:
const product1 = { name: '无线鼠标', price: 79, count: 100, total() { return this.price * this.count; } };这种写法在只有一个对象的时候非常干净利落,可一旦需求变成"管理一批商品",问题就来了。你需要在不同位置写下大量结构相同、只是值不同的对象,而且一旦要给每个对象增加一个方法,或者修改公共字段,就得把每一个对象都改一遍。维护成本会随着数据量直线上升。
有个学员做过一个很真实的尝试:把商品从10个扩展到30个,光是复制粘贴对象就花了半天,后来商品要增加一个"库存预警"方法,他改到崩溃。这个场景其实点出了对象字面量的本质——它适合一次性使用,不适合批量生产。业务里数据往往是成百上千条,靠手写字面量是根本行不通的。
1.2 工厂函数解决了重复,却留下了类型缺口
面对重复代码,最先想到的方案就是函数封装,把创建逻辑抽出来:
function createProduct(name, price, count) { return { name, price, count, total() { return this.price * this.count; } }; } const product1 = createProduct('无线鼠标', 79, 100); const product2 = createProduct('机械键盘', 399, 50);这种"工厂函数"解决了代码重复的问题,逻辑统一了,改一处就能影响所有商品对象。但用着用着你会发现一个尴尬的地方:你没办法区分这30个对象到底是"商品"还是"订单",因为所有对象都是Object的实例,用instanceof去检查,无论哪个都返回true。
这就像流水线上生产出来的产品,外形一样,但没有任何标签型号,出了问题连它属于哪个供应商都查不出来。工厂函数能解决批量生产,却解决不了"身份标识"的问题,这个缺口是由构造函数来补上的。
1.3 构造函数带来的类型标识能力
构造函数模式本质上是"用函数来定义一类对象的模板",通过new来创建具体的对象实例:
function Product(name, price, count) { this.name = name; this.price = price; this.count = count; this.total = function () { return this.price * this.count; }; } const product1 = new Product('无线鼠标', 79, 100);这时每个对象就不再是"无名无姓"的普通对象,而是明确的Product实例,product1 instanceof Product会返回true,类型标示清晰可见。对比工厂函数,构造函数最大的价值不在于写法差别,而在于它让对象有了类型归属,后续做批量处理、类型判断、继承扩展都有了依据。这也是为什么后续会把class引入语言——它只是构造函数的语法糖,底层机制完全一样。
2. new关键字执行时到底做了什么
2.1 new的五步执行过程
构造函数必须配合new使用,这是写JS的人都知道的规则,但很多人不清楚new背后到底做了什么。搞清楚这个,很多看似"莫名其妙"的报错就能一下子想通:
- 在内存中创建一个全新的空对象。
- 把空对象的原型(
__proto__)指向构造函数的prototype对象。 - 将构造函数内的
this指向这个新对象,并执行函数体,给this挂属性。 - 如果构造函数显式返回了一个对象类型,则返回该对象;否则返回步骤1创建的新对象。
第2步是理解原型链的关键,第4步是很多人在new之后拿到奇怪结果的根源。如果构造函数内部写了return { ... },那你拿到的根本不是"new出来的实例",而是被替换掉的那个对象。
2.2 手写一遍new,彻底理解原型链源头
为了让这些步骤真正变成自己的东西,强烈建议在纸上或代码里手工实现一个简易的myNew:
function myNew(Constructor, ...args) { // 步骤1:创建空对象,同时把原型指向构造函数的prototype const obj = Object.create(Constructor.prototype); // 步骤2:执行构造函数,把this绑定到新对象上 const result = Constructor.apply(obj, args); // 步骤3:根据返回值类型决定最终返回什么 return (typeof result === 'object' && result !== null) ? result : obj; } const product = myNew(Product, '蓝牙耳机', 199, 60); console.log(product instanceof Product); // true写一遍这个过程,你对原型链的理解会有一个质的提升。原型链不是玄学,它就是从第2步开始的——实例对象的__proto__指到了Product.prototype,所以实例才能访问到原型上的方法。很多人在面试时说"原型链是对象之间的一种关联关系",听起来很虚,但如果你自己写过myNew,你会脱口而出:原型链就是new第2步建立的那条从实例通向构造函数的prototype的引用链。
2.3 忘记写new的灾难现场
构造函数被当成普通函数调用,是新手最常踩的坑。Product('无线鼠标', 79, 100)这样直接调用,函数照样执行,但this指向的不是新对象,而是当前的全局对象(严格模式下是undefined)。结果是所有属性全部挂到了全局对象上,白白污染了作用域,函数返回的是undefined,你拿到手的什么都不是。
我见过不少人在写代码时手滑漏掉new,结果出现诡异的Cannot read property 'name' of undefined报错。常规做法是约定俗成地用大写字母开头命名构造函数,提醒自己必须加new。更稳妥的做法是在函数内部检查调用方式:
function Product(name, price, count) { if (!new.target) { return new Product(name, price, count); } this.name = name; this.price = price; this.count = count; }new.target是ES6提供的内置属性,当函数被new调用时,它指向构造函数本身;否则为undefined。利用这一点可以在漏写new的时候自动补救,这种防御式写法在封装公共库时很有价值,至少不会让调用方因为一个new就整个程序崩溃。
3. 实例成员与静态成员的本质区别
3.1 从内存分配看实例成员
先看一段最常见的代码:
function Product(name, price, count) { this.name = name; this.price = price; this.count = count; } const p1 = new Product('无线鼠标', 79, 100); const p2 = new Product('键盘', 399, 20);这里的name、price、count就是实例成员。每一次new都会在内存中重新创建一份完全独立的属性空间,p1.name和p2.name虽然名字相同,但在内存里是两个毫无关系的值。你可以修改p1.price = 199,完全不影响p2.price,这是实例成员的基本特征——数据属于每个实例自己。
这也意味着,如果数据天然是每个对象各有一份的(比如商品名、单价、库存),就应该设计成实例成员。如果设计成共享的,反而会造成数据相互污染。这个判断标准可以记一下:实例成员解决的是"每个实例各自的属性"问题。
3.2 静态成员:挂到构造函数身上的工具和计数器
静态成员则完全相反,它是直接挂在构造函数本身上的属性或方法,不属于任何一个实例:
function Product(name, price, count) { this.name = name; this.price = price; this.count = count; Product.totalCount += count; // 累加所有实例的库存 } Product.totalCount = 0; Product.showTotalCount = function () { console.log('总库存:' + this.totalCount); }; const p1 = new Product('无线鼠标', 79, 100); const p2 = new Product('键盘', 399, 20); Product.showTotalCount(); // 总库存:120这里Product.totalCount和Product.showTotalCount是静态成员,它们只存在一份,被所有实例共享。想通过p1.totalCount去访问是拿不到的,因为实例的__proto__链上根本没有这个东西。静态成员最常见的用途有三种:一是统计类级别的数据(比如实例总数),二是提供与类功能相关的工具方法(比如Array.from、Object.keys),三是存放类级别的配置常量。
3.3 原型成员:所有实例共享方法的内存优化
实例成员还有一种尴尬情况:如果给this.total挂方法,每次new都会创建一份全新函数。创建100个实例,内存里就有100个一模一样的函数,太浪费了。优化方案是把方法挂到Product.prototype上:
function Product(name, price, count) { this.name = name; this.price = price; this.count = count; } Product.prototype.total = function () { return this.price * this.count; };原型成员虽然不在实例自身上,但所有实例通过__proto__都能访问到同一个函数,内存里永远只有一份。这里就涉及到一个高频考点:如何区分属性到底是"自己的"还是"从原型链上拿来的"。
hasOwnProperty用来判断属性是否为实例自身的成员,in操作符判断的是实例能否访问到该属性(包含原型链)。两者结合起来就能精确分辨:
console.log(p1.hasOwnProperty('name')); // true,实例自有 console.log(p1.hasOwnProperty('total')); // false,原型上的 console.log('total' in p1); // true,原型链上能拿到这个区分在面试里几乎是必问项,也是后续理解for...in与Object.keys差异的基础。下面是三者对比,方便记忆:
| 成员类型 | 定义位置 | 内存特征 | 访问方式 | 典型用途 |
|---|---|---|---|---|
| 实例成员 | 构造函数内部 this.xxx | 每个实例一份 | 实例直接访问 | 商品名、价格等个体数据 |
| 静态成员 | 构造函数自身 | 全局共享一份 | 构造函数名访问 | 总数统计、工具方法、配置 |
| 原型成员 | Constructor.prototype | 全局共享一份 | 实例访问(走原型链) | 各种复用方法 |
4. 内置构造函数的使用边界
4.1 常见内置构造函数盘点
JS里有不少内置的构造函数,它们由宿主环境提前定义好,直接拿来用即可:
const arr1 = new Array(1, 2, 3); const obj = new Object({ name: 'test' }); const str = new String('hello'); const num = new Number(42); const bool = new Boolean(true);这些构造函数各自带了一批静态成员和原型成员。比如Array的Array.isArray、Array.from是静态成员,而push、pop、map、filter这些则定义在Array.prototype上,属于原型成员。理解了这个归属关系,你就能回答一个常见疑惑:为什么filter不能直接通过Array.filter()调用,而必须arr.filter()调用——因为它是原型成员,挂在实例能访问的链上,而不是构造函数本身。
4.2 new Array(length) 这个经典陷阱
内置构造函数里最大的坑在new Array的单参数调用上。专门写一节提醒:
const a = new Array(3); console.log(a.length); // 3 console.log(a[0]); // undefined看起来像是创建了一个包含3个undefined元素的数组,实际上它创建的是长度为3的稀疏数组,里面没有任何实际元素,也没有一个undefined。用forEach遍历的时候,这三个空位压根不会触发回调函数。而如果你传入的是一个数值而不是一次写多个参数,行为就变成了设置长度而不是填充元素。相比之下,数组字面量[1, 2, 3]和行为统一的Array.of(1, 2, 3)要直观得多。
同样的坑在Number()上也存在。Number('42')会老老实实返回数字类型42,这是函数调用;而new Number('42')返回的是一个包装对象,typeof的结果是'object',字符串比较值都相等,但===判等会失败。所以我个人的项目规范里基本不用new Number、new String、new Boolean创建基本类型,字面量直接写就完了,需要转换时调用对应的函数即可。
4.3 类型判断时避开 instanceof 的误区
内置构造函数在手,类型判断容易顺手就写arr instanceof Array,这在大多数场景下没问题,但在跨页面、跨iframe的场景里会翻车。不同全局环境有各自独立的Array构造函数,一个页面里创建的数组,用另一个环境下的Array去instanceof判断,结果会变成false。
更可靠的做法是Array.isArray(value),它在任何环境下都能给出正确结论。顺带一提,判断对象是不是"纯普通对象"的原理也是类似思路,通常借Object.prototype.toString.call(value),返回[object Object]的才是普普通通的 Object。这些内置构造函数是底层基础设施,多留意它们的边界条件,出了问题能省大量排错时间。
5. 综合案例:用构造函数打造一份购物车统计系统
5.1 需求拆解:哪些数据该设计成什么成员
把前面所有概念串在一起,最合适的练习就是做一个购物车统计系统。需求是模拟一批商品的数据统计:每件商品有名称、单价、数量;系统能算出每件商品小计;能统计所有商品的总数量;能算出购物车总金额;并且在新增商品的时候自动更新这些统计值。
做设计之前,先停下来思考成员的归属。商品的名称、单价、数量天然是每件商品单独一份,应当设计为实例成员。商品的小计方法和商品信息展示方法属于共享逻辑,放原型成员上,内存里只保留一份。整个购物车的总商品数和总金额,不应当算在某件商品身上,而应当是一个"类级别"的汇总数据,用静态成员来维护。这样一分析,设计思路就很清晰了。
5.2 实现代码:实例成员、原型成员、静态成员协同
完整实现如下:
// 构造函数:定义商品的实例成员 function Product(name, price, count) { this.name = name; this.price = price; this.count = count; // 每产生一个新商品,就累加总数量和总金额 Product.totalCount += count; Product.totalAmount += price * count; } // 静态成员:购物车级别的统计数据 Product.totalCount = 0; Product.totalAmount = 0; Product.prototype.showInfo = function () { console.log(`${this.name} 单价¥${this.price} 数量${this.count} 小计¥${this.price * this.count}`); }; Product.prototype.total = function () { return this.price * this.count; }; // 静态方法:查看购物车汇总信息 Product.showCart = function () { console.log(`商品总件数:${Product.totalCount}`); console.log(`购物车总金额:¥${Product.totalAmount.toFixed(2)}`); }; // 创建几件商品 const p1 = new Product('无线鼠标', 79, 2); const p2 = new Product('机械键盘', 399, 1); const p3 = new Product('USB-C扩展坞', 189, 3); p1.showInfo(); // 无线鼠标 单价¥79 数量2 小计¥158 p2.showInfo(); // 机械键盘 单价¥399 数量1 小计¥399 Product.showCart(); // 商品总件数:6 // 购物车总金额:¥1022.00注意Product.totalCount是静态成员,它不能在构造函数内部用this.totalCount来累加,否则累加的是实例自己的属性。这里必须用Product.totalCount显式引用构造函数,才能真正更新共享数据。这个细节在当初我带新人的时候,几乎所有人都会在这卡一下。
5.3 用内置构造函数的方法做数据加工
案例里还有一个值得展开的点:如果想把购物车里的商品按金额排序、过滤出超过指定价格的东西,怎么办?这时候前面学过的内置构造函数的原型方法就派上用场了:
const products = [p1, p2, p3]; const expensiveProducts = products.filter(function (item) { return item.total() >= 200; }); console.log(expensiveProducts.length); // 2(机械键盘、扩展坞小计都超过200) products .slice() .sort(function (a, b) { return b.total() - a.total(); }) .forEach(function (item) { item.showInfo(); });这里用filter时千万要小心:item.total()里用到了this,如果直接传入Product.prototype.total作为回调,this就保不住了。所以回调里得包装一层匿名函数。这种"方法一旦被单独取出,this指向就会丢"的规律,在使用内置方法配合原型方法时反复出现,值得养成下意识检查的习惯。
5.4 换用class语法重写,验证概念是否真正理解
这个综合案例再往前一步,就是验证你究竟有没有吃透上述概念。ES6之后的class语法是把构造函数换皮,能用好它才说明真的理解了成员体系:
class Product { // 静态成员 static totalCount = 0; static totalAmount = 0; constructor(name, price, count) { this.name = name; this.price = price; this.count = count; Product.totalCount += count; Product.totalAmount += price * count; } showInfo() { console.log(`${this.name} 单价¥${this.price} 数量${this.count} 小计¥${this.price * this.count}`); } total() { return this.price * this.count; } static showCart() { console.log(`商品总件数:${Product.totalCount}`); console.log(`购物车总金额:¥${Product.totalAmount.toFixed(2)}`); } }constructor对应构造函数体,实例方法写在类体里自动进原型,static修饰的成员对应静态成员。字面意思和机制完全对得上。如果你能顺畅地在两种写法间切换,说明你对实例成员、静态成员、原型成员这套体系已经有了真正的理解。
拿这个购物车案例去反复练习,每次把class翻译成构造函数、把构造函数翻译成class,很快你就能发现一个事实:所谓JS进阶,本质上就是理解"对象是怎么被制造出来的,以及制造出来之后成员归属于哪里"。这两点搞明白了,再看框架源码或者后来学的class继承,会有一种豁然开朗的感觉。
最后说点我自己的体会。我带过的很多同学,学完这章之后代码量并不大,但这章的价值不在于写了多少行,而在于它逼着你理解了JS最底层的几个动作:new是一个流程,this是一个绑定规则,成员有什么样的归属关系。这些东西看着不起眼,但在你调试那些"这个对象为什么有这个属性"、"这个方法为什么调不到"的问题时,全都是救命级别的基本功。建议你学完这一篇之后,别急着往下走,回头把myNew独立写三遍,再把这套购物车案例用两种风格各写一遍,跑通的那一刻,你对JS对象的认知就算真正迈过进阶这道坎了。