react之refs与dom

第四章 - 脱围机制

使用ref引用值

当你希望组件“记住”某些信息,但又不想让这些信息 触发新的渲染 时,你可以使用 ref

给你的组件添加 ref

你可以通过从 React 导入 useRef Hook 来为你的组件添加一个ref:

import { useRef } from 'react';

在你的组件内,调用useRef Hook 并传入你想要引用的初始值作为唯一参数。例如,这里的ref引用的值是"0":

const ref = useRef(0)

useRef返回一个这样的对象:

{ 
  current: 0 // 你向 useRef 传入的值
}

在这里插入图片描述

你可以用 ref.current 属性访问该ref的当前值。这个值是有意被设置为可变的,意味着你既可以读取它也可以写入它,就像一个React追踪不到的,用来存储组件信息的秘密口袋。 (这就是让他成为React单向数据流的"脱围机制的原因")

这里,每次点击按钮时会使 ref.current 递增:

import { useRef } from 'react';

export default function Counter() {
  let ref = useRef(0);

  function handleClick() {
    ref.current = ref.current + 1;
    alert('你点击了 ' + ref.current + ' 次!');
  }

  return (
    <button onClick={handleClick}>
      点击我!
    </button>
  );
}

这里的ref指向一个数字,但是,像state一样,你可以让他指向任何东西:字符串,对象甚至是函数。与state不同的是,ref是一个普通的JavaScript对象,具有可以被读取和修改的current属性。

请注意,组件不会再每次递增时重新渲染。与state一样,React会在每次重新渲染之间保留ref。但是,设置state会重新渲染组件,更改ref不会!

示例:制作秒表

你可以在单个组件中把 ref 和 state 结合起来使用。例如,让我们制作一个秒表,用户可以通过按按钮来使其启动或停止。为了显示从用户按下“开始”以来经过的时间长度,你需要追踪按下“开始”按钮的时间和当前时间。此信息用于渲染,所以你会把它保存在 state 中:

const [startTime, setStartTime] = useState(null);
const [now, setNow] = useState(null);

当用户按下“开始”时,你将用 setInterval 每 10 毫秒更新一次时间:

当按下“停止”按钮时,你需要取消现有的 interval,以便让它停止更新 now state 变量。你可以通过调用 clearInterval 来完成此操作。但你需要为其提供 interval ID,此 ID 是之前用户按下 Start、调用 setInterval 时返回的。你需要将 interval ID 保留在某处。 由于 interval ID 不用于渲染,你可以将其保存在 ref 中:

import { useState, useRef } from 'react';

export default function Stopwatch() {
  const [startTime, setStartTime] = useState(null);
  const [now, setNow] = useState(null);
  const intervalRef = useRef(null);

  function handleStart() {
    setStartTime(Date.now());
    setNow(Date.now());

    clearInterval(intervalRef.current);
    intervalRef.current = setInterval(() => {
      setNow(Date.now());
    }, 10);
  }

  function handleStop() {
    clearInterval(intervalRef.current);
  }

  let secondsPassed = 0;
  if (startTime != null && now != null) {
    secondsPassed = (now - startTime) / 1000;
  }

  return (
    <>
      <h1>时间过去了: {secondsPassed.toFixed(3)}</h1>
      <button onClick={handleStart}>
        开始
      </button>
      <button onClick={handleStop}>
        停止
      </button>
    </>
  );
}

当一条信息用于渲染时,将它保存在 state 中。当一条信息仅被事件处理器需要,并且更改它不需要重新渲染时,使用 ref 可能会更高效。

ref 和 state 的不同之处

也许你觉得 ref 似乎没有 state 那样“严格” —— 例如,你可以改变它们而非总是必须使用 state 设置函数。但在大多数情况下,我们建议你使用 state。ref 是一种“脱围机制”,你并不会经常用到它。 以下是 state 和 ref 的对比:

refstate
useRef(initialValue)返回 { current: initialValue }useState(initialValue) 返回 state 变量的当前值和一个 state 设置函数 ( [value, setValue])
更改时不会触发重新渲染更改时触发重新渲染。
可变 —— 你可以在渲染过程之外修改和更新 current 的值。“不可变” —— 你必须使用 state 设置函数来修改 state 变量,从而排队重新渲染。
你不应在渲染期间读取(或写入) current 值。你可以随时读取 state。但是,每次渲染都有自己不变的 state 快照

