React Ref 使用不当导致无限滚动失效的问题

June 5, 2025

TL;DR

在使用 react-infinite-scroll-component 时,如果滚动容器使用了 inline callback ref 并在 ref 回调中动态设置 DOM id,会导致无限滚动功能失效。本文深入分析这个问题的根因、修复方案和最佳实践。

关键点:

  • ❌ 不要使用 inline callback ref(除非用 useCallback 包装)
  • ❌ 不要在 ref 回调中设置第三方库依赖的 DOM 属性
  • ✅ 使用稳定的 ref 引用,通过 JSX 属性设置 DOM id

问题背景

在一次常规功能迭代中,我们对基础滚动容器组件 ScrollContainer 进行了重构,将 ref 的设置方式从直接传递改为了 inline callback,并在回调中动态设置元素的 id 属性。

复现示例:https://stackblitz.com/edit/vitejs-vite-gbukpfqa?file=src%2FApp.tsx

// 变更前
<div id={id} ref={setScrollRef} />

// 变更后
<div ref={(el)=> {
  setScrollRef(el);
  if (el && id) {
    el.id= id;  // 动态设置 id
  }
}} />

这个看似无害的改动,却在线上环境引发了严重的问题。

问题现象

影响: 项目列表页面无法加载更多数据

表现:

  • 用户滚动到列表底部后,没有任何反应
  • 没有"加载中"的提示,也没有新数据加载
  • 控制台没有任何错误信息

影响范围:

  • 多数线上环境复现(非 100%)
  • 与设备性能、浏览器类型、页面复杂度相关
  • 本地开发环境可能正常,生产环境失败

问题代码

完整复现代码

import { useState } from 'react';
import InfiniteScroll from 'react-infinite-scroll-component';

function ScrollContainerBuggy({ children, id }) {
  const [scrollRef, setScrollRef] = useState(null);

  return (
    <div
      className="scroll-container"
      ref={(el)=> {
        // ❌ 问题 1: inline callback 每次渲染都是新引用
        setScrollRef(el);

        // ❌ 问题 2: id 在 ref 回调中设置
        if (el && id) {
          el.id= id;
        }
      }}
    >
      {children}
    </div>
  );
}

function ProjectList() {
  const [projects, setProjects] = useState([/* 初始数据 */]);
  const [hasMore, setHasMore] = useState(true);

  const fetchNextPage = () => {
    // 这个函数可能永远不会被调用!
    console.log('Loading next page...');
    // 加载逻辑...
  };

  return (
    <ScrollContainerBuggy id="project-list-scroll">
      <InfiniteScroll
        dataLength={projects.length}
        next={fetchNextPage}
        hasMore={hasMore}
        scrollableTarget="project-list-scroll"  // 通过 id 查找滚动容器
      >
        {/* 项目列表 */}
      </InfiniteScroll>
    </ScrollContainerBuggy>
  );
}

问题出在哪里?

这段代码有两个致命问题:

  1. Inline callback ref 导致引用不稳定

    • ref={(el) => {...}} 每次渲染都会创建新的函数
    • React 检测到 ref 引用变化后会触发清理和重新绑定
  2. DOM id 设置时机错误

    • react-infinite-scroll-component 在初始化时通过 document.getElementById 查找滚动容器
    • 但此时 id 还没有设置到 DOM 上,导致找不到元素

根因分析

根因 1:ID 设置时机问题

让我们看看 react-infinite-scroll-component 的初始化流程:

// react-infinite-scroll-component 内部逻辑(简化版)
class InfiniteScroll extends Component {
  componentDidMount() {
    // 在组件挂载时查找滚动容器
    const target = document.getElementById(this.props.scrollableTarget);

    if (target) {
      // 绑定滚动监听
      target.addEventListener('scroll', this.handleScroll);
    } else {
      // 找不到元素,降级到 window
      console.warn('Scroll target not found, falling back to window');
      window.addEventListener('scroll', this.handleScroll);
    }
  }
}

问题代码的执行时序:

Step 1: React 渲染 ScrollContainer
  └─ 创建 <div>(此时没有 id 属性)
  └─ DOM 元素插入页面

Step 2: InfiniteScroll 组件挂载
  └─ componentDidMount() 执行
  └─ document.getElementById("project-list-scroll")
  └─ ❌ 返回 null(id 还没设置!)
  └─ 降级绑定到 window.scroll

