普通视图

发现新文章,点击刷新页面。
昨天 — 2026年3月6日首页

React性能优化的完整方法论,附赠大厂面试通关技巧

作者 小时前端
2026年3月6日 18:18

开篇语

在前端面试中,React性能优化是一个绕不开的话题。无论是初级还是高级岗位,面试官总会问:"你做过哪些React性能优化?"、"如何定位性能问题?"、"React.memo和useCallback有什么区别?"

很多同学面对这些问题时,只能零散地背几个API,缺乏系统性的思路。今天这篇文章,我将结合一线开发经验,带你建立完整的React性能优化知识体系,让你从"知道几个优化技巧"到"能够系统性解决性能问题"。

性能优化的核心思路

建立性能问题的感知能力

很多开发者都是在用户投诉"页面卡"时才开始关注性能,其实性能优化应该是一个主动的过程。我总结了几个常见的性能问题信号:

  • 首屏加载超过3秒 - 用户开始失去耐心
  • 滚动时出现掉帧 - 肉眼可见的卡顿
  • 点击按钮响应延迟 - 交互体验差

当你发现这些现象时,就要考虑进行性能优化了。

系统性的优化框架

我总结了一个"三步走"的性能优化框架:

graph LR
    A[发现问题] --> B[定位原因] --> C[制定方案] --> D[验证效果]
    B --> E[工具分析]
    C --> F[选择策略]
    D --> G[数据对比]
  1. 发现问题:用户体验角度识别性能问题
  2. 定位原因:使用专业工具分析具体瓶颈
  3. 制定方案:针对不同问题选择合适优化策略
  4. 验证效果:通过数据验证优化效果

性能调试工具全解析

React DevTools Profiler - 组件级性能分析

React DevTools Profiler是我最常用的性能分析工具,它能精确记录组件的渲染时间、重渲染原因和Props变化。

实战案例:电商商品列表优化

有一次优化电商商品列表页面,用户反馈滚动时明显卡顿。我用Profiler录制了滚动操作:

  1. 打开React DevTools,切换到Profiler面板
  2. 点击录制按钮,模拟用户滚动操作
  3. 停止录制,查看火焰图

发现每次滚动都会触发所有商品卡片的重新渲染,即使大部分商品数据并没有变化。通过查看渲染原因,发现是因为父组件传递了内联函数导致引用变化。

优化方案:

// 优化前 - 每次渲染都创建新函数
const ProductList = ({ products }) => {
  return (
    <div>
      {products.map(product => (
        <ProductCard 
          key={product.id}
          product={product}
          onAddToCart={() => addToCart(product.id)} // 内联函数
        />
      ))}
    </div>
  );
};

// 优化后 - 使用useCallback稳定函数引用
const ProductList = ({ products }) => {
  const handleAddToCart = useCallback((productId) => {
    addToCart(productId);
  }, []); // 空依赖数组,函数不会重新创建

  return (
    <div>
      {products.map(product => (
        <ProductCard 
          key={product.id}
          product={product}
          onAddToCart={handleAddToCart}
        />
      ))}
    </div>
  );
};

优化后,Profiler显示渲染时间从25ms降低到8ms,滚动FPS从45提升到58,用户体验明显改善。

Chrome DevTools Performance - 浏览器级性能分析

当Profiler显示组件渲染正常,但用户仍然反馈卡顿时,就需要从浏览器层面进行分析。

实战技巧:

  1. 录制用户操作流程
  2. 关注Main线程的黄色长任务块
  3. 查看Frames部分的帧率表现
  4. 分析Layout和Paint的开销

案例分析:搜索功能优化

搜索框输入时页面卡顿,Performance面板显示:

  • 每次输入都有超过100ms的黄色长任务
  • 大量的Layout和Paint操作
  • 帧率经常低于30fps

通过分析发现是因为搜索建议列表的DOM操作过于频繁。优化方案:

  • 使用防抖函数减少搜索触发频率
  • 实现虚拟滚动,只渲染可见的搜索建议
  • 缓存搜索结果,避免重复计算

其他实用工具推荐

  • why-did-you-render:监控不必要的重渲染
  • webpack-bundle-analyzer:分析打包体积
  • Lighthouse:整体性能评分和建议
  • Web Vitals:监控核心用户体验指标

React性能优化的四大策略

策略一:缓存优化 - 减少不必要的计算

React提供了三个核心的缓存API,合理使用能解决80%的性能问题。

1. React.memo - 组件级缓存

适用场景:组件props没有变化但仍然频繁重渲染

// 商品卡片组件 - 纯展示组件
const ProductCard = React.memo(({ product, onAddToCart }) => {
  console.log('ProductCard render:', product.name);
  
  return (
    <div className="product-card">
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p>价格:¥{product.price}</p>
      <button onClick={() => onAddToCart(product.id)}>
        加入购物车
      </button>
    </div>
  );
}, (prevProps, nextProps) => {
  // 自定义比较函数(可选)
  return prevProps.product.id === nextProps.product.id && 
         prevProps.product.price === nextProps.product.price;
});

// 父组件中使用
const ProductList = ({ products }) => {
  const [cartCount, setCartCount] = useState(0);
  
  // 注意:使用useCallback避免函数引用变化
  const handleAddToCart = useCallback((productId) => {
    console.log('添加商品到购物车:', productId);
    setCartCount(prev => prev + 1);
  }, []); // 空依赖数组,函数引用保持稳定

  return (
    <div>
      <h2>商品列表 (购物车:{cartCount}件)</h2>
      <div className="product-grid">
        {products.map(product => (
          <ProductCard 
            key={product.id}
            product={product}
            onAddToCart={handleAddToCart}
          />
        ))}
      </div>
    </div>
  );
};

实战技巧:

  • 给React.memo提供自定义比较函数时,要确保比较逻辑正确
  • 避免在props中传递对象或数组字面量,会导致比较失败
  • 配合useCallback和useMemo使用效果更佳

2. useMemo - 计算结果缓存

适用场景:复杂计算、数据转换、过滤排序等操作

// 商品列表过滤和排序
const ProductList = ({ products, filter, sortBy }) => {
  // 复杂的数据处理逻辑
  const processedProducts = useMemo(() => {
    console.log('重新计算商品列表');
    
    // 1. 过滤商品
    let filtered = products.filter(product => {
      if (filter.category && product.category !== filter.category) return false;
      if (filter.minPrice && product.price < filter.minPrice) return false;
      if (filter.maxPrice && product.price > filter.maxPrice) return false;
      return true;
    });
    
    // 2. 排序
    filtered.sort((a, b) => {
      switch (sortBy) {
        case 'price-asc':
          return a.price - b.price;
        case 'price-desc':
          return b.price - a.price;
        case 'name':
          return a.name.localeCompare(b.name);
        default:
          return 0;
      }
    });
    
    return filtered;
  }, [products, filter, sortBy]); // 只有这些依赖变化时才重新计算

  return (
    <div>
      <h2>商品列表 ({processedProducts.length}件)</h2>
      <div className="product-grid">
        {processedProducts.map(product => (
          <ProductCard key={product.id} product={product} />
        ))}
      </div>
    </div>
  );
};

3. useCallback - 函数引用缓存

适用场景:将函数作为props传递给子组件时

// 优化的表单组件
const SearchForm = ({ onSearch }) => {
  const [keyword, setKeyword] = useState('');
  const [category, setCategory] = useState('');
  
  // 搜索函数 - 使用useCallback缓存
  const handleSearch = useCallback(() => {
    onSearch({
      keyword,
      category,
      timestamp: Date.now()
    });
  }, [keyword, category, onSearch]);
  
  // 重置函数 - 使用useCallback缓存
  const handleReset = useCallback(() => {
    setKeyword('');
    setCategory('');
    onSearch({
      keyword: '',
      category: '',
      timestamp: Date.now()
    });
  }, [onSearch]);
  
  return (
    <div className="search-form">
      <input 
        type="text" 
        value={keyword}
        onChange={(e) => setKeyword(e.target.value)}
        placeholder="请输入关键词"
      />
      <select value={category} onChange={(e) => setCategory(e.target.value)}>
        <option value="">全部分类</option>
        <option value="electronics">电子产品</option>
        <option value="clothing">服装</option>
        <option value="books">图书</option>
      </select>
      <button onClick={handleSearch}>搜索</button>
      <button onClick={handleReset}>重置</button>
    </div>
  );
};

策略二:架构优化 - 合理拆分组件

良好的组件架构设计能从根源上减少性能问题。

状态就近原则

把状态放在最靠近使用它的组件中,避免不必要的状态提升。

// 不好的设计 - 状态过度提升
const Parent = () => {
  const [isExpanded, setIsExpanded] = useState(false); // 展开状态没必要放在这里
  
  return (
    <div>
      <Child isExpanded={isExpanded} setIsExpanded={setIsExpanded} />
    </div>
  );
};

// 好的设计 - 状态就近管理
const Parent = () => {
  return (
    <div>
      <Child />
    </div>
  );
};

const Child = () => {
  const [isExpanded, setIsExpanded] = useState(false); // 状态放在使用它的组件中
  
  return (
    <div>
      <button onClick={() => setIsExpanded(!isExpanded)}>
        {isExpanded ? '收起' : '展开'}
      </button>
      {isExpanded && <div>展开的内容</div>}
    </div>
  );
};

按更新频率拆分组件

不同部分的更新频率不同,应该拆分成独立组件。

// 商品详情页 - 按更新频率拆分
const ProductDetail = ({ productId }) => {
  return (
    <div className="product-detail">
      {/* 基本信息 - 基本不变 */}
      <ProductBasicInfo productId={productId} />
      
      {/* 价格信息 - 可能促销变化 */}
      <ProductPrice productId={productId} />
      
      {/* 库存信息 - 实时变化 */}
      <ProductStock productId={productId} />
      
      {/* 用户评论 - 实时更新 */}
      <ProductReviews productId={productId} />
      
      {/* 相关推荐 - 根据算法变化 */}
      <ProductRecommendations productId={productId} />
    </div>
  );
};

策略三:列表优化 - 大数据量处理

虚拟滚动技术

当列表数据量很大时(超过1000条),虚拟滚动是必须的技术。

// 使用react-window实现虚拟滚动
import { FixedSizeList as List } from 'react-window';

const LargeProductList = ({ products }) => {
  // 只渲染可见区域的产品
  const Row = ({ index, style }) => (
    <div style={style}>
      <ProductCard product={products[index]} />
    </div>
  );

  return (
    <List
      height={600} // 可视区域高度
      itemCount={products.length} // 总数据量
      itemSize={120} // 每行高度
      width="100%"
    >
      {Row}
    </List>
  );
};

key的正确使用

key的选择对列表性能影响很大。

// 不好的做法 - 使用索引作为key
const ProductList = ({ products }) => {
  return (
    <div>
      {products.map((product, index) => (
        <ProductCard key={index} product={product} /> // 不要用index
      ))}
    </div>
  );
};

// 好的做法 - 使用稳定的唯一标识
const ProductList = ({ products }) => {
  return (
    <div>
      {products.map(product => (
        <ProductCard key={product.id} product={product} /> // 使用稳定的id
      ))}
    </div>
  );
};

策略四:代码分割 - 按需加载

React.lazy和Suspense

// 路由级别的代码分割
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';

// 懒加载页面组件
const Home = React.lazy(() => import('./pages/Home'));
const ProductList = React.lazy(() => import('./pages/ProductList'));
const ProductDetail = React.lazy(() => import('./pages/ProductDetail'));
const ShoppingCart = React.lazy(() => import('./pages/ShoppingCart'));

const App = () => {
  return (
    <Router>
      <React.Suspense fallback={<div>加载中...</div>}>
        <Switch>
          <Route exact path="/" component={Home} />
          <Route path="/products" component={ProductList} />
          <Route path="/product/:id" component={ProductDetail} />
          <Route path="/cart" component={ShoppingCart} />
        </Switch>
      </React.Suspense>
    </Router>
  );
};

组件级别的代码分割

// 重型组件的按需加载
const HeavyChartComponent = React.lazy(() => 
  import('./components/HeavyChartComponent')
);

const Dashboard = () => {
  const [showChart, setShowChart] = useState(false);
  
  return (
    <div>
      <button onClick={() => setShowChart(true)}>
        显示图表
      </button>
      
      {showChart && (
        <React.Suspense fallback={<div>图表加载中...</div>}>
          <HeavyChartComponent />
        </React.Suspense>
      )}
    </div>
  );
};

性能优化的数据验证

性能优化不能凭感觉,必须用数据说话。

优化前后的数据对比

案例:电商首页优化

优化前:

  • 首屏加载时间:4.2秒
  • 组件平均渲染时间:25ms
  • 滚动FPS:平均45
  • 用户跳出率:35%

优化后:

  • 首屏加载时间:2.1秒(提升50%)
  • 组件平均渲染时间:8ms(提升68%)
  • 滚动FPS:平均58(提升29%)
  • 用户跳出率:22%(降低37%)

核心性能指标监控

// Web Vitals监控
import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals';

const reportWebVitals = (metric) => {
  console.log(metric);
  // 发送到监控系统
  sendToAnalytics(metric);
};

