跳到正文

我所理解的前端框架(React 和 Vue)最佳实践(2):利用派生值减少维护成本和心智负担

我所理解的前端框架(React 和 Vue)最佳实践

  1. 用表格组件减少通用渲染和交互的维护成本
  2. 利用派生值减少维护成本和心智负担(当前)

派生值由源数据和规则决定

派生值是根据已有数据和一条规则得到的结果。给定相同的输入,规则应当给出相同的结果。比如姓名表单存了「姓」和「名」,预览区显示两者去掉首尾空格后拼出的全名。姓和名是源数据,全名是派生值。筛选后的列表、根据购物车明细算出的商品小计,也有同样的关系。

表单保存姓陈和名小雨。全名规则去掉两个字段的首尾空格,再按姓和名拼接。预览读取陈小雨。

当一项值只取决于已知输入,更新就可以从输入发起。逻辑层维护必要的可编辑状态,并把结果交给展示层。展示组件只知道自己要渲染一个全名,拼接顺序和空格处理都由上游决定。规则将来要改时,也有一个明确的修改位置。

派生值省去重复状态的同步

假设表单原来显示「陈晓」,接口回填时把名改成「小雨」。两种写法都可以得到正确结果。区别在于预览读取的是已经写回的全名,还是从当前姓和名算出的全名。

接口把名从晓改成小雨。保存全名时先写名,再拼接并写回全名,预览读取保存的结果。若漏写全名,预览还会显示陈晓。声明派生规则时只写名,预览从当前输入拼出陈小雨,排查时核对输入和规则。

独立保存全名时,即使把同步集中在一个地方,也要确认编辑、回填和重置都能让它更新。遇到旧预览,还要追查全名是在哪次写入后落下的。声明派生规则后,拼接仍然要做,省去的是全名的写回和同步。排查时可以先确认输入是否正确,再检查拼接规则。

可以独立编辑的内容属于源数据

如果产品允许用户自定义展示名,就多了一项独立的输入。保存用户填写的覆盖值,显示时优先使用它,清除后再用当下的姓和名生成全名。预览区拿到的最终展示名仍然由规则决定。

姓陈、名小雨保持不变。覆盖字段初始为空,预览显示陈小雨。填写小雨同学后,预览采用覆盖值。清除覆盖后,预览重新使用拼接结果陈小雨。

类似地,如果要保留历史订单当时的成交金额,就要保存那次交易的结果。它记录的是过去的事实,当下的价格和数量已经无法提供同一份信息。是否该存,取决于这项信息能否从当前已有的输入还原。

派生值的定义只约束结果由什么决定。什么时候计算、是否缓存、谁订阅结果,是实现层的选择。下面四种写法都用同一组输入。把「晓」改成「小雨」后,预览应从「陈晓」更新为「陈小雨」。

Vue 组件用 computed 声明派生值

在单个组件里,ref 保存输入,computed 表达规则,模板读取全名。Vue 会追踪计算函数实际读取的响应式值。依赖变化后,下一次需要结果时重新求值。模板直接使用 fullName,无需自己维护订阅或依赖数组。 Vue 计算属性

<script setup lang="ts">
import { computed, ref } from 'vue';

const familyName = ref('陈');
const givenName = ref('晓');
const fullName = computed(() => familyName.value.trim() + givenName.value.trim());
</script>

<template>
  <label>
    姓
    <input v-model="familyName" />
  </label>
  <label>
    名
    <input v-model="givenName" />
  </label>
  <output>{{ fullName }}</output>
</template>

如果全名改用 ref 保存,再由 watch 写回,需要先设置初始值,还要等回调执行后才能读到新副本。以默认的 watcher 调度为例,把 givenName.value 改成「小雨」之后,紧接着同步读取全名,镜像 ref 仍是「陈晓」,而 computed 已经可以从新输入算出「陈小雨」。默认 watcher 通常在所属组件更新 DOM 前执行,差异出现在这次同步读取。需要把输入变化同步到外部系统时,再用 watch。Vue 的 watcher 回调时机

把名从晓改为小雨后,computed 在读取时按新输入求值,镜像 ref 等 watcher 回调写回

展示名也可以绑定可写 computed。读取时优先用覆盖值,输入时更新覆盖字段。点击「恢复默认」把覆盖字段设成 null,于是又会按姓和名拼接。用户把输入框清空得到的是空字符串,仍可作为手动输入交给表单校验。Vue 的可写计算属性

React 组件用 useMemo 标出派生值

React 渲染时可以直接从当轮的 props 或 state 算出全名。对于有业务含义、会交给展示层的命名派生值,我倾向于统一用 useMemo,哪怕计算很简单。读到它就知道先去看输入和计算规则。下面的 NamePreview 只接收结果,表单负责处理编辑。 React 派生状态指南

