fluth-vue: 体验流式编程范式之美
感受流式编程范式在代码组织、代码维护、代码调试、页面渲染方面的全新体验
背景
在 如何处理复杂前端业务代码 文章中简单的介绍了流在异步数据依赖关系维护和异步逻辑依赖关系维护的一些应用,本篇文章详细的介绍在 vue 响应式框架中如何将流这种全新的编程范式无缝的融入,并阐述了通过流式编程这种全新编程维度在代码组织、代码维护、代码调试、页面渲染方面带来的全新体验。
当前 vue 开发的痛点
响应式数据开发调试痛点
vue 的响应式数据用在 template 绑定上体验感非常好,深层的数据修改后可以立马触发组件的更新;但是响应式数据在逻辑层面体验就比较糟糕:
- 响应式数据是mutable的,所以想知道数据的 previous 状态是非常困难的;
- watch 响应式数据,然后做逻辑处理,首先没有语义,其次实现复杂控制标记位过多;
- 对于复杂对象常常很难找出在逻辑中哪个响应式的属性的修改以及代码的哪个位置修改导致组件更新;
用响应式的数据来组织逻辑虽然看上去效率很高,但是在代码阅读和代码维护以及debug方面常常带来很大的困扰。
代码组织维护痛点
写 vue 业务组件或者逻辑的时候,只要稍不注意代码体积就在膨胀;哪怕采用了 hook 编程理念,膨胀依旧发生在hook。这个膨胀体现在:函数处理的场景越来越多,入参就要越来越多,函数体里面 if-else 也就越来越多;似乎只有反复拆解重构才能达到一个比较好的平衡,对心智负担和时间成本消耗都比较大。
复杂度不会消失,只能转移或者隔离;需要成本更低的代码组织形式来对抗业务的复杂度。
流式编程开发的痛点
rxjs 是一个大家都熟悉的响应式编程库,内置了大量的操作符并通过pipe这种管道的形式进行串联,让数据在管道内通过操作符进行处理和流转;在这里将这种通过管道的形式来对数据进行响应式的编程方式叫做流式编程,那么rxjs就是一个流式编程范式的库,流式编程范式具备如下优点:
- 响应式:流可以不断触发也可以被订阅;
- 管道式:任何一个环节都处于管道之中,上游一目了然;
- 声明式:管道内的操作可以内聚成操作符,大大提升代码的表达力和简洁性;
但是流式编程长期以来被认为是“牛刀”,似乎只有复杂的异步数据源场景才配用上,这里面很大归功于 rxjs 较高的上手门槛和繁多的概念。
响应式和流的结合
如果可以简化流式编程的使用成本,采取数据的响应式来更新视图,流的响应式来组织业务逻辑代码代码,结合响应式数据带来的页面自动更新的便捷和流式编程带来的声明式逻辑丝滑处理,是否可以解决上面的痛点并给前端开发带来效率上的提升呢?
下面介绍 fluth-vue 结合数据响应式和流式编程,在开发调试、代码组织、页面渲染等方面发生的奇妙的化学反应。
Promise流
fluth
fluth (/fluːθ/) 由 flux + then 两个单词组合而来,代表类似 promise 的流。Promise 是前端最常接触的异步流式编程范式,类 Promise 的流式编程范式极大地降低了流式编程的门槛,
认为 promise 是发布者而 then 方法是订阅者,promise 的发布行为则只有一次。fluth 加强了 promise,让 promise 可以不断的发布!如果你熟悉 Promise,那么你已经掌握了 fluth 的基础。
以下面代码为例:
import { $ } from 'fluth'
const promise$ = $(
promise$.then(
(r) => console.log('resolve', r),
(e) => console.log('reject', e)
)
promise$.next(1)
promise$.next(2)
promise$.next(Promise.reject(3))
console.log('end')
// 打印:
// resolve 1
// resolve 2
// end
// reject 3
但是 fluth 相比 promise 有如下差异点:
- 相比 promise,fluth 可以不断发布并且支持取消定订阅
- 相比 promise,fluth 同步执行 then 方法,及时更新数据
- 相比 promise,fluth 完全支持
PromiseLike
还有一个重要的差异点:fluth 保留每个订阅节点的数据供后续使用
以下面代码为例:
import { $ } from 'fluth'
const promise$ = $(0)
const observable$ = promise$.thenImmediate(v => v + 1)
promise$.value === 0 ✅
observable$.value === 1 ✅
promise$.next(1)
promise$.value === 1 ✅
observable$.value === 2 ✅
流上面的每一个子节点,返回的值都可以通过 value 属性来获得,这样每个节点既可以进行逻辑处理又能保留处理后的数据。
相比 rxjs 这样非常成熟的流式编程库,和 fluth 相比而言有几个区别
- fluth 上手非常简单,是类 promise 的流式编程库,只要会使用 promise 就可以使用
- fluth 的流是 hot、multicast 的,而 rxjs 的流还具备 cold、unicast 的特性
- fluth 可以流链式订阅,而 rxjs 的订阅后无法再链式订阅
- fluth 保留了每个订阅节点的数据以及状态供后续消费
- fluth 订阅节点存在和 promise 类似的 status 状态
- fluth 可以添加插件来扩展流的功能和添加自定义行为
如下代码所示:
// rxjs:
stream$.pipe(operator1, operator2, operator3)
stream$.subscribe(observer1)
stream$.subscribe(observer2)
stream$.subscribe(observer3)
//fluth:
stream$.use(plugin1, plugin2, plugin3)
stream$
.pipe(operator1, operator2)
.then(observer1)
.pipe(operator3)
.then(observer2)
.pipe(operator4)
.then(observer3);
stream$.next(1);
fluth-vue
fluth-vue 则进一步将响应式 + 流式编程完美融合;让流可以成为替代 ref、reactive 响应式数据的基础单元。
以下面代码为例:
import { $, filter, debounce } from "fluth-vue";
import { ref } from "vue";
const data = ref({name: 'xxx', age: '18'})
const data$ = $({name: 'xxx', age: '18'})
data$.pipe(
debounce(300),
filter((v) => v.age > 18),
map((v) => ({name: v.name, age: v.age + 1}))
)
data 和 data$
在响应式方面几乎完全一致,但是 data$
却比 data 多了一个全新的流式编程的维度。
响应式能力
响应式数据
对于 fluth-vue 来说,和 ref 数据在响应式方面几乎完全一致体现在:
- 可以正常的 watch、computed
- 在 template 中可以正常的被解包,不需要使用 .value
- 在 vue-devtools 中可以正常显示其值
如下所示,除了修改数据,$("fluth") 和 ref("fluth") 两者完全等价。
<template>
<div>
<p>{{ name$ }}</p>
</div>
</template>
<script setup>
import { watch, computed } from "vue"
import { $ } from "fluth-vue";
const name$ = $("fluth");
const computed = computed(() => name$.value);
watch(name$, (value) => {
console.log(value);
});
</script>
响应式更新
唯一的差异点在于:修改数据必须采用 fluth next 或 set 方法
import { $ } from "fluth-vue";
const stream$ = $({ obj: { name: "fluth", age: 0 } });
stream$.set((value) => (value.obj.age += 1));
通过 next 和 set 修改数据后,不但会触发 vue 响应式的更新;还会触发流的推流,所有订阅节点都将得到数据的推送。
不可变数据能力
在修改数据方面存在差异点的原因是 fluth 底层采用了 immutable 的数据存储。
流的数据在流转的过程中被每个节点处理后再把处理结果给到下一个节点,而每个节点都需要保留处理后的数据,通过数据的 immutable 来保证数据之间隔离,让每个节点都能拥有不被污染的数据。fluth提供了set、thenSet、thenImmediateSet、thenOnceSet方法和 set 操作符来对节点进行 immutable 处理。
数据和响应式解耦
使用 ref 或者 reactive 的时候,数据和响应式是一体的,修改了数据就会触发响应式,但是流可以做到数据和响应式的解耦
如下所示:
const wineList$ = $(["Red Wine", "White Wine", "Sparkling Wine", "Rosé Wine"]);
const age$ = $(0);
const availableWineList$ = age$
.pipe(filter((age) => age > 18))
.then(() => wineList.value);
只有 age$
大于 18 的时候,才可以获取到 wineList$
的最新值,但是后续 wineList$
的 immutable 修改不会触发 availableWineList$
的重新计算以及值的变化,只有 age$
的变化才会触发 availableWineList$
的重新取值,如果采用 vue computed 的方式进行运算,不管是 age 还是 wineList 的变化都会引起 availableWineList 的计算。
调试能力
fluth 提供了丰富的调试插件:
打印插件
通过consoleNode 插件可以方便的打印流节点数据
import { $, consoleNode } from "fluth-vue";
const data$ = $().use(consoleNode());
data$.next(1); // 打印 resolve 1
data$.next(2); // 打印 resolve 2
data$.next(3); // 打印 resolve 3
data$.next(Promise.reject(4)); // 打印 reject 4
由于 fluth-vue 底层采用 immutable 的数据,对于复杂对象使用打印插件可以保留每个修改时刻的快照供调试,而 ref 数据要做到则需要采用 JSON.stringify。
通过consoleAll 插件可以方便的查看流所有的节点数据
import { $, consoleAll } from "fluth-vue";
const data$ = $().use(consoleAll());
data$
.pipe(debounce(300))
.then((value) => {
throw new Error(value + 1);
})
.then(undefined, (error) => ({ current: error.message }));
data$.next(1)
// 打印 resolve 1
// 打印 reject Error: 2
// 打印 resolve {current: '2'}
断点插件
通过debugNode插件可以方便的调试流节点数据,并可以查看流节点的调用栈
import { $, debugNode } from "fluth-vue";
const stream$ = $(0);
stream$.then((value) => value + 1).use(debugNode());
stream$.next(1);
// 触发调试器断点
条件调试
import { $ } from "fluth-vue";
import { debugAll } from "fluth-vue";
// 只对字符串类型触发调试器
const conditionFn = (value) => typeof value === "string";
const stream$ = $().use(debugNode(conditionFn));
stream$.next("hello"); // 触发调试器
stream$.next(42); // 不触发调试器
通过debugAll插件可以方便的调试流所有的节点数据,并可以查看流节点的调用栈,可以非常容易的找到数据修改的属性和位置。
import { $, debugAll } from "fluth-vue";
const data$ = $().use(debugAll());
data$.then((value) => value + 1).then((value) => value + 1);
const updateData$ = () => {
data$.next(data$.value + 1);
};
// 在浏览器开发者工具中会在每个节点触发调试器断点
// 当前有三个节点,所以会触发三次断点
打印和调试插件的出现彻底的改变了以前调试 vue 复杂对象的体验。
异步能力
fluth-vue 提供了强大的异步处理能力,体现在下面两个方面:
更强大的promsie
fluth 流的每个节点都实现了 promise 的全套能力:then、catch、finally,与此同时还实现了then节点的同步运行:
Promise.resolve(1).then(v=> console.log(v));
console.log('hello');
// hello
// 1
const stream$ = $()
stream$.then(v => console.log(v));
stream$.next(1)
console.log('hello')
// 1
// hello
fluth 流如果节点都是同步操作,可以看到都是同步执行。同步执行对于前端非常重要,如果每个节点都异步执行那么会导致页面反复的渲染。
还支持对 then 进行取消订阅:
import { $, consoleAll } from "fluth-vue";
const stream$ = $().use(consoleAll());
const observable$ = stream$.then( v => v + 1)
stream$.next(1)
//resolve 1
//resolve 2
observable$.unsubscribe() // 取消订阅
stream$.next(1)
//resolve 1
由于 fluth 可以链式的进行订阅,而订阅的节点可能是异步节点,异步节点返回的时间是不确定的。当异步节点还没返回,如果此时流又推送了新的数据到节点则会产生异步竞态问题,fluth 解决了异步竞态的问题:
const stream$ = $()
stream$
.then(x => x+1)
.then(x => new Promise(resolve => settimeout(() => resolve(x), 50)))
.then(x => x*2)
stream$.next(1)
sleep(30)
stream$.next(2)
sleep(60)
stream$.next(3)
sleep(30)
stream$.next(4)
第一个数据和第三个数据由于竞态问题会在节点处理中丢弃掉,如下图所示:
流式的api
fluth-vue 提供 useFetch 函数,让 api 的请求能够支持流,这样可以将 api 的请求作为流的一个节点看待。
const url$ = $("https://api.example.com/data");
const payload$ = $({ id: 1, name: "fluth" });
const { promise$ } = useFetch(url$, { immediate: false, refetch: true })
.get(payload$)
.json();
promise$.then((data) => {
console.log(data); // api 请求的结果
});
url$.next("https://api.example.com/data2"); // 触发请求,并打印结果
payload$.next({ id: 2, name: "vue" }); // 触发请求,并打印结果
这样,不管是
url$
还是 payload$
发起推流,都会重新发起请求并通过 promise$
进行推流给到下游进行消费。
流式渲染能力
fluth-vue流的数据就是响应式数据可以正常在 template 中渲染,除此之外 fluth-vue 还提供了强大的流式渲染render$功能,可以实现元素级渲染或者块级渲染,整体效果类似 signal 或者 block signal 的渲染。
元素级渲染
import { defineComponent, onUpdated } from "vue";
import { $, effect$ } from "fluth-vue";
export default defineComponent(
() => {
const name$ = $("hello");
onUpdated(() => {
console.log("Example 组件更新");
});
return effect$(() => (
<div>
<div>
名字:{name$.render$()}
</div>
<button onClick={() => name$.set((v) => v + " world")}>更新</button>
</div>
);
},
{
name: "Example",
},
);
点击按钮只会修改 div 元素下的 name$.render$()
内容,不触发组件 onUpdated 生命周期。
块级渲染
import { defineComponent, onUpdated, h } from "vue";
import { $, effect$ } from "fluth-vue";
export default defineComponent(
() => {
const user$ = $({ name: "", age: 0, address: "" });
const order$ = $({ item: "", price: 0, count: 0 });
return effect$(() => (
<div class="card-light">
<div> example component </div>
<div>render time: {Date.now()}</div>
<section style={{ display: "flex", justifyContent: "space-between" }}>
{/* use$ emit data only trigger render content update*/}
{user$.render$((v) => (
<div key={Date.now()} class="card">
<div>user$ render</div>
<div>name:{v.name}</div>
<div>age:{v.age}</div>
<div>address:{v.address}</div>
<div>render time: {Date.now()}</div>
</div>
))}
{/* order$ emit data only trigger render content update*/}
{order$.render$((v) => (
<div key={Date.now()} class="card">
<div>order$ render</div>
<div>item:{v.item}</div>
<div>price:{v.price}</div>
<div>count:{v.count}</div>
<div>render time: {Date.now()}</div>
</div>
))}
</section>
<div class="operator">
<button class="button" onClick={() => user$.set((v) => (v.age += 1))}>
update user$ age
</button>
<button
class="button"
onClick={() => order$.set((v) => (v.count += 1))}
>
update order$ count
</button>
</div>
</div>
));
},
{
name: "streamingRender",
},
);
use$
或者 order$
流更新后,只会更新 render$
函数里面的内容,不会引起组件的虚拟 dom diff 以及 update 的生命周期。
一旦流可以掌控渲染,那么可以做的事情就非常多了,比如 user$.pipe(debounce(300)).render$
😋,这里就不进一步展开了。
代码组织能力
流这种编程范式和前端业务模型高度匹配在代码组织上表现的尤为明显。
下面以一个简单的例子——订单表单的提交页面,来展示流在业务模型中的应用:
传统的前端开发采用命令式编程模式:
- 点击按钮后,调用 handleSubmit 方法
- handleSubmit 先 validateForm 方法,如果验证不通过,则提示报错
- 验证通过拼装后台需要的数据
- 调用后台 fetchAddOrderApi 方法
- 如果调用成功,则继续调用 handleDataB 方法、handleDataC 方法
- 如果调用失败,则提示报错
这应该是大部分前端开发者的日常,开发日常不代表天经地义,这种命令式开发模式、夹杂同步逻辑异步操作,随着业务复杂度增长,handleSubmit 方法会变得越来越臃肿,也将变得越来越难以复用。
下面采用流的声明式编程方式重新实现:
按照业务逻辑,代码实现为六条流:
form$、trigger$、submit$、validate$、payload$、addOrderApi$
,每一条流都承载着独立的逻辑,流的先后顺序按照业务真实顺序进行组织。form$、trigger$
负责将用户的输入转换为流,validate$、addOrderApi$
则将流的处理结果传递用户。
通过代码可以发现:
- 复用性提升,采用流式编程范式后逻辑充分的原子化了,而流既可以分流又可以合流可以轻易的对这些逻辑原子进行逻辑组合,代码的复用性空前的提高
- 维护性提升,代码从上到下是按照业务真实顺序进行组织的,当前只有一个 handleSubmit 方法可能还不明显,当业务逻辑复杂后,按照业务事实顺序组织代码将对阅读性、维护性有极大的提升
- 表达力提升,audit、debounce、filter等操作符以声明式的方式处理了触发器、节流、条件过滤等复杂的异步控制逻辑,通过流的操作符,代码的表达力显著提升。
- 控制反转,相对于方法调用这种”拉“的方式,流式编程范式是”推“的方式,可以实现数据、修改数据的方法、触发数据修改的行为都放置在同一个文件夹内,再也无需全局搜索哪里的调用改变了模块内部的数据。
复用性和可维护性优势
对于命令式的编程,在 handleSubmit 后续的迭代中可能需要分场景:
- 场景 A 调用 fetchAddOrderApi 成功后只需要调用 handleDataB 方法
- 场景 B 调用 fetchAddOrderApi 成功后只需要调用 handleDataC 方法
此时 handleSubmit 只能将场景变为参数交由 if - else 来处理,随着越来越多的分支逻辑,函数逐渐膨胀。如果用流式编程范式来实现,这个问题可以轻松解决:
- 如果场景是流的话,通过组合流就可以轻松解决
// 场景 A 流
const caseA$ = $();
addOrderApi$.pipe(audit(caseA$)).then(handleDataB);
// 场景 B 流
const caseB$ = $();
addOrderApi$.pipe(audit(caseB$)).then(handleDataC);
- 如果场景是数据的话,既可以通过分流也可以通过过滤来处理,两种方式都可以轻松解决
// 场景流,可能是 A,也可能是 B
const case$ = $<"A" | "B">();
// 方法1: 分流
const [caseA$, caseB$] = partition(case$, (value) => value === "A");
addOrderApi$.pipe(audit(caseA$)).then(handleDataB);
addOrderApi$.pipe(audit(caseA$)).then(handleDataC);
// 方法2: 过滤
const caseAA$ = addOrderApi$
.pipe(filter(case$.value === "A"))
.then(handleDataB);
const caseBB$ = addOrderApi$
.pipe(filter(case$.value === "B"))
.then(handleDataC);
代码逻辑原子化以及流的分流和合流让 fluth-vue 在代码组织能力上如鱼得水。
重构优势
上面是一个简单的示例,如果业务逻辑复杂传统开发模式下,一个 setup 函数下面可能有十几个 ref 和几十个 methods,如果认为 setup 是一个 class,那么这个 class 将拥有十几属性和几十个方法以及的坏味道的 watch “打洞”逻辑,阅读和维护成本将非常的高。
虽然更小粒度的的抽离组件以及 hooks 的开发理念可以解决部分问题,但现实是当前大量现存业务仍然是由很多这样臃肿的 setup 函数构造的组件组装的,因为种种原因一旦 setup 成为这个臃肿的 class,那么后续的开发者只能在这个 setup 上持续“深耕”。
而流式编程范式可以很好的解决这个问题,如果一开始采用 fluth-vue 开发业务,随着业务持续迭代,代码也会也来也长;但是流式编程是按照业务真实顺序进行声明式组织代码,相当于一条线不断延伸,此时要抽离逻辑只需要将线剪成几段分别放入 hook 就好了,完全没有心智负担,相当于有一个很重的业务,只需要几分钟就可以解决重构好。
总结
通过在实际业务中用流式编程范式进行开发和调试,发现流这种编程范式在前端领域被严重的低估,可能是 rxjs 概念或者使用较为复杂让大家认为是一把牛刀,只有复杂异步数据流组合场景才配用上,其实最简单的 ref("字符串"),当采用 $("字符串")后都能带来非常可观的收益。
fluth-vue 真正意义上将流式编程范式带给了vue开发者:让流成为前端最基础的数据形态并完美兼容响应式,将响应式进行彻底:除了数据和视图的响应式,逻辑也能用流响应式的组织。
实际体验下来的感受:流式编程范式与前端业务的异步、事件驱动特性天然契合,是组织前端业务逻辑的理想选择。
最后项目已开源🎉🎉🎉,欢迎 star ⭐️⭐️⭐️ !!!