React傳-3

大Y發表於2019-11-22

原文地址

本節是Hook專題,將從 preact 借鑑 Hook 的底層原理實現,雖然實際上 preact 與 react 的 實現有所差異,但是勝在簡單,瞭解瞭解思路邏輯也是可以的嘛。

Hooks

目前react內建了13種hooks

import {
  useCallback, // ---- 快取函式
  useMemo,    // ---- 快取函式
  useContext,  // ---- 上下文共享狀態 hook
  useEffect,   // ---- 副作用
  useLayoutEffect, // ---- 副作用(阻塞)
  useImperativeHandle,// ---- 暴露子元件命令控制程式碼
  useDebugValue, // ---- 除錯hooks
  useReducer, // ---- action hook
  useRef,     // ---- ref引用
  useState,   // ---- state Hook
  useResponder,
  useTransition,
  useDeferredValue,
} from './ReactHooks';
import {withSuspenseConfig} from './ReactBatchConfig';

if (exposeConcurrentModeAPIs /* false */) {
  React.useTransition = useTransition;
  React.useDeferredValue = useDeferredValue;
  React.SuspenseList = REACT_SUSPENSE_LIST_TYPE;
  React.unstable_withSuspenseConfig = withSuspenseConfig;
}

if (enableFlareAPI/* false */) {
  React.unstable_useResponder = useResponder;
  React.unstable_createResponder = createResponder;
}

複製程式碼

最後3個 Hook 尚處於 unstable ,需要等到支援conCurrentMode,這裡就不去贅述。

export function useState<S>(initialState: (() => S) | S) {
  const dispatcher = resolveDispatcher();
  return dispatcher.useState(initialState);
}

export function useEffect(
  create: () => (() => void) | void,
  inputs: Array<mixed> | void | null,
) {
  const dispatcher = resolveDispatcher();
  return dispatcher.useEffect(create, inputs);
}
export function useRef<T>(initialValue: T): {current: T} {
  const dispatcher = resolveDispatcher();
  return dispatcher.useRef(initialValue);
}
複製程式碼

從最常用的useState、useEffect、useRef原始碼,可以看到幾乎都和 resolveDispatcher 函式有關。

function resolveDispatcher() {
  const dispatcher = ReactCurrentDispatcher.current;
  invariant(
    dispatcher !== null,
    'Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for' +
      ' one of the following reasons:\n' +
      '1. You might have mismatching versions of React and the renderer (such as React DOM)\n' +
      '2. You might be breaking the Rules of Hooks\n' +
      '3. You might have more than one copy of React in the same app\n' +
      'See https://fb.me/react-invalid-hook-call for tips about how to debug and fix this problem.',
  );
  return dispatcher;
}
複製程式碼

都知道 Hooks 的三條鐵則,這些方法只會在拿到節點例項的時候觸發執行,為了適配多平臺ReactCurrentDispatcher 實際上需要等到 react-dom 渲染的時候才能拿到。

/**
 * Keeps track of the current dispatcher.
 */
const ReactCurrentDispatcher = {
  /**
   * @internal
   * @type {ReactComponent}
   */
  current: (null: null | Dispatcher),
};
複製程式碼

光看這些得不到什麼比較有效的資訊,但本質上是將節點例項返回後呼叫該節點例項上的對應方法。

原理探究

function Foo() {
  const [str, setStr] = useState('');
  const change = useCallback((e)=>{
    setStr(e.target.value)
  },[])
  
  useEffect(()=>{
    console.log('effect')
    return () => {
      console.log('effect clean')
    };
  },[Math.random()])

  useLayoutEffect(() => {
    console.log('layoutEffect')
    return () => {
      console.log('layoutEffect clean')
    };
  }, [str])

  return (
    <input value={str} onChange={change} />
  )
}
複製程式碼

一個簡單的Hook元件,可能會有個疑問,Hooks 是針對 Function Component 設計的Api,從而賦予 Function Component 擁有與類元件同樣儲存狀態的能力。為什麼不會被例項化還能夠擁有狀態,是怎麼做到的?

其實Hook都依賴了閉包,而hook之間依靠單向連結串列的方式串聯,從而擁有了“狀態”,這也是之所以為什麼Hooks必須在函式作用域的最頂層宣告且不能巢狀在塊級作用域內,如果在某個迴圈或者是表示式內跳過執行,那麼上一次的Hook“連結串列”和本次update的連結串列某個指標指向錯誤,將會得到意料之外的結果。

