精讀《怎麼用 React Hooks 造輪子》
1 引言
上週的 精讀《React Hooks》 已經實現了對 React Hooks 的基本認知,也許你也看了 React Hooks 基本實現剖析(就是陣列),但理解實現原理就可以用好了嗎?學的是知識,而用的是技能,看別人的用法就像刷抖音一樣(哇,飯還可以這樣吃?),你總會有新的收穫。
這篇文章將這些知識實踐起來,看看廣大程式勞動人民是如何發掘 React Hooks 的潛力的(造什麼輪子)。
首先,站在使用角度,要理解 React Hooks 的特點是 “非常方便的 Connect 一切”,所以無論是資料流、Network,或者是定時器都可以監聽,有一點 RXJS 的意味,也就是你可以利用 React Hooks,將 React 元件打造成:任何事物的變化都是輸入源,當這些源變化時會重新觸發 React 元件的 render,你只需要挑選元件繫結哪些資料來源(use 哪些 Hooks),然後只管寫 render 函式就行了!
2 精讀
參考了部分 React Hooks 元件後,筆者按照功能進行了一些分類。
由於 React Hooks 並不是非常複雜,所以就不按照技術實現方式去分類了,畢竟技術總有一天會熟練,而且按照功能分類才有持久的參考價值。
DOM 副作用修改 / 監聽
做一個網頁,總有一些看上去和元件關係不大的麻煩事,比如修改頁面標題(切換頁面記得改成預設標題)、監聽頁面大小變化(元件銷燬記得取消監聽)、斷網時提示(一層層裝飾器要堆成小山了)。而 React Hooks 特別擅長做這些事,造這種輪子,大小皆宜。
由於 React Hooks 降低了高階元件使用成本,那麼一套生命週期才能完成的 “雜耍” 將變得非常簡單。
下面舉幾個例子:
修改頁面 title
效果:在元件裡呼叫 useDocumentTitle
函式即可設定頁面標題,且切換頁面時,頁面標題重置為預設標題 “前端精讀”。
useDocumentTitle("個人中心");
複製程式碼
實現:直接用 document.title
賦值,不能再簡單。在銷燬時再次給一個預設標題即可,這個簡單的函式可以抽象在專案工具函式裡,每個頁面元件都需要呼叫。
function useDocumentTitle(title) {
useEffect(
() => {
document.title = title;
return () => (document.title = "前端精讀");
},
[title]
);
}
複製程式碼
監聽頁面大小變化,網路是否斷開
效果:在元件呼叫 useWindowSize
時,可以拿到頁面大小,並且在瀏覽器縮放時自動觸發元件更新。
const windowSize = useWindowSize();
return <div>頁面高度:{windowSize.innerWidth}</div>;
複製程式碼
實現:和標題思路基本一致,這次從 window.innerHeight
等 API 直接拿到頁面寬高即可,注意此時可以用 window.addEventListener('resize')
監聽頁面大小變化,此時呼叫 setValue
將會觸發呼叫自身的 UI 元件 rerender,就是這麼簡單!
最後注意在銷燬時,removeEventListener
登出監聽。
function getSize() {
return {
innerHeight: window.innerHeight,
innerWidth: window.innerWidth,
outerHeight: window.outerHeight,
outerWidth: window.outerWidth
};
}
function useWindowSize() {
let [windowSize, setWindowSize] = useState(getSize());
function handleResize() {
setWindowSize(getSize());
}
useEffect(() => {
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
return windowSize;
}
複製程式碼
動態注入 css
效果:在頁面注入一段 class,並且當元件銷燬時,移除這個 class。
const className = useCss({
color: "red"
});
return <div className={className}>Text.</div>;
複製程式碼
實現:可以看到,Hooks 方便的地方是在元件銷燬時移除副作用,所以我們可以安心的利用 Hooks 做一些副作用。注入 css 自然不必說了,而銷燬 css 只要找到注入的那段引用進行銷燬即可,具體可以看這個 程式碼片段。
DOM 副作用修改 / 監聽場景有一些現成的庫了,從名字上就能看出來用法:document-visibility、network-status、online-status、window-scroll-position、window-size、document-title。
元件輔助
Hooks 還可以增強元件能力,比如拿到並監聽元件執行時寬高等。
獲取元件寬高
效果:通過呼叫 useComponentSize
拿到某個元件 ref 例項的寬高,並且在寬高變化時,rerender 並拿到最新的寬高。
const ref = useRef(null);
let componentSize = useComponentSize(ref);
return (
<>
{componentSize.width}
<textArea ref={ref} />
</>
);
複製程式碼
實現:和 DOM 監聽類似,這次換成了利用 ResizeObserver
對元件 ref 進行監聽,同時在元件銷燬時,銷燬監聽。
其本質還是監聽一些副作用,但通過 ref 的傳遞,我們可以對元件粒度進行監聽和操作了。
useLayoutEffect(() => {
handleResize();
let resizeObserver = new ResizeObserver(() => handleResize());
resizeObserver.observe(ref.current);
return () => {
resizeObserver.disconnect(ref.current);
resizeObserver = null;
};
}, []);
複製程式碼
線上 Demo,對應元件 component-size。
拿到元件 onChange 丟擲的值
效果:通過 useInputValue()
拿到 Input 框當前使用者輸入的值,而不是手動監聽 onChange 再騰一個 otherInputValue
和一個回撥函式把這一堆邏輯寫在無關的地方。
let name = useInputValue("Jamie");
// name = { value: 'Jamie', onChange: [Function] }
return <input {...name} />;
複製程式碼
可以看到,這樣不僅沒有佔用元件自己的 state,也不需要手寫 onChange 回撥函式進行處理,這些處理都壓縮成了一行 use hook。
實現:讀到這裡應該大致可以猜到了,利用 useState
儲存元件的值,並丟擲 value
與 onChange
,監聽 onChange
並通過 setValue
修改 value
, 就可以在每次 onChange
時觸發呼叫元件的 rerender 了。
function useInputValue(initialValue) {
let [value, setValue] = useState(initialValue);
let onChange = useCallback(function(event) {
setValue(event.currentTarget.value);
}, []);
return {
value,
onChange
};
}
複製程式碼
這裡要注意的是,我們對元件增強時,元件的回撥一般不需要銷燬監聽,而且僅需監聽一次,這與 DOM 監聽不同,因此大部分場景,我們需要利用 useCallback
包裹,並傳一個空陣列,來保證永遠只監聽一次,而且不需要在元件銷燬時登出這個 callback。
線上 Demo,對應元件 input-value。
做動畫
利用 React Hooks 做動畫,一般是拿到一些具有彈性變化的值,我們可以將值賦給進度條之類的元件,這樣其進度變化就符合某種動畫曲線。
在某個時間段內獲取 0-1 之間的值
這個是動畫最基本的概念,某個時間內拿到一個線性增長的值。
效果:通過 useRaf(t)
拿到 t 毫秒內不斷重新整理的 0-1 之間的數字,期間元件會不斷重新整理,但重新整理頻率由 requestAnimationFrame 控制(不會卡頓 UI)。
const value = useRaf(1000);
複製程式碼
實現:寫起來比較冗長,這裡簡單描述一下。利用 requestAnimationFrame
在給定時間內給出 0-1 之間的值,那每次重新整理時,只要判斷當前重新整理的時間點佔總時間的比例是多少,然後做分母,分子是 1 即可。
彈性動畫
效果:通過 useSpring
拿到動畫值,元件以固定頻率重新整理,而這個動畫值以彈性函式進行增減。
實際呼叫方式一般是,先通過 useState
拿到一個值,再通過動畫函式包住這個值,這樣元件就會從原本的重新整理一次,變成重新整理 N 次,拿到的值也隨著動畫函式的規則變化,最後這個值會穩定到最終的輸入值(如例子中的 50
)。
const [target, setTarget] = useState(50);
const value = useSpring(target);
return <div onClick={() => setTarget(100)}>{value}</div>;
複製程式碼
實現:為了實現動畫效果,需要依賴 rebound
庫,它可以實現將一個目標值拆解為符合彈性動畫函式過程的功能,那我們需要利用 React Hooks 做的就是在第一次接收到目標值是,呼叫 spring.setEndValue
來觸發動畫事件,並在 useEffect
裡做一次性監聽,再值變時重新 setValue
即可。
最神奇的 setTarget
聯動 useSpring
重新計算彈性動畫部分,是通過 useEffect
第二個引數實現的:
useEffect(
() => {
if (spring) {
spring.setEndValue(targetValue);
}
},
[targetValue]
);
複製程式碼
也就是當目標值變化後,才會進行新的一輪 rerender,所以 useSpring
並不需要監聽呼叫處的 setTarget
,它只需要監聽 target
的變化即可,而巧妙利用 useEffect
的第二個引數可以事半功倍。
Tween 動畫
明白了彈性動畫原理,Tween 動畫就更簡單了。
效果:通過 useTween
拿到一個從 0 變化到 1 的值,這個值的動畫曲線是 tween
。可以看到,由於取值範圍是固定的,所以我們不需要給初始值了。
const value = useTween();
複製程式碼
實現:通過 useRaf
拿到一個線性增長的值(區間也是 0 ~ 1),再通過 easing
庫將其對映到 0 ~ 1 到值即可。這裡用到了 hook 呼叫 hook 的聯動(通過 useRaf
驅動 useTween
),還可以在其他地方舉一反三。
const fn: Easing = easing[easingName];
const t = useRaf(ms, delay);
return fn(t);
複製程式碼
發請求
利用 Hooks,可以將任意請求 Promise 封裝為帶有標準狀態的物件:loading、error、result。
通用 Http 封裝
效果:通過 useAsync
將一個 Promise 拆解為 loading、error、result 三個物件。
const { loading, error, result } = useAsync(fetchUser, [id]);
複製程式碼
實現:在 Promise 的初期設定 loading,結束後設置 result,如果出錯則設定 error,這裡可以將請求物件包裝成 useAsyncState
來處理,這裡就不放出來了。
export function useAsync(asyncFunction) {
const asyncState = useAsyncState(options);
useEffect(() => {
const promise = asyncFunction();
asyncState.setLoading();
promise.then(
result => asyncState.setResult(result);,
error => asyncState.setError(error);
);
}, params);
}
複製程式碼
具體程式碼可以參考 react-async-hook,這個功能建議僅瞭解原理,具體實現因為有一些邊界情況需要考慮,比如元件 isMounted 後才能相應請求結果。
Request Service
業務層一般會抽象一個 request service
做統一取數的抽象(比如統一 url,或者可以統一換 socket 實現等等)。假如以前比較 low 的做法是:
async componentDidMount() {
// setState: 改 isLoading state
try {
const data = await fetchUser()
// setState: 改 isLoading、error、data
} catch (error) {
// setState: 改 isLoading、error
}
}
複製程式碼
後來把請求放在 redux 裡,通過 connect 注入的方式會稍微有些改觀:
@Connect(...)
class App extends React.PureComponent {
public componentDidMount() {
this.props.fetchUser()
}
public render() {
// this.props.userData.isLoading | error | data
}
}
複製程式碼
最後會發現還是 Hooks 簡潔明瞭:
function App() {
const { isLoading, error, data } = useFetchUser();
}
複製程式碼
而 useFetchUser
利用上面封裝的 useAsync
可以很容易編寫:
const fetchUser = id =>
fetch(`xxx`).then(result => {
if (result.status !== 200) {
throw new Error("bad status = " + result.status);
}
return result.json();
});
function useFetchUser(id) {
const asyncFetchUser = useAsync(fetchUser, id);
return asyncUser;
}
複製程式碼
填表單
React Hooks 特別適合做表單,尤其是 antd form 如果支援 Hooks 版,那用起來會方便許多:
function App() {
const { getFieldDecorator } = useAntdForm();
return (
<Form onSubmit={this.handleSubmit} className="login-form">
<FormItem>
{getFieldDecorator("userName", {
rules: [{ required: true, message: "Please input your username!" }]
})(
<Input
prefix={<Icon type="user" style={{ color: "rgba(0,0,0,.25)" }} />}
placeholder="Username"
/>
)}
</FormItem>
<FormItem>
<Button type="primary" htmlType="submit" className="login-form-button">
Log in
</Button>
Or <a href="">register now!</a>
</FormItem>
</Form>
);
}
複製程式碼
不過雖然如此,getFieldDecorator
還是基於 RenderProps 思路的,徹底的 Hooks 思路是利用之前說的 元件輔助方式,提供一個元件方法集,用解構方式傳給元件。
Hooks 思維的表單元件
效果:通過 useFormState
拿到表單值,並且提供一系列 元件輔助 方法控制組件狀態。
const [formState, { text, password }] = useFormState();
return (
<form>
<input {...text("username")} required />
<input {...password("password")} required minLength={8} />
</form>
);
複製程式碼
上面可以通過 formState
隨時拿到表單值,和一些校驗資訊,通過 password("pwd")
傳給 input
元件,讓這個元件達到受控狀態,且輸入型別是 password
型別,表單 key 是 pwd
。而且可以看到使用的 form
是原生標籤,這種表單增強是相當解耦的。
實現:仔細觀察一下結構,不難發現,我們只要結合 元件輔助 小節說的 “拿到元件 onChange 丟擲的值” 一節的思路,就能輕鬆理解 text
、password
是如何作用於 input
元件,並拿到其輸入狀態。
往簡單的來說,只要把這些狀態 Merge 起來,通過 useReducer
聚合到 formState
就可以實現了。
為了簡化,我們只考慮對 input
的增強,原始碼僅需 30 幾行:
export function useFormState(initialState) {
const [state, setState] = useReducer(stateReducer, initialState || {});
const createPropsGetter = type => (name, ownValue) => {
const hasOwnValue = !!ownValue;
const hasValueInState = state[name] !== undefined;
function setInitialValue() {
let value = "";
setState({ [name]: value });
}
const inputProps = {
name, // 給 input 新增 type: text or password
get value() {
if (!hasValueInState) {
setInitialValue(); // 給初始化值
}
return hasValueInState ? state[name] : ""; // 賦值
},
onChange(e) {
let { value } = e.target;
setState({ [name]: value }); // 修改對應 Key 的值
}
};
return inputProps;
};
const inputPropsCreators = ["text", "password"].reduce(
(methods, type) => ({ ...methods, [type]: createPropsGetter(type) }),
{}
);
return [
{ values: state }, // formState
inputPropsCreators
];
}
複製程式碼
上面 30 行程式碼實現了對 input
標籤型別的設定,監聽 value
onChange
,最終聚合到大的 values
作為 formState
返回。讀到這裡應該發現對 React Hooks 的應用都是萬變不離其宗的,特別是對元件資訊的獲取,通過解構方式來做,Hooks 內部再做一下聚合,就完成表單元件基本功能了。
實際上一個完整的輪子還需要考慮 checkbox
radio
的相容,以及校驗問題,這些思路大同小異,具體原始碼可以看 react-use-form-state。
模擬生命週期
有的時候 React15 的 API 還是挺有用的,利用 React Hooks 幾乎可以模擬出全套。
componentDidMount
效果:通過 useMount
拿到 mount 週期才執行的回撥函式。
useMount(() => {
// quite similar to `componentDidMount`
});
複製程式碼
實現:componentDidMount
等價於 useEffect
的回撥(僅執行一次時),因此直接把回撥函式丟擲來即可。
useEffect(() => void fn(), []);
複製程式碼
componentWillUnmount
效果:通過 useUnmount
拿到 unmount 週期才執行的回撥函式。
useUnmount(() => {
// quite similar to `componentWillUnmount`
});
複製程式碼
實現:componentDidMount
等價於 useEffect
的回撥函式返回值(僅執行一次時),因此直接把回撥函式返回值丟擲來即可。
useEffect(() => fn, []);
複製程式碼
componentDidUpdate
效果:通過 useUpdate
拿到 didUpdate 週期才執行的回撥函式。
useUpdate(() => {
// quite similar to `componentDidUpdate`
});
複製程式碼
實現:componentDidUpdate
等價於 useMount
的邏輯每次執行,除了初始化第一次。因此採用 mouting flag(判斷初始狀態)+ 不加限制引數確保每次 rerender 都會執行即可。
const mounting = useRef(true);
useEffect(() => {
if (mounting.current) {
mounting.current = false;
} else {
fn();
}
});
複製程式碼
Force Update
效果:這個最有意思了,我希望拿到一個函式 update
,每次呼叫就強制重新整理當前元件。
const update = useUpdate();
複製程式碼
實現:我們知道 useState
下標為 1 的項是用來更新資料的,而且就算資料沒有變化,呼叫了也會重新整理元件,所以我們可以把返回一個沒有修改數值的 setValue
,這樣它的功能就僅剩下重新整理元件了。
const useUpdate = () => useState(0)[1];
複製程式碼
對於
getSnapshotBeforeUpdate
,getDerivedStateFromError
,componentDidCatch
目前 Hooks 是無法模擬的。
isMounted
很久以前 React 是提供過這個 API 的,後來移除了,原因是可以通過 componentWillMount
和 componentWillUnmount
推導。自從有了 React Hooks,支援 isMount 簡直是分分鐘的事。
效果:通過 useIsMounted
拿到 isMounted
狀態。
const isMounted = useIsMounted();
複製程式碼
實現:看到這裡的話,應該已經很熟悉這個套路了,useEffect
第一次呼叫時賦值為 true,元件銷燬時返回 false,注意這裡可以加第二個引數為空陣列來優化效能。
const [isMount, setIsMount] = useState(false);
useEffect(() => {
if (!isMount) {
setIsMount(true);
}
return () => setIsMount(false);
}, []);
return isMount;
複製程式碼
存資料
上一篇提到過 React Hooks 內建的 useReducer
可以模擬 Redux 的 reducer 行為,那唯一需要補充的就是將資料持久化。我們考慮最小實現,也就是全域性 Store + Provider 部分。
全域性 Store
效果:通過 createStore
建立一個全域性 Store,再通過 StoreProvider
將 store
注入到子元件的 context
中,最終通過兩個 Hooks 進行獲取與操作:useStore
與 useAction
:
const store = createStore({
user: {
name: "小明",
setName: (state, payload) => {
state.name = payload;
}
}
});
const App = () => (
<StoreProvider store={store}>
<YourApp />
</StoreProvider>
);
function YourApp() {
const userName = useStore(state => state.user.name);
const setName = userAction(dispatch => dispatch.user.setName);
}
複製程式碼
實現:這個例子的實現可以單獨拎出一篇文章了,所以筆者從存資料的角度剖析一下 StoreProvider
的實現。
對,Hooks 並不解決 Provider 的問題,所以全域性狀態必須有 Provider,但這個 Provider 可以利用 React 內建的 createContext
簡單搞定:
const StoreContext = createContext();
const StoreProvider = ({ children, store }) => (
<StoreContext.Provider value={store}>{children}</StoreContext.Provider>
);
複製程式碼
剩下就是 useStore
怎麼取到持久化 Store 的問題了,這裡利用 useContext
和剛才建立的 Context 物件:
const store = useContext(StoreContext);
return store;
複製程式碼
更多原始碼可以參考 easy-peasy,這個庫基於 redux 編寫,提供了一套 Hooks API。
封裝原有庫
是不是 React Hooks 出現後,所有的庫都要重寫一次?當然不是,我們看看其他庫如何做改造。
RenderProps to Hooks
這裡拿 react-powerplug 舉例。
比如有一個 renderProps 庫,希望改造成 Hooks 的用法:
import { Toggle } from 'react-powerplug'
function App() {
return (
<Toggle initial={true}>
{({ on, toggle }) => (
<Checkbox checked={on} onChange={toggle} />
)}
</Toggle>
)
}
↓ ↓ ↓ ↓ ↓ ↓
import { useToggle } from 'react-powerhooks'
function App() {
const [on, toggle] = useToggle()
return <Checkbox checked={on} onChange={toggle} />
}
複製程式碼
效果:假如我是 react-powerplug
的維護者,怎麼樣最小成本支援 React Hook? 說實話這個沒辦法一步做到,但可以通過兩步實現。
export function Toggle() {
// 這是 Toggle 的原始碼
// balabalabala..
}
const App = wrap(() => {
// 第一步:包 wrap
const [on, toggle] = useRenderProps(Toggle); // 第二步:包 useRenderProps
});
複製程式碼
實現:首先解釋一下為什麼要包兩層,首先 Hooks 必須遵循 React 的規範,我們必須寫一個 useRenderProps
函式以符合 Hooks 的格式,**那問題是如何拿到 Toggle 給 render 的 on
與 toggle
?**正常方式應該拿不到,所以退而求其次,將 useRenderProps
拿到的 Toggle 傳給 wrap
,讓 wrap
構造 RenderProps 執行環境拿到 on
與 toggle
後,呼叫 useRenderProps
內部的 setArgs
函式,讓 const [on, toggle] = useRenderProps(Toggle)
實現曲線救國。
const wrappers = []; // 全域性儲存 wrappers
export const useRenderProps = (WrapperComponent, wrapperProps) => {
const [args, setArgs] = useState([]);
const ref = useRef({});
if (!ref.current.initialized) {
wrappers.push({
WrapperComponent,
wrapperProps,
setArgs
});
}
useEffect(() => {
ref.current.initialized = true;
}, []);
return args; // 通過下面 wrap 呼叫 setArgs 獲取值。
};
複製程式碼
由於 useRenderProps
會先於 wrap
執行,所以 wrappers 會先拿到 Toggle,wrap
執行時直接呼叫 wrappers.pop()
即可拿到 Toggle 物件。然後構造出 RenderProps 的執行環境即可:
export const wrap = FunctionComponent => props => {
const element = FunctionComponent(props);
const ref = useRef({ wrapper: wrappers.pop() }); // 拿到 useRenderProps 提供的 Toggle
const { WrapperComponent, wrapperProps } = ref.current.wrapper;
return createElement(WrapperComponent, wrapperProps, (...args) => {
// WrapperComponent => Toggle,這一步是在構造 RenderProps 執行環境
if (!ref.current.processed) {
ref.current.wrapper.setArgs(args); // 拿到 on、toggle 後,通過 setArgs 傳給上面的 args。
ref.current.processed = true;
} else {
ref.current.processed = false;
}
return element;
});
};
複製程式碼
以上實現方案參考 react-hooks-render-props,有需求要可以拿過來直接用,不過實現思路可以參考,作者的腦洞挺大。
Hooks to RenderProps
好吧,如果希望 Hooks 支援 RenderProps,那一定是希望同時支援這兩套語法。
效果:一套程式碼同時支援 Hooks 和 RenderProps。
實現:其實 Hooks 封裝為 RenderProps 最方便,因此我們使用 Hooks 寫核心的程式碼,假設我們寫一個最簡單的 Toggle
:
const useToggle = initialValue => {
const [on, setOn] = useState(initialValue);
return {
on,
toggle: () => setOn(!on)
};
};
複製程式碼
然後通過 render-props
這個庫可以輕鬆封裝出 RenderProps 元件:
const Toggle = ({ initialValue, children, render = children }) =>
renderProps(render, useToggle(initialValue));
複製程式碼
其實 renderProps
這個元件的第二個引數,在 Class 形式 React 元件時,接收的是 this.state
,現在我們改成 useToggle
返回的物件,也可以理解為 state
,利用 Hooks 機制驅動 Toggle 元件 rerender,從而讓子元件 rerender。
封裝原本對 setState 增強的庫
Hooks 也特別適合封裝原本就作用於 setState 的庫,比如 immer。
useState
雖然不是 setState
,但卻可以理解為控制高階元件的 setState
,我們完全可以封裝一個自定義的 useState
,然後內建對 setState
的優化。
比如 immer 的語法是通過 produce
包裝,將 mutable 程式碼通過 Proxy 代理為 immutable:
const nextState = produce(baseState, draftState => {
draftState.push({ todo: "Tweet about it" });
draftState[1].done = true;
});
複製程式碼
那這個 produce
就可以通過封裝一個 useImmer
來隱藏掉:
function useImmer(initialValue) {
const [val, updateValue] = React.useState(initialValue);
return [
val,
updater => {
updateValue(produce(updater));
}
];
}
複製程式碼
使用方式:
const [value, setValue] = useImmer({ a: 1 });
value(obj => (obj.a = 2)); // immutable
複製程式碼
3 總結
本文列出了 React Hooks 的以下幾種使用方式以及實現思路:
- DOM 副作用修改 / 監聽。
- 元件輔助。
- 做動畫。
- 發請求。
- 填表單。
- 模擬生命週期。
- 存資料。
- 封裝原有庫。
歡迎大家的持續補充。
4 更多討論
如果你想參與討論,請點選這裡,每週都有新的主題,週末或週一釋出。前端精讀 - 幫你篩選靠譜的內容。