// 监控核心指标
getCLS(reportWebVitals); // 累积布局偏移
getFID(reportWebVitals); // 首次输入延迟
getFCP(reportWebVitals); // 首次内容绘制
getLCP(reportWebVitals); // 最大内容绘制
getTTFB(reportWebVitals); // 首字节时间

面试中的答题技巧

系统化回答框架

当面试官问"你做过哪些React性能优化"时,不要零散地列举,而是按照系统化框架回答:

我的回答思路: "我通常会从三个层面进行React性能优化:渲染层面、架构层面和加载层面。

在渲染层面,我主要使用React.memo、useMemo、useCallback来减少不必要的重渲染。比如在[具体项目]中,通过React.memo优化商品列表,让渲染时间从25ms降到8ms。

在架构层面,我会合理拆分组件,遵循状态就近原则,按更新频率组织组件结构。比如将商品详情页拆分成基本信息、价格、库存、评论等独立组件。

在加载层面,我使用代码分割、懒加载、虚拟滚动等技术。比如对路由组件进行懒加载,将首屏包体积减少了40%。

同时,我建立了性能监控机制,使用React DevTools Profiler和Chrome Performance面板定期分析性能,确保优化效果可量化。"

展现技术深度

面试官追问:"为什么React.memo能够优化性能?"

深度回答: "React.memo的原理涉及React的协调算法。当组件的props或state变化时,React会启动协调过程,通过Diff算法比较新旧虚拟DOM树的差异。

React.memo在这个过程中起到短路作用。它在组件更新阶段就执行浅比较,如果props没有变化,就直接跳过该组件及其子树的协调过程,避免了昂贵的虚拟DOM比较计算。

这种优化在组件树较深、子组件较多的情况下效果特别明显,因为React.memo阻断了变化向下传播的路径,让状态变化的影响局限在最小范围内。"

体现实战经验

面试官追问:"遇到过哪些具体的性能问题?"

实战案例: "在优化一个商品搜索页面时,用户反馈每输入一个字符都有明显卡顿。

通过Profiler分析发现,每次输入都会触发整个商品列表的重新渲染,包括过滤、排序等复杂计算。而且搜索建议列表的DOM更新也很频繁。

我的解决方案是:

  1. 使用防抖函数,将搜索触发频率控制在300ms一次
  2. 用useMemo缓存过滤排序后的结果
  3. 搜索建议列表实现虚拟滚动,只渲染可见项
  4. 用useCallback稳定事件处理函数

优化后,输入响应时间从500ms降到50ms,用户体验大幅提升。这个案例让我意识到性能优化要从用户操作路径出发,系统性地解决各个环节的性能瓶颈。"

总结与进阶

React性能优化是一个需要持续学习和实践的领域。记住这些核心要点:

  1. 建立性能意识:主动发现问题,而不是被动等待用户反馈
  2. 掌握系统方法:从渲染、架构、加载三个层面综合考虑
  3. 善用工具分析:Profiler、Performance面板等工具是定位问题的利器
  4. 数据驱动优化:用数据验证优化效果,避免凭感觉
  5. 持续监控改进:性能优化是一个持续的过程,不是一次性的工作

技术前沿关注

  • React 18的Concurrent Features:为性能优化带来新的可能性
  • Server Components:减少客户端计算和包体积
  • Edge Computing:结合边缘计算优化加载性能
  • Micro-frontend:微前端架构下的性能优化策略

性能优化不仅仅是技术问题,更是用户体验和业务价值的体现。掌握这些技能,不仅能让你在面试中脱颖而出,更能让你在实际工作中创造真正的价值。

希望这篇文章能帮助你建立完整的React性能优化知识体系。记住,最好的优化是预防,在开发过程中就考虑性能因素,而不是等问题出现后再补救。

你觉得这篇文章对你有帮助吗?欢迎在评论区分享你的性能优化经验和问题!

延伸阅读:

关于我明明用了ref还是陷入React闭包陷阱

作者 喵爱吃鱼
2026年3月6日 12:48
const appendChildren = (
  nodes: DataNode[],
  targetKey: React.Key,
  children: DataNode[],
): DataNode[] =>
  nodes.map((node) => {
    if (node.key === targetKey) {
      return {
        ...node,
        children,
      };
    }
    if (node.children && node.children.length) {
      return {
        ...node,
        children: appendChildren(node.children, targetKey, children),
      };
    }
    return node;
  });


export const DepartmentTree = forwardRef<
  DepartmentTreeRef,
  DepartmentTreeProps
>(function DepartmentTree(props, ref) {
  const treeDataRef = useRef<DataNode[]>([]);
  treeDataRef.current = treeData;

  // useEffect(() => {
  //   treeDataRef.current = treeData;
  // }, [treeData]);

  useEffect(() => {
    let shouldIgnore = false;

    const loadRootData = async () => {
      try {
        const rootRequest = newRequest({
          cache: true,
          ...(searchParams ?? {}),
        });
        const rootRes: any = await rootRequest;
        if (shouldIgnore) return;
        const finalTreeData = normalizeNodes(rootRes?.data?.data || []);
        setTreeData(finalTreeData);
      } catch (error) {
        console.warn( error);
      }
    };

    loadRootData();

    return () => {
      shouldIgnore = true;
    };
  }, [searchParams, getAllRootDeptFullPaths]);

  // 异步加载子节点
  const onLoadData: TreeProps['loadData'] = ({ key, children }) =>
    new Promise<void>((resolve) => {
      if (!key || (children && children.length > 0)) {
        resolve();
        return;
      }
      const newSearchParams = _cloneDeep(searchParams ?? {});
      _set(newSearchParams, 'data.code', key);
      newRequest({ cache: true, ...newSearchParams })
        .then((res: any) => {
          const childrenNodes = normalizeNodes(res?.data?.data || []);

          setTreeData((prev) => {
            const newData = appendChildren(prev, key, childrenNodes);
            // 关键
            treeDataRef.current = newData;
            return newData;
          });
        })
        .catch((error) => {
          console.warn( error);
        })
        .finally(() => resolve());
    });
    
  const onExpand = (nextExpandedKeys: React.Key[]) => {
    setExpandedKeys(nextExpandedKeys);
    setAutoExpandParent(false);
  };

  const findNodeByKey = (
    nodes: DataNode[],
    targetKey: React.Key,
  ): DataNode | null => {
    for (const node of nodes) {
      // 如果当前节点的ID就是我们要找的,直接返回这个节点
      if (node.key === targetKey) return node;
      // 如果当前节点有子节点,就递归查找子节点
      if (node.children && node.children.length) {
        const child = findNodeByKey(node.children as DataNode[], targetKey);
        if (child) return child;
      }
    }
    return null;
  };

  const expandPath = useCallback(
    async (pathKeys: string[]) => {
      // 遍历路径中的所有节点ID(除了最后一个,因为最后一个不需要展开)
      for (const key of pathKeys.slice(0, -1)) {
        const current = findNodeByKey(treeDataRef.current, key);
        if (!current) continue;
        // 根据pathKeys顺序,一层层加载子节点数据
        if (!current.children || current.children.length === 0) {
          await onLoadData({ key, children: current.children } as any);
        }
      }
      setExpandedKeys(pathKeys.slice(0, -1));
      setAutoExpandParent(true);
    },
    [onLoadData],
  );

  const handleSearchSelect = useCallback(
    async (value: string, option: any) => {
      const targetKey = option?.deptId ?? option?.value ?? value;
      const path = String(option?.deptFullPath ?? '')
        .split('/')
        .filter(Boolean);

      if (path.length > 0) {
        await expandPath(path);
      }

      setSelectedKeys([targetKey]);
      setSearchValue(value);
    },
    [onSelect, expandPath],
  );

  return (
    <Tree
        loadData={onLoadData}
        treeData={displayTreeData}
        blockNode
        onSelect={handleTreeSelect}
        onExpand={onExpand}
        expandedKeys={expandedKeys}
        autoExpandParent={autoExpandParent}
        selectedKeys={selectedKeys}
     />
  );
});

这是一个非常经典且高阶的 “React 渲染周期 vs JavaScript 执行顺序” 的问题。

简单来说:JavaScript 的代码执行速度(微任务队列)快于 React 的重新渲染速度。

仅仅在组件主体(或 useEffect)中写 treeDataRef.current = treeData 是不够的,因为这两者之间存在一个致命的时间差

下面拆解这个“时间差”到底发生在哪里:

1. 场景重现:如果没有手动赋值

假设 expandPath 正在循环处理 ['A', 'B'] 两个节点(先加载 A,再加载 B)。

第一步:加载节点 A

  1. expandPath 循环开始,找到节点 A。
  2. 调用 await onLoadData(A)
  3. 请求回来,数据有了。
  4. 执行 setTreeData(newData)
    • 注意: 此时 React 仅仅是**“收到了更新通知”**,它把这次更新放入了队列,准备稍后重新渲染组件。
    • 关键点: 组件还没有重新渲染!所以组件最上面的 treeDataRef.current = treeData 这行代码还没有运行!Ref 里存的还是树。
  5. onLoadData 的 Promise 结束(resolve)。

第二步:灾难发生(加载节点 B)

  1. await 结束,JS 引擎立刻继续执行下一行代码(for 循环进入下一次迭代)。
  2. 循环尝试处理节点 B。
  3. 执行 const current = findNodeByKey(treeDataRef.current, 'B')
  4. 问题来了: 此时 React 还没有来得及执行下一次渲染。treeDataRef.current 依然是的(里面还没有 A 的子节点)。
  5. 结果:找不到节点 B,逻辑中断,展开失败。

第三步:马后炮(React 终于渲染了)

  1. JS 的同步代码和微任务跑完后,React 终于开始 Re-render。
  2. 组件函数重新运行。
  3. 执行 treeDataRef.current = treeData(这时候 Ref 才变成新的)。
  4. 但此时 expandPath 的循环早就跑完了,黄花菜都凉了。

2. 为什么手动赋值能解决?

当写成这样时:

setTreeData((prev) => {
  const newData = appendChildren(prev, key, childrenNodes);
  
  // 【关键操作】强行插队
  // 不等 React 渲染,直接在 JS 层面立刻更新 Ref
  treeDataRef.current = newData; 
  
  return newData;
});

流程变成了:

  1. expandPath 调用 onLoadData
  2. 请求回来,执行 setTreeData 的回调。
  3. 立刻更新 treeDataRef.current = newData(这是同步的 JS 操作,不需要等 React 渲染)。
  4. onLoadData Promise 结束。
  5. expandPath 继续循环。
  6. 循环取 treeDataRef.current,此时它已经是最新的了(因为我们在第3步强行更新了它)。
  7. 成功找到下一个节点。

总结

  • treeDataRef.current = treeData (写在组件体内):依赖于 React 的渲染周期。只有等 React 画完下一帧,Ref 才会变。
  • expandPath 中的 await 循环:依赖于 JS 的 Promise 队列。它的速度极快,不会等待 React 的渲染。

结论: 因为 for 循环跑得太快,不等 React 渲染就要用新数据,所以必须在数据产生的源头(setTreeData 回调里)手动、同步地把最新数据塞进 Ref 里,才能追上循环的脚步。

昨天以前首页

React 事件订阅的稳定引用问题:从 useEffect 到 useEffectEvent

作者 ZengLiangYi
2026年3月5日 16:37

在 React 里订阅 WebSocket / EventEmitter 时,把 handler 直接放进 effect 依赖会导致反复 subscribe/unsubscribe。用 useRef 代理最新 handler 可以解决——但渲染阶段直接赋值 ref 在 Strict Mode 下有副作用风险。本文拆解这个模式的三个演进版本,以及 React 19 的终极解法。

本文假设你理解 React useEffect 的依赖数组机制和闭包基础。


问题:handler 是新的,订阅也是新的

写 WebSocket 消息监听的第一版,大多数人会这样写:

// ❌ 版本 1:handler 变化 = 重新订阅
function ChatPanel({ conversationId }: { conversationId: string }) {
  const [messages, setMessages] = useState<Message[]>([]);

  useEffect(() => {
    socket.on('message:new', (msg) => {
      setMessages((prev) => [...prev, msg]);
    });
    return () => socket.off('message:new', /* 哪个函数? */);
  }, [conversationId]);
}

第一个问题显而易见:socket.off 需要传入与 socket.on 完全相同的函数引用,但内联箭头函数每次渲染都是新对象,off 根本移除不掉正确的监听器,导致监听器堆积。

修复方式是把 handler 提出来,加入依赖数组:

// ❌ 版本 2:监听器能正确移除了,但每次渲染都重订阅
useEffect(() => {
  const handler = (msg: Message) => {
    setMessages((prev) => [...prev, msg]);
  };
  socket.on('message:new', handler);
  return () => socket.off('message:new', handler);
}, [conversationId, setMessages]); // handler 是函数,引用每次都变

更典型的场景是 handler 来自 props:

// ❌ 每次父组件重渲染,onMessage 是新函数 → 重新订阅
function useSocketEvent(event: string, onMessage: (msg: Message) => void) {
  useEffect(() => {
    socket.on(event, onMessage);
    return () => socket.off(event, onMessage);
  }, [event, onMessage]); // onMessage 每次都是新引用
}

