2.3 createStore与setState:复杂状态的两个极端


2.3 createStore 与 setState:复杂状态的两个极端

本节摘要:面对嵌套对象与列表,React 的答案是"不可变更新"——整棵子树克隆出新引用再触发重渲染;Solid 的答案是 createStore——把对象做成路径级响应式,改哪个字段就通知哪个字段的订阅者。本节并排实现同一个"用户设置面板",展示路径赋值、produce 草稿修改、reconcile 大对象对齐三种写法,并给出 Signal 与 Store 的选型分界。

不是所有状态都适合压扁成一个个独立的 Signal。用户设置面板长这样:{ profile: { name, avatar }, notify: { email, sms, push }, tags: [...] }——三层嵌套加一个数组。拆成七八个 Signal 当然可行,但读写会散落各处;整体存进一个 Signal 又回到了老问题:改一个开关,整个对象换新引用,所有读它的地方全部重新计算。Solid 为这种形态准备了专用原语:createStore

同一个面板的两份实现

React 版本,感受一下不可变更新的样板量:

function Settings() { const [state, setState] = useState(initialState); const rename = name => setState(s => ({ ...s, // 克隆第一层 profile: { ...s.profile, name } // 再克隆第二层 })); const toggleNotify = key => setState(s => ({ ...s, notify: { ...s.notify, [key]: !s.notify[key] } // 每个字段一套写法 })); const addTag = tag => setState(s => ({ ...s, tags: [...s.tags, tag] })); }

Solid 版本:

import { createStore, produce } from "solid-js/store"; function Settings() { const [state, setState] = createStore(initialState); const rename = name => setState("profile", "name", name); // 路径赋值 const toggleNotify = key => setState("notify", key, v => !v); const addTag = tag => setState("tags", t => [...t, tag]); // 数组仍推荐函数式 // 嵌套层级深、改动点多的场景,用草稿式修改更顺手 const bulkEdit = () => setState(produce(s => { s.profile.name = "新名字"; s.notify.email = false; s.tags.push("临时标签"); })); }

setState("profile", "name", name) 这行是两种哲学的分水岭:它没有克隆任何东西,只是在 store 的代理结构上找到那个字段,换掉值,然后通知恰好订阅了这条路径的绑定。React 版本做同样的事要克隆两层对象——不是 React 喜欢浪费,而是它的更新通道只有"引用变化触发重渲染"这一条,不换引用,通道就静默。不可变更新是重渲染模型的燃料,细粒度响应式不需要烧这种燃料。

图:store 的路径级更新——只通电被改的那条支路

图:store 的路径级更新——只通电被改的那条支路

produce 与 reconcile 的分工

produce 把 Immer 式的写法内建进来:草稿对象上随便改,收尾时 Solid 自动算出"实际动了哪些路径",只通知这些路径。适合改动点分散、逻辑连续的场景,比如表单批量回填。

reconcile 处理另一个极端:你从服务端拿到了一份全新的大对象(整个设置面板、一篇文档),想整体替换又不想让"值没变的路径"白白触发更新。setState(reconcile(newData)) 会做深度对齐,逐路径比较,只把真正变化的路径广播出去。它相当于把 diff 从"虚拟 DOM 对 DOM"搬到了"新数据对 store",粒度依然是路径级。

⚠️ store 里的对象是代理,展开运算符会把响应式丢掉。{...state.profile} 得到的是静态快照,后续更新不会流进来。需要"挑几个字段还保持响应"时,用第 3 章会讲的 mergeProps 或在绑定层按路径读取。

Signal 还是 Store:选型分界

给一个可操作的判断顺序。值是原始类型(数字、字符串、布尔)——用 Signal。值是对象但整体替换、从不深改(比如一个选中的 tab 对象)——也用 Signal。值会被深层修改,或者列表条目多、条目字段多——用 Store。状态需要在组件树深处共享——两种都可以配合 Context(第 5 章)。

还有一个常被忽略的维度:迁移成本。React 项目里已按不可变规范写好的 reducer 逻辑,搬到 Signal 上要重写更新路径;搬到 Store 加 produce 上则几乎是平移——草稿式修改与 reducer 内的"伪可变"写法形似度很高。团队从 Redux 系迁移时,Store 往往是最先落地的那块拼图。

从 Redux 到 Store:一次真实的迁移推演

把 2.2 结尾埋的线头接上:Redux 系项目的迁移最顺。推演一个真实片段——购物车的 reducer:

// 原 reducer:集中描述状态迁移 function cartReducer(state, action) { switch (action.type) { case "add": return { ...state, items: [...state.items, action.item] }; case "remove": return { ...state, items: state.items.filter(i => i.id !== action.id) }; case "clear": return { ...state, items: [] }; } } // Solid 版:意图函数语义不变,样板消失 const [cart, setCart] = createStore({ items: [] }); const actions = { add: item => setCart("items", list => [...list, item]), remove: id => setCart("items", list => list.filter(i => i.id !== id)), clear: () => setCart("items", []), };

迁移要点有两条。意图函数仍然集中、仍然可以单独测试——可测性是 reducer 范式的核心资产,迁移后不丢。时间旅行调试这一配套设施则没有对位物:状态树不再是纯函数链的产物,回放工具失效。放弃它之前先问一句真实使用率,多数团队的历史回放使用频率低到不足以构成保留理由。

问题:store 里能存函数或类实例吗?

能存但不响应。store 的代理只拦截数据访问,函数成员原样透传;第三方实例(地图对象、播放器)放进 store 不会获得响应性,还可能因代理包装出诡异行为。规范做法是:store 只放数据,实例用组件内变量加 onCleanup 管理,需要联动时用信号存实例的"状态字段"而非实例本身。

本节要点回顾

  • 不可变更新是燃料:React 靠换引用触发通道,Store 靠路径通知,克隆样板在 Solid 里直接消失。
  • 三种更新形态:路径赋值管单点,produce 管分散连续改动,reconcile 管大对象整体替换。
  • store 对象是代理:展开即断响应,按路径读才不断流。
  • 选型看修改形态:深改与长列表进 Store,整体替换留 Signal。

复合状态讲完了。下一节补上派生计算这块拼图——createMemouseMemo,"提示"与"保证"的一字之差。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U