02 状态到底该放哪:Zustand、Service 和 useState 的分工

4 阅读8分钟

上一篇说的是代码放在哪个目录。这一篇说的是另一个更容易吵起来的问题:状态放在哪。

在 React 里,同一个状态你几乎可以放在任何地方:组件的 useState、Context、全局 store、一个普通的 class 实例,甚至一个模块级变量。它们都能工作,区别在于出了问题的时候,你要花多久才能找到「是谁改了它」,以及它变化的时候,有多少东西会跟着重新渲染。

旧代码在这件事上没有规矩,状态在 Redux、组件和几个 Context 之间来回分布。重写的时候我想先把规矩定下来。


三条规则

最后定下来的规则很短:

  1. 跨组件共享的,放 Zustand store。 比如房间是否连上、AI 当前是在听还是在说、用户是不是正在录音。好几个不相关的组件都要读,有的还要在 React 外面读。
  2. 高频变化的数据,放一个普通的 class,用 useSyncExternalStore 接进 React。 典型就是消息列表。
  3. 只在一棵组件树里用的,就用 useState。 输入框里的文字、某个面板展没展开。

另外两条补充:不需要触发渲染的可变值(定时器 ID、上一次的状态)用 useRef;Context 先不用,留着以后真有「一棵子树共享」的需求再说。

规则本身没什么新意,网上随便一搜都差不多。真正让我对它有体感的,是中间踩的两个坑。


坑一:一个打字动画,让整个列表每 30 毫秒重渲染一次

AI 的回复是有打字效果的:文字一个字一个字地出现,像有人在打字。

一开始我是把这个效果放在 service 里做的。思路很自然:消息列表在 MessageStore 里,AI 的文字到了,我就起一个定时器,每隔几十毫秒往这条消息里多塞一个字,然后通知订阅者更新。这样所有的状态都在一个地方,界面只管渲染,很「干净」。

当时的代码大概是这样的:

class TypingAnimationService {
  private activeTasks = new Map<string, TypingTask>();

  constructor(private readonly messageStore: MessageStore) {}

  startTyping(messageId: string, fullText: string, speed: number) {
    const interval = Math.max(10, Math.round(30 / speed));
    const task = { fullText, currentIndex: 0, timer: setInterval(() => this.tick(messageId), interval) };
    this.activeTasks.set(messageId, task);
  }

  private tick(messageId: string) {
    const task = this.activeTasks.get(messageId)!;
    task.currentIndex++;
    // 每一跳都往全局的 MessageStore 里写一次
    this.messageStore.updateMessage(messageId, (msg) => ({
      ...msg,
      text: task.fullText.slice(0, task.currentIndex),
    }));
  }
}

而界面这边,是通过 useSyncExternalStore 订阅整个 MessageStore 的:

class MessageStore {
  private messages: ChatMessage[] = [];
  private listeners = new Set<() => void>();

  subscribe = (listener: () => void) => {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener);
  };
  getMessages = () => this.messages;

  updateMessage(id: string, updater: (m: ChatMessage) => ChatMessage) {
    const idx = this.messages.findIndex((m) => m.id === id);
    this.messages = [...this.messages];  // 新数组,快照变了
    this.messages[idx] = updater(this.messages[idx]);
    this.listeners.forEach((l) => l());  // 通知所有订阅者
  }
}

// 消息列表
const messages = useSyncExternalStore(store.subscribe, store.getMessages);

打字效果是有了,但这个写法有一个当时没想清楚的代价。

说穿了很简单。useSyncExternalStore 的订阅是以「整个 store」为单位的:store 一通知,订阅它的组件就会去拿最新的快照,快照变了就重新渲染。而我每 30 毫秒就往 store 里塞一个字,生成一个新的消息数组,于是整个消息列表每 30 毫秒都要走一遍渲染流程。屏幕上只有最后一条消息在变,其他几十条也被拉着一起陪跑。

后来的做法是把打字效果挪到气泡组件自己身上。service 只负责把「完整的文字」放进消息里,再加一个标记说「这条需要打字效果」;气泡组件里有一个 useTypingAnimation hook,自己开定时器,自己维护「当前显示到第几个字」:

export const useTypingAnimation = (
  isTextStreaming: boolean,
  fullText?: string,
  speed = 1,
  messageId?: string,
  onComplete?: () => void,
): string => {
  const [displayText, setDisplayText] = useState("");
  const indexRef = useRef(0);
  const onCompleteRef = useRef(onComplete);
  onCompleteRef.current = onComplete;

  useEffect(() => {
    if (!isTextStreaming || !fullText) return;
    indexRef.current = 0;
    setDisplayText("");
    const interval = Math.max(10, Math.round(40 / speed));
    const timer = setInterval(() => {
      indexRef.current++;
      if (indexRef.current >= fullText.length) {
        clearInterval(timer);
        setDisplayText(fullText);
        onCompleteRef.current?.();   // 打完了,告诉外面一声
      } else {
        setDisplayText(fullText.slice(0, indexRef.current));
      }
    }, interval);
    return () => clearInterval(timer);
  }, [isTextStreaming, fullText, speed, messageId]);

  return isTextStreaming ? displayText : "";
};

setDisplayText 是这个气泡自己的 state,定时器每跳一次,只有这一个气泡在重新渲染,列表和其他气泡完全不受影响。