这是一个使用 state 实现的计数器按钮:

import { useState } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
  }

  return (
    <button onClick={handleClick}>
      你点击了 {count} 次
    </button>
  );
}

因为 count 的值将会被显示,所以为其使用 state 是合理的。当使用 setCount() 设置计数器的值时,React 会重新渲染组件,并且屏幕会更新以展示新的计数。

如果你试图用 ref 来实现它,React 永远不会重新渲染组件,所以你永远不会看到计数变化!看看点击这个按钮如何 不更新它的文本

import { useRef } from 'react';

export default function Counter() {
  let countRef = useRef(0);

  function handleClick() {
    // 这样并未重新渲染组件!
    countRef.current = countRef.current + 1;
  }

  return (
    <button onClick={handleClick}>
      你点击了 {countRef.current} 次
    </button>
  );
}

这就是为什么在渲染期间读取 ref.current 会导致代码不可靠的原因。如果需要,请改用 state。

何时使用ref

通常,当你的组件需要"跳出"React 并于外部 API 通信时,你会用到ref – 通常是不会影响组件外观的浏览器 API。以下时这些罕见情况中的几个:

  • 存储timeout ID
  • 存储和操作 DOM 元素
  • 存储不需要用来计算JSX的其他对象

如果你的组件需要存储一些值,但不影响渲染逻辑,请选择ref.

ref 的最佳实践

遵循这些原则将使你的组件更具可预测性:

  • 将 ref 视为脱围机制。当你使用外部系统或浏览器 API 时,ref 很有用。如果你很大一部分应用程序逻辑和数据流都依赖于 ref,你可能需要重新考虑你的方法。
  • 不要在渲染过程中读取或写入 ref.current 如果渲染过程中需要某些信息,请使用 state 代替。由于 React 不知道 ref.current 何时发生变化,即使在渲染时读取它也会使组件的行为难以预测。(唯一的例外是像 if (!ref.current) ref.current = new Thing() 这样的代码,它只在第一次渲染期间设置一次 ref。)

React state 的限制不适用于 ref。例如,state 就像 每次渲染的快照,并且 不会同步更新。但是当你改变 ref 的 current 值时,它会立即改变:

ref.current = 5;
console.log(ref.current); // 5

这是因为 ref 本身是一个普通的 JavaScript 对象, 所以它的行为就像对象那样。

当你使用 ref 时,也无需担心 避免变更。只要你改变的对象不用于渲染,React 就不会关心你对 ref 或其内容做了什么。

ref 和 DOM

你可以将ref指向任何值。但是,ref最常见的用法是访问DOM元素。例如,如果你想以编程方式聚焦一个输入框,这种用法就会派上用场。当你将 ref 传递给 JSX 中的 ref 属性时,比如 <div ref={myRef}>,React 会将相应的 DOM 元素放入 myRef.current 中。当元素从 DOM 中删除时,React 会将 myRef.current 更新为 null。你可以在 使用 ref 操作 DOM 中阅读更多相关信息。

摘要
  • ref是一种脱围机制,用于保留不用于渲染的值。
  • ref是一个普通的JavaScript对象,具有一个名为 current的属性,你可以对其进行读取或设置
  • 你可以通过调用useRef Hook 来让react给你一个ref
  • 与state一样,ref允许你在组件的重新渲染之间保留信息
  • 与state不同,设置ref的current值不会触发重新渲染
  • 不要在渲染过程中读取或写入 ref.current。这使你的组件难以预测

使用 ref 操作 DOM

由于 React会自动处理更新DOM以匹配你的渲染输出,因此你在组件中通常不需要操作DOM。但是,有时你可能需要访问由React管理的DOM元素 – 例如,让一个节点获得焦点,滚动到它或测量它的尺寸和位置。在React中没有内置的方法来做这些事情,所以你需要一个指向DOM节点的ref来实现。

获取指向节点的ref

要访问由react管理的DOM节点,首先,引入 useRef Hook;然后在你的组件中使用它声明一个ref,最后将ref作为 ref属性值传递给想要获取的DOM节点的 JSX标签:

import {useRef} from 'react'

const myRef = useRef(null);

<div ref={myRef}></div>

