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

2023-10-20 15:54:14 浏览数 (2)

写这篇文章的动机可以追溯到 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 }) {
    // 


	

0 人点赞