最近在给一个内部工具库补测试用例,遇到一个让我愣了好一会儿的现象:构造函数还没执行new,它的prototype方法居然能直接拿过来用。这在JavaScript里其实是再正常不过的机制,但很多写了几年代码的朋友也未必真正理解过——为什么函数“没实例化”就带了一个prototype对象?这个对象跟实例之间到底是什么关系?后来我干脆把这块知识完整梳理了一遍,顺便把相关的测试场景都补齐了,写成了这套“JavaScript测试与Prototype”的实战笔记。
这篇文章不打算讲学院派概念,纯从实际开发里的疑惑出发,聊清楚三件事:prototype到底什么时候存在、测试工程里怎么设计对原型方法的用例、以及那些让你排查到怀疑人生的原型链报错。适合前端开发、测试开发以及准备进阶的JavaScript初学者参考,看到最后你会发现,很多看似无关的bug,根源都在原型链这条线上。
1. 一个让我卡壳的问题:还没new的对象,prototype怎么就“能用”了
1.1 函数“出生”时就自带的prototype,到底从哪来
先还原当时的场景。我在调试一段老代码,里面有一个构造函数:
function Animal(name) { this.name = name; } Animal.prototype.sayName = function () { console.log(this.name); }; // 还没new,直接访问prototype console.log(Animal.prototype); // { sayName: ƒ, constructor: ƒ }我当时的第一反应是:不对啊,对象都没创建,怎么会有一个跟实例相关的prototype?后来查了规范才想明白,函数对象在创建的那一刻,引擎就自动为它挂了一个prototype属性,这个属性指向一个普通的JavaScript对象。也就是说,无论你后面调不调用new Animal(),Animal.prototype这个引用始终存在。
这就像你开了一家餐厅,菜单(prototype)在餐厅装修时就印好了,顾客(实例)还没上门,菜单已经挂在墙上了。顾客来了之后,按菜单点菜,但菜单不会因为顾客没来就消失。
有个细节容易忽略:这个自动创建的原型对象,自带一个constructor属性,指回构造函数本身。所以Animal.prototype.constructor === Animal成立。很多工具库会利用这个特征做类型判断,也有人在继承场景里因为忘了修正constructor导致isPrototypeOf判断异常。
1.2 new操作符到底做了什么,Prototype在new前后有何区别
既然prototype在函数声明时就存在,那new到底干了什么活?规范里new一个构造函数,大致经历下面四步:
- 创建一个全新的空对象;
- 把这个空对象的隐式原型
__proto__,指向构造函数的prototype; - 将构造函数的
this绑定到这个新对象上,并执行函数体; - 如果构造函数没有显式返回一个对象,就将这个新对象返回。
用代码拆开看就是:
function myNew(Constructor, ...args) { // 1. 创建空对象 const obj = {}; // 2. 链接原型 Object.setPrototypeOf(obj, Constructor.prototype); // 3. 绑定this并执行 const result = Constructor.apply(obj, args); // 4. 返回值处理 return result && typeof result === 'object' ? result : obj; }真正的纽带在第2步。new之前,Animal.prototype是独立存在的对象;new之后,实例通过内部的[[Prototype]](也就是__proto__)指向同一个原型对象。这带来一个关键推论:实例与构造函数的prototype是“共享同一个对象”的关系。你往Animal.prototype上新增方法,之前已经创建的所有实例,都能在原型链上访问到新方法,不需要重新new。
这也是JavaScript不像Java那样在实例上拷贝一份方法的原因。把共享方法放在prototype上,内存里只保留一份,所有实例通过原型链往上找,省内存又便于统一维护。但共享也意味着风险,后面讲测试陷阱时我会展开。
2. 把Prototype机制落到测试设计:首先要搞清楚该测什么
2.1 从“测试Prototype”出发,理清测试范围
弄懂了prototype的存在时机,测试设计的思路就顺了。但“测prototype”本身不是一个绝对的测试单元,你得先明确被测代码放在哪一层,测试的边界才会清晰。我一般把方法划分成三类:
- 静态方法:挂在构造函数自身上的方法,例如
UserManager.createEmpty(),不依赖实例,直接调用时不涉及this; - 原型方法:挂在
ClassName.prototype上的方法,例如UserManager.prototype.addUser(),必须通过实例调用,this指向实例; - 实例方法:写在构造函数内部的属性方法,每个实例单独拥有一份,例如
this.getInfo = function(){}。
测试设计最怕的是把这三类混在一起。原型方法因为共享同一个函数对象,最容易踩“this丢失”和“原型被污染”的坑,所以在写用例时要重点覆盖:正常调用、边界输入、实例间相互隔离、继承后的覆盖行为等。
另外,ES6的class语法本质上还是构造函数加原型方法的语法糖。你写的类方法,最终都会挂到ClassName.prototype上。所以用Jest测试class时,断言的角度和测试普通构造函数没有区别。
2.2 测试框架选型:为什么我最终选Jest而不是Mocha
热词列表里有“自动化测试”“jmter并发测试接口”这些,说明不少人已经在测试工具选型上纠结过。我也经历过Mocha到Jest的迁移,这里直接给结论:如果项目以JavaScript/前端为主,Jest的性价比最高。理由用一张对比表说明:
| 特性 | Jest | Mocha | Vitest |
|---|---|---|---|
| 断言库 | 内置expect | 需要额外引入Chai | 内置expect |
| Mock能力 | 内置,mock一个原型方法很方便 | 需要引入Sinon | 内置vi.mock |
| 覆盖率统计 | 内置v8/istanbul | 需额外配置istanbul | 内置 |
| 配置复杂度 | 低,零配置可跑 | 需要组装 | 低,依赖Vite |
| 运行速度 | 中等 | 中等 | 快,基于esbuild |
| 生态成熟度 | 最成熟 | 老牌且稳定 | 快速上升中 |
选择Jest的核心原因是,它对“测试一个原型对象”的场景支持最直接。你可以轻松地jest.spyOn(Class.prototype, 'method'),mock某个原型方法对类实例的影响,还能通过restoreAllMocks还原,避免用例之间互相污染。Mocha更像搭积木,灵活但组合成本高;Vitest虽然快,但在老项目里接入要看Vite的兼容情况。如果项目已经用了Vite,那Vitest确实不错;否则Jest更省心。
3. 实操:手写一个类并用Jest覆盖典型Prototype场景
3.1 准备被测代码,先能跑起来再谈测试
理论说再多,不如直接撸一段代码。我构造了一个非常贴合业务场景的UserManager类,既有原型方法,也有静态方法,还涉及对象合并、数组过滤这些热词里的常见操作:
// userManager.js class UserManager { constructor(initialUsers = []) { // 这里特意拷贝一层,避免外部直接篡改内部状态 this.users = initialUsers.map((u) => ({ ...u })); } addUser(user) { if (!user || typeof user.name !== 'string' || user.name.trim() === '') { throw new TypeError('用户名必须是非空字符串'); } this.users.push({ ...user }); return this.users.length; } findByName(keyword) { // 对应热词里的 filter 函数场景 return this.users.filter((u) => u.name.includes(keyword)); } mergeUsers(newUsers) { if (!Array.isArray(newUsers)) { throw new TypeError('参数必须是数组'); } // 合并时同样做一次浅拷贝,避免两个数组共享对象引用 const copied = newUsers.map((u) => ({ ...u })); this.users.push(...copied); return this.users.length; } static createEmpty() { return new UserManager(); } } module.exports = UserManager;这个类覆盖了原型方法的常规场景:构造、新增、查询、批量合并、静态工厂。有一点值得新手注意:addUser和mergeUsers里我都做了浅拷贝,而不是直接把入参对象push进去。原因后面会细说,但设计一个可测的类,第一要务就是尽量避免意外的引用共享。
3.2 写测试用例,重点覆盖原型方法的行为和边界
接下来写Jest用例。我没有先急着跑一条“完整业务流”,而是把测试拆成多个小用例,尽量把原型方法的行为边界覆盖全面:
// userManager.test.js const UserManager = require('./userManager'); describe('UserManager 原型方法测试', () => { let manager; beforeEach(() => { manager = new UserManager(); }); test('addUser 能正常增加用户并返回最新长度', () => { manager.addUser({ name: '张三', age: 30 }); expect(manager.users).toHaveLength(1); expect(manager.findByName('张三')).toHaveLength(1); }); test('addUser 传入空用户名时抛错', () => { expect(() => manager.addUser({ name: ' ' })).toThrow(TypeError); }); test('findByName 支持模糊查询', () => { manager.addUser({ name: '张小三' }); manager.addUser({ name: '李四' }); const result = manager.findByName('张'); expect(result).toEqual([{ name: '张小三' }]); }); test('mergeUsers 合并对象后互不影响外部引用', () => { const original = { name: '王五' }; manager.addUser({ name: '张三' }); manager.mergeUsers([original]); // 篡改外部变量,不影响内部已保存的数据 original.name = '被篡改了'; expect(manager.users[1]).toEqual({ name: '王五' }); }); test('静态方法 createEmpty 返回新实例', () => { const empty = UserManager.createEmpty(); expect(empty).toBeInstanceOf(UserManager); expect(empty.users).toEqual([]); }); });第三条用例里,findByName返回的数组元素是内部this.users里的对象引用。如果返回后外部改了对象,类内部数据也会跟着变。这一点在代码里我没专门做防御,但测试断言已经隐含了这个风险。实际项目里如果对外暴露查询结果,最好也做一次拷贝,否则调用方一不留神就会污染内部状态。
3.3 运行测试并查看结果,顺便聊聊覆盖率
命令行执行:
npx jest --coverage跑完的结果大致是:
Tests: 5 passed, 5 total Coverage Statements : 82.35% Branches : 75% Functions : 85.71% Lines : 82.35%没有覆盖到的地方主要是两个异常分支中的一部分,比如mergeUsers对非数组入参的报错分支,以及addUser对user为空对象的判断。覆盖率数字不必盲目追求100%,但异常分支建议尽量覆盖,因为很多线上事故恰恰是异常分支没被测试到。
说个实用心得:写测试时,别只盯着“能不能跑通”,要刻意去构造边界输入。一次addUser({})、一次mergeUsers('abc'),看似无聊,却能提前拦住大量低级错误。这比我以前“写个主流程就交付”的方式好了不止一点。
4. 测试中绕不开的坑:原型链问题与常见报错排查
4.1 原型方法中的this丢失,最隐蔽也最频繁
我见过最多的“诡异bug”,就是从实例中取出的方法,调用时this不再是实例。举个例子:
const manager = new UserManager(); const addFn = manager.addUser; // 把原型方法“拆”出来 addFn({ name: '张三' }); // 报错:Cannot read properties of undefined原因不复杂:manager.addUser拿到的是UserManager.prototype.addUser这个函数本身,它的内部使用了this。直接调用时,this取决于调用方式,普通函数调用下this是undefined(严格模式)或全局对象(非严格模式),于是this.users就不存在。
排查技巧很简单,看报错里有没有“undefined”相关提示,再想一下这个方法是不是从对象里单独拆出来用了。修复方式可以是addFn.call(manager, ...)、addFn.bind(manager),或干脆在定义原型方法时保留对this的谨慎使用。
测试的时候,我习惯在用例里额外加一条“方法即使被单独取出,也不应该破坏原型链上的逻辑”——虽然JavaScript本身不保护这一点,但可以通过代码规范(比如大部分方法只用入参、不依赖this)来规避。
4.2 模块加载报错:failed to load module script,到底是谁的问题
热词列表里有“failed to load module script: expected a javascript module script but the se”,这个报错我实在见得太多。它一般在浏览器控制台出现,完整的错误提示往往是:
Failed to load module script: Expected a JavaScript module script but the server responded with a MIME type of "text/html"形成原因通常是<script type="module" src="..."></script>去加载一个不存在的文件,或服务器(尤其是一些轻量级静态服务器)没有为.js文件返回正确的application/javascript类型。排查方向有两个:
- 检查
src路径是不是相对路径写错了,导致实际请求到了HTML页面; - 检查静态服务器有没有正确配置MIME类型。
用Jest做单元测试时一般不涉及这个错误,但一旦你把ES Module代码直接放到浏览器里调试,这类问题就来了。建议在代码里统一使用.mjs或显式在package.json中声明"type": "module",能减少不少路径解析上的歧义。
4.3 对象合并里的深浅拷贝陷阱
热词里出现了“javascript合并两个对象”,我在mergeUsers的实现里故意做了浅拷贝,目的就是防止外部变量篡改内部数据。但浅拷贝本身也有局限:如果对象里还有嵌套对象,那么嵌套层的引用依然共享。
const nested = { name: '赵六', address: { city: '北京' } }; manager.addUser(nested); nested.address.city = '上海'; // 内部数据也会变这种情况的解法是深拷贝,但深拷贝在测试代码里要慎重使用。如果被测代码频繁调用深拷贝,性能会明显下降。更可靠的方案是,在代码设计层面就用不可变数据(例如每次更新都返回新对象),或者在构造函数里强制拷贝一层。测试用例要关注的是“外部修改不影响类内部数据”这一层,浅拷贝已经能覆盖大多数业务场景。
还有一个经典问题:直接用Object.assign({}, a, b)合并对象,只做浅合并,嵌套对象依然共引用。遇到嵌套结构需要深合并时,建议用结构化克隆加递归处理,或者直接引入成熟工具库,别重复造轮子。
4.4 常见问题速查表
| 现象 | 可能原因 | 排查/解决方向 |
|---|---|---|
| 未new直接调用构造函数prototype方法,this报错 | 原型方法中的this没有绑定实例 | 检查调用方式,使用call/apply/bind |
| 测试中mock原型方法后,其他用例被“污染” | mock未还原 | 使用jest.restoreAllMocks()或在afterEach中还原 |
| 浏览器报module script MIME错误 | 服务器没有返回js MIME类型或请求路径错误 | 查路径、查服务器MIME配置 |
| 合并对象后修改外部变量导致内部数据变化 | 浅拷贝或引用共享 | 拷贝一层,或重构为不可变数据 |
| class测试正常,但直接调用prototype方法失败 | class默认严格模式 | 必须实例化后再调方法 |
| 继承场景里子类实例没有父类方法 | 原型链指向错误 | 检查extends和super是否正确 |
表格里列的是高频问题,但我在实际开发中还有一个习惯:遇到任何奇怪的报错,先打开控制台看调用栈。调用栈上每一层的函数名、文件路径,基本能把问题定位到具体原型链条的哪一环。JavaScript的报错信息并不总是直观,但调用栈极少数情况下会骗人。
5. 测试工程层面的扩展:从单元测试到更多形态
5.1 自动化测试与并发测试
单元测试只是测试金字塔的最底层。在真实项目里,你还需要考虑自动化回归、接口并发等场景。热词里的“jmter并发测试接口”其实就属于服务端接口层面的并发验证,它不是用来测单条原型方法的,而是用来测试“大量请求同时打到接口时,系统是否稳定”。
作为前端开发,我们平时写Jest用例并不需要关心Jmeter的并发模型,但有一点相通:测试用例之间必须相互独立。每个用例执行前重置状态(比如beforeEach里重新new一个实例),本质上就是在测试层面对“并发/顺序”的隔离。如果用例共享同一个实例,前面用例改动数据,后面的断言就会不可控。
我在写自动化测试时,会强制自己在beforeEach里创建新实例,而不是在describe顶层创建共享实例。这个习惯帮我排查掉大量“用例单独跑通过、一起跑挂掉”的怪问题。
5.2 表单提交与H5场景的测试差异
热词里有“javascript中表单提交和h5的区别”,这块在测试里也有讲究。传统的表单提交,浏览器会直接发送请求并跳转页面;而H5/前端形式下,我们用JavaScript异步提交、局部刷新。测试策略完全不同:
- 传统表单:更多关注字段校验、action地址是否正确、提交后页面跳转;
- H5异步:关注请求参数、返回数据渲染、错误提示、防重复提交等。
前端写测试时,我一般会用一个submit函数作为业务逻辑入口,Jest用例直接调用它,断言请求参数对不对、回调处理是否合理,而不是真的在浏览器里走一遍完整表单。这一步可以大大提升测试效率,也符合“单元测试只测一个函数行为”的原则。
5.3 安全与性能测试,需要知道但不必过度设计
热搜词里出现了“安全测试”“渗透测试”“pikachu漏洞测试平台”等。作为开发人员,我们至少要建立安全意识:不要在前端代码里写死敏感信息、不要在原型对象上挂可以任意修改核心逻辑的方法。原型对象是全局共享的,一旦被污染,影响的是所有实例。这也是为什么我在测试用例里会专门检查“原型是否被意外改动”。
性能方面,原型链查找本身很快,但如果你在getter上做复杂计算,或者每次访问属性都触发深层遍历,性能问题就会浮现。测试时可以粗略统计一下单个方法的执行时间,或者用performance.now()在用例里包一层。如果方法执行超过预期阈值,就有必要考虑优化方案。
6. 一些我踩过坑之后养成的测试习惯
6.1 每写一个原型方法,先写“异常分支”用例
很多人写测试,习惯先把正常流程写一遍,跑通了就算完成任务。我的建议是反过来,先从异常分支开始写:传空参、传错类型、传undefined、传null。这些分支一旦被测试覆盖,后续重构时心里会踏实很多。
test('mergeUsers 传入非数组会抛出 TypeError', () => { expect(() => manager.mergeUsers('abc')).toThrow(TypeError); });这样一条用例看着简单,但它锁定了方法对非法输入的边界行为。如果未来有人重构mergeUsers,不小心把类型判断删了,这条用例立刻会亮红灯。
6.2 用jest.spyOn验证方法之间的调用关系
原型方法之间经常互相调用。比如addUser内部可能调用某个validateUser方法。如果你想验证“addUser确实调用了validateUser”,可以直接spy原型上的方法:
test('addUser 会调用原型链上的校验方法', () => { const spy = jest.spyOn(UserManager.prototype, 'validateUser'); manager.addUser({ name: '张三' }); expect(spy).toHaveBeenCalledTimes(1); spy.mockRestore(); });这个能力是Jest相对Mocha的一大优势。它能让你在“不真正执行校验逻辑”的前提下,确认调用关系是否正确。mock和spy的边界也值得记一下:spy不改变原方法行为,只做观察;mock会替换原方法实现。不改变业务逻辑时,优先用spy。
6.3 测试代码也要保持简洁,别让测试比业务代码还难读
最后想说一个心态问题。测试代码不是写得多就好,而是要读起来像一篇文档:每个test('描述')读下来,基本能明白被测方法“在什么输入下应该有什么行为”。我会刻意控制每个测试文件的行数,把用户管理的测试分成addUser、findByName、mergeUsers三个describe块。当某个测试挂掉时,扫一眼测试名,脑中大概能定位出错位置。
结尾
这套笔记写下来,我自己的收获远不止“搞懂了prototype什么时候存在”这么简单。JavaScript原型的核心思想——对象之间通过委托共享行为——其实贯穿了语言设计的方方面面。你在测试中遇到的this丢失、引用共享、mock污染,追根溯源都能回到原型链这个基础概念上。我现在的习惯是,遇到奇怪的JavaScript报错,先停下来想一想“这个对象是怎么创建出来的、方法挂在原型链的哪一层、this到底指向谁”,然后再去搜报错信息。希望你也能从这篇文章里,找到一套属于自己的排查路径。