父组件只要重渲染(比如 state 更新),onMessage 就是新函数,effect 就重跑,WebSocket 就重新订阅一次。在高频更新的组件里,这意味着每秒可能订阅/取消订阅数十次。


心理模型:代理人

解法的核心思路是引入一个稳定的代理人

想象有个翻译:客户(WebSocket)只认识这个翻译(stableHandler),不管雇主(handler)换了几茬,客户永远对着同一个翻译说话。翻译内部维护一个指针,永远转发给最新的雇主。

WebSocket → stableHandler(稳定,不变)→ handlerRef.current(总是最新的 handler)

用代码表示:

const handlerRef = useRef(handler);
// handlerRef.current 永远是最新 handler

const stableHandler = (payload: T) => handlerRef.current(payload);
// stableHandler 是稳定函数引用,只在组件挂载时创建一次

socket.on('message:new', stableHandler); // 只订阅一次

三个版本的演进

版本 1:渲染阶段赋值(常见但有隐患)

export function useStableHandler<T>(
  event: string,
  handler: (payload: T) => void,
) {
  const handlerRef = useRef(handler);
  handlerRef.current = handler; // ← 直接在渲染阶段赋值

  useEffect(() => {
    const stableHandler = (payload: T) => handlerRef.current(payload);
    socket.on(event, stableHandler);
    return () => socket.off(event, stableHandler);
  }, [event]);
}

这个版本能运行,也是网上最常见的写法。但 handlerRef.current = handler 写在渲染函数体里,是渲染阶段的副作用。

React Strict Mode 在开发环境下会故意执行两次渲染函数体(不含 effects),目的是暴露副作用。在并发模式(Concurrent Mode)下,React 可以中断、暂停、重播渲染——如果渲染阶段有副作用,可能在预期之外的时机被多次执行。

对于 ref 赋值,实践中通常没有问题(ref 赋值是幂等的),但这是 React 文档明确标注为"不推荐"的模式。

版本 2:独立 effect 同步(正确且 Strict Mode 安全)

export function useStableHandler<T>(
  subscribe: (handler: (payload: T) => void) => () => void,
  handler: (payload: T) => void,
): void {
  const handlerRef = useRef(handler);

  // Effect 1:同步最新 handler 到 ref(Strict Mode 安全)
  useEffect(() => {
    handlerRef.current = handler;
  }, [handler]);

  // Effect 2:订阅,只在 subscribe 变化时重跑
  useEffect(() => {
    const stableHandler = (payload: T) => handlerRef.current(payload);
    const unsubscribe = subscribe(stableHandler);
    return unsubscribe;
  }, [subscribe]);
}

两个 effect 分工明确:

Effect 职责 依赖 重跑频率
Effect 1 保持 ref 最新 [handler] handler 变化时(可能很频繁)
Effect 2 管理订阅生命周期 [subscribe] subscribe 变化时(应该很少)

关键点:Effect 1 频繁重跑没有性能问题,因为它只做一次 ref 赋值,没有 I/O。Effect 2 重跑才是代价高的(涉及 socket.on/off),而它的依赖 subscribe 应该是稳定的。

版本 3:useEffectEvent(React 19+,最简洁)

import { useEffectEvent } from 'react';

export function useStableHandler<T>(
  subscribe: (handler: (payload: T) => void) => () => void,
  handler: (payload: T) => void,
): void {
  // useEffectEvent 返回一个稳定函数,内部始终能访问最新 handler
  const stableHandler = useEffectEvent(handler);

  useEffect(() => {
    return subscribe(stableHandler);
  }, [subscribe]); // stableHandler 不需要放进依赖
}

useEffectEvent 是 React 官方对这个模式的标准答案。它做的事和版本 2 完全一样,只是封装成了语言原语。被 useEffectEvent 包裹的函数:

  • 稳定引用:不会触发 effect 重跑
  • 始终最新:调用时看到的是最新的 props/state
  • 不可在 effect 外调用(React 会报错,因为语义不同)

最容易踩的坑:subscribe 必须稳定

这个 hook 把订阅稳定性的责任转移到了 subscribe 参数上。如果调用时传入内联函数:

// ❌ 每次渲染 subscribe 都是新函数 → Effect 2 每次都重订阅
useStableHandler(
  (handler) => {
    socket.on('message:new', handler);
    return () => socket.off('message:new', handler);
  },
  (msg) => setMessages((prev) => [...prev, msg]),
);

修复:用 useCallback 稳定 subscribe

// ✅ subscribe 只在 socket 变化时重新创建
const subscribe = useCallback((handler: (msg: Message) => void) => {
  socket.on('message:new', handler);
  return () => socket.off('message:new', handler);
}, [socket]);

useStableHandler(subscribe, (msg) => setMessages((prev) => [...prev, msg]));

handler 参数则没有这个限制——内联函数完全可以,这正是 hook 的价值所在。


实际用例对比

Socket.IO 消息监听

function ChatPanel({ conversationId }: { conversationId: string }) {
  const [messages, setMessages] = useState<Message[]>([]);

  const subscribe = useCallback((handler: (msg: Message) => void) => {
    socket.on('message:new', handler);
    return () => socket.off('message:new', handler);
  }, []); // socket 是模块级单例,依赖为空

  useStableHandler(subscribe, (msg) => {
    if (msg.conversation_id === conversationId) {
      setMessages((prev) => [...prev, msg]);
    }
  });

  // handler 每次渲染都是新函数(因为依赖 conversationId),
  // 但订阅不会重建 ✅
}

原生 resize 监听

function useWindowWidth() {
  const [width, setWidth] = useState(window.innerWidth);

  const subscribe = useCallback((handler: () => void) => {
    window.addEventListener('resize', handler);
    return () => window.removeEventListener('resize', handler);
  }, []);

  useStableHandler(subscribe, () => setWidth(window.innerWidth));

  return width;
}

取舍

优点 缺点
handler 无需 useCallback,调用处更干净 subscribe 必须稳定,调用处需要 useCallback
订阅/取消订阅次数最小化 两个 effect 之间存在一帧的 handler 不同步窗口(极罕见)
适用于任何 subscribe/unsubscribe 接口 版本 2 写法对团队有一定理解门槛

一帧不同步窗口是指:Effect 1(同步 handler)和 Effect 2(使用 handler)在同一个 commit 里按顺序执行,正常情况下没有问题。但如果 subscribe 变化的同时 handler 也变化,理论上可能先执行 Effect 2 再执行 Effect 1,导致新订阅在一帧内用了旧 handler。实践中这种场景几乎不会出现,且影响仅限一次事件处理。

React 19 的 useEffectEvent 从根本上消除了这个窗口,是该模式的最终形态。


完整代码

react/use-stable-handler.ts


延伸阅读

TypeScript在React项目中的实战应用指南

2026年3月5日 10:50

在前端工程化日益成熟的今天,TypeScript(以下简称TS)凭借静态类型检查的优势,已成为React项目开发的标配。本文结合实际项目讨论经验,从组件类型约束、React钩子应用等维度,拆解TS在React项目中的落地技巧,帮助开发者写出更严谨、易维护的代码。

TSX文件与组件类型约束:让传参更可控

React项目中.js文件可无缝转为.tsx文件,核心差异在于类型声明——通过类型约束解决组件传参混乱、类型不明确的问题。

基础类型声明

TS的类型声明可覆盖变量、函数、类等场景,核心目的是「让类型可追溯」。比如组件传参时,若子组件未声明接收的属性类型,TS会直接报错,避免运行时因参数类型错误导致的bug。

组件Props类型约束示例

以React函数组件为例,通过interface声明Props类型,明确组件可接收的属性:


import React from 'react';

// 声明组件接收的属性类型
interface AaaProps {
  name: string; // 必传字符串
  age?: number; // 可选数字
  content: React.ReactNode; // 接收JSX/文本等内容
}

// 函数组件约束Props类型
const Aaa: React.FC<AaaProps> = (props) => {
  return <div>姓名:{props.name},内容:{props.content}</div>;
};

// 父组件使用:类型不匹配会直接报错
export default function App() {
  return <Aaa name="张三" content={<div>Hello TS</div>} />;
}

这种方式可灵活扩展/修改Props类型,减少团队协作中「传参格式不一致」的沟通成本。

React类型层级关系

声明JSX相关类型时,需理清三个核心类型的包含关系:


React.Node > React.Element > JSX.Element
  • JSX.Element:最窄的类型,仅包含JSX节点;

  • React.Element:包含所有React元素(如原生DOM元素、自定义组件);

  • React.ReactNode:最宽泛,包含Element、字符串、数字、null、undefined等。

日常开发中,描述接收JSX的参数时,使用React.ReactNodeReact.ReactElement即可满足大部分场景,无需过度细化。

React核心钩子的TS应用:精准约束类型

React的内置钩子(Hooks)结合TS后,能让状态、DOM操作、性能优化更可控,以下是高频钩子的类型用法。

useState:自动推导+手动声明

useState会根据初始值自动推导类型,也可手动声明类型适配复杂场景:


import React, { useState } from 'react';

export default function App() {
  // 自动推导:num为number类型,setNum为Dispatch<SetStateAction<number>>
  const [num, setNum] = useState(0);
  
  // 手动声明:初始值为undefined,指定num为number类型
  const [count, setCount] = useState<number>();
  
  return <div>num: {num}</div>;
}

useRef:DOM操作与变量缓存双场景

useRef有两大用途,TS需针对性声明类型:

场景1:操作DOM元素


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

export default function App() {
  // 声明ref为HTMLInputElement类型,初始值null
  const inputRef = useRef<HTMLInputElement>(null);
  
  useEffect(() => {
    // 非空断言+DOM操作:自动提示input的方法(如focus)
    inputRef.current?.focus();
  }, []);
  
  return <input type="text" ref={inputRef} />;
}

场景2:缓存变量


import React, { useRef } from 'react';

export default function App() {
  // 声明ref缓存对象类型
  const dataRef = useRef<{ num: number }>(null);
  dataRef.current = { num: 100 }; // 类型匹配才允许赋值
  
  return <div>App</div>;
}

useReducer:复杂状态的类型约束

useReducer用于管理复杂状态,需通过interface声明stateaction类型,确保reducer函数的入参/返回值类型一致:


import React, { useReducer } from 'react';

// 声明state类型
interface Data {
  result: number;
}

// 声明action类型
interface Action {
  type: string;
  num: number;
}

// reducer函数:约束入参和返回值类型
function reducer(state: Data, action: Action) {
  switch (action.type) {
    case 'add':
      return { result: state.result + action.num };
    case 'minus':
      return { result: state.result - action.num };
    default:
      return { result: 0 }; // 兜底避免返回值类型不明确
  }
}

export default function App() {
  // useReducer类型自动推导:state为Data类型,dispatch匹配Action类型
  const [res, dispatch] = useReducer(reducer, { result: 0 });
  
  return (
    <div>
      <button onClick={() => dispatch({ type: 'add', num: 2 })}>加</button>
      <button onClick={() => dispatch({ type: 'minus', num: 1 })}>减</button>
      <div>结果:{res.result}</div>
    </div>
  );
}

useCallback & useMemo:性能优化+类型缓存

这两个钩子用于性能优化,核心是「缓存函数/值」,TS无需额外声明类型(自动推导),重点关注依赖项:


import React, { useMemo, useCallback, memo } from 'react';

export default function App() {
  const [res, dispatch] = useReducer(reducer, { result: 0 });
  
  // useMemo:缓存值,仅依赖项变化时重新计算
  const count = useMemo(() => {
    return res.result * 10;
  }, [res.result]); // 依赖res.result,避免无效计算
  
  // useCallback:缓存函数,避免组件更新时函数重创建
  const handleClick = useCallback(() => {
    console.log('缓存的函数');
  }, []); // 空依赖:组件更新时函数不重新创建
  
  // 结合memo优化子组件:只有props变化时重新渲染
  const Child = memo((props: { cb: () => void }) => {
    return <button onClick={props.cb}>点击</button>;
  });
  
  return <Child cb={handleClick} />;
}

useEffect/useLayoutEffect:无需额外类型标注

这两个钩子的回调函数返回值(清理函数)或入参均无需手动声明类型,TS会根据回调函数自动推导。

子组件与父组件的ref传递:解决DOM穿透问题

若父组件想获取子组件内部的DOM元素(如input),直接传递ref会报错,需通过React.forwardRef实现ref转发,并声明正确的类型:


import React, { useRef, useEffect, forwardRef } from 'react';

// 声明子组件:ForwardRefRenderFunction<HTMLInputElement> 约束ref类型
const Child: React.ForwardRefRenderFunction<HTMLInputElement> = (props, ref) => {
  // 将ref转发给内部input
  return <input type="text" ref={ref} />;
};

// 包装子组件,实现ref转发
const WrapChild = forwardRef(Child);

export default function App() {
  // 声明ref为input元素类型
  const inputRef = useRef<HTMLInputElement>(null);
  
  useEffect(() => {
    // 父组件可直接操作子组件的input DOM
    inputRef.current?.focus();
  }, []);
  
  return <WrapChild ref={inputRef} />;
}

总结

TS在React项目中的核心价值是「提前暴露类型问题」,从组件到钩子的类型约束,本质是让「模糊的逻辑」变得「可预期」。实际开发中:

  1. 组件Props使用interface约束,减少传参问题;

  2. 钩子结合TS类型推导,无需过度声明(如useState自动推导);

  3. ref转发、useReducer等复杂场景,精准声明类型即可。

