一招解决项目中的if...else switch代码
前言
近年来,前端开发变得越来越倾向于使用声明式编程。最典型的例子就是 React,React改变了我们之前直接操作DOM的命令式编程方式,转而使用声明式的方式来表达给定状态下DOM应该呈现的样子。这种方式被业界广泛采纳,现在我们已经认识到声明式代码更容易推理,而且通过采用这种范式可以排除许多错误。不仅如此,状态管理库也在不断向声明式发生转变,比如XState、Redux等库允许开发者以声明式的方式管理状态,使得代码更易于理解、修改和测试。
声明式编程本质上是定义表达式而不是语句,即描述结果的代码,其最重要的思想就是将描述需要完成操作的代码与解释此描述以产生副作用的代码分开,例如,React 应用程序本质上包括使用 JSX 描述 DOM 的外观,并让 React 在幕后以高性能方式渲染 DOM。
然而,Javascript和TypeScript并不是为这种编程范式设计的,这些语言缺少一个非常重要的部分:声明式分支语句。
分支语句的现状
JavaScript分支语句的主要现状是仍然以if
和else
语句为主。这些语句允许根据条件执行不同的代码块。此外,还有switch
语句,它允许根据一个表达式的值来选择执行不同的代码块,这两种语句都属于命令式语句。然而,随着JavaScript的发展,一些新的声明式分支语句也在不断涌现,比如三元运算符、可选链、空值合并等。
命令式分支语句
switch
Switch语句允许我们根据表达式的值来执行不同的代码块。它的使用方式如下:
interface IStateInfo {
status: 'A' | 'B' | 'C';
message: string
}
let res: string = '';
switch (stateInfo.status) {
case "A":
res = ...
break;
case "B":
res = ...
break
case "C":
res = ...
break;
default:
res = ...
break;
}
然而,在实际应用中,它存在严重的缺陷:
- switch 是命令式语句,不能作为计算结果的表达式。
- 在每个
case
中都需要一个显式的break
来避免意外的贯穿。 - 作用域是模糊的(除非使用花括号,否则在一个
case
块内定义的变量在其它case
的作用域中也是可用的)。 - 只能进行
===
比较等。
if…else
if…else 是编程中常用的一种控制结构,用于根据某个条件(或多个条件)的执行结果来选择不同的代码执行路径,if…else 相比于 switch 可拓展能力更强,我们可以执行任何比较逻辑,不像 switch 那样被限定在 ===
。但即使在常见的情况下,它也有几个严重的不足点:
- 逻辑冗长,可读性差,因为它要求我们每次都要明确地列出值的结构路径。
- if..else 是命令式语句,不能作为计算结果的表达式。
声明式分支语句
三元运算符
三元运算符是基于布尔值返回两个不同值的简洁方法:
bool ? valueIfTrue : valueIfFalse;
三元表达式作为声明式范式能够让开发者在 React 中便携条件渲染代码,下面是组件中通常的写法:
const SomeComponent = ({ fetchState }: Props) => (
<div>
{fetchState.status === "loading" ? (
<p>Loading...</p>
) : fetchState.status === "success" ? (
<p>{fetchState.data}</p>
) : fetchState.status === "error" ? (
<p>Oops, an error occured</p>
) : null}
</div>
);
这里我们使用了三层嵌套的三元表达式,这会导致代码阅读起来非常困难,但是我们也没有其他更好的选择,而且,如果我们想确保所有的值都能够被枚举而不是使用默认值,这也是做不到的,这被称为穷尽性检查。
可选链
这是一种新的语言特性,允许在访问深层嵌套对象属性或方法时,安全地检查中间值是否为**null
或undefined
,而不必每次都手动检查。它的语法是?.
**
const user = {
profile: {
name: 'Alice',
age: 25,
address: {
city: 'Beijing',
country: 'China',
},
},
};
console.log(user.profile.address.city); // Output: Beijing
console.log(user.profile.address.zipCode?.code); // Output: undefined
在这个例子中,我们尝试访问 user.profile.address.zipCode.code
。但是 zipCode
这一属性并不存在,如果我们直接访问 user.profile.address.zipCode.code
,将会抛出一个错误。但是,由于我们使用了 ?.
运算符,所以它返回 undefined
,而不是抛出错误。
尽管可选链能够提高代码的健壮性并简化访问深层嵌套属性的复杂性,但它也有一些缺点:
- 它可能会掩盖实际的错误。如果一个属性应该存在但实际上并不存在,使用可选链可能会隐藏这个问题,使得错误更难以被发现和调试。
- 它可能会导致代码的阅读和理解变得更困难,尤其是在链式访问较长或者逻辑较复杂的情况下。
小结
虽然Javascript也在不断的探索并提供了一些声明式分支语句,但很显然这是不够的,在面对一些复杂的数据结构式,这些声明式的语句也无能为力,同时也不具备穷尽性检查功能。为了解决这一问题,2017年在TC39中提出了一个模式匹配的提案,希望将模式匹配添加到 EcmaScript 规范中,这一提案目前还处于第一阶段。
什么是模式匹配?
模式匹配是一种声明式的代码分支技术,允许我们将数据与定义好的结构进行比较,从而轻松选择可用的表达式之一。模式匹配起源于函数式编程语言,与命令式编程中的常见分支结构(如if/else/switch语句)相比,模式匹配通常更为强大且更简洁,特别是在处理复杂条件时。模式匹配是在大多数语言中都有实现,如 Haskell、OCaml、Erlang、Rust、Swift、Elixir、Rescript,下面是一个Rust例子:
fn main() {
// 整数匹配
let number = 42;
match number {
0 => println!("It's zero!"),
1..=100 => println!("It's between 1 and 100!"),
_ => println!("It's something else!"),
}
// 枚举匹配
enum Coin {
Penny,
Nickel,
Dime,
Quarter,
}
let coin = Coin::Quarter;
match coin {
Coin::Penny => println!("It's a penny!"),
Coin::Nickel => println!("It's a nickel!"),
Coin::Dime => println!("It's a dime!"),
Coin::Quarter => println!("It's a quarter!"),
}
// 元组匹配
let point = (3, 5);
match point {
(0, 0) => println!("It's the origin!"),
(x, 0) => println!("It's on the x-axis at {}", x),
(0, y) => println!("It's on the y-axis at {}", y),
(x, y) => println!("It's at ({}, {})", x, y),
}
// 结构体匹配
struct Person {
name: String,
age: u8,
}
let person = Person {
name: String::from("Alice"),
age: 30,
};
match person {
Person { name, age: 30 } => println!("{} is 30 years old.", name),
Person { name, age } => println!("{} is {} years old.", name, age),
}
// 条件匹配
let x = 10;
match x {
n if n % 2 == 0 => println!("It's an even number!"),
n if n % 2 != 0 => println!("It's an odd number!"),
_ => println!("It's something else!"),
}
}
在TC39的模式匹配提案中,其语法大致如下:
// Experimental EcmaScript pattern matching syntax (as of March 2023)
match (IFetchState) {
when ({ status: "loading" }): ({data: xxxx})
when ({ status: "cancel" }): ({data: xxxx})
when ({ status: "success", data }): ({data: xxxx})
when ({ status: "error" }): ({data: xxxx})
}
模式匹配表达式以match关键字开头,后面跟着我们枚举的所有场景下的值和返回数据。每个代码枚举以when关键字开头,然后跟着的是在当前条件下的要返回的数据 。
这种模式匹配方式很符合人大脑天生的模式匹配能力,这使得代码比一堆if…else 更容易阅读,它更短,最关键的是它是一种表达式。但这个提案仍处于第一阶段,如果能正式发布至少需要几年。
穷尽性检查的现状
虽然 switch 或者三元运算符无法保证所有的值都能够被穷举,但是也有一些解决方法可以让 TypeScript 检查 Switch 语句是否穷举,其中之一就是借助 switch 语句的类型缩减和 never 类型的特征来解决:
switch (fetchState.status) {
case "loading":
return <p>Loading...</p>;
case "success":
return <p>{fetchState.data}</p>;
case "error":
return <p>Oops, an error occured</p>;
default:
const status: never = fetchState.status;
return status;
}
只有当所有的 status 值被穷举之后,最后在默认值这里拿到的 status 的类型才是 never,否则就会编译报错。这看起来是一种比较好的解决方案,但是回到之前的 JSX 代码,如果想在 JSX 代码中实现穷举,就需要这样做:
type IFetchState = {status: 'loading'} | {status: 'success'; data: string} | {status: 'error'}
const App = () => {
const statusRender = () => {
switch (fetchState.status) {
case "loading":
return <p>Loading...</p>;
case "success":
return <p>{fetchState.data}</p>;
case "error":
return <p>Oops, an error occured</p>;
default:
const status: never = fetchState.status;
return null;
}
}
return (
<div>{statusRender()}</div>
)
}
这只会让模板代码越来越多,代码的可测试性、维护性并没有得到改善。同时如果需要枚举的值有多个,我们该怎么写?下面这个例子在 status 的基础上再增加了一个 action 字段,为了确保处理所有的情况我们唯一的选择就是使用嵌套 switch 语句:
const reducer = (state: IFetchState, action: IAction) => {
switch(state.status) {
case 'loading': {
switch (action.type) {
case 'cancel': {
return {
status: 'cancel'
}
}
case 'success': {
return state;
}
default: {
const type: never = action.type;
return state;
}
}
}
case 'success': {
switch(action.type) {
//....
}
}
// ....
default: {
const status: never = state.status;
return state;
}
}
}
尽管这更安全,但是代码量很大,而且很容易选择更短、不安全的替代方案。
ts-pattern方案
简介
ts-pattern 是一个基于 typescript 的具备类型安全的模式匹配方案,,作为社区方案来弥补 Javascript 和 Typescript 语言在模式匹配中的缺陷。
核心优势
-
完全适合声明式上下文
ts-pattern 的语法完全是采用声明式的,它适用于任何 Typescript 环境和框架,下面是一个基础示例,比如我们需要在 JSX 中根据状态渲染不同的UI:
type IFetchState = {status: 'loading'} | {status: 'success'; data: string} | {status: 'error'} <div> {match(fetchState) .with({ status: "loading" }, () => <p>Loading...</p>) .with({ status: "success" }, ({ data }) => <p>{data}</p>) .with({ status: "error" }, () => <p>Oops, an error occured</p>) .exhaustive()} </div>;
我们再也不需要在代码中写switch、if…else 或者三元表达式。
-
支持匹配任何数据类型
const data: unknown = ...; const output = match(data) // 匹配数字 .with(1, (x) => ...) // 匹配字符串 .with("hello", (x) => ...) // 支持匹配多个类型 .with(null, undefined, (x) => ...) // 匹配对象 .with({ x: 10, y: 10 }, (x) => ...) .with({ position: { x: 0, y: 0 } }, (x) => ...) // 匹配数组 .with(P.array({ firstName: P.string }), (x) => ...) // 匹配元组 .with([1, 2, 3], (x) => ...) // 匹配Map .with(new Map([["key", "value"]]), (x) => ...) // 匹配Set .with(new Set(["a"]), (x) => ...) // 匹配混合和嵌套类型 .with( [ { type: "user", firstName: "Gabriel" }, { type: "post", name: "Hello World", tags: ["typescript"] } ], (x) => ...) //...
-
支持类型收缩
对于每个 .with(pattern, handler) 语句,其匹配到的值最终会被传输到处理函数,同时其类型也会缩小到模式匹配的类型。
type Action = | { type: "fetch" } | { type: "success"; data: string } | { type: "error"; error: Error } | { type: "cancel" }; match(action) .with({ type: "success" }, (matchedAction) => { /* matchedAction: { type: 'success'; data: string } */ }) .with({ type: "error" }, (matchedAction) => { /* matchedAction: { type: 'error'; error: Error } */ }) .otherwise(() => { /* ... */ });
-
类型详尽检查
类型详尽检查在 typescript 项目中非常有用,当一个值它可枚举的类型非常多时,很容易就会遗漏处理某一个或多个类型,在这之前开发者都是借助借助 switch 语句的类型缩减和 never 类型的特征来解决,但是代码可维护性非常差。现在 ts-pattern 帮我们很好的解决了这个问题。
type Action = | { type: 'fetch' } | { type: 'success'; data: string } | { type: 'error'; error: Error } | { type: 'cancel' }; return match(action) .with({ type: 'fetch' }, () => /* ... */) .with({ type: 'success' }, () => /* ... */) .with({ type: 'error' }, () => /* ... */) .with({ type: 'cancel' }, () => /* ... */) .exhaustive(); // 开启穷尽检查,由于所有类型都已经处理过,编译通过 return match(action) .with({ type: 'fetch' }, () => /* ... */) .with({ type: 'success' }, () => /* ... */) .with({ type: 'error' }, () => /* ... */) .exhaustive(); //开启穷尽检查,由于有一个类型没有处理,编译不通过,并抛出一个NonExhaustiveError<{ type: 'cancel' }>错误
当然穷尽检查可自由配置,开发者也可以选择不开启。
return match(action) .with({ type: 'fetch' }, () => /* ... */) .with({ type: 'success' }, () => /* ... */) .with({ type: 'error' }, () => /* ... */) .run(); // 这会跳过对{ type: 'cancel' }的检查
-
提供了非常完备的数据匹配API
ts-pattern 为了能够支持开发者更灵活、更便捷的去自定义匹配规则,它提供了非常多的数据匹配 API,下面介绍几个核心的 API。
-
通配符(P._)
通配符可以用来匹配任何数据类型,这有点类似 switch 语句中的 default,如果开发者只想处理某几种情况,其余的都采用默认处理方式,则可以使用通配符。
import { match, P } from 'ts-pattern'; match([state, event]) // 匹配[state, event]的所有情况 .with(P._, () => state) // 匹配[state, event]中state的所有情况 .with([P._, { type: 'success' }], ([_, event]) => /* event: { type: 'success', data: string } */) // 匹配[state, event]中state和event.type的所有情况 .with([P._, { type: P._ }], () => state) .exhaustive();
处理之外,还提供了匹配某一种数据类型的匹配符,比如P.string、P.number、P.boolean等,在处理一些非特定值的数据时非常有用,比如接口返回数据。
-
when 语句(P.when)
when 语句给开发者提供了能够自定义匹配规则的能力,在这之前,开发者只能去通过具体的数值或者数据类型去匹配,而这一能力能容让开发者定义更复杂的匹配规则。
import { match, P } from 'ts-pattern'; const isOdd = (x: number) => Boolean(x % 2) match({ x: 2 }) .with({ x: P.when(isOdd) }, ({ x }) => /* `x` is odd */) .with(P._, ({ x }) => /* `x` is even */) .exhaustive();
缺陷
ts-pattern 的核心功能是基于 typescript 实现的,为了使类型推断和穷尽性检查能够正常工作,ts-pattern依赖于类型级别的计算,这可能会减慢项目的类型检查速度和项目编译速度。ts-patern 的作者也在不断尝试使其尽可能快,但目前速度将始终慢于 switch 语句。使用ts-pattern意味着牺牲一些编译时间以换取类型安全和更易于维护的代码,是否需要使用 ts-pattern 需要开发者自己进行权衡。
总结
这篇文章主要讨论了Javascript和Typescript中分支语句的不足,以及如何利用模式匹配来优化这些问题。文章首先介绍了传统的分支语句,如switch和if...else,以及它们的问题,如冗长的逻辑,缺乏穷尽性检查等。然后,文章探讨了声明式分支语句,如三元运算符和可选链,以及它们的优点和缺点。接下来,文章引入了TS-Pattern,一个基于Typescript的模式匹配方案,它可以更好地处理复杂的数据结构,提供类型详尽检查,支持匹配任何数据类型等功能。虽然其具有非常多的核心优势,但是也因为需要依赖于类型级别的计算,会使得项目编译速度变慢,这需要开发者自己进行权衡。
-
转载自:https://juejin.cn/post/7372469848343838754