一个React Ref.current获取引发的思考

4,059 阅读6分钟

背景

最近在开发中发现了一个问题,发现去请求一个ApI, 然后setState展示某个DOM,竟然可以在setState后面直接获取到我这个刚刚从隐藏状态变回显示状态的DOM节点的ref, 让我很不可思议,具体代码类似如下:

import React, { useState, useEffect, useRef, useLayoutEffect } from "react";

export default function App() {
  const [showTitle, setShowTitle] = useState(false);
  const titleRef = useRef(null);

  useEffect(() => {
    // api请求 axios.post().then...
    Promise.resolve(1).then(() => {
      setShowTitle(true);
      // 打印了 titleRef.current <h2>觉醒年代</h2> 竞然不是null🧐
      console.log("titleRef.current", titleRef?.current); 
    })
  }, [])

  return (
    <div className="App">
      <h3>测试 useEffect执行 ref更新 demo!</h3>
      <button>button</button>
      {showTitle && <h2 ref={titleRef}>觉醒年代</h2>}
    </div>
  );
}

useRef

在React中,我们要访问DOM节点可以使用React.useRef返回的值的current属性获取(当然有时候也用来保存一些临时的变量,因为ref在每一次函数执行时指针都不变),即:

如果是简单的useEffect里面获取已经展示的DOM节点,那是可以获取到的,因为useEffect的调度发生在Reactcommit流程,在两个Fiber树调换之后

import React, { useEffect, useRef } from "react";

export default function App() {
  const titleRef = useRef(null);
  useEffect(() => {
    console.log(titleRef.current); // 可以获取到对应DOM的真实节点
  }, [])

  return (
    <div className="App">
      <h2 ref={titleRef}>觉醒年代</h2>
    </div>
  );
}

但是当我们在普通的函数里面去setState展示对应的DOM,接着打印ref.current的时候,我们返回的是null:

export default function App() {
  const [showTitle, setShowTitle] = useState(false);
  const titleRef = useRef(null);

  const toggleShowTitle = () => {
    setShowTitle(!showTitle);
    console.log("titleRef.ref.current", titleRef.current);
  }

  console.log("render");

  return (
    <div className="App">
      <h3>测试 useEffect执行 ref更新 demo!</h3>
      <button onClick={toggleShowTitle}>button</button>
      {showTitle && <h2 ref={titleRef}>觉醒年代</h2>}
    </div>
  );
}

当我们在useEffect里面setState, 紧接着去打印最新的ref的时候,我们也获取不到对应的DOM:

import React, { useState, useEffect, useRef, useLayoutEffect } from "react";

export default function App() {
  const [showTitle, setShowTitle] = useState(false);
  const titleRef = useRef(null);

  useEffect(() => {
    setShowTitle(true);
    console.log("titleRef.ref.current", titleRef?.current); // null
  }, [])

  console.log("render");

  return (
    <div className="App">
      <h3>测试 useEffect执行 ref更新 demo!</h3>
      <button>button</button>
      {showTitle && <h2 ref={titleRef}>觉醒年代</h2>}
    </div>
  );
}

这个很容易理解,因为setState是异步执行的,也就是先setState完成后,并没有马上render, 而是React进行了异步调度, 然后先执行了后面打印的同步代码,所以自然获取不到真实的DOM。

但是当我们把setState放到setTimeout或者Promise.then的回调中的时候,神奇的事情发生了:

import React, { useState, useEffect, useRef, useLayoutEffect } from "react";

export default function App() {
  const [showTitle, setShowTitle] = useState(false);
  const titleRef = useRef(null);

  useEffect(() => {
    // setTimeout(() => {
    //   setShowTitle(true);
    //   console.log("titleRef.ref.current", titleRef?.current);
    // });
    Promise.resolve(1).then(() => {
      setShowTitle(true);
      console.log("titleRef.ref.current", titleRef?.current);
    })
  }, [])

  console.log("render");

  return (
    <div className="App">
      <h3>测试 useEffect执行 ref更新 demo!</h3>
      <button>button</button>
      {showTitle && <h2 ref={titleRef}>觉醒年代</h2>}
    </div>
  );
}