通过TS的类型约束,React项目的可维护性、协作效率会大幅提升,这也是前端工程化的核心趋势。希望本文的实战技巧能帮助你在项目中更好地落地TS+React!

初学React:请求数据参数未更新 && 数据异步状态更新问题

作者 gxp123
2026年3月4日 18:02
 // 请求参数
const [params, setParams] = useState({
    page: 1,
    per_page: 4,
    begin_pubdate: null,
    end_pubdate: null,
    status: '',
    channel_id: null
  })
  // 点击按钮
 const onFinish = (formValue)=>{
    // 设置参数
    setParams({
      ...params,
      channel_id:formValue.channel_id,
      status: formValue.status,
      begin_pubdate: formValue.date ? formValue.date[0].format('YYYY-MM-DD') : null,
      end_pubdate:  formValue.date ? formValue.date[1].format('YYYY-MM-DD')  :  null
    })
  // 更新表格数据
    getTableList()
  }

首次点击按钮导致请求参数没有更新问题。

原因在于 React 的状态更新是异步的。在 onFinish 函数中,先调用了 setParams 更新筛选参数,然后立即调用 getTableList。但由于 setParams 不会立即修改 params 的值,此时 getTableList 内部读取的仍然是旧的 params,因此第一次请求没有带上新选择的筛选条件。第二次点击时,params 已经更新为上一次的值,所以请求能带上上次的条件,但这次又可能因为同样的原因滞后。

解决方案

1. 在 onFinish 中构造新参数并直接传给 getTableList(推荐)

修改 getTableList 使其接受参数,调用时传入最新的筛选条件。

// 修改 getTableList,增加参数
const getTableList = async (reqParams) => {
  // 如果没有传入参数,则使用当前 state 中的 params(用于首次加载)
  const finalParams = reqParams || params;
  try {
    const res = await http.get('/mp/articles', { params: finalParams });
    const { results, total_count } = res.data;
    setArticleTableList({
      list: results,
      count: total_count
    });
  } catch (error) {
    console.log(error);
  }
};

// 修改 onFinish
const onFinish = (formValue) => {
  // 基于当前 params 和表单值构造新参数对象
  const newParams = {
    ...params,
    channel_id: formValue.channel_id,
    status: formValue.status,
    begin_pubdate: formValue.date ? formValue.date[0].format('YYYY-MM-DD') : null,
    end_pubdate: formValue.date ? formValue.date[1].format('YYYY-MM-DD') : null
  };
  setParams(newParams);          // 更新状态用于后续操作(如分页)
  getTableList(newParams);       // 立即用新参数请求数据
};

2. 使用 useEffect 监听 params 变化自动请求

删除 onFinish 中手动调用 getTableList 的代码,改为依赖 params 的副作用。

useEffect(() => {
  getTableList();
}, [params]); // params 变化时重新请求

const onFinish = (formValue) => {
  setParams({
    ...params,
    channel_id: formValue.channel_id,
    status: formValue.status,
    begin_pubdate: formValue.date ? formValue.date[0].format('YYYY-MM-DD') : null,
    end_pubdate: formValue.date ? formValue.date[1].format('YYYY-MM-DD') : null
  });
  // 不需要再手动调用 getTableList
};

注意:使用 useEffect 时需要确保 params 的引用变化(每次更新都创建新对象),并且首次加载也会触发,因此初始 useEffect 中的手动调用可以移除。

总结

两种方式均可解决问题。第一种更直观,请求时机完全由开发者控制;第二种更符合 React 数据流,但需注意避免额外副作用。根据你的场景选择即可。

React 19 对比 React 16 新特性解析

作者 符方昊
2026年3月3日 17:32

AICoding快速做了一个React19和React16的特性对比网站

react19.fufanghao.space

🎉 React 19 正式发布了!这次更新不是换了个壳,而是真的在帮你少写代码。本文通过和 React 16 的对比,用简单的比喻带你搞懂每个新特性,看完保证你立刻想升级。


前言:React 19 升级了什么?

如果把 React 16 比作一辆手动挡汽车,那 React 19 就是自动挡——你还是在开同一辆车,但省掉了很多繁琐操作,开起来更顺畅了。

本文覆盖 React 19 的 9 个核心特性,每个都配有新旧代码对比,一看就懂。


一、use() Hook:读数据再也不用"三件套"了

🤔 以前怎么做

以前从接口拿数据,必须写 useState + useEffect + 手动判断 loading/error 的三件套,就像每次去超市买东西,都要先写购物清单、排队领号、再等叫号——步骤缺一不可。

// React 16:必须手动管理所有状态
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    fetchUser(userId)
      .then(data => {
        setUser(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err);
        setLoading(false);
      });
  }, [userId]);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error!</div>;
  return <div>{user.name}</div>;
}

光是"显示一个用户名",就要写将近 20 行。

✅ React 19 怎么做

React 19 的 use() Hook 就像给了你一个**"魔法读取器"**——把 Promise 塞进去,直接拿到结果,加载中的状态交给 Suspense 来管。

// React 19:直接读取,爽!
import { use, Suspense } from 'react';

function UserProfile({ userPromise }) {
  const user = use(userPromise); // 就这一行!
  return <div>{user.name}</div>;
}

function App() {
  const userPromise = fetchUser(1);
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <UserProfile userPromise={userPromise} />
    </Suspense>
  );
}

省了多少? 20 行 → 2 行核心逻辑,loading 和 error 状态完全不用自己管。

💡 一句话记住use() 就像奶茶店的自助取餐柜,你下单(传入 Promise),等好了直接取(读到数据),等待过程(Suspense)自动帮你排队。


二、Server Actions:表单提交不用再写 API 了

🤔 以前怎么做

以前写一个表单提交,需要:

  1. 前端写 handleSubmit 函数
  2. 手写 fetch 请求
  3. 后端单独写一个 /api/submit 接口
  4. 还要自己管 loading 状态

就像你给朋友发快递,要先打电话、再填单子、再去前台、再等确认——中间每步都要自己操作。

// React 16:前后端需要分别维护
function ContactForm() {
  const [status, setStatus] = useState('idle');

  const handleSubmit = async (e) => {
    e.preventDefault();
    setStatus('submitting');
    const formData = new FormData(e.target);
    
    try {
      await fetch('/api/submit', {  // 还得单独建 API 路由
        method: 'POST',
        body: JSON.stringify({ name: formData.get('name') }),
        headers: { 'Content-Type': 'application/json' }
      });
      setStatus('success');
    } catch {
      setStatus('error');
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" />
      <button disabled={status === 'submitting'}>提交</button>
    </form>
  );
}

✅ React 19 怎么做

React 19 的 Server Actions 让你直接在组件里定义服务端函数,form 的 action 属性直接传进去就行了。

// React 19:函数加个标记,直接用
async function submitForm(formData) {
  'use server'; // 一行声明,这个函数在服务器跑
  const name = formData.get('name');
  await db.users.create({ name });
}

function ContactForm() {
  return (
    <form action={submitForm}> {/* 直接传函数! */}
      <input name="name" />
      <button type="submit">提交</button>
    </form>
  );
}

省了多少? 不用写 /api/submit 接口,不用手写 fetch,不用管状态。

💡 一句话记住:Server Actions 就像微信支付——你只管扫码确认(调用函数),钱从哪儿走、怎么到账,后台自动帮你搞定。


三、useFormStatus:按钮终于知道表单在忙了

🤔 以前怎么做

你有一个「提交按钮」组件,它想知道父级表单是否正在提交中,以便显示 loading 状态。在 React 16 里,你必须把状态从父组件一层层传下去(prop drilling),或者创建一个 Context——就像老板开会要知道项目进度,得一个部门一个部门打电话问。

// React 16:状态传递好麻烦
const FormContext = createContext({ pending: false });

function SubmitButton() {
  const { pending } = useContext(FormContext); // 要专门建一个 Context
  return (
    <button disabled={pending}>
      {pending ? '提交中...' : '提交'}
    </button>
  );
}

function MyForm() {
  const [pending, setPending] = useState(false);
  // 还得自己管状态...
  return (
    <FormContext.Provider value={{ pending }}>
      <form onSubmit={...}>
        <SubmitButton />
      </form>
    </FormContext.Provider>
  );
}

✅ React 19 怎么做

useFormStatus 让子组件天生就能感知父表单状态,不需要任何传递。

// React 19:自动感知,什么都不用传
import { useFormStatus } from 'react-dom';

function SubmitButton() {
  const { pending } = useFormStatus(); // 自动知道父表单状态!
  return (
    <button disabled={pending}>
      {pending ? '提交中...' : '提交'}
    </button>
  );
}

function MyForm() {
  return (
    <form action={serverAction}>
      <input name="email" />
      <SubmitButton /> {/* 直接用,不传任何 props */}
    </form>
  );
}

💡 一句话记住useFormStatus 就像广播电台——表单"开播"了(提交中),收音机(按钮)自动就知道,不需要有人专门去通知。


四、useOptimistic:点了就立刻显示,不用等服务器

🤔 以前怎么做

做一个点赞功能,点击后要等服务器响应才更新 UI,用户会感觉"卡"。想做成"立刻显示"的效果,就得自己写一套乐观更新逻辑:

  • 先临时更新 UI
  • 等接口成功后替换真实数据
  • 接口失败了还得手动回滚

就像你发了一条朋友圈,要等服务器确认才能看到——而现实中微信是先让你看到,有问题再悄悄处理。

// React 16:手动管理乐观更新,容易出 bug
function TodoList({ todos: initialTodos, addTodo }) {
  const [todos, setTodos] = useState(initialTodos);

  const handleAdd = async (text) => {
    const tempId = Date.now();
    // 先临时显示
    setTodos(prev => [...prev, { id: tempId, text, pending: true }]);
    
    try {
      const newTodo = await addTodo(text);
      // 成功:替换临时项
      setTodos(prev => prev.map(t => t.id === tempId ? newTodo : t));
    } catch {
      // 失败:手动回滚
      setTodos(prev => prev.filter(t => t.id !== tempId));
    }
  };
  // ...
}

✅ React 19 怎么做

useOptimistic 把这一套逻辑内置了,你只需要描述"乐观的样子",其他的 React 帮你处理。

// React 19:两行搞定乐观更新
import { useOptimistic } from 'react';

function TodoList({ todos, addTodo }) {
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(
    todos,
    (state, newTodo) => [...state, { ...newTodo, pending: true }]
  );

  const handleAdd = async (text) => {
    addOptimisticTodo({ id: Date.now(), text }); // 立即显示
    await addTodo(text); // 实际提交(失败会自动回滚)
  };

  return (
    <ul>
      {optimisticTodos.map(todo => (
        <li key={todo.id} style={{ opacity: todo.pending ? 0.5 : 1 }}>
          {todo.text}
        </li>
      ))}
    </ul>
  );
}

💡 一句话记住useOptimistic 就像外卖 App 的"预计送达"——你一下单就显示送货中,真正到了再更新状态,失败了自动消失,你完全不用管中间过程。


五、ref 直接当 prop 传:告别 forwardRef

🤔 以前怎么做

你有一个自定义 <Input> 组件,想从外部拿到它的 DOM 节点(比如让它聚焦)。在 React 16 里,直接传 ref 是不行的,必须用 forwardRef 包一层——就像你想给朋友一个礼物,但必须先装进一个特定的礼品盒才能交出去。

// React 16:必须加 forwardRef 包装
import { forwardRef } from 'react';

const Input = forwardRef(function Input(props, ref) {
  return <input ref={ref} {...props} />;
});

// 使用者看不出来,但写组件的人知道有多麻烦

✅ React 19 怎么做

React 19 里,ref 就是个普通 prop,直接收、直接用。

// React 19:ref 就是普通 prop
function Input({ ref, ...props }) {
  return <input ref={ref} {...props} />;
}

// 调用方直接传,简洁!
function App() {
  const inputRef = useRef(null);
  return (
    <div>
      <Input ref={inputRef} placeholder="点我聚焦" />
      <button onClick={() => inputRef.current.focus()}>聚焦</button>
    </div>
  );
}

💡 一句话记住:以前 ref 是"特殊乘客",必须走专用通道(forwardRef)。React 19 让它跟普通乘客一样,随便哪个门进都行。


六、Document Metadata:管 SEO 再也不用装插件了

🤔 以前怎么做

以前想在组件里动态修改页面标题、meta 标签,必须装 react-helmet 或者用框架提供的 <Head> 组件——就像想挂一幅画,必须先找物业批准、填申请表才行。

// React 16:需要第三方库
import { Helmet } from 'react-helmet';

function BlogPost({ post }) {
  return (
    <article>
      <Helmet>
        <title>{post.title} | My Blog</title>
        <meta name="description" content={post.excerpt} />
      </Helmet>
      <h1>{post.title}</h1>
    </article>
  );
}

✅ React 19 怎么做

直接在组件里写 <title><meta>,React 会自动把它们放到 <head> 里。

// React 19:原生支持,直接写!
function BlogPost({ post }) {
  return (
    <article>
      <title>{post.title} | My Blog</title>  {/* 直接写! */}
      <meta name="description" content={post.excerpt} />
      <h1>{post.title}</h1>
    </article>
  );
}

💡 一句话记住:以前写 meta 标签像去银行开户需要各种证明,现在 React 19 给你开了绿色通道,直接走,不用审批。


七、Asset 预加载 API:用 JS 控制资源加载

🤔 以前怎么做

想预加载字体、脚本,要么在 HTML 里手动加 <link rel="preload">,要么通过 react-helmet 动态插入——资源管理分散在 HTML 和 JS 两处,难以维护。

// React 16:在 HTML 或 Helmet 里手动加
<Helmet>
  <link rel="dns-prefetch" href="https://cdn.example.com" />
  <link rel="preload" href="/fonts/inter.woff2" as="font" />
</Helmet>

✅ React 19 怎么做

React 19 提供了 preloadprefetchDNSpreinit 等 API,直接在 JS 里调用:

// React 19:在代码里统一管理
import { prefetchDNS, preload, preinit } from 'react-dom';

function App() {
  prefetchDNS('https://cdn.example.com');
  preload('/fonts/inter.woff2', { as: 'font' });
  preinit('/scripts/analytics.js', { as: 'script' });
  
  return <main>...</main>;
}

💡 一句话记住:以前资源预加载写在 HTML 里,就像把工作任务写在便利贴上贴墙上——不好管。现在全部写进代码,统一放在"任务清单"里。


八、增强错误处理:报错信息更清晰,错误边界更好用

🤔 以前怎么做

React 16 里,错误边界只能用类组件实现,写起来又臭又长,而且不同来源的错误(被捕获 vs 未被捕获)很难区分。

// React 16:必须写类组件
class ErrorBoundary extends Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    logErrorToService(error, errorInfo.componentStack);
  }

  render() {
    if (this.state.hasError) {
      return <h1>出错了</h1>;
    }
    return this.props.children;
  }
}