可以借鑑下preact的實現,與React不同,preact使用的是下標索引。

// 初始化時,只有一個catchError屬性
import { options } from 'preact';

let currentIndex; // 當前hook索引
let currentComponent; // 當前元件
let afterPaintEffects = []; 

// 儲存舊方法,初始為 undefined
let oldBeforeRender = options._render;
let oldAfterDiff = options.diffed;
let oldCommit = options._commit;
let oldBeforeUnmount = options.unmount;

/**
 * currentComponent get hook state
 * @param {number} index The index of the hook to get
 * @returns {import('./internal').HookState}
 */
function getHookState(index) {
    if (options._hook) options._hook(currentComponent);

    const hooks = currentComponent.__hooks ||  (currentComponent.__hooks = {
        _list: [], // 放置effect的狀態
        _pendingEffects: [], // 渲染下一幀後要呼叫的effect佇列
      });
    // 新建effect
    if (index >= hooks._list.length) {
      hooks._list.push({});
    }
    return hooks._list[index];
}
複製程式碼

通過 getHookState 收集管理Effect,即便沒有例項化其本質上是函式每次都會重新執行。通過比較依賴值結果來決定邏輯更新,從這點上看getHookState是一個元件的核心管理器。需要注意的是 _pendingEffect 放入的是不阻塞頁面渲染的 effect 操作,也就是useEffect。

export interface ComponentHooks {
  _list: HookState[];
  _pendingEffects: EffectHookState[];
}

export interface Component extends PreactComponent<any, any> {
  __hooks?: ComponentHooks;
}
複製程式碼

Hook元件與類元件差不多,只不過多了一個__hooks屬性 —— hooks管理器。

useState 與 useReducer

匆匆一瞥:

/**
 * @param {import('./index'). StateUpdater<any>} initialState
 */
export function useState(initialState) {
  return useReducer(invokeOrReturn, initialState);
}
/**
 * @param {import('./index').Reducer<any, any>} reducer
 * @param {import('./index').StateUpdater<any>} initialState
 * @param {(initialState: any) => void} [init]
 * @returns {[ any, (state: any) => void ]}
 */
export function useReducer(reducer, initialState, init) {
  /** @type {import('./internal').ReducerHookState} */
  const hookState = getHookState(currentIndex++);
  if (!hookState._component) {
    hookState._component = currentComponent;

    hookState._value = [
      !init ? invokeOrReturn(undefined, initialState) : init(initialState),

      action => {
        const nextValue = reducer(hookState._value[0], action);
        if (hookState._value[0] !== nextValue) {
          hookState._value[0] = nextValue;
          hookState._component.setState({});
        }
      }
    ];
  }

  return hookState._value;
}
複製程式碼

在 preact 裡 useState 與useReducer是一碼事。也可以使用useState定義useReducer。

function useMyReducer(reducer, initialState, init) {
  const compatible = init ? init(initialState) : initialState;
  const [state, setState] = useState(compatible);
  function dispatch(action) {
    const nextState = reducer(state, action);
    setState(nextState);
  }

  return [state, dispatch];
}
複製程式碼

在Foo元件effect收集階段,useState呼叫useReducer傳入加工函式invokeOrReturn作為reducer傳入。

function invokeOrReturn(arg, f) {
  return typeof f === 'function' ? f(arg) : f;
}
複製程式碼

通過getHookState在當前元件申明一個新的hooks,放入currentComponent.__hooks._list然後將其返回。hookState暫時只是個空物件,當它沒有關聯元件時需要對其進行當前元件的關聯。

export function useReducer(reducer, initialState, init) {
  const hookState = getHookState(currentIndex++);// 建立hook
  if (!hookState._component) {
    hookState._component = currentComponent; // 關聯到當前元件

    hookState._value = [
      !init ? invokeOrReturn(undefined, initialState) : init(initialState),// 初始值

      action => {
        // action 即setStr更新器的引數
        const nextValue = reducer(hookState._value[0], action);
        if (hookState._value[0] !== nextValue) {
          hookState._value[0] = nextValue;
          hookState._component.setState({});// 再通過類元件的setState去通知更新
        }
      }
    ];
  }

  return hookState._value;
}
// ./internal.ts
export interface ReducerHookState {
  _value?: any; // 值與更新器
  _component?: Component; // 關聯元件
}
複製程式碼

