1. 项目概述:从“new”到“工厂”,JS实例化的深度探索
在JavaScript的世界里,“实例化”这个词听起来有点学术,但说白了,就是“造东西”。我们写代码,本质上是在定义蓝图(类或构造函数),而实例化就是拿着这张蓝图,在内存里实实在在地盖出一栋房子(对象)。这个过程,是面向对象编程的基石,也是我们日常开发中无时无刻不在进行的操作。从最简单的new Object(),到复杂的工厂模式、单例模式,再到ES6的class,每一种实例化方式背后,都藏着不同的设计考量、性能特性和适用场景。
很多开发者,尤其是刚入门的同学,可能觉得会用new关键字就够了。但当你开始构建更复杂的应用,需要考虑代码的可维护性、可测试性、性能优化时,你会发现,不同的“造物”方式,带来的结果天差地别。比如,为什么有的库推荐用工厂函数而不是new?Object.create()和new有什么区别?如何在不同的场景下选择最合适的实例化方式?这些问题,直接关系到你代码的健壮性和优雅度。
这篇文章,我将结合自己十多年的前端开发经验,为你彻底拆解JavaScript中各种实例化方式。我们不会停留在语法表面,而是深入到内存、原型链、设计模式的层面,去理解每一种方式的“为什么”。无论你是想夯实基础的新手,还是希望优化现有架构的老手,相信都能从中获得实用的“干货”。
2. 核心概念与原理:理解“蓝图”与“房子”的关系
在深入各种实例化方式之前,我们必须先统一几个核心概念。这就像盖房子前,得先搞清楚图纸、施工队和成品之间的关系。
2.1 构造函数:负责施工的“施工队”
在ES6的class语法出现之前,构造函数是JavaScript创建对象类型的主要方式。它本质上就是一个普通的函数,但约定俗成,构造函数的名字首字母大写。
function Person(name, age) { this.name = name; this.age = age; this.sayHello = function() { console.log(`Hello, I'm ${this.name}`); }; }这里,Person就是一个构造函数。当你用new关键字调用它时,它就扮演了“施工队”的角色。new操作符会做四件关键事情:
- 创建一个全新的空对象。
- 将这个新对象的内部
[[Prototype]](即__proto__)链接到构造函数的prototype对象。这是实现继承的关键。 - 将构造函数内部的
this绑定到这个新创建的对象。 - 执行构造函数内部的代码(为这个新对象添加属性)。
- 如果构造函数没有显式返回一个对象,则自动返回这个新创建的对象。
理解这五步,尤其是第二步,是理解JavaScript原型继承和实例化本质的核心。
2.2 原型(Prototype):共享的“标准配件库”
每个函数都有一个prototype属性(箭头函数除外)。当这个函数被用作构造函数时,它的prototype属性所指向的对象,就成为了所有通过该构造函数创建的实例的“原型”。
Person.prototype.species = 'Homo sapiens'; Person.prototype.introduce = function() { console.log(`I am a ${this.species}. My name is ${this.name}.`); }; const alice = new Person('Alice', 30); const bob = new Person('Bob', 25); console.log(alice.species); // 输出:'Homo sapiens' console.log(bob.species); // 输出:'Homo sapiens' console.log(alice.introduce === bob.introduce); // 输出:true注意,species和introduce并不是直接存在于alice或bob对象上,而是存在于它们的原型(Person.prototype)上。当访问一个对象的属性时,JavaScript引擎会先查找对象本身,如果没有,则沿着原型链向上查找。这种方式实现了方法的共享,避免了每个实例都创建一份相同的函数,节省了内存。
2.3 实例:最终建好的“房子”
通过new操作符调用构造函数后得到的结果,就是一个实例。它是根据“蓝图”(构造函数+原型)建造出来的、拥有独立状态(属性)的个体。
const alice = new Person('Alice', 30); // alice 是一个实例 console.log(alice instanceof Person); // 输出:trueinstanceof操作符就是用来检查一个对象是否是某个构造函数的实例,其原理就是检查对象的原型链上是否存在该构造函数的prototype属性。
注意:一个常见的误区是在构造函数内部定义方法。如上例中的
this.sayHello,这会导致每个实例都拥有一个独立的sayHello函数副本,造成不必要的内存消耗。最佳实践是将方法定义在构造函数的prototype上。
3. 主流实例化方式深度解析
掌握了基本原理,我们来看看JavaScript中几种主流的“造物”方式。它们各有优劣,适用于不同的场景。
3.1 经典方式:new 操作符
这是最传统、最广为人知的方式。
基本语法与流程:
function Car(model, year) { this.model = model; this.year = year; } Car.prototype.drive = function() { console.log('Vroom!'); }; const myCar = new Car('Tesla Model 3', 2023); myCar.drive(); // 输出:Vroom!内部机制再探:我们模拟一下new操作符的行为,这能让你理解得更透彻:
function myNew(constructorFn, ...args) { // 1. 创建一个新对象,并将其原型指向构造函数的prototype const obj = Object.create(constructorFn.prototype); // 2. 执行构造函数,并将this绑定到新对象 const result = constructorFn.apply(obj, args); // 3. 如果构造函数返回了一个对象,则返回该对象;否则返回新创建的对象 return result instanceof Object ? result : obj; } const myCar2 = myNew(Car, 'BMW i4', 2024); console.log(myCar2 instanceof Car); // 输出:true console.log(myCar2.model); // 输出:'BMW i4'优点:
- 语法直观,符合传统面向对象语言的习惯。
- 能清晰地建立原型链关系,便于使用
instanceof进行类型检查。 - 社区接受度最高,文档和资料最丰富。
缺点与坑点:
- 忘记写
new:如果调用构造函数时忘了写new,那么函数内部的this在非严格模式下会指向全局对象(如window),造成属性泄露和难以调试的错误。
规避方法:在构造函数内部使用const badCar = Car('Toyota', 2020); // 糟糕!没有new console.log(window.model); // 输出:'Toyota' (污染了全局) console.log(badCar); // 输出:undefinednew.target(ES6)或判断this是否为构造函数实例来强制使用new。function SafeCar(model) { if (!new.target) { throw new Error('SafeCar must be called with new'); } this.model = model; } - 构造函数有返回值:如果构造函数显式返回了一个对象,那么
new表达式的结果将是这个返回的对象,而不是新创建的那个this对象。这可能会破坏原型链。function WeirdCar() { this.name = 'BMW'; return { name: 'Audi' }; // 返回一个新对象 } const car = new WeirdCar(); console.log(car.name); // 输出:'Audi' console.log(car instanceof WeirdCar); // 输出:false (原型链断了)
3.2 工厂函数模式:更灵活的“制造车间”
工厂函数不依赖于new关键字,它只是一个返回新对象的普通函数。
基本模式:
function createPerson(name, age) { const obj = {}; obj.name = name; obj.age = age; obj.greet = function() { console.log(`Hi, I'm ${name}`); }; // 可以在这里进行一些私有计算或初始化 const secretId = Math.random().toString(36).substr(2); obj.getId = function() { return secretId; }; // 闭包实现私有变量 return obj; } const person1 = createPerson('Charlie', 28); const person2 = createPerson('Diana', 35); person1.greet(); // 输出:Hi, I'm Charlie console.log(person1.getId()); // 输出一个随机ID console.log(person1 instanceof createPerson); // 输出:false进阶:带原型的工厂函数为了复用方法,工厂函数也可以结合原型:
const personMethods = { greet() { console.log(`Hi, I'm ${this.name}`); }, introduce() { console.log(`I'm ${this.age} years old.`); } }; function createPerson(name, age) { const obj = Object.create(personMethods); // 关键:设置原型 obj.name = name; obj.age = age; return obj; } const p = createPerson('Eve', 40); p.greet(); // 方法来自原型 console.log(personMethods.isPrototypeOf(p)); // 输出:true优点:
- 无需
new:完全避免了忘记写new导致的错误。 - 封装性更强:可以轻松地通过闭包创建真正的私有变量(如上例中的
secretId),这是class的私有字段(#)出现前最常用的实现私有性的方式。 - 更灵活的控制:你可以在函数内部进行复杂的逻辑判断,决定返回哪种对象,甚至返回缓存的对象(实现类似单例的效果)。
- 解耦:使用者完全不需要关心对象是如何被构造出来的,只需要调用工厂函数。
缺点:
- 无法使用
instanceof:返回的对象与工厂函数本身没有原型链关系,因此instanceof操作符失效。类型判断需要依赖其他方式,比如“鸭子类型”(检查是否有某个方法)或自定义标签属性。 - 内存效率可能稍低:如果每个对象的方法都是独立的(不使用共享原型),会占用更多内存。但通过上述结合
Object.create的方式可以避免。
适用场景:当你需要创建过程复杂、需要封装私有状态、或者不想强制使用者使用new关键字时,工厂函数是绝佳选择。许多现代JavaScript库(如Redux)的API设计都倾向于使用工厂函数。
3.3 Object.create():直接指定原型的“精准克隆”
Object.create()方法创建一个新对象,并使用现有的对象来提供新创建的对象的__proto__。它是直接操作原型链的底层工具。
基本用法:
const prototypeObj = { greet() { console.log('Hello from prototype!'); } }; const myObj = Object.create(prototypeObj); myObj.name = 'My Object'; myObj.greet(); // 输出:Hello from prototype! console.log(prototypeObj.isPrototypeOf(myObj)); // 输出:true模拟“纯净”的继承:这是实现原型式继承(Prototypal Inheritance)最纯粹的方式,不涉及任何构造函数。
const animal = { init(name) { // 一个初始化方法,类似构造函数 this.name = name; return this; }, eat() { console.log(`${this.name} is eating.`); } }; const dog = Object.create(animal); dog.bark = function() { console.log('Woof!'); }; const myDog = Object.create(dog).init('Buddy'); myDog.eat(); // 输出:Buddy is eating. myDog.bark(); // 输出:Woof! console.log(animal.isPrototypeOf(myDog)); // 输出:true (通过dog)与new的区别:
new Constructor():创建对象 → 链接到Constructor.prototype→ 执行Constructor初始化。Object.create(proto):创建对象 → 链接到proto。初始化需要额外步骤。
优点:
- 极其灵活:可以基于任何对象创建新对象,实现非常动态的原型链。
- 是
new的基石:正如我们在myNew模拟中看到的,Object.create是构建new行为的基础。 - 适合创建“纯净”的数据对象:当你想创建一个没有原型(
null)的纯粹数据字典时,Object.create(null)是唯一选择,这样可以避免与Object.prototype上的属性(如toString,hasOwnProperty)发生冲突。const pureDict = Object.create(null); pureDict.key = 'value'; console.log(pureDict.toString); // 输出:undefined
缺点:
- 初始化不便:没有自动的初始化过程(构造函数),需要手动调用初始化方法或设置属性。
- 语法相对不直观:对于习惯了类式继承的开发者来说,直接操作原型链可能显得抽象。
3.4 ES6 Class 语法糖:更优雅的“施工蓝图”
ES6引入的class语法,本质上是构造函数和原型继承的语法糖,但它提供了更清晰、更接近传统面向对象语言的写法。
基本语法:
class Rectangle { constructor(height, width) { this.height = height; this.width = width; this._privateCache = null; // 约定俗成的“私有”属性(非真正私有) } // Getter get area() { return this.height * this.width; } // 实例方法(实际上存在于 Rectangle.prototype 上) calcArea() { return this.area; } // 静态方法(存在于 Rectangle 本身上) static describe() { return 'I am a rectangle class.'; } } const square = new Rectangle(10, 10); console.log(square.area); // 输出:100 (通过getter访问) console.log(square.calcArea()); // 输出:100 console.log(Rectangle.describe()); // 输出:'I am a rectangle class.' console.log(square instanceof Rectangle); // 输出:true底层原理:class声明的Rectangle本质上仍然是一个函数(构造函数),constructor里的代码就是原来构造函数的函数体,类中定义的方法都挂在Rectangle.prototype上。static方法则挂在Rectangle函数对象本身上。
真正的私有字段(ES2022+):
class Counter { #count = 0; // 真正的私有字段,以#开头 increment() { this.#count++; console.log(this.#count); } // 无法从外部访问 #count } const c = new Counter(); c.increment(); // 输出:1 // console.log(c.#count); // SyntaxError: Private field '#count' must be declared in an enclosing class优点:
- 语法简洁清晰:将构造函数、原型方法、静态方法、继承等集中在一个声明里,结构一目了然。
- 内置
new检查:必须使用new调用,否则直接报错,避免了传统构造函数的陷阱。 - 支持继承的
super关键字:使继承的写法更加直观。 - 真正的私有字段(ES2022+):提供了语言级别的封装支持。
缺点:
- 依然是原型继承:需要理解其本质,否则容易产生误解(比如认为
class带来了全新的继承机制)。 - 兼容性:虽然现代浏览器和Node.js都已支持,但在一些非常老的环境(如旧版IE)中需要转译。
实操心得:对于大多数新的项目,class是首选。它既保留了JavaScript原型链的灵活性,又提供了更友好的语法。但在编写库或框架时,有时为了更极致的控制(如工厂模式的灵活性)或兼容性,仍会采用传统的构造函数或工厂函数。
4. 高级模式与场景化应用
掌握了基础方式,我们来看看在一些特定场景和高级模式中,如何组合运用这些实例化技术。
4.1 单例模式:确保全局唯一的“实例”
单例模式确保一个类只有一个实例,并提供一个全局访问点。在前端,常用于全局状态管理(如Vuex Store、Redux Store)、对话框管理器、日志服务等。
实现方式1:利用模块作用域(推荐)这是最简洁、最符合ES6模块规范的方式。
// Logger.js class Logger { constructor() { this.logs = []; } log(message) { const entry = `[${new Date().toISOString()}] ${message}`; this.logs.push(entry); console.log(entry); } getHistory() { return [...this.logs]; } } // 关键:在模块顶层创建并导出唯一实例 const loggerInstance = new Logger(); export default loggerInstance; // 在其他文件中使用 // import logger from './Logger.js'; // logger.log('App started');这种方式利用了ES6模块的“单例”特性:一个模块只会被加载和执行一次,因此loggerInstance只会被创建一次。
实现方式2:构造函数内判断
class Singleton { constructor() { // 如果实例已存在,则返回该实例 if (Singleton.instance) { return Singleton.instance; } // 否则进行初始化 this.data = {}; Singleton.instance = this; // 确保构造函数总是返回这个唯一实例 return this; } } const s1 = new Singleton(); const s2 = new Singleton(); console.log(s1 === s2); // 输出:true注意:这种方式有一个潜在问题。如果使用者忘记了用
new(虽然class会报错),或者通过Object.create(Singleton.prototype)等方式创建对象,就会破坏单例。因此,模块作用域的实现更健壮。
4.2 对象池模式:管理“实例”的生命周期以提升性能
在需要频繁创建和销毁大量相似对象的场景(如游戏中的子弹、粒子效果,DOM操作中的节点),对象池可以显著提升性能。其核心思想是:预先创建一组对象(池),使用时从池中取出,用完后不销毁而是放回池中,供下次复用。
简易对象池实现:
class ObjectPool { constructor(createFn, resetFn, initialSize = 10) { this.createFn = createFn; // 创建新对象的函数 this.resetFn = resetFn; // 重置对象状态的函数 this.pool = []; // 初始化对象池 for (let i = 0; i < initialSize; i++) { this.pool.push(this.createFn()); } } acquire() { // 如果池中有空闲对象,则取出并重置 if (this.pool.length > 0) { const obj = this.pool.pop(); this.resetFn(obj); return obj; } // 否则创建新对象(池已耗尽) console.warn('Object pool exhausted, creating new instance.'); return this.createFn(); } release(obj) { // 将对象放回池中 this.pool.push(obj); } get size() { return this.pool.length; } } // 使用示例:管理一个简单的向量对象池 const vectorPool = new ObjectPool( () => ({ x: 0, y: 0 }), // 创建函数 (vec) => { vec.x = 0; vec.y = 0; } // 重置函数 ); const v1 = vectorPool.acquire(); v1.x = 5; v1.y = 10; console.log(`Using vector: (${v1.x}, ${v1.y})`); // ... 使用v1进行一些计算 ... vectorPool.release(v1); // 用完后放回池中 console.log(`Pool size after release: ${vectorPool.size}`); // 池大小恢复 const v2 = vectorPool.acquire(); console.log(`Reacquired vector: (${v2.x}, ${v2.y})`); // 输出:(0, 0) 已被重置性能考量:对象池避免了频繁的垃圾回收(GC),对于性能敏感的应用至关重要。但要注意,池的大小需要根据实际需求调整,太小会导致频繁创建新对象,太大则会占用过多初始内存。
4.3 依赖注入与可测试性:松耦合的“实例”创建
在大型应用中,直接在一个模块内部使用new来创建依赖对象,会导致代码高度耦合,难以测试。依赖注入(Dependency Injection, DI)是一种将对象的创建与其使用分离的设计模式。
问题代码(紧耦合):
// UserService.js (难以测试) class UserService { constructor() { this.db = new DatabaseConnection('localhost', 3306, 'myapp'); // 直接创建依赖 } getUser(id) { return this.db.query(`SELECT * FROM users WHERE id = ${id}`); } } // 测试UserService时,必须连接真实数据库!改进:依赖注入(构造函数注入)
// UserService.js (可测试) class UserService { constructor(databaseConnection) { // 依赖通过参数传入 this.db = databaseConnection; } getUser(id) { return this.db.query(`SELECT * FROM users WHERE id = ${id}`); } } // 在生产环境中 const prodDb = new DatabaseConnection('prod-host', 3306, 'myapp'); const userService = new UserService(prodDb); // 在测试环境中 class MockDatabase { query(sql) { console.log(`Mock query: ${sql}`); return { id: 1, name: 'Test User' }; } } const mockDb = new MockDatabase(); const testUserService = new UserService(mockDb); // 轻松注入模拟对象 const user = testUserService.getUser(1); // 不会触及真实数据库工厂函数与依赖注入的结合:我们可以创建一个工厂函数来集中管理这些依赖的创建和组装。
// container.js (简单的依赖注入容器) const dependencies = {}; export function register(key, factoryFn) { dependencies[key] = factoryFn; } export function resolve(key) { if (dependencies[key]) { return dependencies[key](); } throw new Error(`Dependency ${key} not registered.`); } // 注册依赖 register('database', () => new DatabaseConnection('localhost', 3306, 'myapp')); register('userService', () => { const db = resolve('database'); return new UserService(db); }); // 在应用入口 import { resolve } from './container.js'; const userService = resolve('userService');这种方式将对象的创建逻辑集中管理,使核心业务代码(如UserService)更加纯净和可测试。许多前端框架(如Angular、NestJS)内置了强大的依赖注入容器。
5. 性能优化、内存管理与常见陷阱
不同的实例化方式对性能和内存的影响不同,了解这些细节有助于你写出更高效的代码。
5.1 内存占用分析
在构造函数内定义方法:每个实例都会拥有一份独立的函数副本。
function HeavyObject() { this.method = function() { /* ... */ }; // 每个实例都创建新函数 } const arr = []; for (let i = 0; i < 10000; i++) arr.push(new HeavyObject()); // 内存占用高,因为有一万个不同的method函数在原型上定义方法:所有实例共享同一个函数引用。
function LightObject() {} LightObject.prototype.method = function() { /* ... */ }; // 所有实例共享 const arr = []; for (let i = 0; i < 10000; i++) arr.push(new LightObject()); // 内存占用低,一万个实例共享一个method函数结论:对于需要大量创建的同类型对象,务必将方法定义在原型上。这是JavaScript性能优化中最基本也最重要的一条原则。
5.2 实例化速度微基准测试
虽然大多数情况下差异不大,但在极端性能敏感的场景(如每秒创建数万个对象),选择更快的实例化方式可能有意义。
// 简单测试三种方式的创建速度 function TestClass() { this.value = 1; } TestClass.prototype.method = function() {}; function testFactory() { return { value: 1, method: function() {} }; } const proto = { method: function() {} }; function testObjectCreate() { const obj = Object.create(proto); obj.value = 1; return obj; } function runTest(name, fn, iterations = 1000000) { console.time(name); for (let i = 0; i < iterations; i++) { fn(); } console.timeEnd(name); } runTest('new with class', () => new TestClass()); runTest('factory', testFactory); runTest('Object.create', testObjectCreate);在我的测试环境中(Chrome浏览器),通常new和工厂函数速度接近,Object.create可能会稍慢一点,因为多了一次原型链接的设置。但绝对不要过早优化,除非性能分析工具明确显示对象创建是瓶颈。代码的清晰度和可维护性永远应该放在第一位。
5.3 原型链污染与属性屏蔽
这是一个高级但重要的概念。当通过实例访问一个属性时,如果实例自身没有,就会去原型链上找。但如果给实例设置一个与原型链上同名的属性,就会发生“属性屏蔽”。
const proto = { value: 10 }; const obj = Object.create(proto); console.log(obj.value); // 输出:10 (来自原型) obj.value = 20; // 在obj自身上创建属性`value`,屏蔽了原型上的`value` console.log(obj.value); // 输出:20 (来自自身) console.log(proto.value); // 输出:10 (原型未变) delete obj.value; // 删除自身属性 console.log(obj.value); // 输出:10 (再次来自原型)注意事项:在修改对象属性时,要清楚你修改的是自身属性还是原型属性。无意中屏蔽原型属性可能导致难以察觉的bug。可以使用Object.hasOwn(obj, 'prop')来检查属性是否直接存在于对象自身。
5.4 循环引用与内存泄漏
虽然现代垃圾回收机制(GC)很强大,但不当的引用仍会导致内存泄漏,尤其是在单页应用(SPA)和Node.js长期运行的服务中。
典型场景:事件监听器
class Component { constructor(element) { this.element = element; this.handleClick = this.handleClick.bind(this); this.element.addEventListener('click', this.handleClick); } handleClick() { console.log('Clicked', this); } destroy() { // 忘记移除监听器会导致内存泄漏! // this.element.removeEventListener('click', this.handleClick); } } const div = document.createElement('div'); const comp = new Component(div); // 即使comp不再被使用,因为div还持有对comp.handleClick的引用, // comp无法被GC回收,div本身也因被comp引用而无法回收。 comp = null; // 仅仅这样是不够的解决方案:在实例需要被销毁时(如组件卸载),必须手动清理对外部资源的引用,如移除事件监听器、清除定时器、断开WebSocket连接等。这就是为什么在React的useEffect或Vue的beforeUnmount等生命周期钩子中清理副作用如此重要。
6. 现代框架中的实例化实践
看看主流框架是如何运用这些实例化概念的,能帮助我们更好地理解其设计哲学。
6.1 React:函数组件与Hooks
React推崇函数式编程,其函数组件本质上就是一个返回React元素(描述UI的对象)的工厂函数。
// 函数组件:一个创建UI描述的工厂 function Welcome(props) { // 每次渲染,都会调用这个函数,生成新的React元素 return <h1>Hello, {props.name}</h1>; } // 使用:<Welcome name="Sara" />useState,useEffect等Hooks,可以看作是React在背后为每个组件实例维护了一个“记忆单元格”链表,将状态与特定的组件函数调用关联起来。虽然组件函数本身被多次调用,但Hooks机制保证了状态的持续性。
6.2 Vue:响应式代理与工厂
Vue 3的reactive()和ref()函数,是创建响应式对象的工厂函数。
import { reactive, ref } from 'vue'; // reactive 工厂函数:创建一个响应式代理对象 const state = reactive({ count: 0 }); // ref 工厂函数:创建一个包装了值的响应式引用对象 const message = ref('Hello'); // 在组合式API中,setup()函数类似于一个为组件实例创建响应式上下文的工厂 export default { setup() { const state = reactive({ /* ... */ }); const double = computed(() => state.count * 2); // computed也是工厂函数 return { state, double }; // 返回的对象被暴露给模板 } }Vue的响应式系统基于Proxy,reactive()工厂函数创建了一个代理对象,拦截所有get/set操作,从而实现依赖追踪和触发更新。
6.3 框架共性与启示
无论是React的函数组件还是Vue的组合式API,现代前端框架都在弱化传统的new和class(尽管Vue选项式API和React Class组件仍支持),转而拥抱工厂函数和函数式编程的理念。这带来了以下好处:
- 更好的可测试性:纯函数或工厂函数没有内部状态依赖,更容易测试。
- 更灵活的代码组织:逻辑可以更容易地被抽取和复用(如自定义Hooks、Composables)。
- 更小的打包体积:Tree-shaking对函数比对类更友好。
7. 实战:构建一个可配置的日志器工厂
最后,我们综合运用所学,构建一个实用的、可配置的日志器工厂。它将支持不同日志级别、可定制的输出格式、以及可插拔的日志存储后端(如控制台、本地存储、远程服务器)。
// 1. 定义日志级别常量 const LogLevel = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3, NONE: 4 }; // 2. 定义存储后端接口(抽象) class LogStorage { write(level, message, meta) { throw new Error('Method `write` must be implemented by subclasses.'); } } // 3. 实现具体的存储后端 class ConsoleStorage extends LogStorage { write(level, message, meta) { const prefix = `[${new Date().toISOString()}] [${level.toUpperCase()}]`; const logFn = console[level] || console.log; logFn(prefix, message, meta || ''); } } class LocalStorageStorage extends LogStorage { constructor(key = 'app_logs', maxEntries = 100) { super(); this.key = key; this.maxEntries = maxEntries; } write(level, message, meta) { const entry = { timestamp: Date.now(), level, message, meta }; const logs = JSON.parse(localStorage.getItem(this.key) || '[]'); logs.push(entry); // 保持日志数量不超过最大值 if (logs.length > this.maxEntries) { logs.splice(0, logs.length - this.maxEntries); } localStorage.setItem(this.key, JSON.stringify(logs)); } } // 4. 核心:日志器工厂函数 function createLogger(options = {}) { const { level = LogLevel.INFO, storage = new ConsoleStorage(), // 默认使用控制台 formatter = (level, msg, meta) => `${level}: ${msg}`, // 默认格式化器 enableStackTrace = false // 是否记录调用栈(性能开销大,仅用于调试) } = options; // 私有状态和工具函数 const currentLevel = level; const logStorage = storage; function shouldLog(msgLevel) { return msgLevel >= currentLevel; } function getStackTrace() { try { throw new Error(); } catch (e) { return e.stack; } } // 创建实际的日志方法 function makeLogMethod(levelName, levelValue) { return function(message, meta) { if (!shouldLog(levelValue)) return; const finalMeta = { ...meta }; if (enableStackTrace && levelValue >= LogLevel.ERROR) { finalMeta.stackTrace = getStackTrace(); } const formattedMessage = formatter(levelName, message, finalMeta); try { logStorage.write(levelName, formattedMessage, finalMeta); } catch (err) { // 防止日志记录本身出错导致应用崩溃 console.error('Logger failed:', err); } }; } // 返回日志器实例 return { debug: makeLogMethod('DEBUG', LogLevel.DEBUG), info: makeLogMethod('INFO', LogLevel.INFO), warn: makeLogMethod('WARN', LogLevel.WARN), error: makeLogMethod('ERROR', LogLevel.ERROR), // 可以动态修改配置 setLevel(newLevel) { if (Object.values(LogLevel).includes(newLevel)) { currentLevel = newLevel; } }, // 添加新的存储后端 addStorage(newStorage) { // 可以组合多个存储后端,这里简单覆盖 logStorage = newStorage; } }; } // 5. 使用示例 // 创建一个开发环境日志器 const devLogger = createLogger({ level: LogLevel.DEBUG, storage: new ConsoleStorage(), enableStackTrace: true }); // 创建一个生产环境日志器(只记录WARN和ERROR) const prodLogger = createLogger({ level: LogLevel.WARN, storage: new LocalStorageStorage('prod_logs', 500) }); // 使用 devLogger.info('Application started', { version: '1.0.0' }); devLogger.debug('User clicked button', { buttonId: 'submit' }); try { // 模拟一个错误 throw new Error('Network request failed'); } catch (err) { prodLogger.error('Operation failed', { error: err.message, code: 500 }); } // 6. 扩展:创建一个单例的全局日志器(结合模块模式) // globalLogger.js const globalLogger = createLogger({ level: process.env.NODE_ENV === 'production' ? LogLevel.WARN : LogLevel.DEBUG, storage: process.env.NODE_ENV === 'production' ? new LocalStorageStorage() : new ConsoleStorage() }); export default globalLogger;这个实战案例展示了如何将工厂函数、组合模式、策略模式(不同的存储后端)和单例模式结合起来,创建一个灵活、健壮、可维护的工具。它避免了使用new和class带来的继承复杂性,通过函数组合和配置化,提供了强大的扩展能力。你可以轻松地添加新的存储后端(如发送到服务器),或者改变日志格式,而无需修改核心日志逻辑。这正是现代JavaScript库设计所推崇的“组合优于继承”和“关注点分离”原则的体现。