在 React 函数组件中,UI 通常由 state 和 props 驱动,开发者很少直接调用 document.getElementById 或修改节点样式。但日历组件恰恰是例外:它需要根据容器宽度计算网格高度、在方向键按下时移动焦点样式、在窗口缩放后重新布局。这些能力无法完全用 useState 描述,必须通过 useRef 拿到真实 DOM 节点,并借助 useEffect、useLayoutEffect 管理副作用。本文以一个不引入第三方日期库的月历组件为例,梳理其中的关键实现。

一、从 ref 说起:为什么函数组件也需要命令式 DOM 入口
很多交互效果都依赖真实的布局信息,比如容器宽度、单元格高度、滚动位置。React 的声明式渲染解决的是数据到界面的映射,但测量工作必须在 DOM 更新完成后进行。useRef 返回一个稳定的可变对象,修改 current 属性不会触发组件重新渲染,这使它非常适合保存 DOM 节点引用。在日历组件中,我们通常把 ref 挂在最外层的容器元素上,后续所有测量都从这个节点开始。
下面的代码展示了最基本的引用方式。注意 containerRef 在首次渲染时是 null,只有在提交阶段 React 才会把真实节点写进 current。因此读取节点一定要放在 effect 中,而不是直接在函数体里读取。
import { useRef, useEffect } from 'react';
function CalendarShell() {
const containerRef = useRef(null);
useEffect(() => {
const node = containerRef.current;
if (!node) return;
console.log('容器宽度:', node.getBoundingClientRect().width);
}, []);
return <div ref={containerRef} className="calendar-shell">月历占位</div>;
}
这里 <div> 元素通过 ref 属性与 containerRef 关联。当组件挂载后,useEffect 执行,我们可以安全读取 getBoundingClientRect()。如果换成在函数体里读取,containerRef.current 仍然是 null。这个细节在日历首次布局时非常重要,因为首帧宽高如果拿不到,就会导致格子高度计算错误。
另一个容易混淆的地方是:直接修改 containerRef.current.style.height 虽然会改变界面,但 React 并不知道 DOM 已经被修改。如果你后续通过 setState 触发渲染,React 会用虚拟 DOM 结果覆盖掉之前的手动修改。因此,凡是要长期保留的布局值,应当转为 state 或由 state 计算;只有一次性、不影响后续渲染的命令式操作,才适合直接写 DOM。
二、useEffect 与 useLayoutEffect:把尺寸同步放在正确的时机
日历组件的容器宽度通常不是固定像素,而是来自父级布局。要计算网格,需要测量容器实际宽度,再除以 7 得到每个格子的宽度。测量动作必须在 DOM 提交之后进行,但 useEffect 是在浏览器绘制之后异步执行,如果在里面修改状态,用户会先看到一帧默认尺寸,再跳到计算后的尺寸,表现为明显的闪烁。useLayoutEffect 则会在 DOM 变更后、浏览器绘制前同步执行,适合首屏布局测量。
所以一种稳妥的做法是:初始尺寸测量放在 useLayoutEffect 中,窗口缩放或容器变化放在 useEffect 中通过 ResizeObserver 监听。ResizeObserver 会在内容矩形尺寸变化时回调,比监听 window.resize 更精确,因为容器尺寸变化不一定来自窗口缩放。
import { useRef, useEffect, useLayoutEffect, useState } from 'react';
function CalendarGrid() {
const containerRef = useRef(null);
const [cellHeight, setCellHeight] = useState(96);
useLayoutEffect(() => {
const el = containerRef.current;
if (!el) return;
const width = el.getBoundingClientRect().width;
setCellHeight(width < 600 ? 64 : 96);
}, []);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const contentRect = entries[0].contentRect;
setCellHeight(Math.max(56, Math.floor(contentRect.width / 7)));
});
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div ref={containerRef} className="calendar-grid" style={{ height: cellHeight * 6 }}>
<div className="cell" style={{ height: cellHeight }}>1</div>
<div className="cell" style={{ height: cellHeight }}>2</div>
<div className="cell" style={{ height: cellHeight }}>3</div>
</div>
);
}
上面的代码把首屏测量和后续观察拆成了两个 effect。useLayoutEffect 中读取到的宽度是布局后的真实值,设置 cellHeight 后,React 会同步重新渲染,绘制前就得到正确的格子高度。后续 ResizeObserver 触发时,useEffect 的回调可以异步执行,即便稍晚一帧也不会造成明显闪烁。两个 effect 的依赖数组都是空数组,这意味着只在挂载时执行,但每个 effect 返回的清理函数会在卸载时调用,ResizeObserver 的 disconnect 必须放在清理函数里,否则组件卸载后回调依然可能触发。
还需要留意,不要在 useEffect 中读取 offsetWidth 后立即同步调用 setState,又不在依赖数组中声明依赖,这样容易造成 ESLint 警告和重复测量。正确方式是先测量再决定是否需要更新,并且把测量对象最小化。日历网格的行高通常由列宽决定,因此只需保存一个 cellHeight,而不是把整个布局对象都塞进 state。
三、全局键盘导航与副作用清理
日历组件如果不支持键盘操作,可用性会大打折扣。方向键需要在 42 个日期格子之间移动焦点,但焦点样式不一定使用原生 focus,更常见的做法是把当前坐标保存到 cursor 状态,再根据坐标高亮对应格子。问题在于键盘事件应当绑定在 window 上,而不是容器 <div> 上,因为容器可能没有获得焦点。这样全局监听就必须在卸载时移除,否则每次挂载都会累积一个新监听。
下面的代码在 useEffect 中注册 keydown 事件,并在清理函数中移除。事件回调里使用函数式 setCursor,这样即使回调被缓存,也能拿到最新状态,避免闭包陷阱。
import { useEffect, useState } from 'react';
function useKeyboardNavigation(rows, columns) {
const [cursor, setCursor] = useState({ row: 0, col: 0 });
useEffect(() => {
const onKeyDown = (event) => {
if (event.key === 'ArrowRight') {
setCursor((prev) => ({
...prev,
col: Math.min(columns - 1, prev.col + 1)
}));
}
if (event.key === 'ArrowLeft') {
setCursor((prev) => ({
...prev,
col: Math.max(0, prev.col - 1)
}));
}
if (event.key === 'ArrowDown') {
setCursor((prev) => ({
...prev,
row: Math.min(rows - 1, prev.row + 1)
}));
}
if (event.key === 'ArrowUp') {
setCursor((prev) => ({
...prev,
row: Math.max(0, prev.row - 1)
}));
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [rows, columns]);
return cursor;
}
这里 columns 和 rows 作为依赖传入,当它们变化时,旧监听被移除,新监听用最新边界创建。由于使用了函数式更新,回调内不会因为读取旧闭包中的 cursor 而出错。事件监听本身也属于副作用,必须返回清理函数。很多人会忘记移除 window 监听,结果在路由切换后方向键仍然影响已经卸载的日历,甚至产生内存泄漏。
如果同时使用 setTimeout、setInterval 或动画帧,也应该在同一个 effect 的清理函数中处理。例如双击日期进入编辑模式时可能会启动一个定时器收起提示,卸载时需要 clearTimeout。把这些清理逻辑和事件移除放在一起,组件生命周期会更可靠。
四、把命令式逻辑收敛到自定义 Hook
当 ref、useLayoutEffect、useEffect、键盘监听和 ResizeObserver 都写在一个组件里时,主体会变得臃肿。更好的做法是封装成 useCalendarGrid Hook,对外只暴露 containerRef、cellHeight、cursor 等状态,组件本身只负责渲染日期数组和样式。
自定义 Hook 与普通函数最大的区别是它可以调用其他 Hook,从而把有状态逻辑从组件中抽离。下面的示例把前面三节的内容整合成一个 Hook,并返回渲染所需的数据。这样月历组件主体就能保持简洁,业务逻辑与交互逻辑也更容易独立测试。
import { useRef, useEffect, useLayoutEffect, useState } from 'react';
function useCalendarGrid(totalRows, totalColumns) {
const containerRef = useRef(null);
const [cellHeight, setCellHeight] = useState(96);
const [cursor, setCursor] = useState({ row: 0, col: 0 });
useLayoutEffect(() => {
const el = containerRef.current;
if (!el) return;
const width = el.getBoundingClientRect().width;
setCellHeight(width < 600 ? 64 : 96);
}, []);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const contentRect = entries[0].contentRect;
setCellHeight(Math.max(56, Math.floor(contentRect.width / totalColumns)));
});
observer.observe(el);
return () => observer.disconnect();
}, [totalColumns]);
useEffect(() => {
const onKeyDown = (event) => {
if (event.key === 'ArrowRight') {
setCursor((prev) => ({ ...prev, col: Math.min(totalColumns - 1, prev.col + 1) }));
}
if (event.key === 'ArrowLeft') {
setCursor((prev) => ({ ...prev, col: Math.max(0, prev.col - 1) }));
}
if (event.key === 'ArrowDown') {
setCursor((prev) => ({ ...prev, row: Math.min(totalRows - 1, prev.row + 1) }));
}
if (event.key === 'ArrowUp') {
setCursor((prev) => ({ ...prev, row: Math.max(0, prev.row - 1) }));
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [totalRows, totalColumns]);
return { containerRef, cellHeight, cursor };
}
function Calendar() {
const { containerRef, cellHeight, cursor } = useCalendarGrid(6, 7);
const days = Array.from({ length: 42 }, (_, i) => i + 1);
return (
<div ref={containerRef} className="calendar">
{days.map((day, index) => {
const row = Math.floor(index / 7);
const col = index % 7;
const active = row === cursor.row && col === cursor.col;
return (
<div
key={day}
className={active ? 'cell cell--active' : 'cell'}
style={{ height: cellHeight }}
>
{day}
</div>
);
})}
</div>
);
}
这段代码中 days 数组直接生成 42 个占位日期,实际项目中可以替换成真实月份数据。组件里不再出现任何 useEffect,只保留一个 useCalendarGrid 调用,意图非常清晰。containerRef 必须挂到同一个容器上,因为 Hook 内部的测量和观察都围绕这个节点展开。如果组件被 React.StrictMode 包裹,开发环境下 effect 会执行两次,但由于每个 effect 都正确返回了清理函数,监听不会重复残留。
最后要强调:直接操作 DOM 并不是反模式,关键在于把操作纳入 React 的生命周期管理。凡是涉及测量、焦点、动画、第三方非 React 库的集成,都可以用 useRef 获取节点,用 useEffect 或 useLayoutEffect 建立和销毁副作用。日历组件只是这些问题的集中体现,掌握这类模式后,类似的可拖拽面板、虚拟滚动列表、富文本编辑器都能用同样的方式构建得更加可靠。