验证同步执行: 如果直接在setShowTitle后面去打印showTitle, 因为闭包问题,我们是不能获取到最新的showTitle=true的,尽管已经放在了 Promise.resolve中去setState, 当然在类组件中很容易通过this.state.showTitle获取到最新的state, 那么在函数组件中怎么获取同步更新的最新state呢?答案是从函数组件本身的Fiber对象中获取:

import React, { useState, useEffect, useRef, useLayoutEffect } from "react";

export default function App() {
  const [showTitle, setShowTitle] = useState(false);
  const titleRef = useRef(null);
  const appRef = useRef(null);

  useEffect(() => {
    Promise.resolve(1).then(() => {
      setShowTitle(true);
      console.log(appRef.current[Object.keys(appRef.current)[0]].return.alternate.memoizedState);
      console.log("titleRef.ref.current", titleRef?.current);
    })
  }, [])

  console.log("render");

  return (
    <div className="App" ref={appRef}>
      <h3>测试 useEffect执行 ref更新 demo!</h3>
      <button>button</button>
      {showTitle && <h2 ref={titleRef}>觉醒年代</h2>}
    </div>
  );
}

至于为什么要从alternate里面获取state, 原因是同步执行后其实此时的APP函数组件对应的Fiber节点已经commit完成,即完成了两颗Fiber树的切换过程

为什么在setState或者Promise.then里面setState的执行和渲染是同步的呢

要回答这个问题,首先要回到React里面普通的setState为什么异步的呢,这个可以看官方issue的回答: github.com/facebook/re…, 总结一下大概就是为了性能优化而做的批处理,保证state和props的更新只有在reconciliation and flushing阶段之后,也就是React的beginWork阶段之后。也就是多个setState被批量异步处理,然后保证当前页面可以交互的情况下根据新的state计算出新的DOM树,然后再最终切换当前的Fiber树和计算好的新的Fiber树。

也就是下方的demo中,虽然在useEffect后重新setState了两次,但是整个过程却只render了2次。

import React, { useState, useEffect, useRef, useLayoutEffect } from "react";

export default function App() {
  const [showTitle, setShowTitle] = useState(false);
  const [showParagram, setShowParagram] = useState(false);

  const titleRef = useRef(null);
  const appRef = useRef(null);

  useEffect(() => {
    setShowTitle(true);
    setShowParagram(true);
  }, [])

  console.log("render"); // 一共render两次

  return (
    <div className="App" ref={appRef}>
      <h3>测试 useEffect执行 ref更新 demo!</h3>
      <button>button</button>
      {showTitle && <h2 ref={titleRef}>觉醒年代</h2>}
      {showParagram && <p ref={titleRef}>真好看</p>}
    </div>
  );
}

但是如果两次放到了setTimeout或者Promise.then回调里面,却一共渲染了3次:

import React, { useState, useEffect, useRef, useLayoutEffect } from "react";

export default function App() {
  const [showTitle, setShowTitle] = useState(false);
  const [showParagram, setShowParagram] = useState(false);

  const titleRef = useRef(null);
  const appRef = useRef(null);

  useEffect(() => {
    setTimeout(() => {
      setShowTitle(true);
      setShowParagram(true);
    })
  }, [])

  console.log("render"); // 一共render了三次

  return (
    <div className="App" ref={appRef}>
      <h3>测试 useEffect执行 ref更新 demo!</h3>
      <button>button</button>
      {showTitle && <h2 ref={titleRef}>觉醒年代</h2>}
      {showParagram && <p ref={titleRef}>真好看</p>}
    </div>
  );
}

React17及其之前的版本不会对类似setTimeout, Promise.then或者原生的事件回调里面的setState进行批量更新,至于为什么我觉得没有必要知道了,因为在前几天出来的React18的计划中The Plan for React 18, React官方已经说又要在这些本来没有批处理的地方又加上批处理了Automatic batching for fewer renders in React 18

