React hooks在框架编程上具有明显特征,在推广functional组件的进程中,javascript是天然具有函数式编程优势的语言,因此,react团队越来越倾向并重视hooks的应用。hooks编程之所以拥有比较大的魅力,除了它抹平class组件和functional组件在生命周期上的差异之外,更重要的是,它让react开发者践行代数效应。React核心团队成员Sebastian Markbåge(React Hooks
的发明者)曾说:
我们在React中做的就是践行代数效应(Algebraic Effects)。
本文就将从践行代数效应的角度出发,探索我们在其他场景(非react functional组件)下践行代数效应的可能性。
什么是代数效应?
在理解什么是“代数效应”之前,请先阅读一下这篇文章。为了理解“代数”,我们举一个例子:
已知: y + 2x = 9 (1) 2y + x = 15 (2) 求: x + y = ? -------------------------- 由 (1) 可得: y = 9 - 2x (3) 将 (3) 代入 (2) 可得: 2(9 - 2x) + x = 15 => 18 - 3x = 15 => x = 1 (4) 将 (4) 代入 (3) 可得: y = 7 将求得的x, y代入x + y可得 x + y = 8
这就是代数,本质上,代数是研究函数(数的关系)的科学,它的精髓在于“代入”这个动作,它的主要方法是解方程。
想象一下,上面的题,如何用程序来解决呢?不,我的意思是,你所写的程序,如何解出 3x2 + y2 = ? 甚至更多的算式?这和我们往常的编程思路恰恰相反,我们以往的编程思路,是通过不同点测算出函数,再根据函数获得另一个点,这是机器学习的基本思路。但是,我们的编程,尚未有以代数为基本的思路。
在第2个图中,已知的是1、2两个函数都通过A点,但A点具体值我们并不知道,要求出的,是任何可能穿过A的其他函数的方程。
JS原生代数效应
在本节开头引用的那篇文章里提到了,总结而言,代数效应,是让编程(代码)可以自上而下书写,但让程序的执行可以在不同函数间跳跃的编程效果。代数效应对我们编程有什么启示呢?实际上,在js里面已经有这样的编程方式:
async function doSome() { const v1 = do1() const v2 = await do2(v1) const v3 = do3(v2) const v4 = await do4(v3) return v4 }
或者:
function* doSome() { const v1 = do1() const v2 = yield do2(v1) const v3 = do3(v2) const v4 = yield do4(v3) return v4 }
以及for await...of语法,都是具有代数效应的编程方式。简单说,js中的代数效应表达方式,让我们通过await和yield语法,让程序从原有的函数执行流中,跳到另外一个执行流中完成副作用,并将副作用结果返回给当前执行流,再用这个结果进行剩下的计算。所以说,上面说的“函数间跳跃”的主要目的,是将函数式和副作用进行分离,保持函数式编程的同时,又支持副作用操作。
但是,async/await和generator函数具有传染性,它们要求所有外部编程在语法上必须采用不可替代的表示式,从而让代数效应的实现不具备普适性和通用性。
模拟代数效应的实现
在js领域,不止一人提出了新的语法以支持这种编程方法。比较典型的编程方法如下:
try { const v1 = do1() const v2 = raise '1' const v3 = do3(v2) const v4 = raise '3' return v4 } handle (e) { if (e === '1') { resume 'v2' } else if (e === '3') { resume 'v4' } }
关键字都用红色标注出来了。它和 try...catch 一样,通过 throw 抛出异常,通过 catch 捕获异常一样,在这段代码中,通过 raise 抛出代数陷阱,通过 handle 捕获陷阱,在捕获块中应对(处理)陷阱,通过 resume 跳出陷阱,将处理结果带出陷阱作为值继续执行 try 块中的剩余代码。它和 try...catch的区别仅仅在于try...catch一旦throw,后续代码就不会再执行。而try...handle不仅可以持续执行至代码块结束,而且由于resume的使用可以是随意的,所以在handle中可以写异步操作,从而在无await/yeild的情况下,让异步操作变得更加像同步操作。
除了在形式上的新颖有趣,更重要的是实用性。
在以前,我们要为一个函数提供某种修改的能力,我们也会尝试类似的方法,例如:
function calcZ() { const x = calcX() const y = calcY() const z = x + y return z }
我们有这样一个函数,我们将这个函数提供给其他人,他就可以用它来计算z的值。对于使用它的人而言,需要提供calcX和calcY函数,这样,他就可以自定义x和y的计算逻辑。这在简单全局场景是可以的,但在模块化的今天,则不可行,除非要求写全局的calcX和calcY函数。
但通过try...handle则让这个自定义x和y的计算逻辑变得简单:
function calcZ() { const x = raise 'x' const y = raise 'y' const z = x + y return z }
对于使用者而言:
try { const z = calcZ() } handle (e) { switch (e) { case 'x': { resume 10 break } case 'y': { resume 15 break } } }
甚至,在handle中进行异步操作:
try { const z = calcZ() } handle (e) { switch (e) { case 'x': { setTimeout(() => { resume 10 }, 1000) break } case 'y': { resume 15 break } } }
这样,对于原始函数calcZ而言,它完全是同步的,但我们却可以异步的获取x的值,在异步求x值后,代入原来的函数中继续执行后续计算。
Hooks中的代数效应
既然hooks的发明者Sebastian Markbåge说hooks在践行代数效应,那么我们是否需要换一种思维,去理解hooks的运行原理。
function App() { const [num, updateNum] = useState(0) return ( <button onClick={() => updateNum(num => num + 1)}>{num}</button> ) }
我们将上面这段代码进行翻译:
function App() { const num = raise 'state_num' return <button>{num}</button> }
我们去掉干扰信息,得到上面这段简单代码,而hooks帮我们在react库内部完成了如下工作:
function render() { let state_num // 建立了一个变量用于缓存 try { return App() } handle (e) { if (e === 'state_num') { return typeof state_num === 'undefined' ? 0 : state_num } } }
这就是hooks在践行的代数效应。将上面的代码进行扩展,我们可以非常方便的再写出updateNum的实现方式。
当然,除了hooks,Suspense还有Reconciler,都是对代数效应的践行,它们本质上就如我前文所说,在正常的程序流程中,允许我们停下来,去做另外一件事,做完之后,我们可以再从被打断的地方继续往下执行,而另外的那件事,可以是同步的,也可以是异步的,理论上,它的执行过程与我们当前的流程无关,我们仅关心(或根本不关心)它的结果。
类hooks编程
React hooks在实践代数效应,我们能否在其他环境下(非react相关)也仿造hooks的思想,践行代数效应?问题的关键点在于,js并没有try...handle语法!
我希望创建一种类hooks的编程。例如:
function calc() { const x = get('x') const y = get('y') return x + y }
如何实现呢?我们可以再提供一个接口,让开发者规定get('x')要做什么事情:
define('x', function(set) { setTimeout(() => { set(100) }, 1000) })
看,当执行get('x')时,define的第二个参数会被执行,这个参数,就是try...handle中的handle部分。但是,calc是同步的,而获取x的过程是异步的,怎么办呢?再计算一次!
setup(function() { const z = calc() })
当set(100)被执行时,setup内的函数再次执行,这样,z就可以被再计算一次,而此时get('x')的结果为100。
结语
我最近写了一个新的小项目Algeb,我试图用代数效应的方式,去推翻我之前写databaxe这个库时的思想。我在这篇文章中提出了服务层的构想,但没有给出实现。我在新库中,仍然延续“数据源”这个概念,同时也引入hooks的思想,对于数据源而言,它是固定的,它将从api接口获得某一个源的具体数据,但是,在使用时,却可以是同步的写法(类hooks),通过“再计算一次”的方式,让数据的下游(视图层)对数据的变化进行响应。当然,这还只是一次尝试。我看到有人提出了了try...handle的议案,或许未来不久,我们就可以在es中原生支持这种方案了。
这个文章写的最容易懂了