前端项目中的函数式编程初步实践
相信大部分程序员这几年都或多或少听说过函数式编程(Functional Programming, 以下简称FP),而我生产开发中也通过践行FP的思想获益良多,借此文聊聊我所认为的前端项目中应该如何使用FP。
除了本文,我也制作了slidev幻灯片,欢迎使用:
- 幻灯片演示地址:functional-programming-mocha.vercel.app/ ;
- 幻灯片项目仓库(推荐):github.com/RJiazhen/fu... ;
什么是FP?
先看以下两个例子
柯里化:
ts
import { curry } from 'ramda';
const add = curry((a, b) => a + b); // 普通函数使用curry包裹后,变成柯里化函数
console.log(add(10, 5)); // 15,传两个参数则直接返回结果
const add5 = add(5); // 传入参数不足时则返回一个函数
console.log(add5(10)); // 15,将剩下的参数传入,得到最终结果
函数组装:
ts
import { compose } from 'ramda';
const double = (num) => num * 2;
const addOne = (num) => num + 1;
const toString = (num) => num.toString();
const composedFunction = compose(toString, addOne, double); // 使用compose组装函数,从右到左执行
console.log(composedFunction(3)); // '7'
相信大家从这两个例子中就能立即感受到FP的强大,在FP的开发范式下,所有的函数都是可以非常优雅地进行自由组装和复用的,不需要函数嵌套,不需要使用工厂函数。
而这只是FP的冰山一角,参考mostly-adequate.gitbook.io/mostly-adeq...的定义,函数式编程包含以下基本层级,每下一层都依托于上一层,越往下越复杂,也越接近完整的FP:
- 底层思想(λ演算)
- 变量和函数定义
- 纯函数
- 不可变性
- 声明式
- 高阶函数
- 函数柯里化
- 函数组装
- ......
- 函子及应用
- Container
- 错误处理
- 副作用封装
- ......
应该如何使用FP?有什么好处?
首先,我不推荐在前端项目中完全遵循FP开发模式 ,因为在我看来,JS这门语言本身对FP的支持是不够的,例如:
- 原生不支持柯里化和compose等特性;
- 大部分运行环境没有尾递归优化;
- 没有强大的类型系统;
再考虑到全面实行FP的难度,以及这样做的收益可能并不明显。在本文中,我也只推荐在前端项目中实践FP中的纯函数、不可变性和声明式。
相比于全面推行FP,只践行这一部分的好处是:
- 无需引入
ramda
等外部库; - 实行难度很低,无需改变当前的项目架构,可以从任意编程习惯快速过渡;
当然,即便只是实行了这部分,也能明显得到FP带来的以下好处:
- 避免或减少
this
相关bug; - 提升代码易读性、降低代码维护成本;
- 能加深对React的函数式组件和Vue3的组合式API的理解和运用;
- 能更好地适应Typescript的类型系统;
具体实践
纯函数
纯函数即函数内部只使用传入参数,传入相同的参数永远输出相同的参数,且函数内部不对外部造成任何影响。
简单示例
以下是一个简单示例:
ts
const num1 = 1
const num2 = 2
/** 非纯函数,在函数内部引用了外部变量num1 */
const impureAdd = (b:number) => num1 + b
console.log(impureAdd(2)) // 3
/** 纯函数,使用的所有变量均从外部获取 */
const pureAdd = (a:number, b:number) => a + b
console.log(pureAdd(num1+num2) // 3
实际开发示例
再来一个前端项目中比较常见的例子,这个例子的重点在于将表单提交后的逻辑拆分为副作用部分和无副作用的纯函数部分。
非纯函数:
tsx
export const ImpureExample = () => {
const [formValues, setFormValues] = useState({
name: '',
email: '',
});
/** 表单提交的结果 */
const [submitResult, setSubmitResult] = useState(null);
/** 提交表单的函数,非纯函数版本 */
const impureSubmitForm = async () => {
const res = await axios.post('/api/submit-form', formValues);
setSubmitResult(res.data.result);
// 其他逻辑...
};
/** 表单提交回调函数,只有在表单提交时执行 */
const onFinish = async () => {
// 其他逻辑...
// 调用非纯函数时,无法得知其使用了哪些外部变量,且无法确定其是否会修改外部变量的值
impureSubmitForm();
};
return <>{/* ... */}</>;
};
纯函数:
tsx
export const PureExample = () => {
const [formValues, setFormValues] = useState({
name: '',
email: '',
});
/** 表单提交的结果 */
const [submitResult, setSubmitResult] = useState(null);
/** 纯函数版本的提交表单函数 */
const pureSubmitForm = async (params: { name: string; email: string }) => {
const res = await axios.post('/api/submit-form', params);
return res.data.result;
};
/** 表单提交回调函数,只有在表单提交时执行 */
const onFinish = async () => {
// 其他逻辑...
// 调用纯函数时,使用的参数是确定的,且不会修改外部变量的值
const result = await pureSubmitForm(formValues);
setSubmitResult(result);
};
return <>{/* ... */}</>;
};
因为远程请求的返回值是不固定,所以其实纯函数示例中的pureSubmitForm
其实并不算完全的纯函数,但由于没有副作用,所以还是相对纯净的。
从这个例子,可以明显看出纯函数最大的好处------降低代码维护难度。
在阅读纯函数时,完全无需关心其内部实现,只需要关心输入和输出,完全遵守关注点分离原则。
但如果要尽可能追求纯函数,那很明显,需要解决"那副作用相关的逻辑应该放在哪?"这个问题。
就我个人实践的经验来说,我推荐遵循以下原则进行处理:
- 放在没有返回值的函数中;
- 放在调用时不关心是否有副作用的函数中;
- 放在必定有副作用的函数中;
- 放在在最外层函数中执行;
- 尽可能放在同一个函数体中集中执行;
- 公共函数、公共组件不得带有副作用。
例如上面示例中的onFinish
函数,它是只有在表单时才会执行的,而且该函数没有返回值 ,且在执行过程中必定会修改submitResult
这个state(通常还会修改分页参数、表单Loading状态变量等),那么它就是最佳的副作用容器,这时将副作用相关的逻辑放在这个函数的最外层执行就是最合适的。
不可变性和声明式
说完了纯函数,接下来就是不可变性 和声明式。之所以这两个概念放在一起介绍,是因为这两者是相辅相成的,在实践中几乎无法分开。
首先是不可变性,它的意思就是一个变量定义之后就是不可改变的 。例如在React和微信小程序开发中,如果定义了一个state,要更新时,就必须调用setState
或者this.setState
方法,而不能直接赋值或修改。
而声明式的含义是不应该说明一件事是怎么做的,而是直接描述结果是怎样的 ,例如使用jQuery
更新网页元素就是命令浏览器应该怎么更新,而现在常用的Vue、React框架就都是描述网页元素是什么样的,当相关变量更新后,网页元素也会自动进行更新。
而如果遵循了不可变性,那么就会自然而然地使用声明式,因为当一个变量无法被修改时,那么就无法使用命令语句对其进行操作,而必须在定义结果变量时,就声明清楚它是怎样的。
简单示例
简单类型数据例子:
ts
/** 可变的数字变量(字符串等简单类型数据同理) */
let mutableNum = 1;
mutableNum = 2;
/** 不可变的数字变量 */
const immutableNum = 1;
immutableNum = 2; // ❌不可变的,不能修改
const newImmutableNum = immutableNum + 1; // ✅ 声明一个新的变量存储新值
对象类型数据例子:
ts
/** 可变的对象变量 */
let mutableObj = {
a: 1,
b: 'b',
};
mutableObj.a = 2;
mutableObj = 'mutableObj'; // ts中会出现类型报错
mutableObj.c = false; // ts中会出现类型报错
/** 不可变的对象变量 */
const immutableObj = {
a: 1,
b: 'b',
};
immutableObj.a = 2; // ❌ 虽然不会报错,但是属性的值也是不应该改变的
immutableObj = 'immutableObj'; // ❌ 不能直接赋值
const newMutableObj = {
...mutableObj,
c: false,
}; // ✅ 当需要添加属性时,应该声明一个新的变量来实现
数组类型数据例子:
ts
/** 可变的数组变量 */
let mutableArray = [1, 2, 3, 4, 5];
mutableArray.push(6);
mutableArray = [...mutableArray, 7];
mutableArray.forEach((item) => {
item += 1;
});
/** 不可变的数组变量 */
const immutableArray = [1, 2, 3, 4, 5];
immutableArray.push(6); // ❌ 不能直接往数组里添加元素
immutableArray = [...immutableArray, 7]; // ❌ 不能直接赋值
const newImmutableArray1 = [...immutableArray, 7]; // ✅ 可以通过扩展运算符创建和声明新的数组
immutableArray.forEach((item) => {
item += 1; // ❌ 不能直接修改常量数组里的元素
});
const newImmutableArray2 = immutableArray.map((item) => item + 1); // ✅ 可以通过 map 方法创建和声明新的数组
实际开发示例
以下是一个实际开发比较常见的例子,在表单组件的提交回调中,进行请求参数的组装。
命令式:
ts
const imperativeOnFormFinish = async (formValues: FormValues) => {
let requestParams = { ...formValues }; // 这里requestParams的类型被推断为FormValues,所以后续部分赋值语句会出现类型报错
requestParams.phone = formValues.phone.trim(); // 手机号,去除前后空格
requestParams.age = dayjs().diff(dayjs(formValues.age), 'year'); // 年龄,使用生日计算年龄
requestParams.sex = formValues.sex === 'male' ? 1 : 2; // 性别,1为男,2为女
// 家庭成员列表
requestParams.family = [];
formValues.family.forEach((item) => {
requestParams.family.push({
name: item.name,
relation: item.relation,
});
});
// ... 其他编辑requestParams的逻辑
// ... 其他请求点的逻辑
const res = await axios.post('/api/user', requestParams);
};
声明式:
ts
const declarativeOnFormFinish = async (formValues: FormValues) => {
/** 请求参数 类型根据字段定义自动推断,没有ts报错 */
const requestParams = {
...formValues,
phone: formValues.phone.trim(),
age: dayjs().diff(dayjs(formValues.age), 'year'),
sex: formValues.sex === 'male' ? 1 : 2,
family: formValues.family.map((item) => ({
name: item.name,
relation: item.relation,
})),
// ... requestParams的其他字段
};
const res = await axios.post('/api/user', requestParams);
};
对比这两段代码可以非常清晰地看出遵循不可变性带来了这些好处:
- 声明时类型即固定,无需额外为变量编写类型定义,更好地适配TypeScript;
requestParams
的所有属性的逻辑,默认都被约束在定义对象的{}
中,符合关注点分离原则;- 在遵循不可变性的项目中,
requestParams
默认是不可变的,所以在后续代码中不会发生任何修改,哪怕后续还有再多的逻辑,也不用担心requestParams
这个变量定义后被二次修改,降低了维护难度。
有局限的地方
不过,虽然有这些好处,但遵循不可变性也会在以下两个场景中增加开发和维护难度:
给对象动态添加属性
针对动态添加对象属性,且不能设置属性值为undefined
的情况,如果是不遵循不可变性,那就可以直接使用if判断,动态设置对象属性,而遵循不可变性,就会稍微复杂些。
不遵循不可变性的命令式:
ts
const a = 9;
let obj = {};
if (a > 10) {
obj.b = true; // 注意,由于obj的类型被推断为{},这里的赋值语句会出现类型报错
}
遵循不可变性的声明式1:
ts
const a = 9;
const obj = {
...(a > 10 && { b: true }), // 需要使用对象展开和且运算符
};
遵循不可变性的声明式2:
ts
const a = 9;
const obj = Object.assign({}, a > 10 && { b: true }); // 需要使用assign和且运算符
在这些例子中,都是为了完成同样的逻辑------当a
大于10时,给对象obj
添加上b:true
这个属性。
但是很明显,遵循命令式编写出来的代码是更符合自然语言描述的。针对这种情况,就根据实际情况来确定使用何种规范了。
对数组进行复杂操作
这也是刚开始实践FP时的常见误区,为了充分使用Array的内置方法,反而将内置方法中的逻辑复杂化。
以下示例中,需要将arr过滤到只剩偶数,并且对过滤后的每个元素取平方,添加到原有元素的后面。
不遵循不可变性的命令式:
ts
const arr = [1, 2, 3, 4, 5];
let result = [];
for (let i = 0; i < arr.length; i++) {
if (arr[i] % 2 === 0) {
result.push(arr[i], arr[i] ** 2); // 由于result的类型是never[],所以这里会有类型报错
}
}
糟糕的声明式:
ts
const arr = [1, 2, 3, 4, 5];
const result = arr
.filter((item) => item % 2 === 0)
.map((item) => {
if (item % 2 === 0) {
return [item, item ** 2];
}
return item;
})
.flat();
好的声明式,但可能有点难懂:
ts
const arr = [1, 2, 3, 4, 5];
const result = arr.reduce((prev: Array<number>, curr) => {
if (curr % 2 === 0) {
return [...prev, curr, curr ** 2];
}
return prev;
}, []);
如果是刚开始接触FP,就有可能写成第二种形式,这种方式不仅代码量更多,性能也更差(多循环了一次)。而第三种才是声明式的最佳实践,而如果看的人对reduce
不熟悉,也会造成维护上的困难。
总结
相信通过上述的阅读,相信你已经对FP的基础实践有了一定的认识,也清楚如何在日常的开发中实践了。
针对实践时的各种代码规范,后续我会再单独出一篇文章进行归纳讨论,敬请期待。