焦点是交互真正发生的地方------正在接收键盘输入的输入框、键盘用户刚 Tab 到的按钮。但 React 没有为它提供任何 state。document.activeElement 知道答案却从不通知你它变了,autoFocus 属性只在挂载时触发一次且无法重新触发,而要把焦点用于渲染 ------编辑时显示提示、浮动标签、离开后再校验------就得给每个需要的字段手写 focus/blur 监听器。
@reactuses/core 的 useFocus 把这一切压缩成一行:一个组件直接渲染的实时 isFocused 布尔值,外加一个随时能按你的逻辑聚焦或失焦元素的 setter。
快速上手
bash
npm install @reactuses/core
tsx
import { useRef } from "react";
import { useFocus } from "@reactuses/core";
function SearchField() {
const ref = useRef<HTMLInputElement>(null);
const [isFocused, setFocused] = useFocus(ref);
return (
<div className={isFocused ? "field field--active" : "field"}>
<input ref={ref} placeholder="搜索 hooks..." />
{isFocused && <kbd className="hint">esc 清空</kbd>}
<button onClick={() => setFocused(true)}>跳到搜索</button>
</div>
);
}
集成到此为止。hook 订阅元素的 focus 和 blur 事件并把它们镜像成 state;setFocused(true) 调用 element.focus(),setFocused(false) 调用 element.blur()。一个元组,双向打通------既能观察焦点,也能指挥焦点。
为什么不用 autoFocus、activeElement 或纯 CSS?
内置的每个选项都只覆盖问题的一角:
- CSS
:focus/:focus-within在响应是纯样式 时就是正确工具------边框变色、外发光。用它,零 JavaScript、零重渲染。hook 的用武之地在于焦点要驱动逻辑或 JSX :渲染提示面板、决定何时校验、用户打字时暂停轮播。 document.activeElement是快照,不是订阅。在 render 里读它,下一次 Tab 它就过期了;焦点移动时没有任何东西会让你的组件重渲染。autoFocus只在挂载时触发一次,这就是它的全部 API。它不能按需聚焦("按/搜索")、不能失焦,也不告诉你当前状态。- 在各个 handler 里散落
ref.current.focus()能用------直到你还需要知道元素是否聚焦,于是监听器还是得自己维护。
手写版------以及坑在哪
手写版看起来人畜无害:
tsx
// ⚠️ 手写版------demo 里能跑,应用里漏 bug
function SearchField() {
const ref = useRef<HTMLInputElement>(null);
const [isFocused, setIsFocused] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const onFocus = () => setIsFocused(true);
const onBlur = () => setIsFocused(false);
el.addEventListener("focus", onFocus);
el.addEventListener("blur", onBlur);
return () => {
el.removeEventListener("focus", onFocus);
el.removeEventListener("blur", onBlur);
};
}, []);
// ...
}
十五行里藏着三个问题:
- 晚挂载的目标永远接不上线。
if (!el) return只跑一次。如果输入框是条件渲染的------在弹窗里、在某个 tab 后面、在 loading 态之后------effect 早已 return,监听器永远挂不上。空依赖数组表达不了"元素出现时重新运行"。 - 漏掉初始状态。 如果在你的 effect 运行之前就有东西聚焦了这个元素(
autoFocus属性、路由的焦点恢复),你的 state 是false,而元素正带着焦点坐在那。除了监听器,你还得在挂载时补一次document.activeElement检查。 - 每个字段都要复制一遍。 把这十五行乘以表单里的每个输入框,整个文件大半是管道代码。
useFocus 把三个坑全部吸收:target 可以是惰性 getter(() => document.querySelector(".modal input")),随 DOM 变化重新解析;挂载时的状态帮你对齐;每个字段就一行。
useFocus API
tsx
const [isFocused, setFocused] = useFocus(target, initialValue?);
target 很灵活------手头有什么传什么:
tsx
useFocus(ref); // ref 对象
useFocus(document.getElementById("search")); // 元素本身
useFocus(() => document.querySelector(".otp input")); // 惰性 getter
SVG 元素也支持------target 类型是 HTMLElement | SVGElement,图表里一个可聚焦的 <circle tabindex="0"> 同样适用。
initialValue (默认 false)是声明式的自动聚焦:传 true,hook 会在挂载时聚焦元素。和 autoFocus 属性不同,它走的是与 setFocused 相同的代码路径,支持 getter 目标,之后你手里还握着实时状态。
setFocused 是自带兜底的命令式控制:true → element.focus(),false → element.blur()。目标还不存在时,调用是安全的 no-op 而不是崩溃。
真实场景
浮动标签------标签自己知道何时上浮
Material 风格输入框:标签待在字段里,字段活跃或有内容时上浮。
tsx
function FloatingLabelInput({ label }: { label: string }) {
const ref = useRef<HTMLInputElement>(null);
const [isFocused] = useFocus(ref);
const [value, setValue] = useState("");
const floated = isFocused || value.length > 0;
return (
<label className="float-field">
<span className={floated ? "float-label up" : "float-label"}>
{label}
</span>
<input ref={ref} value={value} onChange={e => setValue(e.target.value)} />
</label>
);
}
纯 CSS 用 :focus-within + :placeholder-shown 能逼近,但上浮条件一旦涉及应用状态------受控 value、校验标记------你就需要作为 state 的焦点,这就是它。
失焦时校验,而不是每次击键
对一个刚敲了三个字符的用户吼"邮箱格式错误"是经典的表单 UX 翻车。解法是 touched 语义------用户离开字段后才校验:
tsx
function EmailField() {
const ref = useRef<HTMLInputElement>(null);
const [isFocused] = useFocus(ref);
const [value, setValue] = useState("");
const [touched, setTouched] = useState(false);
useEffect(() => {
if (isFocused) return; // 还在编辑------保持安静
if (value) setTouched(true); // 带着内容离开了字段 → 开始评判
}, [isFocused, value]);
const error = touched && !isFocused && !value.includes("@");
return (
<div>
<input ref={ref} value={value} onChange={e => setValue(e.target.value)} />
{error && <p className="error">这看起来不像一个邮箱地址。</p>}
</div>
);
}
isFocused 的翻转就是 touched 信号------不用层层透传 onBlur,而且用户回来修正的那一刻错误自动消失。
按 / 聚焦搜索
每个文档站都有这个功能,setFocused 加 useEventListener 就是完整实现:
tsx
function DocSearch() {
const ref = useRef<HTMLInputElement>(null);
const [isFocused, setFocused] = useFocus(ref);
useEventListener("keydown", (e) => {
if (e.key === "/" && !isFocused) {
e.preventDefault(); // 别把斜杠打进去
setFocused(true);
}
if (e.key === "Escape") setFocused(false);
});
return <input ref={ref} placeholder="按 / 搜索" />;
}
注意元组的两半各司其职:isFocused 防止劫持用户正往输入框里 敲的 /,setFocused 负责跳转。
条件渲染也不怕的自动聚焦
聚焦弹窗表单的第一个字段,而这个输入框在 useDisclosure 点头之前根本不存在:
tsx
function RenameDialog({ open }: { open: boolean }) {
const ref = useRef<HTMLInputElement>(null);
useFocus(ref, true); // 挂载即聚焦------也就是弹窗打开的那一刻
if (!open) return null;
return (
<dialog open>
<input ref={ref} defaultValue="untitled.md" />
</dialog>
);
}
因为组件(连同 hook)在弹窗打开时才挂载,initialValue: true 恰好在正确的时机触发------不需要 setTimeout(..., 0) 之类的咒语。
useFocus 与它的兄弟们
@reactuses/core 提供三个焦点 hook,对应三个缩放级别------按你要问的问题选:
| Hook | 回答的问题 | 什么时候用 |
|---|---|---|
useFocus |
"这个元素聚焦了吗?"+ 控制 | 字段级 UI:标签、提示、校验时机、快捷键 |
useActiveElement |
"全文档范围内哪个元素持有焦点?" | 表单级逻辑、焦点调试、roving-focus 组件 |
useWindowFocus |
"标签页/窗口本身有焦点吗?" | 用户切走时暂停轮询或动画 |
CSS :focus / :focus-visible |
仅样式 | 任何纯 CSS 能解决的响应------永远先试它 |
经验法则:单个元素 → useFocus;整个文档 → useActiveElement;浏览器窗口本身 → useWindowFocus。
生产环境注意事项
- SSR 已处理。 服务端没有 DOM;hook 渲染你给的
initialValue,水合后再挂监听器------你的代码里不需要typeof window守卫。 element.focus()会滚动页面。 浏览器会把新聚焦的元素滚进视口。页面加载时自动聚焦一个首屏以下的元素会猛拽视口------initialValue: true留给用户视线所在的元素(弹窗、行内编辑器)。- 别偷焦点。 移动焦点是无障碍动作,不是视觉动作:屏幕阅读器会朗读新聚焦的元素,键盘用户会丢失位置。只在用户意图下聚焦(快捷键、打开弹窗),永远不要在定时器或数据刷新里聚焦。
- 失焦会把焦点丢给
<body>。setFocused(false)不会把焦点还回原处------关闭弹窗后,请显式把焦点交还给触发按钮。 - 样式交给
:focus-visible,逻辑交给isFocused。 键盘专属的焦点环是 CSS 已解决的问题;把环留在 CSS 里,把 hook 的 state 花在逻辑上。相关的关注点也按同样方式组合------点击字段外部是useClickOutside的事,不该用 blur hack。
要点回顾
- React 没有焦点 state,原生原语也拼不出一个:
document.activeElement没有响应性,autoFocus只触发一次,手写监听器会漏掉晚挂载的元素和挂载前已聚焦的情况。useFocus就是那个缺失的[isFocused, setFocused]元组。 - setter 是双向控制------
true聚焦,false失焦,元素还不存在时安全跳过;initialValue: true是声明式自动聚焦,精确落在挂载那一刻。 - 杀手级场景都是时机场景:编辑时浮动标签、离开后才校验、按
/跳搜索、弹窗第一个字段一出现就聚焦。 - 纯样式归 CSS 的
:focus和:focus-visible------把 hook 花在逻辑和 JSX 上。选对缩放级别:元素 →useFocus,文档 →useActiveElement,窗口 →useWindowFocus。 - 焦点是无障碍界面:跟随用户意图移动它,永远不偷它,用完把它还回原处。
useFocus 和其他 110+ 个 SSR 安全、TypeScript 优先的 hooks 都在 @reactuses/core------一次安装,可摇树优化,没有需要看护的依赖。
bash
npm install @reactuses/core