做前端这些年我最大的感触是很多 JavaScript 开发者一听设计模式就犯怵觉得那是 Java、C 的专属可真到了要重构一堆 if/else 的时候才想起来还有工厂模式这种好东西。工厂模式的核心其实一句话就能讲清把“创建对象”这件事从业务代码里摘出去交给一个专门的工厂来处理。调用方不关心这个对象到底是怎么 new 出来的只关心“我要哪种能力”。听起来很简单但真正用好的前提是搞明白它到底在消灭什么问题否则很容易写成“为了工厂而工厂”。1. 从new到工厂把对象创建从业务逻辑里拆出来1.1 一个看起来没什么问题的new很多项目开始的时候直接 new 一个类是最自然不过的写法const logger new ConsoleLogger({ level: debug }); logger.info(启动完成);这段代码放在业务入口里毫无违和感。问题出现在需求开始膨胀的时候。假设第二周要求支持文件日志第三周要求支持远程日志第四周又多了个按天切割的日志。于是你的业务代码里开始出现这种片段let logger; if (config.logType console) { logger new ConsoleLogger(config); } else if (config.logType file) { logger new FileLogger({ path: config.logPath, level: config.level }); } else if (config.logType remote) { logger new RemoteLogger({ url: config.logUrl, token: config.token }); }这段代码本身不复杂但它把“创建细节”和“业务逻辑”死死焊在了一起。每次新增一个 logger 类型你都得跑到业务代码里加一个 else if如果这个创建逻辑散落在多个文件里那就是灾难。所谓“需求一来满屏都是 if/else”就是这个过程积累出来的。真正让人头疼的并不是多写几个分支而是你的业务代码必须知道所有具体类的构造参数——这等于把耦合从“类型”扩散到了“内部细节”。1.2 新增需求时改业务代码还是改工厂工厂模式最直接的收益是把“变化”隔离在一个地方。还是日志的例子如果引入一个 createLogger 工厂函数业务代码会变成const logger createLogger(config);后续不管新增 Kafka 日志、数据库日志还是云日志业务代码这一行永远不动。改动的只有工厂函数。这就是所谓的“开闭原则”在创建层面的落地对扩展开放对修改关闭。注意这句口号不是让你把所有代码都写成读不懂的抽象而是说“变化频率最高的地方”应该被单独收口。日志类型、支付渠道、数据解析器、组件映射这些都属于“很容易扩展”的场景用工厂收口非常合适。1.3 工厂模式的定义与我理解的本质官方定义是定义一个创建对象的接口让子类决定实例化哪个类。但在我眼里工厂模式的本质只有四个字延迟决策。调用方不关心自己拿到的是哪个具体类只关心这个对象能不能满足接口约定。JavaScript 由于有鸭子类型所谓的“接口约定”其实就是几个方法签名。因此 JS 里的工厂实现往往比 Java 更轻量一个函数、一张映射表、甚至一个 Proxy 都能完成“工厂”的职责。理解到这一层你再看那些把工厂模式写成七八个类的教程就会发现那只是其中一种形态而不是唯一答案。2. 三种工厂形态简单工厂、工厂方法、抽象工厂怎么用2.1 简单工厂用函数集中管理创建逻辑简单工厂通常是一个函数根据参数返回不同的实例。前端项目里最常见的就是这种function createLogger(type, options {}) { switch (type) { case console: return new ConsoleLogger(options); case file: return new FileLogger({ path: options.logPath, ...options }); case remote: return new RemoteLogger({ url: options.logUrl, ...options }); default: throw new Error(Unknown logger type: ${type}); } }简单工厂的优点是直观、好写、好测试。缺点也很明确每增加一种产品类型都要改这个函数。很多人觉得这是缺点其实要辩证看。如果产品类型只有三五种且都在你的可控范围内简单工厂完全够用。真正需要警惕的不是“改工厂函数”而是“工厂函数被改得越来越大”。我见过几百行的简单工厂那种情况说明产品之间差异太大简单工厂已经承载不了这么多分支逻辑该考虑工厂方法了。2.2 工厂方法把创建动作交还给子类工厂方法模式把“创建哪个产品”的决策下沉到子类。父类定义流程子类决定细节。典型的写法是class LoggerFactory { createLogger(options) { throw new Error(子类必须实现 createLogger); } build(options) { const logger this.createLogger(options); if (logger.init) { logger.init(); } return logger; } } class ConsoleLoggerFactory extends LoggerFactory { createLogger(options) { return new ConsoleLogger(options); } } class FileLoggerFactory extends LoggerFactory { createLogger(options) { return new FileLogger({ path: options.logPath, ...options }); } }使用的时候只需要记住具体工厂类const factory new FileLoggerFactory(); const logger factory.build({ logPath: ./app.log, level: info });有人会觉得这不就是把 if/else 换成了类继承吗是也不是。关键是父类的 build 方法里可以编排公共流程比如创建前的校验、创建后的初始化、创建次数的统计。如果你把这些公共逻辑放在业务代码里每个调用方都得重复写一遍放到工厂方法里所有子类自动复用。所以工厂方法更适合“产品创建流程有固定模板但产品实现各不相同”的场景。2.3 抽象工厂处理一组产品的配套关系抽象工厂解决的是“产品族”问题。所谓产品族是指一组必须配套使用的产品。举个前端常见的例子主题系统。亮色主题需要亮色按钮、亮色输入框、亮色弹窗暗色主题需要暗色按钮、暗色输入框、暗色弹窗。如果你在业务代码里分别创建按钮和输入框很容易出现“按钮是亮色、输入框是暗色”的混搭事故。抽象工厂的做法是定义一个主题工厂接口每个主题实现一套完整的产品class LightThemeFactory { createButton() { return new LightButton(); } createInput() { return new LightInput(); } createModal() { return new LightModal(); } } class DarkThemeFactory { createButton() { return new DarkButton(); } createInput() { return new DarkInput(); } createModal() { return new DarkModal(); } }业务代码里只需要注入主题工厂const themeFactory currentTheme dark ? new DarkThemeFactory() : new LightThemeFactory(); const button themeFactory.createButton(); const input themeFactory.createInput();这样无论当前是什么主题创建出来的组件一定是同一套风格。抽象工厂在 JS 前端的典型应用就是主题系统、组件库的多品牌适配以及跨平台渲染器的能力分组。2.4 三张表看透三种工厂的差异维度简单工厂工厂方法抽象工厂核心载体函数或类中的方法父类定义方法子类实现接口/类定义一组创建方法扩展方式修改工厂函数新增子类工厂新增一个产品族实现适用场景类型少、创建逻辑简单创建流程固定、产品差异大存在产品族需要保证配套产品数量单个产品单个产品一组产品常见误用工厂函数越来越臃肿为了继承而继承只做一个产品也硬上抽象工厂记住这张表的判断逻辑选型基本不会跑偏。3. 实战落点前端项目里工厂模式最香的5个场景3.1 支付渠道、登录方式这类“同接口多实现”的注册表业务系统最常见的需求就是多渠道支持支付有支付宝、微信、银联登录有账号密码、短信验证码、第三方 OAuth。硬写 if/else 的话每加一种渠道就要动业务代码。用工厂加注册表的方式可以做到渠道扩展零侵入const payChannelFactories { alipay: (config) new AlipayChannel(config), wechat: (config) new WechatChannel(config), card: (config) new CardChannel(config), }; function createPayChannel(type, config) { const factory payChannelFactories[type]; if (!factory) { throw new Error(Unsupported pay channel: ${type}); } return factory(config); }新增渠道时只需要在 map 里增加一行业务调用方 createPayChannel 完全不用改。而且这种 map 结构天然可枚举你可以在管理后台把支持的渠道列表直接渲染出来。工厂在这里的价值不只是“创建对象”还顺带变成了“渠道注册中心”。3.2 数据解析器把后端各种格式统一成前端结构前后端联调时最怕的是每个接口返回的数据结构都不一样。有的返回 snake_case有的返回 camelCase有的直接给你字符串拼接的时间。如果每次都在组件里处理很容易漏。我的做法是给每类数据源配一个解析器工厂function createDataParser(sourceType) { switch (sourceType) { case user: return new UserParser(); case order: return new OrderParser(); case product: return new ProductParser(); default: throw new Error(Unknown parser: ${sourceType}); } }每个 Parser 都暴露 parse(rawData) 方法统一输出前端组件需要的结构。业务层拿到的是已经“标准化”的数据不需要关心来源到底是 v1 接口还是 v2 接口。这样做的另一个好处是后端字段变更时你只需要改对应的 Parser不会污染 UI 层。3.3 动态组件映射React/Vue里的组件工厂前端框架里的动态组件渲染本质也是一种工厂。比如表单配置化系统里后端返回一段 JSON 配置里头的 type 字段可能是 input、select、datePicker、upload。前端不可能用 if/else 渲染所有组件而是维护一张组件映射表const componentMap { input: InputField, select: SelectField, datePicker: DatePickerField, upload: UploadField, }; function renderField(config) { const Component componentMap[config.type]; if (!Component) { return null; } return h(Component, { config }); }这张映射表就是组件工厂。新增一种字段类型不需要改渲染主流程只需要往 componentMap 里注册新组件。很多同学没意识到这也是工厂模式因为它没有函数调用但本质完全一致根据一个类型标识返回对应的组件/对象。3.4 游戏对象生成与对象池工厂和性能的配合在浏览器里做游戏或复杂动画时子弹、敌人、粒子这些对象如果频繁 new 和销毁会造成明显的 GC 卡顿。一个游戏引擎里常见的做法是“工厂 对象池”class BulletFactory { constructor() { this.pool []; } create(options) { const bullet this.pool.pop() || new Bullet(); bullet.init(options); return bullet; } recycle(bullet) { bullet.reset(); this.pool.push(bullet); } }工厂负责从池子里取对象取不到才 new销毁时不是直接扔掉而是回收复用。这在 Canvas/WebGL 场景里非常实用。很多开发者不知道的是工厂模式天然适合叠加“缓存、池化、单例”这些优化策略因为创建行为已经被收口了你可以在工厂内部随意加一层逻辑调用方无感知。3.5 表单字段渲染根据配置元数据生成控件和组件映射类似表单引擎里经常要根据元数据生成控件。元数据可能来自后端也可能来自 JSON Schema。这时候工厂不仅要根据 type 返回控件还要根据 props 返回已经装配好的字段实例function createField(config) { const { type, name, label, rules } config; switch (type) { case text: return new TextField({ name, label, rules }); case number: return new NumberField({ name, label, rules, min: config.min, max: config.max }); case checkbox: return new CheckboxField({ name, label, options: config.options }); default: throw new Error(Unsupported field type: ${type}); } }这种写法让表单配置变成纯数据驱动业务方只需要维护配置不需要关心控件实例化细节。配合 JSON Schema 动态渲染就能搭出一个低代码表单平台。4. 决策指南什么时候用哪种工厂以及什么时候别用4.1 选型的三个问题每次要引入工厂模式时我都先问三个问题创建对象的过程是不是已经复杂到影响业务阅读产品类型是否经常增加或变化调用方是否真的不需要知道具体类如果三个答案都是“是”才值得上工厂。如果只是个别地方 new 了一个类那没必要为了模式而模式。记住设计模式是用来消灭重复和耦合的不是用来制造抽象层的。很多人一上来就把简单工厂、工厂方法、抽象工厂全部堆上最后项目里全是工厂的工厂那才是真灾难。4.2 简单工厂的边界与妥协简单工厂最合适的场景是类型少、创建参数简单、变化频率可控。我自己的判断标准是工厂函数里的分支不超过 5 个且每个分支不超过 3 行。一旦超过这个界限就要考虑拆成工厂方法。另一个折中方案是“注册表 默认实现”也就是用 map 替代 switchconst registry new Map(); function registerFactory(type, factory) { registry.set(type, factory); } function createProduct(type, options) { const factory registry.get(type); if (!factory) { throw new Error(Unregistered type: ${type}); } return factory(options); }这种写法让简单工厂变成了可扩展的注册表不需要改函数体就能新增产品算是一种轻量升级。4.3 工厂方法适合产品创建逻辑复杂且需要扩展的场景当产品种类多并且每个产品的创建步骤差异很大时简单工厂会变得越来越难读。工厂方法把每个产品的创建细节拆分到独立的子类工厂中符合“单一职责”。比如一套跨端渲染引擎同样的组件在 Web、小程序、原生端都有不同的创建逻辑。用工厂方法每个端一个工厂各自负责各自的平台差异上层代码只面对统一的 build 接口。这个场景里工厂方法不是可有可无而是唯一能控制复杂度的方式。4.4 抽象工厂别在只有一个产品族的时候用抽象工厂的杀伤力在于管理“产品族”但很多项目其实只有一个产品族。比如你只有一个主题那你写 DarkThemeFactory、LightThemeFactory 就是多余的。只有当确确实实存在两套以上的配套产品并且你需要保证它们之间不能混搭时抽象工厂才成立。否则还不如用简单工厂一个个创建代码更短心智负担更低。4.5 不要为了设计模式而设计模式我在 code review 里见过很多“过度工厂”的例子。一个只有一种实现的类外面包了一层工厂函数一个无论如何都只有一个类型的对象也套了抽象工厂。这种代码表面上显得“专业”实际上增加了阅读成本。工厂模式的本质是管理“变化”没有变化就没有工厂。如果你预判这个产品未来有扩展可能可以留好扩展点但不要一上来就把所有扩展都实现掉。YAGNI 原则在模式选型里同样适用。5. 进阶玩法工厂模式跟策略模式、依赖注入、高阶函数搭档5.1 工厂策略表把switch消灭在入口处策略模式和工厂模式经常一起出现。工厂负责“创建策略对象”策略对象负责“实现具体算法”。比如一个表单校验器不同字段类型对应不同校验策略const validators { email: (value) /^\S\S\.\S$/.test(value), phone: (value) /^1[3-9]\d{9}$/.test(value), required: (value) value ! undefined value ! , }; function getValidator(type) { return validators[type] || (() true); }这里的工厂函数 getValidator 返回的是一个函数策略而不是一个类实例。在 JavaScript 里函数也是对象把策略表放进工厂里异常灵活。这种用法比纯 class 更符合 JS 的语言习惯。5.2 工厂依赖注入让构造函数不直接依赖产品类依赖注入的核心思想是“我不自己 new你给我”。如果把工厂和依赖注入配合可以做到连工厂都不直接依赖具体类class Container { constructor() { this.dependencies new Map(); } register(name, dependency) { this.dependencies.set(name, dependency); } resolve(name) { const dependency this.dependencies.get(name); if (!dependency) { throw new Error(Dependency not found: ${name}); } return typeof dependency function ? dependency() : dependency; } } const container new Container(); container.register(logger, () createLogger(console)); container.register(payment, () createPayChannel(alipay, config)); const logger container.resolve(logger);这其实是一个极简的依赖注入容器。工厂模式负责“如何创建”容器负责“如何管理依赖关系”。两者合在一起能让模块之间的耦合降到最低。5.3 用高阶函数封装通用工厂JavaScript 的高阶函数让工厂有了更灵活的形态。比如你可以写一个“带缓存的高阶工厂”function memoizedFactory(factory) { const cache new Map(); return (type, options) { if (!cache.has(type)) { cache.set(type, factory(type, options)); } return cache.get(type); }; } const createLoggerCached memoizedFactory(createLogger); const logger1 createLoggerCached(console); const logger2 createLoggerCached(console); console.log(logger1 logger2); // true这种高阶工厂不关心你创建的是什么只负责统一加缓存逻辑。如果项目中多个工厂都需要缓存用这种组合式的方式可以避免在每个工厂里重复写缓存代码。6. 踩坑实录我在工厂模式落地时遇到的四个典型问题6.1 this指向丢失class方法被当作工厂函数调用有一次我用 class 实现工厂把工厂方法直接赋值给事件回调结果 this 变成了 undefined。原因很简单方法被单独取出来调用时class 里的 this 不会自动绑定。解决办法是箭头函数字段或者 bindclass LoggerFactory { createLogger (type, options) { // 箭头函数保证 this 指向实例 }; }如果你的项目不支持 class 字段语法可以在构造函数里 bindclass LoggerFactory { constructor() { this.createLogger this.createLogger.bind(this); } createLogger(type, options) {} }这个坑在 React 类组件时代非常常见现在虽然少了但用工厂方法时依然要注意。6.2 产品构造参数不统一把所有参数收敛成一个options对象不同产品需要不同参数这是工厂最头疼的问题。有的产品需要 path有的需要 url有的只需要 level。如果工厂签名是 createLogger(type, path, url, token)调用起来不传一堆 undefined 才怪。我的经验是工厂函数永远只接受两个参数第一个是 type第二个是 options 对象function createLogger(type, options {}) { switch (type) { case console: return new ConsoleLogger(options); case file: return new FileLogger({ path: options.path, level: options.level }); // 每个分支只取自己需要的字段 } }调用方按需传字段就好不用关心其他产品需要什么。这个习惯能让工厂长期稳定不会因为新增产品就把签名改得面目全非。6.3 缓存与单例重复创建对象造成的状态污染有些对象必须是单例比如全局日志、全局配置、数据库连接。如果你每次调用工厂都 new 一个轻则浪费内存重则状态不同步。我踩过最痛的坑是用工厂创建了一个 WebSocket 连接管理器页面每次进入都 new 一次导致旧连接没有关闭最终服务端连接数爆炸。后来我直接在工厂里加了单例判断let instance; function createConnection(type, options) { if (instance) { return instance; } instance new Connection(type, options); return instance; }更通用的做法是用上面提到的高阶工厂统一加缓存。总之工厂只是一个创建入口它不保证返回的一定是新对象。是否需要单例完全由业务决定。6.4 测试中的mock把工厂本身变成可注入的入口工厂模式对测试非常友好因为你可以通过替换工厂来替换所有产品。但这里也有个隐藏坑如果你的业务代码模块级直接引用工厂函数测试时 mock 起来就要用 vi.mock 或 jest.mock 去替换模块稍显麻烦。我的做法是把工厂函数放到一个依赖对象里业务代码通过依赖对象调用工厂。这样测试时只要注入一个 fake factory 即可。这个思路本质上是把工厂视为一个依赖而不是一个全局函数。如果你的项目测试比较重建议一开始就把工厂注入到业务类或组件的 props 里。最后再分享一个小技巧遇到“if/else 在创建对象”的场景先不急着重构。等同样的 if/else 出现第二遍的时候再提炼工厂。这时候你已经知道变化的方向提炼出来的工厂往往更贴合实际。设计模式不是表演它是为了解决真实问题而存在的工具。工厂模式尤其如此。