hookState._value 返回的即是平常所用的 const [str, setStr] = useState('');,值與更新器。 hookState._component 就是一個簡單的無狀態元件,但是React底層仍然是通過呼叫setState觸發enqueueRender進行diff更新。

這些後面再寫...因為確實很難簡短描述。

useEffect 與 useLayoutEffect

/**
 * @param {any[]} oldArgs
 * @param {any[]} newArgs
 */
function argsChanged(oldArgs, newArgs) { // 比對新舊依賴
  return !oldArgs || newArgs.some((arg, index) => arg !== oldArgs[index]);
}

/**
 * @param {import('./internal').Effect} callback
 * @param {any[]} args 依賴
 */
export function useEffect(callback, args) {
  /** @type {import('./internal').EffectHookState} */
  const state = getHookState(currentIndex++);
  if (argsChanged(state._args, args)) { // 比對依賴決定是否執行
    state._value = callback;
    state._args = args;
    // 推入 effect 佇列
    currentComponent.__hooks._pendingEffects.push(state);
  }
}

/**
 * @param {import('./internal').Effect} callback
 * @param {any[]} args
 */
export function useLayoutEffect(callback, args) {
  /** @type {import('./internal').EffectHookState} */
  const state = getHookState(currentIndex++);
  if (argsChanged(state._args, args)) {
    state._value = callback;
    state._args = args;
    // 推入元件render回撥佇列
    currentComponent._renderCallbacks.push(state);
  }
}

// ./internal.ts
export interface EffectHookState {
  _value?: Effect; // 回撥函式
  _args?: any[]; // 依賴項
  _cleanup?: Cleanup; // 清理函式
}

複製程式碼

React傳-3

useEffect 與 useLayoutEffect 唯一不同的是在於推入的佇列以及執行的時機,前面講到過,__hooks._pendingEffects 佇列執行的時機是下一幀繪製前執行(本次render後,下次render前),不阻塞本次的瀏覽器渲染。而 _renderCallbacks 則在元件commit鉤子內執行

元件render的流程是怎樣的?還有是怎麼進行比對和派發更新的。

在 Function Component中 除去 vnode 階段外,元件自身有四個鉤子階段,也就是 render=>diffed=>commit=>unmount

options._render = vnode => {
  if (oldBeforeRender) oldBeforeRender(vnode);

  currentComponent = vnode._component; // 當前元件
  currentIndex = 0;

  if (currentComponent.__hooks) {
    // 先執行清理函式
    currentComponent.__hooks._pendingEffects.forEach(invokeCleanup);
    // 清空上次渲染未處理的Effect(useEffect)
    currentComponent.__hooks._pendingEffects.forEach(invokeEffect);
    currentComponent.__hooks._pendingEffects = [];
  }
};

options.diffed = vnode => {
  if (oldAfterDiff) oldAfterDiff(vnode);

  const c = vnode._component;
  if (!c) return;

  const hooks = c.__hooks;
  if (hooks) {
    // vnode 的 diff 完成之後,將當前的_pendingEffects推進執行佇列
    if (hooks._pendingEffects.length) {
       // afterPaint 本次幀繪完——下一幀開始前執行
      afterPaint(afterPaintEffects.push(c));
    }
  }
};

options._commit = (vnode, commitQueue) => {
  commitQueue.some(component => {
    // 執行阻塞渲染任務內的清理函式
    component._renderCallbacks.forEach(invokeCleanup);
    // 更新清理函式
    component._renderCallbacks = component._renderCallbacks.filter(cb =>
      cb._value ? invokeEffect(cb) : true
    );
  });

  if (oldCommit) oldCommit(vnode, commitQueue);
};

options.unmount = vnode => {
  if (oldBeforeUnmount) oldBeforeUnmount(vnode);

  const c = vnode._component;
  if (!c) return;

  const hooks = c.__hooks;
  if (hooks) {
    // 元件解除安裝直接執行清理函式
    hooks._list.forEach(hook => hook._cleanup && hook._cleanup());
  }
};