Step 3: Ref 回调执行
  └─ el.id = "project-list-scroll"
  └─ ✅ ID 设置成功(但为时已晚)

Step 4: 用户滚动容器
  └─ 触发容器的 scroll 事件
  └─ 但监听器绑定在 window 上
  └─ ❌ handleScroll 不会被调用
  └─ ❌ fetchNextPage 永远不会触发

根因 2:Inline Callback Ref 导致抖动

React 对 callback ref 的处理机制:

当 ref 回调的引用发生变化时,React 会:

  1. 先调用旧的 ref 函数,传入 null(清理)
  2. 再调用新的 ref 函数,传入 element(重新绑定)

问题代码的抖动链路:

Render 1:
  ├─ 创建 ref_v1 = (el) => {...}
  └─ React 调用 ref_v1(element)
     └─ setScrollRef(element)
     └─ 触发 state 更新  Render 2

Render 2:
  ├─ 创建 ref_v2 = (el) => {...}  // ⚠️ 新的函数引用!
  ├─ React 检测到 ref 变化
  ├─ 调用 ref_v1(null)  // 清理旧 ref
    └─ setScrollRef(null)
    └─ 触发 state 更新  Render 3
  
  └─ 调用 ref_v2(element)  // 绑定新 ref
     └─ setScrollRef(element)
     └─ 触发 state 更新  Render 4

Render 3, 4, 5...
  └─ 循环继续... ♻️

对第三方库的影响:

  • InfiniteScrollcomponentDidUpdate 被频繁触发
  • 滚动事件监听器被反复绑定/解绑
  • 绑定时机错乱,最终导致监听失效

为什么不是 100% 复现?

问题是否出现取决于多个因素的竞态:

因素容易触发不容易触发
渲染性能生产环境(快)开发环境(慢)
页面复杂度复杂页面,组件多简单页面,组件少
React 模式Strict Mode(双重渲染)普通模式
首屏数据数据量大,渲染慢数据量小,渲染快
网络条件快速加载慢速加载

触发条件: InfiniteScroll 初始化 早于 ref 回调设置 id

修复方案

方案对比

❌ 有问题的代码

function ScrollContainerBuggy({ children, id }) {
  const [scrollRef, setScrollRef] = useState(null);

  return (
    <div
      ref={(el)=> {           // 问题 1: inline callback
        setScrollRef(el);
        if (el && id) {
          el.id= id;          // 问题 2: 动态设置 id
        }
      }}
    >
      {children}
    </div>
  );
}

问题:

  • ❌ Ref 引用每次渲染都变化
  • ❌ ID 在 ref 回调中延后设置
  • ❌ 触发频繁的重渲染
  • ❌ 第三方库初始化时找不到元素

✅ 修复后的代码

function ScrollContainerFixed({ children, id }) {
  const [scrollRef, setScrollRef] = useState(null);

  return (
    <div
      id={id}                  // ✅ 直接通过 JSX 设置 id
      ref={setScrollRef}       // ✅ 稳定的 ref 引用
    >
      {children}
    </div>
  );
}

优点:

  • setScrollRef 是稳定的函数引用(React 保证)
  • ✅ ID 在首次渲染时就存在于 DOM
  • ✅ 没有额外的重渲染
  • ✅ 第三方库能正确找到元素

修改对比表

维度问题代码修复代码
Ref 稳定性每次渲染新引用引用稳定
ID 可见时机ref 回调中(延后)首次渲染(立即)
额外渲染可能无限循环无额外渲染
第三方库兼容初始化可能失败正常初始化
性能影响频繁重渲染正常性能

React Ref 机制深入

什么是 Ref?

Ref 是 React 提供的一种直接访问 DOM 节点的方式。主要有三种使用方式:

1. useRef Hook

function Component() {
  const divRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (divRef.current) {
      console.log('DOM element:', divRef.current);
    }
  }, []);

  return <div ref={divRef}>Content</div>;
}

特点:

  • divRef 对象在整个生命周期中保持不变
  • divRef.current 在挂载后指向 DOM 元素
  • ✅ 不会触发重渲染

2. Callback Ref(正确用法)

function Component() {
  const [element, setElement] = useState<HTMLDivElement | null>(null);

  return <div ref={setElement}>Content</div>;
}

