写这篇文章的动机可以追溯到 3 年前, 我发现很多身边开发者并没有正确地使用 React Hooks, 所以我觉得应该把我的开发经验和思维整理下来。
尽管本文主要从 Vue 的角度出发,但是很多思维也可以用在 React Hooks 上。
从广义的的“响应式编程(Reactive Programing)” 上看,Vue、React、Rxjs 等框架都属于这个范畴。而狭义的响应式编程通常指的是 rxjs 这类 “面向数据串流和变化传播的声明式编程范式”
虽然 Vue 也是‘响应式编程’, 但是和 RxJS 是完全不一样的概念,至少RxJS 是有范式约束的,不管是编码上还是思维上面,我们都可以感受到它的强力约束,这和我们惯用的命令式编程差别很大。这也导致了它的学习门槛比较高。
为什么要牵扯到 RxJS 呢?因为它的思维对我们写好 Vue 代码很有帮助!
简述 RxJS
先祭上徐飞的买房的例子,感受一下 RxJS 的魅力:
代码语言:javascript复制// 工资周期 ———> 工资
// ↓
// 房租周期 ———> 租金 ———> 收入 ———> 现金
// ↑ ↓
// 房子数量 <——— 新购房
// 挣钱是为了买房,买房是为了赚钱
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)
// houseCount$.subscribe(num => console.log(`houseCount: ${num}`))
如果用几个关键字来描述 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 的很多东西并不能直接套用过来,但思想和原则是可以复用的。
其中一个重要的思想就是:管道变换。这是一种思维方式的转变,在以往的编程设计中,我们更多操心的是类、模块、数据结构和算法。而管道变换我们会把程序视作从输入到输出的一个变换去构思:
代码语言:javascript复制# “列出目录树中最长的五个文”
find . -type f | xargs wc -l | sort -n | tail -5
不要把数据看作是遍布整个系统的小数据池,而要把数据看作是一条浩浩荡荡的河流。
另一方面,编写 RxJS 代码一些原则,对我们编写 Vue 代码也大有裨益:
避免副作用。RxJS 的操作符应该是没有副作用的函数,只关注输入的数据,然后对数据进行变换,传递给下一个。
避免外部状态/缓存状态。外部状态也是副作用的一种,单独拎出来讲,是因为我们在 Vue 中创建外部状态太容易了,而 RxJS 则相对来说麻烦一些,毕竟外部状态和事件流显得格格不入。
在 RxJS 中管道是自包含的, 所有的状态从一个操作器流向下一个操作器,而不需要外部变量:
代码语言:javascript复制Observable.from([1, 2, 3, 4, 5, 6, 7, 8])
.filter(val => val % 2)
.map(val => val * 10);
看看你代码中的坏味道
看看你的 Vue 代码有没有这些现象,如果存在这些坏味道,说明你并没有正确使用 Vue 的 Reactivity API。
- 创建了大量的缓存状态。比如 sum,avg,temp…
- 使用了很多
watch
/watchEffect
… - 冗长的
setup
方法或者组件代码 - 状态被随意修改,修改不属于管辖范围内的状态
- …
实践
分页
先从简单的场景开始: 分页请求。
❌ 常规的做法:
代码语言:javascript复制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})
✅ 推荐做法:
代码语言:javascript复制const query = reactive({}) // 查询参数
const pagination = reactive({pageNo: 1, pageSize: 10})
// data 包含了 list、loading、error、total 等信息
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 发起数据请求
⚠️ 常规的实现:
代码语言:javascript复制const query = ref('')
// 法一:在事件处理器加 debounce
// 如果这么实现,双向绑定到表单可能有卡顿问题
const handleQueryChange = debounce((evt) => {
query.value = evt.target.value
}, 800)
const data = ref()
watch(query, async (q) => {
const res = await fetchData(q)
// FIXME: 需要处理竞态问题
data.value = res
})
// ---------------
// 法二,在 watch 回调或者 fetchData 加上 debounce
const handleQueryChange = (evt) => {
query.value = evt.target.value
}
watch(query, debounce(async (q) => {
const res = await fetchData(q)
data.value = res
}, 800))
RxJS 实现:
代码语言:javascript复制const searchInput$ = fromEvent(searchInput, 'input').pipe(
// 使用 debounceTime 进行防抖处理
debounceTime(800),
// 使用 map 将事件转换为输入框的值
map(event => event.target.value),
// 使用 distinctUntilChanged 进行去重处理
distinctUntilChanged(),
// 使用 switchMap 进行请求并转换为列表数据
switchMap(keyword => from(searchList(keyword)))
)
我们使用 Vue 也可以表达类似的流程:
代码语言:javascript复制const query = ref('')
const debouncedQuery = refDebounced(input, 1000)
const data = useRequest(() => `/something?${qs({query: query.value})}`)
refDebounce 来源于 VueUse,可以 “Debounce” 指定输入 ref 值的变动。
定时刷新
假设我们要在上面的分页基础上实现定时轮询的功能:
代码语言:javascript复制const query = reactive({}) // 查询参数
const tick = useInterval(5000)
const pagination = reactive({pageNo: 1, pageSize: 10})
// data 包含了 list、loading、error、total 等信息
const data = useRequest(() => `/something?${qs({...query, ...pagination, _t: tick.value})}`)
我们看到上面的流程很自然。
现在加大难度,如果要在特定条件下终止呢?
代码语言:javascript复制const query = reactive({}) // 查询参数
// 默认关闭
const {counter: tick, pause, resume} = useInterval(5000, {controls: true, immediate: false})
const pagination = reactive({pageNo: 1, pageSize: 10})
// data 包含了 list、loading、error、total 等信息
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 来实现的话,代码大概如下:
代码语言:javascript复制
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$ 和 stop$ 组合在一起
poll$
.pipe(
// 使用 takeUntil 在 stop$ 发送事件后停止轮询
takeUntil(stop$)
)
.subscribe((i) => {
console.log(i);
});
因为 RxJS 的 Observable 是惰性的,只有被 subscribe 时才会开始执行,同理停止订阅就会中断执行。
中断执行后,如果要重新发起请求,重新订阅就好了。有点异曲同工之妙吧
省市区选择器
再来看一个稍微复杂一点的例子,常见的省市区选择器,这是一个典型的数据联动的场景。
我们先来看一个反例吧,我们的选择器需要先选择国家或地区,然后根据它来确定行政区域的划分,接着渲染各级行政区域选择器:
代码语言:javascript复制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):
代码语言:javascript复制export const AreaSelect2 = defineComponent({
props: {
// 表单值是数组格式,每一项保存的是区域的 id
modelValue: Array as PropType<number[]>,
},
emits: ['update:modelValue'],
setup(props, { emit }) {
//