设计模式在 JS 中的应用:用 TS 重写经典模式,结合 React / Vue 真实业务场景
很多人学设计模式时都有一种感觉: "代码是看懂了,但不知道往哪用。"
问题不在模式本身,而在教材还在用 Java 写鸭子叫、披萨店。本文用 TypeScript 5.x 重写 6 个最高频的经典模式,每一个都绑定 React / Vue 的真实业务场景——看完你能直接抄进项目里。
一、为什么前端需要设计模式(而且要用 TS)
前端项目的复杂度早就不是"页面 + 接口"了。状态管理、缓存策略、组件通信、权限控制、埋点监控——这些问题的结构和后端一模一样,只是场景不同。
TypeScript 的价值在于:模式依赖抽象,抽象依赖类型系统。没有类型约束的"模式"只是约定,有了 TS,编译器帮你兜底。
下面进入正题。
二、策略模式(Strategy):表单校验的终极写法
经典意图
定义一系列算法,让它们可以互相替换,客户端不感知具体实现。
场景:一个支持多种校验规则的表单
// 策略接口
interface ValidationStrategy<T = unknown> {
validate: (value: T) => string | null; // 返回错误信息,null 表示通过
}
// 具体策略
class RequiredStrategy implements ValidationStrategy<string> {
validate(value: string) {
return value.trim() ? null : "不能为空";
}
}
class MinLengthStrategy implements ValidationStrategy<string> {
constructor(private min: number) {}
validate(value: string) {
return value.length >= this.min ? null : `至少 ${this.min} 个字符`;
}
}
class EmailStrategy implements ValidationStrategy<string> {
private regex = /^[^\s@]+@[^\s@]+.[^\s@]+$/;
validate(value: string) {
return this.regex.test(value) ? null : "邮箱格式不正确";
}
}
// 上下文:组合使用
class Validator<T> {
private strategies: ValidationStrategy<T>[] = [];
add(s: ValidationStrategy<T>) { this.strategies.push(s); return this; }
validate(value: T): string | null {
for (const s of this.strategies) {
const err = s.validate(value);
if (err) return err;
}
return null;
}
}
React 中的用法
function LoginForm() {
const [email, setEmail] = useState("");
const [error, setError] = useState<string | null>(null);
const validator = useMemo(() =>
new Validator<string>()
.add(new RequiredStrategy())
.add(new EmailStrategy()),
[]);
const onSubmit = () => {
const err = validator.validate(email);
setError(err);
if (!err) submit(email);
};
return (
<>
<input value={email} onChange={e => setEmail(e.target.value)} />
{error && <span className="error">{error}</span>}
<button onClick={onSubmit}>提交</button>
</>
);
}
Vue 中的用法
// composables/useValidator.ts
export function useValidator<T>(strategies: ValidationStrategy<T>[]) {
const validator = new Validator<T>();
strategies.forEach(s => validator.add(s));
return { validate: validator.validate.bind(validator) };
}
// 组件
const { validate } = useValidator([
new RequiredStrategy(),
new EmailStrategy(),
]);
const error = ref<string | null>(null);
const onSubmit = () => { error.value = validate(email.value); };
为什么不用 if-else? 校验规则会不断增长。策略模式让"加规则"变成"加一个类",不改原有代码——符合开闭原则。
三、观察者模式(Observer):跨组件事件总线
经典意图
一对多依赖,一个对象状态变化,所有依赖者自动收到通知。
场景:微前端 / 跨路由 tab 的全局消息通知
// 泛型事件总线
type Handler<T = void> = (payload: T) => void;
class EventBus {
private handlers = new Map<string, Set<Handler<any>>>();
on<K extends string, P = unknown>(event: K, handler: Handler<P>): () => void {
if (!this.handlers.has(event)) this.handlers.set(event, new Set());
this.handlers.get(event)!.add(handler);
// 返回取消订阅函数——防止内存泄漏
return () => this.off(event, handler);
}
off<K extends string>(event: K, handler: Handler) {
this.handlers.get(event)?.delete(handler);
}
emit<K extends string, P = unknown>(event: K, payload: P) {
this.handlers.get(event)?.forEach(h => h(payload));
}
}
export const bus = new EventBus();
React Hook 封装
function useEventBus<K extends string, P>(event: K, handler: Handler<P>) {
useEffect(() => bus.on(event, handler), [event, handler]);
}
Vue Composable 封装
export function useEventListener<K extends string, P>(
event: K, handler: Handler<P>
) {
onMounted(() => bus.on(event, handler));
onUnmounted(() => bus.off(event, handler));
}
对比 Pinia / Redux:事件总线适合"通知型"通信(如"用户登录了"、"主题切换了"),不适合共享状态本身。选对工具。
四、单例模式(Singleton):全局配置 & 服务实例
经典意图
保证一个类只有一个实例,提供全局访问点。
场景:API 客户端、WebSocket 连接、日志服务
class ApiClient {
private static instance: ApiClient;
private token = "";
private constructor() {} // 私有构造,禁止 new
static getInstance(): ApiClient {
if (!ApiClient.instance) {
ApiClient.instance = new ApiClient();
}
return ApiClient.instance;
}
setToken(t: string) { this.token = t; }
async request<T>(url: string): Promise<T> {
return fetch(url, { headers: { Authorization: `Bearer ${this.token}` } })
.then(r => r.json());
}
}
// 使用
const api = ApiClient.getInstance();
TS 5.x 更优雅的写法:satisfies + 对象字面量
// 很多"单例"其实不需要 class,一个模块作用域对象就够了
export const apiClient = {
token: "",
setToken(t: string) { this.token = t; },
async request<T>(url: string): Promise<T> { /* ... */ },
} satisfies Record<string, unknown>;
// 配合 import type 做 tree-shake 友好导出
注意:单例的代价是全局状态。测试时难 mock,SSR 时注意实例生命周期。
五、工厂模式(Factory):组件动态渲染
经典意图
把对象的创建逻辑封装起来,调用方不关心具体类型。
场景:后台管理系统的表单生成器 / 低代码编辑器
// 产品接口
interface FormField {
render(): VNode; // Vue 用 VNode,React 用 ReactNode
getValue(): unknown;
}
// 具体产品
class InputField implements FormField {
constructor(private props: { label: string; placeholder?: string }) {}
render() { /* 返回输入框 */ }
getValue() { return ""; }
}
class SelectField implements FormField {
constructor(private props: { label: string; options: string[] }) {}
render() { /* 返回下拉框 */ }
getValue() { return ""; }
}
// 工厂
class FieldFactory {
private constructors = new Map<string, new (...a: any[]) => FormField>();
register(type: string, ctor: new (...a: any[]) => FormField) {
this.constructors.set(type, ctor);
}
create(config: { type: string; [k: string]: any }): FormField {
const Ctor = this.constructors.get(config.type);
if (!Ctor) throw new Error(`Unknown field type: ${config.type}`);
return new Ctor(config);
}
}
// 注册
const factory = new FieldFactory();
factory.register("input", InputField);
factory.register("select", SelectField);
// 使用:配置驱动
const schema = [
{ type: "input", label: "姓名", placeholder: "请输入" },
{ type: "select", label: "城市", options: ["北京", "上海"] },
];
const fields = schema.map(s => factory.create(s));
React 中的等价思路:用 componentMap 对象替代 class 工厂
const componentMap = {
input: InputComponent,
select: SelectComponent,
} as const;
type FieldType = keyof typeof componentMap;
function renderField(type: FieldType, props: any) {
const Comp = componentMap[type];
return <Comp {...props} />;
}
六、装饰器模式(Decorator):权限、日志、缓存
经典意图
动态地给对象添加职责,不改变原有结构。
TS 5.x 场景:方法装饰器做权限校验
// 权限装饰器
function RequirePermission(permission: string) {
return function (
target: any,
context: ClassMethodDecoratorContext
) {
return function (this: any, ...args: any[]) {
if (!this.currentUser?.permissions?.includes(permission)) {
throw new Error(`缺少权限: ${permission}`);
}
return target.apply(this, args);
};
};
}
class UserService {
currentUser: { permissions: string[] } | null = null;
@RequirePermission("user:delete")
deleteUser(id: string) {
console.log("删除用户", id);
}
}
React 中的高阶组件(HOC)——装饰器的组件版
// 日志装饰
function withLogger<P extends object>(
Wrapped: ComponentType<P>
) {
return function WithLogger(props: P) {
useEffect(() => {
console.log("mounted", Wrapped.name);
return () => console.log("unmounted", Wrapped.name);
}, []);
return <Wrapped {...props} />;
};
}
// 使用
const DashboardWithLog = withLogger(Dashboard);
Vue 中的等价写法:自定义指令
// 权限指令 = 装饰器思路
app.directive("permission", {
mounted(el, binding) {
const { value } = binding;
const hasPerm = checkPermission(value);
if (!hasPerm) el.parentNode?.removeChild(el);
},
});
// 使用
// <button v-permission="'user:delete'">删除</button>
七、发布-订阅 + 命令模式组合:撤销/重做
场景:富文本编辑器、画布工具、表单撤销
// 命令接口
interface Command {
execute(): void;
undo(): void;
}
// 具体命令
class AddTextCommand implements Command {
constructor(
private editor: TextEditor,
private text: string,
private pos: number
) {}
execute() { this.editor.insert(this.text, this.pos); }
undo() { this.editor.delete(this.pos, this.text.length); }
}
// 调用者 + 历史栈
class CommandManager {
private history: Command[] = [];
private index = -1;
execute(cmd: Command) {
cmd.execute();
this.history = this.history.slice(0, this.index + 1); // 截断重做栈
this.history.push(cmd);
this.index++;
}
undo() {
if (this.index < 0) return;
this.history[this.index].undo();
this.index--;
}
redo() {
if (this.index >= this.history.length - 1) return;
this.index++;
this.history[this.index].execute();
}
}
React 中接 useUndo hook,Vue 中接 useRef + reactive——模式是框架无关的,只是胶水不同。
八、怎么判断"该用模式了"
| 信号 | 该考虑的模式 |
|---|---|
if (type === 'A') ... else if (type === 'B') ... 超过 3 个分支 | 策略 / 工厂 |
| 一个状态变化要通知多个不相关的模块 | 观察者 / 发布订阅 |
全局只需要一个实例,但到处都在 new | 单例 |
| 组件/函数需要动态加功能(日志、权限、缓存) | 装饰器 |
| 用户操作需要撤销 | 命令 |
| 对象创建很复杂,调用方不需要知道细节 | 工厂 / 建造者 |
九、一句话总结
设计模式不是"炫技",是"把变化封装起来"。 用 TS 写模式,类型系统帮你守住边界;绑到 React / Vue 场景里,模式才真正活起来。别背 23 个模式的名字,记住上面这 6 个,遇到对应信号直接套——这就是老手和新手的分水岭。