特点:

  • setElement 是稳定的函数引用
  • ✅ 元素挂载时自动调用 setElement(el)
  • ⚠️ 会触发一次重渲染(因为 state 更新)

3. Callback Ref(错误用法)

function Component() {
  const [element, setElement] = useState<HTMLDivElement | null>(null);

  return (
    <div ref={(el)=> setElement(el)}>  {/* ❌ inline callback */}
      Content
    </div>
  );
}

问题:

  • (el) => setElement(el) 每次渲染都是新函数
  • ❌ 触发 ref 的清理和重新绑定
  • ❌ 可能导致无限循环

Callback Ref 的生命周期

function Component() {
  const refCallback = (el: HTMLDivElement | null) => {
    if (el) {
      console.log('元素挂载:', el);
    } else {
      console.log('元素卸载');
    }
  };

  return <div ref={refCallback}>Content</div>;
}

调用时机:

  1. 挂载时: refCallback(domElement)
  2. 卸载时: refCallback(null)
  3. ref 引用变化时:
    • 先调用旧 ref:oldRef(null)
    • 再调用新 ref:newRef(domElement)

为什么 Inline Callback 会导致问题?

// 渲染过程分析
function Component() {
  const [count, setCount] = useState(0);

  console.log('Render', count);

  return (
    <div
      ref={(el)=> {
        console.log('Ref callback called', el ? 'with element' : 'with null');
        if (el) setCount(c=> c + 1);  // ⚠️ 触发重渲染
      }}
    >
      Count: {count}
    </div>
  );
}

// 输出:
// Render 0
// Ref callback called with element
// Render 1
// Ref callback called with null      ← ref 引用变化,先清理
// Ref callback called with element   ← 再重新绑定
// Render 2
// Ref callback called with null
// Ref callback called with element
// Render 3
// ... 无限循环

正确的解决方案

方案 1:使用 useRef(推荐)

function Component() {
  const divRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (divRef.current) {
      // 在 effect 中处理副作用
      console.log('Element mounted:', divRef.current);
    }
  }, []);

  return <div ref={divRef}>Content</div>;
}

方案 2:直接传递 setter(适用于需要 state 的场景)

function Component() {
  const [element, setElement] = useState<HTMLDivElement | null>(null);

  useEffect(() => {
    if (element) {
      console.log('Element changed:', element);
    }
  }, [element]);

  return <div ref={setElement}>Content</div>;
}

方案 3:使用 useCallback(需要额外逻辑时)

function Component() {
  const [element, setElement] = useState<HTMLDivElement | null>(null);

  const handleRef = useCallback((el: HTMLDivElement | null) => {
    setElement(el);
    if (el) {
      // 额外逻辑
      console.log('Element size:', el.clientWidth, el.clientHeight);
    }
  }, []); // ⚠️ 依赖数组必须为空或最小化

  return <div ref={handleRef}>Content</div>;
}

最佳实践

1. Ref 使用规范

✅ DO - 推荐做法

// ✅ 使用 useRef
const ref = useRef<HTMLDivElement>(null);
<div ref={ref} />

// ✅ 直接传递稳定的函数引用
const [element, setElement] = useState(null);
<div ref={setElement} />

// ✅ 使用 useCallback 包装(依赖为空)
const handleRef = useCallback((el) => {
  // 处理逻辑
}, []);
<div ref={handleRef} />

❌ DON'T - 避免的做法

// ❌ Inline callback ref
<div ref={(el)=> setElement(el)} />

// ❌ 在 ref 回调中触发 state 更新
<div ref={(el)=> {
  setElement(el);
  setWidth(el?.clientWidth);  // 多个 state 更新
}} />

// ❌ 在 ref 回调中设置 DOM 属性(如果第三方库依赖)
<div ref={(el)=> {
  if (el) el.id= 'my-id';  // 延后设置
}} />

// ❌ useCallback 依赖过多
const handleRef = useCallback((el) => {
  // ...
}, [prop1, prop2, prop3]);  // 依赖变化时 ref 会重新绑定

2. 与第三方库集成

场景 1:库需要通过 ID 查找元素

// ❌ 错误:ID 延后设置
<div ref={(el)=> {
  if (el) el.id= 'target-id';
}}>
  <ThirdPartyComponent targetId="target-id" />
</div>

