React新文件:不要濫用Ref哦~

卡頌 發表於 2022-06-16
React

大家好,我卡頌。

React新文件有個很有意思的細節:useRefuseEffect這兩個API的介紹,在文件中所在的章節叫Escape Hatches(逃生艙)。

顯然,正常航行時是不需要逃生艙的,只有在遇到危險時會用到。

如果開發者過多依賴這兩個API,可能是誤用。

React新文件:不要濫用effect哦中我們談到useEffect的正確使用場景。

今天,我們來聊聊Ref的使用場景。

歡迎加入人類高質量前端框架群,帶飛

為什麼是逃生艙?

先思考一個問題:為什麼refeffect被歸類到逃生艙中?

這是因為二者操作的都是脫離React控制的因素

effect中處理的是副作用。比如:在useEffect中修改了document.title

document.title不屬於React中的狀態,React無法感知他的變化,所以被歸類到effect中。

同樣,使DOM聚焦需要呼叫element.focus(),直接執行DOM API也是不受React控制的。

雖然他們是脫離React控制的因素,但為了保證應用的健壯,React也要儘可能防止他們失控。

失控的Ref

對於Ref,什麼叫失控呢?

首先來看不失控的情況:

  • 執行ref.currentfocusblur等方法
  • 執行ref.current.scrollIntoView使element滾動到視野內
  • 執行ref.current.getBoundingClientRect測量DOM尺寸

這些情況下,雖然我們操作了DOM,但涉及的都是React控制範圍外的因素,所以不算失控。

但是下面的情況:

  • 執行ref.current.remove移除DOM
  • 執行ref.current.appendChild插入子節點

同樣是操作DOM,但這些屬於React控制範圍內的因素,通過ref執行這些操作就屬於失控的情況。

舉個例子,下面是React文件中的例子

按鈕1點選後會插入/移除 P節點,按鈕2點選後會呼叫DOM API移除P節點:

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

  return (
    <div>
      <button
        onClick={() => {
          setShow(!show);
        }}>
        Toggle with setState
      </button>
      <button
        onClick={() => {
          ref.current.remove();
        }}>
        Remove from the DOM
      </button>
      {show && <p ref={ref}>Hello world</p>}
    </div>
  );
}

按鈕1通過React控制的方式移除P節點。

按鈕2直接操作DOM移除P節點。

如果這兩種移除P節點的方式混用,那麼先點選按鈕1再點選按鈕2就會報錯:

React新文件:不要濫用Ref哦~

這就是使用Ref操作DOM造成的失控情況導致的。

如何限制失控

現在問題來了,既然叫失控了,那就是React沒法控制的(React總不能限制開發者不能使用DOM API吧?),那如何限制失控呢?

React中,元件可以分為:

  • 高階元件
  • 低階元件

低階元件指那些基於DOM封裝的元件,比如下面的元件,直接基於input節點封裝:

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

低階元件中,是可以直接將ref指向DOM的,比如:

function MyInput(props) {
  const ref = useRef(null);
  return <input ref={ref} {...props} />;
}

高階元件指那些基於低階元件封裝的元件,比如下面的Form元件,基於Input元件封裝:

function Form() {
  return (
    <>
      <MyInput/>
    </>
  )
}

高階元件無法直接將ref指向DOM,這一限制就將ref失控的範圍控制在單個元件內,不會出現跨越元件的ref失控

文件中的示例為例,如果我們想在Form元件中點選按鈕,操作input聚焦:

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

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

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

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

點選後,會報錯:

React新文件:不要濫用Ref哦~

這是因為在Form元件中向MyInput傳遞ref失敗了,inputRef.current並沒有指向input節點。

究其原因,就是上面說的為了將ref失控的範圍控制在單個元件內,React預設情況下不支援跨元件傳遞ref

人為取消限制

如果一定要取消這個限制,可以使用forwardRef API顯式傳遞ref

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

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

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

  return (
    <>
      <MyInput ref={inputRef} />
      <button onClick={handleClick}>
        Focus the input
      </button>
    </>
  );
}

使用forwardRefforward在這裡是傳遞的意思)後,就能跨元件傳遞ref

在例子中,我們將inputRefForm跨元件傳遞到MyInput中,並與input產生關聯。

在實踐中,一些同學可能覺得forwardRef這一API有些多此一舉。

但從ref失控的角度看,forwardRef的意圖就很明顯了:既然開發者手動呼叫forwardRef破除防止ref失控的限制,那他應該知道自己在做什麼,也應該自己承擔相應的風險。

同時,有了forwardRef的存在,發生ref相關錯誤後也更容易定位錯誤。

useImperativeHandle

除了限制跨元件傳遞ref外,還有一種防止ref失控的措施,那就是useImperativeHandle,他的邏輯是這樣的:

既然ref失控是由於使用了不該被使用的DOM方法(比如appendChild),那我可以限制ref中只存在可以被使用的方法

useImperativeHandle修改我們的MyInput元件:

const MyInput = forwardRef((props, ref) => {
  const realInputRef = useRef(null);
  useImperativeHandle(ref, () => ({
    focus() {
      realInputRef.current.focus();
    },
  }));
  return <input {...props} ref={realInputRef} />;
});

現在,Form元件中通過inputRef.current只能取到如下資料結構:

{
  focus() {
    realInputRef.current.focus();
  },
}

就杜絕了開發者通過ref取到DOM後,執行不該被使用的API,出現ref失控的情況。

總結

正常情況,Ref的使用比較少,他是作為逃生艙而存在的。

為了防止錯用/濫用導致ref失控React限制預設情況下,不能跨元件傳遞ref

為了破除這種限制,可以使用forwardRef

為了減少refDOM的濫用,可以使用useImperativeHandle限制ref傳遞的資料結構。