import { useMemo, useState } from 'react';

function NameForm() {
  const [familyName, setFamilyName] = useState('陈');
  const [givenName, setGivenName] = useState('晓');
  const fullName = useMemo(() => familyName.trim() + givenName.trim(), [familyName, givenName]);

  return (
    <>
      <label>
        姓 <input value={familyName} onChange={(event) => setFamilyName(event.target.value)} />
      </label>
      <label>
        名 <input value={givenName} onChange={(event) => setGivenName(event.target.value)} />
      </label>
      <NamePreview fullName={fullName} />
    </>
  );
}

function NamePreview({ fullName }: { fullName: string }) {
  return <output>{fullName}</output>;
}

把 useMemo 读作「这个值由其他值派生」是项目约定。React API 提供的是计算缓存,重算也要得到正确结果,返回的对象应当按只读结果消费。计算规则读了哪些响应值,依赖数组就要写全哪些。首次渲染执行到这个 Hook 就会求值。如果昂贵的列表分组只在用户展开预览时才需要,可以把计算放到展开时挂载的子组件里。 React useMemo · 依赖完整性检查

React Compiler 可以与已有的 useMemo 共存,并继续优化周围的表达式。React 18 可以设为 target: '18' 并安装 react-compiler-runtime。在 React 18.3.1 和 Compiler 1.0.0 的最小实验里,编译器把返回姓名对象的 useMemo 改写为缓存槽,还单独缓存了接收这个对象的子元素。编译前后的 HTML 一致。 编译目标配置 · Compiler 对已有手写 memo 的建议

Vue 状态管理用 Pinia getter 共享派生规则

如果编辑表单和顶部预览分属不同组件,而且已经用 Pinia 共享姓名,拼接规则也可以放在 store。Setup Store 里的 ref 成为 state,computed 成为 getter。编辑者写入 store.familyName 或 store.givenName,预览读取 store.fullName。要解构时,可以用 storeToRefs(store) 保留响应性。 Pinia Setup Store · Pinia getter

import { defineStore } from 'pinia';
import { computed, ref } from 'vue';

export const useNameStore = defineStore('name', () => {
  const familyName = ref('陈');
  const givenName = ref('晓');
  const fullName = computed(() => familyName.value.trim() + givenName.value.trim());

  return { familyName, givenName, fullName };
});

只由某个页面使用的派生值可以留在该页面。比如全局 store 保存人物列表,页面自己的搜索词和列表筛选就可以由页面维护。上移的是多个消费者确实共用的数据和规则。

React 状态管理用 Jotai 派生 atom 共享规则

用 Jotai 共享姓名时,两个基础 atom 保存输入。派生 atom 通过 get 读取依赖,只传读取函数就能把全名公开为只读 atom。表单通过 useAtom(familyNameAtom) 或 useAtom(givenNameAtom) 读写输入,预览通过 useAtomValue(fullNameAtom) 读取全名。两个组件使用同一个 store 时,改任一输入都会更新预览。 Jotai atom · Jotai useAtom

import { atom, useAtomValue } from 'jotai';

export const familyNameAtom = atom('陈');
export const givenNameAtom = atom('晓');
const fullNameAtom = atom((get) => get(familyNameAtom).trim() + get(givenNameAtom).trim());

export function NamePreview() {
  const fullName = useAtomValue(fullNameAtom);
  return <output>{fullName}</output>;
}

已有 MobX 时,可以用 computed 追踪 observable,由 observer 组件消费。已有 Redux 时,可以用 selector 从 store 计算结果,只有昂贵计算或引用复用需要时再考虑 memoized selector。源数据已经在哪一层,就从那一层找合适的派生能力。 MobX computed · Redux 派生数据指南

Preact Signals 的 useComputed 也能追踪读到的 Signal 并按需求值。采用它就要给 Signal 划分读写和订阅边界。在 React 19.3.1 和 @preact/signals-react 3.12.0 的最小实验中,useComputed 同时读取 Signal 和普通 prop,只改 prop 后仍返回旧值。和 Compiler 一起用时,也要核对编译后的组件是否随 Signal 更新。只为了省下组件中 useMemo 的依赖数组,我会保留现有的数据流。 Signals 的 React 接入 · useComputed 实现 · Compiler 兼容性讨论

总结

整理页面状态时,可以拿一次重置验证设计。假设表单只把可编辑的字段还原到初始值,预览是否会自动回到对应的结果?如果还得另外给预览赋值,就沿着这次写入看看哪些字段可以改成派生值。状态放在组件里还是共享 store 里,都可以用这条路径检查。