React 外部状态存储与 React 声明周期同步

欢迎来到“React 状态同步大教堂”。我是你们的主讲人,一个在 React 代码里摸爬滚打了十年的老油条。

今天我们要聊的话题,听起来有点枯燥,甚至有点像是在给计算机念经:“生命周期”、“副作用”、“外部状态”。但在座的各位,不管是刚入门的新手,还是觉得自己已经看透红尘的老手,请把手里的咖啡放下,听我说完。因为如果你搞不懂这个,你的应用迟早会变成一个“幽灵应用”——它在内存里活着,但在外部世界里,它早就死了。

我们要解决的核心问题是:当 React 的内部状态(比如 UI 上的数字变了)和外部存储(比如数据库、Redux、或者 LocalStorage)发生冲突时,你怎么保证它们是一致的?

别担心,这不像处理婆媳关系那么难,虽然有时候感觉差不多。


第一部分:类组件的旧时代遗物

在 Hooks 出现之前,React 给了我们一套非常明确的规则,就像交通信号灯一样。那时候,外部状态同步全靠这三盏灯:componentDidMount(挂载),componentDidUpdate(更新),componentWillUnmount(卸载)。

1. 挂载:你是谁?你在哪?

当你把一个组件扔到页面上,React 就像是在招租。componentDidMount 就是租客进门的那一刻。

这是你第一次接触到“外部状态”的最佳时机。通常,外部状态是静态的或者需要从服务器拉取的。你不能在构造函数里做这件事,因为构造函数只负责“搭骨架”,不负责“搞装修”。

场景: 假设我们有一个用户信息组件,数据存在一个叫 UserService 的外部服务里。

import React, { Component } from 'react';

class UserProfile extends Component {
  constructor(props) {
    super(props);
    this.state = {
      user: null,
      loading: true
    };
  }

  // 这就是那盏“挂载”绿灯
  componentDidMount() {
    console.log("挂载了!我要去拉取用户数据了!");

    // 去外部存储(比如 API 或 Redux)拿数据
    UserService.getUser(this.props.userId)
      .then(data => {
        // 更新 React 状态
        this.setState({ user: data, loading: false });
      })
      .catch(err => {
        this.setState({ loading: false, error: err.message });
      });
  }

  render() {
    if (this.state.loading) return <div>正在加载你的灵魂...</div>;
    if (this.state.error) return <div>哎呀,加载失败了。</div>;

    return (
      <div>
        <h1>欢迎, {this.state.user.name}</h1>
        <p>ID: {this.state.user.id}</p>
      </div>
    );
  }
}

你看,这就是同步的第一步:初始化。外部存储告诉 React “你是谁”,React 告诉用户“我拿到你了”。

2. 更新:别光顾着照镜子

当你修改了 this.state,或者父组件传了新的 props,React 就会触发 componentDidUpdate。这就像是你换了个发型,或者换了件衣服,你得去照照镜子确认一下,顺便告诉你的经纪人(外部服务):“嘿,我变了!”

但是,这里有个巨大的陷阱。如果你在 componentDidUpdate 里直接去更新外部存储,而外部存储的更新又触发了 React 的状态更新……哎哟,这就死循环了。

正确的姿势是:比较。

componentDidUpdate(prevProps, prevState) {
  // 只有当用户 ID 真的变了,我才去请求新数据
  if (prevProps.userId !== this.props.userId) {
    console.log("用户变了,我要去拉取新数据了!");
    this.setState({ loading: true }); // 先把 UI 变成 loading
    UserService.getUser(this.props.userId)
      .then(data => {
        this.setState({ user: data, loading: false });
      });
  }

  // 另一种场景:如果 React 状态变了,同步到外部存储
  if (this.state.user && prevState.user !== this.state.user) {
    console.log("用户信息变了,我要去保存!");
    UserService.saveUser(this.state.user);
  }
}

3. 卸载:记得关灯

这是新手最容易忽略的。当你离开这个页面,组件被销毁了。如果此时你的组件还在默默地向外部存储发送心跳包,或者订阅了某个 WebSocket,那么这个连接就会一直存在,直到服务器超时。

