在一个图像生成 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。
