如何使用hooks为元素数组使用多个引用?

277

据我的理解,我可以像这样为单个元素使用refs:

const { useRef, useState, useEffect } = React;

const App = () => {
  const elRef = useRef();
  const [elWidth, setElWidth] = useState();

  useEffect(() => {
    setElWidth(elRef.current.offsetWidth);
  }, []);

  return (
    <div>
      <div ref={elRef} style={{ width: "100px" }}>
        Width is: {elWidth}
      </div>
    </div>
  );
};

ReactDOM.render(
  <App />,
  document.getElementById("root")
);
<script src="https://unpkg.com/react@16/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@16/umd/react-dom.production.min.js"></script>

<div id="root"></div>

如何针对一个元素数组实现这个功能?显然不能像这样实现:(即使我没有尝试过,我也知道这一点:)

const { useRef, useState, useEffect } = React;

const App = () => {
  const elRef = useRef();
  const [elWidth, setElWidth] = useState();

  useEffect(() => {
    setElWidth(elRef.current.offsetWidth);
  }, []);

  return (
    <div>
      {[1, 2, 3].map(el => (
        <div ref={elRef} style={{ width: `${el * 100}px` }}>
          Width is: {elWidth}
        </div>
      ))}
    </div>
  );
};

ReactDOM.render(
  <App />,
  document.getElementById("root")
);
<script src="https://unpkg.com/react@16/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@16/umd/react-dom.production.min.js"></script>

<div id="root"></div>

我看到了 这个,以及因此看到了这个。但是,对于如何在这种简单情况下实现该建议,我仍然感到困惑。


如果我说的有些无知,请原谅。但是,如果您只调用一次 useRef(),为什么您希望元素具有不同的引用呢?据我所知,React 使用引用作为迭代元素的标识符,因此当您使用相同的引用时,它无法区分它们之间的差异。 - MTCoster
10
因为我还在学习Hooks和Refs,所以这里没有任何无知。因此,对我来说,任何建议都是好的建议。这就是我想做的事情:为不同的元素动态创建不同的引用(refs)。我的第二个例子只是一个“不要使用这个”例子:) - devserkan
[1,2,3] 是从哪里来的?它是静态的吗?答案取决于它。 - Estus Flask
最终,它们将来自远程端点。但现在,如果我学习静态的,我会很高兴。如果您能解释远程情况,那就太棒了。谢谢。 - devserkan
如何在递归组件渲染中实现动态 ref? - Kanish
@EstusFlask 在SO上的问题通常都有简化的示例,其中一种简化就是使用静态数组,比如[1,2,3]。然而,你绝不能假设真实世界中的使用情况就像这么简单,应该谨慎地考虑输入数据可能比简化示例复杂得多。例如,数组可能是动态的,长度可能会变化;它可能作为JSON从服务器传输过来;它可能包含不同类型的元素等等。你可以编写与简单示例相似的答案,但你的解决方案必须能够合理地扩展到更复杂的输入情况。 - ADTC
19个回答

343

由于不能在循环内使用钩子,因此以下是一种解决方案,使其在数组随时间变化时仍能正常工作。

我假设该数组来自props:

const App = props => {
    const itemsRef = useRef([]);
    // you can access the elements with itemsRef.current[n]

    useEffect(() => {
       itemsRef.current = itemsRef.current.slice(0, props.items.length);
    }, [props.items]);

    return props.items.map((item, i) => (
      <div 
          key={i} 
          ref={el => itemsRef.current[i] = el} 
          style={{ width: `${(i + 1) * 100}px` }}>
        ...
      </div>
    ));
}

