写这篇文章的动机可以追溯到 3 年前, 我发现很多身边开发者并没有正确地使用 React Hooks, 所以我觉得应该把我的开发经验和思维整理下来。
尽管本文主要从 Vue 的角度出发,但是很多思维也可以用在 React Hooks 上。
从广义的的“响应式编程(Reactive Programing)” 上看,Vue、React、Rxjs 等框架都属于这个范畴。而狭义的响应式编程通常指的是 rxjs 这类 “面向数据串流和变化传播的声明式编程范式”
虽然 Vue 也是‘响应式编程’, 但是和 RxJS 是完全不一样的概念,至少RxJS 是有范式约束的,不管是编码上还是思维上面,我们都可以感受到它的强力约束,这和我们惯用的命令式编程差别很大。这也导致了它的学习门槛比较高。
为什么要牵扯到 RxJS 呢?因为它的思维对我们写好 Vue 代码很有帮助!
简述 RxJS
先祭上徐飞的买房的例子,感受一下 RxJS 的魅力:
const house$ = new Subject() const houseCount$ = house$.scan((acc, num) => acc + num, 0).startWith(0)
const salary$ = Observable.interval(100).mapTo(2) const rent$ = Observable.interval(3000) .withLatestFrom(houseCount$) .map(arr => arr[1] * 5)
const income$ = Observable.merge(salary$, rent$) const cash$ = income$ .scan((acc, num) => { const newSum = acc + num
const newHouse = Math.floor(newSum / 100) if (newHouse > 0) { house$.next(newHouse) }
return newSum % 100 }, 0)
|
如果用几个关键字来描述 RxJS 的话,我想应该是:
这几个模式我们分开去理解都没啥特别,比如 Vue 的 reactivity 数据就是观察者模式;JavaScript 的 for…of/generator 就是迭代器模式;数组的map/filter/reduce, shell 命令都符合管道模式。
RxJS 的牛逼之处就是把这三个模式优雅地组合起来了。它把事件抽象成为类似’数组’一样的序列,然后提供了丰富的操作符来变换这个序列,就像操作数组一样自然,最后通过管道将这些操作符组合起来实现复杂的功能变换。
为什么建议你去学习 rxjs?
至少它可以帮助你写好 Vue 代码。它可以帮你写出更简洁、结构更清晰、低耦合、更容易测试的代码,这些代码更能体现原本的交互逻辑或业务流程。
相信我,尝试换个思路,可能原本复杂的实现,会变得更加简单。
RxJS 和 Vue Reactivity Data 有什么关联?
一些和 RxJS 相似的概念
响应式数据。我们用 ref 或reactive 创建的数据,可以等似于 RxJS 的 Observable。只不过响应式数据并不像 rxjs 有显式的事件发布和订阅过程,也不存在事件流(序列)。
我们可以认为Vue 数据的每次变更就相当于 RxJS 发出每次事件。
衍生数据。我们会使用 computed 来衍生新的数据,等似于 RxJS 用操作符衍生出新的 Observable。即 Vue 数据衍生数据,RxJS 事件衍生事件
- 副作用。在 Vue 中, watch/watcheffects/render 相当于 RxJS 的 subscribe,RxJS 的数据流的终点通常也是副作用处理,比如将数据渲染到页面上。
RxJS 的很多东西并不能直接套用过来,但思想和原则是可以复用的。
其中一个重要的思想就是:管道变换。这是一种思维方式的转变,在以往的编程设计中,我们更多操心的是类、模块、数据结构和算法。而管道变换我们会把程序视作从输入到输出的一个变换去构思:
find . -type f | xargs wc -l | sort -n | tail -5
|
不要把数据看作是遍布整个系统的小数据池,而要把数据看作是一条浩浩荡荡的河流。
另一方面,编写 RxJS 代码一些原则,对我们编写 Vue 代码也大有裨益:
看看你代码中的坏味道
看看你的 Vue 代码有没有这些现象,如果存在这些坏味道,说明你并没有正确使用 Vue 的 Reactivity API。
- 创建了大量的缓存状态。比如 sum,avg,temp…
- 使用了很多
watch
/ watchEffect
…
- 冗长的
setup
方法或者组件代码
- 状态被随意修改,修改不属于管辖范围内的状态
- …
实践
分页
先从简单的场景开始: 分页请求。
❌ 常规的做法:
const query = reactive({}) const pagination = reactive({pageNo: 1, pageSize: 10}) const total = ref(0) const list = ref([]) const loading = ref(false) const error = ref()
watch([query, pagination], async () => { try { error.value = undefined loading.value = true const data = await request(`/something?${qs({...query, ...pagination})}`) total.value = data.total list.value = data.list } catch (err){ error.value = err } finally { loading.value = false } }, {immediate: true})
|
✅ 推荐做法:
const query = reactive({}) const pagination = reactive({pageNo: 1, pageSize: 10})
const data = useRequest(() => `/something?${qs({...query, ...pagination})}`)
|
自然地表达 query/pagination → data 的数据流。useRequest 更像 computed 的语义,从一个数据衍生出新的数据,不管它是同步的还是异步的。
而使用 watch 会中断数据的流动,并且我们需要创建冗余缓存状态,代码看起来会比较混乱。想象一下复杂的页面,我们可能会有很多复杂、联动的异步请求,情况就会慢慢失控。
useRequest
是啥?它封装了网络请求, useRequest 可以基于 swrv(swr 在 Vue 下的实现, 非官方)、或者VueUse 里面的 computedAsync、useFetch 来封装。
useRequest 类似于 RxJS 的 switchMap,当新的发起新的请求时,应该将旧的请求抛弃。
笔者推荐使用 swr 这类库去处理网络请求,相比直接用 watch, 这类库支持数据缓存、Stale-while-revalidate 更新、还有并发竞态的处理等等。
实时搜索
第二个例子也比较简单,用户输入文本,我们debounce 发起数据请求
⚠️ 常规的实现:
const query = ref('')
const handleQueryChange = debounce((evt) => { query.value = evt.target.value }, 800)
const data = ref()
watch(query, async (q) => { const res = await fetchData(q) data.value = res })
const handleQueryChange = (evt) => { query.value = evt.target.value }
watch(query, debounce(async (q) => { const res = await fetchData(q) data.value = res }, 800))
|
RxJS 实现:
const searchInput$ = fromEvent(searchInput, 'input').pipe( debounceTime(800), map(event => event.target.value), distinctUntilChanged(), switchMap(keyword => from(searchList(keyword))) )
|
我们使用 Vue 也可以表达类似的流程:
const query = ref('') const debouncedQuery = refDebounced(input, 1000)
const data = useRequest(() => `/something?${qs({query: query.value})}`)
|
refDebounce 来源于 VueUse,可以 “Debounce” 指定输入 ref 值的变动。
定时刷新
假设我们要在上面的分页基础上实现定时轮询的功能:
const query = reactive({}) const tick = useInterval(5000) const pagination = reactive({pageNo: 1, pageSize: 10})
const data = useRequest(() => `/something?${qs({...query, ...pagination, _t: tick.value})}`)
|
我们看到上面的流程很自然。
现在加大难度,如果要在特定条件下终止呢?
const query = reactive({})
const {counter: tick, pause, resume} = useInterval(5000, {controls: true, immediate: false}) const pagination = reactive({pageNo: 1, pageSize: 10})
const data = useRequest(() => `/something?${qs({...query, ...pagination, _t: tick.value})}`)
const shouldPoll = computed(() => { return data.data?.some(i => i.expired > Date.now()) })
watch(shoudPoll, (p) => p ? resume() : pause())
|
如果用 RxJS 来实现的话,代码大概如下:
const interval$ = interval(5000);
const poll$ = interval$.pipe( switchMap(() => from(fetchData())), share() );
const stop$ = poll$.pipe( filter((i) => { return i.every(i => i.expired <= Date.now()) }) );
poll$ .pipe( takeUntil(stop$) ) .subscribe((i) => { console.log(i); });
|
因为 RxJS 的 Observable 是惰性的,只有被 subscribe 时才会开始执行,同理停止订阅就会中断执行。
中断执行后,如果要重新发起请求,重新订阅就好了。有点异曲同工之妙吧
省市区选择器
再来看一个稍微复杂一点的例子,常见的省市区选择器,这是一个典型的数据联动的场景。
我们先来看一个反例吧,我们的选择器需要先选择国家或地区,然后根据它来确定行政区域的划分,接着渲染各级行政区域选择器:
export default defineComponent({ props: { modelValue: { type: Array as () => number[], default: () => [], }, onChange: { type: Function, default: () => {}, }, },
setup(props, { emit }) { const isEchoingData = ref(false); const regionList = ref<RegionInfoDTO[][]>([]); const regionUrl = ref(''); const queryParams = ref({} as IQueryParams);
const selectedRegion = computed<number[]>({ get: () => props.modelValue, set: value => emit('update:modelValue', [...value]), });
const { data: countryList } = useRequest<CountryInfoDTO>( () => `请求国家列表` );
// 请求区域列表 const { data: regionItems } = useRequest<RegionInfoDTO>(() => regionUrl.value);
watch(regionItems, () => { regionList.value[queryParams.value.level] = regionItems.value?.data!; });
const countryOptions = computed(() => { return countryList.value?.data.map(i => { return { label: i.name, value: i.id, }; }); });
watch(queryParams, async newValue => { if (!Object.keys(newValue).length) return;
const query = `&countryId=${newValue.level ? '' : newValue.value}&parentId=${ newValue.level ? newValue.value : '' }&level=${newValue.level + 1}`; regionUrl.value = `区域请求路径${query}`; });
watch( props.modelValue, async (newValue, oldValue) => { const newLen = newValue.length; const oldLen = oldValue?.length ?? 0;
if (newLen && newLen !== oldLen) { const index = 0;
queryParams.value = { value: newValue[index], level: index }; isEchoingData.value = true; } }, { immediate: true } );
watch( regionList, newVal => { const len = newVal.length; const selectedLen = selectedRegion.value.length;
if (isEchoingData.value && selectedLen > len) { if (len === selectedLen - 1) return (isEchoingData.value = false);
queryParams.value = { value: selectedRegion.value[len], level: len }; } }, { deep: true } );
const onRegionChange = (value: number, level: number) => { selectedRegion.value.splice(level); regionList.value.splice(level); selectedRegion.value.push(value);
const currentRegion = regionList.value[level - 1]?.find(region => region.id === value);
if (!currentRegion?.isLeaf) { queryParams.value = { value, level }; }
props.onChange?.([...selectedRegion.value], [...selectedRegionNames.value]); };
const currentRegionPlaceholder = (index: number) => { return `${selectedCountry.value?.regionLevelInfos[index]?.name ?? '区域'}`; };
const selectedCountry = computed(() => { const selectedCountryId = selectedRegion.value[0]; const selectedCountry = countryList.value?.data.find(country => country.id === selectedCountryId);
return selectedCountry; });
const selectedRegionNames = computed(() => { const names = [];
if (selectedCountry.value) { names.push(selectedCountry.value.name); }
selectedRegion.value.slice(1).forEach((id, index) => { const region = regionList.value[index]?.find(region => region.id === id); if (region) { names.push(region.name); } });
return names; });
return () => ( <FatSpace> <ElSelect modelValue={selectedRegion.value[0]} placeholder="请选择国家" onChange={val => onRegionChange(val, 0)} filterable > {countryOptions.value?.map(country => ( <ElOption key={country.value} label={country.label} value={country.value} /> ))} </ElSelect>
{regionList.value.map((regions, index) => ( <ElSelect key={index} modelValue={selectedRegion.value[index + 1]} placeholder={`请选择${currentRegionPlaceholder(index)}`} onChange={val => onRegionChange(val, index + 1)} filterable > {regions.map(region => ( <ElOption key={region.id} label={region.name} value={region.id} /> ))} </ElSelect> ))} </FatSpace> ); }, });
|
也就 150 行左右的代码,实现的是 国家-国家各种区域
的选择器,比如选择了中国就会有 中国-省-市-区
这样的分级。
读者也没必要读懂这些代码,我看到也头大,你只需要记住,这个充斥着我们上文提到的各种坏味道:过渡依赖 watch、数据流混乱…
让我们回归到业务本身,我们为什么需要不恪守这样的联动关系去组织代码呢?
可以的,一个比较重要的技巧就是自顶而下地去分析流程/数据流变换的过程。
首先从国家开始,只有用户选择了指定国家之后,我们才能获取到区域的结构信息(是省/市/区, 还是州/城市,anyway):
export const AreaSelect2 = defineComponent({ props: { // 表单值是数组格式,每一项保存的是区域的 id modelValue: Array as PropType<number[]>, }, emits: ['update:modelValue'], setup(props, { emit }) { // 🔴 获取国家列表 const country = useCountryList();
// 🔴 计算当前选中的国家,我们从这里拿到行政区域结构 const currentCountry = computed(() => { return country.data.value?.data?.find(i => i.id === props.modelValue?.[0]); });
const handleCountryChange = (value: number) => { if (value !== props.modelValue?.[0]) { // 🛑 国家变动后,重置掉后续的数据 emit('update:modelValue', [value]); } };
return () => { return ( <div> <ElSelect modelValue={props.modelValue?.[0]} placeholder="请选择国家" onUpdate:modelValue={handleCountryChange} filterable fitInputWidth loading={country.isValidating.value} > {country.data.value?.data?.map(i => { return <ElOption key={i.id} label={i.name} value={i.id}></ElOption>; })} </ElSelect> {/* 此处暂时忽略 */} </div> ); }; }, });
|
- Composition API 的好处是,它让组合和封装变得非常便利。如上面的代码,我们将获取国家的相关逻辑封装成 useCountryList,代码变得更加简洁易读。
- 避免中间变量。恪守 v-model 单向数据流
接着我们根据选中的国家来渲染后续的区域联动。
这里提醒一下读者:“不要吝啬创建组件”
我在 React组件设计实践总结04 - 组件的思维 中讲过:
大部分情况下, 组件表示是一个 UI 对象. 其实组件不单单可以表示 UI, 也可以用来抽象业务对象, 有时候抽象为组件可以巧妙地解决一些问题
组件这个设计实在太好用了,笔者觉得它体现的更重要的思想是分治,而不是复用。组件一些比较重要的特性是:
关于这块的详细阐述可以看笔者的旧文章。
所以说,我们可以创建组件来封装区域选择的逻辑,将复杂度分流出去。或者说通过 props 将数据流往下传递给子组件…
export const AreaSelect2 = defineComponent({ // 省略 setup(props, { emit }) { // 省略
return () => { return ( <div> <ElSelect // 省略 > {country.data.value?.data?.map(i => { return <ElOption key={i.id} label={i.name} value={i.id}></ElOption>; })} </ElSelect> + {!!currentCountry.value && + currentCountry.value?.regionLevelInfos?.map((i, index) => { + // 父区域 id + const parentValue = props.modelValue?.[index]; + // 当前区域 + const value = props.modelValue?.[index + 1]; + // 父区域信息 + const parentRegion: RegionLevelDTO = + index === 0 + ? { code: currentCountry.value?.code!, name: currentCountry.value?.name! } + : currentCountry.value?.regionLevelInfos?.[index - 1]!;
+ const handleChange = (nextValue: number) => { + if (value === nextValue) { + return; + }
+ assert(Array.isArray(props.modelValue), 'modelValue is required'); + // 裁剪掉当前区域后面的区域数据 + const clone = [...props.modelValue].slice(0, index + 2); + clone[index + 1] = nextValue;
+ emit('update:modelValue', clone); + };
+ return ( + <Section + index={index} + parentValue={parentValue} + modelValue={value} + region={i} + parentRegion={parentRegion} + country={currentCountry.value!} + onUpdate:modelValue={handleChange} + /> + ); + })} </div> ); }; }, });
|
继续分流, 看看 Section 组件的实现:
const Section = defineComponent({ name: 'AreaSelectSection', props: { /** * 当前索引 */ index: { type: Number, required: true },
/** * 区域信息 */ region: { type: Object as PropType<RegionLevelDTO>, required: true },
/** * 当前国家 */ country: { type: Object as PropType<CountryInfoDTO>, required: true },
/** * 父级 */ parentRegion: { type: Object as PropType<RegionLevelDTO>, required: true },
/** * 父级的值 */ parentValue: Number,
/** * 当前值 */ modelValue: Number, }, emits: ['update:modelValue'], setup(props, { emit }) { // 获取区域列表 const region = useRegion( computed(() => { return { countryId: props.country.id, level: props.index + 1, parentId: props.parentValue, }; }) );
const handleChange = (value: number) => { emit('update:modelValue', value); };
return () => { return ( <ElSelect modelValue={props.modelValue} placeholder={`请选择${props.region.name}`} filterable fitInputWidth disabled={!props.parentValue} onUpdate:modelValue={handleChange} loading={region.isValidating.value} class={s.select} > {region.data.value?.data?.map(i => { return <ElOption key={i.id} label={i.name} value={i.id}></ElOption>; })} </ElSelect> ); }; }, });
|
可见,Section 的实现也再简单不过了。到这里,我相信很多读者已经感受到“响应式”编程的魅力了吧
原则和建议
- 优先使用
computed
,警惕 watch
/watchEffect
等 API 的使用。转换思维先从克制使用 watch 开始。
- 适当使用
readonly
, 禁止状态被坏人修改
- 最小化状态。避免创建‘缓存’状态,让数据自然流动,不要阻断。
- 自顶而下,将细节/副作用分流到 hooks 或子组件中,起一个好一点的名字, 让流程看起来更清晰
- 将 watch 转换为 computed 的语义。外观上的差别是 watch 有 callback, 而 computed 是「管道」,会衍生新的数据。比如上面 useRequest 的例子
- 推荐使用 VueUse
- 封装 hooks, 让各种外部的状态或副作用优雅地集成进来
- 单向数据流,对这个有两层理解
- 表示是一种数据流动的方向,通常和 CQRS 模式配合,比如 Redux、Vuex,只能单向的修改和查询
- 表示一种数据管辖的范围。 通常应用只有数据的拥有者才有权限变更。进一步地讲,我们应该以组件为边界,来限定数据的管辖范围。需要变更时,通过‘事件’ 来通知拥有者。比如 严格遵循 v-model 协议。
使用响应式开发思维,构造单向的数据流
- 尽量管道化的方式去设计你的程序
- 声明式,不要命令式
- 拆分组件或hooks来分治数据流
- 组件之间 props 传递也属于数据流。
- 使用 ref/reactive → computed → watch → handler → render 这样的顺序组织代码
🌹本文完,你的点赞是我写作的最大动力,欢迎留言转发(备注原文作者和链接)。
扩展阅读