Vue 开发的正确姿势:响应式编程思维


狗不理
狗不理 2023-07-19 14:03:54 63420
分类专栏: 资讯

从广义的的“响应式编程(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)

// 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 的很多东西并不能直接套用过来,但思想和原则是可以复用的。

其中一个重要的思想就是:管道变换。这是一种思维方式的转变,在以往的编程设计中,我们更多操心的是类、模块、数据结构和算法。而管道变换我们会把程序视作从输入到输出的一个变换去构思:

# “列出目录树中最长的五个文”
find . -type f | xargs wc -l | sort -n | tail -5

不要把数据看作是遍布整个系统的小数据池,而要把数据看作是一条浩浩荡荡的河流。

另一方面,编写 RxJS 代码一些原则,对我们编写 Vue 代码也大有裨益:

  • 避免副作用。RxJS 的操作符应该是没有副作用的函数,只关注输入的数据,然后对数据进行变换,传递给下一个。

  • 避免外部状态/缓存状态。外部状态也是副作用的一种,单独拎出来讲,是因为我们在 Vue 中创建外部状态太容易了,而 RxJS 则相对来说麻烦一些,毕竟外部状态和事件流显得格格不入。

    在 RxJS 中管道是自包含的, 所有的状态从一个操作器流向下一个操作器,而不需要外部变量:

    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 方法或者组件代码
  • 状态被随意修改,修改不属于管辖范围内的状态

图片

实践

分页

先从简单的场景开始: 分页请求。

❌ 常规的做法:

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})

// 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 发起数据请求

⚠️ 常规的实现:

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  实现:

const searchInput$ = fromEvent(searchInput, 'input').pipe(
  // 使用 debounceTime 进行防抖处理
  debounceTime(800),
  // 使用 map 将事件转换为输入框的值
  map(event => event.target.value),
  // 使用 distinctUntilChanged 进行去重处理
  distinctUntilChanged(),
  // 使用 switchMap 进行请求并转换为列表数据
  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})

// data 包含了 list、loading、error、total 等信息
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})

// 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 来实现的话,代码大概如下:


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 时才会开始执行,同理停止订阅就会中断执行。

中断执行后,如果要重新发起请求,重新订阅就好了。有点异曲同工之妙吧




省市区选择器

再来看一个稍微复杂一点的例子,常见的省市区选择器,这是一个典型的数据联动的场景。


我们先来看一个反例吧,我们的选择器需要先选择国家或地区,然后根据它来确定行政区域的划分,接着渲染各级行政区域选择器:

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、数据流混乱…

 

图片
Untitled

让我们回归到业务本身,我们为什么需要不恪守这样的联动关系去组织代码呢?

可以的,一个比较重要的技巧就是自顶而下地去分析流程/数据流变换的过程。

首先从国家开始,只有用户选择了指定国家之后,我们才能获取到区域的结构信息(是省/市/区, 还是州/城市,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 单向数据流

网站声明:如果转载,请联系本站管理员。否则一切后果自行承担。

本文链接:https://www.xckfsq.com/news/show.html?id=26694
赞同 1
评论 0 条
狗不理L2
粉丝 4 发表 30 + 关注 私信
上周热门
银河麒麟添加网络打印机时,出现“client-error-not-possible”错误提示  1487
银河麒麟打印带有图像的文档时出错  1405
银河麒麟添加打印机时,出现“server-error-internal-error”  1194
统信操作系统各版本介绍  1116
统信桌面专业版【如何查询系统安装时间】  1114
统信桌面专业版【全盘安装UOS系统】介绍  1068
麒麟系统也能完整体验微信啦!  1026
统信【启动盘制作工具】使用介绍  672
统信桌面专业版【一个U盘做多个系统启动盘】的方法  616
信刻全自动档案蓝光光盘检测一体机  526
本周热议
我的信创开放社区兼职赚钱历程 40
今天你签到了吗? 27
信创开放社区邀请他人注册的具体步骤如下 15
如何玩转信创开放社区—从小白进阶到专家 15
方德桌面操作系统 14
我有15积分有什么用? 13
用抖音玩法闯信创开放社区——用平台宣传企业产品服务 13
如何让你先人一步获得悬赏问题信息?(创作者必看) 12
2024中国信创产业发展大会暨中国信息科技创新与应用博览会 9
中央国家机关政府采购中心:应当将CPU、操作系统符合安全可靠测评要求纳入采购需求 8

添加我为好友,拉您入交流群!

请使用微信扫一扫!