React18重整批量更新

可以在codesandbox中更换下React18(需要使用ReactDOM.createRoot而不是ReactDOM.render),来看下效果:

import ReactDOM from "react-dom";

import App from "./App";

const rootElement = document.getElementById("root");
const root = ReactDOM.createRoot(rootElement);
root.render(<App />);

可以看到即使是放在setTimeout里面,也是一共只渲染了两次,进行了批量更新:

import React, { useState, useEffect, useRef, useLayoutEffect } from "react";

export default function App() {
  const [showTitle, setShowTitle] = useState(false);
  const [showParagram, setShowParagram] = useState(false);

  const titleRef = useRef(null);
  const appRef = useRef(null);

  useEffect(() => {
    setTimeout(() => {
      setShowTitle(true);
      setShowParagram(true);
    })
  }, [])

  console.log("render"); 

  return (
    <div className="App" ref={appRef}>
      <h3>测试 useEffect执行 ref更新 demo!</h3>
      <button>button</button>
      {showTitle && <h2 ref={titleRef}>觉醒年代</h2>}
      {showParagram && <p ref={titleRef}>真好看</p>}
    </div>
  );
}

那如果我们不想批量更新了怎么办? 使用ReactDOMflushSync模块:我们可以看到又渲染了三次了! demo地址

import React, { useState, useEffect, useRef, useLayoutEffect } from "react";
import { flushSync } from 'react-dom';

export default function App() {
  const [showTitle, setShowTitle] = useState(false);
  const [showParagram, setShowParagram] = useState(false);

  const titleRef = useRef(null);
  const appRef = useRef(null);

  useEffect(() => {
    setTimeout(() => {
      flushSync(() => setShowTitle(true));
      flushSync(() => setShowParagram(true));
    })
  }, [])

  console.log("render");

  return (
    <div className="App" ref={appRef}>
      <h3>测试 useEffect执行 ref更新 demo!</h3>
      <button>button</button>
      {showTitle && <h2 ref={titleRef}>觉醒年代</h2>}
      {showParagram && <p ref={titleRef}>真好看</p>}
    </div>
  );
}

最后

所以如果我想要在一个隐藏节点展示后去做一些副作用,比如Echarts组件拿到数据后要setOptions或者其他case: 那从最开始的demo里面我们可以看到,我们是可以在请求数据后直接在then回调里面获取到对于的Echarts instance的,伪代码如下:

import React, { useState, useEffect, useRef, useLayoutEffect } from "react";

export default function App() {
  const [showCharts, setShowCharts] = useState(false);
  const chartsRef = useRef(null);

  useEffect(() => {
    // api请求 
   axios.post(xxx).then((res) => {
      showCharts(true);
      chartsRef.current.getEchartsInstance().setOptions(res);
    })
  }, [])

  return (
    <div className="App">
      {showCharts && <EchartsComponent ref={chartsRef}>觉醒年代</EchartsComponent>}
    </div>
  );
}

那如果React18在这里做了批量更新(异步), 按照上面的思路,我们可以在setShowTitle(true)包装一层flushSync, 或者我们简单的使用showChartsstate当作一个新的useEffect的依赖,当showCharts=true的时候就是我们Echarts节点展示的时候我们再去获取他真实的DOM去操做的就可以了.

当然还有其他的方式就是callbackRef啦,相比于useRef(没有订阅的功能), callbackRef会在ref更新的时候再执行一遍回调

伪代码如下:

import React, { useState, useEffect, useRef, useLayoutEffect } from "react";

export default function App() {
  const [showCharts, setShowCharts] = useState(false);
  // ...
  
  const chartsRef = useCallback(node => {
    if (node !== null) {
      getEchartsInstance().setOptions(xxx);
    }
  }, []);

  return (
    <div className="App">
      {showCharts && <EchartsComponent ref={chartsRef}>觉醒年代</EchartsComponent>}
    </div>
  );
}

总结

编程总是一个探索的过程,好了,继续看《觉醒年代》了,如果有什么写的不对的也希望大家指正!

参考链接