本節是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; // 清理函式
}
複製程式碼
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;
}
複製程式碼
最後有兩個函式,invokeCleanup
和 invokeEffect
用來執行清理函式和回撥函式.
前面三個鉤子在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負責執行生命週期類方法以及呼叫_render
和 diffed
方法。
- _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之後,還不能進行渲染。
- commit 階段,在commitRoot 內被呼叫
/**
* @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的庫,確實不能對其要求太高.