揭秘Vue-3.0最具潜力的API
尤雨溪昨天发布了 Vue Function-based API RFC,说是 3.0 最重要的 RFC。
文章发布后,引起了许多人的讨论和争执。
有人表示喜欢和赞赏,有人却表示“这不就是抄 React 吗?我干嘛不直接学React去了”。
从个人角度,相比 vue 之前的 class-component 提案,我更欣赏现在的 function-based 模型。表面上看它好像是 react-hooks 的翻版。其实它跟 react-hooks 走的函数增强路线不同,vue-hooks 是一个 value 增强的路线。
function 强化跟 value 强化,是一个能力相当的对偶模型。一个是 a -> data ,另一个则是 data -> a。后者也是现在函数式研究的一个方向,叫 codata。
react 路线:如何从普通的 value 中,通过函数管道,输出一个 view。
vue 路线:如何从一个特殊的(响应式的)值中,衍生出普通的值以及 view。
今天我们要揭示的,不是上面那个最重要的,而是最具潜力的、最能表征 Vue 路线的 API。
众所周知,Vue 当年的核心竞争力之一就是使用 ES5 的 Object.defineProperty 的 getter/setter 改良了当时的 MVVM 使用脏检查或者 get()/set() 函数。如今基于 ES2015 Proxy 升级成了新的 reactivity api。
它就是 Advanced Reactivity API,Provide standalone APIs for creating and observing reactive state。(注:请先大概看一下 API 介绍,它是理解后续内容的基础)。
某种意义上,vue 暴露的内部 api(reactivity api)比 react 暴露的内部 api(hooks),具有更强的表达能力和普适性。它比 react 更完整,因为 value$ 既可以衍生出 state$ 也可以衍生出 view$,它自带了状态管理和视图,且两者是无缝对接的。
react hooks 只能借鉴思路。在别的地方使用时,要去重新实现,是一种模式。而 reactvity api 可以直接作为 library 来用。比如,拥有了这个 API,我们可以实现出类似 cyclejs, rxjs, immer, react-hooks 的特性。
那么问题来了,vue 3.0 还没有发布,我们没有代码,怎么演示和证明 reactivity api 可以作为 library 来用呢?
哼哼,这个难不倒我们。
我们来亲自手撸一个简单的 vue 3.0 reactivity api,不就行了吗?
具体如何实现,不是这篇文章的重点,按下不表。如果你等不急看代码和效果,可以点击原文访问DEMO(我基于 reactivity-api 实现了 counter 和 todo-list 效果)。你会发现 reactivity.js 已经被编译和压缩过了,可读性很低。这是因为,最近前端社区有一些不良风气,一些小朋友,从各处抄了一点代码,就觉得实现了 vue/react 的核心。过分自信的在四处发表错漏百出、富有偏见的观点。因此我们特意做了一下处理,增加点抄袭成本,反正这不妨碍我们此次的演示目的。
如何实现 cyclejs-like 的 reactive-view
首先实现一个 watchable 函数,可以将任意对象或数组,变成可 watch 的,它有第二个参数,options,其中 options.map 决定 set 阶段时如何储存到 target。
state 采用递归的方式,将整颗树都 watch 起来。value 则只 watch value 字段。
尽管 vue 被认为不够 fp,不过我们其实可以插上一些 fp 的翅膀,比如将 value 视为 monad,实现 fmap, ap, bind 等函数。
fmap 是基于一个 watchable a,和 a -> b 的 f 函数,构造一个 watchable b 对象。这里简单 watch a$,然后在赋值给 b$ 的阶段,调用 f(a) 构造新的值即可。
ap 则是 watchable f 和 watchable a,构造一个 watchable b,b 是 f(a) 的产物。
bind 是 watchable a 加上类型为 a -> watchable b 的 f,实现基于上一个 value 的值构造下一个 watchable 的功能。
至此,我们有了 state, value, 两个构造函数,有了 watch 监听函数, 有了 fmap, ap, bind 基于 value 构造下一个 value 的基本操作。
实现 reactive view 用不到 computed,因此我们没有去实现它。
vue 跟 rxjs 这种特殊的值,可以直接衍生出 view。首先实现一个 combinaLatest([value$]) ,得到一个在 value$ 范畴内构造数组的方式,然后通过 [[key, value]] ,从处理数组的方式中,配合 fromEntries 衍生出 value$ 层面构造 object 的方式。而 virtual-ui-model 就是用特定的 object 表征 ui 对象。因此,我们基于 object$ 可以实现 view$,它代表了一个在时间序列中动态输出的视图流,并且因为 combinaLatest 自动复用未变化的值,使得 view$ -> view 输出的结构,总是结构共享的,利于 diff 算法。
实现 combineArray:如果一个数组里存在一个 reactive value,那么它也返回一个 reactive array,每次输出一个纯数组。如果数组里不包含 reactive value,它什么也不包装,直接返回该数组。相当于 Promise.all(list),只不过它有可能不返回 promise/reactive-value。
有了 combineArray,可以实现 combineObject,正如前面说的,就是 entries和 fromEntries 的转换。
再封装一下,得到一个 combine 函数,可以将任意结构,构造成 reactive-value,只要子结构了包含 reactive-value,它就 wrap 成一个整体。
现在我们除了 vue-like 的 reactivity api,还有 combine 函数了,可以去 combine react-element 了。为什么是 combine react-element ?因为我们就是要证明 vue 3.0 的 reactivity api 可以作为 library,脱离 vue 来用。因此就用在其竞争对手 react 身上(其实是因为我比较熟悉 react)。
我们会将 jsx 的编译函数从 React.createElement,切换成我们自己构造的 createElement。
createElement 将可能包含 reactivity-value 的 type, props, children,给 combine 起来。检测到 component 用 monad 的 bind,此时我们将组件描述为 bind 的 f 参数。检测到 element 我们用 functor 的 fmap,将 props 映射成 react-element。
最后,实现一个 map 函数,用来 map 一个 reactive-value,既支持数组,也支持非数组。
准备工作做好了,把它们 import 进来。
回顾一下我们的 combineArray 是如何更新的,它不是直接赋值,而是先浅拷贝,再赋值。
这意味着,它总是返回 immutable-list,因为它跟 immer 一样 copy-on-write。
我们免费得到了一个行走的 immer,不需要 produce 包裹。combine 一下,然后随便改,watch 函数都会拿到结构共享的 immutable data。
如果没有实现这一点,combine react-element 时,子树直接被修改,react 进行diff 时检测不出来子树有变化,就不会去更新视图了。
现在可以实现一个 Counter 组件试试。
看这个代码,是不是觉得非常有趣?既像 vue 那样可以用 js 赋值操作,又像 react-hooks 那样的形式,还像 cycle.js 一样在组件内部可以操作 reactive value。
它怎么做到自动更新视图的呢?
因为 let count = value(0),它是一个 reactive-value。它被 handleIncre 和 handleDecre 修改,它同时用在了 jsx 里。我们的 createElement 会检测到这个 jsx 结构里包含一个 reactive-value,因此它会被整个 combine 起来,成为一个大的响应式的值 view.value。
前面我们将 jsx 编译从 React.createElement 切换到我们的 createElement 函数,因此 <Counter /> 组件不是返回 react-element,而是返回我们的 reactive-value,它是一个响应式的值,可以被 watch。我们 watch 这个 <Counter />,然后拿到它真正的 react-element,再用 ReactDOM 渲染到 root 节点。
看起来像下面那样。
只支持一个 counter,看起来可能是一个特例,我们可以再实现一个 todo list。
TodoApp 组件里构造一个 reactive-state,然后传递给 TodoInput 和 Todos。
TodoInput 里构造一个 reactive text,作为局部状态,绑定到 input 元素。
点击 add 按钮时,构造一个 todo,直接 push 到 todos 里即可。
其它用到 todos 的地方,会自动检测到 todos 变化而进行局部渲染。比如我们的 Todos。
它通过 map 函数,将 reactive todos 映射成 Todo 组件,每当 todos 变化时,这个 map 函数就会自动再次执行,然后 top-level 的 app 就会拿到一个 immutable vdom,除了 todos 以外,其它结构复用原来的引用。
Todo 里面很简单,就是展示一下,支持 toggle 和 remove 什么的。
整体看上去像下面那样。
可以看到,我们从未调用 setState/setValue 等触发函数,只用到了原生 js 的方法和赋值操作。以一种符合直觉的方式,构建了我们这个 reactive todo-list。
react 在这里只是起来了一个 renderer 的作用,理论上,用任意 vdom library 都行。
如何用 combine 函数实现行走的 immer
上面的 test 是一个 reactive state,里面深层节点里包含了 reactive-value。
mobx 作者的 immer ,是现用现抛,nextState = produce(state, update)。
我们 reactive-state 的版本则是,draftState 不必限制在 update 函数里,可以在外面随意传递和使用,watch 函数拿到的总是 immutable 的。
我们构造了 3 个方法,分别深度更新不同的字段,然后随机使用这些更新方法。它们不会引起其它字段的引用变化,共享没有变化的结构。
比如,randomMethod a 只引起了 a 字段的更新,因此 c 和 g 字段跟 prev 对比是相等的。
如何用 reactivity api 实现 react-hooks 的机制?
vue 3.0 的 reactivity api,更多的是承担 connect, computed, combine 等结构关联的动作,它没有作为 source 去 produce data。data 是外部传入 state/value,以及 reactive-state 在别的地方被 mutate 出新数据。
而 react-hooks 其实是一个 producer,它不断的 re-execute 自身,产生很多次 return data 的过程。
react-hooks 跟 reactivity api 的结合,就得到了一个 producer + combinator。比如,我们要构造一个 count,它不只是在 count.value += 1 的时候被动产生新的 value,而是可以通过某个机制,不断自动产生。
这个结构看起来跟 rxjs 倒很像。有 next, cleanup/unsubscribe,默认自带 startWith 操作符。后续我们可以实现 merge, combine, concat, filter, take 等其它 operator。这样直接 vue, react, rxjs 的 pattern 一家亲了~
不过,额外引入 react-hooks,跟 vue-reactivity 并行,会显得很奇怪,应该用后者实现前者的机制。就是 re-run 时,重用 state/value,并且 state/value 的变化,会引起函数的 re-run。
useEffect 应该是 watch 自身,是一个语法糖。watch(self, effect)。
如此,区分出了两种 reactivity 形态,一种是在 producer 外部的 free-order-reactivity,一种是在 producer 内部的 fixed-order-reactivity。
实现起来很简单。
实现 3 个增强函数的函数,resumable 增强函数 re-run 自身的能力,referencable 增强函数持久化内部状态的能力。reactive 增强函数使用 reactivity api 的能力。
首先存在一个 env 内部环境,它会被 resumeable, referencable, reactivie 等 enhancer 进行拓展。
reactivie 就是将 prodcuer 的返回值,挂载到 value$.value 上,自身永远返回 value$,因此外部总是拿到一个 reactive value。
useRef 的实现,直接使用 referencable 提供的 storage 方法即可。
useEffect 在使用 storage 方法时,通过 reactive enhancer 拿到了 value$,watch 它,并返回 unwatch。
useReactive 在内部构造 reactive value/state,watch 它,然后使用 resumable enhancer 提供的 resume 方法,触发重新执行。
然后用 useReactive,分别实现 useState,和 useValue。再用它们实现一个 interval 函数,可以输出一个自行变化的 count。
把 interval 用在我们之前的 Counter 组件里。
效果,有一个 tick 自动随时间而变化,不需要额外的地方去 count.value += 1。
如何用 reactivity api 实现 rxjs-like 的功能?
先实现一个 rxjs 那样的 pipe,用法是 pipe(source, operator1, opeator2, operator3) 这类。
map operator 的实现,可以直接用 functor 的 fmap,参数映射一下 pipe 函数的要求。
因为 map 函数已经定义过了,因此这个 map operator 只好改名为 mapx。
filter operator 就是通过 predicate 函数,有选择的将 source$ 的值,转移到 sink$。
take 和 scan 则分别是内部计数和累加acc,代码都很简单。
将这些 operators 用在我们的 tick 上。
输出 10 个奇数的数组。如下图所示。
总结
需要说明的是,目前的模拟是一个粗糙的做法,有很多没有处理,比如 unwatch 的时机,它几乎一定会内存泄露。需要更精细的去实现和控制,才能得到一个可用的形态,当下只是演示一下思路 。
这些 demo 只是演示一些能力。没有考虑实际项目里怎么用,不管大小,都不要用这个方案。
等有人基于这个思路做出一个完成度更好的库或者框架,再考虑吧。
到目前为止,我们差不多填完了用 vue reativity api 实现 immer-like, rxjs-like, react-hooks-like, cyclejs-like(就是最初的那个 reactive view) 的坑,应该足以展示 vue reactivity api 是一个更加 primitive 的机制了(毕竟基于 Proxy)。
vue 3.0 reactivity api 的能力还不局限于上面演示的,感兴趣的同学,可以自行探索一下。
点击原文访问DEMO