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>
);
}
问题出在哪里?
这段代码有两个致命问题:
-
Inline callback ref 导致引用不稳定
ref={(el) => {...}}每次渲染都会创建新的函数- React 检测到 ref 引用变化后会触发清理和重新绑定
-
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 会:
- 先调用旧的 ref 函数,传入
null(清理) - 再调用新的 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...
└─ 循环继续... ♻️
对第三方库的影响:
InfiniteScroll的componentDidUpdate被频繁触发- 滚动事件监听器被反复绑定/解绑
- 绑定时机错乱,最终导致监听失效
为什么不是 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>;
}
调用时机:
- 挂载时:
refCallback(domElement) - 卸载时:
refCallback(null) - ref 引用变化时:
- 先调用旧 ref:
oldRef(null) - 再调用新 ref:
newRef(domElement)
- 先调用旧 ref:
为什么 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 行,包含:
- ScrollContainerBuggy - 有问题的滚动容器
- App - 项目列表页面,使用 InfiniteScroll
如何观察问题
- 打开浏览器控制台 (F12)
- 查看日志输出:
🐛 [Buggy] ref callback called会被频繁调用🐛 [Buggy] ID set in ref callback显示 ID 设置时机
- 滚动到底部:
- 可能不会触发
🔄 fetchNextPage called - 无限滚动失效
- 可能不会触发
修复验证
将 ScrollContainerBuggy 改为:
function ScrollContainerFixed({ children, id }) {
const [scrollRef, setScrollRef] = useState(null);
return (
<div
id={id} // ✅ 修复
ref={setScrollRef} // ✅ 修复
>
{children}
</div>
);
}
重新运行,滚动到底部,会正常加载下一页。
总结
关键要点
-
不要使用 inline callback ref
- 除非用
useCallback包装且依赖最小化 - 优先使用
useRef或直接传递 setter
- 除非用
-
第三方库依赖的 DOM 属性必须同步可见
- 不要在 ref 回调中设置
- 通过 JSX 属性直接设置
-
理解 ref 的生命周期
- Ref 引用变化会触发清理和重新绑定
- 避免在 ref 回调中触发 state 更新
技术债务警示
这次事故暴露的深层问题:
-
基础组件变更风险被低估
- 缺乏充分的测试覆盖
- 没有评估对依赖方的影响
-
对 React 机制理解不够深入
- 不清楚 ref 回调的触发时机
- 忽略了 inline function 的引用问题
-
缺少代码规范和静态检查
- 没有 ESLint 规则检测 inline callback ref
- Code Review 没有发现问题
改进措施
- 代码规范: 明确 ref 使用规范,禁止 inline callback
- 静态检查: 添加 ESLint 规则检测不安全的 ref 用法
- 测试覆盖: 为关键交互添加 E2E 测试
- 监控告警: 添加前端监控,及时发现异常
- 发布流程: 基础组件变更增加更严格的审查