这就好比你在酒店退房了,但是你还在走廊里大声放音乐。房东会恨死你的。

componentWillUnmount() {
  console.log("我要走了,我要断开连接了!");
  // 绝对不能忘!
  UserService.disconnect(); 
  // 或者 unsubscribe(eventId)
}

第二部分:Hooks 时代,副作用大爆发

好,时光飞逝,React 16.8 带来了 Hooks。类组件那套显式的生命周期被“副作用”这个词取代了。

useEffect 就像一个万能插座。它可以在组件渲染后执行任何事:订阅数据、修改 DOM、或者更新外部状态。

1. 初始化与挂载

useEffect 里,[](空依赖数组)就等同于 componentDidMount

import React, { useState, useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    console.log("组件挂载了,或者 userId 变了,我要去拉数据。");

    const controller = new AbortController(); // 防止竞态条件的利器

    UserService.getUser(userId, { signal: controller.signal })
      .then(data => {
        setUser(data);
        setLoading(false);
      })
      .catch(err => {
        if (err.name !== 'AbortError') {
          console.error(err);
        }
      });

    // 返回清理函数,相当于 componentWillUnmount
    return () => {
      console.log("组件卸载了,或者 userId 变了,我要取消请求。");
      controller.abort(); // 取消那个还没回来的请求
    };
  }, [userId]); // 依赖项:userId

  if (loading) return <div>加载中...</div>;
  return <div>用户: {user?.name}</div>;
}

这里有个非常重要的点:清理函数useEffect 的清理函数会在组件卸载时执行,也会在组件因为依赖项变化而重新运行之前执行。这意味着,如果你快速切换用户 ID,React 会先执行上一个 userId 对应的 useEffect 的清理函数(取消请求),然后重新运行新的 useEffect

2. 同步外部变更回 React

有时候,外部状态变了,React 不知道。比如用户在另一个标签页登录了,或者 Redux 的 store 更新了。

我们怎么监听外部变化?

方案 A:轮询(不推荐,除非你疯了)

useEffect(() => {
  const interval = setInterval(() => {
    // 去检查外部状态
    if (externalStore.isDirty) {
      // 更新 React 状态
      setLocalData(externalStore.getData());
    }
  }, 1000);
  return () => clearInterval(interval);
}, []);

这就像你每隔一秒去看一眼手机有没有消息,虽然能收到,但太累了,而且容易漏。

方案 B:事件监听(推荐)
假设外部状态是一个全局的 PubSub 事件。

import PubSub from 'pubsub-js';

function MyComponent() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    // 订阅事件
    const token = PubSub.subscribe('GLOBAL_UPDATE', (msg, data) => {
      console.log("收到外部广播:", data);
      setCount(data.value);
    });

    // 清理:退订
    return () => {
      PubSub.unsubscribe(token);
    };
  }, []);

  return <div>计数: {count}</div>;
}

第三部分:Redux 与 Context API 的同步艺术

这是 React 生态里最常见的外部状态存储。它们就像一个巨大的中央银行。我们的组件是储户,React 是银行柜台。

1. Redux 的订阅陷阱

在 Redux 里,我们常用 useSelector 来获取状态。但是,如果你在 useEffect 里去 dispatch action,这通常是不对的,因为 dispatch 会导致组件重新渲染,而重新渲染会再次触发 useEffect(如果依赖项没控制好),导致无限循环。

正确的做法是利用 Redux 的订阅机制,或者在 useEffect 里做那些“只做一次”的副作用。

错误示范:在 useEffect 里 dispatch

useEffect(() => {
  // 危险!这会触发 Redux 更新 -> 触发 Selector 变化 -> 触发组件重渲染 -> 再次触发 useEffect
  dispatch(incrementCounter());
}, [dispatch]); 

正确示范:利用 useSelector 监听外部状态
Redux 是单向数据流。只要你在 Redux 里更新了数据,所有订阅了该数据的组件都会收到通知。

import { useSelector, useDispatch } from 'react-redux';

