Skip to content

绘制前测量与组件的命令接口 ​

有些布局信息必须等 DOM 出现后才知道:提示框实际有多高,取决于字体、宽度和换行。直接从字符串长度猜高度不可靠;渲染时节点又还没提交。需要先让 React 放入节点,读取真实尺寸,再在用户看到画面前修正位置。

useLayoutEffect 多争取的是一次绘制前修正 ​

useLayoutEffect(setup, dependencies) 的依赖与清理规则和 Effect 相似,但它在 DOM 提交后、浏览器重绘前执行。里面安排的状态更新也会阻止浏览器先画出未经修正的结果。它不会让测量免费,也不适合一般网络取数。

下面把提示放在按钮上方;若顶部空间不够,改放下方。完整替换 src/App.jsx:

jsx
import { useLayoutEffect, useRef, useState } from 'react'

function Tip({ anchor, text }) {
  const node = useRef(null)
  const [height, setHeight] = useState(0)
  useLayoutEffect(() => {
    setHeight(node.current.getBoundingClientRect().height)
  }, [text])
  const above = anchor.top - height - 8 >= 0
  const top = above ? anchor.top - height - 8 : anchor.bottom + 8
  return <div ref={node} role="status" style={{
    position: 'fixed', left: anchor.left, top, width: 180,
    padding: 8, background: '#222', color: 'white', boxSizing: 'border-box',
  }}>
    {text}<small style={{ display: 'block' }}>放置:{above ? '上方' : '下方'}</small>
  </div>
}

export default function App() {
  const trigger = useRef(null)
  const [anchor, setAnchor] = useState(null)
  const [long, setLong] = useState(false)
  const text = long ? '这是较长的提示。'.repeat(12) : '简短提示'
  return <main style={{ paddingTop: 120 }}>
    <button ref={trigger} onClick={() =>
      setAnchor(trigger.current.getBoundingClientRect())
    }>显示提示</button>
    <button onClick={() => setLong(value => !value)}>切换长度</button>
    <button onClick={() => setAnchor(null)}>关闭提示</button>
    {anchor && <Tip anchor={anchor} text={text} />}
  </main>
}

首次 height 为 0,React 先按这个暂定值渲染;提交后 ref 指向提示节点;布局 Effect 读取高度并写 state;React 再按真实高度定位;最后浏览器绘制。切换长文字时 text 变化,重新测量;一旦高度超过上方空间,提示应移到下方。观察 DOM 中的 top 与提示高度,比赌肉眼能看见一帧闪烁更可靠。

将 useLayoutEffect 换成 useEffect,浏览器就有可能先绘制暂定位置再修正;不是保证每次都闪。相反,把所有 Effect 都改成布局 Effect,会把网络启动、日志等工作也挤进绘制前,拖慢首帧。

这个小实验只在点击时取得锚点位置,滚动、缩放或字体晚加载后不会自动跟踪。实际浮层还要监听相应变化并清理订阅,也要处理视口右侧和底部溢出。此处只验证测量到定位的两遍过程。服务端没有布局可测,布局 Effect 不在服务端执行;需要服务端首屏时应避免依赖测量才能显示的初始内容,或将该区域限定为客户端内容。

React 19 的 ref prop 不等于自动暴露 DOM ​

父组件写 <Field ref={fieldRef} />,函数组件在 React 19 可以接收 { ref };它仍必须决定把 ref 传到哪个 DOM 节点,或者暴露什么接口。仅接收而不使用不会自动让父组件得到内部 input。React 18 及更早常见 forwardRef 包装,阅读旧资料要保留这个版本差别。

如果父组件只需要“聚焦并选中”,直接暴露整个 DOM 节点就给出了多余能力。可以用 useImperativeHandle 创建一个只包含该命令的对象:

jsx
import { useImperativeHandle, useRef, useState } from 'react'

function Field({ ref, value, onChange }) {
  const input = useRef(null)
  useImperativeHandle(ref, () => ({
    focusAndSelect() {
      input.current.focus()
      input.current.select()
    },
  }), [])
  return <label>姓名<input ref={input} value={value}
    onChange={event => onChange(event.target.value)} /></label>
}

export default function App() {
  const field = useRef(null)
  const [name, setName] = useState('Ada')
  return <main>
    <Field ref={field} value={name} onChange={setName} />
    <button onClick={() => field.current.focusAndSelect()}>聚焦并选中</button>
    <p>当前姓名:{name}</p>
  </main>
}

input.current 是真实节点,field.current 则是自定义对象,只有 focusAndSelect。input.select() 选择输入中的文字,它不修改 React 状态;姓名仍由 value/onChange 表达。父组件无权用 field.current.remove() 删除节点。

第三参是创建 handle 时读取的响应式依赖。本例只读取稳定的内部 Ref 容器,所以可以是空数组;如果方法读取 value 或调用 onChange,就应列出它们。否则父组件下一次调用命令,可能仍读到旧渲染捕获的值。依赖变化可以让 handle 对象更新,调用时读取 ref.current,不要提前缓存旧 handle 期待它永远最新。

适合命令接口的是聚焦、选择、滚动等无法仅靠 props 清楚表达的动作。打开与关闭、选中的业务项、表单值通常仍适合 props/state;不要为了“封装”把所有单向数据流改成父组件命令子组件。

练习 ​

为 Field 增加 clearAndFocus():通过 onChange 清空受控值,再聚焦输入;不能直接写 DOM 的 value,也不能向父组件暴露整个节点。保留原来的 focusAndSelect,并增加两个按钮。先输入新姓名,清空后正文和输入都应为空;再次输入再选择仍正常。解释为什么创建 handle 的依赖要包含 onChange。

查看答案 · 下一篇:Portal 与可访问关联

用心记录每一步成长