就算在 2024 年,你还是得为了错误边界专门写一个类组件。

✅ React 19 怎么做

React 19 在 createRoot 上提供了三种错误回调,可以精细区分错误类型:

// React 19:在根节点统一配置错误处理
const root = createRoot(document.getElementById('root'), {
  onCaughtError(error, errorInfo) {
    // 被错误边界捕获的错误
    logError(error, errorInfo.componentStack);
  },
  onUncaughtError(error, errorInfo) {
    // 未被捕获的错误
    showErrorDialog(error);
  },
  onRecoverableError(error) {
    // 可自动恢复的错误(如 hydration 不一致)
    console.warn(error);
  }
});

💡 一句话记住:React 16 的错误处理像保安——只有一个岗亭,什么人都拦在一起。React 19 装了分类闸机:普通人走这边、VIP 走那边、问题人员走那边,各司其职。


九、Context 简化:.Provider 消失了

🤔 以前怎么做

用 Context 传数据,必须写 <ThemeContext.Provider> 这种带 .Provider 的写法,感觉像买了个手机还必须套个指定品牌的壳才能用。

// React 16:必须写 .Provider
<ThemeContext.Provider value="dark">
  <App />
</ThemeContext.Provider>

✅ React 19 怎么做

直接用 Context 本身作为组件:

// React 19:直接用,省掉 .Provider
<ThemeContext value="dark">
  <App />
</ThemeContext>

而且读取时还可以用更灵活的 use() 替代 useContext(),最大的区别是 use() 可以在 if 语句里调用useContext 不行):

// React 19:use() 可以在条件中使用
function Page({ isLoggedIn }) {
  if (!isLoggedIn) return <Login />;
  const theme = use(ThemeContext); // ✅ 在 if 之后调用没问题
  return <div className={theme}>...</div>;
}

💡 一句话记住:以前用 Context 像开车必须带驾照(.Provider),React 19 直接把驾照内置到车里了——上去就能开。


总结对比表

特性 React 16 React 19 节省了什么
异步数据获取 useState + useEffect 三件套 use() + Suspense 减少 ~70% 样板代码
表单提交 手写 fetch + API 路由 Server Actions 消除客户端/服务端分离
表单状态共享 Context 或 prop drilling useFormStatus 零传参自动感知
乐观更新 手写临时状态 + 回滚逻辑 useOptimistic 自动管理,消除 bug
ref 转发 必须 forwardRef 包装 直接当 prop 传 告别包装地狱
SEO 元数据 react-helmet 等第三方库 原生 <title>/<meta> 零依赖
资源预加载 HTML 手写 / Helmet preload/preinit API JS 统一管理
错误处理 类组件错误边界 createRoot 回调 + 细分类型 告别类组件
Context 使用 <Context.Provider> <Context> 直接用 语法更简洁

该不该升级?

✅ 推荐升级的场景:

  • 新项目:直接用 React 19,享受所有新特性
  • 用了 Next.js 14+:Server Actions 已经是主推方案
  • 有大量表单交互的项目:useFormStatus + useOptimistic 省代码很明显

⚠️ 谨慎升级的场景:

  • 大型存量项目:Server Actions 依赖特定框架(Next.js/Remix),不是纯前端能用的
  • 严格依赖类组件的老项目:需要逐步迁移
  • 团队还没准备好:use() 的心智模型和以前不一样

结语

React 19 最大的变化不是增加了多少功能,而是帮你删掉了多少样板代码。每个新 API 背后都是 React 团队在说:"这个事儿你不用管了,交给我们。"

从 16 到 19,变化的不只是版本号,而是写 React 的姿势。


如果这篇文章对你有帮助,欢迎点赞收藏 👍 有问题欢迎评论区交流~

前端组件化样式隔离实战:React CSS Modules、styled-components 与 Vue scoped 对比

2026年3月3日 17:10

前端组件化样式隔离实战:React CSS Modules、styled-components 与 Vue scoped 对比

引言:当组件遇见 CSS

在现代前端开发中,组件化已成为构建用户界面的主流方式。我们将页面拆分为独立、可复用的组件,每个组件管理自己的 HTML、CSS 和 JavaScript。然而,CSS 的设计初衷是全局作用域的 —— 样式一旦定义,就会影响整个页面,这给组件化带来了严峻挑战。

试想一个多人协作的项目:A 同学写了一个按钮组件,类名为 .button;B 同学也写了一个按钮组件,同样用了 .button。当两个组件同时出现在页面上时,后加载的样式会覆盖前者,造成意料之外的 UI 错乱。如何让组件的样式“与世隔绝”,既不影响他人,也不被他人影响?本文将深入探讨 React 和 Vue 生态中三种主流的样式隔离方案:CSS Modulesstyled-componentsVue scoped。我们将通过实际代码,由浅入深地理解它们的原理与用法。

1. CSS 的“先天不足”与组件化的冲突

在传统网页开发中,我们通常这样写 CSS:

/* global.css */
.button {
  background-color: blue;
  color: white;
}

这个 .button 样式会作用于页面上所有带有 class="button" 的元素,无论它身处哪个组件。这种全局性在小型项目中或许无伤大雅,但在组件化架构下却成了灾难。

假设我们有 Button.jsxAnotherButton.jsx 两个组件,分别引入了各自的 CSS 文件:

/* Button.css */
.button { background: blue; }

/* AnotherButton.css */
.button { background: red; }
效果图

image.png

最终页面上两个按钮都会是红色,因为后引入的 AnotherButton.css 覆盖了前者的规则。这就是样式冲突的典型场景。

为了解决这一问题,社区发展出了多种作用域隔离方案,核心思想都是将样式“限定”在组件内部。下面我们分别看看 React 和 Vue 是如何做到的。

2. React 中的 CSS Modules

2.1 什么是 CSS Modules?

CSS Modules 是一种将 CSS 文件编译为局部作用域的技术。它并不是官方的 CSS 规范,而是通过构建工具(如 webpack)在编译时给类名自动添加唯一的哈希字符串,从而实现样式隔离。在 React 项目中,使用 Create React App 或 Vite 脚手架时,开箱即支持 CSS Modules。

2.2 基本用法

我们约定 CSS 文件命名为 *.module.css。在组件中像导入一个对象一样导入样式文件,然后通过对象的属性引用类名。

Button.module.css

.button {
  background-color: blue;
  color: white;
  padding: 10px 20px;
}

.txt {
  color: red;
  background-color: orange;
  font-size: 30px;
}

Button.jsx

import styles from './Button.module.css';
console.log(styles); // 输出:{ button: "Button_button__1a2b3c", txt: "Button_txt__4d5e6f" }

export default function Button() {
  return (
    <>
      <h1 className={styles.txt}>你好,世界!!!</h1>
      <button className={styles.button}>My Button</button>
    </>
  );
}
效果图

image.png

在浏览器中,最终渲染的 HTML 类似:

<h1 class="Button_txt__4d5e6f">你好,世界!!!</h1>
<button class="Button_button__1a2b3c">My Button</button>
打开控制台我们点击元素开可以看到每个元素都有唯一的id

image.png

可以看到,原始的类名 .button.txt 被转换成了带有组件名和哈希的唯一类名,从而避免了全局污染。

2.3 多人协作的保障

再来看另一个组件 AnotherButton,它也定义了同名的 .button 样式:

anotherButton.module.css

.button {
  background-color: red;
  color: black;
  padding: 10px 20px;
}

AnotherButton.jsx

import styles from './anotherButton.module.css';

export default function AnotherButton() {
  return <button className={styles.button}>My Another Button</button>;
}

两个组件的样式互不干扰,因为编译后的类名分别是 AnotherButton_button__xxxButton_button__xxx。这正是 CSS Modules 的魅力所在——让开发者无需担心类名冲突,专注于组件本身的样式。

2.4 原理浅析

CSS Modules 的原理并不复杂:在构建阶段,webpack 的 css-loader 会解析 *.module.css 文件,将每个类名映射为一个唯一的标识符(通常是 [文件名]_[类名]__[hash]),同时生成一个映射对象(即 styles)。在 JavaScript 中,我们通过这个映射对象来引用最终的类名,而 CSS 文件中的原始类名则被替换为哈希后的类名。这样,CSS 和 JS 就通过同一份映射关系保证了样式的私有性。

3. React 中的 styled-components

如果说 CSS Modules 是在编译时通过修改类名来实现隔离,那么 styled-components 则代表了另一种思潮:CSS-in-JS,即在 JavaScript 中编写 CSS,并利用 JavaScript 的作用域来实现样式隔离。

3.1 什么是 styled-components?

styled-components 是一个流行的 React 库,它允许你使用 ES6 的模板字符串定义样式组件,这些样式组件会自动生成一个唯一的类名,并将样式注入到 <head> 中。

3.2 基本用法

首先安装 styled-components:

npm install styled-components

然后在组件中创建样式化组件:

import styled from 'styled-components';

// 定义一个带样式的 button 组件
const Button = styled.button`
  background-color: ${props => props.primary ? 'blue' : 'white'};
  color: ${props => props.primary ? 'white' : 'blue'};
  border: 1px solid blue;
  padding: 8px 16px;
  border-radius: 4px;
`;

function App() {
  return (
    <>
      <Button>默认按钮</Button>
      <Button primary>主要按钮</Button>
    </>
  );
}
效果图

image.png 渲染后的 HTML 如下(每个人的截图中的真实类名可能不同):

<button class="sc-axZvf jflFSQ">默认按钮</button>
<button class="sc-axZvf efDizw">主要按钮</button>
打开控制台点开控制台元素,我们同样可以看到每个元素都有唯一id

image.png 这里的 sc-axZvf 是组件标识前缀,同一组件生成的实例共享这个前缀,而 jflFSQ 和 efDizw 则是具体的样式类名,分别对应不同的样式规则(例如一个是默认样式,一个是 primary 样式)。所有样式都被动态地生成为 <style> 标签插入页面头部。

3.3 动态样式与 props

styled-components 的一大优势是支持基于 props 的动态样式。如上例所示,通过 props.primary 可以轻松改变背景色和文字颜色。这比传统 CSS 需要额外维护多个类名要直观得多。

3.4 原理浅析

styled-components 在运行时(runtime)工作:当组件渲染时,它会解析模板字符串中的样式规则,根据 props 计算出最终的 CSS 文本,然后生成一个唯一的类名(如 jflFSQ),并将 CSS 规则以 <style> 标签的形式插入到文档头部。值得注意的是,同一组件(如 Button)的所有实例会共享一个组件级标识(sc-axZvf),而具体样式类名则每个实例或每个变体可能不同。由于每个组件实例都可能生成不同的类名,样式天然是隔离的。同时,它还能自动处理浏览器前缀、关键帧动画等,为开发者提供了良好的体验。

4. Vue 中的 scoped 样式

Vue 作为另一大前端框架,其单文件组件(SFC)提供了内置的样式隔离方案——scoped 属性。

4.1 什么是 scoped?

在 Vue 的单文件组件中,可以在 <style> 标签上添加 scoped 属性,指示该样式只作用于当前组件。它的实现方式是为组件模板中的元素添加唯一的自定义属性(如 data-v-xxxxx),然后通过属性选择器来限制样式的生效范围。每个组件会生成一个唯一的哈希 ID,该组件内的所有元素都会被打上这个 ID 作为属性。

4.2 基本用法

App.vue