function Counter() {
  const count = useSelector(state => state.count);
  const dispatch = useDispatch();

  useEffect(() => {
    console.log("计数器变了,我需要执行一些副作用,比如记录日志");
  }, [count]);

  return (
    <div>
      <h1>{count}</h1>
      <button onClick={() => dispatch({ type: 'INCREMENT' })}>加 1</button>
    </div>
  );
}

在这里,React 和 Redux 是自动同步的。你不需要手动写 componentDidUpdate 去同步 Redux,因为 React 的渲染循环会自动处理。但是,你依然需要 useEffect 来处理副作用(比如发起 API 请求,或者调用第三方库)。

2. Context API 的同步

Context API 本质上也是一个订阅者模式。当你 Provider 的 value 变了,所有 Context 的消费者都会重新渲染。

const ThemeContext = React.createContext();

function App() {
  const [theme, setTheme] = useState('dark');

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      <Toolbar />
    </ThemeContext.Provider>
  );
}

function Toolbar() {
  const { theme, setTheme } = useContext(ThemeContext);

  useEffect(() => {
    // 这里同步到外部系统,比如修改 document 的 title 或者发送埋点
    document.title = `当前主题: ${theme}`;
    console.log(`主题已切换为: ${theme}`);
  }, [theme]);

  return (
    <button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
      切换主题
    </button>
  );
}

第四部分:LocalStorage 与 IndexedDB 的持久化同步

这是最让人头疼的,因为 localStorage 是同步的。而 React 是异步的。这会导致性能问题和竞态条件。

1. 简单的同步:从存储初始化状态

如果你只是想把状态存下来,下次打开页面还能读出来,你可以这么做。

function MyForm() {
  const [name, setName] = useState('');

  useEffect(() => {
    // 1. 初始化:从 LocalStorage 读取
    const savedName = localStorage.getItem('my_app_name');
    if (savedName) {
      setName(savedName);
    }
  }, []);

  useEffect(() => {
    // 2. 同步:当 React 状态改变时,写回 LocalStorage
    localStorage.setItem('my_app_name', name);
  }, [name]);

  return <input value={name} onChange={(e) => setName(e.target.value)} />;
}

这看起来很完美,对吧?但如果你在同一个页面有多个输入框,它们都监听 name 变化,你每敲一个字,整个页面可能就会因为 localStorage 的同步读写导致闪烁,或者因为频繁的 useEffect 触发而卡顿。

2. 高级技巧:防抖

为了避免每敲一个字都触发 localStorage 写入,我们需要“防抖”。

import { useState, useEffect } from 'react';

function MyForm() {
  const [name, setName] = useState('');

  useEffect(() => {
    // 使用一个定时器来延迟保存
    const timer = setTimeout(() => {
      console.log("保存到 LocalStorage:", name);
      localStorage.setItem('my_app_name', name);
    }, 500); // 停止输入 500ms 后才保存

    // 清理函数:如果用户继续输入,清除上一次的定时器
    return () => clearTimeout(timer);
  }, [name]);

  return <input value={name} onChange={(e) => setName(e.target.value)} />;
}

3. IndexedDB:大数据的归宿

localStorage 有个巨大的限制:只有 5MB。如果你的数据量大,或者有图片、文件,你得上 IndexedDB

IndexedDB 是异步的,这很好,不会阻塞 UI。但它的 API 很复杂(Promise 链很长)。这里我们用一个小技巧来封装它,让它看起来像是一个同步的存储,但内部是异步的。

// 封装一个简单的 IndexedDB 包装器
const dbPromise = new Promise((resolve, reject) => {
  const request = indexedDB.open('MyAppDB', 1);

  request.onupgradeneeded = (event) => {
    const db = event.target.result;
    if (!db.objectStoreNames.contains('settings')) {
      db.createObjectStore('settings', { keyPath: 'key' });
    }
  };

  request.onsuccess = (event) => resolve(event.target.result);
  request.onerror = (event) => reject(event.target.error);
});

