从使用场景切入前端常见设计模式

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)看到的按钮不同,可以用策略对象角色到组件或操作函数

    js 复制代码
       const 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. 装饰器

两者在代码结构上经常很相似,但意图决定模式

  • 代理模式:主要意图是控制访问------懒加载控制访问时机,缓存代理控制访问结果,权限代理控制访问资格。
  • 装饰器模式:主要意图是动态增强功能------增加日志、权限、埋点等,通常不会阻止原对象的正常使用。

总结:实际中一个高阶组件既可以作为权限控制的代理,也可以作为数据注入的装饰器,所以不必死扣定义,意图清晰就好。

策略 + 工厂组合

工厂负责创建不同的策略对象,策略负责具体的算法执行:

相关推荐
郭老二11 小时前
【前端】vue3:编译步骤
前端
不在逃避q12 小时前
Trae/Vs Code/Cursor命令行无法跑npm命令
前端·arcgis·npm
夏殇之殁12 小时前
包中创建自定义列表项。 . 使用自定义列表项进行数据绑定 . 将天气预报数据保存到本地内存表,通过LiveBindings进行显示。 ...
服务器·前端·javascript
The Chosen One98512 小时前
高进度算法模板速记(待完善)
java·前端·算法
陈随易12 小时前
MoonBit抓包模块pcap,查看电脑的每一次联网通信
前端·后端·程序员
新中地GIS开发老师13 小时前
WebGIS开发学生作品|低空航天管理与航线规划系统
前端·javascript·webgis·三维gis开发
用户0595401744614 小时前
大模型对话记忆持久化踩坑实录:用 Playwright 自动化测了 300 次,终于揪出会话丢失的真凶
前端·css
丙氨酸長鏈14 小时前
Web前端入门第 问:JavaScript 一个简单的 IndexedDB 数据库入门示例
前端·javascript·数据库
kyriewen15 小时前
面试官让我手写虚拟列表——AI生成的版本,快速滚动几下就白屏了
前端·javascript·面试