從 React 源碼徹底搞懂 Ref 的全部 api

ref 是 React 裏常用的特性,我們會用它來拿到 dom 的引用。

它一般是這麼用的:

函數組件裏用 useRef:

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

export default function App() {
  const inputRef = useRef();

  useEffect(()={
    inputRef.current.focus();
  }[]);

  return <input ref={inputRef} type="text" />
}

class 組件裏用 createRef:

import React from "react";

export default class App  extends React.Component{
  constructor() {
    super();
    this.inputRef = React.createRef();
  }

  componentDidMount() {
    this.inputRef.current.focus();
  }

  render() {
    return <input ref={this.inputRef} type="text" />
  }
}

如果想轉發 ref 給父組件,可以用 forwardRef:

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

const ForwardRefMyInput = forwardRef(
  function(props, ref) {
    return <input {...props} ref={ref} type="text" />
  }
)

export default function App() {
  const inputRef = useRef();

  useEffect(() ={
    inputRef.current.focus();
  }[])

  return (
    <div class>
      <ForwardRefMyInput ref={inputRef} />
    </div>
  );
}

而且還可以使用 useImperativeHandle 自定義傳給父元素的 ref:

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

const ForwardRefMyInput = forwardRef(
  function(props, ref) {
    const inputRef = useRef();

    useImperativeHandle(ref, () ={
      return {
        aaa() {
          inputRef.current.focus();
        }
      }
    });

    return <input {...props} ref={inputRef} type="text" />
  }
)

export default function App() {
  const inputRef = useRef();

  useEffect(() ={
    inputRef.current.aaa();
  }[])

  return (
    <div class>
      <ForwardRefMyInput ref={inputRef} />
    </div>
  );
}

這就是我們平時用到的所有的 ref api 了。

小結一下:

相信開發 React 項目,大家或多或少會用到這些 api。

那這些 ref api 的實現原理是什麼呢?

下面我們就從源碼來探究下:

我們通過 jsx 寫的代碼,最終會編譯成 React.createElement 等 render function,執行之後產生 vdom:

所謂的 vdom 就是這樣的節點對象:

vdom 是一個 children 屬性連接起來的樹。

react 會先把它轉成 fiber 鏈表:

vdom 樹轉 fiber 鏈表樹的過程就叫做 reconcile,這個階段叫 render。

render 階段會從根組件開始 reconcile,根據不同的類型做不同的處理,拿到渲染的結果之後再進行 reconcileChildren,這個過程叫做 beginWork:

比如函數組件渲染完產生的 vom 會繼續 renconcileChildren:

beginWork 只負責渲染組件,然後繼續渲染 children,一層層的遞歸。

全部渲染完之後,會遞歸回來,這個階段會調用 completeWork:

這個階段會創建需要的 dom,然後記錄增刪改的 tag,同時也記錄下需要執行的其他副作用到 effect 鏈表裏。

之後 commit 階段纔會遍歷 effect 鏈表根據 tag 來執行增刪改 dom 等 effect。

commit 階段也分了三個小階段,beforeMutation、mutation、layout:

在源碼裏就是並排的 3 個 do while 循環:

它們都是消費的同一條 effect 鏈表,但是每個階段做的事情不同,所以上圖裏有 nextEffect = fistEffect 這一行,也就是每個階段處理完了,就讓下個階段從頭開始處理 effect。

mutation 階段會根據標記增刪改 dom,也就是這樣的:

所以這個階段叫做 mutation,它之前的一個階段叫做 beforeMutation,而它之後的階段叫做 layout。

小結下 react 的流程:

通過 jsx 寫的代碼會編譯成 render function,執行產生 vdom,也就是 React Element 對象的樹。

react 分爲 render 和 commit 兩個階段:

render 階段會遞歸做 vdom 轉 fiber,beginWork 裏遞歸進行 reconcile、reconcileChildren,completeWork 裏創建 dom,記錄增刪改等 tag 和其他 effect

commit 階段遍歷 effect 鏈表,做三輪處理,這三輪分別叫做 before mutation、mutation、layout,mutation 階段會根據 tag 做 dom 增刪改。

ref 的實現同樣是在這個流程裏的。

首先,我們 ref 屬性肯定是加在原生標籤上的,比如 input、div、p 這些,所以只要看 HostComponent 的分支就可以了,HostComponent 就是原生標籤。

可以看到處理原生標籤的 fiber 節點時,beginWork 裏會走到這個分支:

裏面調用 markRef 打了個標記:

前面說的 tag 就是指這個 flags。

在 completeWork 裏,判斷 flags 如果不是默認的,那就把這個 fiber 記錄到父節點的 firstEffect -> nextEffect -> nextEffect 這樣的鏈表裏:

這裏記錄到的是父組件的 effect 鏈表,那父組件又會記錄到它的父組件裏,這樣最終就在 root fiber 裏記錄了完整的 effect 鏈表。

然後就到了 commit 階段,開始處理這條 effect 鏈表:

你可以看到在 mutation 階段,操作 dom 之前,如果有 ref 標記,也就是會用到 ref,那就會 dettachRef 清空 ref。

之後在 layout 階段,這時候已經操作完 dom 了,就設置新的 ref:

ref 的元素就是在 fiber.stateNode 屬性上保存的在 render 階段就創建好了的 dom,:

這樣,在代碼裏的 ref.current 就能拿到這個元素了:

而且我們可以發現,他只是對 ref.current 做了賦值,並不管你是用 createRef 創建的、useRef 創建的,還是自己創建的一個普通對象。

我們試驗一下:

我創建了一個普通對象,current 屬性依然被賦值爲 input 元素。

那我們用 createRef、useRef 的意義是啥呢?

看下源碼就知道了:

createRef 也是創建了一個這樣的對象,只不過 Object.seal 了,不能增刪屬性。

用自己創建的對象其實也沒啥問題。

那 useRef 呢?

useRef 也是一樣的,只不過是保存在了 fiber 節點 hook 鏈表元素的 memoizedState 屬性上。

只是保存位置的不同,沒啥很大的區別。

同樣,用 forwardRef 轉發的 ref 也很容易理解,只是保存的位置變了,變成了從父組件傳過來的 ref:

那 forwardRef 是怎麼實現這個 ref 轉發的呢?

我們再看下源碼:

forwarRef 函數其實就是創建了個專門的 React Element 類型:

然後 beginWork 處理到這個類型的節點會做專門的處理:

也就是把它的 ref 傳遞給函數組件:

渲染函數組件的時候專門留了個後門來傳第二個參數:

所以函數組件裏就可以拿到 ref 參數了:

這樣就完成了 ref 從父組件到子組件的傳遞:

那 useImperativeHandle 是怎麼實現的修改 ref 的值呢?

源碼裏可以看到 useImperativeHandle 底層就是 useEffect,只不過是回調函數是把傳入的 ref 和 create 函數給 bind 到 imperativeHandleEffect 這個函數了:

而這個函數里就是更新 ref.current 的邏輯:

在 layout 階段會調用所有的生命週期函數,比如 class 組件的生命週期和 function 組件的 effect hook 的回調:

這裏就調用了 useImperativeHandle 的回調:

更新了 ref 的值:

hook 的 effect 和前面的處理 ref 的 effect 保存在不同的地方:

增刪改 dom、處理 ref 等這些 effect 是在 fistEffect、lastEffect、nextEffect 的鏈表裏:

而 hook 的 effect 保存在 updateQueue 裏:

小結下 ref 的實現原理:

beginWork 處理到原生標籤也就是 HostComponent 類型的時候,如果有 ref 屬性會在 flags 里加一個標記。

completeWork 處理 fiber 節點的時候,flags 不是默認值的 fiber 節點會被記錄到 effect 鏈表裏,通過 firstEffect、lastEffefct、nextEffect 來記錄這條鏈表。

commit 階段會處理 effect 鏈表,在 mutation 階段操作 dom 之前會清空 ref,在 layout 階段會設置 ref,也就是把 fiber.stateNode 賦值給 ref.current。

react 並不關心 ref 是哪裏創建的,用 createRef、useRef 創建的,或者 forwardRef 傳過來的都行,甚至普通對象也可以,createRef、useRef 只是把普通對象 Object.seal 了一下。

forwarRef 是創建了單獨的 vdom 類型,在 beginWork 處理到它的時候做了特殊處理,也就是把它的 ref 作爲第二個參數傳遞給了函數組件,這就是它 ref 轉發的原理。

useImperativeHandle 的底層實現就是 useEffect,只不過執行的函數是它指定的,bind 了傳入的 ref 和 create 函數,這樣在 layout 階段調用 hook 的 effect 函數的時候就可以更新 ref 了。

總結

我們平時會用到 createRef、useRef、forwardRef、useImperativeHandle 這些 api,而理解它們的原理需要熟悉 react 的運行流程,也就是 render(beginWork、completeWork) + commit(before mutation、mutation、layout)的流程。

從底層原理來說,更新 ref 有兩種方式:

這兩種 effect 保存的位置不一樣,ref 的 effect 是記錄在 fistEffect、nextEffect、lastEffect 鏈表裏的,而 hooks 的 effect 是記錄在 updateQueue 裏的。

理解了 react 運行流程,包括普通 effect 的流程和 hook 的 effect 的流程,就能徹底理解 React ref 的實現原理。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/KnFTafpmvr7u8PUoCZwTTA