async function saveToDB(key, value) {
  const db = await dbPromise;
  const transaction = db.transaction(['settings'], 'readwrite');
  const store = transaction.objectStore('settings');
  store.put({ key, value });
}

async function getFromDB(key) {
  const db = await dbPromise;
  return new Promise((resolve, reject) => {
    const transaction = db.transaction(['settings'], 'readonly');
    const store = transaction.objectStore('settings');
    const request = store.get(key);
    request.onsuccess = () => resolve(request.result?.value);
    request.onerror = () => reject(request.error);
  });
}

// 在组件中使用
function MySettings() {
  const [theme, setTheme] = useState('light');

  useEffect(() => {
    // 初始化
    getFromDB('theme').then(savedTheme => {
      if (savedTheme) setTheme(savedTheme);
    });
  }, []);

  useEffect(() => {
    // 同步更新
    saveToDB('theme', theme);
  }, [theme]);

  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      切换主题
    </button>
  );
}

第五部分:WebSocket 与实时同步

这是外部状态同步的“硬核”领域。React 组件是静态的,但网络连接是动态的。

假设你正在做一个股票交易软件。后端通过 WebSocket 推送实时价格。React 组件怎么知道价格变了?

1. 简单的 Socket 连接

你需要在组件挂载时建立连接,卸载时断开连接。同时,当收到消息时,更新 React 状态。

import React, { useState, useEffect } from 'react';

function StockTicker({ symbol }) {
  const [price, setPrice] = useState(0);
  const [socket, setSocket] = useState(null);

  useEffect(() => {
    console.log(`连接到 ${symbol} 的股票行情...`);

    // 模拟建立连接
    const ws = new WebSocket(`wss://api.example.com/stocks/${symbol}`);

    ws.onopen = () => {
      console.log("连接成功!");
    };

    ws.onmessage = (event) => {
      const data = JSON.parse(event.data);
      // 收到外部数据,更新 React 状态
      setPrice(data.price);
    };

    // 保存 socket 到 state,以便在 cleanup 中使用
    setSocket(ws);

    // 清理函数:断开连接
    return () => {
      console.log("断开连接...");
      ws.close();
    };
  }, [symbol]);

  return (
    <div className="ticker">
      <h2>{symbol}</h2>
      <p className="price">当前价格: ${price.toFixed(2)}</p>
    </div>
  );
}

2. 处理断线重连

现实比代码复杂。网络会断。如果 Socket 断了,React 组件还在那儿显示着旧价格。你怎么知道它断了?

通常的做法是监听 ws.onclose 事件,然后触发重连逻辑。

useEffect(() => {
  let ws;
  let interval;

  const connect = () => {
    ws = new WebSocket(url);

    ws.onmessage = (event) => {
      setPrice(JSON.parse(event.data).price);
    };

    ws.onclose = () => {
      console.log("连接断开,3秒后重试...");
      interval = setInterval(connect, 3000);
    };
  };

  connect();

  return () => {
    if (ws) ws.close();
    if (interval) clearInterval(interval);
  };
}, [url]);

这里有一个微妙的同步问题:当 WebSocket 断开时,React 的状态(价格)可能已经过期了。你应该在 UI 上显示一个“数据已过期”的标记。

return (
  <div>
    <span className={price > 0 ? 'up' : 'down'}>
      {price > 0 ? `$${price.toFixed(2)}` : '等待连接...'}
    </span>
  </div>
);

第六部分:进阶挑战与“屎山”预警

现在,你已经掌握了基本原理。但现实世界的代码往往比这要混乱得多。这里有几个高级场景,能帮你区分“初级工程师”和“资深架构师”。

1. 竞态条件

这是 React 同步外部状态时最可怕的敌人。

场景: 用户点击了一个“刷新数据”按钮。

  1. 你发起了请求 A。
  2. 用户又点击了一次“刷新数据”按钮(或者 React 重新渲染了)。
  3. 你发起了请求 B。
  4. 请求 B 先回来了,更新了状态。
  5. 请求 A 后回来了,覆盖了状态 B。

结果:用户明明想看最新的数据,却看到了旧数据。

解决方案:AbortController(AbortSignal)

