02前端5 min

全局 store 什么时候才配有一席之地

全局状态不能用方便来正当化。它的正当理由是:某个中间组件收到了它自己不用的 props,只是为了往下传。

作者 Salva Castells

封面图: Fabian Horst, CC BY-SA 4.0.
本文目录

在一个图像生成 SaaS 里,我有一个页面带两个子组件:一个放控件的侧边栏和一个图库对话框。页面自己什么都不画。它就是这样:

export default function BoardPage() {
  const [attachedImages, setAttachedImages] = useState<AttachedImage[]>([]);
  const [galleryOpen, setGalleryOpen] = useState(false);
  const [galleryFilters, setGalleryFilters] = useState<GalleryFilters>({
    type: "all",
    models: [],
  });

  return (
    <div className="flex h-screen overflow-hidden">
      <Sidebar attachedImages={attachedImages} ... />

这些状态页面一个都不用。它声明它们,只因为它是真正使用它们的那两个组件的共同祖先。这就是最诚实形态的 prop-drilling:状态上升到最不碍事的地方,再顺着一条 props 管道往下流。

让我决定迁移的那个具体信号

不是"props 太多"。它更具体:一个组件收到一个 prop,不读它,原封不动传给下一个。

在那个 board 里,侧边栏收到 selectedModels 和 onModelsChange 只是为了转发给模型 popover。它并不看这些值。页面从自己的 useState 拿到 galleryFilters,只是为了交给对话框。每次我加一个新的生成字段,都要动三个文件,而这三个文件都没有和那个字段相关的逻辑。

第二个信号是从第一个衍生的:props 的类型变成了状态的镜像。GalleryDialogProps 有四项——galleryOpen、galleryFilters、onGalleryClose、onGalleryFiltersChange——没有一项描述这个对话框做什么。它们描述的是别人怎么告诉它。

迁移是一个下午里连着的四个提交。先是一个带图库和附加图片的 use-board-store.ts,页面去消费它。然后子组件直接从 store 读,于是页面变成了这样:

export default function BoardPage() {
  return (
    <div className="flex h-screen overflow-hidden">
      <Sidebar />
      <GalleryDialog onImageClick={...} />

零个状态 props。这个组件重新变回了它声称的东西:一个布局。当我在第三个提交里把 prompt、selectedAspectRatio、selectedModels 和 imageCount 放进 store 时,侧边栏少了 83 行,多了 9 行。

我不放进 store 的东西

这种迁移之后的诱惑是把一切都放进去。不该这么做,而且 diff 本身就说明了这一点。

模型 popover 有四个 props:open、onOpenChange、selectedModels、onModelsChange。只有两个进了 store。另外两个留下了,但下沉了:

export function ModelsPopover() {
  const { selectedModels, setSelectedModels } = useBoardStore();
  const [open, setOpen] = useState(false);

判断标准是谁还需要读这个值。selectedModels 被 popover 读,被侧边栏读来算预估成本,被比例选择器读来知道哪些宽高比可用。open 除了 popover 自己没人读。要是我把它提到 store,全局状态里就会有五个毫无理由的 xOpen 布尔值,外加某天两个同时打开的 bug。

我也不会把任何可推导的东西放进 store。可用比例的计算是所选各模型支持比例的交集。它是 selectedModels 的一个函数,所以它住在用它的地方,按需重算。store 里放一个 availableAspectRatios 字段就是一份需要保持同步的副本,而它失去同步的那天,用户会看到一个模型不接受的比例。

  • 放进 store: 被树上两个不同分支读取的东西。
  • 组件本地: 一次交互中转瞬即逝的东西——开/关、hover、焦点。
  • 哪儿都不放: 能从上面两者算出来的东西。

那个画布编辑器:树是扁平的,所以依然没有 store

差不多同一时期我在做一个办公室平面图编辑器,类似 Figma 但是画工位的,基于 Konva。直觉在那里会说"这个明摆着要 store":有一块画布、一个工具栏、一个属性面板,而且它们都在动同一批图形。

它没有 store。依然是根组件里的 useState:

const [floorPlan, setFloorPlan] = useState<FloorPlan>(initialPlan || DEFAULT_FLOOR_PLAN);
const [editorState, setEditorState] = useState<EditorState>({
  selectedShapeId: null, tool: "select", zoom: 1, isDragging: false,
});

它能成立,因为树是扁平的。编辑器有三个直接子组件,每个都只收到它真正要用的东西。没人转发任何东西。它以好的方式满足了上面那个标准:没有任何中间组件收到它不读的 props。

那个编辑器里真正造成伤害的不是状态,而是类型爆炸。有九种工位变体被写成了九个不同的类型:dual-desk-facing、quad-desk-parallel、hex-desk-facing、octo-desk-parallel。九个除了名字完全相同的 interface,画布 switch 里九个分支,九个组件文件。修好它的重构把它们塌缩成两个,multi-desk-facing 和 multi-desk-parallel,外加一个 seats 字段:

{Array.from({ length: seats }, (_, index) => (
  <SingleDesk key={index} ... />
))}

它删掉了七个文件,并从预订视图里画平面图的那个组件中去掉了 453 行。任何 store 都修不好这个,因为问题从来不是状态住在哪,而是它有多少种形状。

状态离开 props 之后会变的东西

确实会失去一些东西:看一个组件的签名,你不再知道它依赖什么。你得打开它。测试不能再通过 props 注入状态,得先准备好 store。而那个提醒你耦合过度的摩擦也消失了——props 管道很丑,但它是耦合程度的一个诚实计数器。

所以 store 的位置是挣来的,不是送的。问题不是有多少个 props。而是路径上有没有哪个组件收到一个它不用的 prop。