设计模式在前端项目中的应用

来源 | https://juejin.cn/post/6956825832073461768
前端的设计模式是什么
前端的设计模式的基本准则
单一职责原则:每个类只需要负责自己的那部分,类的复杂度降低。 开闭原则:一个实体,如类、模块和函数应该对扩展开放,对修改关闭,让程序更稳定更灵活。 里式替换原则:所有引用基类的地方必须能透明地使用其子类的对象,也就是说子类对象可以替换其父类对象,而程序执行效果不变。便于构建扩展性更好的系统。 依赖倒置原则:上层模块不应该依赖底层模块,它们都应该依赖于抽象;抽象不应该依赖于细节,细节应该依赖于抽象。这可以让项目拥有变化的能力。 接口隔离原则:多个特定的客户端接口要好于一个通用性的总接口,系统有更高的灵活性。 迪米特原则(最少知识原则):一个类对于其他类知道的越少越好,也就是说一个对象应当对其他对象有尽可能少的了解。
设计模式的种类
1、 创建型模式
包括:单例模式,工厂方法模式,抽象工厂模式,建造者模式,原型模式。
2、结构型模式
包括:适配器模式,桥接模式,组合模式,装饰器模式,外观模式,享元模式,代理模式,过滤器模式。
3、行为型模式
包括:命令模式,解释器模式,迭代器模式,中介者模式,备忘录模式,观察者模式,状态模式,策略模式,模板方法模式,访问者模式,责任链模式。
前端常用的计模式应用实例
1、单例模式
class MessageBox {show() {console.log("show");}hide() {}static getInstance() {if (!MessageBox.instance) {MessageBox.instance = new MessageBox();}return MessageBox.instance;}}let box3 = MessageBox.getInstance();let box4 = MessageBox.getInstance();console.log(box3 === box4); // true
上面这种是比较常见的单例模式实现,但是这种方式存在一些弊端。因为它需要让调用方了解到通过Message.getInstance来获取单例。
又或者假设需求变更,可以通过存在二次弹窗,则需要改动不少地方,因为MessageBox除了实现常规的弹窗逻辑之外,还需要负责维护单例的逻辑。
因此,可以将初始化单例的逻辑单独维护,实现一个通用的、返回某个类对应单例的方法。
function getSingleton(ClassName) {let instance;return () => {if (!instance) {instance = new ClassName();}return instance;};}const createMessageBox = getSingleton(MessageBox);let box5 = createMessageBox();let box6 = createMessageBox();console.log(box5 === box6);
这样,通过createMessageBox返回的始终是同一个实例。如果在某些场景下需要生成另外的实例,则可以重新生成一个createMessageBox方法,或者直接调用new MessageBox(),这样就对之前的逻辑不会有任何影响。
2、工厂模式
工厂模式提供了一种创建对象的方法,对使用方隐藏了对象的具体实现细节,并使用一个公共的接口来创建对象。
前端本地存储目前最常见的方案就是使用localStorage,为了避免在业务代码中各种getItem和setItem,我们可以做一下最简单的封装。
let themeModel = {name: "local_theme",get() {let val = localStorage.getItem(this.name);return val && jsON.parse(val);},set(val) {localStorage.setItem(this.name, jsON.stringify(val));},remove() {localStorage.removeItem(this.name);},};themeModel.get();themeModel.set({ darkMode: true });
这样,通过themeModel暴露的get、set接口,我们无需再维护local_theme。但上面的封装也存在一些可见的问题,如果需要新增多个 name,那么上面的模板代码需要重新写多遍吗?为了解决这个问题,我们可以创建Model对象的逻辑进行封装。
const storageMap = new Map()function createStorageModel(key, storage = localStorage) {// 相同key返回单例if (storageMap.has(key)) {return storageMap.get(key);}const model = {key,set(val) {storage.setItem(this.key, JSON.stringify(val););},get() {let val = storage.getItem(this.key);return val && JSON.parse(val);},remove() {storage.removeItem(this.key);},};storageMap.set(key, model);return model;}const themeModel = createStorageModel('local_theme', localStorage)const utmSourceModel = createStorageModel('utm_source', sessionStorage)
这样,我们就可以通过createStorageModel这个公共的接口来创建各种不同本地存储的对象,而无需关注创建对象的具体细节。
3、策略模式
策略模式,可以针对不同的状态,给出不同的算法或者结果。将层级相同的逻辑封装成可以组合和替换的策略方法,减少if...else代码,方便扩展后续功能。
表单校验是我们最常见的场景了,我们一般都会想到用if...else来判断。
function onFormSubmit(params) {if (!params.name) {return showError("请填写昵称");}if (params.name.length > 6) {return showError("昵称最多6位字符");}if (!/^1\d{10}$/.test(params.phone))return showError("请填写正确的手机号");}// ...sendSubmit(params)}
将所有字段的校验规则都堆叠在一起,代码量大,排查问题也是一个大麻烦。在遇见错误时,直接通过 return 跳过了后面的判断;如果我们希望直接展示每个字段的错误呢,那么改动的工作量又不少。
不过,在antd、ELementUI等框架盛行的年代,我们已经不再需要写这些复杂的表单校验,但是对于他们的实现原理,我们可以简单模拟一下。
// 定义一个校验的类,主要暴露了构造参数和validate两个接口class Schema {constructor(descriptor) {this.descriptor = descriptor; // 传入定义的校验规则}// 拆分出一些更通用的规则,比如required(必填)、len(长度)、min/max(最值)等,可以尽可能地复用handleRule(val, rule) {const { key, params, message } = rule;let ruleMap = {required() {return !val;},max() {return val > params;},validator() {return params(val);},};let handler = ruleMap[key];if (handler && handler()) {throw message;}}validate(data) {return new Promise((resolve, reject) => {let keys = Object.keys(data);let errors = [];for (let key of keys) {const ruleList = this.descriptor[key];if (!Array.isArray(ruleList) || !ruleList.length) continue;const val = data[key];for (let rule of ruleList) {try {this.handleRule(val, rule);} catch (e) {errors.push(e.toString());}}}if (errors.length) {reject(errors);} else {resolve();}});}}// 声明每个字段的校验逻辑const descriptor = {nickname: [{ key: "required", message: "请填写昵称" },{ key: "max", params: 6, message: "昵称最多6位字符" },],phone: [{ key: "required", message: "请填写电话号码" },{key: "validator",params(val) {return !/^1\d{10}$/.test(val);},message: "请填写正确的电话号码",},],};// 开始对数据进行校验const validator = new Schema(descriptor);const params = { nickname: "", phone: "123000" };validator.validate(params).then(() => {console.log("success");}).catch((e) => {console.log(e);});
Schema主要暴露了构造参数和validate两个接口,是一个通用的工具类,而params是表单提交的数据源,因此主要的校验逻辑实际上是在descriptor中声明的。将常见的校验规则都放在ruleMap中,比之前各种不可复用的if..else判断更容易维护和迭代。
4、状态模式
状态模式允许一个对象在其内部状态改变的时候改变它的行为。状态模式的思路是:首先创建一个状态对象保存状态变量,然后封装好每种动作对应的状态,然后状态对象返回一个接口对象,它可以对内部的状态修改或者调用。
常见的使用场景,比如滚动加载,包含了初始化加载、加载成功、加载失败、滚动加载等状态,任意时间它只会处于一种状态。
// 定义一个状态机class rollingLoad {constructor() {this._currentState = 'init'this.states = {init: { failed: 'error' },init: { complete: 'normal' },normal: { rolling: 'loading' },loading: { complete: 'normal' },loading: { failed: 'error' },}this.actions = {init() {console.log('初始化加载,大loading')},normal() {console.log('加载成功,正常展示')},error() {console.log('加载失败')},loading() {console.log('滚动加载')}// .....}}change(state) {// 更改当前状态let to = this.states[this._currentState][state]if(to){this._currentState = tothis.go()return true}return false}go() {this.actions[this._currentState]()return this}}// 状态更改的操作const rollingLoad = new rollingLoad()rollingLoad.go()rollingLoad.change('complete')rollingLoad.change('loading')
这样,我们就可以通过状态变更,运行相应的函数,且状态之间存在联系。那么,看起来是不是和策略模式很像呢?其实不然,策略类的各个属性之间是平等平行的,它们之间没有任何联系。而状态机中的各个状态之间存在相互切换,且是被规定好了的。
5、发布-订阅模式
发布—订阅模式又叫观察者模式,它定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都将得到通知。
发布订阅模式大概是前端同学最熟悉的设计模式之一了,常见的事件监听addEventListener,各种属性方法onload、onchange,vue响应式数据,组件通信redux、eventBus等。
常见的获取登录信息,假设我们开发一个商城网站,网站里有 header 头部、nav 导航、消息列表、购物车等模块。
这几个模块的渲染有一个共同的前提条件,就是必须先用 ajax 异步请求获取用户的登录信息。
比如用户的名字和头像要显示在 header 模块里,而这两个字段都来自用户登录后返回的信息。异步的问题通常也可以用回调函数来解决:
login.succ(function(data){header.setAvatar( data.avatar); // 设置 header 模块的头像nav.setAvatar( data.avatar ); // 设置导航模块的头像message.refresh(); // 刷新消息列表cart.refresh(); // 刷新购物车列表});
我们还必须了解 header 模块里设置头像的方法叫setAvatar、购物车模块里刷新的方法叫refresh,这种强耦合性会使程序变得不易拓展。
那么回头看看我们的发布—订阅模式,这种模式下,对用户信息感兴趣的业务模块可以自行订阅登录成功的消息事件。
当登录成功时,登录模块只需要发布登录成功的消息,而业务方接受到消息之后,就会开始进行各自的业务处理,登录模块并不关心业务方究竟要做什么。
// 发布登录成功的消息$.ajax( 'http://xxx.com?login', function(data){ // 登录成功login.trigger( 'loginSucc', data); // 发布登录成功的消息});// 各模块监听登录成功的消息var header = (function(){ // header 模块login.listen( 'loginSucc', function(data){header.setAvatar( data.avatar );});return {setAvatar: function( data ){console.log( '设置 header 模块的头像' );}}})();var nav = (function(){ // nav 模块login.listen( 'loginSucc', function( data ){nav.setAvatar( data.avatar );});return {setAvatar: function( avatar ){console.log( '设置 nav 模块的头像' );}}})();
发布—订阅模式可以广泛应用于异步编程中,这是一种替代传递回调函数的方案。比如,我们可以订阅ajax请求的error、succ等事件。
或者如果想在动画的每一帧完成之后做一些事情,那我们可以订阅一个事件,然后在动画的每一帧完成之后发布这个事件。
在异步编程中使用发布—订阅模式,我们就无需过多关注对象在异步运行期间的内部状态,而只需要订阅感兴趣的事件发生点。
6、迭代器模式
迭代器模式是指提供一种方法顺序访问一个聚合对象中的各个元素,而又不需要暴露该对象的内部表示。
迭代器模式可以把迭代的过程从业务逻辑中分离出来,在使用迭代器模式之后,即使不关心对象的内部构造,也可以按顺序访问其中的每个元素。
JS 也内置了多种遍历数组的方法如forEach、reduce等。对于数组的循环大家都轻车熟路了,在实际开发中,也可以通过循环来优化代码。
一个常见的开发场景是:通过 ua 判断当前页面的运行平台,方便执行不同的业务逻辑,最基本的写法当然是if...else。
const PAGE_TYPE = {app: "app", // appwx: "wx", // 微信tiktok: "tiktok", // 抖音bili: "bili", // B站kwai: "kwai", // 快手};function getPageType() {const ua = navigator.userAgent;let pageType;// 移动端、桌面端微信浏览器if (/xxx_app/i.test(ua)) {pageType = app;} else if (/MicroMessenger/i.test(ua)) {pageType = wx;} else if (/aweme/i.test(ua)) {pageType = tiktok;} else if (/BiliApp/i.test(ua)) {pageType = bili;} else if (/Kwai/i.test(ua)) {pageType = kwai;} else {// ...}return pageType;}
参考策略模式的思路,我们可以减少分支判断的出现,将每个平台的判断拆分成单独的策略:
function isApp(ua) {return /xxx_app/i.test(ua);}function isWx(ua) {return /MicroMessenger/i.test(ua);}function isTiktok(ua) {return /aweme/i.test(ua);}function isBili(ua) {return /BiliApp/i.test(ua);}function isKwai(ua) {return /Kwai/i.test(ua);}let platformList = [{ name: "app", validator: isApp },{ name: "wx", validator: isWx },{ name: "tiktok", validator: isTiktok },{ name: "bili", validator: isBili },{ name: "kwai", validator: isKwai },];function getPageType() {// 每个平台的名称与检测方法const ua = navigator.userAgent;// 遍历for (let { name, validator } in platformList) {if (validator(ua)) {return name;}}}
这样,整个getPageType方法就变得非常简洁:按顺序遍历platformList,返回第一个匹配上的平台名称作为pageType。
这样即使后面需要增加或移除平台判断,需要修改的仅仅也只是platformList这个地方而已。
迭代器模式是一种相对简单的模式,简单到很多时候我们都不认为它是一种设计模式。目前的绝大部分语言都内置了迭代器。
总结
在将函数作为一等对象的语言中,有许多需要利用对象多态性的设计模式,这些模式的结构与传统面向对象语言的结构大相径庭,实际上已经融入到了语言之中,我们可能经常使用它们,只是不知道它们的名字而已。
深入理解他们,并有意识地去使用设计模式来优化代码,提升效率,使我们的系统有更好的拓展性才是我们追求的。
学习更多技能
请点击下方公众号
![]()