// ✅ 正确:ID 立即可见
<div id="target-id">
  <ThirdPartyComponent targetId="target-id" />
</div>

适用场景:

  • react-infinite-scroll-component (scrollableTarget)
  • Sortable.js (通过 ID 初始化)
  • Chart.js (canvas ID)
  • 任何通过 document.getElementById 查找的库

场景 2:库需要直接操作 DOM

// ✅ 正确做法
function Component() {
  const chartRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    if (chartRef.current) {
      const chart = new Chart(chartRef.current, {
        // 配置...
      });

      return () => chart.destroy();  // 清理
    }
  }, []);

  return <canvas ref={chartRef} />;
}

3. 性能优化

避免不必要的重渲染

// ❌ 每次 parent 渲染都会重新创建 callback
function Parent() {
  return <Child ref={(el)=> console.log(el)} />;
}

// ✅ 使用 useCallback 缓存
function Parent() {
  const handleRef = useCallback((el) => console.log(el), []);
  return <Child ref={handleRef} />;
}

最小化 useCallback 依赖

// ❌ 依赖过多,ref 频繁变化
const handleRef = useCallback((el) => {
  if (el) {
    console.log(prop1, prop2, prop3);
  }
}, [prop1, prop2, prop3]);

// ✅ 使用 ref 存储最新值
const propsRef = useRef({ prop1, prop2, prop3 });
propsRef.current = { prop1, prop2, prop3 };

const handleRef = useCallback((el) => {
  if (el) {
    console.log(propsRef.current);
  }
}, []);

4. Code Review Checklist

在审查涉及 ref 的代码时,检查:

  • 是否使用了 inline callback ref?
  • Callback ref 是否触发了 state 更新?
  • 是否在 ref 回调中设置 DOM 属性?
  • 是否有第三方库依赖该 DOM 节点?
  • 如果使用 useCallback,依赖数组是否最小化?
  • 是否可以用 useRef 替代 callback ref?

Demo 演示

在线 Demo

https://stackblitz.com/edit/vitejs-vite-gbukpfqa?file=src%2FApp.tsx

核心代码

完整代码在 src/App.tsx 中,只有 140 行,包含:

  1. ScrollContainerBuggy - 有问题的滚动容器
  2. App - 项目列表页面,使用 InfiniteScroll

如何观察问题

  1. 打开浏览器控制台 (F12)
  2. 查看日志输出:
    • 🐛 [Buggy] ref callback called 会被频繁调用
    • 🐛 [Buggy] ID set in ref callback 显示 ID 设置时机
  3. 滚动到底部:
    • 可能不会触发 🔄 fetchNextPage called
    • 无限滚动失效

修复验证

ScrollContainerBuggy 改为:

function ScrollContainerFixed({ children, id }) {
  const [scrollRef, setScrollRef] = useState(null);

  return (
    <div
      id={id}                  // ✅ 修复
      ref={setScrollRef}       // ✅ 修复
    >
      {children}
    </div>
  );
}

重新运行,滚动到底部,会正常加载下一页。

总结

关键要点

  1. 不要使用 inline callback ref

    • 除非用 useCallback 包装且依赖最小化
    • 优先使用 useRef 或直接传递 setter
  2. 第三方库依赖的 DOM 属性必须同步可见

    • 不要在 ref 回调中设置
    • 通过 JSX 属性直接设置
  3. 理解 ref 的生命周期

    • Ref 引用变化会触发清理和重新绑定
    • 避免在 ref 回调中触发 state 更新

技术债务警示

这次事故暴露的深层问题:

  1. 基础组件变更风险被低估

    • 缺乏充分的测试覆盖
    • 没有评估对依赖方的影响
  2. 对 React 机制理解不够深入

    • 不清楚 ref 回调的触发时机
    • 忽略了 inline function 的引用问题
  3. 缺少代码规范和静态检查

    • 没有 ESLint 规则检测 inline callback ref
    • Code Review 没有发现问题

改进措施

  1. 代码规范: 明确 ref 使用规范,禁止 inline callback
  2. 静态检查: 添加 ESLint 规则检测不安全的 ref 用法
  3. 测试覆盖: 为关键交互添加 E2E 测试
  4. 监控告警: 添加前端监控,及时发现异常
  5. 发布流程: 基础组件变更增加更严格的审查

参考资料