React Ref 为什么是对象

你是否想过 React 中 ref 的用法是 ref.current 而不是直接通过 ref 获得我们想要的数据,这个包含 current 属性的对象结构是多此一举吗?毕竟它有且仅有 current 这一个属性。const ref = useRef(null); // 声明 ref
console.log(ref.current); // 使用 ref 为什么不直接设计成 console.log(ref)先说结论,React Ref 的数据结构设计成 JavaScript Obeject 是为了让数据在其他作用域中也能被正确地读取

在React 函数式组件(FC)中,我们使用 useRef hook 来声明 ref 数据,可能你对 ref 特性或者 useRef hook 并不熟悉,这里有一篇文章深入浅出地介绍了 useRef 及其应用场景

总结一下这篇文章的知识点就是:

  • ref 数据和 state 数据不同点在于,ref 更新时组件不会更新(重走一遍函数作用域)
  • 由于 ref 的上述特性,它常常可以用作保存无需响应式更新UI的数据,用的最多的是保存某个 DOM 节点对象的引用

一个截图的例子

笔者负责了一个开发业绩长图的需求,场景是将一篇比较丰富的海报用 DOM 还原出来供用户预览,再通过类似于“截图”的方式将海报下载成图片。业内截图用的比较多的是 html2canvas

附上简单代码。

const App = () => {
  const reviewRef = useRef(null) // 创建 ref,用于引用 DOM 节点对象

  /**
   * 点击下载按钮后进行简单的保存 DOM 为图片并下载的逻辑
   */
  const onClick = () => {
    reviewRef.current &&
      html2canvas(reviewRef.current, {}).then((canvas) => {
        downloadByB64({
          fileName: "report.jpg",

          b64: `${canvas.toDataURL()}`,
        })
      })
  }

  return (
    <div>
      <button onClick={onClick}>下载图片</button>
      {/* 以下是预览区域 */}
      <article ref={reviewRef}>
        <h1>标题</h1>
        <p>内容</p>
      </article>
    </div>
  )
}

简单梳理代码过程如下

  • App 组件内声明了 ref 数据 reviewRef,声明了回调函数 onClick,App 函数作用域返回 jsx 代码,将 onClick 回调函数设置为 button 元素的 click event handler,当页面中的App组件渲染完毕后,reviewRef 和 article 元素形成一对一的关系,具体表现为 review.ref 为 article 的 DOM 元素引用
  • 当用户点击下载图片 button,onClick 回调函数执行,完成预期的下载操作。

UI和逻辑分离

领导建议组件中UI代码和逻辑代码分离,这样对团队成员的协同开发和代码的可读性都有好处。UI代码即 jsx 代码,逻辑代码包括 hook 代码和各种回调函数代码,将逻辑代码抽成自定义 hook 代码,第一反应是从上述代码抽解出自定义的下载图片 hook(后称 useDownload hook ),useDownload hook 唯一依赖于 DOM 节点,因此我很自然地将 DOM 节点即 reviewRef.current 当做函数参数传入 useDownload hook

/**
* 下载预览区域为图片的业务逻辑钩子
*/
const useDownload = (el) => {
  const onClick = () => {
    el &&
      html2canvas(el).then((canvas) => {
        downloadByB64({
          fileName: "report.jpg",
          b64: `${canvas.toDataURL()}`,
        })
      })
  }

  return onClick
}

const App = () => {
  const reviewRef = useRef(null)
  const onClick = useDownload(reviewRef.current)

  return (
    <div>
      <button onClick={onClick}>下载图片</button>
      {/* 以下是预览区域 */}
      <article ref={reviewRef}>
        <h1>标题</h1>
        <p>内容</p>
      </article>
    </div>
  )
}

但是这样写出来代码却并不符合预期,一番 debug 过后,发现在点击下载图片按钮,执行 onClick 回调的过程中,el 的值为一直为 null ,而并非 DOM 元素对象的引用,因此也就无法将元素下载成图片。

原因是什么呢??❓

按照 React 运作的时序来分析,当函数组件 App 的最后一段 return 代码执行完后, ref.current 值从 null 被更新为 DOM 元素对象的引用,代码执行完毕,函数作用域被回收。因此,在 useDownload hook 被调用的时刻,被传递的参数 ref.current 很明显是 null,而在 ref.current 被更新的时候,hook 的传参却没有更新,即数据过期了。

想当然的解决办法就是在 ref.current 数据更新后,重新调起一次 useDownload 函数作用域,hook 代码被重新执行一遍,以确保拿到的形参数据是最新的。这种重新渲染组件的要求可以通过更新组件状态的方式间接实现,代码简单示例如下,但这种方法无疑不太优雅且缺少考虑。

/**
* 下载预览区域为图片的业务逻辑钩子
*/
const useDownload = (el) => {
	const [temp, setTemp] = useState(0);

	useEffect(() => {
		setTemp(temp+1);  // temp state 更新 => 重走一遍函数作用域
	}, [])
	
	// other logics
}

