目前来说,虽然编程语言众多,但编程范式相较来说却少得多,甚至多个范式只见仅相差几个概念,不过,在前端,有三个编程范式是一定去了解的:
命令式编程:是过程式编程的一种,是入门的时候大家都会写的,他的特点是模块化,实现过程中使用了状态,并且依赖外部变量,可读性较低,后期维护成本也较高。
面向对象编程:简称OOP,可能在入门之后,知识进行积累,也接触了不少热门框架,因此开始使用继承等特性,面向对象编程核心是抽象,提供清晰的对象边界,结合封装、集成、多态,降低代码耦合,提升了系统可维护性。
函数式编程:简称FP,在这一步,所经历的并不是平滑曲线,而是不一样的思维模式了,函数式编程核心在于避免副作用,不改变也不依赖当前函数外部的数据,通过不可变的数据、函数第一公民等特性,使函数自带描述性,可读性较高,维护性、复用性也较高。
目前前端起手式基本是左手命令式、右手面向对象、偶尔封装一下,但这样真的是唯一解吗?甚至有的时候,这套起手式也遇到不少复制粘贴的逻辑,即使将这逻辑进行封装了,可读性也变得很差,甚至遇到一个新的类似逻辑,依然需要更改这套被封装的逻辑。这个时候,可能需要了解一下函数式编程了。
纯度
接触到函数式编程的时候,我们经常遇到"纯度"、”副作用“等名词,甚至副作用与优雅挂钩,比如”这个函数有副作用,最好提纯一下,否则不够优雅。“
因此纯函数,是函数式编程的最大的前提,也是他的基础。
纯函数具有两个特征
对于相同的输入,总是会得到相同的输出
执行过程中不会存在语义上的副作用
什么是副作用
副作用是在执行函数的时候,除了返回可能的函数值以外,还会对主调用函数产生其他的影响。
也就是说,当这个函数被执行的时候,他所做的就是根据入参返回值就行,除此以外对上下文、环境等增删查改都是不纯的表现,都是副作用。
举个例子
存在这个函数
这个函数接收x,y,然后返回相加结果值。
他符合纯函数的两个特征,相同的入参,返回相同的值,即使x 、y不是数字类型而是其他类型,重复多少次,他们都会重复返回多少次相同的结果。而不是第一次是1,再执行一次变成了2,再执行一次报错了。
他们没有任何副作用,放在代码任何地方执行,都不会修改已有的逻辑、上下文。
那么什么是不纯的函数呢?
这个函数把非纯函数的特征基本都实现了一遍。
他没有入参,返回值全凭外部变量,因此当外部变量改变的时候,他会返回不同的值,同时,网络请求函数也是同外部变量一样,是动态的非固定的,后端不一定返回相同的值
甚至网络请求失败导致了Error,未经捕获就输出到控制台,造成了副作用,并且还把结果赋值给函数外的变量,也修改了上下文以及宿主环境。当未执行add的时候c是0,当执行了add的时候,c的值不确定起来。
不确定性是不纯函数的最直接的问题,因为我没法确定当前函数能否复用,甚至不复用,更改外部某个变量会不会让这个函数产生意料之外的结果。
上面这个例子很容易看到外部变量的依赖,试想一下,假如当前函数用了10个函数,每个函数内部一层套一层调用函数或者类来实现某个计算结果,其中某一层正好依赖一个外部变量,而这个外部变量被看起来完全不相关的另一个函数的内部实现更改了,好吧,灾难开始了,找问题都够头大了。
同时副作用引起并行计算带来的竞争问题,在前端可能很难遇到并行计算的问题,但的确存在这种问题,试想一下多个函数同时向一个文件进行写入,那么这个文件就处于薛定谔状态。
概括一下:不确定会导致我们的代码难以调试、数据变化难以控制、计算逻辑不能复用的问题,这不是优雅不优雅的问题了,这个就是BUG的巢穴,万恶之源。
纯函数
消除副作用,就能消除不确定性,也能消除并行计算的问题,因为他的计算总在内部,不会对任何外部自检造成影响。
因为他的灵活性,我们可以将他移动到任何位置,他的计算逻辑在任何上下文都是成立的。那么从目前来说,纯函数简直是万能的解药,然而事实真的是这样吗?
很显然不是的,因为无论如何纯,我们总要跟副作用打交道,因为我们无论如何都要渲染页面、无论如何都要发送接口,因此,我们使用纯函数,并不是完全消灭副作用——除了部分纯计算的库以外这几乎是不可能的。我们使用纯函数,是为了将计算逻辑和副作用进行解耦,从而提高代码的质量。
函数第一公民
什么是函数第一公民?实际上可以换个说法——函数具有特殊待遇。
那么究竟是什么特殊待遇呢?
当一门编程语言的函数可以被当作变量一样用时,则称这门语言拥有头等函数。例如,在这门语言中,函数可以被当作参数传递给其他函数,可以作为另一个函数的返回值,还可以被赋值给一个变量。 ——MDN First-class Function
从这里可以看出,特殊待遇就是:
可以被当做参数传递给其它函数
可以作为另一个函数的返回值
可以赋值给变量
细一看没什么,可能会在心里说这不是理所当然的吗?为什么这就是一等公民了?在前端是这样的,基本所有类型都是一等公民,但在其他编程语言中,存在二等公民和三等公民类型,不管这不妨碍我们理解一等功能的概念,那就是高度的自由度!
不可变数据
我们都知道前端具有值类型和引用类型,因此他们的区别我们不再赘述,但是回过头来,这个不可变数据指的是什么呢?是具体数据还是内存地址呢?很显然,不变的内存地址对我们写函数式变成没有多少增益,因此不变的是数据指的是具体数据。
从特征上来说,值类型都是不可变的数据,从赋值开始,他们的数据无法再被修改,直到再次被赋值。
但引用类型就不同了,在引用本身内存地址不变的情况下,引用所指的内容是可以发生改变的。
函数式是不喜欢可变的数据的,这个对react来说刚刚好,但对vue来说有点不同了——Vue拥抱可变数据,并称之为响应式数据。
因为内存地址并没有变化,因此大多是我们没有察觉的时候进行了修改,然后造成了类似副作用的风险,甚至比副作用的风险还要高,因为他可能通过入参来传播。
例如我们修改了函数的入参——这个入参一般是对象或者数组一个引用类型,而这个入参还要传入其他函数的时候,那么当前函数的逻辑实现了,但可能让其他函数的结果失真——即使其他函数都是纯函数,因为入参被当前函数修改了。
getName是一个纯函数,但是入参被其他业务逻辑需改了,newObj本意是创建一个新的对象,但实际是只是修改了原对象并返回,从此数据源就被修改了。
我们没法把保证数据是否已经被改变了,因为甚至改变数据的逻辑不是我们自己写的,而是协作者甚至是第三方包里面。
可变数据的出现让函数的作用边界变得模糊,并且复用成本大大提高,在大部分情况下,我们默认纯函数是一个黑盒,我们不关心里面如何实现的,你只要根据我的输入,给予我想要的输出,然后不要碰代码其他部分,执行后我的代码和数据依然是之前的样子。
因此不可变的函数的本质,是坚持了纯函数,没有产生副作用。
compose / pipe
我们已经了解到了纯函数,那么我们来了解一下函数式编程的应用,如何将纯函数利用起来,首先来出一道题,你知道compose或者pipe怎么实现的吗?
想要或者解法,就要先明白什么是compose、pipe。
如果一个值,要经过很多函数,才能变成另一个值,我们可以把中间步骤合成一个函数,这个就叫做compose,而pipe也是相同的定义,唯一区别是,compose的参数执行是从右往左,而pipe是从左往右。
那么如何让很多个函数一个接一个执行呢,当然,第一个想法还是命令式,的确,这样可以让函数一个接一个执行,那么我们来加个限制:函数不定个,甚至需要执行的函数都是外部传进来的。
不定个、一个接一个、有且长度不限,这不就是数组的遍历吗!
是的,我们可以把这些传进来的函数放进数组里面,然后用循环遍历这些函数,让他们进行执行。
foreach ?不,我们忽略了pip的一个前提:一个值要经过很多函数,以pip来说就是这个值是第一个函数的入参,而他们的返回值是第二个函数的入参。
什么数组的方法可以应用上一个循环的结果?没错是reduce。
好的,目前我们的推断结论就是,将很多个待组合的纯函数放到数组里面,然后调用数组的reduce方法,就可以创建一个多函数组组成的工作流。
恭喜你,世面上的主流函数式库里面,compose、pipe也是这么实现的。
好的,让我们来写个pipe。
目前来说,这个pipe没什么太大的缺陷,同时compose是pipe的反向,那么我们可以直接使用reverse来调转吗?当然不行,因为reverse会修改原数组,因此需要将数组进行克隆或者生成一个新数组。所以我们直接用reduceRight,就可以得到compose了。
看到这里大家可能不以为然,这有什么用呢?我们知道,面向对象的核心在于继承,而函数式编程的核心是组合。组合就像是工厂流水线一样,可以在流水线上放各种各样的纯函数,然后得出我们想要的结果。
让我们回到pipe函数上,这个函数是pipe,但是功能还不够强大,比如我往funList里面压入的函数的入参都大于1,从目前来看,pipe的参数只支持1个,如果压入的参数大于1怎么办呢?
我们来思考一下,只需要将压入的函数提前包装一下,提前传入已经确定的参数,这样应该可以正常运行。
看起来也没问题,不过如果这样搞之后很难复用,因为如果我想a,b都被确定的话,我就需要再新建一个函数了,显然之后的维护成本就变高了,那么我能不能直接将这个函数解构,想要几个参数自己传几个参数,返回一个需要符合compose、pipe需要的函数。
答案是肯定的,并且我们这里就接触到了一个概念——柯里化。
柯里化和偏函数
柯里化可以让n元函数变成n个一元函数,也就是套娃,让一个函数不断的返回一个函数,我们直接上例子。
那么什么是偏函数呢?我们其实已经早早接触到了,上文的_multiply就是一个偏函数,他不像柯里化的函数一样,函数增多(增多了参数个),而是函数一直不变,只是参数被固定了,入参减少了。
我们可以看到addThreeNum函数柯里化之后,变成了三个函数addA、addB、addC,他们都是只接受一个参数,并且在最后一个函数的参数补充上调用后才调用一开始传入的函数体。但这个是不够的,因为他只返回下一个参数的一元函数,无法自动递归柯里。
那么我们就让他自动递归。
观察一下柯里化,我们发现他有几个参数就递归几次,每次的入参都是当前递归的参数。那么获取函数的递归次数至关重要,幸好,我们有对应的api —— Function.length,对应的正好是入参的个数。
个数获取到了,思路也有了,我们就可以得到以下函数
当前函数可以自动对目标函数柯里化,我们可以那几个算术函数来试一试。
符合预期
范畴论
终于进入了正题,范畴论是一门数学学科,范畴是指彼此之间存在某种关系的概念、事物、对象等等,范畴成员之间称之为态射。
简单的理解就是"集合 + 函数"。
本身是数学上的概念,但是很巧,可以用来写程序。数学上的函数要求求值,不做其他事情,这也就是函数式编程为什么要求使用纯函数来编写。否则,就违背了函数最初的运算法则。
回过头来,我们看一下前面的compose和pipe函数,发现里面其实还是有不足的,之所以是不足而不是缺陷因为还没有严重到这种程度,那就是他的入参是固定的,我必须在入参的时候给定一个顺序。
比如上文中的add和multiply以及再加上一个divide运算,需要add再add,之后将结果带入其他纯函数,根据返回值来判断是divide还是multiply。
好吧,这显然不是一个pipe能解决的,因为这里面用到了返回值,而pipe的返回值只有计算结束的时候才会返回值。
那么有没有什么方法呢,我们回顾一下需求,发现这个需求跟之前的某些业务逻辑很相似:通过axios发送接口,根据两个接口的数据再判断发送哪个接口,获取最后一个接口值,而axios是怎么实现呢?是Promise,或者说——链式调用。
是的,链式调用可以满足我们的业务逻辑,而从jQuery时代的链式调用实现原理就没进行大的变动——都是返回自身,或者说是更改了自身上下文的自身。
那么,一个纯函数,唯一可以变的是什么呢?就是他的入参,也就是说,我们需要实现一个链式调用,这个链式调用返回更改了入参的自身。
我们模拟Promise的链式调用,以及结合自身的需求,产生了一个新的pipe,而我们使用的时候就可以通过链式调用来一步步求值,最后通过res来获取结果。
符合预期。
我们通过组合不同函数,甚至可以异步组合,来获取最后的结果,巧了,范畴论就很擅长进行函数的组合。
那么回到范畴论,将两个范畴关联起来的数据类型称为函子。什么是函子?
函子
函子是基本的运算单位和功能单位。或者说,一个函子是一个可以被处理的东西,比如一个变量、一种类型、甚至一种结构。但是还没完,还有后半句话,联系起来,显而易见,里面得有一个具体实现才能将两个范畴联系起来,比如我们上面实现的then方法。不过用then方法的话似乎有点偏离语义,我们细细品一下,实际上then实现的并非只有暂时保存结果,而是将当前结果的值映射为下一个函数的入参。既然是映射,那就是map。
因此我们将整个实现替换一下名称
因此我们可以说,我们实现另一个函子,也就是Functor,他自身是一套行为框架,内部的数据变化在定义的时候都是不可知的,只有他的行为模式是预定义的。
而我们往这个Functor增加其它的方法,我们就可以说实现了不同的Functor。
我们思考一个情况,假如前一个函数的返回值是null或者undefined,那么这个空值会被当做下一个函数的入参穿进去,但实际上下一个函数并没有义务处理上一个函数的空值,因此不出意外地出意外了,空值显然没办法做加减法。
那么我们在函子里面进行处理怎么样。
每次x被传进来的时候,会及时打断链式调用,不会将当前参数传入下一个函数,好的,恭喜大家,我们实现了MayBe Functor。
目前来看这个像啥,rxjs?没错,他就是基于这个模型的基础来构建的。
单子
大家可能以为单子Monad是一个新的东西,实际上他跟Maybe Functor一样,是函子的扩展,他实现了flatMap,或者说实现了flatMap的函子就是单子。
那么他有什么用呢?我们在上文的Maybe Functor基础上思考一种情况:Maybe最后取值的时候,都要调用一次res,但是如果Maybe最后一个函数也是一个Maybe呢?
我想获取最终结果,需要调用res().res(),甚至如果不是最后一个函数,而是链式调用中间某个函数是一个Maybe呢?那么下一个函数进行调用的时候会出问题的,因为下一个入参不是一个预期中的数据,而是包装了数据的Maybe。
那么如何不破坏链式调用的前提,将Maybe的map的值拿出来呢?换一句话是如何实现flatMap呢?
我们思考一下,引起两个res()调用的原因,是因为往下一个单子里面传了,那么如果我不往下一个单子里面传不就好了!
如果下一个函数是函子,我们使用flatMap来处理这个函子,在flatMap中,由于f是一个Maybe,因此返回值也是函子,也存在map方法,所以他的链式调用不会断掉。
半群
半群,也就是Semigroup,我们来了解一下他的定义:在数学中,半群是闭合于结合性二元运算之下的集合 S 构成的代数结构。
好的,看不懂,我们直接上例子
他们就是一个半群,半群满足两个条件
封闭性,也就是闭合的,什么意思?就是运算前是什么类型,运算后还是什么类型,比如一开始布尔值,运算后还是布尔值,数组和字符串concat后还是原来的类型
结合律,结合律小学就学过,就是(a·b)c = a·(b·c),在上文中布尔值、数组、字符串也都符合
同时需要注意的是这里是二元操作,什么是二元操作?我们看到上文数组和字符串中,concat必须要有上下文以及入参才能调用,上下文和入参共同构成了二元运算的二元。
而上面已经给我们演示了js中的concat是一个半群实现,那么我们自己实现一个加法半群
这个加法实现了类似函子的逻辑,将运算逻辑都包裹进递归,最后返回一个新的半群,concat是他的核心,他消化任何半群运算,同时他存在取值操作(.value),意味着他的参数类型就是一个半群,换句话说,他可以将两个半群链接、串联起来。
同理,我们可以更改运算符,可以创造乘法半群。
幺半群
看到这里,可能会抱怨,怎么又来一个新的概念。
别急,这其实不是一个新的概念,正如单子是特殊的函子一样,幺半群,也就是Monoid其实是一个特殊的半群,他的改动很小——他拥有一个幺元。
什么是幺元?幺元的特点是他和任何运算数相结合的时候,不会改变运算数。我们一般叫他空值。
比如说Add半群
每个运算半群的空值实现方式不一样,比如乘法半群就是
说白了是,算了,但也没算。
也就是semigroup + 空值 = Monoid
那么这空值有什么用呢?
很简单,我们看半群的起点,都是有一个手工规定的起点,比如Add(1)或者Multi(4),但是如果我也不知道起点是什么,总要有个默认值吧,这个加法和乘法运算,自己还能手写出起点,但更加复杂的运算,自己还要去看实现代码来写一个起点。
这个起点让半群自己实现不好吗?
所以幺半群的幺元可以作为他的起点。
看到这里可能惊呼,就这?对,就这。
当然,概念就这,我们前面说了,重在组合。概念已经了解了,现在需要相互组合了。
之前我们说到了reduce,那么Monoid能放到reduce里面吗,答案是肯定的,甚至reduce的初始值就可以直接用幺元。
那么再发散一下,如果[1, 2, 3, 4]也是Monoid组成的数组呢?
简单改造了一下,还可以达到目的,但是最开始的 [Monoid(1), Monoid(2), Monoid(3), Monoid(4)]不够优雅,我们直接用map来改造一下
但这样写就有问题了,又变回命令式了,我们需要改造成函数式,仔细观察,上面的可变的就是arr和Mulit,那么他们就是函数的入参,然后我们把剩下的都塞到函数体里面,再加个名字,嗯,就叫foldMap吧。
至此,恭喜你,实现了函数式编程中的常用高阶函数——foldMap。
到这里,大家应该理解了,范畴论或者函数式编程的基础,就是组合。
那么说起组合,就说说compose了,他和Monoid有什么共性呢?
compose显然也属于闭合的二元运算,compose并没有改变运算数的类型,因此也算闭合,同时compose也有两两组合的特征。同时compose也符合结合律
那么我们应该可以认定compose就是半群,那么是不是幺半群呢?他可以是,因为是不是幺半群就看有没有幺元。
我现在定义了一个幺元,好了,现在他是了。
所以我们证明了,compose就是Monoid,或者说,compose是可以组合成Monoid。
换句话说,范畴论或者函数式编程的基础,就是组合。
面向对象和函数式编程
这个问题我们终究是绕不开,与其说是不同的范式,倒不如说两种不同的宗教,或者说世界。
面向对象
面向对象中,对象是一等公民,面向对象思想来源于现实世界的观察,或者说事物模型的描述。通过寻找事物之间的共性,来抽象出对一类事物的描述。
既然是事物,那么名词就会占据主导,因此我们在面向对象中,关注的是结构、联系,而不是行为本身。
函数式编程
在函数式编程之中,动词占据了主导,如果我们看一些经典的函数式编程项目,会发现基本上对行为的描述,也就是说,在这里,数据不是主角,而是围绕数据展开的行为才是主角,行为就是函数,这些行为组合起来,进而组合成一个复杂、强大的大行为。就像人一样,眼睛接受光子,然后身体做出动作。
代码重用
举个例子,我要开发一个超人游戏,里面也有普通人,按照面型对象的思路,我们需要使用继承,那么我需要定义一个Person,然后让SuperMan extends Person,我们在SuperMan里面实现fly,fast,power等方法,同时在logo里面塞入了一个大大的S图像。
看起来没什么问题,好的,现在我们进行需求追加。
我们增加一个坏人,他原来也是一个普通人,但他有超人的力量和过人的智慧。
好的 ,badMan extends Person,我们来实现了他的power方法,同时实现了wisdom方法。
看到了吗,power没有被复用,明明跟超人的一个样,但power并没有办法复用。
什么?badMan extends SuperMan就好了?不,坏人不会飞!
好吧,我知道了,我使用SuperMan extends badMan,这样总该可以了吧,当然不可以,我们这一版的超人可没有wisdom方法。
为了实现需求,我们只能SuperMan extends badMan,然后把SuperMan的wisdom返回Person.wisdom
需求终于实现了。
好的,版本更新了,我们加入了新角色,闪电侠,闪电侠拥有超人的fast,但不会fly,power也是普通人的power,那么闪电侠这个class应该继承谁呢?为了让闪电侠参与继承,我应该修改哪个class呢?
显然我们这个时候依然坚持继承,就有点守旧了,不妨使用函数式编程,我们把fly,fast,power,wisdom抽象成一个个纯函数,当一个对象——是的,当然是对象,因为这里不是非黑即白,而是哪个合适就使用哪个,当一个对象需要显示超人模板的时候,我们只需要拿普通人的模板,往上面挂载fly,fast,power,方法就好了。
JS 语言非常特别,它的对象和函数之间没有特别清晰的边界,函数和对象都可以视作是一等公民(甚至函数本身就是一种可执行的对象)。在项目中混合使用多种范式开发,对于我们来说是极度正常的一件事情——即使选择了 FP 作为程序的主要范式,仍然免不了要使用对象这种数据结构;即使选择了 OOP 作为程序的主要范式,也避不开函数这种抽象方式。因此我始终认为,OOP 和 FP 之间并不是互斥/对立的关系,而是正交/协作的关系。
RXJS&React Hook
RXJS
我们在前面提了一下rxjs,来回顾一下他的定义
RxJS 是一个在 JavaScript 中实现响应式编程的库,它利用可观察序列(Observable)来表达异步数据流,并通过一系列的操作符(Operators)来对这些数据流进行转换、筛选和组合,最终实现业务逻辑。
尽管rxjs用起来跟函数式编程很相似,但他们是不同的,因为rxjs遵循的是响应式编程。
函数式编程强调的是函数的组合和变换,通过将复杂的问题分解成小的函数,再将这些函数组合起来,达到解决问题的目的。函数式编程中,函数是“一等公民”。
响应式编程强调的是数据流的变化和响应,它将复杂的问题抽象成一个数据流,通过对数据流进行变换和响应,达到解决问题的目的。响应式编程中,函数仍然是“一等公民”,但它更强调对“数据流”的关注。
假设大家对rxjs已经部分了解了,我们回顾一下,rxjs不少地方提到了数据了。没错,响应式编程关注数据了。大家可以把响应式编程看做函数式编程的分支。
当使用 RxJS 时,我们经常会遇到需要在异步数据流中执行副作用的情况,这时 RxJS 就会使用 Monad 来处理这些副作用。这里也就引出了 Monad 的“另一面”:把“副作用”放进盒子——我们可以将具有副作用的操作封装在 Monad 中,以便于隔离其它函数对副作用的关注。
什么意思?
我们知道在 RxJS 中,Observable 负责生产数据,而 Observer 负责消费数据。
生产数据会涉及到副作用吗?会,因为数据可能是从接口获取的。
消费数据会涉及到副作用吗?会,因为数据可能要渲染到页面上,或者发起新的接口。
那么既然是函数式编程的一种?纯函数在哪呢?
纯就纯在那些夹在 Observable 和 Observer 之间的操作,例如 map 、 filter 、 merge 等等,这些操作专注于数据的计算,并不关心数据的来源和去处、不涉及外部环境,因此它们总是纯的。
这也就是说,RxJS 背靠函数式编程的思想,在 Observable 和 Observer 之间架起了一条“函数管道” 。生产端 Observable 将数据“发射”出去后,数据首先会经过这条“管道”,在“管道”中完成所有的计算工作后,才会抵达消费端 Observer。
对于 RxJS 来说,想和外界发生交互,只能通过管道的首尾两端(也即生产端、消费端)。管道内部是由纯函数组成的,这就保证了整个计算过程的可靠性和可预测性。同时,通过这条“管道”,生产端 Observable 和消费端 Observer 被有效地分离,实现了高度的解耦。
React Hook
我在前面的不可变数据提到了React,React要求开发者遵循不可变数据,意思是数据一旦被创建,就不能被修改,只能创建新的数据来实现。
众所周知,React 的FP是吃数据吐UI的纯函数。
唉?这里可能有人说矛盾了?吐UI还会有纯函数?这里的UI并非DOM,而是虚拟DOM。
纯函数意味着确定性,意味着严格的一对一映射关系,意味着对于相同的数据输入,必须有相同的视图输出。
在这个映射关系的支撑下,对于同一个函数、同一套入参来说,组件所计算出的视图内容必定是一致的。也就是说,在数据没有发生变化的情况下,React 是有权不去做重计算的。这也是我们可以借助Pure Component 和 React.memo() 等技术缓存 React 组件的根本原因。
但有的人还有疑问,React Hook的使用,应该不会让React的纯函数不纯了吧,因为函数拥有了自己的状态!
上面的代码中App每次被数据改变而加载,age都是不同的,那么App不就不纯了吗?因为对这个函数组件来说,即便输入相同的 props,也不一定能得到相同的输出。
确实,从这个角度来说没错,但众所周知函数是没有记忆的,所以useState的状态是函数外维护的,并且因为React的特殊处理,使用了hook的函数变成了偏函数,就如同下面的代码
从下面的代码来说,App是不是又纯了起来,当state变化的时候,Wrapper会触发App的渲染,state也是入参的一个,而非是被其他地方随意修改的副作用。
Hook 对函数能力的拓展,并不影响函数本身的性质。函数组件始终都是从数据到 UI 的映射,是一层很纯的东西。而以 useEffect、useState 为代表的 Hooks,则负责消化那些不纯的逻辑。比如状态的变化,比如网络请求、DOM 操作等副作用。他们都被包装到函数外层,与当前函数本身解耦了。
小结
我们学习了函数式编程,学完之后还是感觉空空的,这个是正常的,因为本文讲述的是编程习惯,是一种潜移默化的东西,而不是编程快餐,当自己潜移默化开始封装纯函数,杜绝副作用的时候,函数式编程自己已经入门了,当然未来的路还很长,需要学的东西有很多,我们依然在路上。