一个很长的表单。用户点了提交,校验在往下三屏的某个字段挂了,错误提示渲染在了他根本看不见的地方。修起来只要调一个浏览器 API------但放在哪里调 、传什么参数,能耗掉你一个下午。
先给结论,大部分人搜过来就是要这个:
tsx
import { useRef } from "react";
function Article() {
const sectionRef = useRef<HTMLDivElement>(null);
return (
<>
<button onClick={() => sectionRef.current?.scrollIntoView({ behavior: "smooth" })}>
跳到详情
</button>
{/* ... 一大堆内容 ... */}
<div ref={sectionRef}>详情</div>
</>
);
}
整个模式就这些:给元素挂个 ref,在事件处理里调 .scrollIntoView(),加 ?. 是因为 React 提交之前 sectionRef.current 一直是 null。浏览器原生支持,零成本,对这种静态锚点来说这就是正确答案------别上库。
这篇讲的是剩下的部分:那几个参数到底做什么、固定头部遮挡问题(以及为什么 CSS 的解法比 JavaScript 的好)、怎么滚动到刚刚 渲染出来的东西,还有原生调用真正做不到的四件事------到那一步,@reactuses/core 的 useScrollIntoView 才配上场。
你手上真正有的参数
Element.scrollIntoView() 接一个可选的配置对象,就三个键:
| 参数 | 取值 | 默认值 | 作用 |
|---|---|---|---|
block |
start · center · end · nearest |
start |
块轴方向的对齐------常规书写模式下就是垂直方向 |
inline |
start · center · end · nearest |
nearest |
行内轴方向的对齐------水平方向 |
behavior |
auto · instant · smooth |
auto |
auto 跟随滚动容器的 CSS scroll-behavior |
所以值得背下来的就三种调用:
tsx
el.scrollIntoView(); // 顶到顶部
el.scrollIntoView({ behavior: "smooth", block: "center" }); // 平滑滑到中间
el.scrollIntoView({ block: "nearest" }); // 只在看不见时才移动
block: "nearest" 是被低估的那个。它只滚动最小必要距离把元素带进视口,元素已经可见时干脆什么都不做------这正是列表框键盘导航要的效果:每按一次方向键就重新居中,会让列表感觉在跟你较劲。
还有个遗留的布尔写法:scrollIntoView(true) 等于 block: "start",scrollIntoView(false) 等于 block: "end"。到处都还能用,但对象写法能自解释。
有个点常让人意外:scrollIntoView 会滚动所有可滚动的祖先,不只是最近的那个。如果你的元素在一个可滚动面板里、面板又在可滚动页面里,两个都会动,最终让元素露出来。这基本上就是你想要的。
固定头部:用 CSS,别用魔法数字
最高频的追问:滚到某个标题,结果 64px 的固定头部正好压在上面。
第一反应是自己算:
tsx
// 别这么写
const top = el.getBoundingClientRect().top + window.scrollY - 64;
window.scrollTo({ top, behavior: "smooth" });
现在这个 64 归你养了。移动端头部更矮时它是错的,头部上方冒出一条促销 banner 时它是错的,元素在滚动容器而不是页面里时它是错的------顺带你还放弃了 scrollIntoView 处理祖先容器的能力。
平台就有一个专门干这事的属性:
css
.section {
scroll-margin-top: 5rem; /* 或者 var(--header-height) */
}
scroll-margin-top 告诉浏览器:仅在滚动定位时 把这个元素当作多了这么多外边距。然后一句朴素的 el.scrollIntoView({ behavior: "smooth" }) 就会提前 5rem 停住,布局完全不受影响,而且这个值就写在它依赖的头部高度旁边。它还顺手修好了 :target 锚点和浏览器的页内查找定位------这两个 JavaScript 版本永远做不到。
优先用 scroll-margin-top。每次都是。
滚动到刚渲染出来的元素
问题的另一半是时机。你往列表里加一项,想滚过去;展开一个折叠面板,想露出来;设置了一个错误,想跳过去。天真的写法不管用:
tsx
// 有 bug:新行还不在 DOM 里
function addRow() {
setRows(r => [...r, newRow]);
lastRowRef.current?.scrollIntoView(); // 还是**旧的**最后一行,或者是 null
}
setRows 只是排了一次渲染。React 会在之后提交------而在 React 18+ 的并发渲染下,这个"之后"确确实实不在这一个 tick 里。那行代码执行的瞬间,DOM 还是旧的 DOM。
默认解法是用 effect。 在加进那一行的提交之后再滚:
tsx
useEffect(() => {
lastRowRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
}, [rows.length]);
如果你要的是瞬间 滚动、并且希望它在浏览器绘制之前落位,就换成 useLayoutEffect------否则用户会看到一帧停在旧位置,观感上就是闪一下。平滑滚动则无所谓,动画反正都要开始。
"刚创建出来的元素"用回调 ref 更干净。 不用 effect、不用依赖数组、也不用维护一个 ref------React 挂上节点的那一刻回调就触发:
tsx
const scrollOnMount = useCallback((node: HTMLElement | null) => {
node?.scrollIntoView({ behavior: "smooth", block: "nearest" });
}, []);
// ...
{rows.map((row, i) => (
<Row key={row.id} ref={i === rows.length - 1 ? scrollOnMount : undefined} />
))}
flushSync 是逃生舱,不是默认选项。 如果你确实必须在改状态的同一个事件处理里滚动,可以强制同步提交:
tsx
import { flushSync } from "react-dom";
flushSync(() => setExpanded(true));
detailsRef.current?.scrollIntoView({ behavior: "smooth" });
能用,代价是放弃 React 替你做的批处理和并发调度。在处理函数里偶尔用一次没问题;一个文件里出现三次就是味道不对了。
原生调用的天花板
对锚点、"滚到错误处"、列表键盘导航来说,上面这些已经够了,你可以不用往下读。原生真正做不到的有四件事:
1. 控制不了时长和曲线。 behavior: "smooth" 具体多快由浏览器说了算------Chrome 和 Firefox 不一样,而且完全没有旋钮。如果这个滚动是一段编排好的转场的一部分、必须和 400ms 的淡入对齐,那你办不到。
2. 没有可靠的"滚完了"回调。 scrollend 事件就是为这个设计的,Chrome/Edge 114、Firefox 109 落地,Safari 更晚一些------依赖它之前先查一下支持度,而且它并不告诉你结束的是哪一次 程序化滚动。大家退而求其次的那些做法(setTimeout 猜一个、轮询 scrollY 直到不变),脆得跟听起来一样。
3. 取消不了。 开一段长距离平滑滚动,用户中途抓住滚轮,浏览器照样把他拖到目的地。长页面上这是最招人烦的滚动 bug,没有之一,而且没有 API 能停下它。
4. 它不管 prefers-reduced-motion。 对于要求减少动效的用户,浏览器并不会统一把 behavior: "smooth" 降级------这得你自己来:
tsx
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
el.scrollIntoView({ behavior: reduce ? "auto" : "smooth" });
写一次很容易,在另外十一处滚动的地方忘掉也很容易。
useScrollIntoView
useScrollIntoView 自己在 requestAnimationFrame 上跑动画,这四件事就都换回来了:
bash
npm install @reactuses/core
tsx
import { useRef } from "react";
import { useScrollIntoView } from "@reactuses/core";
function Article() {
const targetRef = useRef<HTMLParagraphElement>(null);
const { scrollIntoView, cancel } = useScrollIntoView(targetRef, {
duration: 600,
offset: 80,
onScrollFinish: () => targetRef.current?.focus(),
});
return (
<>
<button onClick={() => scrollIntoView({ alignment: "center" })}>跳到详情</button>
<div style={{ height: "150vh" }} />
<p ref={targetRef} tabIndex={-1}>详情</p>
</>
);
}
useScrollIntoView(target, options?, scrollContainer?) 返回 { scrollIntoView, cancel }。它是 SSR 安全的------你不调用,它就不碰 DOM------而且 target 可以是 ref、元素,或者一个 getter 函数,你手上有什么都能用。
配置项全是可选的:
| 参数 | 默认值 | 说明 |
|---|---|---|
duration |
1250 |
毫秒。0 表示瞬间跳过去。 |
easing |
easeInOutQuad |
任意 0...1 上的 (t: number) => number。 |
axis |
"y" |
横向滚动容器用 "x"。一个 hook 一个轴。 |
offset |
0 |
离边缘的额外距离------就是固定头部的余量。 |
cancelable |
true |
滚轮或触摸会中止动画。 |
isList |
false |
目标已在视野内就不滚。 |
onScrollFinish |
--- | 动画停下时触发。 |
对齐方式放在调用上而不是配置里,因为它通常每次都不同:scrollIntoView({ alignment: "start" | "center" | "end" })。
cancelable 是你真能感觉到的那个
cancelable: true(默认)时,hook 会监听 wheel 和 touchmove,一有动静就把动画停在当下。用户飞到一半伸手去够滚动条,页面就......让他滚。对比一下 behavior: "smooth",它能跟用户较劲整整一秒。
你也可以自己停------比如触发滚动的那个弹窗被关掉了:
tsx
const { scrollIntoView, cancel } = useScrollIntoView(targetRef);
useEffect(() => cancel, [cancel]); // 卸载时也会自动取消
减少动效已经处理好了
hook 内部通过 useReducedMotion 读 prefers-reduced-motion。当用户要求减少动效时,缓动直接坍缩到终值,滚动变成瞬间跳转------同样的目的地、同样会触发 onScrollFinish,只是没有动画。这个分支不用你写。
在容器里滚,以及横着滚
想动某个具体元素的滚动位置而不是整页时,把滚动容器作为第三个参数传进去:
tsx
const listRef = useRef<HTMLDivElement>(null);
const itemRef = useRef<HTMLLIElement>(null);
const { scrollIntoView } = useScrollIntoView(itemRef, { isList: true }, listRef);
不传第三个参数时,hook 会从目标往上找,挑第一个计算样式里 overflow-x/overflow-y 为 auto 或 scroll 的祖先,找不到就回退到整页。这个自动探测大多数时候方便且正确;你确定容器是谁的时候就显式传。
轮播就换个轴:
tsx
const { scrollIntoView } = useScrollIntoView(slideRef, { axis: "x", duration: 400 }, trackRef);
scrollIntoView({ alignment: "center" });
滚动到第一个校验失败的字段
开头那个场景,每块拼图都放对位置:
tsx
function CheckoutForm() {
const [errors, setErrors] = useState<Record<string, string>>({});
const firstErrorRef = useRef<HTMLDivElement>(null);
const { scrollIntoView } = useScrollIntoView(firstErrorRef, {
offset: 96, // 让开固定头部
duration: 500,
onScrollFinish: () => firstErrorRef.current?.querySelector("input")?.focus(),
});
function onSubmit(e: React.FormEvent) {
e.preventDefault();
const next = validate(values);
setErrors(next);
if (Object.keys(next).length > 0) scrollIntoView({ alignment: "start" });
}
const firstErrorField = Object.keys(errors)[0];
return (
<form onSubmit={onSubmit}>
{FIELDS.map(f => (
<Field key={f.name} ref={f.name === firstErrorField ? firstErrorRef : undefined} {...f} />
))}
</form>
);
}
因为 hook 是在你调用 scrollIntoView 的那一刻才去解析目标、而不是在渲染时解析,所以哪怕 firstErrorRef 是被 setErrors 触发的那次渲染才挂上的,在同一个处理函数里调用它照样有效。不需要 flushSync,也不需要 effect。把焦点转移放在 onScrollFinish 里而不是立刻执行,能让读屏用户和视力正常的用户同时抵达。
值得知道的坑
offset对alignment: "center"不生效。 它是从最近的边缘 量起的余量,所以只影响"start"和"end"。想把某个东西居中又要避开固定头部,要么用"start"配 offset,要么接受居中。这一条如果你想当然,它会悄无声息地什么都不做。- 别和
scroll-behavior: smooth一起用。 hook 是靠每帧赋值scrollTop/scrollLeft来做动画的。如果 CSS 也声明这个容器要平滑滚动,浏览器会试图给这 ~60 次赋值每一次都做动画,结果是一坨卡顿。每个容器二选一:要么 CSS 平滑滚动,要么这个 hook。 - 一个 hook 只管一个轴。
axis是"x"或"y",不能都要。需要斜向移动的网格就用两个 hook,或者用原生调用。 - 自动探测到的滚动父容器会按元素缓存。 某个节点的第一次查找结果会被记住。如果你的布局在运行时切换祖先的
overflow------比如面板展开后才变得可滚动------那就把容器作为第三个参数传进去,别靠探测。 cancelable管滚轮和触摸,不管键盘。 Page Down 和拖滚动条不会中止动画。它覆盖的是常见情况,不是全部情况;真在意的话自己在 keydown 里调cancel()。isList是有方向的。isList: true时,hook 只在目标位于容器由alignment决定的那一侧 之外时才移动------已经可见的目标压根不会产生滚动。这正是它的用意(防止键盘导航的列表每敲一次键就抖一下),但也意味着isList: true配错 alignment 会让人觉得 hook 在无视你。duration: 0是瞬间跳转,不是什么都不做。 想遵循你自己那个"关闭动画"设置时很好用,不用去分支判断该调哪个函数。
什么时候别用这个 hook
原生 scrollIntoView 是正解的场合,比你以为的多:
- 静态锚点或目录链接 →
el.scrollIntoView({ behavior: "smooth" })加scroll-margin-top。没有依赖,没有动画循环。 - 列表框的键盘导航 →
block: "nearest"原生就是最小移动的行为,而且那个场景本来就该是瞬间的手感。 - 需要同时对齐两个轴 → 原生调用同时接
block和inline。 - 你要滚到某个位置而不是某个元素 →
window.scrollTo/el.scrollTo,或者用useScroll读取和响应滚动位置。 - 你想知道什么在视野里,而不是移动过去 →
useIntersectionObserver,目录里高亮当前章节也是靠它。 - 你想彻底禁止页面滚动 (弹窗打开时)→
useScrollLock。
要点
- 基础写法就三行:给元素上
useRef,处理函数里ref.current?.scrollIntoView({ behavior: "smooth" }),加?.是因为提交之前 ref 是null。记住block: "nearest"------你用得最多的就是它。 - 固定头部遮挡用 CSS 的
scroll-margin-top解决,别拿getBoundingClientRect()减一个写死的像素值。前者扛得住响应式头部,还顺手修好:target锚点。 - 要滚动到刚渲染出来的东西,就在按变化取依赖的 effect 里滚,或者用回调 ref。
flushSync能用,但要放弃批处理------留着当逃生舱。 - 原生平滑滚动没有时长控制、没有靠谱的完成事件、不能取消、不处理
prefers-reduced-motion。这四条你都不在乎,就别加依赖。 useScrollIntoView补的恰好就是这几个缺口------可配的duration/easing、onScrollFinish、滚轮和触摸即时取消、自动的减少动效回退,外加offset、横向axis、显式滚动容器,以及让列表导航不抖的isList。它在调用时才解析目标,所以能和渲染出它的那次setState待在同一个处理函数里。
useScrollIntoView、useScroll、useScrollLock,以及另外 110+ 个 SSR 安全、TypeScript 优先的 hook 都在 @reactuses/core 里------装一次,可 tree-shake,没有需要你伺候的依赖。
bash
npm install @reactuses/core