设计模式系列

116 阅读2分钟

1: 迭代器模式

迭代器模式: 提供一种方法顺序访问一个聚合对象中的各个元素,使用者并不需要关心该方法的内部表示. 作用

  • 为遍历不同集合提供统一接口
  • 保护原集合但又提供外部访问内部元素的方式

实际案例

前端开发中会接触到各种数组或者类数组对象,在 jQuery 中可以通过$.each 等接口遍历各种列表,当然 JS 也内置了多种遍历数组的方法如forEachreduce等。

对于数组的循环大家都轻车熟路了,在实际开发中,也可以通过循环来优化代码。

一个常见的开发场景是:通过 ua 判断当前页面的运行平台,方便执行不同的业务逻辑,最基本的写法当然是if...else一把梭

const PAGE_TYPE = {
    app: "app", // app
    wx: "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;
}
复制代码

判断的逻辑很简单,遍历当前需要判断的平台列表,并返回第一个匹配的平台类型,可以看见,当我们需要新增一种平台的判断时,要做修改两个地方

  • 修改PAGE_TYPE的枚举值,增加新的平台名称
  • 修改getPageType中的实现,增加一个else if分支

同理,移除某种平台的判断,也需要修改这两个地方。

参考策略模式的思路,我们可以减少分支判断的出现,将每个平台的判断拆分成单独的策略

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);
}

每个策略都定义了相同的函数签名:接收 ua 字符串并返回布尔值,为 true 表示匹配。然后重新实现getPageType

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这个地方而已。比如将头条新闻 APP 也算作 tiktok 的话,只需要修改isTiktok

function isTiktok(ua) {
    return /aweme|NewsArticle/i.test(ua);
}

类似的条件判断还有很多,比如为了兼容老浏览器找到一个合适的 XHR 版本对象、根据 app 版本号使用不同的客户端接口等。只要代码修改的范围足够小,产生 bug 的几率就越小。

在上面的例子中,我们更像是在使用策略模式或者责任链模式,却在不知不觉中使用到了迭代器模式。大部分现代语言都内部了迭代器,我们没必要刻意去实现prevnextisDone等接口,而应该学会灵活使用迭代器来简化代码逻辑。

2:单例模式

单例模式: 保证一个类只有一个实例, 一般先判断实例是否存在,如果存在直接返回, 不存在则先创建再返回,这样就可以保证一个类只有一个实例对象.

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

3策略模式

主要作用是:将层级相同的逻辑封装成可以组合和替换的策略方法,减少 if...else 代码,方便扩展后续功能。

说到前端代码中的 if...else 代码,最常见的恐怕就是表单校验了

function onFormSubmit(params) {
    if (!params.nickname) {
        return showError("请填写昵称");
    }
    if (params.nickname.length > 6) {
        return showError("昵称最多6位字符");
    }
    if (!/^1\d{10}$/.test(params.phone))
        return showError("请填写正确的手机号");
    }
    // ...
    sendSubmit(params)
}
复制代码

关于 if..else 代码的罪过想必大家都比较熟悉了,这种写法还有一些额外的问题

  • 将所有字段的校验规则都堆叠在一起,如果想查看某个字段的校验规则,则需要将所有的判断都看一遍(避免某个同事将同一个字段的两种判断放在了不同的位置)
  • 在遇见错误时,直接通过 return 跳过了后面的判断;如果产品希望直接展示每个字段的错误,则改动的工作量可不谓不少。

不过目前在antdELementUI等框架盛行的年代,在 Form 组件中已经很少看见这种代码了,这要归功于async-validator

下面我们来实现一个建议的validator

class Schema {
    constructor(descriptor) {
        this.descriptor = descriptor;
    }

    validate(data) {
        return new Promise((resolve, reject) => {
            let keys = Object.keys(data);
            let errors = [];
            for (let key of keys) {
                const config = this.descriptor[key];
                if (!config) continue;

                const { validator } = config;
                try {
                    validator(data[key]);
                } catch (e) {
                    errors.push(e.toString());
                }
            }
            if (errors.length) {
                reject(errors);
            } else {
                resolve();
            }
        });
    }
}
复制代码

然后声明每个字段的校验规则,

// 首先声明每个字段的校验规则
const descriptor = {
    nickname: {
        validator(val) {
            if (!val) {
                throw "请填写昵称";
            }
            if (val.length < 6) {
                throw "昵称最多6位字符";
            }
        },
    },
    phone: {
        validator(val) {
            if (!val) {
                throw "请填写电话号码";
            }
            if (!/^1\d{10}$/.test(val)) {
                throw "请填写正确的手机号";
            }
        },
    },
};
复制代码

最后校验数据源

// 开始校验
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中声明的。

在上面的实现中,我们按照字段的维度,为每个字段实现了一个validator方法,用于处理校验该字段需要的逻辑。

实际上我们可以拆分出一些更通用的规则,比如required(必填)、len(长度)、min/max(最值)等,这样,当多个字段存在一些类似的校验逻辑时,可以尽可能地复用。

修改一下 descriptor,将每一个字段的校验规则类型修改为列表,列表中每个元素的 key 表示这条规则的名称,validator作为自定义规则

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: "请填写正确的电话号码",
        },
    ],
};
复制代码

然后修改Schema的实现,增加handleRule方法

class Schema {
    constructor(descriptor) {
        this.descriptor = descriptor;
    }

    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();
            }
        });
    }
}
复制代码

这样,就可以将常见的校验规则都放在ruleMap中,并暴露给使用者自己组合各种校验规则,比之前各种不可复用的 if..else 判断会更容易维护和迭代。

4:代理模式

代理模式是为一个对象提供一个代用品或占位符,以便控制对它的访问