/**
 * @param {import('./internal').EffectHookState} hook
 */
function invokeCleanup(hook) { // 執行清理函式
    if (hook._cleanup) hook._cleanup();
}
/**
 * Invoke a Hook's effect
 * @param {import('./internal').EffectHookState} hook
 */
function invokeEffect(hook) { // 執行回撥函式
    const result = hook._value();
    if (typeof result === 'function') hook._cleanup = result;
}
複製程式碼

最後有兩個函式,invokeCleanupinvokeEffect 用來執行清理函式和回撥函式.

前面三個鉤子在render函式內被同步呼叫。

export function render(vnode, parentDom, replaceNode) {
  if (options._root) options._root(vnode, parentDom);

  let isHydrating = replaceNode === IS_HYDRATE;
  let oldVNode = isHydrating  ? null 
    : (replaceNode && replaceNode._children) || parentDom._children;
  vnode = createElement(Fragment, null, [vnode]); // 建立新的vnode

  let commitQueue = [];
  diff(
    parentDom, // 父節點
    ((isHydrating ? parentDom : replaceNode || parentDom)._children = vnode), // newVnode
    oldVNode || EMPTY_OBJ, // oldVNode ,初始化渲染時為空物件
    EMPTY_OBJ, // 上下文物件
    parentDom.ownerSVGElement !== undefined, // 是否為Svg節點
    replaceNode && !isHydrating // 替換的同級節點
      ? [replaceNode]
      : oldVNode
      ? null
      : EMPTY_ARR.slice.call(parentDom.childNodes),
    commitQueue, // 有阻塞渲染任務的effect元件列表——useLayoutEffect
    replaceNode || EMPTY_OBJ, // 替換的節點
    isHydrating // 是否節點複用,服務端渲染使用
  );
  commitRoot(commitQueue, vnode);
}
複製程式碼

具體的功能不用涉及,首先進行diff,diff負責執行生命週期類方法以及呼叫_renderdiffed 方法。

  • _render 負責將 currentComponent 指向 vnode._component 並執行 _pendingEffects 佇列。
  • diffed 執行 afterPaint(afterPaintEffects.push(c)) 會把帶有 _pendingEffects 推入 afterPaintEffects 佇列,然後 afterPaint 呼叫 afterNextFrame(flushAfterPaintEffects) 執行effect 保證其在下一幀前呼叫.
function afterPaint(newQueueLength) {
  // diffed在每次render內只執行一次
  if (newQueueLength === 1 || prevRaf !== options.requestAnimationFrame) {
    prevRaf = options.requestAnimationFrame;

    /* istanbul ignore next */
    (prevRaf || afterNextFrame)(flushAfterPaintEffects);
  }
}
/**
 * 當raf執行在後臺標籤頁或者隱藏的<iframe> 裡時,會被暫停呼叫以提升效能和電池壽命。
 * 當前幀的raf並不會結束,所以需要結合setTimeout以確保即使raf沒有觸發也會呼叫回撥
 * @param {() => void} callback
 */
function afterNextFrame(callback) {
  const done = () => {
    clearTimeout(timeout);
    cancelAnimationFrame(raf);
    setTimeout(callback);
  };
  const timeout = setTimeout(done, RAF_TIMEOUT);

  let raf;
  if (typeof window !== 'undefined') {
    raf = requestAnimationFrame(done);
  }
}
function flushAfterPaintEffects() {
  afterPaintEffects.some(component => {
    if (component._parentDom) { // 如果節點還在html內
      // 執行清理函式
      component.__hooks._pendingEffects.forEach(invokeCleanup);
      // 執行effects
      component.__hooks._pendingEffects.forEach(invokeEffect);
      component.__hooks._pendingEffects = [];
    }
  });
  afterPaintEffects = [];
}
複製程式碼

得到diff後的vnode之後,還不能進行渲染。

/**
 * @param {Array<import('../internal').Component>} commitQueue 含有layoutEffect阻塞渲染任務元件列表
 * @param {import('../internal').VNode} root vnode
 */