useRef Hook 返回一个对象,该对象有一个名为 current 的属性。最初,myRef.currentnull。当 React 为这个 <div> 创建一个 DOM 节点时,React 会把对该节点的引用放入 myRef.current。然后,你可以从 事件处理器 访问此 DOM 节点,并使用在其上定义的内置浏览器 API

// 你可以使用任意浏览器 API,例如:
myRef.current.scrollIntoView();

示例:使文本输入框获得焦点

import { useRef } from 'react';

export default function Form() {
  const inputRef = useRef(null);

  function handleClick() {
    inputRef.current.focus();
  }

  return (
    <>
      <input ref={inputRef} />
      <button onClick={handleClick}>
        聚焦输入框
      </button>
    </>
  );
}

要实现这一点:

  1. 使用 useRef Hook 声明 inputRef。
  2. 像 这样传递它。这告诉React将这个 的DOM节点放入
  3. 在handelClick 函数中,从 inputRef.current 读取 input DOM 节点并使用 inputRef.current.focus() 调用它的focus
  4. 用 onClick 将 handleClick 时间处理器传递给

虽然DOM操作是ref最常见的用例,但 useRef Hook 可用于存储 React之外的其他内容,例如计时器ID。与state类似,ref能在渲染之间保留,你甚至可以将ref视为设置它们时不会触发重新渲染的state变量!

示例:滚动至一个元素

个组件中可以有多个 ref。在这个例子中,有一个由三张图片和三个按钮组成的轮播,点击按钮会调用浏览器的 scrollIntoView() 方法,在相应的 DOM 节点上将它们居中显示在视口中:

import { useRef } from 'react';

export default function CatFriends() {
  const firstCatRef = useRef(null);
  const secondCatRef = useRef(null);
  const thirdCatRef = useRef(null);

  function handleScrollToFirstCat() {
    firstCatRef.current.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest',
      inline: 'center'
    });
  }

  function handleScrollToSecondCat() {
    secondCatRef.current.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest',
      inline: 'center'
    });
  }

  function handleScrollToThirdCat() {
    thirdCatRef.current.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest',
      inline: 'center'
    });
  }

  return (
    <>
      <nav>
        <button onClick={handleScrollToFirstCat}>
          Tom
        </button>
        <button onClick={handleScrollToSecondCat}>
          Maru
        </button>
        <button onClick={handleScrollToThirdCat}>
          Jellylorum
        </button>
      </nav>
      <div>
        <ul>
          <li>
            <img
              src="https://placekitten.com/g/200/200"
              alt="Tom"
              ref={firstCatRef}
            />
          </li>
          <li>
            <img
              src="https://placekitten.com/g/300/200"
              alt="Maru"
              ref={secondCatRef}
            />
          </li>
          <li>
            <img
              src="https://placekitten.com/g/250/200"
              alt="Jellylorum"
              ref={thirdCatRef}
            />
          </li>
        </ul>
      </div>
    </>
  );
}
深入讨论 - 如何使用ref回调管理ref列表

在上面的例子中,ref 的数量是预先确定的。但有时候,你可能需要为列表中的每一项都绑定 ref ,而你又不知道会有多少项。像下面这样做是行不通的

<ul>
  {items.map((item) => {
    // 行不通!
    const ref = useRef(null);
    return <li ref={ref} />;
  })}
</ul>

这是因为 Hook 只能在顶层组件被调用,不能在循环语句,条件语句或 map() 函数中调用 useRef。

一种可能的解决方案是用一个 ref 引用其父元素,然后用 DOM 操作方法如 querySelectorAll 来寻找它的子节点。然而,这种方法很脆弱,如果 DOM 结构发生变化,可能会失效或报错。

另一种解决方法是将函数传递给ref属性。这称为 ref 回调。当需要设置ref时,React将传入DOM节点来调用你的ref回调,并在需要清除时传入null。这使你可以维护自己的数组或map,并通过其索引或某种类型的ID访问任何ref。

此示例展示了如何使用此方法滚动到长列表中的任意节点:

import {useRef , useState} from "react";