<script setup>
import HelloWorld from './components/HelloWorld.vue'
</script>

<template>
  <div>
    <h1 class="txt">Hello world in App</h1>
    <h2 class="txt2">一点点</h2>
    <HelloWorld />
  </div>
</template>

<style scoped>
.txt {
  color: red;
}
.txt2 {
  color: green;
}
</style>

HelloWorld.vue

<template>
  <div>
    <h1 class="txt">你好,世界!!!</h1>
    <h2 class="txt2">一点点</h2>
  </div>
</template>

<style scoped>
.txt {
  color: blue;
}
.txt2 {
  color: orange;
}
</style>
效果图

image.png

4.3 渲染结果与原理

编译后,Vue 会为每个组件生成一个唯一的哈希 ID。假设 App 组件的 ID 为 data-v-7a7a37b1,HelloWorld 组件的 ID 为 data-v-e17ea971。最终渲染的 HTML 结构如下(来自实际截图):

html

<div data-v-7a7a37b1>
  <h1 data-v-7a7a37b1 class="txt">Hello world in App</h1>
  <h2 data-v-7a7a37b1 class="txt2">一点点</h2>
</div>

<div data-v-e17ea971 data-v-7a7a37b1>
  <h1 data-v-e17ea971 class="txt">你好,世界!!!</h1>
  <h2 data-v-e17ea971 class="txt2">一点点</h2>
</div>

仔细观察可以发现:

  • App 组件内的所有元素(包括根 div)都带有自己的 ID data-v-7a7a37b1
  • HelloWorld 组件内的所有元素(包括其根 div)都带有自己的 ID data-v-e17ea971特别地,HelloWorld 的根元素上还额外附加了父组件 App 的 ID data-v-7a7a37b1。这是 Vue 故意设计的,目的是让父组件的样式可以通过属性选择器(如 .txt[data-v-7a7a37b1])作用于子组件的根元素,从而实现父组件对子组件根节点的样式控制(如果父组件样式选择器匹配的话)。

对应的 CSS 会被编译为:

css

.txt[data-v-7a7a37b1] { color: red; }
.txt2[data-v-7a7a37b1] { color: green; }
.txt[data-v-e17ea971] { color: blue; }
.txt2[data-v-e17ea971] { color: orange; }

由于属性选择器的存在,每个组件的样式只作用于带有对应属性的元素,实现了完美的样式隔离。同时,子组件根元素拥有双重属性,使得父组件的样式能够有选择地影响子组件的最外层,保持了样式的可控性。

打开控制台元素,我们就可以看到

image.png

4.4 与 CSS Modules 的对比

Vue 的 scoped 与 React 的 CSS Modules 思路相似,都是通过给选择器附加唯一标识来实现作用域。区别在于:

  • CSS Modules 修改了类名本身,而 Vue 保留了原始类名,额外添加了属性选择器。
  • Vue 的 scoped 无需导入对象,直接在模板中使用原始类名,可读性更好。
  • CSS Modules 需要显式引用 styles 对象,略显繁琐,但胜在灵活(比如可以组合多个类名)。

4.5 原理浅析

Vue 在编译单文件组件时,会为每个组件生成一个唯一的哈希 ID。然后:

  1. 将模板中的所有元素加上该 ID 作为属性(根元素额外加上父组件的 ID,如果存在父组件)。
  2. 将 <style scoped> 中的每条 CSS 规则都加上对应的属性选择器。
  3. 最终生成带作用域的 CSS。

整个过程在构建阶段完成,没有运行时开销,性能极佳。

5. 对比与总结

方案 框架 实现原理 优点 缺点
CSS Modules React / Vue 编译时修改类名,生成哈希映射 静态样式,简单可靠;可与预处理器结合 类名需要引用,模板稍显啰嗦
styled-components React 运行时生成唯一类名,注入 <style> 动态样式能力强;完全组件化;支持 props 运行时开销;包体积较大;调试稍难
Vue scoped Vue 编译时添加唯一属性,属性选择器限制 语法简洁;无运行时开销;保留原始类名 仅适用于 Vue;深度选择器需特殊处理

如何选择?

  • 如果你的项目是 React,且偏好“传统”的 CSS 写法,CSS Modules 是最佳选择,它简单、高效,与设计工具(如 Figma)配合良好。
  • 如果你追求极致的动态样式和组件封装,或者希望将样式也作为组件逻辑的一部分,styled-components 能带来流畅的开发体验。
  • 对于 Vue 项目,scoped 是官方推荐的内置方案,开箱即用,足够满足绝大多数场景。

当然,这些方案并非互斥。在大型项目中,你可能会组合使用它们:用 CSS Modules 处理全局样式库,用 styled-components 处理高频复用的动态组件。重要的是理解每种方案的原理,以便在合适的场景做出正确的选择。

结语

从 CSS 的全局困境到组件样式的精细隔离,前端社区给出了多种优雅的解决方案。无论是 React 的 CSS Modules 和 styled-components,还是 Vue 的 scoped,它们都体现了“关注点分离”到“组件内聚”的思想演进。希望本文能帮助你更好地掌握这些工具,在项目中写出健壮、可维护的样式代码。如果你有更多关于样式隔离的思考或实践,欢迎在评论区交流讨论!

React Context 详解:从入门到性能优化

作者 阿虎儿
2026年3月3日 14:21

React Context 详解:从入门到性能优化

本文适合熟悉 Vue 但刚开始学习 React 的开发者,通过 Vue 的 provide/inject 对比来理解 React Context。

一、什么是 Context?

在组件开发中,我们经常遇到这样的场景:某个数据需要在多层嵌套的组件间共享。如果一层层通过 props 传递,代码会变得非常冗长且难以维护,这就是所谓的 "prop drilling" 问题。

React 的 Context 和 Vue 的 provide/inject 都是为了解决这个问题而设计的 —— 它们允许数据跨层级传递,跳过中间组件。

二、React Context 基础用法

核心三步

  1. 创建 Context —— 创建一个数据共享的"通道"
  2. 提供数据 —— 父组件通过 Provider 提供数据
  3. 消费数据 —— 子组件通过 useContext 获取数据

完整示例

// ========== 1. 创建 Context ==========
// context.tsx
import { createContext, useContext } from 'react'

// 定义数据类型
type MyContextValue = {
  name: string
  age: number
}

// 创建 Context(可设置默认值)
const MyContext = createContext<MyContextValue>({ name: '', age: 0 })

// 导出一个 hook 方便使用
const useMyContext = () => useContext(MyContext)

export { MyContext, useMyContext }
// ========== 2. 父组件提供数据 ==========
// parent.tsx
import { MyContext } from './context'
import Child from './child'

const Parent = () => {
  const data = { name: '张三', age: 18 }

  return (
    <MyContext.Provider value={data}>
      <Child />
    </MyContext.Provider>
  )
}
// ========== 3. 子组件消费数据 ==========
// child.tsx
import { useMyContext } from './context'

const Child = () => {
  const { name, age } = useMyContext()
  
  return <div>{name} - {age}岁</div>
}

三、对比 Vue 的 provide/inject

如果你熟悉 Vue,这个概念其实非常相似:

步骤 React Vue
创建 createContext() 无需显式创建
提供 <Context.Provider value={}> provide(key, value)
消费 useContext(Context) inject(key)

Vue 等价写法

<!-- 父组件 -->
<script setup>
import { provide } from 'vue'
import Child from './child.vue'

const data = { name: '张三', age: 18 }
provide('myContext', data)
</script>

<template>
  <Child />
</template>
<!-- 子组件 -->
<script setup>
import { inject } from 'vue'

const { name, age } = inject('myContext')
</script>

<template>
  <div>{{ name }} - {{ age }}岁</div>
</template>

可以看到,两者的设计思想是一致的,只是语法不同:

  • React 使用 JSX 的组件包裹方式 <Context.Provider>
  • Vue 使用 Composition API 的函数调用方式

四、原生 Context 的性能问题

原生 React Context 存在一个性能陷阱:

只要 Context value 中的任何一个字段变化,所有消费这个 Context 的组件都会重新渲染,即使它们只用到了没变的字段。

// 原生 React Context 的问题
const MyContext = createContext({ name: '张三', age: 18, city: '北京' })

// 这个组件只用 name,但 age 或 city 变化时也会重新渲染!
const Child = () => {
  const { name } = useContext(MyContext)
  return <div>{name}</div>
}

当 Context 中有几十个字段时(这在大型应用中很常见),这个问题会严重影响性能。

五、use-context-selector:性能优化方案

为了解决这个问题,社区提供了 use-context-selector 库。它支持选择器模式,让组件只订阅自己关心的字段。

安装

npm install use-context-selector

使用方式

// 从 use-context-selector 导入,而不是 react
import { createContext, useContext } from 'use-context-selector'

const MyContext = createContext({ name: '张三', age: 18, city: '北京' })

// 使用选择器,只订阅 name
const Child = () => {
  const name = useContext(MyContext, v => v.name)  // age 或 city 变化不会触发重渲染
  return <div>{name}</div>
}

核心区别

特性 React 原生 use-context-selector
导入来源 'react' 'use-context-selector'
更新粒度 整个 Context 变化就重渲染 可以用选择器精确订阅某个字段
性能 大型 Context 可能性能差 优化了选择器模式,避免不必要的重渲染
使用方式 useContext(ctx) useContext(ctx, selector?)

六、实际案例分析

以 Dify 项目中的 ChatWithHistoryContext 为例:

// context.tsx
import { createContext, useContext } from 'use-context-selector'

export type ChatWithHistoryContextValue = {
  appMeta?: AppMeta | null
  appData?: AppData | null
  appParams?: ChatConfig
  currentConversationId: string
  conversationList: AppConversationData['data']
  handleNewConversation: () => void
  handleChangeConversation: (conversationId: string) => void
  // ... 还有 20+ 个字段
}

export const ChatWithHistoryContext = createContext<ChatWithHistoryContextValue>({
  currentConversationId: '',
  // ... 默认值
})

export const useChatWithHistoryContext = () => useContext(ChatWithHistoryContext)
// parent.tsx - 提供数据
const ChatWithHistoryWrap = () => {
  const contextValue = useChatWithHistory()  // 获取所有数据

  return (
    <ChatWithHistoryContext.Provider value={contextValue}>
      <ChatWithHistory />
    </ChatWithHistoryContext.Provider>
  )
}
// child.tsx - 消费数据
const ChatWithHistory = () => {
  const { 
    appData, 
    conversationList, 
    handleChangeConversation 
  } = useChatWithHistoryContext()
  
  // 使用数据...
}

这个 Context 有 30+ 个字段,如果使用原生 Context,任何一个字段变化都会导致所有子组件重渲染。使用 use-context-selector 后,框架内部做了优化,避免了不必要的渲染。

七、数据流图解

┌─────────────────────────────────────────────────┐
│  ChatWithHistoryWrap (父组件)                    │
│                                                 │
│  通过 useChatWithHistory() 获取所有数据          │
│  { appData, appParams, conversationList, ... }  │
└─────────────────────────────────────────────────┘
                        │
                        ▼
┌─────────────────────────────────────────────────┐
│  ChatWithHistoryContext.Provider                │
│  value={{ appData, appParams, ... }}            │  ← 数据注入到 Context
└─────────────────────────────────────────────────┘
                        │
                        ▼
┌─────────────────────────────────────────────────┐
│  ChatWithHistory (子组件)                        │
│                                                 │
│  useChatWithHistoryContext() 获取数据           │
└─────────────────────────────────────────────────┘
                        │
          ┌─────────────┼─────────────┐
          ▼             ▼             ▼
     ┌─────────┐  ┌─────────┐  ┌─────────┐
     │ Sidebar │  │ Header  │  │ ChatWrap│
     └─────────┘  └─────────┘  └─────────┘
          │             │             │
          └─────────────┴─────────────┘
                        │
              孙组件同样可以通过
           useChatWithHistoryContext() 获取数据

八、最佳实践

  1. 小型项目:使用原生 React Context 即可,简单直接
  2. 大型项目:当 Context 字段较多(10+)时,考虑使用 use-context-selector
  3. 拆分 Context:如果可能,将不相关的数据拆分到不同的 Context 中
  4. 命名规范:导出一个自定义 hook(如 useMyContext),统一消费方式

九、总结

场景 推荐方案
简单数据共享 React 原生 Context
大型 Context,字段多 use-context-selector
Vue 背景开发者 理解为 provide/inject 的 React 版本

Context 本质上就是 跨层级传递数据 的工具,理解了这一点,无论是 React 还是 Vue,核心概念都是相通的。

看完就懂 useSyncExternalStore

作者 ssshooter
2026年3月2日 19:42

功能

React 引入 useSyncExternalStore 也很长一段时间了,但是存在感还不太强。简而言之,它专门用来搞定那些不受 React 内部生命周期控制的外部数据源

过去最大的问题其实是 React 渲染时的 「撕裂」,这是 React 为了优化页面响应速度引入的并发渲染机制带来的副作用。

简单来说就是 React 为了防止在渲染时长时间无法响应用户输入,把渲染过程拆分成多个可中断的小任务,这就能小任务的间隙中插入用户响应,从而模拟出「并发」的感觉。更完整的前因后果可以参考《React 的设计哲学》

