1、为什么前端要学设计模式?
在很多人的印象里,设计模式好像是强类型面向对象语言的专属。其实大错特错。前端从刀耕火种的jq时代发展到如今大规模单页面应用,代码复杂度早就不可同日而语。设计模式不只是八股文,而是前辈们总结出来的在特定场景下解决某类问题的通用方案。
学它的直接收益至少有三个:
- 代码复用,而不是到处复制黏贴:弹窗不需要每次都重新创新一遍,校验逻辑不用在一个300行的ifelse里面挣扎。
- 可读性飙升,团队协作不再猜谜:你说的"这里用策略模式",别人立刻懂你打算怎么拆分逻辑。
- 面试高频:尤其是像观察者、单例、发布-订阅,几乎是前端面试必考题目,而且往往结合框架原理一起问。
一句话概括:设计模式就是让你的代码从"能跑就行"变成"漂亮有好养"的通用招式
2、前端设计模式的分类
前端不和传统GoF分类一样,它更加侧重务实/"你要解决什么痛点"来分
| 痛点 | 适合模式 | 一句话说明 |
|---|---|---|
| 我真的不想写if-else | 策略模式 | 把每种情况封装成独立策略,按key调用 |
| 我要保证全局只有一个东西 | 单例模式 | 一个东西只有一个,并提供全局访问点 |
| 一个地方变化,好多地方要跟着动 | 观察者/发布订阅 | 对象间一对多依赖,变化是自动通知 |
| 我想动态给某个功能加点料,又不改动源码 | 装饰器模式 | 通过包装的方式动态加职责 |
| 根据不同类型造不同对象,不想到处new | 工厂模式 | 封装对象创建过程,对外隐藏具体类型 |
| 给对象加个"中间人",控制访问或做缓存 | 代理模式 | 用一个替身对象控制源对象访问 |
| 相统一不同接口,让他们协助 | 适配器模式 | 转换接口,让原本不兼容的接口能一起工作 |
| 复杂的状态流转写得一团乱 | 状态模式 | 将状态行为封装,状态变化时行为跟着变 |
3、逐个模式拆解
3.1 单例模式:全局只有一个IndexedDB实例
从业务场景聊
在进行chrome插件开发是数据经常存储在indexedDb中,然后在项目得各个需要得地方使用。比如:newtab和background.js中。因为background有自己的生命周期,和其他内容不一样。如果在需要使用的地方分别new indexedDb 那么就会报错。这是因为IndexedDB 规定:一个数据库同一时刻只能存在一个版本 。那么如何向在不同页面/js中 打开同一个数据库, 不同数据表(使用indexedDB的正确打开方式和多种使用场景)。
这里用到单例,其核心思想:利用闭包缓存唯一实例,没有就创建,有就直接返回。
js
export default class DatabaseManager {
private static instance: DatabaseManager;
private db: IDBDatabase | null = null;
private dbName: string;
private version: number;
private storeConfigs: StoreConfig[];
private constructor(
dbName: string,
version: number,
stores: StoreConfig[]
) {
this.dbName = dbName;
this.version = version;
this.storeConfigs = stores;
}
static getInstance(dbName: string, version: number, stores: StoreConfig[]): DatabaseManager {
if (!DatabaseManager.instance) { // 这里是一个单例模式
DatabaseManager.instance = new DatabaseManager(dbName, version, stores);
}
return DatabaseManager.instance;
}
}
// 在main.ts中使用
const dbManager = DatabaseManager.getInstance(dbConfig.dbName, dbConfig.version, dbConfig.storeConfigs);
dbManager.init().then(() => {
const app = createApp(App)
app.config.globalProperties.$bdManager = dbManager;
app.provide('dbManager', dbManager);
app.mount('#app');
}).catch((err) => {
console.error('IndexedDB 初始化失败:', err);
})
// 在background.js中使用
const dbManager = DatabaseManager.getInstance(dbConfig.dbName, dbConfig.version, dbConfig.storeConfigs);
dbManager.init().catch((err) => { console.error('DB 初始化失败', err) });
框架中的影子
- vuex/pinia:每个vue应用只有有一个store实例,通过 createStore 或 createPinia 并挂载一次,全局都可以通过useStore获取,是典型的单例思维。
- vue router: createRouter() 创建的router 实例,通常也是全局唯一。
总结:当需要全局唯一且共享状态的场景,比如模态框管理,全局缓存、配置中心时就可以使用
3.2 观察者/发布订阅:数据变了,视图就知道
从生活场景聊起
观察者:你是公众号的作者,亲自维护了一个粉丝列表,每次发文章你亲自群发给每个粉丝。
发布订阅:你是up主,视频上传到B站,B站负责通知所有订阅你的用户,你根本每个用户去通知
两者的微妙区别
- 观察者模式: subject 直接维护一个 observer 列表,状态变化时亲自去通知每个Observer,两者"互相认识"。
- 发布订阅模式:中间多了一个调度中心,publisher 把事件发到调度中心,subscriber 只对事件名感兴趣,双方完全解耦。
手写EventBus(发布订阅)
js
const createEventBus = () => {
const listeners = new Map(); // 事件名 -> 回调函数数组
return {
// 订阅事件
on(event, cb) {
if (!listeners.has(event)) {
listeners.set(event, []);
}
listeners.get(event).push(cb);
},
// 取消订阅
off(event, cb) {
if (!listeners.has(event)) return;
const cbs = listeners.get(event).filter(c => c !== cb);
if (cbs.length === 0) {
listeners.delete(event);
} else {
listeners.set(event, cbs);
}
},
// 发布事件
emit(event, ...args) {
if (!listeners.has(event)) return;
listeners.get(event).forEach(cb => {
cb(...args);
})
}
}
};
const bus = createEventBus();
const log1 = (msg) => console.log('订阅者1:', msg);
const log2 = (msg) => console.log('订阅者2:', msg);
bus.on('message', log1);
bus.on('message', log2);
bus.emit('message', 'Hello world');
bus.off('message', log1);
bus.emit('message', 'Only log2');
特点: Publisher 和 Subscriber 完全解耦,都只与事件中心交互。事件中心可以轻松扩展(如加中间件、异步调度),但调试时链条变长。
观察者模式
js
// 被观察者工厂
function createSubject() {
const observers = []; // 观察者列表(闭包私有)
return {
addObserver(observer) {
observers.push(observer);
},
removeObserver(observer) {
const idx = observers.indexOf(observer);
if (idx > -1) observers.splice(idx, 1);
},
notify(data) {
// 亲自调用每个观察者的 update
observers.forEach(obs => obs.update(data));
}
};
}
// 观察者工厂
function createObserver(name) {
return {
update(data) {
console.log(`${name} 收到通知:`, data);
}
};
}
// 使用
const subject = createSubject();
const obs1 = createObserver('观察者1');
const obs2 = createObserver('观察者2');
subject.addObserver(obs1);
subject.addObserver(obs2);
subject.notify('新数据来了');
// 观察者1 收到通知: 新数据来了
// 观察者2 收到通知: 新数据来了
特点: Subject 必须知道 Observer 的存在,并且需要 Observer 提供 update 方法。两者耦合较强,但结构简单直接。
3.3 策略模式:告别表单校验的 if 炼狱
场景与痛点
一个表单有多个字段:手机号、邮箱、必填...最常见的写法是:
js
function validate(type, value) {
if (type === 'phone') {
return /^1[3-9]\d{9}$/.test(value);
} else if (type === 'email') {
return /^\w+@\w+\.\w+$/.test(value);
} else if (type === 'required') {
return value.trim() !== '';
}
// 后面可能还要加 身份证、URL...
}
这个 if 山会随着规则增加无限膨胀,而且各种校验逻辑揉在一起,难度难改。
策略模式重构
把每种规则封装成独立的策略函数,放到一个对象里面,调用时只需 key 取出即可。
js
// 策略对象:每种规则都是一个函数
const strategies = {
phone: (val) => /^1[3-9]\d{9}$/.test(val),
email: (val) => /^\w+@\w+\.\w+$/.test(val),
required: (val) => val.trim() !== '',
minLength: (val, len) => val.length >= len,
};
// 通用校验函数
function validate(rule, value, ...args) {
return strategies[rule] && strategies[rule](value, ...args);
}
// 使用
validate('phone', '13800138000'); // true
validate('minLength', 'abc', 5); // false
再来一个模拟ant-desgin-vue 表单校验,其中规则校验也是使用策略模式
js
interface RuleItem {
required?: boolean;
type?: 'string' | 'number' | 'boolean' | 'array' | 'email' | 'url' | 'integer' | 'float';
min?: number;
max?: number;
len?: number;
pattern?: RegExp;
message?: string;
trigger?: string;
validator?: (rule: RuleItem, value: any, callback:(error?: Error) => void, data?: any) => void | Promise<void>
}
type StrategyKey = Extract<keyof RuleItem, 'type' | 'min' | 'max' |'len' | 'pattern'>;
type StrategyKeyArray = StrategyKey[];
type Strategies = Record<StrategyKey | 'required', (value: any, rule: RuleItem, file: string) => void>;
export type Rules = Record<string, RuleItem[]>;
export default class FormValidator<T extends Record<string, any>> {
rules: Rules;
strategies: Strategies;
constructor(rulesObj: Rules) {
this.rules = rulesObj;
this.strategies = {
required: this._requiredValidator.bind(this),
type: this._typeValidator.bind(this),
min: this._minValidator.bind(this),
max: this._maxValidator.bind(this),
len: this._lenValidator.bind(this),
pattern: this._patternValidator.bind(this)
};
}
/**
* 校验整个数据对象
* @param { T } data 表单数据
* @return { Promise<boolean> }
*/
async validate(data: T): Promise<boolean> {
const errors: Error[] = [];
const fields: Record<string, {message: string, field: string }[]> = {};
const promises = [];
for (const field of Object.keys(this.rules)) {
const fieldRules = this.rules[field];
const value = data[field];
for (const rule of fieldRules) {
promises.push(
this._validateRule(field, value, rule, data)
.catch(error => {
errors.push(error);
if (!fields[field]) fields[field] = [];
fields[field].push({ message: error.message, field });
})
);
}
}
return Promise.all(promises).then(() => {
if (errors.length > 0) {
return Promise.reject({ errors, fields });
}
return true;
});
}
_requiredValidator(value: any, rule: RuleItem, field: string) {
}
_checkType(value: any, type: RuleItem['type']) {
switch (type) {
case 'string': return typeof value === 'string';
case 'number': return typeof value === 'number' && !isNaN(value);
case 'boolean': return typeof value === 'boolean';
case 'array': return Array.isArray(value);
case 'email': return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
case 'url': return /^(https?:\/\/)?([\w-]+\.)+[\w-]+(\/[\w- ./?%&=]*)?$/.test(value);
case 'integer': return Number.isInteger(value);
case 'float': return typeof value === 'number' && !Number.isInteger(value);
default: return true;
}
}
_typeValidator(value: any, rule: RuleItem, field: string) {
if (!this._checkType(value, rule.type)) {
throw new Error(rule.message || `${field} 类型错误`);
}
}
_minValidator(value: any, rule: RuleItem, field: string) {
let valid = false;
if (rule.min !== null) return;
if (typeof value === 'string' || Array.isArray(value)) {
valid = value.length >= rule.min;
} else if (typeof value === 'number') {
valid = value >= rule.min;
}
if (!valid) {
throw new Error(rule.message || `${field} 不能小于 ${rule.min}`);
}
}
_maxValidator(value: any, rule: RuleItem, field: string) {
let valid = false;
if (rule.max !== null) return;
if (typeof value === 'string' || Array.isArray(value)) {
valid = value.length <= rule.max;
} else if (typeof value === 'number') {
valid = value <= rule.max;
}
if (!valid) {
throw new Error(rule.message || `${field} 不能大于 ${rule.max}`);
}
}
_lenValidator(value: any, rule: RuleItem, field: string) {
let valid = false;
if (typeof value === 'string' || Array.isArray(value)) {
valid = value.length === rule.len;
} else if (typeof value === 'number') {
valid = value === rule.len;
}
if (!valid) {
throw new Error(rule.message || `${field} 长度必须为 ${rule.len}`);
}
}
_patternValidator(value: any, rule: RuleItem, field: string) {
if (!rule.pattern) return;
if (!rule.pattern.test(value)) {
throw new Error(rule.message || `${field} 格式不正确`);
}
}
_isEmpty(value: any) {
return value === undefined || value === null || value === '';
}
// 自定义校验器
_runCustomValidator(rule: RuleItem, value: any, data: T) {
return new Promise((resolve, reject) => {
const callback = (error: any) => {
if (error) {
reject(error instanceof Error ? error : new Error(error));
} else {
resolve('');
}
};
try {
if (!rule.validator) {
resolve('');
return;
}
const result = rule.validator(rule, value, callback, data);
if (result && typeof result.then === 'function') {
result.then(resolve).catch(reject);
}
} catch (error) {
reject(error);
}
});
}
/**
* 校验单条规则
*/
async _validateRule(field: string, value: any, rule: RuleItem, data: T) {
// 1. 必填
if (rule.required && this._isEmpty(value)) {
throw new Error(rule.message || `${field} 是必填项`);
}
// if (this._isEmpty(value) && !rule.required) return;
// 2.依次执行已配置得内置策略
const strategyKeys = ([ 'type', 'min', 'max', 'len', 'pattern'] as StrategyKeyArray).filter(key => rule[key] !== undefined);
for (const key of strategyKeys) {
this.strategies[key](value, rule, field);
}
if (rule.validator) {
await this._runCustomValidator(rule, value, data);
}
}
}
框架中的影子
-
权限控制:不同角色(admin、editor、viewer)看到的按钮不同,可以用策略对象角色到组件或操作函数
jsconst permissionMap = { admin: () => <AdminPanel />, editor: () => <EditorPanel />, viewer: () => <ReadOnlyPanel />, };``` -
表单校验库
3.4 工厂模式:根据类型造对象,不用到处new
场景
从后端拉回用户列表,每个用户有type:'admin' | 'editor' | 'viewer', 你需要根据不同角色创建具备不同权限的对象。如果业务代码里面到处都是 if (type === 'admin') { ... } 会很难维护。
工厂实现
js
function createUser(type, name) {
const permissions = {
admin: ['write', 'read', 'delete'],
editor: ['write', 'read'],
viewer: ['read'],
}[type];
if (!permissions) {
throw new Error('未知用户类型');
}
return { name, type, permissions };
}
// 使用
const user = createUser('admin', '小王');
console.log(user.permissions); // ['write', 'read', 'delete']
如果不同角色还需要不同的方法,也可以根据类型挂载对应函数:
js
function createUser(type, name) {
const base = { name, type };
const factories = {
admin: () => ({
...base,
permissions: ['write', 'read', 'delete'],
deleteUser() { /* ... */ },
}),
editor: () => ({
...base,
permissions: ['write', 'read'],
editArticle() { /* ... */ },
}),
viewer: () => ({
...base,
permissions: ['read'],
}),
};
const factory = factories[type];
if (!factory) throw new Error('未知用户类型');
return factory();
}
工厂函数对外隐藏了具体实现,只要传入type,就能得到符合预期的对象。
框架中的影子
- Vue的 h 函数是工厂函数,你不需要知道div 虚拟dom的具体构造细节
- 动态组件<component :is="compName">:会根据 compName 决定渲染那个组件,也是一种工厂思想
总结: 有多个类似但细节不同的对象需要创建时,可以使用
3.5 代理模式:图片懒加载背后的功臣
虚拟代理------图片懒加载
不希望页面一上来就加载十几张未进入视口的大图片,可以先用占位图,等图片滚动到屏幕内再替换src。
js
const lazyLoadImage = (() => {
const imgList = document.querySelectorAll('img[data-src]');
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
observer.unobserve(img);
}
});
})
imgList.forEach(img => observer.observe(img));
})()
这里图片元素的 dataset.src 其实就相当于一个"虚拟代理",真实的图片资源直到进入视口才被请求。
缓存代理------计算结果缓存
一个计算量很大的函数(比如递归求斐波那契数列),我们可以包装一层缓存代理提升性能
js
function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
// 缓存代理工厂(高阶函数)
function createCachedProxy(fn) {
const cache = {};
return function(arg) {
if (cache[arg] !== undefined) {
return cache[arg];
}
cache[arg] = fn(arg);
return cache[arg];
};
}
const cachedFib = createCachedProxy(fibonacci);
cachedFib(40); // 计算并缓存
cachedFib(40); // 直接取缓存,极快
ES6 Proxy 与 vue3 响应式
vue3 的响应式系统抛弃了 Object.defineProperty,改用 Proxy实现真正的对象代理
js
const createProtectedProxy = (obj, allowedKeys) => {
return new Proxy(obj, {
get(target, prop){
if (allowedKeys.includes(prop)) {
return target[prop];
} else {
throw new Error(`无权访问属性:${prop}`);
}
},
set(target, prop, value) {
throw new Error('对象是只读');
}
})
};
const target = {
name: 'Alice',
age: 25
};
const protectedObj = createProtectedProxy(target, ['name']);
console.log(protectedObj.name);
console.log(protectedObj.age);
Proxy 可以拦截对象几乎所有操作,完美契合代理模式的"控制访问"意图。
总结:
- 何时用:需要控制对象访问(懒加载、权限验证)、增加额外功能(缓存、日志)而不想侵入原对象时。
- 区分:代理常和装饰器混淆,代理更关注 "控制访问",装饰器更关注 "增强功能"。不过前端某些实现(如 HOC)可能同时扮演两者角色。
3.6 装饰器模式:我的组件也能"穿衣服"
从场景聊起
有一个基础按钮组件,根据不同页面需要动态增加:权限校验,点击埋点上报,loading效果...你不想在每个页面里面写一堆重复的包裹代码,也不想去改原本的按钮组件源码。
React 高阶组件(HOC)------典型的函数式装饰器
js
// 一个普通按钮(函数组件)
function Button(props) {
return <button onClick={props.onClick}>{props.children}</button>;
}
// 装饰器:给按钮加上权限校验(高阶组件)
function withAuth(WrappedComponent) {
return function(props) {
if (!props.hasPermission) {
return <div>无权限</div>;
}
return <WrappedComponent {...props} />;
};
}
// 使用
const AuthButton = withAuth(Button);
<AuthButton hasPermission={false} onClick={...}>删除</AuthButton>
withAuth 没有修改 Button 内部逻辑,只是在外层包了一层,动态赋予了权限功能。这就是装饰器模式的核心:动态地将责任附加到对象上。React hook 出现后,许多场景可用自定义 Hook 代替 HOC,但装饰器思想依然存在。
给方法加日志
不用类和装饰器语法,也能实现装饰模式。下面是一个函数式装饰器,用来给对象的方法增加日志。
js
function withLogging(obj, methodName) {
const original = obj[methodName];
obj[methodName] = function(...args) {
console.log(`调用方法 ${methodName},参数:`, args);
const result = original.apply(this, args);
console.log(`方法 ${methodName} 返回:`, result);
return result;
};
}
// 使用
const userService = {
getUser(id) {
return { id, name: 'test' };
}
};
withLogging(userService, 'getUser');
userService.getUser(42);
// 控制台打印调用信息和返回值
这样给方法增加了日志功能,而原始对象除了被包装的方法外完全不变。
总结:需要在不影响原有代码的情况下,给组件、函数、对象动态添加功能(日志、权限、数据预处理)才可以考虑使用。
4、模式对比与组合
观察者 vs. 发布-订阅
- 观察者:Subject 直接维护 Observer 列表并亲自通知,耦合紧但调度直接。vue2 的Dep/Watcher 属于此类
- 发布/订阅:中间存在一个事件通道,发布者和订阅则互补感知。vue的emit/on属于此类
总结: 直接通知的是观察者,中间夹了一层的是发布-订阅。
代理 vs. 装饰器
两者在代码结构上经常很相似,但意图决定模式
- 代理模式:主要意图是控制访问------懒加载控制访问时机,缓存代理控制访问结果,权限代理控制访问资格。
- 装饰器模式:主要意图是动态增强功能------增加日志、权限、埋点等,通常不会阻止原对象的正常使用。
总结:实际中一个高阶组件既可以作为权限控制的代理,也可以作为数据注入的装饰器,所以不必死扣定义,意图清晰就好。
策略 + 工厂组合
工厂负责创建不同的策略对象,策略负责具体的算法执行: