在前端业务场景下的设计模式
前端的代码也是需要设计的,话虽如此,但即便看了很多设计模式的书籍,也无法真正应用起来。后来发现还是需要真实业务场景入手,思考在面对复杂多变的需求时如何编写更简洁、更容易维护的代码。本文从这个角度入手,整理了自己在前端业务开发中遇见的一些设计模式。
参考
- 《JavaScript 设计模式与开发实践——曾探》
- 编程的智慧
本文不会介绍相关的概念,也不会按照常规的设计模式给出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 跳过了后面的判断;如果产品希望直接展示每个字段的错误,则改动的工作量可不谓不少。
不过目前在antd
、ELementUI
等框架盛行的年代,在 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/#devices
和safari开发工具
之外,我们还可以使用vConole或erdua来完成浏览页面结构、查看 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
,为了避免在业务代码里面散落各种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
;但上面的封装也存在一些可见的问题,新增 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 也内置了多种遍历数组的方法如forEach
、reduce
等。
对于数组的循环大家都轻车熟路了,在实际开发中,也可以通过循环来优化代码。
一个常见的开发场景是:通过 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 的几率就越小。
在上面的例子中,我们更像是在使用策略模式或者责任链模式,却在不知不觉中使用到了迭代器模式。大部分现代语言都内部了迭代器,我们没必要刻意去实现prev
、next
、isDone
等接口,而应该学会灵活使用迭代器来简化代码逻辑。
发布-订阅模式:eventBus 事件通信
发布订阅模式大概是前端同学最熟悉的设计模式之一了,常见的
addEventListener
,基本的事件监听,以及各种属性方法onload
、onchange
等$.on
、$.emit
,jQuery 版本的事件监听- vue 响应式数据、组件通信
redux
、eventBus
等
(貌似发布-订阅在前端可以理解为就是”事件“...
除了框架提供的相关方法之外,我们也可以通过这种模式解耦各个模块之前的依赖。
假设现在有一个页面,在进入页面后我们需要做两件事情
- 上报某个特定的埋点
- 判断是不是活动期间,如果是活动期则弹出优惠券弹窗
按照常规的写法
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 框架在适当的时机调用。
责任链模式:处理会员等级
参考: https://mp.weixin.qq.com/s/oP3GOPbjg5wHcgtizExThw
错误处理是所有开发人员都需要面对的一个问题,在 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); // undefined
});
}
适配器模式会带来一些隐含的问题:如果为了避免改变现有代码而使用适配器模式,日积月累下来,整个项目会变得越来越难以维护,因此最好只在为了兼容旧系统或第三方服务的场景下才使用。
小结
本文主要整理了一些在日常开发中使用设计模式优化代码的方式,除了文中提到的之外,还有其他比较常见的设计模式如装饰器、中介模式等,由于篇幅有限,暂时没有列出来了。
设计模式这个东西,就像是“读了很多大道理,却依旧过不好这一生”,感觉还是要多写点代码,少咬文嚼字,毕竟提高编程水平最有效的办法是修改自己的烂代码。
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。