export default function CatFriends() {
	const itemRef = useRef(null);
    const [catList,setCatList] = useState(setupCatList);
    
    function scrollToCat(cat) {
        const map = getMap();
        const node = map.get(cat);
        node.scrollIntoView({
            behavior: "smooth",
            block: "nearest",
            inline: "center",
        })
    }
    
    function getMap() {
        if(!itemsRef.current) {
            // 首次运行时初始化 Map
            itemsRef.current = new Map()
        }
        return itemsRef.current;
    }
    return (
    <>
      <nav>
        <button onClick={() => scrollToCat(catList[0])}>Tom</button>
        <button onClick={() => scrollToCat(catList[5])}>Maru</button>
        <button onClick={() => scrollToCat(catList[9])}>Jellylorum</button>
      </nav>
      <div>
        <ul>
          {catList.map((cat) => (
            <li
              key={cat}
              ref={(node) => {
                        const map = getMap()
                        if(node) {
							map.set(cat,node)
                        }else {
                            map.delete(cat)
                        }
                    }}
            >
              <img src={cat} />
            </li>
          ))}
        </ul>
      </div>
    </>
}
function setupCatList() {
  const catList = [];
  for (let i = 0; i < 10; i++) {
    catList.push("https://loremflickr.com/320/240/cat?lock=" + i);
  }

  return catList;
}

在这个例子中,itemsRef保存的不是单个DOM节点,而是保存了包含列表项ID和DOM节点的Map。(Ref可以保存任何值) 每个列表项上的ref回调负责更新Map:

<li
  key={cat.id}
  ref={node => {
    const map = getMap();
    // Add to the Map
    map.set(cat, node);

    return () => {
      // Remove from the Map
      map.delete(cat);
    };
  }}
>

访问另一个组件的DOM节点

当你将 ref 放在像 <input /> 这样输出浏览器元素的内置组件上时,React 会将该 ref 的 current 属性设置为相应的 DOM 节点(例如浏览器中实际的 <input /> )。但是,如果你尝试将 ref 放在 你自己的 组件上,例如 <MyInput />,默认情况下你会得到 null。这个示例演示了这种情况。请注意单击按钮 并不会 聚焦输入框:

import { useRef } from 'react';

function MyInput(props) {
  return <input {...props} />;
}

export default function MyForm() {
  const inputRef = useRef(null);

  function handleClick() {
    inputRef.current.focus();
  }

  return (
    <>
      <MyInput ref={inputRef} />
      <button onClick={handleClick}>
        聚焦输入框
      </button>
    </>
  );
}

发生这种情况是因为默认情况下,React不允许组件访问其他组件的DOM节点。甚至子的子组件也不行!这是故意的。Refs是一种脱围机制,应该谨慎使用。手动操作另一个组件的DOM节点会使你的代码更加脆弱。

相反,想要暴露其DOM节点的组件必须选择该行为。一个组件可以指定将它的ref 转发给一个子组件。下面是 MyInput 如何使用 forwardRef API:

const MyInput = forwardRef((props,ref) => {
    return <input {...props} ref={ref}></input>;
})

它是这样工作的:

  1. <MyInput ref={inputRef} /> 告诉 React 将对应的 DOM 节点放入 inputRef.current 中。但是,这取决于 MyInput 组件是否允许这种行为, 默认情况下是不允许的。
  2. MyInput 组件是使用 forwardRef 声明的。 这让从上面接收的 inputRef 作为第二个参数 ref 传入组件,第一个参数是 props
  3. MyInput 组件将自己接收到的 ref 传递给它内部的 <input>

在设计系统中,将低级组件(如按钮、输入框等)的 ref 转发到它们的 DOM 节点是一种常见模式。另一方面,像表单、列表或页面段落这样的高级组件通常不会暴露它们的 DOM 节点,以避免对 DOM 结构的意外依赖。

深入讨论 - 使用命令句柄暴露一部分API

在上面的例子中,MyInput 暴露了原始的 DOM 元素 input。这让父组件可以对其调用focus()。然而,这也让父组件能够做其他事情 —— 例如,改变其 CSS 样式。在一些不常见的情况下,你可能希望限制暴露的功能。你可以用 useImperativeHandle 做到这一点:

import {
  forwardRef, 
  useRef, 
  useImperativeHandle
} from 'react';

const MyInput = forwardRef((props,ref) => {
    const realInputRef = useRef(null)
    useImperativeHandle(ref, () => ({
        // 只暴露focus,没有别的
        focus() {
            realInputRef.current.focus();
        },
    }))
    return <input {...props} ref={realInputRef}></input>
})

这里,MyInput 中的 realInputRef 保存了实际的 input DOM 节点。 但是,useImperativeHandle 指示 React 将你自己指定的对象作为父组件的 ref 值。 所以 Form 组件内的 inputRef.current 将只有 focus 方法。在这种情况下,ref “句柄”不是 DOM 节点,而是你在 useImperativeHandle 调用中创建的自定义对象。

React 何时添加resf

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

  • 渲染 阶段, React 调用你的组件来确定屏幕上应该显示什么。
  • 提交 阶段, React 把变更应用于 DOM。

通常,你 不希望 在渲染期间访问 refs。这也适用于保存 DOM 节点的 refs。在第一次渲染期间,DOM 节点尚未创建,因此 ref.current 将为 null。在渲染更新的过程中,DOM 节点还没有更新。所以读取它们还为时过早。

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

通常,你将从事件处理器访问 refs。 如果你想使用 ref 执行某些操作,但没有特定的事件可以执行此操作,你可能需要一个 effect。我们将在下一页讨论 effect。

深入讨论 - 用 flushSync 同步更新 state

思考这样的代码,它添加一个新的待办事项,并将屏幕向下滚动到列表的最后一个子项。请注意,出于某种原因,它总是滚动到最后一个添加 之前 的待办事项:

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}>
        添加
      </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: '待办 #' + (i + 1)
  });
}

