资讯动态

JavaScript进阶:构造函数、new机制与实例/静态成员深入解析

发布时间:2026/10/21 19:05:36 来源:尧图企业网站定制
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彻底理解原型链源头为了让这些步骤真正变成自己的东西强烈建议在纸上或代码里手工实现一个简易的myNewfunction 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对象的认知就算真正迈过进阶这道坎了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