在前端业务场景下的设计模式

2,475 阅读13分钟

本文同步在个人博客shymean.com上,欢迎关注

前端的代码也是需要设计的,话虽如此,但即便看了很多设计模式的书籍,也无法真正应用起来。后来发现还是需要真实业务场景入手,思考在面对复杂多变的需求时如何编写更简洁、更容易维护的代码。本文从这个角度入手,整理了自己在前端业务开发中遇见的一些设计模式。

参考

本文不会介绍相关的概念,也不会按照常规的设计模式给出UML图,由于本人水平有限,也是半路出身的写代码,没有经历过专门的CS教育,如果文中有写的不对的地方,还望大家指正。

单例模式:全局弹窗

弹窗是前端开发中一个比较常规的需求,下面定义了一个简易的MessageBox,用于实例化各种弹窗

class MessageBox {
    show() {
        console.log("show");
    }
    hide() {}
}
let box1 = new MessageBox();
let box2 = new MessageBox();
console.log(box1 === box2); // false

在常规情况下,一般同一时间只会存在一个全局弹窗,我们可以实现单例模式,保证每次实例化时返回的实际上是同一个方法

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(),这样就对之前的逻辑不会有任何影响。

策略模式:表单判断

策略模式的主要作用是:将层级相同的逻辑封装成可以组合和替换的策略方法,减少 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 判断会更容易维护和迭代。

代理模式:erdua 移动端调试面板

代理模式主要是封装对某些对像的访问,在后端中最典型的应用就是 Spring 中的 AOP,对前端而言,比较熟悉的大概就是 Vue3 中响应式原理核心实现Pxory,此外还有诸如网络代理、缓存代理等各种代理术语。接下来介绍一种在前端业务中使用代理模式的场景。

在前端开发的移动端页面调试中,由于在移动端没有对应的开发者面板,除了使用chrome://inspect/#devicessafari开发工具之外,我们还可以使用vConoleerdua来完成浏览页面结构、查看 console 等调试需求。

以 eruda 举例,对于代码中的console信息

首先是在 erdua 调试面板打印的信息

同时也会在浏览器控制台真实打印的信息,可以看见代码 source 来自于erdua.js,而不是原本写 console 代码的位置

整个调试面板的原理也比较容易理解,通过 erdua 代理真实的 console,然后将原本的打印信息展示在 erdua 面板上

const browserConsole = window.console;

function printInConsolePanel(type, msg) {
    const dom = document.querySelector("J_proxy_console_panel");
    dom.innerHTML = type + ":" + msg;
}

const proxyConsole = {
    browserConsole,
    log(msg) {
        // 打印在真实面板上
        printInConsolePanel("log", msg);
        // 原本的浏览器log
        this.browserConsole.log(msg);
    },
};

window.console = {
    ...browserConsole,
    ...proxyConsole,
};

这样,在移动端设备这些不容易访问控制台的地方,通过proxyConsole代理了了真实的 window.console,然后就可以对用户提供直接浏览控制台信息的接口了。

工厂模式:封装 storage

工厂模式提供了一种创建对象的方法,对使用方隐藏了对象的具体实现细节,并使用一个公共的接口来创建对象。

前端本地存储目前最常见的方案就是使用localStorage,为了避免在业务代码里面散落各种getItemsetItem,我们可以做一下最简单的封装

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暴露的getset接口,我们无需再维护local_theme;但上面的封装也存在一些可见的问题,新增 10 个 name,则上面的模板代码需要重新写 10 遍?

为了解决这个问题,我们可以将创建 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创建各种不同本地存储接口对象,而无需关注创建对象的具体细节。

迭代模式:平台判断

前端开发中会接触到各种数组或者类数组对象,在 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等接口,而应该学会灵活使用迭代器来简化代码逻辑。

发布-订阅模式:eventBus 事件通信

发布订阅模式大概是前端同学最熟悉的设计模式之一了,常见的

  • addEventListener,基本的事件监听,以及各种属性方法onloadonchange
  • $.on$.emit,jQuery 版本的事件监听
  • vue 响应式数据、组件通信
  • reduxeventBus

(貌似发布-订阅在前端可以理解为就是”事件“...

除了框架提供的相关方法之外,我们也可以通过这种模式解耦各个模块之前的依赖。

假设现在有一个页面,在进入页面后我们需要做两件事情

  • 上报某个特定的埋点
  • 判断是不是活动期间,如果是活动期则弹出优惠券弹窗

按照常规的写法

const activity = {
    showCouponDialog() {
        console.log("恭喜你获得优惠券");
    },
};

const logger = {
    pageView(page) {
        reportLog("page_view", page);
    },
};

// 将逻辑写在页面中
const indexPage = {
    mounted() {
        console.log("mounted");

        logger.pageView("index");
        activity.showCouponDialog();
    },
};

indexPage.mounted();

这样写看起来逻辑比较清晰,在当下也能满足需求,但就维护而言存在一些问题

  • 当新的需求也要在进入页面后处理时,需要找到 indexPage 插入相关逻辑
  • 当不再需要showCouponDialog时,需要找到 indexPage 并移除相关逻辑
const indexPage = {
    mounted() {
        console.log("mounted");

        logger.pageView("index");
        // 取消展示优惠券
        // activity.showCouponDialog()
        // 展示会员过期提示弹窗
        vip.showExpireTipDialog();
    },
};

就改动而言,未来的需求是不可预知的,但 indexPage 本身的逻辑应该是比较稳定的,因此上面的代码可以修改为

// ... 忽略eventBus的实现
const eventBus = {
    on() {},
    emit() {},
};

const activity = {
    init() {
        eventBus.on("enterIndexPage", () => {
            this.showCouponDialog();
        });
    },
    showCouponDialog() {
        console.log("恭喜你获得优惠券");
    },
};

const logger = {
    init() {
        eventBus.on("enterIndexPage", () => {
            this.pageView("index");
        });
    },
    pageView(page) {
        reportLog("page_view", page);
    },
};

const indexPage = {
    mounted() {
        console.log("mounted");
        eventBus.emit("enterIndexPage");
    },
};

// 各个模块按需要监听事件
activity.init();
logger.init();

// 触发时间
indexPage.mounted();

现在我们解耦了 indexPage 和各个业务模块,当遇见前面的需求变更场景时,就不再需要改动 indexPage 相关的代码了。当然,这种方式也会引入新的问题:除了查找整个项目,我们无法了解 indexPage 到底被哪些模块订阅了,这会导致部分流程难以追踪和理解。

模板方法:复用逻辑,隔离变化

在某些时候,我们可能会编写一些看起来很重复,但不容易找到复用点的代码片段,以上面判断设备平台的场景为例

// 判断是否是app环境
if (isApp) {
    a();
} else {
    b();
}

c();

if (isApp) {
    d();
} else {
    e();
}

上面的if(isApp)...else出现了两次,我们可以稍微将他们简化一下

const appHanlder = () => {
    a();
    c();
    d();
};
const defaultHandler = () => {
    b();
    c();
    e();
};

if (isApp) {
    appHanlder();
} else {
    defaultHandler();
}

看起来代码逻辑更加清晰了,对于这种部分代码可以复用、但其他部分有独立逻辑的代码段,还可以使用模板方法进行优化。

单纯看模板方法模式,可以理解为将一些通用的流程方法提升到公共的父类,然后子类单独实现各自特定的方法(最经典的例子:泡茶和泡咖啡),当然在这篇文章中为了贴近 JavaScript,我们不讨论父类或子类,取而代之的是大家都熟悉的 UI 组件

现在有两个商品促销详情页

  • 他们的公共逻辑包括:查询商品详情接口、检测用户是否已经购买、根据商品信息下单
  • 他们的差异逻辑包括:展示的 UI 不同、商品 A 点击按钮时会先弹出 SKU 列表选择,商品 B 点击购买按钮时直接购买

我们可以将公共的逻辑封装成基础组件,将各个商品的 UI 差异单独封装,通过render props(React)或者slot(Vue)传入基础组件进行展示,下面是使用 Vue 展示的简陋代码

const sellPage = {
    template: `
    <div class="page">
        <slot name="default"></slot>
    </div>
    `,
    methods: {
        fetchDetail() {
            // 获取商品详情
        },
        confirmPay(item) {
            // 根据商品拉起支付
        },
    },
};

const A = {
    components: {
        sellPage,
    },
    template: `
    <div>
        <sellPage ref="sellPage"> 
            <!--商品A详情-->
            <button @click="showSkuList">立即购买</button>
        </sellPage>
        <skuList v-show="skuVisible" @confirm="confirmBuy"/>
    </div>
    `,
    data() {
        return {
            skuVisible: false,
        };
    },
    methods: {
        showSkuList() {
            this.skuVisible = true;
        },
        confirmBuy(item) {
            this.$refs.sellPage(item);
        },
    },
};
const B = {
    components: {
        sellPage,
    },
    template: `
    <div>
        <sellPage>
            <!--商品B详情-->
        </sellPage>
    </div>
    `,
    methods: {
        confirmBuy(item) {
            this.$refs.sellPage(item);
        },
    },
};

这样的话,我们公共的逻辑封装在sellPage组件里面,同时定义了default slot接口,然后由 A、B 组件自己实现对应的 UI 和特定逻辑。

除此之外,模板方法在前端中的另外一种形式,即钩子方法,用于在封装的模块或组件中向外部暴露一些数据和信息,如生命周期函数、Function prop 等。

从另外一个角度来看待这些做法,可以理解为子类放弃了对自己的控制,只负责实现逻辑,然后由父类在合适的实际调用相关方法,比如我们只需要在 mounted 中实现逻辑,然后交给 vue 框架在适当的时机调用。

责任链模式:处理会员等级

参考: mp.weixin.qq.com/s/oP3GOPbjg…

错误处理是所有开发人员都需要面对的一个问题,在 catch 中,如果当前代码能够处理对应的错误,则进行处理;如果不能,则需要向上继续抛出异常而不是将该异常静默的吃掉,这是一个典型的责任链模式应用场景

function a() {
    throw "error b";
}

// 当b能够处理异常时,则不再向上抛出
function b() {
    try {
        a();
    } catch (e) {
        if (e === "error b") {
            console.log("由b处理");
        } else {
            throw e;
        }
    }
}

function main() {
    try {
        b();
    } catch (e) {
        console.log("顶层catch");
    }
}

责任链模式主要的思路是将每种校验定义成一个Handler,每个 Handler 会暴露一个添加下一个 Handler 的接口,当前错误无法处理时,则内部会自动调用下一个 Handler,以此类推,这样就极大地增加了处理任务的灵活性,当流程变化时,不需要修改底层实现,只需要修改责任链的顺序即可。

下面是一段根据用户积分返回会员等级的代码

function getLevel(score) {
    if (score < 10) {
        // ... 1级会员相关逻辑
    } else if (score < 100) {
        // ... 2级会员
    } else if (score < 1000) {
        // ... 3级会员
    }
    // ...
}

没错,又到了大家都喜欢的if...else环节。由于每个等级的会员后续进行的逻辑是不一样的,我们按等级将每种会员的逻辑分离出来,然后使用责任链模式

function createRule(handler) {
    return {
        next: null,
        handle(args) {
            let ans = handler(args);
            if (ans) {
                return ans;
            } else if (this.next) {
                this.next.handle(args);
            }
        },
        setNext(rule) {
            this.next = rule;
        },
    };
}
const rule1 = createRule((score) => {
    if (score >= 10) return;
    // ... 会员1
    return true;
});
const rule2 = createRule((score) => {
    if (score >= 100) return;
    // ... 会员2
    return true;
});
const rule3 = createRule((score) => {
    if (score >= 1000) return;
    // ... 会员3
    return true;
});

rule1.setNext(rule2);
rule2.setNext(rule3);

rule1.handle(80);

当会员规则进行了调整,或者插入了新的会员等级时,只需要修改链条的顺序rule3.setNext(rule4)就可以了,无需再增加额外的if...else判断。

适配器:兼容历史项目

我目前的项目在前段时间经过一次后台重构,从 Python 服务切换成了 Spring Cloud 微服务,除了业务逻辑、接口字段的变化之外,还有一个显著的差异:Python 的字段风格是下划线的,Java 的字段风格是驼峰的~,这导致大部分接口的字段名称都从下划线转换成了驼峰形式。

由于部分历史组件是直接使用的接口字段,如果直接迁移到新接口,则需要深入到每个组件中找到使用的字段,改动范围势必很大,由于这些组件已经比较稳定了,为了避免大动干戈,最后采用的方案是对接口返回的字段进行适配,将驼峰的字段映射为下划线字段。

大概的实现如下

function api() {
    return Promise.resolve({
        code: 0,
        data: {
            userName: 123,
        },
    });
}

function adapterApi() {
    return api().then((res) => {
        // 增加适配
        return {
            code: res.code,
            data: {
                user_name: res.data.userName,
            },
        };
    });
}

function a() {
    api().then((res) => {
        console.log(res.data.user_name); // undefined
    });

    adapterApi().then((res) => {
        console.log(res.data.user_name); // 123
    });
}

适配器模式会带来一些隐含的问题:如果为了避免改变现有代码而使用适配器模式,日积月累下来,整个项目会变得越来越难以维护,因此最好只在为了兼容旧系统或第三方服务的场景下才使用。

小结

本文主要整理了一些在日常开发中使用设计模式优化代码的方式,除了文中提到的之外,还有其他比较常见的设计模式如装饰器、中介模式等,由于篇幅有限,暂时没有列出来了。

设计模式这个东西,就像是“读了很多大道理,却依旧过不好这一生”,感觉还是要多写点代码,少咬文嚼字,毕竟提高编程水平最有效的办法是修改自己的烂代码。