这就是为什么我在第一部分给你展示 AbortController 的原因。当你发起请求时,给请求加个信号。如果你发起了新请求,就把旧请求 Abort。

useEffect(() => {
  let isMounted = true; // React 18 的新特性,但 AbortController 更通用
  const controller = new AbortController();

  fetch(url, { signal: controller.signal })
    .then(res => res.json())
    .then(data => {
      // 必须检查 isMounted,防止组件已卸载后更新状态
      if (isMounted) {
        setMyData(data);
      }
    });

  return () => {
    controller.abort(); // 取消请求
    isMounted = false;
  };
}, [url]);

2. 闭包陷阱

useEffect 中,你经常需要访问最新的 props 或 state。但是,useEffect 的闭包会“捕获”当时的值。

function Counter() {
  const [count, setCount] = useState(0);
  const [step, setStep] = useState(1);

  useEffect(() => {
    const interval = setInterval(() => {
      // 这里有个坑:interval 里的 setCount 永远是 0
      // 因为 setCount 是在 useEffect 创建闭包时捕获的,而不是每次执行时捕获的
      setCount(prevCount => prevCount + step); 
    }, 1000);

    return () => clearInterval(interval);
  }, [step]); // 只有 step 变了,effect 才会重新运行

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setStep(s => s + 1)}>增加步长</button>
    </div>
  );
}

注意看上面的代码。我在 setCount 里用了 prevCount => prevCount + step。这是 React 推荐的写法。因为 setCount 的回调函数会在下一次渲染时执行,它能看到最新的 step

如果你写成了 setCount(count + step),那你就掉进坑里了。count 在闭包里是死的。

3. 性能优化:不要在 Effect 里做昂贵的计算

如果你的 useEffect 里有一个巨大的循环,或者一个复杂的计算,每次状态变化都会触发它。这会让你的应用像蜗牛一样慢。

useEffect(() => {
  // 坏主意:每次渲染都重新计算整个图表
  const data = heavyCalculation(myState); 
  updateChart(data);
}, [myState]);

解决方案:使用 useMemo 或 useCallback。

虽然 useMemo 是用来缓存计算结果的,但在这里,我们可以结合它们来控制何时更新外部状态。

const memoizedData = useMemo(() => heavyCalculation(myState), [myState]);

useEffect(() => {
  updateChart(memoizedData);
}, [memoizedData]);

第七部分:总结与实战建议

好了,讲座接近尾声。让我们把这些零散的知识点串成一条项链。

  1. 生命周期是契约: 挂载是签约,更新是履约,卸载是解约。解约(Cleanup)必须做。
  2. Effect 是副作用: 它不是 React 的核心,它是 React 的“补丁”。它能让你在渲染后做一些外部的事(发请求、存数据、监听事件)。
  3. 依赖数组是承诺: useEffect 依赖数组里写了什么,它就在什么时候执行。如果你写了 [],它只执行一次。如果你忘了写,它会在每次渲染后执行(除非你用了 useCallback)。
  4. 同步是双向的: 外部 -> React (初始化),React -> 外部 (更新)。
  5. 内存泄漏是敌人: 订阅了没取消,定时器没清除,WebSocket 没关。这是新手最容易犯的错。

最后,给各位一个“防坑指南”:

  • 永远不要在 useEffect 里写 return 来直接修改状态(比如 return setCount(c => c + 1)),这虽然能跑,但非常反直觉,而且可能导致不可预测的渲染次数。
  • 如果你在 Effect 里使用了 setXdispatch,确保它不改变 Effect 的依赖项,否则会导致无限循环。
  • 使用 ESLint 插件: eslint-plugin-react-hooks 会告诉你哪些 Effect 缺了依赖,或者哪些依赖没写进去。相信这个插件,它比你自己脑子记得更清楚。

React 的世界是动态的,外部存储也是动态的。作为开发者,你的任务就是在这两者之间架起一座坚固的桥梁。不要让你的组件变成那个“退房了还在走廊放音乐”的租客。

好了,现在去写代码吧!记得关灯!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注