概述
上文我們聊了useRef
的使用和實現,主要兩個用途:1、用于持久化保存 2、用于綁定dom
。 但是有時候我們需要在父組件中訪問子組件的dom或者屬性/方法,而React中默認是不允許父組件直接訪問子組件的dom的
,這時候就可以通過forwardRef將ref傳入子組件,并暴露子組件的dom給父組件使用,但是這種方式直接暴露了子組件的dom,處于安全性能考慮,我們希望子組件只暴露我們所希望的屬性,由子組件自己決定暴露什么,這個就需要使用到useImperativeHandle來處理。 基于這些使用場景,所以本文主要從基本使用和源碼實現兩方面來介紹下forwardRef、useImperativeHandle
這兩個API。
基本使用
本小節主要介紹這兩個API在Function Component中的使用,已經熟悉APi的同學可以跳過該部分,直接查看源碼解析部分。
forwardRef
forwardRef主要解決的是從父組件傳遞ref到子組件的問題,定義如下:
const SomeComponent = forwardRef(render)
- render:組件的渲染函數。React 會調用該函數并傳入父組件傳遞的 props 和 ref。返回的 JSX 將作為組件的輸出。
- 返回一個可以在 JSX 中渲染的 React 組件。與作為純函數定義的 React 組件不同,forwardRef 返回的組件還能夠接收 ref 屬性。
forwardRef接收一個渲染函數,然后返回一個可在JSX中渲染的組件。一般用于包裹子組件,用于ref傳遞,將子組件綁定的ref通過第二個參數傳入,并綁定到子組件dom節點暴露。可以這樣理解forwardRef 是一個接收render函數作為參數的高階函數
如下demo在子組件中暴露了input
組件,使父組件可以訪問并進行例如獲取焦點等dom操作
const MyInput = forwardRef(function MyInput(props, ref) {return (<label>{props.label}<input ref={ref} /></label>);
});
進過forwardRef包裹之后,父組件就可以通過ref來訪問子組件中暴露的dom節點,但是有時候我們希望自己控制暴露哪些屬性,尤其是當作為公共組件被多方調用的時候,這時候就需要通過useImperativeHandle
來實現自定義暴露屬性
forwardRef并不是一個Hook,這里主要是作為介紹useImperativeHandle的媒介,通常是將這兩個Api連用,所以這里一起簡單介紹下。所有的Hook都是用use開頭命名的
useImperativeHandle
useImperativeHandle 是 React 中的一個 Hook,它能讓你自定義由 ref 暴露出來的句柄。
useImperativeHandle(ref, createHandle, dependencies?)
- ref:該 ref 是你從 forwardRef 渲染函數 中獲得的第二個參數。
- createHandle:該函數無需參數,它返回你想要暴露的 ref 的句柄。該句柄可以包含任何類型。通常,你會返回一個包含你想暴露的方法的對象。
- dependencies:可選參數,作為函數 createHandle的依賴收集。React 會使用 Object.is 來比較每一個依賴項與其對應的之前值。如果該數組值發生改變或者數組為空數組,則會重新執行createHandle并將新的對象綁定到ref。
一般是將forwardRef和useImperativeHandle一起用,通過useImperativeHandle暴露指定屬性,然后父組件可以通過forwardRef注入的ref來進行訪問
。舉例來說,假設你不想暴露出整個 DOM 節點,但你想要它其中兩個方法:focus 和 scrollIntoView。為此,用單獨額外的 ref 來指向真實的瀏覽器 DOM。然后使用 useImperativeHandle 來暴露一個句柄,它只返回你想要父組件去調用的方法:
import { forwardRef, useRef, useImperativeHandle } from 'react';const MyInput = forwardRef(function MyInput(props, ref) {const inputRef = useRef(null);useImperativeHandle(ref, () => {return {focus() {inputRef.current.focus();},scrollIntoView() {inputRef.current.scrollIntoView();},};}, []);return <input {...props} ref={inputRef} />;
});
在上述代碼中,該 ref 已不再被轉發到<input>
中,而是傳入到了useImperativeHandle,可以理解為useImperativeHandle將ref進行了劫持并將暴露的屬性綁定到ref.current上
。而且我們也可以自定義返回的數據,比如正常在父組件是通過ref.current
訪問,這個可以自定義為ref.xxx
import { forwardRef, useRef, useImperativeHandle } from 'react';const MyInput = forwardRef(function MyInput(props, ref) {const inputRef = useRef(null);useImperativeHandle((createResult) => {ref['xxx'] = createResult;return ref}, () => {return {focus() {inputRef.current.focus();},scrollIntoView() {inputRef.current.scrollIntoView();},};}, []);return <input {...props} ref={inputRef} />;
});
這是由于在源碼實現中對于傳遞對象類型和函數類型的ref處理是不一樣的,詳情可以查看下面源碼解析。
源碼解析
上面我們介紹了這兩個API的基本語法和使用場景,下面我們將從源碼的角度,一步一步分析其內部是如何實現的。
主要涉及文件如下:
- 代碼入口文件路徑:
https://github.com/facebook/react/blob/main/packages/react/src/ReactHooks.js
- 執行代碼文件路徑:
https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberHooks.js
- forwardRef文件路徑:
https://github.com/facebook/react/blob/main/packages/react/src/ReactForwardRef.js
- beginWork函數路徑:
https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberBeginWork.js
下面的都是基于生產環境下的代碼分析,以及會省略與本次解釋無關的代碼,完整代碼可以根據以上路徑前往官網github查看。
forwardRef
從代碼中能看出,在生產環境下,調用forwardRef包裹render函數之后,會將該render函數打上標簽$$typeof
用于React區分當前組件是什么類型并進行不同的處理。
$$typeof: 這個屬性是一個符號常量,用來標識這是一個 forwardRef 組件類型。在 React 內部,這個符號被用來區分不同類型的 React 元素,例如函數組件、類組件、片段(fragment)等。REACT_FORWARD_REF_TYPE 的值是一個獨特的符號,確保了它在 React 內部可以被正確識別。
export function forwardRef<Props, ElementType: React$ElementType>(render: (props: Props, ref: React$Ref<ElementType>) => React$Node
) {const elementType = {$$typeof: REACT_FORWARD_REF_TYPE,render,};return elementType;
}
當完成標記之后,后續會進入到Recondiler協調器中進行fiber構造,其中會經歷beginWork階段對JSX代碼進行處理并生成Fiber節點。在這個階段會根據tag
來對不同組件進行處理,這里就是ForwardRef
類型
function beginWork(current, workInProgress, renderLanes) {// ...switch (workInProgress.tag) {// ...case ForwardRef:const type = workInProgress.type;const unresolvedProps = workInProgress.pendingProps;const resolvedProps =disableDefaultPropsExceptForClasses ||workInProgress.elementType === type? unresolvedProps: resolveDefaultPropsOnNonClassComponent(type, unresolvedProps);return updateForwardRef(current, // 當前頁面顯示的fiber樹workInProgress, // 內存中構建的fiber樹type, // fiber類型,即ForwardRef返回的elementTyperesolvedProps, // 傳遞給組件的屬性集合renderLanes // 優先級);// ...}
}
參數介紹如下:
- current: 當前頁面顯示的舊的fiber節點
- workInProgress: 內存中構建的新的fiber節點
- type: fiber類型,即通過ForwardRef創建的elementType對象,包含
$$typeof、render
- resolvedProps: 傳遞給組件的屬性集合
- renderLanes: 渲染優先級
調用renderWithHooks
處理 hooks 邏輯,并調用實際的 render 函數,傳遞 nextProps 和 ref
function updateForwardRef(current, workInProgress, Component, nextProps, renderLanes) {const render = Component.render; // 獲取 render 函數const ref = workInProgress.ref; // 獲取 reflet nextChildren;// 調用 renderWithHooks 處理 hooks 邏輯并調用 render 函數nextChildren = renderWithHooks(current, workInProgress, render, nextProps, ref, renderLanes);workInProgress.flags |= PerformedWork;// 調用 reconcileChildren 處理子節點協調reconcileChildren(current, workInProgress, nextChildren, renderLanes);return workInProgress.child;
}
function renderWithHooks(current, workInProgress, Component, props, secondArg, nextRenderLanes) {// ...const children = Component(props, secondArg);// ...return children;
}
從以上代碼能看出,forWardRef原理就是: 給傳遞的render函數打上ForWardRef($$typeof
)的標簽,讓React知道當前組件的類型,然后在beginWork階段會根據這個類型處理,將render和ref解析處理之后將ref作為render的第二個參數傳入即const children = Component(props, secondArg);
useImperativeHandle
該Hook提供了子組件自定義暴露的屬性方法的能力,同其他Hook一樣,本文也從首次渲染、更新渲染兩個方便來說明其實現。
由于本文分成了Mount、Update兩個部分介紹,所以這里簡單介紹下兩者區別:
- Mount 階段:在組件的初始掛載(第一次渲染)階段。主要負責初始化 Hook 的狀態和隊列,并建立 Hook 之間的鏈式關系。
- Update 階段:組件在其 props 或 state 改變后被重新渲染。主要負責處理狀態更新,將新的狀態應用到 memoizedState 中,以便下一次渲染使用,并更新 Hook 鏈表(復用/克隆現有Hook)及其更新隊列updateQueue。
mount首次渲染
雖然我們在使用時只是useImperativeHandle
函數,但是在React內部通過dispatcher
進行了派發,在mount階段執行的mountImperativeHandle
函數
function mountImperativeHandle<T>(ref: { current: T | null } | ((inst: T | null) => mixed) | null | void,create: () => T,deps: Array<mixed> | void | null
): void {// TODO: If deps are provided, should we skip comparing the ref itself?const effectDeps =deps !== null && deps !== undefined ? deps.concat([ref]) : null;let fiberFlags: Flags = UpdateEffect | LayoutStaticEffect;mountEffectImpl(fiberFlags,HookLayout,imperativeHandleEffect.bind(null, create, ref),effectDeps);
}
mountImperativeHandle
函數作為入口函數,主要就是調用mountEffectImpl
創建副作用:
- 獲取依賴,并將ref(第一個參數)作為依賴添加,以便后面對比判斷是否更新
- 獲取Flag標識當前副作用
- 通過bind綁定
imperativeHandleEffect
,然后調用mountEffectImpl
創建副作用
上面知道我們傳入的第二個參數即create函數,在調用時實際執行的imperativeHandleEffect
函數來對ref進行處理,其中該函數邏輯如下:
function imperativeHandleEffect<T>(create: () => T,ref: { current: T | null } | ((inst: T | null) => mixed) | null | void
): void | (() => void) {if (typeof ref === "function") {const refCallback = ref;const inst = create(); // 創建實例const refCleanup = refCallback(inst); // 執行 refCallback 并返回結果return () => {if (typeof refCleanup === "function") {refCleanup(); // 如果 refCleanup 是函數,則調用它} else {refCallback(null); // 否則調用 refCallback(null) 清除引用}};} else if (ref !== null && ref !== undefined) {const refObject = ref;const inst = create();refObject.current = inst;return () => {refObject.current = null;};}
}
從代碼能看出來,由于ref可以是對象、或者函數,所以這里進行了差別處理。當為對象時,會將暴露的對象綁定在current中,即可以通過ref.current來訪問暴露的屬性,然后會返回一個清除函數在組件卸載時會調用,來清除引用便于垃圾收回。當ref是函數時,會講create執行結果作為入參傳遞給ref函數,然后自行處理(通過ref.current不能訪問),根據返回的值是否是函數判斷進一步處理清除函數,方便垃圾回收。
mountEffectImpl
函數如下:
function mountEffectImpl(fiberFlags: Flags,hookFlags: HookFlags,create: () => (() => void) | void,deps: Array<mixed> | void | null
): void {const hook = mountWorkInProgressHook();const nextDeps = deps === undefined ? null : deps;currentlyRenderingFiber.flags |= fiberFlags;hook.memoizedState = pushEffect(HookHasEffect | hookFlags,create,createEffectInstance(),nextDeps);
}
主要就是通過mountWorkInProgressHook
基于當前fiber創建一個初始化hook,然后將依賴和create傳入pushEffect處理副作用列表。
pushEffect
函數如下:
function pushEffect(tag: HookFlags,create: () => (() => void) | void,inst: EffectInstance,deps: Array<mixed> | null
): Effect {const effect: Effect = {tag,create,inst,deps,// Circularnext: (null: any),};let componentUpdateQueue: null | FunctionComponentUpdateQueue =(currentlyRenderingFiber.updateQueue: any);// 首次渲染時 為nullif (componentUpdateQueue === null) {componentUpdateQueue = createFunctionComponentUpdateQueue();currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any);componentUpdateQueue.lastEffect = effect.next = effect;} else {const lastEffect = componentUpdateQueue.lastEffect;if (lastEffect === null) {componentUpdateQueue.lastEffect = effect.next = effect;} else {const firstEffect = lastEffect.next;lastEffect.next = effect;effect.next = firstEffect;componentUpdateQueue.lastEffect = effect;}}return effect;
}createFunctionComponentUpdateQueue = () => {return {lastEffect: null,events: null,stores: null,memoCache: null,};
};
主要邏輯就是根據當前配置創建effect副作用,并將其添加到更新隊列updateQueue中。在代碼中通過判斷 currentlyRenderingFiber.updateQueue是否為null來判斷當前是否有其他的更新任務,如果沒有則通過createFunctionComponentUpdateQueue
創建初始更新隊列,反之則直接添加到鏈表尾部。
updateQueue更新隊列也是是通過lastEffect指向尾節點的循環鏈表,可以更好的進行插入和快速找到頭節點
至此我們介紹了在mount階段依次調用的函數鏈, mountImperativeHandle - mountEffectImpl - mountWorkInProgressHook - pushEffect
最終初始化構建了從fiber到更新的鏈式關系。其中本次需要更新的狀態保存在updateQueue中,而memoizedState中保存的是上一次渲染更新的狀態,為了方便狀態的追蹤和新狀態的基準值。
Update更新渲染
在這里先介紹下函數調用關系,然后再針對該調用鏈以此介紹。通過dispatcher派發之后函數調用如下:updateImperativeHandle - updateEffectImpl - imperativeHandleEffect - updateWorkInProgressHook - pushEffect
其中 imperativeHandleEffect
和pushEffect
在Mount階段已經講過,所以這里就跳過,主要介紹其他函數。
updateImperativeHandle
函數
function updateImperativeHandle<T>(ref: { current: T | null } | ((inst: T | null) => mixed) | null | void,create: () => T,deps: Array<mixed> | void | null
): void {// TODO: If deps are provided, should we skip comparing the ref itself?const effectDeps =deps !== null && deps !== undefined ? deps.concat([ref]) : null;updateEffectImpl(UpdateEffect,HookLayout,imperativeHandleEffect.bind(null, create, ref),effectDeps);
}
從代碼能看出該函數主要工作就是調用updateEffectImpl來處理副作用:
- 獲取deps依賴數組,并將ref(第一個參數)作為依賴添加
- 綁定
imperativeHandleEffect
處理ref,并調用updateEffectImpl
更新副作用列表
updateEffectImpl
函數:
function updateEffectImpl(fiberFlags: Flags,hookFlags: HookFlags,create: () => (() => void) | void,deps: Array<mixed> | void | null
): void {const hook = updateWorkInProgressHook();const nextDeps = deps === undefined ? null : deps;const effect: Effect = hook.memoizedState;const inst = effect.inst;// currentHook is null on initial mount when rerendering after a render phase// state update or for strict mode.// 如果 currentHook 存在,表示這是一個更新操作,否則是一個初始化操作。if (currentHook !== null) {if (nextDeps !== null) {const prevEffect: Effect = currentHook.memoizedState;const prevDeps = prevEffect.deps;if (areHookInputsEqual(nextDeps, prevDeps)) {// 依賴沒有變化,則傳入hookFlags,不需要更新hook.memoizedState = pushEffect(hookFlags, create, inst, nextDeps);return;}}}// 通過位或運算,更新flag表示當前fiber需要更新currentlyRenderingFiber.flags |= fiberFlags;hook.memoizedState = pushEffect(HookHasEffect | hookFlags,create,inst,nextDeps);
}// 通過for循環遍歷依賴數組,然后通過Object.is判斷是否變化
function areHookInputsEqual(nextDeps: Array<mixed>,prevDeps: Array<mixed> | null,): boolean {if (prevDeps === null) {return false;}// $FlowFixMe[incompatible-use] found when upgrading Flowfor (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {// $FlowFixMe[incompatible-use] found when upgrading Flowif (is(nextDeps[i], prevDeps[i])) {continue;}return false;}return true;}
該函數主要功能就是創建更新任務然后添加到Hook中,并對比deps是否變化來決定是否觸發更新,最后更新memoizedState緩存狀態。
- 通過
updateWorkInProgressHook
函數復用Hook并添加更新任務 - 通過
areHookInputsEqual
對比依賴變化,通過傳入Flag來判斷是否跳過更新 - 通過
pushEffect
添加副作用,并更新memoizedState
緩存值
updateWorkInProgressHook函數在前面文章已經介紹過,主要就是復用Hook鏈表,優先復用workInProgress中的Hook,沒有則克隆當前頁面顯示的current Hook 詳情可以查看這篇文章:【React Hooks原理 - useState】
Hook數據結構中和fiber數據結構中都有memoizedState字段,但是表達的意義不同,Hook中是作為緩存的state值,但是fiber中是指向的當前fiber下的hooks隊列的首個hook(hook是鏈表結構,指向首個,就意味著可以訪問整個hooks隊列)
至此Mount階段和Update階段就介紹完了,總的來說就是在Mount階段進行初始化,在Update階段創建更新任務添加到更新列表,等待Scheduler調度更新。
總結
總的來說React默認不允許父組件訪問子組件中的DOM,所以需要通過forwardRef來將ref注入到子組件中,通過在子組件中綁定dom來讓父組件訪問。但是我們又想自定義暴露哪些屬性,所以需要useImperativeHandle
這個Hook來幫助完成。
forwardRef的本質就是返回一個帶有特定標識符$$typeof
的對象,React根據這個表示知道當前組件是ForWardRef類型,則會在執行函數組件渲染時將ref作為第二個參數傳入即Component(props, ref)
useImperativeHandle可以理解為這個Hook是對forwardRef傳入的ref進行了攔截,根據不同數據類型的ref做了不同處理,對于對象類型,直接將暴露的對象綁定到ref.current中(因為ref是通過useRef創建,默認會帶有current屬性),而函數類型則將暴露的對象作為ref函數的入參由開發者自行控制。所以ref是對象時,父組件可以通過ref.curren訪問,而ref是函數時則需要根據設置訪問,此時ref.current === null
。