Hooks 是 16.7.0-alpha
新加入的新特性,目的解决状态逻辑复用,使无状态组件拥有了许多状态组件的能力,如自更新能力(useState)、访问ref(useRef)、上下文context(useContext)、更高级的setState(useReducer)及周期方法(useEffect/useLayoutEffect)及缓存(useMemo、useCallback)。其底层实现没有太多变动,整体更接近函数式语法,逻辑内聚,高阶封装这两大特点,让你同时领悟到 Hooks 的强大与优雅。
如果你已经厌倦写诸如修改网页标题,判断用户离线状态,监听页面大小,用户手机状态(电池、螺旋仪...),说明你已经不甘心做一个重复劳动的开发者 ,那么自定义hooks非常适合你。只想关注Custom Hooks,F 传送!!!!
在阅读本文之前,建议unLearning,也就是忘记你之前学会的“React” ,它已经不是那个“它”了,否则只会给你带来“误导”。
ps: 为了更好的阅读体验,- 表示删减代码, + 表示新增代码,* 表示修改行
useState
看这篇解析之前,我们已经知道自己的水平,岂能像新手一样先看api? 当然是要先从源码入手。
function useState<S>(initialState: (() => S) | S) {
const dispatcher = resolveDispatcher();
return dispatcher.useState(initialState);
}
WTF ???
回到正题,做人怎么能手高眼低呢?
高手,当然是要先从源码入手。
我们先看下官方的api
useState
用来定义组件数据变量。传入一个初始值,得到一个数组,前者为值,后者是一个dispatch函数,通过dispatch函数可以去更新该值。
const [value, updateVal] = useState(0)
value // 0
updateVal(1)
value // 1
也可以传入一个同步函数。
// ⚠️ Math.random() 每次 render 都会调用Math.random()
const [value, updateVal] = useState(Math.random());
// 只会执行一次
const [value, updateVal] = useState(() => Math.random());
useState仅在函数组件第一次执行初始化。在组件存在期间始终返回最新的值。不会再次去执行初始化函数。看到这,是不是觉得和闭包一样?
指向问题
// Index.js
class Index extends Component {
componentDidMount() {
setTimeout(() => console.log('classComponent', this.state.value), 1000);
this.setState({ value: 5 });
}
render() {
return null;
}
}
// App.js
function App () {
const [value, updateVal] = useState(0);
useEffect(() => {
setTimeout(() => console.log('FunctionComponent', value), 1000);
updateVal(5);
}, []);
return (
<Index />
);
}
// classComponent 5
// FunctionComponent 0
如果你还不了解 useEffect
,可以暂时把上面 useEffect
暂时看成是 componentWillMount
。目的是一秒钟后打印当前的value值。
前者通过 this
可以访问到最新的值,而函数组件由于闭包的原因,打印的时候访问的还是更新前的值。这种情况可以通过useRef解决,如果你还不了解useRef
const num = useRef(null);
useEffect(() => {
setTimeout(() => console.log(num.current), 1000); // 2
updateVal((pre) => {
const newPre = pre * 2;
num.current = newPre;
return newPre;
});
}, []);
但是updateVal
执行的时机无法保证(毕竟在整个周期的最后);还有个比较low的方案 —— 就是 useState
的 dispatch
函数 。
useEffect(() => {
setTimeout(() => updateVal((pre) => {
console.log('FunctionComponent', pre);
return pre;
}), 1000);
updateVal(5);
}, []);
// classComponent 5
// FunctionComponent 5
效果是能实现,但是由于 dispatch set 新值会触发一次 re-render。 所以这个方案不建议使用。后面会有封装的hooks达到目的。
根作用域顺序声明
不能嵌套在
if
或者for
中声明
之前看过不少hooks的文章,都说hooks是以数组的形式存储的,所以才会出现指向问题。但在后来实践发现并非如此(连官方也这么误导我).
React 如何将 Hook 调用与组件相关联?
React 跟踪当前渲染组件。 由于 Hooks 规则,我们知道 Hook 只能从 React 组件调用(或自定义 Hooks 也只能从 React 组件中调用)。
每个组件都有一个 “内存单元” 的内部列表。它们只是 JavaScript 对象,我们可以在其中放置一些数据。当调用 useState() 这样的Hook 时,它读取当前单元格(或在第一次呈现时初始化它),然后将指针移动到下一个单元格。这就是多个 useState() 调用各自获取独立本地状态的方式。
实际上是以一种单向循环链表。类似A.next === B => B.next === C 。
剖析 引用
const [state1,setState1] = useState(1)
const [state2,setState2] = useState(2)
const [state3,setState3] = useState(3)
每个FunctionalComponent
都会有个对应的Fiber对象,
function FiberNode(
tag: WorkTag,
pendingProps: mixed,
key: null | string,
mode: TypeOfMode,
) {
// Instance
this.tag = tag;
this.key = key;
this.elementType = null; // ReactElement[?typeof]
this.type = null; // ReactElement.type
this.stateNode = null;
// ...others
this.ref = null;
this.memoizedState = null;
// ...others
}
在其中调用的useState
会有个 Hook
对象。
export type Hook = {
memoizedState: any,
baseState: any,
baseUpdate: Update<any, any> | null,
queue: UpdateQueue<any, any> | null,
next: Hook | null, // 指向下一个hook节点
};
其他的问题不用太关注,只需要知道当在第一次执行到useState
的时候,会对应 Fiber
对象上的 memoizedState
,这个属性原来设计来是用来存储 ClassComponent
的 state
的,因为在 ClassComponent
中 state
是一整个对象,所以可以和memoizedState
一一对应。
但是在 Hooks
中,React并不知道我们调用了几次 useState
,所以在保存 state 这件事情上,React 想出了一个比较有意思的方案,
Fiber.memoizedState === hook1
state1 === hook1.memoizedState
hook1.next === hook2
state2 === hook2.memoizedState
hook2.next === hook3
state3 === hook2.memoizedState
每个在 FunctionalComponent
中调用的 useState
都会有一个对应的 Hook
对象,他们按照执行的顺序以类似单向循环链表的数据格式存放在 Fiber.memoizedState
上。
如果出现下面这种逻辑
if(false) {
const [status,setStatus] = useState(false)
}
// or
let times = 10 // 某次逻辑修改了times
for(let i = 0;i < times; i++) {
const status = useState(false)
}
会导致某次 re-render 后,少了某个 hook ,next 指向错误,比如 hook1.next 指向了 hook3 造成数据混乱,无法达到预想效果。
useEffect
生命周期的阶段性方法,类似setState(state, cb)中的cb,执行时机位于整个更新周期的最后。
话不多少,先上源码。
///////// useEffect
export function useEffect(
create: () => (() => void) | void,
inputs: Array<mixed> | void | null,
) {
const dispatcher = resolveDispatcher();
return dispatcher.useEffect(create, inputs);
}
//...省略
function mountEffect(
create: () => (() => void) | void,
deps: Array<mixed> | void | null,
): void {
return mountEffectImpl(
UpdateEffect | PassiveEffect,
UnmountPassive | MountPassive,
create,
deps,
);
}
咳,这个方法可谓是hooks里最重要的一个hooks。如果把useState
看成 HTML+CSS
,那 useEffect
就是 JS
。
useEffect(fn, deps?:any[])
,fn
执行函数,deps
依赖。与 useState
类似,fn
在初始化时执行一次。而后续执行则依赖于 deps
的变化,如果 re-render 后执行该 effects 发现此次 deps 与上次不同,就会触发执行。
ps: React 内部使用
Object.is
对 deps 进行浅比较。
刚开始脱离 classComponent 转而使用 hooks 时曾以为它在 render
前执行,其实不然。
默认情况下,效果在每次完成渲染后运行
useEffect(() => {
// 仅在初始化时(首次render后)执行
}, []);
useEffect(() => {
// 每次render后执行
});
fn
可返回一个清理函数,大多数运用于 addEventListener
与 removeEventListener
useEffect(() => {
// 首次render后执行
return () => {
// 组件卸载前执行
}
},[]);
useEffect(() => {
(function fn() => { /*dst...*/ })()
// 每次render后执行
return () => {
// 从第二次开始,先运行此清理函数,再执行fn
}
});
清理函数的执行时机可以理解为如果该 Effect 句柄执行过,则下次优先执行清理函数,以防止内存泄漏,最后一次执行时机在组件卸载后。
如果非要形容对应哪个生命周期,我更觉得像 componentDidUpdate
。
不要在 useEffect 中操作DOM。比如使用 requestAnimationFrame 添加几万个节点。会有意想不到的惊喜。
eg:
const Message = ({ boxRef, children }) => {
const msgRef = React.useRef(null);
React.useEffect(() => {
const rect = boxRef.current.getBoundingClientRect(); // 获取尺寸
msgRef.current.style.top = `${rect.height + rect.top}px`; // 放到盒子下方
}, [boxRef]);
return (
<span ref={msgRef} className="msgA">
{children}
</span>
);
};
const App = () => {
const [show, setShow] = React.useState(false);
const boxRef = React.useRef(null);
return (
<div>
<div ref={boxRef} className="box" onClick={() => setShow(prev => !prev)}>
Click me A
</div>
{show && <Message boxRef={boxRef}>useEffect</Message>}
</div>
);
};
目的很简单,将 Message
组件 显示时放置到div下,但实际运行结果时会发现有一瞬间跳动效果。
当把Message组件内的 useEffect
换成useLayoutEffect
就正常了。
原因是虽然useEffect在浏览器绘制后执行,也代表着它会在新渲染之前触发。需要执行新的渲染之前它会先刷新现有的effects。
什么?你不信?
e.g:
const [val, updateVal] = useState(0)
useEffect(() => { // hooks1
updateVal(2);
}, []);
useEffect(() => { // hooks2
console.log(val);// ---- 0
});
在 render后,先执行hook1 updateVal(2)
触发了 re-render,但在此之前需要先刷新现有的 effects,所以hooks2 val
打印出来的还是 0
,然后再次触发 render 渲染后的 effects hooks2才打印出 2
。
依赖于闭包
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
setTimeout(() => {
console.log(`You clicked ${count} times`);
}, 3000);
});
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
你觉得快速的连续点击5次,弹出来的会是什么?
与classComponent不同,它访问的而是this。而不是闭包。
componentDidUpdate() {
setTimeout(() => {
console.log(`You clicked ${this.state.count} times`);
}, 3000);
}
在定时器里执行的事件,完全依赖于闭包。可能你不认同,但是事实确是如此。
关于依赖项不要对React撒谎
function App(){
const [tagType, setTagType] = useState()
async function request () {
const result = await Api.getShopTag({
tagType
})
}
useEffect(()=>{
setTagType('hot')
request()
},[])
return null
}
request
函数依赖于 tagType
,但是Effects没有依赖于tagType
,当tagType
改变时,request
内 tagType
的值仍然是 hot
。
你可能只是想挂载的请求它,但是
现在只需要记住:如果你设置了依赖项,effect中用到的所有组件内的值都要包含在依赖中。这包括 props,state,函数 — 组件内的任何东西。
后话
在组件年内请求数据时,经常会这么写
function App(){
async function request () {
// ...
setList(result)
setLoaded(false)
}
useEffect(()=>{
request()
},[])
return null
}
在正常情况下访问当然没问题,当组件体积庞大或者请求速度慢时,你会收到“惊喜”。
意思是还没请求完毕你就去到别的页面,导致effects内的
setList/setLoaded
无从下手送温暖。 这也是闭包的弊端 —— 无法及时销毁。还有一个解决方案是 AbortController。
其实搞定这两个api就能完成80%的业务了。符合二八定律,即20%的功能完成80%的业务。封装自定义hooks大多数也需要它们。
useLayoutEffect
useLayoutEffect 名字与 useEffect 相差了一个 Layout。 顾名思义,它们的区别就是执行时机不一样,表示在 Layout 后触发。即 render 后。
源码如下:
签名与 useEffect 相同,但在所有 DOM 变化后同步触发。 使用它来从 DOM 读取布局并同步重新渲染。 在浏览器有机会绘制之前,将在 useLayoutEffect 内部计划的更新将同步刷新。
官方解释它会阻塞渲染,所以在不操作dom的情况用 useEffect ,以避免阻止视觉更新。
执行时机 render > useLayoutEffect > useEffect > setState(useState) > 清理effects > render(第二遍) > ...
而useLayoutEffect内setState的执行机制和useEffect不一样。虽然最后都执行了合并策略。在mount和update的阶段也是不一样的。甚至函数组件顶部申明useState的顺序都会导致执行结果不一致。
相对于组件 mount,在 update 触发 Hooks 的顺序更让人容易理解一些。
requestAnimationFrame将任务“打碎”,执行的时机在于重绘后,也就是useLayoutEffect执行过后。
ps:如果你只是改变数据,首选useEffect
,因为它不会阻塞渲染。这是优点也是缺点,不阻塞(代表异步),当然也保证不了顺序。而涉及到 DOM 操作的建议使用useLayoutEffect
。
useReducer
内置hook,看名字就知道和redux有关。使用方法和redux相同。
const reducer = (state,action) => {
let backups = { ...state }
switch(action.type){
case 'any': // ... ; break;
}
return backups
}
const initial = { nowTimes: Date.now() }
function App () {
const [val, dispatch] = useReducer(reducer,initial);
return null
}
通过 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];
}
第三个参数类似于 redux
的 initialState
,用于设置初始State。无论是否命中 reducer,每次 dispatch 都将触发 re-render。
如果你想用它代替 Redux
可能还是缺少点什么。有一个明显的问题,这里定义的state是和组件绑定的,和 useState
一样,无法和其他组件共享数据。但是通过 useContext
可以达到目的。
useContext
useContext(context: React.createContext)
// Context/index.js
const ContextStore = React.createContext()
// App.js
function Todo() {
const value = React.useContext(ContextStore);
return (
<React.Fragment>
{
JSON.stringify(value, null, 4)
}
</React.Fragment>
);
}
function App() {
return (
<ContextStore.Provider value={store}>
<Todo />
</ContextStore.Provider>
);
}
通过使用方法发现,配合 useReducer
可以在组件树顶层使用 Context.Provider
生产/改变数据,在子组件使用 useContext
消费数据。
const myContext = React.createContext();
const ContextProvider = () => {
const products = useReducer(productsReducer, { count: 0 });
const order = useReducer(orderReducer, { order: [] });
const store = {
product: products,
order: order // [ order,deOrder ]
}
return (
<myContext.Provider value={store}>
<Todo />
</myContext.Provider>
);
};
const Todo = () => {
const { product, order } = useContext(myContext)
return (
<React.Fragment>
{
JSON.stringify(state, null, 4)
}
<button onClick={product.dispatch}> product dispatch </button>
</React.Fragment>
)
}
弊端是当数据量变大时,整个应用会变得“十分臃肿”并且性能差劲。这有个很不错的实现 iostore。
useMemo
译文备忘录,如果更贴切点我想应该叫缓存,useCache
? 但后来想想也对,叫备忘录也没错,毕竟是状态逻辑复用。
useMemo
和reselect
库功能相同,都是依赖于传入的值,有固定的输入就一定有固定的输出。不必重新去计算,优化性能。在依赖不改变的情况下避免重新去计算浪费性能。
但是reselect用起来太繁琐了。useMemo相对简单的多
const memoDate = useMemo(()=>{
return new Date().toLocalString()
},[])
useMemo的第二个参数与useEffect功能相同,当依赖发生变化才会进行重新计算。memoDate
在组件内将始终不变。
依赖项
可能你写过这样的代码
const [count, setCount] = useState(0);
const request = useMemo(() => {
return aysnc () => {
let result = await Api.getMainShopTag({
startNum: count,
size: 10
})
setCount(result.count)
}
}, []);
return <button onClick={request} type="button"> request </button>
简单的一个分页标签请求,开始一切正常,当请求第二次的时候发现 count
仍为0。useMemo
缓存了函数,自然也缓存了函数内变量的指向。所以需要在deps内添加函数内需要依赖的参数。
如果你对这一切还不熟悉,react-hooks 针对 eslint 推出一款插件 eslint-plugin-react-hooks
,它可以自动帮你修复依赖项,并且提供优化支持。在制定自定义hooks的时候,严格遵守准则。
npm i eslint-plugin-react-hooks -D
// .eslintrc
{
// other ...
"plugins": [
"html","react","react-hooks"
],
"rules":{
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
}
useCallback
useCallback 是 useMemo
的变体。两者作用相同,你可以理解为前者更偏向于函数缓存。在定义一些不依赖于当前组件的属性变量方法时,可以尽量采用 useCallback
缓存。避免组件每次render前再次申明。
useCallback(fn, deps) === useMemo(() => fn, deps))
比如上面的代码你可以简化成
const request = useCallback(aysnc () => {
let result = await Api.getMainShopTag({
startNum: count,
size: 10
})
setCount(result.count)
}, [count]);
关于memo与callback
useCallback与useMemo需要慎重使用。很多人以为两者是为了解决创建函数带来的性能问题,其实不然。
上菜:
const forgetPwd = () => {
const sendSmsCode = () => { /*...*/ }
}
const forgetPwd = () => {
const sendSmsCode = useMemo(()=>{ /*...*/ }, [A,B])
}
上面的例子中两者效果是一样的,无论如何 sendSmsCode
都会被创建。只不因为后者需要比对依赖而耗费了稍微一点点的性能(蚊子再小也是肉),那可能会有疑问,为什么使用了缓存性能反而越来越差。
<button onClick={() => {}}>Search</button>
// 第二次render
<button onClick={() => {}}>Search</button>
两次渲染 inline 函数永远不会相等,与memo的概念背道而驰。这是没有意义的diff。只会浪费时间,而组件也绝对不会被memo优化。
useCallback 实际上缓存的是 inline callback 的实例,配合React.memo能够起到避免不必要的渲染。两者缺一个性能都会变差。当你的函数组件 UI 容器内有任何一个 inline 函数,任何优化都是没有意义的。
useRef
解决的问题是组件数据状态无法保存,有如下代码
function Interval() {
const [time, setTime] = useState(Date.now());
let intervalId;
const start = () => {
intervalId = setInterval(() => {
setTime(Date.now());
}, 500);
};
const clear = () => {
clearInterval(intervalId);
};
return (
<div>
<button onClick={start} type="button">start</button>
<button onClick={clear} type="button">clear</button>
</div>
);
}
看起来很正常的一段逻辑,但是启动定时器后,发现无法关闭定时器了。
这是因为启动定时器后,setTime(Date.now())
更新值后,函数组件被 re-render。此时intervalId
已经被重新声明了。所以清除不了之前的定时器。
函数组件没有被实例化,意味着无法使用this、没有内部的组件属性变量。需要避免其每次被重新声明。
const [intervalId, setIntervalId] = useState(null)
const start = () => {
setIntervalId(
setInterval(() => setTime(Date.now()), 500)
)
};
难道必须全部使用 useState
储存状态么?前面提到过,每次执行 setIntervalId
句柄都会触发一次 re-render,即使没有在视图里没有用到。
可以用 useRef
处理组件属性。改造组件
// ... other
let intervalId = useRef(null);
const start = () => {
intervalId.current = setInterval(() => {
setTime(Date.now());
}, 500);
};
const clear = () => {
clearInterval(intervalId.current);
};
使用 useRef 最好的理由是不会触发 re-render 。源码:
function mountRef<T>(initialValue: T): {current: T} {
const hook = mountWorkInProgressHook();
const ref = {current: initialValue};
if (__DEV__) {
Object.seal(ref);
}
hook.memoizedState = ref;
return ref;
}
那怎么理解useRef?
你可以把它看成是一个盒子。可以放任何数据(甚至组件) —— 海纳百川有容乃大。在盒子中的东西(current)会被隔离,且值将会被深拷贝,不会被外界所干扰、同时也不会响应。你可以重新通过 ref.current
去赋值。并且不会触发 re-render 与 useEffect
。通过 .current
获取的值始终都是最新的。
const info = useRef({ status: false });
const focus = () => {
// 始终都是最新的
setTimeout(() => console.log(info.current), 1000); // {status: true}
info.current.status = true;
}
const input = useRef();
useEffect(() => {
//可以访问元素上的方法
input.current.focus()
}, [])
useEffect(() => {
// info改变不会触发
}, [info.current.status])
return <input ref={input} type="text" onFocus={focus} />
useImperativeHandle
虽然通过useRef
可以访问本组件属性。但如果父元素想操作子组件就显得较无能无力。在 classComponent 你可以使用this.chilren
去访问子组件方法。函数组件就没有这项特权了,毕竟没有被实例化,官方提供useImperativeHandle(原useImperativeMethods)
向父组件暴露组件方法。额外的是需要配合 forwardRef
转发该节点使用,官方的例子已经极为清楚了:
// FancyInput.js
function FancyInput(props, ref) {
const inputRef = useRef();
useImperativeHandle(ref, () => ({
focus: () => {
inputRef.current.focus();
},
blurs: () =>{
// dst...
}
}));
return <input ref={inputRef} ... />;
}
FancyInput = forwardRef(FancyInput);
//App.js
function App(){
const fancyInputRef = useRef()
useEffect(()=>{
fancyInputRef.current.focus()
},[])
return (
<React.Fragment>
<FancyInput ref={fancyInputRef} />
</React.Fragment>
)
}
useDebugValue
这个就属于辅助标识了。在某个custom Hook 使用,标识该钩子是 custom hook。
比如申明一个 useMount
。
const useMount = (mount) => {
useEffect(() => {
mount && mount();
}, []);
useDebugValue('it"s my Custom hook',fit => `${fit} !` );
};
// App.js
const App = () => {
useMount(() => { /*dst...*/});
return (
<Provider>
<Count />
<IncrementButton />
</Provider>
);
};
该Api只会在ReactDevTools启用状况下才会加载,蓝色代表
Custom Hook
名称,比如useMount。红色为描述。
自定义钩子
实现 setState 回调
export const useStateCallback: <T>(
initialState: T
) => [T, stateCallback<T>] = <T>(initialState: T) => {
const [state, setState] = useState<T>(initialState)
const cbRef = useRef<stateCallback<T>>(null)
const setStateCallback: stateCallback<T> = (state, cb) => {
cbRef.current = cb // store passed callback to ref
setState(state)
}
useEffect(() => {
if (cbRef.current) {
cbRef.current(state)
cbRef.current = null // reset callback after execution
}
}, [state])
return [state, setStateCallback]
}
useLifeCycles
或许不需要单独拿出来。但还是忍不住凑字数。
const useLifeCycles = (mount, unMount) => {
useEffect(() => {
mount && mount();
return () => {
unMount && unMount();
};
}, []);
};
useRequestAnimationFrame
RequestAnimationFrame使用的频率很高,理所当然将其封装成一个hooks;名字太长可不是个好事。
/**
* useRaf useRequestAnimationFrame
* @param callback 回调函数
* @param startRun 立即执行
*/
const useRaf = (callback, startRun = true) => {
const requestRef = useRef(); // 储存RequestAnimationFrame返回的id
const previousTimeRef = useRef(null); // 每次耗时间隔
const animate = useCallback((time) => {
if (previousTimeRef.current !== undefined) {
const deltaTime = time - previousTimeRef.current; // 耗时间隔
callback(deltaTime);
}
previousTimeRef.current = time;
requestRef.current = requestAnimationFrame(animate);
}, [callback]);
useEffect(() => {
requestRef.current = requestAnimationFrame(animate);
return () => cancelAnimationFrame(requestRef.current);
}, []);
const stopRaf = useCallback(() => {
if(startRun) cancelAnimationFrame(requestRef.current);
requestRef.current = null;
}, [animate]);
const restartRaf = useCallback(() => {
if (requestRef.current === null) {
requestAnimationFrame(animate);
}
}, [animate]);
return [restartRaf, stopRaf];
};
// App.js
const App = () => {
const [count, setCounter] = useState(0);
const run = () => {
setCounter(pre => pre + 1);
};
const [start, stop] = useRaf(() => run());
return (
<div>
<button type="button" onClick={start}>开始</button>
<button type="button" onClick={stop}>暂停</button>
<h1>{count}</h1>
</div>
);
};
该hook接受一个函数作为帧变动的callback,callback接受一个参数,作为距离上次performance.now()
的间隔耗时,通常为16ms上下(意义不大,但可为低配置用户启用优化方案)。hook返回两个控制器,一个用来重启,当然不会将数据重置,另一个用来暂停。
ps:不要用来操作DOM,如果非得操作,建议改成
useLayoutEffect
。
有了这个hook,相信你就能够轻轻松松做出秒表、倒计时、数字逐帧变动等酷炫组件了。
usePrevious
利用 useRef
保存上一次的值,在Effect里第一次取值会拿到 undefined
的情况。有时候还需要去判断,这里利用Symbol 判断,首次返回该值。当然你也可以不考虑这种情况(第二个参数为false)。
export const usePrevious = (value) => {
const r = useRef(Math.random().toString(36)) // 利用随机数创建全局唯一的id
const ref = useRef(Symbol.for(r.current));
useEffect(() => {
ref.current = value;
});
return Symbol.for(r.current) === ref.current ? value : ref.current;
};
useEventListener
不想去频繁写原生event事件,将其封装成hooks。
export function useEventListener(eventName, handler, target = window) {
const memoHandler = useRef();
useEffect(() => {
memoHandler.current = handler;
}, [handler]);
useEffect(() => {
const eventListener = event => memoHandler.current(event);
const targetEl =
"current" in target && typeof target.current === "object"
? target.current
: target;
targetEl.addEventListener(eventName, eventListener);
return () => {
targetEl.removeEventListener(eventName, eventListener);
};
}, [eventName, target]);
}
由于React-DOM为IE9+,不考虑 attachEvent
。函数组件只能通过ref访问元素,增加.current判断防止报错。
useDebounce
防抖都不会陌生
/**
* 防抖函数执行
* @param {*} fn 被防抖函数
* @param {number} [ms=300] 间隔
*/
export const useDebounce = (fn, args, ms = 300 ) => {
const pendingInput = useRef(true);
useEffect(() => {
let savedHandlerId;
if (pendingInput.current) {
pendingInput.current = false;
} else {
savedHandlerId = setTimeout(fn, ms);
}
return () => clearTimeout(savedHandlerId);
}, [fn, ms, args]);
};
useThrottle
节流有更加简单的第三方实现
const throttled = useRef(throttle((newValue) => {
// dst...
}, 1000))
useEffect(() => throttled.current(value), [value])
但是入乡随俗,还是要实现一个。
/*
* 节流函数,等电梯,电梯15秒一轮,进人不重置。
* @param {*} fn 被节流函数
* @param {*} args 依赖更新参数
* @param {number} [timing=300] 节流阀时间
* @returns 节流值
*/
const useThrottle = (fn, args, timing = 300) => {
const [state, setState] = useState(() => fn(...args));
const timeout = useRef(null);
const lastArgs = useRef(null); // 最近一次参数
const hasChanged = useRef(false); // 是否有更新
useEffect(() => {
if (!timeout.current) {
const timeoutHandler = () => {
if (hasChanged.current) { // 有更新,立即更新并再启动一次,否则放弃更新
hasChanged.current = false;
setState(() => fn(...lastArgs.current));
timeout.current = setTimeout(timeoutHandler, timing);
} else {
timeout.current = undefined;
}
};
timeout.current = setTimeout(timeoutHandler, timing);
} else {
lastArgs.current = args; // 更新最新参数
hasChanged.current = true; // 有更新任务
}
}, [...args, fn, timing]);
return state;
};
使用方法
const throttledValue = useThrottle(value => value, [val], 1000);
useImtArray
制作一个 ImmutableArray
。
/**
* 通过二次封装数组,达到类似ImmutableArray效果
* @param {*} initial
* @returns
*/
const useImtArray = (initial = []) => {
const [value, setValue] = useState(()=>{
if(!Array.isArray(initial)) {
throw new Error('useImtArray argument Expectations are arrays. Actually, they are' + Object.prototype.toString.call(initial))
}
return initial
});
return {
value,
push: useCallback(val => setValue(v => [...v, val]), []),
pop: useCallback(() => setValue(arr => arr.slice(0, arr.length - 1)), []),
shift: useCallback(() => setValue(arr => arr.slice(1, arr.length)),[]),
unshift: useCallback(val => setValue(v => [val, ...v]), []),
clear: useCallback(() => setValue(() => []), []),
removeByVal: useCallback(val => setValue(arr => arr.filter(v => v !== val)),[]),
removeByIdx: useCallback(index => setValue(arr =>
arr.filter((v, idx) => parseInt(index, 10) !== idx),
), []),
};
};
usePromise
Promise当然也少不了。
/**
* 简化Promise
* @param {*} fn Promise函数
* @param {*} [args=[]] 依赖更新参数
* @returns loading:加载状态,value:成功状态的值,error:失败状态的值
*/
const usePromise = (fn, args = []) => {
const [state, setState] = useState({});
// eslint-disable-next-line react-hooks/exhaustive-deps
const memoPromise = useCallback(() => fn(), args);
useEffect(() => {
let pending = true; // 防止多次触发
setState(newestState => ({ ...newestState, loading: true }));
Promise.resolve(memoPromise())
.then(value => {
if (pending) {
setState({
loading: false,
value,
});
}
})
.catch(error => {
if (pending) {
setState({
loading: false,
error,
});
}
});
return () => {
pending = false;
};
}, [memoPromise]);
return state;
};
// App.js
const request = () => new Promise((resolve,reject)=>{
setTimeout(()=>{
if(Math.random() > 0.5 ){
resolve('Success')
}else{
reject('Fail')
}
},2000)
})
function App(){
const { value, loading, error} = usePromise(request)
return (
<div>{loading? <span>Loading...</span> : result:<span>{error||value}</span>}</div>
)
}
useGetter
通过 Object.definedProperty
能够简单的去监听读取属性
import { clone, isPlainObject } from '../utils';
/**
* 监听对象属性被读取
* @param {*} watcher 监听对象
* @param {*} fn 回调
*/
const useGetter = (watcher, fn) => {
if (!isPlainObject(watcher)) {
throw new Error(
`Expectation is the object, the actual result ${Object.prototype.toString.call(
watcher,
)}`,
);
}
const value = useMemo(() => watcher, [watcher]);
const cloneVal = useMemo(() => clone(watcher), [watcher]);
const cb = useRef(fn);
Object.keys(cloneVal).forEach(name => {
Object.defineProperty(value, name, {
get() {
if (typeof cb.current === 'function')
cb.current(name, cloneVal);
return cloneVal[name];
},
});
});
};
useLockBodyScroll
这个钩子偶然看到的,针对防止遮罩滚动穿透。原地址
/**
* 锁定body滚动条,多用于modal,后台
*/
const useLockBodyScroll = () => {
useLayoutEffect(() => {
const originalStyle = window.getComputedStyle(document.body)
.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = originalStyle;
};
}, []);
};
useTheme
你甚至可以自己切换主题配色,就像这样
/**
* 更换主题
* @param {*} theme 主题数据
*/
const useTheme = theme => {
useLayoutEffect(() => {
for (const key in theme) {
document.documentElement.style.setProperty(
`--${key}`,
theme[key],
);
}
}, [theme]);
};
useInput
写input的时候你还在手动 onChange
么?
/**
* auto Input Hooks
* @param {*} initial Input初始值
* @returns InputProps clear清空 replace(arg:any|Function) bind 绑定Input
*/
function useInput(initial) {
const [value, setValue] = useState(initial);
function onChange(event) {
setValue(event.currentTarget.value);
}
const clear = () => {
setValue('');
};
const replace = arg => {
setValue(pre => (typeof arg === 'function' ? arg(pre) : arg));
};
return {
bind: {
value,
onChange,
},
value,
clear,
replace,
};
}
function Input() {
let userName = useInput("Seven"); // {clear,replace,bind:{value,onChange}}
return <input {...userName.bind} />;
}
useDragger
一个极简的拖拽hook,稍加改造。
/**
* 拖拽元素
* @param {*} el 目标元素
* @returns x,y偏移量 pageX,pageY 元素左上角位置
*/
function useDraggable(el) {
const [{ dx, dy }, setOffset] = useState({ dx: 0, dy: 0 });
const [{ pageX, pageY }, setPageOffset] = useState({
pageX: 0,
pageY: 0,
});
useEffect(() => {
const { top, left } = el.current.getBoundingClientRect();
setPageOffset({ pageX: top, pageY: left });
const handleMouseDown = event => {
const startX = event.pageX - dx;
const startY = event.pageY - dy;
const handleMouseMove = e => {
const newDx = e.pageX - startX;
const newDy = e.pageY - startY;
setOffset({ dx: newDx, dy: newDy });
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', () => {
document.removeEventListener('mousemove', handleMouseMove);
},{ once: true });
};
el.current.addEventListener('mousedown', handleMouseDown);
return () => {
el.current.removeEventListener('mousedown', handleMouseDown);
};
}, [dx, dy, el]);
useEffect(() => {
el.current.style.transform = `translate3d(${dx}px, ${dy}px, 0)`;
}, [dx, dy, el]);
return { x: dx, y: dy, pageX, pageY };
}
生命周期:从类组件到函数组件的过渡
截至目前 react 最新版本为 16.9
,从图例中,探索各生命周期的实现方案。虽然没有理由再去使用 LifyCycle 了,但是了解下还是可以的。
componentDidMount 与 componentWillUnmount
由于是函数组件,没有被实例化,就没有一套完整的 LifeCycle 。componentWillMount
与 componentDidMount
只有顺序之分,放在组件顶部。
function App (){
// 函数组件顶部
const [value, setValue] = useState(0)
useEffect(() => {
console.log('componentDidMount');
return () => {
console.log('componentWillUnMount');
};
}, []);
// other
}
forceUpdate
通过更新一个无关的state闭包变量强制更新
const [updateDep,setUpdateDep] = useState(0)
function forceUpdate() {
setUpdateDep((updateDep) => updateDep + 1 )
}
getSnapshotBeforeUpdate
render后渲染dom之前调用,当然是 useLayoutEffect
。效果有待验证。
const SnapshotRef = useRef()
useLayoutEffect(()=>{
SnapshotRef.current = //...
})
componentDidUpdate
利用上文setState回调的例子,不同的是 componentDidUpdate
依赖的是所有值,所以没有deps。结合 useRef
和 useEffect
实现,componentDidUpdate
执行时机为组件第二次开始render,只需要判断执行render次数是否大于1即可。时机晚于 useLayoutEffect
。以便可以拿到最新的 SnapshotRef.current
。
let updateRender = useRef(0)
useEffect(() => {
updateRender.current++
if(updateRender.current > 1){
// componentDidUpdate
// get SnapshotRef.current do some thing
}
})
shouldComponentUpdate
在class里,PureComponent
替代自动shouldComponentUpdate
,而在函数组件里,当然是memo,能将一个组件完美优化工作量可不会小。
但是有时候我们就单单想控制某个组件不更新。也是可以做到的
const ShouldUpdateCertainCpt = useMemo(() => (
<div>Never updated</div>
), [])
return (
<ShouldUpdateCertainCpt />
)
后话
还是那句话,入乡随俗,React hooks 确实是革命性的变动,不能把 hooks 看成是 ClassComponent LifeCycle 的进化版,应该称之为重做版,于前者来说对新手也不太友好。把底层机制通过 effects 暴露给开发者确实是个明智之举。如果仍然想着用Hooks去实现LifeCycle 那么为什么不用 react 的“老版本”呢?
更有有意思的Hooks
本文有部分Hook都出自react-use
及 hook-guide
思路去开发的。相信掌握了hooks,你离成功剩下的只差一个Idea了。
Hooks | 描述 |
---|---|
React Use | hooks 工具库 |
useHistory | 管理历史记录栈 |
useScript | 动态添加脚本 |
useAuth | 用户状态 |
useWhyDidYouUpdate | hook版Why-Did-You-Update |
useDarkMode | 切换夜间模式 |
参考文献
如果你还觉得不错,star一下也是不错的like-hooks