问题出在这两行:

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

在react中,state更新是排队进行的。通常,这就是你想要的。但是,在这个实例中会导致问题,因为setTodos 不会立即更新 DOM。因此当你将列表滚动到最后一个元素时,尚未添加待办事项。这就是为什么滚动总是落后一行的原因。

要解决此问题,你可以强制React同步更新 DOM。为此,从 react-dom导入 flushSync 并将 state更新包裹到 flushSync 中:

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

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

使用 refs操作DOM的最佳实践

Refs 是一种脱围机制。你应该只在你必须“跳出 React”时使用它们。这方面的常见示例包括管理焦点、滚动位置或调用 React 未暴露的浏览器 API。

如果你坚持聚焦和滚动等非破坏性操作,应该不会遇到任何问题。但是,如果你尝试手动修改 DOM,则可能会与 React 所做的更改发生冲突。

为了说明这个问题,这个例子包括一条欢迎消息和两个按钮。第一个按钮使用 条件渲染state 切换它的显示和隐藏,就像你通常在 React 中所做的那样。第二个按钮使用 remove() DOM API 将其从 React 控制之外的 DOM 中强行移除.

尝试按几次“通过 setState 切换”。该消息会消失并再次出现。然后按 “从 DOM 中删除”。这将强行删除它。最后,按 “通过 setState 切换”:

import { useState, useRef } from 'react';

export default function Counter() {
  const [show, setShow] = useState(true);
  const ref = useRef(null);

  return (
    <div>
      <button
        onClick={() => {
          setShow(!show);
        }}>
        通过 setState 切换
      </button>
      <button
        onClick={() => {
          ref.current.remove();
        }}>
        从 DOM 中删除
      </button>
      {show && <p ref={ref}>Hello world</p>}
    </div>
  );
}

在你手动删除 DOM 元素后,尝试使用 setState 再次显示它会导致崩溃。这是因为你更改了 DOM,而 React 不知道如何继续正确管理它。

避免更改由 React 管理的 DOM 节点。 对 React 管理的元素进行修改、添加子元素、从中删除子元素会导致不一致的视觉结果,或与上述类似的崩溃。

但是,这并不意味着你完全不能这样做。它需要谨慎。 你可以安全地修改 React 没有理由更新的部分 DOM。 例如,如果某些 <div> 在 JSX 中始终为空,React 将没有理由去变动其子列表。 因此,在那里手动增删元素是安全的。

摘要
  • Refs是一种通用概念,但大多数情况下你会使用它们来保存DOM元素
  • 你通过传递 <div ref={myRef}> 指示 React 将 DOM 节点放入 myRef.current
  • 通常,你会将refs用于非破坏性,例如聚焦,滚动或测量dom元素
  • 默认情况下,组件不暴露其DOM节点。您可以通过使用forwardRef并将第二个ref参数传递给特定节点来暴露DOM节点
  • 避免更改由React管理的DOM节点
  • 如果你确实修改了React管理的DOM节点,请修改React没有理由更新的部分
  • 12
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值