Appearance
useLayoutEffect同步更新
学习目标
通过本章学习,你将全面掌握:
- useLayoutEffect的概念和原理
- useLayoutEffect vs useEffect的区别
- 执行时机的详细对比
- useLayoutEffect的使用场景
- DOM测量和同步更新
- 避免闪烁的技巧
- 性能考虑和最佳实践
- 动画和过渡效果
- 布局计算与响应式设计
- React 19中的最佳实践
- TypeScript集成
- 与第三方库的配合
第一部分:useLayoutEffect基础
1.1 什么是useLayoutEffect
useLayoutEffect与useEffect功能相同,但在所有DOM变更之后同步调用,在浏览器绘制之前执行。
jsx
import { useEffect, useLayoutEffect, useState } from 'react';
function BasicComparison() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log('3. useEffect执行(在浏览器绘制后)');
});
useLayoutEffect(() => {
console.log('2. useLayoutEffect执行(在浏览器绘制前)');
});
console.log('1. 组件渲染');
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>增加</button>
</div>
);
// 执行顺序:
// 1. 组件渲染(生成虚拟DOM)
// 2. React更新真实DOM
// 3. useLayoutEffect执行(同步,阻塞)
// 4. 浏览器绘制到屏幕
// 5. useEffect执行(异步,不阻塞)
}1.2 useEffect vs useLayoutEffect
jsx
function DetailedComparison() {
const [value, setValue] = useState(0);
// useEffect:异步执行(不阻塞绘制)
useEffect(() => {
// 在浏览器绘制后执行
console.log('useEffect - 值:', value);
// 适合:
// - 数据获取
// - 订阅事件
// - 日志记录
// - 不影响布局的操作
// - 大多数副作用
}, [value]);
// useLayoutEffect:同步执行(阻塞绘制)
useLayoutEffect(() => {
// 在浏览器绘制前执行
console.log('useLayoutEffect - 值:', value);
// 适合:
// - DOM测量
// - 布局计算
// - 避免闪烁
// - 需要同步读取/修改DOM的操作
// - 动画初始化
}, [value]);
return (
<div>
<p>Value: {value}</p>
<button onClick={() => setValue(v => v + 1)}>增加</button>
</div>
);
}1.3 生命周期对比图
jsx
/**
* React组件更新流程:
*
* 1. 触发更新(setState、props变化等)
* ↓
* 2. React调用组件函数,生成虚拟DOM
* ↓
* 3. React比较虚拟DOM(Reconciliation)
* ↓
* 4. React更新真实DOM(Commit Phase)
* ↓
* 5. **useLayoutEffect执行**(同步,阻塞)
* ↓
* 6. 浏览器绘制(Paint)
* ↓
* 7. **useEffect执行**(异步,不阻塞)
*
* 关键区别:
* - useLayoutEffect在绘制前执行,可以同步修改DOM
* - useEffect在绘制后执行,修改DOM会导致第二次绘制
*/
function LifecycleDemo() {
const [step, setStep] = useState(0);
useLayoutEffect(() => {
console.log(`useLayoutEffect - step ${step}`);
// 此时DOM已更新,但还未绘制
}, [step]);
useEffect(() => {
console.log(`useEffect - step ${step}`);
// 此时DOM已更新并已绘制
}, [step]);
return (
<div>
<p>Current step: {step}</p>
<button onClick={() => setStep(s => s + 1)}>下一步</button>
</div>
);
}1.4 闪烁问题详细演示
jsx
// ❌ 使用useEffect:会闪烁
function FlickerWithUseEffect() {
const [position, setPosition] = useState(0);
const boxRef = useRef(null);
useEffect(() => {
// 1. 浏览器先绘制position=0的位置(左侧)
// 2. 然后这里计算新位置
const newPosition = calculatePosition(boxRef.current);
// 3. 更新state
setPosition(newPosition);
// 4. 再次绘制新位置(右侧)
// 用户会看到从左到右的闪烁
}, []);
return (
<div
ref={boxRef}
style={{
transform: `translateX(${position}px)`,
background: '#e74c3c',
padding: '20px'
}}
>
使用useEffect(会闪烁)
</div>
);
}
// ✅ 使用useLayoutEffect:无闪烁
function NoFlickerWithLayoutEffect() {
const [position, setPosition] = useState(0);
const boxRef = useRef(null);
useLayoutEffect(() => {
// 1. DOM已更新,但还未绘制
// 2. 计算新位置
const newPosition = calculatePosition(boxRef.current);
// 3. 更新state
setPosition(newPosition);
// 4. 浏览器直接绘制最终位置
// 用户只看到最终结果,无闪烁
}, []);
return (
<div
ref={boxRef}
style={{
transform: `translateX(${position}px)`,
background: '#2ecc71',
padding: '20px'
}}
>
使用useLayoutEffect(无闪烁)
</div>
);
}
function calculatePosition(element) {
if (!element) return 0;
const rect = element.getBoundingClientRect();
return window.innerWidth - rect.width - 20;
}
// 对比组件
function FlickerComparison() {
return (
<div style={{ padding: '20px' }}>
<h2>闪烁对比</h2>
<p>刷新页面观察区别</p>
<div style={{ marginBottom: '20px' }}>
<FlickerWithUseEffect />
</div>
<div>
<NoFlickerWithLayoutEffect />
</div>
</div>
);
}第二部分:DOM测量
2.1 测量元素尺寸
jsx
function MeasureElement({ children }) {
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
const elementRef = useRef(null);
useLayoutEffect(() => {
// 在绘制前测量并设置尺寸
const updateDimensions = () => {
if (elementRef.current) {
const { width, height } = elementRef.current.getBoundingClientRect();
setDimensions({ width, height });
}
};
updateDimensions();
window.addEventListener('resize', updateDimensions);
return () => window.removeEventListener('resize', updateDimensions);
}, []);
return (
<div>
<div
ref={elementRef}
style={{
padding: '20px',
background: '#f0f0f0',
borderRadius: '8px'
}}
>
{children}
</div>
<div style={{ marginTop: '10px', fontSize: '14px', color: '#666' }}>
<p>宽度: {dimensions.width.toFixed(2)}px</p>
<p>高度: {dimensions.height.toFixed(2)}px</p>
<p>面积: {(dimensions.width * dimensions.height).toFixed(2)}px²</p>
</div>
</div>
);
}
// 使用
function App() {
return (
<MeasureElement>
<h3>标题</h3>
<p>这是一段文本内容</p>
<button>按钮</button>
</MeasureElement>
);
}2.2 自适应Tooltip
jsx
function SmartTooltip({ children, content, preferredPosition = 'top' }) {
const [position, setPosition] = useState({ top: 0, left: 0 });
const [actualPosition, setActualPosition] = useState(preferredPosition);
const [visible, setVisible] = useState(false);
const triggerRef = useRef(null);
const tooltipRef = useRef(null);
useLayoutEffect(() => {
if (!visible || !triggerRef.current || !tooltipRef.current) {
return;
}
const triggerRect = triggerRef.current.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let finalPosition = preferredPosition;
let top = 0;
let left = 0;
// 尝试首选位置
switch (preferredPosition) {
case 'top':
top = triggerRect.top - tooltipRect.height - 10;
left = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2;
// 如果顶部空间不足,切换到底部
if (top < 0) {
finalPosition = 'bottom';
top = triggerRect.bottom + 10;
}
break;
case 'bottom':
top = triggerRect.bottom + 10;
left = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2;
// 如果底部空间不足,切换到顶部
if (top + tooltipRect.height > viewportHeight) {
finalPosition = 'top';
top = triggerRect.top - tooltipRect.height - 10;
}
break;
case 'left':
top = triggerRect.top + (triggerRect.height - tooltipRect.height) / 2;
left = triggerRect.left - tooltipRect.width - 10;
// 如果左侧空间不足,切换到右侧
if (left < 0) {
finalPosition = 'right';
left = triggerRect.right + 10;
}
break;
case 'right':
top = triggerRect.top + (triggerRect.height - tooltipRect.height) / 2;
left = triggerRect.right + 10;
// 如果右侧空间不足,切换到左侧
if (left + tooltipRect.width > viewportWidth) {
finalPosition = 'left';
left = triggerRect.left - tooltipRect.width - 10;
}
break;
}
// 确保不超出视口
left = Math.max(10, Math.min(left, viewportWidth - tooltipRect.width - 10));
top = Math.max(10, Math.min(top, viewportHeight - tooltipRect.height - 10));
setPosition({ top, left });
setActualPosition(finalPosition);
}, [visible, preferredPosition]);
return (
<div className="tooltip-container">
<div
ref={triggerRef}
onMouseEnter={() => setVisible(true)}
onMouseLeave={() => setVisible(false)}
className="tooltip-trigger"
>
{children}
</div>
{visible && (
<div
ref={tooltipRef}
className={`tooltip tooltip-${actualPosition}`}
style={{
position: 'fixed',
top: `${position.top}px`,
left: `${position.left}px`,
background: '#333',
color: '#fff',
padding: '8px 12px',
borderRadius: '4px',
fontSize: '14px',
zIndex: 1000,
pointerEvents: 'none'
}}
>
{content}
<div className={`tooltip-arrow arrow-${actualPosition}`} />
</div>
)}
</div>
);
}
// 使用
function TooltipDemo() {
return (
<div style={{ padding: '100px' }}>
<SmartTooltip content="这是一个智能Tooltip" preferredPosition="top">
<button>悬停查看Tooltip</button>
</SmartTooltip>
</div>
);
}2.3 响应式网格布局
jsx
function ResponsiveGrid({ items, minColumnWidth = 200 }) {
const [columns, setColumns] = useState(1);
const gridRef = useRef(null);
useLayoutEffect(() => {
const updateColumns = () => {
if (gridRef.current) {
const containerWidth = gridRef.current.offsetWidth;
const newColumns = Math.max(1, Math.floor(containerWidth / minColumnWidth));
setColumns(newColumns);
}
};
updateColumns();
window.addEventListener('resize', updateColumns);
return () => window.removeEventListener('resize', updateColumns);
}, [minColumnWidth]);
return (
<div
ref={gridRef}
style={{
display: 'grid',
gridTemplateColumns: `repeat(${columns}, 1fr)`,
gap: '16px',
padding: '16px'
}}
>
{items.map((item, index) => (
<div
key={index}
style={{
background: '#f0f0f0',
padding: '20px',
borderRadius: '8px'
}}
>
{item}
</div>
))}
</div>
);
}
// 使用
function GridDemo() {
const items = Array.from({ length: 20 }, (_, i) => `项目 ${i + 1}`);
return (
<div>
<h2>响应式网格</h2>
<ResponsiveGrid items={items} minColumnWidth={250} />
</div>
);
}第三部分:避免闪烁
3.1 文本截断
jsx
function TruncatedText({ text, maxLines = 2 }) {
const [truncated, setTruncated] = useState(false);
const [showFull, setShowFull] = useState(false);
const textRef = useRef(null);
useLayoutEffect(() => {
if (!textRef.current) return;
const element = textRef.current;
const computedStyle = getComputedStyle(element);
const lineHeight = parseFloat(computedStyle.lineHeight);
const maxHeight = lineHeight * maxLines;
setTruncated(element.scrollHeight > maxHeight);
}, [text, maxLines]);
return (
<div className="truncated-text">
<div
ref={textRef}
style={{
maxHeight: showFull ? 'none' : `${maxLines * 1.5}em`,
overflow: 'hidden',
lineHeight: '1.5em'
}}
>
{text}
</div>
{truncated && (
<button
onClick={() => setShowFull(!showFull)}
style={{
marginTop: '8px',
color: '#007bff',
background: 'none',
border: 'none',
cursor: 'pointer'
}}
>
{showFull ? '收起' : '展开'}
</button>
)}
</div>
);
}
// 使用
function TextDemo() {
const longText = `
React是一个用于构建用户界面的JavaScript库。
它由Facebook开发和维护,是目前最流行的前端框架之一。
React采用声明式编程范式,使得创建交互式UI变得简单。
通过组件化的思想,React让代码更易于维护和复用。
虚拟DOM技术使得React性能出色,能够高效地更新用户界面。
`;
return (
<div style={{ maxWidth: '600px', margin: '0 auto' }}>
<h2>文本截断示例</h2>
<TruncatedText text={longText} maxLines={3} />
</div>
);
}3.2 模态框居中定位
jsx
function CenteredModal({ isOpen, onClose, children }) {
const [position, setPosition] = useState({ top: 0, left: 0 });
const modalRef = useRef(null);
useLayoutEffect(() => {
if (isOpen && modalRef.current) {
const rect = modalRef.current.getBoundingClientRect();
setPosition({
top: (window.innerHeight - rect.height) / 2,
left: (window.innerWidth - rect.width) / 2
});
}
}, [isOpen, children]);
if (!isOpen) return null;
return (
<div
className="modal-backdrop"
onClick={onClose}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.5)',
zIndex: 1000
}}
>
<div
ref={modalRef}
className="modal"
onClick={(e) => e.stopPropagation()}
style={{
position: 'fixed',
top: `${position.top}px`,
left: `${position.left}px`,
background: 'white',
padding: '20px',
borderRadius: '8px',
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)'
}}
>
{children}
<button
onClick={onClose}
style={{
marginTop: '20px',
padding: '8px 16px'
}}
>
关闭
</button>
</div>
</div>
);
}
// 使用
function ModalDemo() {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button onClick={() => setIsOpen(true)}>打开模态框</button>
<CenteredModal isOpen={isOpen} onClose={() => setIsOpen(false)}>
<h2>模态框标题</h2>
<p>这是模态框内容</p>
<p>模态框会自动居中显示</p>
</CenteredModal>
</div>
);
}3.3 动态高度过渡
jsx
function ExpandableSection({ title, children, defaultExpanded = false }) {
const [expanded, setExpanded] = useState(defaultExpanded);
const [height, setHeight] = useState(0);
const contentRef = useRef(null);
useLayoutEffect(() => {
if (contentRef.current) {
const contentHeight = contentRef.current.scrollHeight;
setHeight(expanded ? contentHeight : 0);
}
}, [expanded, children]);
return (
<div className="expandable-section">
<button
onClick={() => setExpanded(!expanded)}
className="section-header"
style={{
width: '100%',
padding: '12px',
background: '#f5f5f5',
border: 'none',
textAlign: 'left',
cursor: 'pointer',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}
>
<span>{title}</span>
<span style={{ transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.3s' }}>
▼
</span>
</button>
<div
style={{
height: `${height}px`,
overflow: 'hidden',
transition: 'height 0.3s ease-in-out'
}}
>
<div ref={contentRef} style={{ padding: '12px' }}>
{children}
</div>
</div>
</div>
);
}
// 使用
function AccordionDemo() {
return (
<div style={{ maxWidth: '600px', margin: '0 auto' }}>
<h2>手风琴组件</h2>
<ExpandableSection title="什么是React?">
<p>React是一个用于构建用户界面的JavaScript库。</p>
<p>它由Facebook开发,采用组件化和声明式编程。</p>
</ExpandableSection>
<ExpandableSection title="为什么使用React?">
<p>React有以下优势:</p>
<ul>
<li>组件化开发</li>
<li>虚拟DOM提升性能</li>
<li>丰富的生态系统</li>
<li>强大的社区支持</li>
</ul>
</ExpandableSection>
<ExpandableSection title="如何学习React?" defaultExpanded>
<p>学习React的步骤:</p>
<ol>
<li>掌握JavaScript基础</li>
<li>学习JSX语法</li>
<li>理解组件和Props</li>
<li>掌握Hooks</li>
<li>实践项目</li>
</ol>
</ExpandableSection>
</div>
);
}第四部分:动画和过渡
4.1 滚动动画
jsx
function ScrollAnimation({ children }) {
const [isVisible, setIsVisible] = useState(false);
const [offset, setOffset] = useState(50);
const elementRef = useRef(null);
useLayoutEffect(() => {
const handleScroll = () => {
if (elementRef.current) {
const rect = elementRef.current.getBoundingClientRect();
const windowHeight = window.innerHeight;
if (rect.top < windowHeight * 0.8) {
setIsVisible(true);
setOffset(0);
} else {
setIsVisible(false);
setOffset(50);
}
}
};
handleScroll();
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
return (
<div
ref={elementRef}
style={{
opacity: isVisible ? 1 : 0,
transform: `translateY(${offset}px)`,
transition: 'opacity 0.6s, transform 0.6s'
}}
>
{children}
</div>
);
}
// 使用
function ScrollDemo() {
return (
<div>
<div style={{ height: '100vh', padding: '20px' }}>
<h1>向下滚动查看动画</h1>
</div>
<ScrollAnimation>
<div style={{ padding: '40px', background: '#f0f0f0' }}>
<h2>内容1</h2>
<p>滚动到此处时会有淡入动画</p>
</div>
</ScrollAnimation>
<ScrollAnimation>
<div style={{ padding: '40px', background: '#e0e0e0' }}>
<h2>内容2</h2>
<p>每个元素独立动画</p>
</div>
</ScrollAnimation>
<ScrollAnimation>
<div style={{ padding: '40px', background: '#d0d0d0' }}>
<h2>内容3</h2>
<p>继续滚动查看更多</p>
</div>
</ScrollAnimation>
</div>
);
}4.2 拖拽位置同步
jsx
function DraggableElement() {
const [position, setPosition] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const elementRef = useRef(null);
useLayoutEffect(() => {
if (!isDragging) return;
const handleMouseMove = (e) => {
const deltaX = e.clientX - dragStart.x;
const deltaY = e.clientY - dragStart.y;
setPosition({
x: position.x + deltaX,
y: position.y + deltaY
});
setDragStart({ x: e.clientX, y: e.clientY });
};
const handleMouseUp = () => {
setIsDragging(false);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, dragStart, position]);
const handleMouseDown = (e) => {
setIsDragging(true);
setDragStart({ x: e.clientX, y: e.clientY });
};
return (
<div
ref={elementRef}
onMouseDown={handleMouseDown}
style={{
position: 'absolute',
top: `${position.y}px`,
left: `${position.x}px`,
padding: '20px',
background: '#007bff',
color: 'white',
borderRadius: '8px',
cursor: isDragging ? 'grabbing' : 'grab',
userSelect: 'none'
}}
>
拖动我
</div>
);
}第五部分:复杂布局计算
5.1 自适应下拉菜单
jsx
function AdaptiveDropdown({ trigger, items }) {
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0 });
const [direction, setDirection] = useState('down');
const triggerRef = useRef(null);
const menuRef = useRef(null);
useLayoutEffect(() => {
if (!isOpen || !triggerRef.current || !menuRef.current) {
return;
}
const triggerRect = triggerRef.current.getBoundingClientRect();
const menuRect = menuRef.current.getBoundingClientRect();
const viewportHeight = window.innerHeight;
// 判断下方是否有足够空间
const spaceBelow = viewportHeight - triggerRect.bottom;
const spaceAbove = triggerRect.top;
let top;
let newDirection;
if (spaceBelow >= menuRect.height) {
// 下方有空间,向下展开
top = triggerRect.bottom;
newDirection = 'down';
} else if (spaceAbove >= menuRect.height) {
// 上方有空间,向上展开
top = triggerRect.top - menuRect.height;
newDirection = 'up';
} else {
// 两边都不够,选择空间较大的一侧
if (spaceBelow > spaceAbove) {
top = triggerRect.bottom;
newDirection = 'down';
} else {
top = triggerRect.top - menuRect.height;
newDirection = 'up';
}
}
setPosition({
top,
left: triggerRect.left
});
setDirection(newDirection);
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (e) => {
if (
triggerRef.current &&
menuRef.current &&
!triggerRef.current.contains(e.target) &&
!menuRef.current.contains(e.target)
) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen]);
return (
<div className="dropdown">
<div ref={triggerRef} onClick={() => setIsOpen(!isOpen)}>
{trigger}
</div>
{isOpen && (
<div
ref={menuRef}
className={`dropdown-menu direction-${direction}`}
style={{
position: 'fixed',
top: `${position.top}px`,
left: `${position.left}px`,
background: 'white',
border: '1px solid #ddd',
borderRadius: '4px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
zIndex: 1000,
minWidth: '150px'
}}
>
{items.map((item, index) => (
<div
key={index}
className="dropdown-item"
onClick={() => {
item.onClick?.();
setIsOpen(false);
}}
style={{
padding: '8px 12px',
cursor: 'pointer',
transition: 'background 0.2s'
}}
>
{item.label}
</div>
))}
</div>
)}
</div>
);
}
// 使用
function DropdownDemo() {
return (
<div style={{ padding: '100px' }}>
<AdaptiveDropdown
trigger={<button>打开菜单</button>}
items={[
{ label: '选项1', onClick: () => console.log('选项1') },
{ label: '选项2', onClick: () => console.log('选项2') },
{ label: '选项3', onClick: () => console.log('选项3') }
]}
/>
</div>
);
}5.2 虚拟滚动优化
jsx
function VirtualList({ items, itemHeight = 50, containerHeight = 400 }) {
const [scrollTop, setScrollTop] = useState(0);
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 0 });
const containerRef = useRef(null);
useLayoutEffect(() => {
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = Math.min(
items.length - 1,
Math.ceil((scrollTop + containerHeight) / itemHeight)
);
setVisibleRange({ start: startIndex, end: endIndex });
}, [scrollTop, itemHeight, containerHeight, items.length]);
const handleScroll = (e) => {
setScrollTop(e.target.scrollTop);
};
const visibleItems = items.slice(visibleRange.start, visibleRange.end + 1);
const totalHeight = items.length * itemHeight;
const offsetY = visibleRange.start * itemHeight;
return (
<div
ref={containerRef}
className="virtual-list"
onScroll={handleScroll}
style={{
height: `${containerHeight}px`,
overflow: 'auto',
border: '1px solid #ddd'
}}
>
<div style={{ height: `${totalHeight}px`, position: 'relative' }}>
<div
style={{
position: 'absolute',
top: `${offsetY}px`,
left: 0,
right: 0
}}
>
{visibleItems.map((item, index) => (
<div
key={visibleRange.start + index}
style={{
height: `${itemHeight}px`,
padding: '10px',
borderBottom: '1px solid #eee',
display: 'flex',
alignItems: 'center'
}}
>
项目 #{visibleRange.start + index + 1}: {item}
</div>
))}
</div>
</div>
</div>
);
}
// 使用
function VirtualListDemo() {
const items = Array.from({ length: 10000 }, (_, i) => `Item ${i + 1}`);
return (
<div style={{ padding: '20px' }}>
<h2>虚拟滚动列表(10,000项)</h2>
<VirtualList items={items} itemHeight={50} containerHeight={400} />
</div>
);
}第六部分:与第三方库集成
6.1 集成Canvas库
jsx
import { useRef, useLayoutEffect, useState } from 'react';
function CanvasChart({ data }) {
const canvasRef = useRef(null);
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
useLayoutEffect(() => {
if (!canvasRef.current) return;
const canvas = canvasRef.current;
const rect = canvas.getBoundingClientRect();
// 设置canvas实际尺寸
canvas.width = rect.width;
canvas.height = rect.height;
setDimensions({ width: rect.width, height: rect.height });
}, []);
useLayoutEffect(() => {
if (!canvasRef.current || !dimensions.width) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
// 清空画布
ctx.clearRect(0, 0, dimensions.width, dimensions.height);
// 绘制图表
drawChart(ctx, data, dimensions);
}, [data, dimensions]);
return (
<canvas
ref={canvasRef}
style={{
width: '100%',
height: '300px',
border: '1px solid #ddd'
}}
/>
);
}
function drawChart(ctx, data, dimensions) {
const { width, height } = dimensions;
const barWidth = width / data.length;
const maxValue = Math.max(...data);
data.forEach((value, index) => {
const barHeight = (value / maxValue) * height * 0.8;
const x = index * barWidth;
const y = height - barHeight;
ctx.fillStyle = '#007bff';
ctx.fillRect(x + 5, y, barWidth - 10, barHeight);
// 绘制数值
ctx.fillStyle = '#333';
ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(value, x + barWidth / 2, y - 5);
});
}
// 使用
function ChartDemo() {
const [data, setData] = useState([10, 25, 15, 30, 20, 35, 28]);
const randomizeData = () => {
setData(Array.from({ length: 7 }, () => Math.floor(Math.random() * 50) + 10));
};
return (
<div style={{ padding: '20px' }}>
<h2>Canvas图表</h2>
<CanvasChart data={data} />
<button onClick={randomizeData} style={{ marginTop: '10px' }}>
随机数据
</button>
</div>
);
}6.2 集成动画库(GSAP)
jsx
import { useRef, useLayoutEffect } from 'react';
import gsap from 'gsap';
function GSAPAnimation({ children }) {
const elementRef = useRef(null);
useLayoutEffect(() => {
if (!elementRef.current) return;
// 在DOM更新后、绘制前初始化动画
gsap.fromTo(
elementRef.current,
{ opacity: 0, y: 50 },
{ opacity: 1, y: 0, duration: 0.6, ease: 'power2.out' }
);
}, []);
return (
<div ref={elementRef}>
{children}
</div>
);
}
// 复杂的序列动画
function SequenceAnimation({ items }) {
const containerRef = useRef(null);
useLayoutEffect(() => {
if (!containerRef.current) return;
const elements = containerRef.current.children;
gsap.fromTo(
elements,
{ opacity: 0, x: -50 },
{
opacity: 1,
x: 0,
duration: 0.4,
stagger: 0.1,
ease: 'power2.out'
}
);
}, [items]);
return (
<div ref={containerRef}>
{items.map((item, index) => (
<div
key={index}
style={{
padding: '12px',
background: '#f0f0f0',
marginBottom: '8px',
borderRadius: '4px'
}}
>
{item}
</div>
))}
</div>
);
}第七部分:性能优化
7.1 节流DOM测量
jsx
function ThrottledMeasurement() {
const [size, setSize] = useState({ width: 0, height: 0 });
const elementRef = useRef(null);
const measureTimeoutRef = useRef(null);
useLayoutEffect(() => {
const measure = () => {
if (elementRef.current) {
const rect = elementRef.current.getBoundingClientRect();
setSize({ width: rect.width, height: rect.height });
}
};
// 初始测量
measure();
const handleResize = () => {
// 节流:等待100ms后再测量
if (measureTimeoutRef.current) {
clearTimeout(measureTimeoutRef.current);
}
measureTimeoutRef.current = setTimeout(measure, 100);
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
if (measureTimeoutRef.current) {
clearTimeout(measureTimeoutRef.current);
}
};
}, []);
return (
<div>
<div
ref={elementRef}
style={{
padding: '20px',
background: '#f0f0f0',
resize: 'both',
overflow: 'auto',
minWidth: '200px',
minHeight: '100px'
}}
>
调整窗口大小查看测量
</div>
<p>宽度: {size.width.toFixed(0)}px</p>
<p>高度: {size.height.toFixed(0)}px</p>
</div>
);
}7.2 使用ResizeObserver替代
jsx
function ModernResizeDetection() {
const [size, setSize] = useState({ width: 0, height: 0 });
const elementRef = useRef(null);
useLayoutEffect(() => {
if (!elementRef.current) return;
// 使用ResizeObserver更高效
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
setSize({ width, height });
}
});
resizeObserver.observe(elementRef.current);
return () => {
resizeObserver.disconnect();
};
}, []);
return (
<div>
<div
ref={elementRef}
contentEditable
style={{
padding: '20px',
background: '#f0f0f0',
minHeight: '100px',
border: '2px dashed #ccc'
}}
>
编辑这段文本,观察尺寸变化
</div>
<div style={{ marginTop: '10px' }}>
<p>宽度: {size.width.toFixed(2)}px</p>
<p>高度: {size.height.toFixed(2)}px</p>
</div>
</div>
);
}第八部分:TypeScript集成
8.1 类型安全的useLayoutEffect
typescript
import { useLayoutEffect, useRef, useState, RefObject } from 'react';
interface Dimensions {
width: number;
height: number;
}
function useMeasure<T extends HTMLElement>(): [RefObject<T>, Dimensions] {
const ref = useRef<T>(null);
const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });
useLayoutEffect(() => {
if (!ref.current) return;
const measure = () => {
if (ref.current) {
const rect = ref.current.getBoundingClientRect();
setDimensions({
width: rect.width,
height: rect.height
});
}
};
measure();
const resizeObserver = new ResizeObserver(measure);
resizeObserver.observe(ref.current);
return () => {
resizeObserver.disconnect();
};
}, []);
return [ref, dimensions];
}
// 使用
function TypedMeasureDemo() {
const [ref, dimensions] = useMeasure<HTMLDivElement>();
return (
<div>
<div
ref={ref}
style={{
padding: '20px',
background: '#f0f0f0',
resize: 'both',
overflow: 'auto'
}}
>
调整大小
</div>
<p>尺寸: {dimensions.width}x{dimensions.height}</p>
</div>
);
}8.2 类型化的Tooltip Hook
typescript
interface Position {
top: number;
left: number;
}
type TooltipPlacement = 'top' | 'bottom' | 'left' | 'right';
interface UseTooltipOptions {
preferredPlacement?: TooltipPlacement;
offset?: number;
}
function useTooltip(options: UseTooltipOptions = {}) {
const { preferredPlacement = 'top', offset = 10 } = options;
const [isVisible, setIsVisible] = useState(false);
const [position, setPosition] = useState<Position>({ top: 0, left: 0 });
const [actualPlacement, setActualPlacement] = useState<TooltipPlacement>(preferredPlacement);
const triggerRef = useRef<HTMLElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
if (!isVisible || !triggerRef.current || !tooltipRef.current) {
return;
}
const triggerRect = triggerRef.current.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect();
let placement = preferredPlacement;
let pos: Position = { top: 0, left: 0 };
// 计算位置逻辑...
switch (preferredPlacement) {
case 'top':
pos.top = triggerRect.top - tooltipRect.height - offset;
pos.left = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2;
if (pos.top < 0) {
placement = 'bottom';
pos.top = triggerRect.bottom + offset;
}
break;
// ... 其他方向
}
setPosition(pos);
setActualPlacement(placement);
}, [isVisible, preferredPlacement, offset]);
return {
triggerRef,
tooltipRef,
isVisible,
position,
actualPlacement,
show: () => setIsVisible(true),
hide: () => setIsVisible(false),
toggle: () => setIsVisible(!isVisible)
};
}第九部分:高级应用
9.1 自适应表格列宽
jsx
function AdaptiveTable({ columns, data }) {
const [columnWidths, setColumnWidths] = useState([]);
const tableRef = useRef(null);
useLayoutEffect(() => {
if (!tableRef.current) return;
const cells = tableRef.current.querySelectorAll('th');
const widths = Array.from(cells).map(cell => cell.offsetWidth);
setColumnWidths(widths);
}, [columns, data]);
return (
<table ref={tableRef} style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
{columns.map((col, index) => (
<th
key={index}
style={{
width: columnWidths[index] || 'auto',
padding: '12px',
textAlign: 'left',
borderBottom: '2px solid #ddd'
}}
>
{col.header}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, rowIndex) => (
<tr key={rowIndex}>
{columns.map((col, colIndex) => (
<td
key={colIndex}
style={{
padding: '12px',
borderBottom: '1px solid #eee'
}}
>
{row[col.key]}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
// 使用
function TableDemo() {
const columns = [
{ key: 'name', header: '姓名' },
{ key: 'age', header: '年龄' },
{ key: 'email', header: '邮箱' }
];
const data = [
{ name: '张三', age: 28, email: 'zhang@example.com' },
{ name: '李四', age: 32, email: 'li@example.com' },
{ name: '王五', age: 25, email: 'wang@example.com' }
];
return (
<div style={{ padding: '20px' }}>
<h2>自适应表格</h2>
<AdaptiveTable columns={columns} data={data} />
</div>
);
}9.2 粘性头部
jsx
function StickyHeader({ children }) {
const [isSticky, setIsSticky] = useState(false);
const [headerHeight, setHeaderHeight] = useState(0);
const headerRef = useRef(null);
const placeholderRef = useRef(null);
useLayoutEffect(() => {
if (!headerRef.current) return;
const height = headerRef.current.offsetHeight;
setHeaderHeight(height);
}, [children]);
useEffect(() => {
const handleScroll = () => {
if (placeholderRef.current) {
const rect = placeholderRef.current.getBoundingClientRect();
setIsSticky(rect.top <= 0);
}
};
window.addEventListener('scroll', handleScroll);
handleScroll();
return () => window.removeEventListener('scroll', handleScroll);
}, []);
return (
<>
<div ref={placeholderRef} style={{ height: isSticky ? `${headerHeight}px` : '0' }} />
<div
ref={headerRef}
style={{
position: isSticky ? 'fixed' : 'relative',
top: isSticky ? '0' : 'auto',
left: 0,
right: 0,
background: 'white',
boxShadow: isSticky ? '0 2px 4px rgba(0, 0, 0, 0.1)' : 'none',
zIndex: 100,
transition: 'box-shadow 0.3s'
}}
>
{children}
</div>
</>
);
}
// 使用
function StickyHeaderDemo() {
return (
<div>
<div style={{ height: '100vh', background: '#f0f0f0', padding: '20px' }}>
<h1>向下滚动</h1>
</div>
<StickyHeader>
<div style={{ padding: '16px', background: '#007bff', color: 'white' }}>
<h2>粘性头部</h2>
<nav>
<a href="#section1" style={{ color: 'white', marginRight: '16px' }}>Section 1</a>
<a href="#section2" style={{ color: 'white', marginRight: '16px' }}>Section 2</a>
<a href="#section3" style={{ color: 'white' }}>Section 3</a>
</nav>
</div>
</StickyHeader>
<div style={{ height: '200vh', padding: '20px' }}>
<h2>内容区域</h2>
<p>继续滚动,头部会保持固定</p>
</div>
</div>
);
}第十部分:React 19最佳实践
10.1 与Concurrent Features配合
jsx
import { useLayoutEffect, useRef, useState, useTransition } from 'react';
function ConcurrentLayout() {
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
const [isPending, startTransition] = useTransition();
const elementRef = useRef(null);
useLayoutEffect(() => {
if (!elementRef.current) return;
const measure = () => {
if (elementRef.current) {
const rect = elementRef.current.getBoundingClientRect();
// 使用transition标记非紧急更新
startTransition(() => {
setDimensions({
width: rect.width,
height: rect.height
});
});
}
};
measure();
window.addEventListener('resize', measure);
return () => window.removeEventListener('resize', measure);
}, []);
return (
<div>
<div
ref={elementRef}
style={{
padding: '20px',
background: isPending ? '#f0f0f0' : '#e0e0e0',
transition: 'background 0.3s'
}}
>
内容区域
</div>
<p>尺寸: {dimensions.width.toFixed(0)}x{dimensions.height.toFixed(0)}</p>
</div>
);
}10.2 与Server Components配合
jsx
// app/components/ClientMeasure.tsx
'use client';
import { useLayoutEffect, useRef, useState } from 'react';
export function ClientMeasure({ children }) {
const [height, setHeight] = useState(0);
const contentRef = useRef(null);
useLayoutEffect(() => {
if (contentRef.current) {
setHeight(contentRef.current.scrollHeight);
}
}, [children]);
return (
<div>
<div ref={contentRef}>
{children}
</div>
<p className="meta">内容高度: {height}px</p>
</div>
);
}
// app/page.tsx
// Server Component
export default function Page() {
return (
<ClientMeasure>
<h1>服务器渲染的内容</h1>
<p>这些内容在服务器端生成</p>
<p>但测量在客户端进行</p>
</ClientMeasure>
);
}注意事项
1. 何时使用useLayoutEffect
jsx
// ✅ 正确使用场景
// 1. DOM测量
function MeasureCase() {
const ref = useRef(null);
const [size, setSize] = useState(0);
useLayoutEffect(() => {
setSize(ref.current.offsetWidth);
}, []);
return <div ref={ref}>测量宽度</div>;
}
// 2. 避免视觉闪烁
function NoFlickerCase() {
const [position, setPosition] = useState(0);
const ref = useRef(null);
useLayoutEffect(() => {
setPosition(calculatePosition(ref.current));
}, []);
return <div ref={ref} style={{ left: position }}>定位元素</div>;
}
// 3. 同步DOM操作
function SyncDOMCase() {
const ref = useRef(null);
useLayoutEffect(() => {
ref.current.scrollTop = 0; // 需要立即生效
}, []);
return <div ref={ref}>内容</div>;
}
// ❌ 不应该使用useLayoutEffect
// 1. 数据获取(会阻塞渲染)
function BadFetchCase() {
useLayoutEffect(() => {
fetch('/api/data'); // ❌ 使用useEffect
}, []);
return <div>Data</div>;
}
// 2. 订阅(不需要同步)
function BadSubscribeCase() {
useLayoutEffect(() => {
const subscription = subscribe(); // ❌ 使用useEffect
return () => subscription.unsubscribe();
}, []);
return <div>Subscribed</div>;
}
// 3. 日志记录(不需要同步)
function BadLogCase() {
useLayoutEffect(() => {
console.log('Component mounted'); // ❌ 使用useEffect
}, []);
return <div>Component</div>;
}2. 性能影响
jsx
// ⚠️ useLayoutEffect是同步的,会阻塞渲染
function PerformanceWarning() {
const [data, setData] = useState([]);
useLayoutEffect(() => {
// 这段代码会阻塞浏览器绘制
// 如果计算很慢,用户会感到卡顿
const processedData = expensiveCalculation(data);
setData(processedData);
}, [data]);
return <div>{/* ... */}</div>;
}
// ✅ 如果不需要同步,使用useEffect
function BetterPerformance() {
const [data, setData] = useState([]);
useEffect(() => {
// 异步执行,不阻塞渲染
const processedData = expensiveCalculation(data);
setData(processedData);
}, [data]);
return <div>{/* ... */}</div>;
}
function expensiveCalculation(data) {
// 耗时计算
return data;
}3. 服务器端渲染注意
jsx
// useLayoutEffect在服务器端不执行
// 如果依赖useLayoutEffect设置关键状态,可能导致问题
// ❌ 潜在问题
function ServerIssue() {
const [criticalValue, setCriticalValue] = useState(null);
useLayoutEffect(() => {
setCriticalValue(calculateValue());
}, []);
if (!criticalValue) {
return <div>Loading...</div>;
}
return <div>{criticalValue}</div>;
// 服务器端会渲染"Loading..."
// 客户端hydrate后才显示实际值
// 可能导致hydration mismatch
}
// ✅ 解决方案
function ServerSafe() {
const [criticalValue, setCriticalValue] = useState(() => calculateValue());
useLayoutEffect(() => {
// 只在客户端调整
const adjustedValue = adjustForClient(criticalValue);
setCriticalValue(adjustedValue);
}, []);
return <div>{criticalValue}</div>;
}
function calculateValue() {
return 'default value';
}
function adjustForClient(value) {
return value;
}4. 避免无限循环
jsx
// ❌ 错误:导致无限循环
function InfiniteLoop() {
const [count, setCount] = useState(0);
useLayoutEffect(() => {
setCount(count + 1); // 每次渲染都更新,导致无限循环
});
return <div>{count}</div>;
}
// ✅ 正确:提供依赖数组
function CorrectDependency() {
const [count, setCount] = useState(0);
useLayoutEffect(() => {
// 只在mount时执行一次
console.log('Mounted');
}, []);
return <div>{count}</div>;
}
// ✅ 正确:条件更新
function ConditionalUpdate() {
const [count, setCount] = useState(0);
const [size, setSize] = useState(0);
const ref = useRef(null);
useLayoutEffect(() => {
const newSize = ref.current.offsetWidth;
// 只在值确实变化时更新
if (newSize !== size) {
setSize(newSize);
}
}, [count]); // 依赖count,不依赖size
return (
<div ref={ref}>
<p>Count: {count}</p>
<p>Size: {size}</p>
<button onClick={() => setCount(c => c + 1)}>增加</button>
</div>
);
}5. 清理函数
jsx
function ProperCleanup() {
const elementRef = useRef(null);
useLayoutEffect(() => {
const element = elementRef.current;
// 设置样式
element.style.opacity = '1';
// 清理函数
return () => {
element.style.opacity = '0';
};
}, []);
return <div ref={elementRef}>内容</div>;
}
// 监听器清理
function ListenerCleanup() {
const [size, setSize] = useState({ width: 0, height: 0 });
useLayoutEffect(() => {
const handleResize = () => {
setSize({
width: window.innerWidth,
height: window.innerHeight
});
};
handleResize();
window.addEventListener('resize', handleResize);
// 必须清理监听器
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
return <div>窗口: {size.width}x{size.height}</div>;
}常见问题
1. useLayoutEffect会阻塞渲染吗?
会。useLayoutEffect是同步执行的,会阻塞浏览器绘制。
jsx
function BlockingExample() {
useLayoutEffect(() => {
// 这段代码会阻塞浏览器绘制
const start = Date.now();
while (Date.now() - start < 1000) {
// 模拟耗时操作,阻塞1秒
}
console.log('完成');
}, []);
return <div>内容要等1秒后才显示</div>;
}
// 对比:useEffect不会阻塞
function NonBlockingExample() {
useEffect(() => {
const start = Date.now();
while (Date.now() - start < 1000) {
// 耗时操作
}
console.log('完成');
}, []);
return <div>内容立即显示,计算在后台进行</div>;
}2. 什么时候该用useLayoutEffect而不是useEffect?
当你需要在浏览器绘制前同步读取或修改DOM时。
jsx
// 场景1:测量DOM后立即使用
function NeedLayoutEffect() {
const [height, setHeight] = useState(0);
const ref = useRef(null);
useLayoutEffect(() => {
// 必须在绘制前获取高度
setHeight(ref.current.offsetHeight);
}, []);
return (
<div>
<div ref={ref}>内容</div>
<div style={{ height }}>匹配高度的元素</div>
</div>
);
}
// 场景2:避免闪烁
function AvoidFlicker() {
const [position, setPosition] = useState(0);
const ref = useRef(null);
useLayoutEffect(() => {
// 必须在绘制前设置位置
setPosition(calculatePosition(ref.current));
}, []);
return <div ref={ref} style={{ left: position }}>元素</div>;
}3. useLayoutEffect在SSR中的行为?
useLayoutEffect在服务器端不执行,只在客户端执行。
jsx
function SSRBehavior() {
const [clientOnly, setClientOnly] = useState(false);
useLayoutEffect(() => {
// 服务器端不执行
// 只在客户端执行
setClientOnly(true);
console.log('这只在客户端打印');
}, []);
return (
<div>
<p>服务器端: clientOnly = false</p>
<p>客户端: clientOnly = {clientOnly.toString()}</p>
</div>
);
}
// ⚠️ 可能导致hydration警告
function HydrationWarning() {
const [value, setValue] = useState('server');
useLayoutEffect(() => {
setValue('client'); // 改变初始值
}, []);
return <div>{value}</div>;
// 服务器: "server"
// 客户端第一次渲染: "server"
// useLayoutEffect后: "client"
// 可能有hydration警告
}4. 如何调试useLayoutEffect?
jsx
function DebugLayoutEffect() {
const [count, setCount] = useState(0);
useLayoutEffect(() => {
console.log('[useLayoutEffect] 开始');
console.log('[useLayoutEffect] count:', count);
const start = performance.now();
// 你的代码
const end = performance.now();
console.log('[useLayoutEffect] 耗时:', end - start, 'ms');
return () => {
console.log('[useLayoutEffect] 清理');
};
}, [count]);
console.log('[Render] count:', count);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>增加</button>
</div>
);
}5. useLayoutEffect vs useEffect性能对比
jsx
function PerformanceComparison() {
const [useLayout, setUseLayout] = useState(false);
const [count, setCount] = useState(0);
const ref = useRef(null);
// 根据选择使用不同的hook
const effectHook = useLayout ? useLayoutEffect : useEffect;
effectHook(() => {
const start = performance.now();
// 模拟DOM操作
if (ref.current) {
ref.current.style.background = count % 2 === 0 ? '#f0f0f0' : '#e0e0e0';
}
const end = performance.now();
console.log(`${useLayout ? 'useLayoutEffect' : 'useEffect'} 耗时:`, end - start);
}, [count]);
return (
<div style={{ padding: '20px' }}>
<h2>性能对比</h2>
<div>
<label>
<input
type="checkbox"
checked={useLayout}
onChange={(e) => setUseLayout(e.target.checked)}
/>
使用 useLayoutEffect(否则使用useEffect)
</label>
</div>
<div ref={ref} style={{ padding: '20px', marginTop: '10px' }}>
Count: {count}
</div>
<button onClick={() => setCount(c => c + 1)}>增加</button>
<div style={{ marginTop: '20px', fontSize: '14px', color: '#666' }}>
打开控制台查看性能数据
</div>
</div>
);
}6. 在严格模式下的行为
jsx
// React 18严格模式会double-invoke effects
function StrictModeWarning() {
const [count, setCount] = useState(0);
useLayoutEffect(() => {
console.log('useLayoutEffect执行');
// 在严格模式下,这会执行两次:
// 1. 第一次执行
// 2. 清理
// 3. 第二次执行
return () => {
console.log('useLayoutEffect清理');
};
}, []);
return <div>Count: {count}</div>;
}
// 确保代码能处理多次执行
function StrictModeSafe() {
const ref = useRef(null);
const initialized = useRef(false);
useLayoutEffect(() => {
if (initialized.current) return;
// 只初始化一次
initialized.current = true;
setupDOM(ref.current);
return () => {
initialized.current = false;
};
}, []);
return <div ref={ref}>Content</div>;
}
function setupDOM(element) {
// DOM初始化逻辑
}总结
useLayoutEffect核心要点
执行时机
- DOM更新后、浏览器绘制前
- 同步执行,阻塞绘制
- 在useEffect之前执行
主要用途
- DOM测量(getBoundingClientRect等)
- 避免视觉闪烁
- 布局计算
- 同步DOM操作
- 动画初始化
与useEffect对比
- useLayoutEffect: 同步、阻塞、绘制前
- useEffect: 异步、不阻塞、绘制后
- 默认使用useEffect
- 只在必要时使用useLayoutEffect
性能考虑
- 会阻塞渲染
- 避免耗时操作
- 使用节流优化
- 考虑使用ResizeObserver
最佳实践
- 只在需要同步DOM操作时使用
- 避免在其中进行数据获取
- 提供正确的依赖数组
- 及时清理副作用
- 注意SSR兼容性
常见场景
- Tooltip位置计算
- 模态框居中
- 文本截断检测
- 响应式布局
- 虚拟滚动
- 拖拽位置同步
注意事项
- 不在服务器端执行
- 严格模式下双重调用
- 避免无限循环
- 性能影响
- Hydration一致性
通过本章学习,你已经全面掌握了useLayoutEffect的使用。记住核心原则:默认使用useEffect,只在需要同步DOM操作、避免闪烁时才使用useLayoutEffect。合理使用能让你的应用更流畅、用户体验更好!