在 React 并发渲染机制下,如果用普通的 useEffect 去同步外部数据,可能会出现渲染进行到一半时数据突然发生变化,导致同一份页面中,一半的组件拿着老数据,另一半拿着新数据的灵异现象(但是实际上出现这个问题的几率其实非常小,大家都忽略了,这就导致了 useSyncExternalStore 的存在感很低)。使用 useSyncExternalStore 后,如果在渲染过程中快照发生变化,React 会丢弃当前渲染并重新开始,从而保证同一次提交中的所有组件看到的是同一个版本的数据。

使用场景

订阅浏览器 API

拿监听网络状态来说。不使用这个 Hook 之前,我们通常得在组件里写个包含完整挂载和清理逻辑的 useEffect 去监听 onlineoffline 事件。

function subscribe(callback) {
  window.addEventListener("online", callback);
  window.addEventListener("offline", callback);
  return () => {
    window.removeEventListener("online", callback);
    window.removeEventListener("offline", callback);
  };
}

function getSnapshot() {
  return navigator.onLine;
}

// 组件里直接这么用
const isOnline = useSyncExternalStore(subscribe, getSnapshot);

监听媒体查询(Media Queries)响应式布局也是同样的套路:

const query = window.matchMedia("(max-width: 600px)");

function subscribe(callback) {
  query.addEventListener("change", callback);
  return () => query.removeEventListener("change", callback);
}

const isMobile = useSyncExternalStore(subscribe, () => query.matches);

轻量级全局状态

如果你接手了一个极小的项目,不想引入 Redux 或 Zustand 这样繁琐的包,但又迫切需要在几个跨层级的组件间共享某部分状态。这时候你可以直接手搓一个简易的 Store:

// 丢在 React 外面的状态中心
let internalState = { count: 0 };
const listeners = new Set();

const store = {
  increment() {
    internalState = { count: internalState.count + 1 };
    listeners.forEach((l) => l());
  },
  subscribe(callback) {
    listeners.add(callback);
    return () => listeners.delete(callback);
  },
  getSnapshot() {
    return internalState;
  },
};

// 任何组件里都可以直接同步获取状态
const state = useSyncExternalStore(store.subscribe, store.getSnapshot);

注意:useSyncExternalStore 内部用 Object.is 比较前后快照,如果 getSnapshot 在数据未变的情况下每次都返回新对象,会导致无限循环重渲染。

只要把这段代码看懂,你就掌握了 Zustand 这种现代状态管理库的核心原理

竞品 API

useEffect + setState

曾经大家都习惯在 useEffect 里监听外部变化,如果变了,再跑一下 setState 触发更新。

这就又到了日常批判 useEffect 的时候了。

useEffect 带来重复渲染和闪烁问题。如果你的外部状态和页面初始计算的状态不对齐,页面渲染就会经历「旧值 -> 闪烁 -> 新值」这三步。而 useSyncExternalStore 在渲染中途就能直接取走最新的正确值。

另外,在处理服务端渲染时,用副作用很容易抛出水合(Hydration)错误,因为服务端和客户端首次生成的 HTML 大概率因为外部数据对不上。useSyncExternalStore 为此专门开了一个叫 getServerSnapshot 的参数,让你传能兜底服务端的静态快照。

Context

很多人滥用 Context 做全局状态,但如果是频繁变动的数据,Context 的广播机制简直是一场灾难。只要 Provider 提供的值发生了变动,它底下所有的子组件也会跟着无脑重跑 Render,除非你给每个组件层级套一层 React.memo(当然现在有 compiler,但也不是毫无代价)。

相比之下,useSyncExternalStore 实现了高精度的按需订阅——只有从 Store 取出的快照真的有了变化,关联的组件才会再次渲染。在这里还是顺便强调一下,没事别用 Context。

总结

要判断何时使用 useSyncExternalStore 其实很简单,只要你的数据依然在 React 的生命周期里流转(例如表单实时输入、控制弹窗开闭的布尔值),那就老老实实用回你的 useStateuseReducer

一旦数据满足游离于 React 管理之外、会随时间变化、且你要让 UI 能自动响应这种变化这三个条件,就毫不犹豫上 useSyncExternalStore。日常写前端页面也许碰不到几次,但之后你要是去造底层 Hook 库,或者需要硬啃第三方库内部暴露出的状态时,useSyncExternalStore 绝对好使~

相关链接

从 0 到 1 实现一个 useState

作者 印刻君
2026年3月2日 08:49

大家好,我是印刻君。如果你是前端程序员,相信对 React 并不陌生。但你有没有想过,如果 React 没有给你提供 useState,你能否自己实现一个呢

今天我就带你从零实现一个 useState。我会先梳理它必备的核心能力,再用极简代码实现一个功能完整的基础版,在基础版之上,我最终会实现一个更严谨的进阶版。

一、useState 的核心能力拆解

动手前,我们先明确一个合格的 useState 必须具备的核心能力。具体有以下几点:

1.1 数据持久化与触发重新渲染

我们通过一个简单示例理解这两个能力:

function App() {
  const [count, setCount] = useState(0)
  const handleClick = () => {
    setCount(count + 1)
  }
  return (
    <div>
      <p>{count}</p>
      <button onClick={handleClick}>计数</button>
    </div>
  )
}

1.1.1 数据持久化

组件首次挂载时,count 的值是 0;点击“计数”按钮之后,组件会重新执行,但 count 的值并没有变回初始值 0,而是“记住”了更新之后的 1。

这种在组件多次渲染中记忆数据,不被重置的性质就是 state 的数据持久化能力。

1.1.2 触发重新渲染

点击“计数”按钮之后,setCount 会更新 count 的值,React 检测到 state 发生变化,会再执行一遍组件函数。

这种检测 state 变化并更新组件的性质,就是触发重新渲染

1.2 setState 支持函数式更新

如下面代码示例,setState 既可以传入一个值,也可以传入一个回调函数。

传入回调函数的方式,就是函数式更新。

const handleClick = () => {
  setCount(prev => prev + 1)
}

1.3 setState 支持批量更新

当你连续多次调用 setState(比如示例中的 setCount 时),并不会触发多次渲染。React 会把这些更新操作整合起来,只触发一次渲染。

这就是 setState 的批量更新特性。

const handleClick = () => {
  setCount(prev => prev + 1)
  setCount(prev => prev + 1)
  setCount(prev => prev + 1)
}

1.4 useState 可多次调用,创建多个相互独立的状态

在一个组件中,你可以多次调用 useState,每次调用会创建一个独立的状态单元(包括状态值和更新函数)。

比如示例中,更新 count 并不会影响 str 和 time,修改 str 也不会影响 count 和 time。

function App() {
  const [count, setCount] = useState(0)
  const [str, setStr] = useState('ink')
  const [time, setTime] = useState(Date.now())
  
  // ...
}

二、基础版的 useState

2.0 环境准备

要自己实现 useState 并验证是否符合预期,我们需要先搭建一个简单的浏览器运行环境,也就是下方的 HTML 模板:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>从零实现 useState</title>
  <script
    src="https://unpkg.com/@babel/standalone/babel.min.js"
  ></script>
  <script
    src="https://unpkg.com/react@18/umd/react.development.js"
  ></script>
  <script
    src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"
  ></script>
</head>
<body>
  <div id="root"></div>
  <script type="text/babel">
    const root = ReactDOM.createRoot(document.getElementById('root'))
    function App() {
      return (
        <p>从零开始实现 useState</p>
      )
    }
    function render() {
      root.render(<App />)
    }
    render()
  </script>
</body>
</html>

其中:

  1. 引入 Babel 是为了让浏览器识别 JSX 语法;
  2. 引入 ReactDOM 是为了借助它的渲染能力,把组件显示到浏览器中,方便我们验证自己写的 useState 是否生效;
  3. 引入 React 是因为 ReactDOM 内部代码依赖它,但我们全程不会使用 React 自带的 useState,而是自己实现。

整个模板的功能,就是把 App 组件渲染到页面的 root 节点上,方便我们后续测试自定义 useState。

2.1 实现数据持久化、触发重新渲染

2.1.1 实现 state 数据持久化

在之前准备的 HTML 模板基础上,我们先实现最基础的 state 数据持久化功能。要做到这一点,需要满足两个要求:

1. state 不随函数执行重置

state 需要在函数内执行,但更新组件时不能被重置,这意味着 state 不能存储在函数内部(局部变量会在函数执行后销毁),而必须存储函数外部作为全局变量。

2. state 只在首次渲染时初始化

state 只在第一次执行时用初始值初始化,后续不能重复初始化。这要求我们区分”首次渲染“和”更新渲染“两个时机,我们可以用 state 是否为 undefined 来区分。

满足要求的代码如下:

let state
function render() {
  root.render(<App />)
}
function useState(initialVal) {
  if (state === undefined) {
    state = initialVal
  }
  function setState(newVal) {
    state = newVal
  }
  return [state, setState]
}

2.1.2 实现 setState 触发组件重新渲染

接下来我们给自定义的 useState 补上“调用 setState”触发渲染的能力。

在 React 源码中,调度更新的模块叫做 Scheduler,负责决定"什么时候更新"。

我们这里简化处理,不实现优先级调度,直接在 setState 后立即触发渲染,把这个函数叫做 schedule 以示致敬。

function schedule() {
  render()
}
function useState(initialVal) {
  if (state === undefined) {
    state = initialVal
  }
  function setState(newVal) {
    state = newVal
    schedule()
  }
  return [state, setState]
}

2.1.3 完整代码

结合 HTML 模板的完整代码我放到 codesandbox 上了,链接为:codesandbox.io/p/sandbox/a…

2.2 实现函数式更新

2.2.1 实现逻辑

接下来我们给 setState 增加函数式更新 的能力。核心逻辑是:

如果 setState 的参数类型是函数,就把“上一次的 state”传给这个函数,并把函数的返回值作为新的 state;

如果 setState 的参数类型是普通值,直接用这个值更新 state。

function useState(initialVal) {
  // ...
  const prevState = state
  function setState(action) {
    state = typeof action === 'function'
          ? action(prevState)
          : action
    schedule()
  }
  
  return [state, setState]
}

2.2.2 完整代码

结合 HTML 模板的完整代码我放到 codesandbox 上了,链接为:codesandbox.io/p/sandbox/w…

2.3 实现批量更新

先说明,我们接下来实现的是手动批量更新(需要主动调用方法包裹 setState),语法如下:

const handleClick = () => {
  batchUpdate(() => {
    setCount(prev => prev + 1)
    setCount(prev => prev + 1)
    setCount(prev => prev + 1)
  })
}

而 React 源码中的是自动批量更新(不需要主动调用方法包裹 setState)。

我们选择手动批量更新是为了简化理解,核心逻辑和 React 源码的批量更新是一致的。

2.3.1 核心思路

批量更新的本质是“先收集所有更新任务,最后一次性执行”,实现这个逻辑需要两个全局变量、两个核心函数。

1. 两个全局变量
  • queue,更新队列,专门用来存放 setState 更新任务;
  • isBatchingUpdates,更新标记,用来判断当前是否处于批量更新阶段。
let queue = []
let isBatchingUpdates = false
// ...
function useState(initialVal) {
  if (state === undefined) {
    state = initialVal;
  }
  function setState(action) {
    if (isBatchingUpdates) {
      // 批量阶段:把更新任务加入队列
      queue.push(action);
    } else {
      // 非批量阶段:直接更新 state, 立即渲染
      const prevState = state;
      state = typeof action === "function" ? action(prevState) : action;
      schedule();
    }
  }
  return [state, setState];
}
2. 两个核心函数
  • flushUpdates,执行队列内所有的更新任务,返回最后的 state;
function flushUpdates() {
  let currentState = state;
  // 遍历队列,依次执行每个更新任务
  while (queue.length > 0) {
    const update = queue.shift();
    currentState = typeof update === "function" ? update(currentState) : update;
  }
  return currentState;
}
  • batchUpdate,手动开启批量更新
function batchUpdate(callback) {
  isBatchingUpdates = true;
  try {
    // 执行用户传入的回调(里面会调用多次 setState)
    callback();
  } finally {
    isBatchingUpdates = false;
    // 批量阶段结束后,执行所有更新任务并更新 state
    state = flushUpdates();
    schedule();
  }
}

2.3.2 完整代码

结合 HTML 模板的完整代码:codesandbox.io/p/sandbox/3…

2.3.3 React 原生批量更新的特性

不同 React 版本中,setState 的“同步/异步”表现不同,核心原因是批量更新标记的开启规则不一样:

React18 之前

批量更新仅在 React 管控的场景自动开启(比如点击/输入等合成事件,useEffect/生命周期钩子),此时 setState 会先收集任务、延迟渲染,表现为“异步”;

而在原生事件(比如 document.onclick)、定时器(setTimeout)和 Promise 回调中,批量更新标记未开启,useState 会立即更新并渲染,表现为“同步”。

React18 之后

批量更新标记默认全局开启,几乎所有场景下 setState 都是“异步”的。只有用 flushSync 包裹时,才会强制同步更新。

2.4 支持多个 useState

2.4.1 核心思路

我们之前只在全局定义了一个 state,如果多次调用 useState(比如同时定义 count 和 age 两个状态),会导致状态值混乱,要解决这个问题,核心思路如下:

