组件里有很多逻辑是可以复用的。
对于常规的 JS 逻辑,我们会封装成函数,也会用一些通用函数的库,比如 lodash。
对于用到 hook 的逻辑,我们会封装成自定义 hook,当然,也会有通用 hook 库,比如 react-use 和 ahooks。
看周下载量,react-use 是 ahooks 的十倍。
自定义 hook 就是函数封装,和普通函数的区别只是在于名字规范是用 use 开头,并且要用到 react 的内置 hook。
useMountedState
useMountedState 可以用来获取组件是否 mount 到 dom:
js
import { useEffect, useState } from 'react';
import {useMountedState} from 'react-use';
const App = () => {
const isMounted = useMountedState();
const [,setNum ] = useState(0);
useEffect(() => {
setTimeout(() => {
setNum(1);
}, 1000);
}, []);
return <div>{ isMounted() ? 'mounted' : 'pending' }</div>
};
export default App;
第一次组件渲染的时候,组件还没 mount 到 dom,isMounted()为false,此时显示pending。
1 秒后在useEffect中通过 setState 触发再次渲染的时候, 我们知道 useEffect 是在首次渲染结束后执行的,所以isMounted()为 true, 这时候组件已经 mount 到 dom 了,显示 mounted。
这个 hook 的实现也比较简单:
js
import { useCallback, useEffect, useRef } from 'react';
export default function useMountedState(): () => boolean {
const mountedRef = useRef<boolean>(false);
const get = useCallback(() => mountedRef.current, []);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
return get;
}
我们知道, useRef 创建一个可变的 ref 对象,其 .current 属性可以存储任意值,且该值在组件的所有渲染周期中保持不变 (不会因组件重新渲染而重置)。本质是「持久化存储容器」,用于保存跨渲染周期需要保留的数据。
当首次渲染完成后,执行 mountedRef.current = true; 当 setNum(1);再次触发渲染时,mountedRef.current 已经为true了。
使用 useRef 而不是 useState 保存 mount 的值是因为修改 ref.current 并不会引起组件重新渲染。
并且返回的 get 函数要用 useCallback 包裹,这样用它作为其它 memo 组件参数的时候,就不会导致额外的渲染。
useLifeCycles
js
import {useLifecycles} from 'react-use';
const App = () => {
useLifecycles(() => console.log('MOUNTED'), () => console.log('UNMOUNTED'));
return null;
};
export default App;
这个也是用 useEffect 的特性实现的:
js
import { useEffect } from 'react';
const useLifecycles = (mount: Function, unmount?: Function) => {
useEffect(() => {
if (mount) {
mount();
}
return () => {
if (unmount) {
unmount();
}
};
}, []);
};
export default useLifecycles;
在 useEffect 里调用 mount,这时候 dom 操作完了,组件已经 mount。
然后返回的清理函数里调用 unmount,在组件从 dom 卸载时调用。
这两个 hook 都是依赖 useEffect 的特性来实现的。
useCookie
useCookie 可以方便的增删改 cookie:
js
import { useEffect } from "react";
import { useCookie } from "react-use";
const App = () => {
const [value, updateCookie, deleteCookie] = useCookie("peng");
useEffect(() => {
deleteCookie();
}, []);
const updateCookieHandler = () => {
updateCookie("666");
};
return (
<div>
<p>cookie 值: {value}</p>
<button onClick={updateCookieHandler}>更新 Cookie</button>
<br />
<button onClick={deleteCookie}>删除 Cookie</button>
</div>
);
};
export default App;
它是对 js-cookie 这个包的封装, 安装下:
js
npm i --save js-cookie
然后实现 useCookie:
js
import { useCallback, useState } from 'react';
import Cookies from 'js-cookie';
const useCookie = (
cookieName: string
): [string | null, (newValue: string, options?: Cookies.CookieAttributes) => void, () => void] => {
const [value, setValue] = useState<string | null>(() => Cookies.get(cookieName) || null);
const updateCookie = useCallback(
(newValue: string, options?: Cookies.CookieAttributes) => {
Cookies.set(cookieName, newValue, options);
setValue(newValue);
},
[cookieName]
);
const deleteCookie = useCallback(() => {
Cookies.remove(cookieName);
setValue(null);
}, [cookieName]);
return [value, updateCookie, deleteCookie];
};
export default useCookie;
就是基于 js-cookie 来 get、set、remove cookie。
一般自定义 hook 里返回的函数都要用 useCallback 包裹下,这样调用者就不用自己处理了。
useScrolling
useScrolling 封装了滚动的状态:
js
import { useRef } from "react";
import { useScrolling } from "react-use";
const App = () => {
const scrollRef = useRef<HTMLDivElement>(null);
const scrolling = useScrolling(scrollRef);
return (
<>
{<div>{scrolling ? "滚动中.." : "没有滚动"}</div>}
<div ref={scrollRef} style={{height: '200px', overflow: 'auto'}}>
<div>guang</div>
<div>guang</div>
<div>guang</div>
<div>guang</div>
<div>guang</div>
<div>guang</div>
<div>guang</div>
<div>guang</div>
<div>guang</div>
<div>guang</div>
<div>guang</div>
<div>guang</div>
<div>guang</div>
<div>guang</div>
<div>guang</div>
<div>guang</div>
<div>guang</div>
<div>guang</div>
<div>guang</div>
<div>guang</div>
<div>guang</div>
<div>guang</div>
</div>
</>
);
};
export default App;
实现下:
js
import { RefObject, useEffect, useState } from 'react';
const useScrolling = (ref: RefObject<HTMLElement>): boolean => {
const [scrolling, setScrolling] = useState<boolean>(false);
useEffect(() => {
if (ref.current) {
let scollingTimer: number;
const handleScrollEnd = () => {
setScrolling(false);
};
const handleScroll = () => {
setScrolling(true);
clearTimeout(scollingTimer);
scollingTimer = setTimeout(() => handleScrollEnd(), 150);
};
ref.current?.addEventListener('scroll', handleScroll);
return () => {
if (ref.current) {
ref.current?.removeEventListener('scroll', handleScroll);
}
};
}
return () => {};
}, [ref]);
return scrolling;
};
export default useScrolling;
用 useState 创建个状态,给 ref 绑定 scroll 事件,scroll 的时候设置 scrolling 为 true。
并且定时器 150ms 以后修改为 false。
这样只要不断滚动,就会一直重置定时器,结束滚动后才会设置为 false。
useSize
useSize 是用来获取 dom 尺寸的,并且在窗口大小改变的时候会实时返回新的尺寸。
js
import React, { useRef } from 'react';
import { useSize } from 'ahooks';
export default () => {
const ref = useRef<HTMLDivElement>(null);
const size = useSize(ref);
return (
<div ref={ref}>
<p>改变窗口大小试试</p>
<p>
width: {size?.width}px, height: {size?.height}px
</p>
</div>
);
};
实现下:
js
import ResizeObserver from 'resize-observer-polyfill';
import { RefObject, useEffect, useState } from 'react';
type Size = { width: number; height: number };
function useSize(targetRef: RefObject<HTMLElement>): Size | undefined {
const [state, setState] = useState<Size | undefined>(
() => {
const el = targetRef.current;
return el ? { width: el.clientWidth, height: el.clientHeight } : undefined
},
);
useEffect(() => {
const el = targetRef.current;
if (!el) {
return;
}
const resizeObserver = new ResizeObserver((entries) => {
entries.forEach((entry) => {
const { clientWidth, clientHeight } = entry.target;
setState({ width: clientWidth, height: clientHeight });
});
});
resizeObserver.observe(el);
return () => {
resizeObserver.disconnect();
};
}, []);
return state;
}
export default useSize;
用 useState 创建 state,初始值是传入的 ref 元素的宽高。
这里取 clientHeight,也就是不包含边框的高度。
然后用 ResizeObserver 监听元素尺寸的变化,改变的时候 setState 触发重新渲染。
这里为了兼容,用了 resize-observer-polyfill。
js
npm i --save resize-observer-polyfill
useHover
下面是ahooks的使用方式:
js
import React, { useRef } from 'react';
import { useHover } from 'ahooks';
export default () => {
const ref = useRef<HTMLDivElement>(null);
const isHovering = useHover(ref);
return <div ref={ref}>{isHovering ? 'hover' : 'leaveHover'}</div>;
};
实现下:
js
import { RefObject, useEffect, useState } from 'react';
export interface Options {
onEnter?: () => void;
onLeave?: () => void;
onChange?: (isHovering: boolean) => void;
}
export default (ref: RefObject<HTMLElement>, options?: Options): boolean => {
const { onEnter, onLeave, onChange } = options || {};
const [isEnter, setIsEnter] = useState<boolean>(false);
useEffect(() => {
ref.current?.addEventListener('mouseenter', () => {
onEnter?.();
setIsEnter(true);
onChange?.(true);
});
ref.current?.addEventListener('mouseleave', () => {
onLeave?.();
setIsEnter(false);
onChange?.(false);
});
}, [ref]);
return isEnter;
};
useTimeout
js
import React, { useState } from 'react';
import { useTimeout } from 'ahooks';
export default () => {
const [state, setState] = useState(1);
useTimeout(() => {
setState(state + 1);
}, 3000);
return <div>{state}</div>;
};
实现下:
js
import { useCallback, useEffect, useRef } from 'react';
const useTimeout = (fn: () => void, delay?: number) => {
const fnRef = useRef<Function>(fn);
fnRef.current = fn;
const timerRef = useRef<number>();
const clear = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
}, []);
useEffect(() => {
timerRef.current = setTimeout(fnRef.current, delay);
return clear;
}, [delay]);
return clear;
};
export default useTimeout;
首次渲染,useRef<Function>(fn) 会初始化 fnRef.current 为当前的 fn。
当组件重新渲染,组件函数会重新执行,此时会创建一个新的 fn 函数 ,新的 fn 引用的是最新的 state。
但 useRef 的特性是:初始值仅在首次渲染生效,后续渲染不会更新 current ,所以 fnRef.current 仍然指向上一次渲染时的旧 fn (引用的是旧的 state)。
因此,需要手动赋值 fnRef.current = fn:
useWhyDidYouUpdate
props 变了会导致组件重新渲染,而 useWhyDidYouUpdate 就是用来打印是哪些 props 改变导致的重新渲染:
js
import { useWhyDidYouUpdate } from 'ahooks';
import React, { useState } from 'react';
const Demo: React.FC<{ count: number }> = (props) => {
const [randomNum, setRandomNum] = useState(Math.random());
useWhyDidYouUpdate('Demo', { ...props, randomNum });
return (
<div>
<div>
<span>number: {props.count}</span>
</div>
<div>
randomNum: {randomNum}
<button onClick={() => setRandomNum(Math.random)}>
设置随机 state
</button>
</div>
</div>
);
};
export default () => {
const [count, setCount] = useState(0);
return (
<div>
<Demo count={count} />
<div>
<button onClick={() => setCount((prevCount) => prevCount - 1)}>减一</button>
<button onClick={() => setCount((prevCount) => prevCount + 1)}>加一</button>
</div>
</div>
);
};
Demo 组件有 count 的 props,有 randomNum 的 state。
当状态变化导致组件重新渲染时, 都能打印出是那个状态导致的,并且打印出从 from 改变到 to 导致的。

它的实现其实很简单,我们来写一下:
js
import { useEffect, useRef } from 'react';
export type IProps = Record<string, any>;
export default function useWhyDidYouUpdate(componentName: string, props: IProps) {
const prevProps = useRef<IProps>({});
useEffect(() => {
if (prevProps.current) {
const allKeys = Object.keys({ ...prevProps.current, ...props });
const changedProps: IProps = {};
allKeys.forEach((key) => {
if (!Object.is(prevProps.current[key], props[key])) {
changedProps[key] = {
from: prevProps.current[key],
to: props[key],
};
}
});
if (Object.keys(changedProps).length) {
console.log('[why-did-you-update]', componentName, changedProps);
}
}
prevProps.current = props;
});
}
Record<string, any> 是任意的对象的 ts 类型。
核心就是 useRef 保存 props 或者其他值,当下次渲染的时候,拿到新的值和上次的对比下,打印值的变化。
Object.is是 "更严格的===",修复了===在NaN和正负 0 上的判断缺陷,其他场景行为完全一致。日常开发中,若无需处理这些特殊值,===已足够;若需精准判断所有值的相等性,优先使用Object.is。