当然也有更加有效和优雅的解决方案,直接上代码。

/**
* 下载预览区域为图片的业务逻辑钩子
*/
const useDownload = (reviewRef) => {
  const onClick = () => {
	const el = reviewRef.current;
    el &&
      html2canvas(el).then((canvas) => {
        downloadByB64({
          fileName: "report.jpg",
          b64: `${canvas.toDataURL()}`,
        })
      })
  }

  return onClick
}

const App = () => {
  const reviewRef = useRef(null)
  const onClick = useDownload(reviewRef)

  return (
    <div>
      <button onClick={onClick}>下载图片</button>
      {/* 以下是预览区域 */}
      <article ref={reviewRef}>
        <h1>标题</h1>
        <p>内容</p>
      </article>
    </div>
  )
}

React 作用的时序并没有变,变化的是传给自定义hook 的参数,参数变成了完整 reviewRef 对象,而非精摘出来的 reviewRef.current 值,当 onClick 回调被执行时,onClick 函数作用域在 App 函数作用域上产生了闭包,读取到的 reviewRef.current 是符合预期的 DOM 元素的对象引用。

到此为止我们已经可以呼应到本文的主题了,ref 数据为什么设置成对象的形式?DOM 元素为什么要通过 ref.current 点用?

因为 dom 元素并非一开始就绑定在 ref 数据上,而是在组件渲染完成后才绑定在 ref 数据上,那么在不同作用域的传递数据时,使用 JavaScript object 的形式能够确保不同作用域读取的数据来自同一处内存块,尽管内存块中的数据内容在更新,但只要保证内存块的地址不变,就可以始终保证通过地址引用拿到的内存块的数据内容永远是最新的。以此延伸到在一些别的场景下我们也可以通过将函数形参传递成object形式以规避维护数据一致性的额外工作。

Pasted image 20221207122016.png

或许我们还可以把 useDownload hook 抽取得更加优雅,将 ref 数据的声明直接从 App 函数作用域移至 useDownload 函数作用域使UI跟逻辑分离得更彻底。

/**
 * 下载预览区域为图片的业务逻辑钩子
 */
const useDownload = () => {
  const reviewRef = useRef(null)
  const onClick = () => {
    const el = reviewRef.current
    el &&
      html2canvas(el).then((canvas) => {
        downloadByB64({
          fileName: "report.jpg",
          b64: `${canvas.toDataURL()}`,
        })
      })
  }
  return {
    onClick,
    reviewRef,
  }
}

const App = () => {
  const { onClick, reviewRef } = useDownload()

  return (
    <div>
      <button onClick={onClick}>下载图片</button>
      {/* 以下是预览区域 */}
      <article ref={reviewRef}>
        <h1>标题</h1>
        <p>内容</p>
      </article>
    </div>
  )
}

有没有同学跟我一样记性不好??

既然上文已经说过,ref 数据看起来就是提供了一层对象包装,使数据在传递的过程中只传递对象引用而非传递 primitive values,那么是否有同学会和我一下本能地并不是特别钟意使用太多框架提供的方法,心里总觉得会加重一些记忆负担。提供的替代 ref 方案是在 useDownload 作用域的上层作用域声明一个 类ref 数据,提供代码如下。当然你会得到一个 React-warning 或者无法通过类型检查如果你使用 typescript 进行开发。

/**
 * 下载预览区域为图片的业务逻辑钩子 useDownload.js
 */
const refEqual = {};  // 类 ref 数据
const useDownload = () => {
  const onClick = () => {
    const el = refEqual.current
    el &&
      html2canvas(el).then((canvas) => {
        downloadByB64({
          fileName: "report.jpg",
          b64: `${canvas.toDataURL()}`,
        })
      })
  }
  return {
    onClick,
    refEqual
  }
}
/**
 * App UI 组件 App.jsx
 */
const App = () => {
  const { onClick } = useDownload()

  return (
    <div>
      <button onClick={onClick}>下载图片</button>
      {/* 以下是预览区域 */}
      <article ref={refEqual}>
        <h1>标题</h1>
        <p>内容</p>
      </article>
    </div>
  )
}

总结

  1. ref 的数据结构设计成对象的原因在于让数据在其他作用域中也能被正确地读取
  2. 在自定义hook的时候需要考虑到 React 运作时序,可能不能单单用常规的抽象函数的思维来抽象自定义hook

完整的代码示例请参阅 codesandbox 链接? => why ref is object

版权声明:
作者:lsdyi
链接:https://jkboy.com/archives/8001.html
来源:随风的博客
文章版权归作者所有,未经允许请勿转载。

THE END
分享
二维码
海报
React Ref 为什么是对象
在React 函数式组件(FC)中,我们使用 useRef hook 来声明 ref 数据,可能你对 ref 特性或者 useRef hook 并不熟悉,这里...
<<上一篇
下一篇>>