1. 用数组存储多个 state

把全局的单个 state 改成 state 数组,每个 useState 都对应 state 数组中的一个元素。

let stateArr = []

2. 用下标(调用顺序)来匹配 state 用数组存储多个 state 后,我们需要在调用 setState 时准确知道更新数组中的哪一个 state。

因此我们需要靠下标(调用顺序)来匹配 state。

在全局维护一个 hookIndex 变量,每调用一次 useState,hookIndex 变量就自增 1(保存在 useState 的闭包中),这样每个 useState 都对应数组中的一个固定下标,就能精准匹配自己的 state。

let stateArr = [];
let hookIndex = 0;
// ...
function useState(initialVal) {
  const currentIndex = hookIndex;
  if (!stateArr[currentIndex]) {
    stateArr[currentIndex] = initialVal;
    // ...
  }
  function setState(action) {
    if (isBatchingUpdates) {
      // ...
    } else {
      // 非批量阶段:直接更新对应下标的 state,立即渲染
      const prevState = stateArr[currentIndex];
      stateArr[currentIndex] = typeof action === "function" ? action(prevState) : action;
      schedule();
    }
  }
  // 索引自增,匹配下一次 useState 调用
  hookIndex++;
  return [stateArr[currentIndex], setState];
}

3. 批量更新队列也要适配多状态

之前为单个 useState 维护了一个批量更新队列,现在需要支持多个 useState,单个队列也需要改造为队列数组。

let queueArr = [];
// ...
function flushUpdates() {
  // 遍历每个 state 的更新队列,执行更新
  for (let i = 0; i < queueArr.length; i++) {
    const queue = queueArr[i] || [];
    let currentState = stateArr[i];
    while (queue.length > 0) {
      const update = queue.shift();
      currentState = typeof update === "function" ? update(currentState) : update;
    }
    stateArr[i] = currentState; // 更新对应下标的state
    queueArr[i] = []; // 清空当前队列
  }
}
// ...
function useState(initialVal) {
  const currentIndex = hookIndex;
  if (!stateArr[currentIndex]) {
    // ...
    queueArr[currentIndex] = []; // 初始化对应下标的更新队列
  }
  function setState(action) {
    if (isBatchingUpdates) {
      // 批量阶段:把更新任务加入当前下标对应的队列
      queueArr[currentIndex].push(action);
    } else {
      // ...
    }
  }
  // ...
}

2.4.2 完整代码

结合 HTML 模板的完整代码我放到 codesandbox 上了,链接为:codesandbox.io/p/sandbox/j…

2.4.3 为什么 useState 不能放在 if 和 for 循环中

我们用数组实现的 useState 有个关键限制:必须在组件顶层调用,不能放在 if、for 循环里,否则会导致状态错乱。

比如下面例子,就是条件语句导致状态混乱。因为 if 的存在,flag 对应的 hookIndex 不固定,状态就会匹配错误。

function App() {
  const [count, setCount] = useState(0)   // hookIndex = 0
  
  if (count > 0) {
    const [extra, setExtra] = useState(0) // ❌ 错误!条件调用
  }
  
  const [flag, setFlag] = useState(false) // hookIndex 可能是 1 或 2,无法确定!
}

React 源码没有用数组存储 Hooks,而是用链表(每个 Hook 是一个节点,调用时从当前节点移到下一个)。但核心逻辑和数组实现一致,都依赖固定的调用顺序匹配状态,因此依然要遵守规则:useState 不能放在 if、for 循环中。

三、进阶版:基于链表的 useState

3.1 数组方案的致命缺陷:无法适配 React 18 并发模式

我们之前用数组实现的 useState,在同步渲染下能正常工作,但在 React18 的并发模式(Concurrent Mode)下会彻底失效。因为并发模式下可能中断渲染,一旦中断渲染,数组的索引就会全部错乱。

3.1.1 什么是并发模式?

我们知道,浏览器的刷新帧率约为 60fps,也就是大概每 16.6ms 刷新一次。这意味着如果一段 JavaScript 代码执行时间超过 16.6ms,就会阻塞页面刷新,导致卡顿。

React 的并发模式,就是允许中断渲染过程(比如优先处理用户点击、输入等高频交互),等浏览器空闲时再恢复渲染,避免页面卡顿。

3.1.2 数组在并发模式下的问题

数组实现的 useState 依靠全局唯一的 hookIndex 来匹配状态:

如果组件渲染过程被中断(比如渲染到一半,hookIndex 刚走到 2),等恢复渲染时,全局 hookIndex 并不会自动回到中断前的位置,而是会继续往后自增,这就会导致后续的 useState 与状态数组的下标 错位,最终状态匹配错误。

链表实现则可以很好地解决这个问题

链表不依赖全局索引,而是为每个组件独立维护一条 Hook 链表,并只用一个当前节点指针来记录遍历位置。渲染中断时,只需要保存当前指针指向的 Hook 节点;恢复渲染时,直接从这个节点继续往下遍历即可,不会出现索引错乱、状态错位的问题。

3.2 利用链表替代数组

现在我们基于之前的数组版本,把 useState 改造成链表实现,这样可以适配并发模式,更贴合 React 源码。

改造可以分为 4 个关键步骤:

3.2.1 定义 Hook 链表节点(替换数组存储)

首先会删除全局的 stateArr(状态数组)和 hookIndex(状态索引),改用链表节点存储每个 useState 的状态。

  • 每个 Hook 节点包含状态值、更新队列,以及指向下一个节点的指针;
  • 用 rootHook 记录链表的头节点,利用 currentHook 记录链表的当前节点。
// 定义单个 Hook 节点结构(链表核心)
function createHookNode(initialVal) {
  return {
    state: initialVal, // 当前 Hook 的状态值
    queue: [], // 当前 Hook 的更新队列
    next: null // 指向下一个 Hook 节点的指针
  };
}
// 链表核心指针:rootHook(链表头)、currentHook(当前遍历节点)
let rootHook = null;
let currentHook = null;

3.2.2 渲染函数适配(重置链表指针)

每次组件渲染前,需要把 currentHook 重置到链表头(rootHook),替代原本 hookIndex = 0 的逻辑,这样可以保证每次渲染时都从第一个 Hook 节点开始遍历。

function render() {
  currentHook = rootHook;
  root.render(<App />);
}

3.2.3 适配 useState 函数(遍历链表匹配状态)

之前靠自增 hookIndex 找到对应的 state,现在改为遍历链表(移动 currentHook 指针)匹配状态:

  • 首次渲染时,创建新的 Hook 节点并挂载到链表末尾;
  • 调用 setState 时,操作当前节点的状态 / 队列,而非数组下标;
  • 每次调用完 useState,把指针移到下一个节点(替代原来的 hookIndex++)。
function useState(initialVal) {
  // 首次渲染:创建新节点,初始化链表
  if (!currentHook) {
    const newHook = createHookNode(initialVal);
    // 链表为空时,rootHook指向第一个节点
    if (!rootHook) {
      rootHook = newHook;
    } else {
      // 链表已有节点,挂载到当前节点的next
      let lastHook = rootHook;
      while (lastHook.next) {
        lastHook = lastHook.next;
      }
      lastHook.next = newHook;
    }
    currentHook = newHook;
  }
  // 保存当前节点(避免后续指针移动影响)
  const hookNode = currentHook;
  function setState(action) {
    if (isBatchingUpdates) {
      // 批量阶段:加入当前节点的更新队列
      hookNode.queue.push(action);
    } else {
      // 非批量阶段:直接更新状态并渲染
      const prevState = hookNode.state;
      hookNode.state = typeof action === "function" ? action(prevState) : action;
      schedule();
    }
  }
  // 移动指针到下一个节点(替代原hookIndex++)
  currentHook = currentHook.next;
  return [hookNode.state, setState];
}

3.2.4 批量更新函数适配

之前批量更新是遍历 “队列数组”,现在更新队列存在每个链表节点里,因此改为遍历整个链表,逐个执行节点的更新任务。

function flushUpdates() {
  // 遍历整个Hook链表,执行每个节点的更新队列
  let hook = rootHook;
  while (hook) {
    const queue = hook.queue;
    let currentState = hook.state;
    // 执行当前节点的所有更新任务
    while (queue.length > 0) {
      const update = queue.shift();
      currentState = typeof update === "function" ? update(currentState) : update;
    }
    hook.state = currentState; // 更新节点状态
    hook.queue = []; // 清空队列
    hook = hook.next; // 移动到下一个节点
  }
}

3.2.5 完整代码

结合 HTML 模板的完整代码我放到 codesandbox 上了,链接为:codesandbox.io/p/sandbox/l…

3.3 利用环状链表替换队列

React 源码中,Hook 的更新队列并非普通数组,而是环状链表(循环链表)。相比普通数组,环状链表在 “频繁新增 / 删除更新任务” 时性能更高,且能更高效地处理并发模式下的更新中断 / 恢复。

为了更贴合 React 源码,我们把每个 Hook 节点中的 queue 替换为环状链表队列,并适配对应的 “入队、遍历执行” 逻辑。 大概可以分为 4 个步骤:

3.3.1 定义环状链表的节点

我们先创建环状链表的基础单元(单个更新任务),每个节点包含:

  • action:更新动作(比如 prev => prev + 1);
  • next:指向下一个更新任务节点的指针(最后一个节点的 next 指向头节点)。
// 新增:定义环状链表的更新任务节点
function createUpdateNode(action) {
  return {
    action: action, // 存储更新动作(值/函数)
    next: null      // 指向下一个更新任务节点
  };
}

3.3.2 修改 Hook 节点结构(替换普通数组队列)

我们把 Hook 节点中的 queue 替换为环状链表的核心指针:

  • queueHead:更新队列的头节点(默认 null);
  • queueTail:更新队列的尾节点(默认 null),环状链表的 queueTail.next = queueHead。
// 改造:Hook节点不再用数组队列,改用环状链表指针
function createHookNode(initialVal) {
  return {
    state: initialVal,    // 当前Hook的状态值
    queueHead: null,      // 更新队列头节点(环状链表)
    queueTail: null,      // 更新队列尾节点(环状链表)
    next: null            // 指向下一个Hook节点的指针
  };
}

3.3.3 适配 setState 入队逻辑(新增任务到环状链表)

原来的 hookNode.queue.push(action) 替换为 “环状链表入队”:

  • 若队列为空:头/尾节点都指向新任务;
  • 若队列非空:尾节点的 next 指向新任务,更新尾节点,且尾节点 next 指向头节点(形成环)。
// 新增:更新任务入队(环状链表)
function enqueueUpdate(hookNode, action) {
  const newNode = createUpdateNode(action);
  // 队列为空:头/尾节点都指向新节点
  if (!hookNode.queueHead) {
    hookNode.queueHead = newNode;
    hookNode.queueTail = newNode;
    newNode.next = newNode; // 环状:自己指向自己
  } else {
    // 队列非空:尾节点next指向新节点,更新尾节点,形成环
    hookNode.queueTail.next = newNode;
    hookNode.queueTail = newNode;
    newNode.next = hookNode.queueHead;
  }
}
// 改造useState中的setState:
function setState(action) {
  if (isBatchingUpdates) {
    // 替换:数组push → 环状链表入队
    enqueueUpdate(hookNode, action);
  } else {
    // 非批量逻辑不变(仅演示批量场景,非批量可复用入队+执行逻辑)
    const prevState = hookNode.state;
    hookNode.state = typeof action === "function" ? action(prevState) : action;
    schedule();
  }
}

3.3.4 适配 flushUpdates(遍历环状链表执行更新)

原来的 “遍历数组 queue.shift ()” 替换为 “遍历环状链表”:

  • 从队列头开始遍历,直到回到头节点(环状终止条件);
  • 执行所有更新任务后,清空环状链表(重置 head/tail 为 null)。
// 改造:批量更新核心(遍历环状链表执行更新)
function flushUpdates() {
  let hook = rootHook;
  while (hook) {
    const head = hook.queueHead;
    let currentState = hook.state;
    
    // 若有更新任务,遍历环状链表
    if (head) {
      let currentNode = head;
      // 环状链表遍历:直到回到头节点(终止)
      do {
        const action = currentNode.action;
        // 执行更新动作(和原逻辑一致)
        currentState = typeof action === "function" ? action(currentState) : action;
        currentNode = currentNode.next;
      } while (currentNode !== head); // 环状终止条件
      // 执行完所有任务,清空环状链表
      hook.queueHead = null;
      hook.queueTail = null;
      // 更新Hook节点的最终状态
      hook.state = currentState;
    }
    hook = hook.next; // 移动到下一个Hook节点
  }
}

3.3.5 完整代码

结合 HTML 模板的完整代码我放到 codesandbox 上了,链接为:codesandbox.io/p/sandbox/3…

四、总结

本篇文章,我拆解了 useState 的核心能力,并完成了基础版到进阶版的手写。通过从 0 到 1 实现一个 useState,你知道了 useState 的核心能力、设计思路和局限。

相信了解这些,能帮助吃透底层原理,从而更轻松应对面试,也更快地在日常开发中定位问题。

我是印刻君,一位探索 AI 的前端程序员。关注我,让前端知识有温度,技术落地有深度。

❌
❌