如何使用 ref 操作 DOM?(五)原理、react state 同步更新 DOM 的方式

900 阅读3分钟

这是我参与11月更文挑战的第16天,活动详情查看:2021最后一次更文挑战

翻译自:beta.reactjs.org/learn/manip…

因为 React 已经根据 render 的输出处理了 DOM 结构,所以你的组件不经常需要操作 DOM。然而,有的时候你可能需要操作 React 管理的 DOM 元素,比如,将焦点放到一个节点上,滚动到这个节点,或者去计算它的宽和高。React 中没有内置的方法去做这些事情,所以你将会需要 ref 去指向这个 DOM 节点。

这个系列的文章你将会学到:

  • 如何使用 ref 属性访问由 React 管理的 DOM 节点
  • 如何将 JSX 的 ref 属性关联到 useRef 钩子
  • 如何访问其他组件的 DOM 节点
  • 在哪种情况下,修改 React 管理的 DOM 是安全的

关于 ref 相关的介绍和例子,可以看我前面一个系列的文章 useRef 简单易懂解析

系列文章

React 如何给 refs 赋值

在 React 中,每次更新都分为两个阶段:

  • render 期间,React 调用您的组件来确定屏幕上应该显示什么。
  • commit 期间,React 将更改应用于 DOM。

通常,您不会想在 render 期间访问 refs。这也适用于持有 DOM 节点的 refs,也就是我们这个系列文章说的情况。在第一次 render 过程中,DOM 节点还没有被创建,所以 ref.current 的值是 null。并且在 render 更新的过程中,DOM 节点还没有更新。所以现在读它们的值还为时过早。

React 在 commit 期间设置 ref.current,在更新 DOM 之前,React 将受影响的 ref.current 值设置为 null。更新 DOM 后,React 立即将它们设置到相应的 DOM 节点。

通常,您将从事件处理程序访问 refs。,如果你想用 ref 做一些事情,但没有特定的事件来做,你可能需要一个 effect,我们将在后面讨论 effect。

同步刷新状态

考虑这样的代码,期望是它添加一个新的待办事项并将屏幕向下滚动到列表的最后一个子项。但是实际情况,出于某种原因,它总是滚动到上次添加的待办事项之前的待办事项:

import { useState, useRef } from 'react';

export default function TodoList() {
  const listRef = useRef(null);
  const [text, setText] = useState('');
  const [todos, setTodos] = useState(
    initialTodos
  );

  function handleAdd() {
    const newTodo = { id: nextId++, text: text };
    setText('');
    setTodos([ ...todos, newTodo]);
    listRef.current.lastChild.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest'
    });
  }

  return (
    <>
      <button onClick={handleAdd}>
        Add
      </button>
      <input
        value={text}
        onChange={e => setText(e.target.value)}
      />
      <ul ref={listRef}>
        {todos.map(todo => (
          <li key={todo.id}>{todo.text}</li>
        ))}
      </ul>
    </>
  );
}

let nextId = 0;
let initialTodos = [];
for (let i = 0; i < 20; i++) {
  initialTodos.push({
    id: nextId++,
    text: 'Todo #' + (i + 1)
  });
}

问题在于这两行代码:

setTodos([ ...todos, newTodo]);
listRef.current.lastChild.scrollIntoView();

在 React 中,状态是排队更新的。通常,这就是您想要的。但是,这里会导致一个问题,因为 setTodos 不会立即更新 DOM。因此,当你将列表滚动到最后一个元素时,新的待办事项还没有更新到 DOM 中。这就是为什么滚动总是“落后”一项的原因。

为了解决这个问题,你可以强制 React 同步更新(“刷新”)DOM。为此,从 react-dom 导入 flushSync 并将状态更新包装到一个 flushSync 调用中:

flushSync(() => {
  setTodos([ ...todos, newTodo]);
});
listRef.current.lastChild.scrollIntoView();

这将指示 React 在包装在 flushSync 中的代码执行后立即同步更新 DOM。因此,当您尝试滚动到最后一个 todo 时,它已经在 DOM 中了:

import { useState, useRef } from 'react';
import { flushSync } from 'react-dom';

export default function TodoList() {
  const listRef = useRef(null);
  const [text, setText] = useState('');
  const [todos, setTodos] = useState(
    initialTodos
  );

  function handleAdd() {
    const newTodo = { id: nextId++, text: text };
    flushSync(() => {
      setText('');
      setTodos([ ...todos, newTodo]);      
    });
    listRef.current.lastChild.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest'
    });
  }

  return (
    <>
      <button onClick={handleAdd}>
        Add
      </button>
      <input
        value={text}
        onChange={e => setText(e.target.value)}
      />
      <ul ref={listRef}>
        {todos.map(todo => (
          <li key={todo.id}>{todo.text}</li>
        ))}
      </ul>
    </>
  );
}

let nextId = 0;
let initialTodos = [];
for (let i = 0; i < 20; i++) {
  initialTodos.push({
    id: nextId++,
    text: 'Todo #' + (i + 1)
  });
}