原型模式(Prototype)是 GoF 23 个模式里最容易被误解的一个 —— 听起来玄,实际上做的事就是"克隆"。但要把克隆做对、做安全、做高效,牵扯到深浅拷贝、序列化陷阱、循环引用、不可变共享一连串细节。这篇文章从最朴素的需求讲到 JS 原型链、Spring Prototype Scope,让你看到这个模式如何在不同语言里以不同样貌出现。
原型模式要解决什么问题
正常创建对象有两条路:new ClassName(...) 或者经过工厂方法。但有些场景这两条都不优雅:
- 对象创建非常昂贵(要读文件、查数据库、做复杂计算),但你需要很多结构相同的副本。
- 对象的具体类型在运行时才知道,你只有一个"已经在手的实例",想要"再来一个一模一样的"。
- 对象有大量字段,挨个 set 既啰嗦又容易漏。
- 对象需要保留历史快照(撤销/重做),每次操作前先克隆一份。
原型模式说:不要 new,从一个"原型"对象克隆出新副本。这样既复用了已有对象的全部初始化成本,又能给每个副本独立的状态。
最朴素版:实现 Cloneable
// Java 内置的 Cloneable 接口 + Object.clone()
public class GameNpc implements Cloneable {
String name;
int hp;
int attack;
List<Item> inventory;
@Override
public GameNpc clone() {
try {
return (GameNpc) super.clone(); // 浅拷贝
} catch (CloneNotSupportedException e) {
throw new AssertionError(e);
}
}
}
GameNpc prototype = loadFromDB(); // 昂贵
GameNpc enemy1 = prototype.clone(); // 几乎免费
GameNpc enemy2 = prototype.clone();
enemy1.name = "elite goblin"; // 各自独立修改
但 super.clone() 是浅拷贝—— 它把字段的值复制过去,引用类型字段(List<Item>)的值是引用,新旧对象共享同一个列表。改 enemy1 的 inventory,enemy2 跟着变。这就是浅拷贝的陷阱。
深浅拷贝的边界
// 浅拷贝
GameNpc shallow = prototype.clone();
shallow.inventory.add(newItem); // prototype.inventory 也变了!
// 深拷贝:递归克隆所有引用字段
@Override
public GameNpc clone() {
GameNpc copy = (GameNpc) super.clone();
copy.inventory = new ArrayList<>();
for (Item item : this.inventory) {
copy.inventory.add(item.clone()); // 递归克隆每个 Item
}
return copy;
}
规则简单粗暴:需要独立修改的字段必须深拷贝,只读 / 不可变的字段可以浅拷贝共享。这是一个性能与正确性的权衡 —— 越深拷贝越安全,但越慢、越费内存。
不可变对象自动免疫
如果 Item 是不可变对象(所有字段 final,没有 setter),那共享同一个 Item 引用是安全的 —— 谁都改不了它。这就是为什么"不可变设计"在 Java / Scala / Rust 圈很受推崇:克隆从"递归深拷贝"退化成"共享引用",免费且正确。
通用深拷贝的几种实现
方式 1:逐字段手写
最精确,完全可控,但样板代码多,加字段容易漏。
方式 2:序列化反序列化
// 简单粗暴:写出来再读回来
public static <T extends Serializable> T deepCopy(T obj) {
try (ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bos)) {
oos.writeObject(obj);
try (ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray());
ObjectInputStream ois = new ObjectInputStream(bis)) {
return (T) ois.readObject();
}
} catch (Exception e) {
throw new RuntimeException(e);
}
}
// 或者用 Jackson / Gson
ObjectMapper mapper = new ObjectMapper();
String json = mapper.writeValueAsString(obj);
T copy = mapper.readValue(json, (Class<T>) obj.getClass());
优点:无需为每个类写 clone。缺点:
- 性能差(序列化 + 反序列化都是 O(对象大小) 的开销)。
- 对象图必须能被序列化(没有 transient 字段、没有不可序列化的成员)。
- 循环引用:Jackson 默认会无限递归,需要专门配置。
ObjectMapper用@JsonIdentityInfo可以处理。
方式 3:Apache Commons Lang / Spring BeanUtils
import org.apache.commons.lang3.SerializationUtils;
GameNpc copy = SerializationUtils.clone(prototype); // 内部走 Java 序列化
// Spring 的浅拷贝(只拷字段,不递归)
BeanUtils.copyProperties(prototype, target);
方式 4:第三方深拷贝库
// Kryo:高效二进制序列化,深拷贝也快
Kryo kryo = new Kryo();
GameNpc copy = kryo.copy(prototype); // 比 Java 序列化快 5-10 倍
实战推荐:对小对象、可控字段,手写;对大对象图、配置型对象,用 JSON 序列化往返;追求性能就上 Kryo 这种二进制方案。
原型注册表:按 key 找原型
GoF 经典原型模式加了一个"原型管理器":
class ShapeRegistry {
private Map<String, Shape> prototypes = new HashMap<>();
public void register(String key, Shape proto) {
prototypes.put(key, proto);
}
public Shape create(String key) {
Shape proto = prototypes.get(key);
if (proto == null) throw new IllegalArgumentException();
return proto.clone();
}
}
// 启动期一次性注册"标准款"
registry.register("red-circle", new Circle("red", 10));
registry.register("blue-square", new Square("blue", 20));
// 使用时只要 key,不需要知道具体类
Shape s1 = registry.create("red-circle"); // 拿到独立副本
Shape s2 = registry.create("red-circle"); // 另一个独立副本
这种结构在游戏(角色/怪物模板)、表单(字段模板)、画板(图形预设)里都很常见。本质上是"工厂 + 原型"组合—— 工厂用 key 决定造什么,造的方式是克隆。
JavaScript 的原型链:语言级别的原型模式
JS 是原生原型继承的语言。每个对象都有一个 [[Prototype]] 指针指向另一个对象,属性查找沿这条链上溯。这意味着 JS 不需要专门的"克隆模式" —— 创建对象时直接用现有对象做原型即可:
const animal = {
type: 'animal',
breathe() { console.log(`${this.name} is breathing`); }
};
// Object.create:以 animal 为原型创建新对象
const dog = Object.create(animal);
dog.name = 'Lucky';
dog.bark = function() { console.log('woof'); };
dog.breathe(); // Lucky is breathing —— 通过原型链找到 breathe
dog.bark(); // woof —— 自己的方法
// 浅克隆:Object.assign 或扩展运算符
const dog2 = Object.assign({}, dog);
const dog3 = { ...dog };
// 深克隆:JSON 法(简单但有限)
const deep = JSON.parse(JSON.stringify(obj)); // 丢失函数、undefined、Symbol、Date 变字符串
// 现代浏览器 + Node:structuredClone(全面深拷贝)
const real = structuredClone(obj); // 处理循环引用、Map、Set、Date、TypedArray
structuredClone 是 2022 年起浏览器和 Node 都标准支持的深拷贝 API,性能远比 JSON 法好,且支持几乎所有内置类型。日常 JS 深拷贝默认就该用它。
Spring 的 Prototype Scope
Spring 容器默认 bean 是单例(每次注入同一实例)。@Scope("prototype") 让容器每次注入新实例:
@Component
@Scope("prototype")
public class HttpRequest {
private final String requestId = UUID.randomUUID().toString();
public String getRequestId() { return requestId; }
}
@RestController
public class Controller {
@Autowired
private ObjectProvider<HttpRequest> reqProvider;
@GetMapping("/")
public String handle() {
HttpRequest req = reqProvider.getObject(); // 每次新建
return req.getRequestId();
}
}
这其实就是"原型模式"在 IoC 容器层面的实现 —— 你声明一次"原型 bean",容器负责"每次拿一份新的"。比手写工厂 + clone 更声明式。
实战 1:撤销 / 重做
编辑器的 undo / redo 经典实现:每次操作前先克隆一份当前状态压栈,撤销时弹栈复原。
class Editor {
private Document doc;
private Deque<Document> undoStack = new ArrayDeque<>();
private Deque<Document> redoStack = new ArrayDeque<>();
public void execute(Command cmd) {
undoStack.push(deepCopy(doc)); // 保存当前快照
redoStack.clear(); // 新操作清空 redo
cmd.apply(doc);
}
public void undo() {
if (undoStack.isEmpty()) return;
redoStack.push(deepCopy(doc));
doc = undoStack.pop();
}
public void redo() {
if (redoStack.isEmpty()) return;
undoStack.push(deepCopy(doc));
doc = redoStack.pop();
}
}
性能优化:大文档每步全量克隆昂贵。可以用不可变数据结构(Clojure 的 persistent data structure,Java 的 PCollections)或命令记录(只存 diff 而非全量) —— 后者就是备忘录模式的领地了。
实战 2:React 不可变更新
// React 状态更新必须返回新对象,本质就是原型模式的浅克隆
const [user, setUser] = useState({ name: 'mores', profile: { age: 30 } });
// 错误:直接改不会触发重渲(React 用 === 比较)
user.profile.age = 31;
setUser(user); // 引用没变,React 跳过更新
// 正确:克隆 + 修改
setUser({ ...user, profile: { ...user.profile, age: 31 } });
// 深结构改起来烦,用 Immer 解决
import produce from 'immer';
setUser(produce(user, (draft) => {
draft.profile.age = 31; // 看起来"修改",Immer 在背后克隆
}));
实战 3:测试数据工厂
// 测试里经常需要"用户对象,但密码字段不同"这种变体
const baseUser = {
id: 1, name: 'test', email: 't@x.com',
password: 'p1', role: 'user', active: true,
};
// 用展开做"原型 + 覆盖"
const admin = { ...baseUser, role: 'admin' };
const banned = { ...baseUser, active: false };
// 工厂函数封装,更复用
function makeUser(overrides = {}) {
return { ...baseUser, ...overrides };
}
makeUser({ role: 'admin' });
常见坑
坑 1:clone() 没考虑继承。 父类的 clone 只克隆自己定义的字段,子类新增的字段被遗漏。规范:每个有可变字段的类都重写 clone,逐字段处理。
坑 2:循环引用导致深拷贝爆栈。 A 引用 B,B 引用 A,递归克隆栈溢出。修复:用一个 IdentityHashMap 记录"已克隆对象 → 新对象的映射",遇到已克隆的直接返回新引用。structuredClone / Kryo 内部都做了这件事。
坑 3:克隆"绑定到外部资源"的对象。 文件句柄、数据库连接、网络 socket 这种带"操作系统句柄"的对象不能简单克隆 —— 克隆出来的两个对象会同时操作同一个句柄,造成混乱。这类对象通常应该禁止 clone(标 final 或抛异常)。
坑 4:JSON 法当成万能。 JSON.parse(JSON.stringify(...)) 在简单 plain object 上能用,但碰到 Date / Map / Set / undefined / 函数 / 循环引用全失效。能用就用 structuredClone。
原型模式 vs 工厂模式 vs Builder
- 工厂:不关心已有实例,凭参数从头创建。
- 建造者:逐步参数化一个复杂对象。
- 原型:从一个已有实例克隆,新对象继承原型的状态。
三者经常组合:工厂方法返回 Builder,Builder 内部有一个原型对象,build 时克隆原型再应用配置。Spring 的 BeanDefinition 体系就是这种思路。
写在最后
原型模式的本质是"用克隆代替构造"。在 Java / C# / Go 这种"new + 构造函数"主导的语言里,它表现为 clone 接口和深浅拷贝技巧;在 JS / Lua 里它内化为语言机制(原型链);在容器化 / 不可变数据生态里它被声明式地表达(prototype scope、structuredClone、immer)。无论形式如何,核心问题始终是:你需要的新对象,跟一个已有的对象高度相似。
给一个判断标准:当你写 new X(...) 时连续传一堆"从另一个 X 那里抄来的"参数 —— 那就是原型模式喊你的信号。停下来,用克隆替换构造,代码会立刻短一半,且未来加字段不需要每个 new 点同步改。
—— 别看了 · 2026