export function commitRoot(commitQueue, root) {
  if (options._commit) options._commit(root, commitQueue);

  commitQueue.some(c => {
    try {
      // 清空執行任務
      commitQueue = c._renderCallbacks;
      c._renderCallbacks = [];
      commitQueue.some(cb => {
        cb.call(c);
      });
    } catch (e) {
      options._catchError(e, c._vnode);
    }
  });
}
複製程式碼

最後一個階段在diffChildren 刪除vnode之前執行.

useImperativeHandle

在官方例子裡,useImperativeHandle用於獲取子元件例項方法.因為自定義元件會過濾ref所以通常要與 forwardRef 組合搭配.

const FancyInput = forwardRef(
  (props, ref) => {
    const inputRef = useRef();
    useImperativeHandle(ref, () => ({
      focus: () => {
        inputRef.current.focus();
      }
    }));
    return <input ref={inputRef} ... />;
  }
)

function App(){
  const childrenRef = useRef()

  return (
    <div>
      <FancyInput ref={childrenRef}/>
      <button onClick={() => childrenRef.focus()}>click</button>
    </div>
  )
}

複製程式碼

其原理是獲取到父元件的ref後將例項方法物件傳入.

/**
 * @param {object} ref
 * @param {() => object} createHandle
 * @param {any[]} args
 */
export function useImperativeHandle(ref, createHandle, args) {
  useLayoutEffect(
    () => {
      //相容舊版本createRef
      if (typeof ref === 'function') ref(createHandle());
      else if (ref) ref.current = createHandle();
    },
    args == null ? args : args.concat(ref) // 依賴值
  );
}
複製程式碼

useMemo 與 useCallback

useCallback 是 useMemo的函式版本,其原理實現相同.通過比較依賴的變化返回新值.

/**
 * @param {() => any} callback
 * @param {any[]} args
 */
export function useMemo(callback, args) {
  /** @type {import('./internal').MemoHookState} */
  const state = getHookState(currentIndex++);
  if (argsChanged(state._args, args)) { // 比對依賴是否重新建立
    state._args = args;
    state._callback = callback;
    return (state._value = callback());
  }

  return state._value;
}

/**
 * @param {() => void} callback
 * @param {any[]} args
 */
export function useCallback(callback, args) {
  return useMemo(() => callback, args);
}
複製程式碼

useRef

useRef也是對於useMemo的變種.

export function useRef(initialValue) {
  return useMemo(() => ({ current: initialValue }), []);
}
複製程式碼

createContext

// src/create-context.js
export function createContext(defaultValue) {
  const ctx = {};

  const context = {
    _id: '__cC' + i++,
    _defaultValue: defaultValue,
    Consumer(props, context) {
      return props.children(context);
    },
    Provider(props) {
      if (!this.getChildContext) {
        const subs = [];
        this.getChildContext = () => {
          ctx[context._id] = this;
          return ctx;
        };
        this.shouldComponentUpdate = _props => {
          if (props.value !== _props.value) {
            subs.some(c => {
              c.context = _props.value;
              // provide值變化時更新訂閱的元件
              enqueueRender(c);
            });
          }
        };
        this.sub = c => {
          subs.push(c);
          let old = c.componentWillUnmount;
          c.componentWillUnmount = () => { // 元件解除安裝時從訂閱中移除
            subs.splice(subs.indexOf(c), 1);
            old && old.call(c);
          };
        };
      }
      return props.children;
    }
  };

  context.Consumer.contextType = context;

  return context;
}

// src/hooks/index
/**
 * @param {import('./internal').PreactContext} context
 */
export function useContext(context) {
  const provider = currentComponent.context[context._id];
  if (!provider) return context._defaultValue; // 沒有找到Provide元件
  const state = getHookState(currentIndex++);
  // This is probably not safe to convert to "!"
  if (state._value == null) {
    state._value = true;
    provider.sub(currentComponent); // 訂閱元件
  }
  return provider.props.value;
}
複製程式碼

通過訂閱收發的模式生產和消費資料.

後話

本文的目的是研究Hooks原理與機制,實際上 preact 與 react 其實有很多地方不一樣,其底層如children和ref的處理機制受限;children只能是陣列,react則可以是任何資料;ref的獲取時機;事件系統直接繫結在元素上而非基於冒泡;由於體積較小diff演算法過於簡單;setState的時機被推遲;生態問題...

不過作為一個只有3kb的庫,確實不能對其要求太高.

相關文章