这次改动的提交说明我写的是「把打字动画挪到组件本地,消除高频的全局重渲染」,现在回头看,这句话差不多就是第二条规则的由来。


但挪到本地是有代价的

这里有一个当时没想到的副作用。

打字效果挪到组件里之后,service 就不知道「这条消息打完了没有」了。可偏偏有别的逻辑依赖这件事:比如消息下面有一个可以展开的详情面板,展开的时候列表要跟着滚动;如果 service 还以为这条消息「正在打字」,它就会一直让列表跟着最新内容滚,用户展开面板之后,页面停不下来。

我第一次修这个问题,是让 service 按文字长度和打字速度估算一个时间,到点了就认为打完了。这个改动当天就回滚了。它的问题在于,真正在打字的是组件,service 只是在猜:组件那边的定时器什么时候开始、中途有没有被打断、用户有没有切换文字的显示方式,service 都不知道,算出来的时间迟早会和实际进度对不上。

最后的做法是反过来:组件打完字之后,通过一个回调告诉 service「这条打完了」,service 再把标记清掉。

// 气泡组件
const typingText = useTypingAnimation(!!message.isTextStreaming, message.fullText, message.speed, message.id,
  () => onTypingComplete?.(message.id),
);

// service:真正的「打完了」只由组件来报告
markTypingComplete(messageId: string) {
  this.messageStore.updateMessage(messageId, (msg) => ({
    ...msg,
    text: msg.fullText || msg.text,
    isTextStreaming: false,
  }));
}

注意那个 onCompleteRef:回调每次渲染都可能是一个新函数,如果把它放进 effect 的依赖里,定时器就会被反复重启。用 ref 存住最新的回调,effect 里只读 ref,是这种「定时器 + 回调」组合里很常用的一个小技巧。

这件事让我意识到,「状态放在离用到它的地方最近的位置」这条原则,有一个隐含前提:这个状态没有别人关心。 一旦有别人关心,你要么把它提上去,要么建一条明确的通知路径。最危险的做法是让两边各自猜,猜着猜着就对不上了。


坑二:一个 Toast 也能让动画卡顿

还有一个更隐蔽的。

App 的老代码里,弹 Toast 是 dispatch 一个 Redux action。V2 一开始也沿用了这个做法,比如引导流程里,进度推进到一定程度会弹一个鼓励的提示。

结果发现,弹 Toast 的时候,AI 的打字动画会顿一下。

原因是一次 Redux dispatch,会让所有订阅了 Redux 的组件都去检查一遍要不要更新,其中一大片跟这个页面毫无关系。平时感觉不到,但在一个正在跑几十毫秒定时器动画的页面上,这一下就很明显。

第一步是先换成原生的 Toast,绕开 Redux。几天之后,V2 干脆自己做了一个很小的 Toast store,用 Zustand 实现,样式和老的保持一致,但只有页面里那一个 Toast 组件订阅它。整个 store 就这么几行:

export const useToastStore = create<ToastStore>((set) => ({
  message: null,
  showToast: (message) => set({ message }),
  hideToast: () => set({ message: null }),
}));

// 页面里只有一个组件订阅它
const ToastOverlay = () => {
  const message = useToastStore((s) => s.message);
  return message ? <Toast text={message} /> : null;
};

// 任何地方都可以弹,包括 service 里
useToastStore.getState().showToast("Failed to start, please try again");

弹一次 Toast,重新渲染的只有 ToastOverlay 这一个组件。这也是为什么项目约定里有一条「V2 不要 dispatch Redux action」。它不是洁癖,是真的会卡。


Zustand 的一个小习惯

最后说一个很小、但我觉得很值得的习惯。

Zustand 的 store 可以整个解构出来用:

// 不要这样:store 里任何一个字段变化,这个组件都会重新渲染
const { speed, isRecording, agentState } = useLiveKitStore();

我们的约定是一律用选择器,一个字段一个字段地订阅:

const speed = useLiveKitStore((s) => s.speed);
const isRecording = useLiveKitStore((s) => s.isRecording);

连 action 也是这样。之前有一些地方写的是 useStore.getState().showAlert(...),后来统一改成了 const showAlert = useStore((s) => s.showAlert)。action 的引用是稳定的,用选择器订阅它不会多一次渲染,但能让组件里的依赖关系一眼看清楚。getState() 只留给真正不在渲染流程里的地方,比如 service 内部、一个和组件生命周期无关的回调。

写多了你会发现,这个习惯最大的好处不是性能,而是读代码的时候,看一眼组件开头那几行选择器,就知道它关心哪些状态。


一张决策顺序

最后,我们在文档里留了一个判断顺序,新加状态的时候照着过一遍:

1. 只在当前组件里用?                → useState
2. 可变的值,不需要触发渲染?          → useRef
3. 好几个组件都要用?                  → Zustand store
4. 变化很频繁,需要精确控制通知?      → class + useSyncExternalStore
5. 只在某一棵子树里共享?              → Context
6. 要读老代码的 Redux 数据?           → shared 里包好的 hook

这张表本身没什么了不起的。它真正的作用是,当两个人对「这个状态该放哪」有分歧的时候,有一个可以一起对照的东西,而不是各凭感觉。