最近有个三端统一的技术场景,主要是以前移动端的 hybrid 网页在不考虑 UI 适配的情况下、期望能够直接在 PC 客户端投放。在评估修改面的时候发现了一段可以深思的代码:
if (platform === 'iphone') {
location.href = iphoneClientUrl;
} else {
location.href = gphoneClientUrl;
}
其中platform
是来自平台判断函数获得的当前系统标识、其值如'iphone'
(iPhone)、'gphone'
(安卓),iphoneClientUrl
/gphoneClientUrl
分别是 iPhone 和安卓应用的 URL Schemes 客户端协议跳转地址。
我们知道,根据不同系统/应用进行区分处理是常有的事、比如这里的调用不同协议,那么这段代码在当前面临适配 PC 运行的场景会有什么样的问题呢?
问题 1.不合理的兜底处理
首先如果直接在 PC 客户端投放的话,这段代码会直接走进else
的执行分支、即会调用安卓的客户端协议跳转地址(gphoneClientUrl
)。这种情况大概率是调不通的、会容易导致执行异常,比如跳到空白页之类。
所以这段代码的第一个问题就是不能让安卓逻辑的执行代码作为最后 else 的兜底,PC 端运行安卓 mobile 的代码容易出错。
为了修改这个问题、之前的代码可以改为:
if (platform === 'iphone') {
location.href = iphoneClientUrl;
} else if (platform === 'gphone') {
location.href = gphoneClientUrl;
} else {
// 兜底处理
console.log('当前系统未支持此协议调用');
}
这里增加了一个未识别平台的兜底处理,避免直接运行 mobile 端的兜底处理。
*这类兜底判断做法我们可以在很多大厂的代码中发现,如下是百度的一段:
问题 2.没有较好得遵循“开闭原则”
为了适配当前 PC 客户端的需求,这段代码现在还要对 PC 客户端的协议进行判断处理,如:
if (platform === 'iphone') {
location.href = iphoneClientUrl;
} else if (platform === 'gphone') {
location.href = gphoneClientUrl;
} else if (platform === 'windows') {
location.href = windowsClientUrl;
} else {
// 兜底处理
console.log('当前系统未支持此协议调用');
}
那么问题可能又来了,如果要适配 Mac、iPad、Linux 甚至鸿蒙等系统这段代码又要进行调整,如:
if (platform === 'iphone') {
location.href = iphoneClientUrl;
} else if (platform === 'gphone') {
location.href = gphoneClientUrl;
} else if (platform === 'windows') {
location.href = windowsClientUrl;
} else if (platform === 'mac') {
location.href = macClientUrl;
} else if (platform === 'ipad') {
location.href = ipadClientUrl;
} else if (platform === 'linux') {
location.href = linuxClientUrl;
} else if (platform === 'harmony') {
location.href = harmonyClientUrl;
} else {
// 兜底处理
console.log('当前系统未支持此协议调用');
}
也就是说,每当要适配一个新的系统就需要再增加一条 else 判断,那么这段代码就没有较好得遵循“开闭原则”、不易维护。因为这段代码的主体逻辑是根据不同平台进行协议跳转,而我们的改动只是增加一个新的平台处理、不应该对主体代码进行修改。
另外这样的代码也使得这段的代码重点迷失,从原本的关注根据 url 进行跳转变成了关注通过各分支进行跳转处理。
那么这段代码应该如何调整呢?先放调整后的参考代码:
const PLATFORM_CLIENT_URLS = {
iphone: iphoneClientUrl,
gphone: gphoneClientUrl,
windows: windowsClientUrl,
mac: macClientUrl,
ipad: ipadClientUrl,
linux: linuxClientUrl,
harmony: harmonyClientUrl,
};
// 调用体
function jumpToClientUrl(platform) {
const clientUrl = PLATFORM_CLIENT_URLS[platform];
if (clientUrl) {
location.href = clientUrl;
} else {
// 兜底处理
console.log('当前系统未支持此协议调用');
}
}
这里我们用对象字面量PLATFORM_CLIENT_URLS
来收口各系统及其对应协议地址,抽象了根据不同平台进行协议跳转的主体逻辑至jumpToClientUrl
方法中,这样做的好处是每当要适配或调整一个新的系统时,我们只需要修改PLATFORM_CLIENT_URLS
即可,这个对象还可以放在配置文件中与运行时代码解耦从而使调整时甚至不用改动运行时代码。
当然,这种情况下比较简单,那么遇到稍复杂些的场景应该怎么样呢?
场景 1.各判断分支的判断条件或对应执行处理都不一样时。
继续延续前面代码的场景,首先看判断条件不一样的情况,比如假设
- iPhone 需要大于 iOS10(
osVersion >= 10
) - 安卓需要在安卓 6 ~ 10 区间(
osVersion >= 6 && osVersion <= 8
) - windows 必须是 Windows 8.1 版本(
osVersion === 8.1
)
这种情况下刚才的对象字面量方式就不能进行直接处理了,那么应该如何适配呢?
抽离判断分支,对刚才的对象字面量进行调整:
const PLATFORM_CLIENT_SCHEMA = {
iphone: {
rule: osVersion => osVersion >= 10,
url: iphoneClientUrl,
},
gphone: {
rule: osVersion => osVersion >= 6 && osVersion <= 8,
url: gphoneClientUrl,
},
windows: {
rule: osVersion => osVersion === 8.1,
url: windowsClientUrl,
},
mac: {
rule: osVersion => osVersion >= 0,
url: macClientUrl,
},
};
// 调用体
function jumpToClientUrl(platform, osVersion) {
const clientSchema = PLATFORM_CLIENT_SCHEMA[platform];
let jumpClientUrl = '';
// 如果有规则且判断通过
if (clientSchema?.rule?.(osVersion)) {
jumpClientUrl = clientSchema.url;
}
if (jumpClientUrl) {
location.href = jumpClientUrl;
} else {
// 兜底处理
console.log('当前系统未支持此协议调用');
}
}
我们对需要单独进行判断的系统场景进行了结构调整,将特殊判断用rule
字段抽离,同样保持了适配一个新系统只需要调整对象(PLATFORM_CLIENT_RULES_AND_URLS
)而不用修改jumpToClientUrl
函数。
再看执行不一致的场景,假如
- iPhone 是打开一个弹窗(
Alert.show()
) - 安卓是调用 js 方法而不是跳转(
callAndroidNative(gphoneClientUrl)
) - windows 是
window.open()
打开协议地址(window.open(windowsClientUrl)
)
这种情况下可以延续刚才判断条件的抽离、进行:
抽离执行语句,对刚才的对象字面量进行调整:
const PLATFORM_CLIENT_SCHEMA = {
iphone: {
rule: osVersion => osVersion >= 10,
url: iphoneClientUrl,
run: () => Alert.show(),
},
gphone: {
rule: osVersion => osVersion >= 6 && osVersion <= 8,
url: gphoneClientUrl,
run: () => callAndroidNative(gphoneClientUrl),
},
windows: {
rule: osVersion => osVersion === 8.1,
url: windowsClientUrl,
run: () => window.open(windowsClientUrl),
},
mac: {
rule: osVersion => osVersion >= 0,
url: macClientUrl,
},
};
// 调用体
function jumpToClientUrl(platform, osVersion) {
const clientSchema = PLATFORM_CLIENT_SCHEMA[platform];
let jumpClientUrl = '';
// 如果有规则且判断通过
if (clientSchema?.rule?.(osVersion)) {
// 如果有单独执行条件
if (clientSchema.run) {
return clientSchema.run();
}
jumpClientUrl = clientSchema.url;
}
if (jumpClientUrl) {
location.href = jumpClientUrl;
} else {
// 兜底处理
console.log('当前系统未支持此协议调用');
}
}
进一步对需要单独执行处理的系统场景进行了结构调整,将特殊处理用run
字段抽离,同样保持了适配一个新系统只需要调整对象(PLATFORM_CLIENT_SCHEMA
)而不用修改jumpToClientUrl
函数。
场景 2.考虑拓展应用场景。刚才我们所做的一系列优化实质还只是在一个小应用场景,如何将类似的系统判断处理通用化呢?
我们可以定义抽象类或接口、将各系统的属性信息、各类判断和执行方法作为此抽象类的实现类中,将各场景的消费处理放到消费类中。然后通过类似策略模式、模版模式甚至适配器模式供消费类使用。可以通过如策略模式来实现判断条件和执行逻辑的统一抽象,提高整体代码的可扩展性、复用性和可读性。
那么接下来就以策略模式为例实现一个简单的跨端 api 封装(因为 js 中还没有抽象类/接口语法,下面就用 ts 来实现代码效果):
策略模式
策略模式作为一种软件设计模式,指对象有某个行为,但是在不同的场景中,该行为有不同的实现算法。比如每个人都要“交个人所得税”,但是“在美国交个人所得税”和“在中华民国交个人所得税”就有不同的算税方法。——WikiPedia-策略模式
先来回顾下策略模式的概念:在策略模式(Strategy Pattern)中,一个类的行为或其算法可以在运行时更改。这种类型的设计模式属于行为型模式。
在策略模式中,我们创建表示各种策略的对象和一个行为随着策略对象改变而改变的 context 对象。策略对象改变 context 对象的执行算法。策略模式就是能够把一系列“可互换的”算法封装起来,并根据用户需求来选择其中一种。
策略模式实现的核心就是:将算法的使用和算法的实现分离。算法的实现交给策略类。算法的使用交给环境类,环境类会根据不同的情况选择合适的算法。
UML 如:
- 优点:
- 算法可以自由切换。
- 避免使用多重条件判断。
- 扩展性良好。
- 缺点:
- 策略类会增多。
- 所有策略类都需要对外暴露。
策略模式非常适合我们之前系统环境判断的处理,以下是一个实现 demo:
策略模式实现系统判断及处理
接口:
interface PlatformStrategy {
// 跳转场景
jumpClient(): void;
// 其他场景、如设置标题
setTitle(): void;
}
实现类(策略类):
class IphoneStrategy implements PlatformStrategy {
jumpClient(osVersion: number) {
if (osVersion >= 10) {
Alert.show();
} else {
console.log('当前系统未支持此协议调用(iOS版本小于10)');
}
}
setTitle(title: string) {
document.title = `${title}(iPhone)`;
}
}
class GphoneStrategy implements PlatformStrategy {
jumpClient(osVersion: number) {
if (osVersion >= 6 && osVersion <= 8) {
callAndroidNative(gphoneClientUrl);
} else {
console.log('当前系统未支持此协议调用(安卓版本小于6或大于8)');
}
}
setTitle(title: string) {
setAndroidTitle(title);
}
}
class OtherStrategy implements PlatformStrategy {
jumpClient(osVersion: number) {
console.log('当前系统未支持此协议调用');
}
setTitle(title: string) {
console.log('当前系统未支持此协议调用');
}
}
消费类(环境类):
class PlatformCustom {
platformStrategy: PlatformStrategy;
constructor(platformStrategy: PlatformStrategy) {
this.platformStrategy = platformStrategy;
}
setHomePageTitle() {
this.platformStrategy.setTitle('主页');
}
setHomeRuleTitle() {
this.platformStrategy.setTitle('规则页');
}
jumpClient() {
this.platformStrategy.jumpClient(osVersion);
}
}
使用:
const NowPlatformStrategy = STRATEGY_MAP[platform] || OtherStrategy;
const platformCustomer = new PlatformCustom(new NowPlatformStrategy());
// ...
platformCustomer.setHomePageTitle();
// ...
platformCustomer.jumpClient();
可以发现,我们在消费类的定义和使用时,无须关系各系统环境的处理、进行在面临适配新系统时也不用对使用或消费类进行修改,很好得遵循了“开闭原则”。
另外,在大前端领域下,这类模式也适合跨端 Api 的封装,大家可以看各类跨端框架(如 Taro)的封装、都或多或少遵循了策略模式/适配器模式。
总结
本文的优化建议
从本次前端系统区分判断处理的业务场景以及一段代码的优化处理下,本次提出的前端优化建议有以下几点:
- 我们需要合理设计兜底处理,避免在适配新场景下直接调用不兼容的代码;
- 涉及较多判断的场景下,我们可以使用抽象方式进行处理、遵循开闭原则;
- 策略模式/适配器模式/模版模式可以应用于一些统一处理的场景、比如跨端统一判断逻辑;
- 我们需要持续学习设计模式、思考在前端的实践应用
*可能伴随的问题
上述的各类对 if 处理做了各种抽象,这种情况有没有什么问题隐患呢?
如果硬要说隐患的话,有以下两点几乎可以不值一提的隐患:
- 多创建了枚举/对象/类,占用了空间。;
- 代码的理解成本或许有所增高、没有直接 if else 看得顺畅。
思考
我们还有哪些场景可以提前做 if 语句的抽象优化?比如你需要处理各家银行、各个城市、各类水果、各只基金代码等等…
在处理这些场景时我们是否需要提前引入设计模式?如果需要、判断条件会是什么?
推荐阅读
- 《重构-改善既有代码的设计》
- 《代码整洁之道》
- 《编程珠玑》
- 《程序员的思维修炼:开发认知潜能的九堂课》
以上这些经典书籍都包含了 if 语句优化方面的内容。