- 文档
- 技术博客
- 教程
【免费下载链接】weekly
前端精读周刊。帮你理解最前沿、实用的技术。
Prototype(原型模式)是创建型模式家族中思路最直接的一种:它既不是工厂,也不是直接New,而是以拷贝已有对象(原型实例)的方式创建新对象。本篇基于前端精读周刊《设计模式 - Prototype 原型模式》展开,结合本仓库的可视化搭建、编译原理等源码级资料,讲清原型模式的意图、结构、TypeScript 实现、深浅拷贝的取舍与常见弊端,并落到"模版组件""缓存状态表"等真实场景中。读完你将掌握:什么时候该用原型模式、clone()该怎么写、如何与工厂模式组合使用,以及为什么"拷贝成本低于重建成本"是它的核心判断依据。
模式定位:创建型模式中的"拷贝创建"
在创建型模式中,我们通常有三种创建对象的方式:
- 直接
New:最朴素,但调用方需要知道具体类。 - 工厂(Factory / Factory Method / Abstract Factory):把创建逻辑收口到工厂,调用方只需面向接口。
- 原型(Prototype):既不 New 也不进工厂,而是基于一个已有的"原型实例"进行拷贝。
原型的官方意图是:
用原型实例指定创建对象的种类,并且通过拷贝这些原型创建新的对象。
"原型实例"就是被选为拷贝模版的那个对象——比如配钥匙时你递给老板的样板钥匙、缓存中的高级会员列表、可视化搭建里被选中的那个组件。之后需要新对象时,直接对这个原型调用拷贝逻辑即可,而不必关心它的具体实现类。
三个例子:什么场景下会用到原型模式
设计模式需要在日常工作中用起来,结合例子才能加深理解。原文给出了三个典型场景,我们逐一展开。
做钥匙:复制比制造更省事
为了房屋安全,要尽量做到一把钥匙只能开一扇门,每把钥匙结构都多多少少不一样,却又很相似。做钥匙的人按照你给的钥匙一模一样做一把新的,这属于什么模式?
答案是原型模式:你手里的样板钥匙就是"原型实例",配钥匙的过程就是"拷贝"。钥匙工厂无法解决每把钥匙齿形都不一样的问题,我们要的偏偏就是和某一把钥匙一模一样的副本,那么"复制一份"就是最直接的方案——抽象一点看,如果每把钥匙都遵循Prototype接口、提供clone()方法以复制自己,就能快速复制任意一把钥匙。
两种状态表:复制缓存而不是重查数据库
网站做不停机维护时,假设维护内容是给每个高级会员账户多打 100 元现金,需要改数据库表。已知:
- 数据库表有几千万条数据,其中高级会员有几千位,为了方便调用已经缓存在中间层了,且数据库对应 ID 更新后对应缓存也会更新。
- 几千条数据修改语句执行完需要几分钟,这几分钟内无法接受用户数据不同步的问题。
一种常见做法是:生成一份高级会员列表的拷贝,代替数据库缓存的结果。数据库读到对应会员 ID 时从拷贝列表中获取,数据表新增一列状态标志,操作完后移除拷贝、更新高级会员缓存。
关键问题是:如何生成这份拷贝?如果直接从几千万条用户数据中重新查询,数据库查询成本极高。此时直接复制已经查询好的列表,时间几乎可以忽略不计——这正是原型模式最典型的价值:查询/构建成本高昂,而拷贝成本极低时,直接复制是比"重新查询 + 工厂模式"更经济的方案。
模版组件:可视化搭建中的"分段式复制粘贴"
通用搭建系统中,我们可以把某个拖拽到页面的区块设置为"模版",这个模版可以作为一个新组件被重新拖拽到任意位置、实例化任意次。这本质上是一种分段式复制粘贴。
如果为每一种组件实例都提前定义好基类再 New,成本极高且不现实。更经济的做法是:每个组件提供一个clone()函数,选中任意组件实例后直接拷贝一份,立即生成新的实例。本仓库的可视化搭建系列正是这一思路的落地场景:组件树由ComponentInstance(componentName+props+children)同构描述,任意组件节点都可以"拎出来成为一棵新组件树",这与"原型实例可被任意拷贝"在抽象层面完全一致;而ComponentLoader 与动态组件中standalone动态组件"每个ComponentLoader此时都是一个唯一的实例,在designer内部会自动分配一个固定的组件 ID",本质上也是在处理"脱离组件树、按需实例化"的拷贝创建需求。
意图解释:原型模式的精髓是对象提供 clone()
结合三个例子,可以总结出原型模式的精髓:
- 原型实例:被选为拷贝模版的那个对象(样板钥匙 / 缓存列表 / 被选中的组件)。
- 拷贝创建:通过
clone()复制原型,得到新对象,而不是 New 或走工厂。
从结构上看,Client是发出指令的客户端,Prototype是一个接口,描述了一个对象如何克隆自身(比如必须拥有clone()方法),而ConcretePrototype是克隆的具体实现,不同对象用不同的实现来拷贝自身。客户端的代码只依赖Prototype接口,因此无论具体对象是什么实现,复制动作都是统一的clone()调用。
代码例子:TypeScript 实现 Component.clone()
下面例子使用 TypeScript 编写。一个实现了Prototype接口的组件类如下:
class Component implements Prototype { /** * 组件名 */ private name: string /** * 组件版本 */ private version: string /** * 拷贝自身 */ public clone = () => { // 构造函数省略了,大概就是传递 name 和 version return new Component(this.name, this.version) } }实现了Prototype接口的Component必须实现clone方法,这样任意组件执行复制时,都可以直接调用clone(),而不用关心每个组件不同的实现方式。使用起来也非常简单:
const newComponent = oldComponent.clone()从这段代码可以看出,原型模式与 Factory Method(工厂方法) 和 Builder(生成器) 有相似之处——都在隐藏创建对象的细节。区别在于:工厂把"创建"委托给工厂类,原型把"创建"委托给对象自己的拷贝能力。
clone() 设计的两个注意点
注意点一:clone() 不要加参数
如果要二次修改生成的对象,不建议给clone函数加参数,因为不同实现的clone参数各不相同,会导致接口不一致。正确的做法是:
clone()保持零参数,语义统一为"拷贝一份和原型一模一样的新对象";- 拷贝完成后,通过对象实例提供的
set函数进行二次修改。
注意点二:clone() 要考虑性能与拷贝深度
原型模式的拷贝建议用深拷贝——新对象最好不要影响到旧对象。但是在深拷贝性能问题较大的情况下,可以考虑深浅拷贝结合:将在新对象中不会修改的数据使用浅拷贝,可能被修改的数据使用深拷贝。
这一点在本仓库中有多处实践佐证:
- 定义联动协议中,协议表达式
relation.do在转换前使用JSON.parse(JSON.stringify(relation.do))做一次深拷贝,避免表达式对象在不同组件联动关系间共享引用而互相污染; - 精读《源码学习》介绍 Immer 风格的状态管理时提到:"核心思路是利用 Proxy 把脏活累活做掉……通过自定义 setting 不断递归进行浅拷贝,最后返回一个新引用的顶层对象"——这正是**写时拷贝(copy-on-write)**思想:只有被修改的路径才真正深拷贝,未修改路径共享引用(浅拷贝);
- 精读《Records & Tuples 提案》讨论的 Record/Tuple 在语言层面提供结构共享的不可变数据,同样是为了让"复制/比较"变得廉价。
也就是说,深浅拷贝结合并非模糊的建议,而是一条可量化的工程准则:
| 数据特征 | 拷贝策略 | 原因 |
|---|---|---|
| 拷贝后不会再修改 | 浅拷贝(共享引用) | 零成本,避免无谓的内存复制 |
| 拷贝后可能被修改 | 深拷贝(复制新值) | 隔离新旧对象,防止互相污染 |
| 存在循环引用 / 复杂引用链 | 谨慎或放弃clone | 深拷贝可能无法实现或开销失控 |
另外要特别注意:当对象存在引用关系甚至循环引用时,甚至不一定能实现拷贝函数——此时原型模式可能并不适用。
深浅拷贝的实现工具箱
在 JavaScript / TypeScript 中落地clone()时,可以按需选择以下手段:
// 1. 对象展开 / Object.assign:浅拷贝,适合只含基本类型字段的对象 const shallowCopy = { ...prototype } // 2. JSON 序列化:深拷贝,但会丢失函数、undefined、Symbol、循环引用 const deepCopy = JSON.parse(JSON.stringify(prototype)) // 3. structuredClone:原生深拷贝,支持循环引用与更多内置类型(需运行环境支持) const deepCopy = structuredClone(prototype) // 4. 手写递归 / 借助 Proxy 写时拷贝:按需深拷贝被修改的路径对于Component这样只有name、version两个基本类型字段的对象,直接new Component(this.name, this.version)即是最简单可靠的"深拷贝"。而对于字段复杂的对象,应先评估引用关系与修改面,再决定是纯深拷贝还是深浅结合。
弊端:每个设计模式都有代价
每个设计模式必有弊端,有弊端不代表设计模式不好用,而是指在某种场景下存在问题。我们只要规避这些场景,在合理的场景使用对应设计模式即可。
原型模式的弊端主要有两点:
- 每个类都要实现
clone方法,这对类的实现是有一定侵入的;要修改已有类时,违背了开闭原则——为已存在的类新增clone()属于"修改已有类",无法做到"对扩展开放、对修改关闭"。 - 深拷贝链路可能特别长:当类又调用了其他对象时,如果要实现深拷贝,需要对应对象也实现
clone方法,对象间引用关系越复杂,整条拷贝链路就越长,实现起来越麻烦;遇到循环引用甚至无法实现。
总结:原型模式一般与工厂模式搭配使用
原型模式一般与工厂模式搭配使用:工厂方法接收一个符合原型模式的实例,调用它的clone()函数创建并返回新对象,而不是New或调用其他工厂函数。代码大概是:
// buildComponentFactory 内部通过 targetComponent.clone() 创建对象,而不是 New 或者调用其他工厂函数。 const newComponent = buildComponentFactory(new Component())这样组合的好处是:
- 工厂只依赖
Prototype接口,无需关心具体实现类; - 工厂无需维护"如何构建一个完整对象"的逻辑,构建细节交给原型自己;
- 工厂负责统一入口与生命周期,原型负责低成本复制。
选择原型模式的决策标准可以归纳为一句话:当"复制一个已有对象"的成本显著低于"从零创建一个对象"(如重查数据库、重跑复杂初始化、重实例化复杂组件树),并且你需要保留原型对象完整状态时,就用原型模式;而当对象引用关系复杂、循环引用严重、或clone链路过长时,应谨慎评估或改用工厂等方案。
延伸阅读
- 设计模式 - Factory Method 工厂方法:原型模式最常见的搭档,理解面向接口创建对象的思路。
- 设计模式 - Builder 生成器:同为创建型模式,对比隐藏创建细节的不同方式。
- 可视化搭建 - 组件注册与画布渲染:组件树
ComponentInstance的同构结构,是"模版组件"例子的具体实现背景。 - 可视化搭建 - ComponentLoader 与动态组件:
standalone动态组件的按需实例化,呼应"拷贝创建"的另一种形态。 - 可视化搭建 - 定义联动协议:
JSON.parse(JSON.stringify(...))深拷贝协议表达式的真实代码。 - 精读《源码学习》:写时拷贝 + 浅拷贝的状态管理实现,理解深浅拷贝结合的工程化做法。
- 精读《Records & Tuples 提案》:语言层面结构共享的不可变数据,关注"廉价复制"的未来方向。
- 文档
- 技术博客
- 教程
【免费下载链接】weekly
前端精读周刊。帮你理解最前沿、实用的技术。
相关推荐
DesignPatternsPHP 原型模式(Prototype Pattern)实战:用克隆替代 new 提升 PHP 8 对象创建效率
DesignPatternsPHP 原型模式(Prototype Pattern)实战:用克隆替代 new 提升 PHP 8 对象创建效率 导读 原型模式(Pr
示例工程教程为什么选择 Rust?ppfuzz 原型链污染扫描工具技术选型与性能优势深度分析
为什么选择 Rust?ppfuzz 原型链污染扫描工具技术选型与性能优势深度分析 在 Web 安全领域,扫描工具的速度与可靠性往往决定了漏洞发现的效率。 ppf
QMUI_iOS中的原型模式:对象复制的高效方式
QMUI_iOS中的原型模式:对象复制的高效方式 在iOS开发中,对象复制是日常开发中频繁遇到的需求,无论是数据传递、状态保存还是多线程操作,都离不开高效的对象
移动开发UI组件
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考