77
太好了!另外需要说明的是,在TypeScript中,itemsRef的签名似乎是:const itemsRef = useRef<Array<HTMLDivElement | null>>([]) - Mike Niebling
2
你可以通过在构造函数中创建一个实例变量this.itemsRef = []来在类组件中获得相同的结果。然后,您需要将useEffect代码移动到componentDidUpdate生命周期方法中。最后,在render方法中,您应该使用<div key={i} ref={el => this.itemsRef.current[i] = el}`来存储引用。 - Olivier Boissé
8
这对我没有起作用。 - Vinay Prajapati
3
有人可以解释一下为什么要使用“slice”而不仅仅将引用重置为空数组的原因吗? - Advena
3
@Tanckom 因为 useEffect 在组件渲染后调用(因此在引用回调被调用后)。如果你在 useEffect 中将 itemsRef 重置为空数组,那么你将失去所有元素。 - Olivier Boissé
显示剩余17条评论

159

一个ref最初只是一个{ current: null }对象。useRef在组件重新渲染之间保持对该对象的引用。current值主要用于组件引用,但可以容纳任何内容。

最终应该有一个ref数组。如果数组长度可能在渲染之间变化,则数组应相应地扩展:

const arrLength = arr.length;
const [elRefs, setElRefs] = React.useState([]);

React.useEffect(() => {
  // add or remove refs
  setElRefs((elRefs) =>
    Array(arrLength)
      .fill()
      .map((_, i) => elRefs[i] || createRef()),
  );
}, [arrLength]);

return (
  <div>
    {arr.map((el, i) => (
      <div ref={elRefs[i]} style={...}>
        ...
      </div>
    ))}
  </div>
);

通过展开useEffect并使用useRef替换useState,可以优化此代码片段,但需要注意的是在渲染函数中执行副作用通常被认为是一种不良实践:

const arrLength = arr.length;
const elRefs = React.useRef([]);

if (elRefs.current.length !== arrLength) {
  // add or remove refs
  elRefs.current = Array(arrLength)
    .fill()
    .map((_, i) => elRefs.current[i] || createRef());
}

return (
  <div>
    {arr.map((el, i) => (
      <div ref={elRefs.current[i]} style={...}>
        ...
      </div>
    ))}
  </div>
);

1
感谢您的回答@estus。这清楚地展示了我如何创建refs。如果可能的话,您能否提供一种使用“state”与这些refs的方法?因为在此状态下,如果我没有错的话,我无法使用任何refs。它们在第一次渲染之前没有被创建,而且我需要使用useEffect和state。比方说,我想使用refs获取那些元素的宽度,就像我在第一个例子中所做的那样。 - devserkan
7
只有在数组长度始终相同的情况下才能正常运行,如果长度不同,您的解决方案将无法奏效。 - Olivier Boissé
1
在上面的代码中,这将发生在 .map((el, i) => ... 内部。 - Estus Flask
1
@Greg 的好处是在渲染函数中不会产生副作用,这被认为是一种可接受但不应该作为基本规则推荐的不良实践。如果我出于初步优化的考虑采取相反的方式,那么这也将成为批评答案的理由。我想不到有什么情况会使原地副作用成为一个真正糟糕的选择,但这并不意味着它不存在。我只是留下所有选项。 - Estus Flask
1
你可以省略那个 .fill(),只需确保使用 createRef 初始化状态: React.useState<RefObject<HTMLDivElement | null[]>>([createRef()]) - Code Drop
显示剩余15条评论

86

更新

新的React文档展示了一个使用map的推荐方法。

请在Beta版本中查看 这里(2022年12月)


有两种方法:

  1. 使用一个拥有多个current元素的ref
const inputRef = useRef([]);

inputRef.current[idx].focus();

<input
  ref={el => inputRef.current[idx] = el}
/>

const {useRef} = React;
const App = () => {
  const list = [...Array(8).keys()];
  const inputRef = useRef([]);
  const handler = idx => e => {
    const next = inputRef.current[idx + 1];
    if (next) {
      next.focus()
    }
  };
  return (
    <div className="App">
      <div className="input_boxes">
        {list.map(x => (
        <div>
          <input
            key={x}
            ref={el => inputRef.current[x] = el} 
            onChange={handler(x)}
            type="number"
            className="otp_box"
          />
        </div>
        ))}
      </div>
    </div>
  );
}
ReactDOM.render(<App />, document.getElementById("root"));
<div id="root"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.12.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.12.0/umd/react-dom.production.min.js"></script>

  1. 使用 ref 数组

    如上述帖子所说,这不被推荐,因为官方指南(以及内部lint检查)不允许通过。

    不要在循环、条件或嵌套函数中调用Hooks。相反,总是在React函数的顶层使用Hooks。 遵循这个规则,可以确保每次组件渲染时都按相同顺序调用Hooks。

    然而,由于这不是我们当前的情况,下面的演示仍然有效,只是不被推荐。

const inputRef = list.map(x => useRef(null));

inputRef[idx].current.focus();

<input
  ref={inputRef[idx]}
/>

const {useRef} = React;
const App = () => {
const list = [...Array(8).keys()];
const inputRef = list.map(x => useRef(null));
const handler = idx => () => {
  const next = inputRef[idx + 1];
  if (next) {
    next.current.focus();
  }
};
return (
  <div className="App">
    <div className="input_boxes">
      {list.map(x => (
      <div>
        <input
          key={x}
          ref={inputRef[x]}
          onChange={handler(x)}
          type="number"
          className="otp_box"
        />
      </div>
      ))}
    </div>
  </div>
);
}
ReactDOM.render(<App />, document.getElementById("root"));
<div id="root"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.12.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.12.0/umd/react-dom.production.min.js"></script>


尝试在React Native Maps的标记上使用showCallout()时,选项二对我起作用了。 - 7ahid
3
简单但有用 - Faris Rayhan
选项#2不正确。您只能在顶层使用钩子:https://pl.reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level只要列表的长度是恒定的,选项#2对您有效,但是当您向列表中添加新项目时,它将抛出错误。 - Adrian
@Adrian,正如我在答案中所说的,以那种方式编写是不允许的,也不推荐这样做。你可以选择不使用它并点击downvote,但这并不会使上面的演示不起作用(你也可以通过点击“显示代码片段”然后“运行”来尝试它)。我仍然保留#2的原因是为了更清楚地解释为什么存在这个问题。 - keikai
第一种方法非常有效。 - zonay
1
选项#1会在每次渲染时返回一个新的ref函数,从而不必要地重新呈现所有子元素。 - Daniel

17
所有其他选项都依赖于数组,但这样做会使事情变得非常脆弱,因为元素可能会被重新排序,然后我们无法跟踪哪个 ref 属于哪个元素。
React 使用 "key" 属性来跟踪项目。因此,如果您按键存储您的 refs,就不会有任何问题:
const useRefs = () => {
  const refsByKey = useRef<Record<string,HTMLElement | null>>({})

  const setRef = (element: HTMLElement | null, key: string) => {
    refsByKey.current[key] = element;
  }

  return {refsByKey: refsByKey.current, setRef};
}

const Comp = ({ items }) => {
  const {refsByKey, setRef} = useRefs()

  const refs = Object.values(refsByKey).filter(Boolean) // your array of refs here

  return (
    <div>
      {items.map(item => (
        <div key={item.id} ref={element => setRef(element, item.id)}/>
      )}
    </div>
  )
}

请注意,当React卸载一个项目时,它会使用null调用提供的函数,这将在对象中将匹配的key条目设置为null,因此一切都将是最新的。

1
这很不错,适用于对列表进行排序时,其他人使用的索引我认为不行。当排序时,索引和引用需要每次计算,这不是一个好方法。谢谢你。 - RJA
这也是一个不错的主意。如果不是因为有人回答说只需使用父引用并将子项作为数组获取,我本来会这样做的。 - ADTC

12

最简单、最有效的方法是根本不使用 useRef。只需使用一个回调 ref,在每次渲染时创建一个新的引用数组。

function useArrayRef() {
  const refs = []
  return [refs, el => el && refs.push(el)]
}

演示

<div id="root"></div>

<script type="text/babel" defer>
const { useEffect, useState } = React

function useArrayRef() {
  const refs = []
  return [refs, el => el && refs.push(el)]
}

const App = () => {
  const [elements, ref] = useArrayRef()
  const [third, setThird] = useState(false)
  
  useEffect(() => {
    console.log(elements)
  }, [third])

  return (
    <div>
      <div ref={ref}>
        <button ref={ref} onClick={() => setThird(!third)}>toggle third div</button>
      </div>
      <div ref={ref}>another div</div>
      { third && <div ref={ref}>third div</div>}
    </div>
  );
}

ReactDOM.render(<App />, document.getElementById("root"));
</script>

<script src="https://unpkg.com/@babel/standalone@7/babel.min.js"></script>
<script src="https://unpkg.com/react@17/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@17/umd/react-dom.production.min.js"></script>


2
如果您只需要一个组件,这是一个不错的解决方案。 - Joel Davey
这些被称为“回调引用”-https://reactjs.org/docs/refs-and-the-dom.html#callback-refs - Manohar Reddy Poreddy

10
请注意,不应在循环中使用useRef,原因很简单:使用的钩子的顺序确实很重要!
文档中提到:
不要在循环、条件或嵌套函数中调用Hooks。相反,始终在React函数的顶层使用Hooks。遵循此规则,可以确保每次组件渲染时以相同的顺序调用Hooks。这就是允许React在多个useState和useEffect调用之间正确保存Hooks状态的原因。(如果您好奇,我们将在下面深入解释这一点。)
但请注意,这显然适用于动态数组...但如果您正在使用静态数组(您始终呈现相同数量的组件),则不必太担心,了解您正在做什么并利用它。

8

我使用 useRef 钩子创建数据面板以便独立控制。首先,我初始化 useRef 来存储一个数组:

import React, { useRef } from "react";

const arr = [1, 2, 3];

const refs = useRef([])

初始化数组时,我们观察到它实际上看起来像这样:

//refs = {current: []}

然后,我们使用map函数应用div标签创建面板,我们将引用它,并将当前元素添加到我们的refs.current数组中以进行查看:

arr.map((item, index) => {
  <div key={index} ref={(element) => {refs.current[index] = element}}>
    {item}
    <a
      href="#"
      onClick={(e) => {
        e.preventDefault();
        onClick(index)
      }}
    >
      Review
    </a>
})

最后,一个能接收所按按钮的索引并控制我们要显示面板的函数。

const onClick = (index) => {
  console.log(index)
  console.log(refs.current[index])
}

最终的完整代码如下:

import React, { useRef } from "react";

const arr = [1, 2, 3];

const refs = useRef([])
//refs = {current: []}

const onClick = (index) => {
  console.log(index)
  console.log(refs.current[index])
}

const MyPage = () => {
   const content = arr.map((item, index) => {
     <div key={index} ref={(element) => {refs.current[index] = element}}>
       {item}
       <a
         href="#"
         onClick={(e) => {
           e.preventDefault();
           onClick(index)
         }}
       >
         Review
       </a>
   })
   return content
}

export default MyPage

这对我很有效!希望这个知识对你有所帮助。


8

您可以使用数组(或对象)来跟踪所有引用,并使用方法将引用添加到数组中。

注意:如果您要添加和删除引用,则必须在每个渲染周期中清空数组。

import React, { useRef } from "react";

const MyComponent = () => {
   // intialize as en empty array
   const refs = useRefs([]); // or an {}
   // Make it empty at every render cycle as we will get the full list of it at the end of the render cycle
   refs.current = []; // or an {}

   // since it is an array we need to method to add the refs
   const addToRefs = el => {
     if (el && !refs.current.includes(el)) {
       refs.current.push(el);
     }
    };
    return (
     <div className="App">
       {[1,2,3,4].map(val => (
         <div key={val} ref={addToRefs}>
           {val}
         </div>
       ))}
     </div>
   );

}

工作示例 https://codesandbox.io/s/serene-hermann-kqpsu


为什么在每个渲染周期都要清空数组,如果你已经检查了el是否在数组中? - GWorking
因为每个渲染周期它都会将其添加到数组中,我们只想要一个元素的副本。 - Neo
1
是的,但你是否使用 !refs.current.includes(el) 进行了检查? - GWorking

7
假设您的数组包含非基本类型,您可以将 WeakMap 用作 Ref 的值。
function MyComp(props) {
    const itemsRef = React.useRef(new WeakMap())

    // access an item's ref using itemsRef.get(someItem)

    render (
        <ul>
            {props.items.map(item => (
                <li ref={el => itemsRef.current.set(item, el)}>
                    {item.label}
                </li>
            )}
        </ul>
    )
}

1
实际上,在我的真实情况中,我的数组包含非原始数据类型,但我必须循环遍历该数组。我认为这不可能使用WeakMap实现,但如果不需要迭代,它确实是一个不错的选择。谢谢。附注:啊,有一个提案(https://github.com/tc39/proposal-weakrefs#iterable-weakmaps)可以解决这个问题,现在已经是第三阶段了。好知道 :) - devserkan
我对React/JS还很陌生,所以请原谅我的幼稚,但是ref属性有回调函数吗?此外,如果不使用StackOverflow,如何了解这样的信息?是否有文档/手册可供参考?谢谢。 - constantlyFlagged

4

使用TypeScript通过所有的ESLint警告

const itemsRef = useRef<HTMLDivElement[]>([]);

data.map((i) => (
  <Item
    key={i}
    ref={(el: HTMLDivElement) => {
      itemsRef.current[i] = el;
      return el;
    }}
  />
))

必须使用 React.forwardRef 构建 <Item />


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接