React Hook大师之路:ahooks常用Hook源码学习
我正在参加「掘金·启航计划」
序言
上一次写技术文章,已经将近是半年前了。
过去的半年里,接触到了非常多的技术大佬,其中还不乏有很多同龄人甚至中小学生,让我的技术视野变得更加开阔,也因此更深刻地认识到自己的不足。
我直接恍惊起而长嗟啊,这帮人怎么能这么卷啊,我觉都睡不好了啊喂!
曾经也幻想过有朝一日能成为JavaScript Master
, 但是眼前的实际情况似乎是刚出新手村就被各种路人拷打...
我不服啊!
别人能学,我难道就不能学了吗?程序员宁有种乎!
看我这反手就把ahooks
源码学起来,等我学会了这套人类高质量React Hook库,我也要和大佬们一起玩技术!
为了代码更加直观易懂,本文会尽可能省略不必要的TS。
useLatest
介绍
总是能够获取一个状态的最新值的 hook!
可以避免闭包问题!
不太清楚具体是干什么的?那来看看下面这个场景:
export default App() {
const [cnt, setCnt] = useState(0);
useEffect(() => {
setInterval(() => {
setCnt(cnt + 1); // 注意看,这个变量叫小帅(狗头
}, 400);
}, []);
return (
<div>
<div>鼠鼠我啊,卡在1动不了咯: {cnt}</div>
</div>
);
};
这里渲染的cnt
最终会卡在1,这是因为这里存在useEffect闭包陷阱,个人对此的理解是:
因为函数的作用域在声明的时候就确定了,一开始,定时器的回调函数能根据作用域链找到此时值为0的
cnt
。当定时器回调执行时,会根据刚刚提到的作用域链(创建其执行上下文),找到值为0的
cnt
进行计算得到结果为1。而这里的
useEffect
仅在函数首次渲染的时候执行了一次,所以其内的定时器回调的作用域链不会再得到更新,就一直访问的是值为0的cnt
,结果就一直为1。
这时,我们就需要useLatest
:
import { useState, useEffect } from 'react';
import { useLatest } from 'ahooks';
export default () => {
const [cnt, setCnt] = useState(0);
// TODO: 实现这个hook
const cntRef = useLatest(cnt);
useEffect(() => {
const id = setInterval(() => {
setCount(cntRef.current + 1);
}, 400);
// 记得要及时销毁定时器
return () => clearInterval(id);
}, []);
return (
<>
<p>总是最新的捏: {cnt}</p>
</>
);
};
实现
useLatest
的源码实现也非常简单!
只需要利用useRef
总能获取到最新的值的特点,进行简单封装一下即可:
import { useRef } from 'react';
function useLatest (v) {
const ref = useRef(v);
ref.current = v;
return ref;
}
非常精简,但是确实好用!
useUnmount
介绍
在组件卸载时,执行函数的hook。
哦吼,说到组件卸载,这里我们首先就会想到使用useEffect
实现:
const callback = () => {
// ...
};
useEffect(() => {
return callback; // 组件卸载时执行
}, []);
但是这样会不会有什么潜在的问题呢?
让我们先来看一个场景,假设我们要统计用户在某个组件展示时的点击次数:
import { useState, useEffect } from 'react';
// 被点击的组件
const ClickArea = () => {
// 点击次数
const [cnt, setCnt] = useState(0);
const callback = () => {
// 哎哟我的天神啊,为什么一直是0啊
console.log('点击次数:', cnt);
};
useEffect(() => {
return callback;
}, []);
return (
<div
style={{ background: 'red', width: '200px', height: '200px' }}
onClick={() => setCnt(cnt + 1)}
>
{cnt}
</div>
);
};
const App = () => {
// 模拟卸载组件(真实场景下可能是因为路由切换而卸载)
const [isShow, setIsShow] = useState(true);
return (
<div>
{isShow && <ClickArea></ClickArea>}
<button onClick={() => setIsShow(!isShow)}>是否 卸载 组件?</button>
</div>
);
};
结果是,在子组件上点击n次,子组件的cnt
渲染为n,但是最后打印的结果无论如何都是0。
依然是useEffect闭包陷阱, 我们可以用useUnmount
来解决:
// useEffect(() => {
// return callback;
// }, []);
// 把上面的内容替换为下面这个:
useUnmount(() => {
return callback;
});
实现
我们在useLatest
的使用场景里已经提到过了,useLatest
就是为解决闭包问题而生的!
那么我们在这里完全可以复用它来解决现在的问题:
// 复用我们之前写的hook:
// import useLatest from xxx
const useUnmount = (callback) => {
const cbRef = useLatest(callback);
useEffect(() => {
return cbRef.current();
}, []);
};
是的,还是那么简单,只需要保证callback
是最新的即可!
useDebounceFn
介绍
用于创建函数防抖的hook。
前端的防抖策略就像是电梯门按钮一样,一直按就一直不关门,不按了过一会儿就关上了。
import { useDebounceFn } from 'ahooks';
import { useState } from 'react';
export default () => {
const [cnt, setCnt] = useState(0);
// TODO: 实现这个hook
const { run } = useDebounceFn(() => {
setCnt(cnt + 1);
},
{ wait: 500 },
);
return (
<div>
<p>cnt: {cnt} </p>
<button type="button" onClick={run}>点点我!</button>
</div>
);
};
实现
ahooks
并没有自己实现防抖,而是直接使用了loadash
中的方法。
import debounce from 'lodash/debounce';
import { useUnmount } from 'ahooks';
import { useMemo } from 'react';
const useDebounceFn = (fn, options) => {
// 这是源码本来的实现,保证了传入的回调始终是最新的:
// const fnRef = useLatest(fn);
const wait = options?.wait ?? 1000;
const debounced = useMemo(() => {
return debounce(
(...args) => {
// return fnRef.current(...args);
return fn(...args);
},
wait,
options,
);
}, []);
useUnmount(() => {
debounced.cancel(); // 销毁,及时释放空间
});
return {
run: debounced, // 执行函数
cancel: debounced.cancel, // 手动销毁防抖
flush: debounced.flush, // 手动触发防抖
};
};
useThrottleFn
节流hook的实现基本与上面一致,仅仅是把debounce
替换为了throttle
上述源码的实现中,用
useLatest
保证了传入的回调始终是最新的。但是什么情况下才会有回调不是最新的问题呢?
难道这只是作者为了追求稳上加稳而做的防御性编程?
还希望大家不吝赐教,在评论区分享一下自己的见解,非常感谢!!!
useLockFn
介绍
用于给一个异步函数增加竞态锁,防止并发执行的hook。
为了避免短时间内大量触发重复的操作,除了防抖节流之外,我们还可以选择直接上锁,抛弃锁定期间的操作。
import { useLockFn } from 'ahooks';
import { useState } from 'react';
function mockApiRequest() {
return new Promise<void>((resolve) => {
setTimeout(() => {
resolve();
}, 2000);
});
}
export default () => {
// TODO: 实现这个hook
// 传入一个异步函数,返回一个最大并发数为1的异步函数
const submit = useLockFn(async () => {
console.log('开始了');
await mockApiRequest();
console.log('结束了');
});
return (
<>
<button onClick={submit}>Submit</button>
</>
);
};
实现
先写一个简单版实现核心逻辑的:
import { useRef } from 'react';
const useLockFn = (fn) => {
const lockRef = useRef(false);
return async (...args) => {
if (lockRef.current) return; // 锁定期间,不做处理
lockRef.current = true; // 上锁
const res = await fn(...args);
lockRef.current = false; // 解锁
return res;
};
}
再丰富一下细节,考虑一下 作为组件参数传递 和 异常处理 的情况:
import { useRef, useCallback } from 'react';
const useLockFn = (fn) => {
const lockRef = useRef(false);
// 用useCallback包一下,作为组件参数时避免引起不必要的重新渲染
return useCallback(
async (...args) => {
if (lockRef.current) return; // 锁定期间,不做处理
lockRef.current = true; // 上锁
try {
const res = await fn(...args);
lockRef.current = false; // 解锁
return res;
} catch (e) {
lockRef.current = false; // 解锁
throw e;
}
},
[fn],
);
}
usePrevious
介绍
保存上一次状态的 Hook。
这里的上一次状态默认是不与当前状态相同的。
有时候,我们想要获取一个状态新旧两个值,那我们首先得使用两次useState
,之后每次都还得写两次setXXX
,这样太麻烦了。
使用usePrevious
就优雅多了:
import { useState } from 'react';
export default () => {
const [count, setCount] = useState(0);
// TODO: 实现这个hook
const previous = usePrevious(count);
return (
<>
<div>现在的: {count}</div>
<div>上一次的: {previous}</div>
<button onClick={() => setCount(c + 1)}>
+1
</button>
</>
);
};
实现
思路也比较简单,还是设置两个状态——当然这里用的是useRef
而不是useState
来做这件事,避免了因为修改状态导致组件发生额外的渲染。
import { useRef } from 'react';
// 默认的比较方法,判定前后状态是否一致
const defaultShouldUpdate = (a, b) => !Object.is(a, b);
function usePrevious(
state,
shouldUpdate = defaultShouldUpdate,
) {
const prevRef = useRef();
const curRef = useRef();
if (shouldUpdate(curRef.current, state)) {
prevRef.current = curRef.current;
curRef.current = state;
}
return prevRef.current;
}
export default usePrevious;
useToggle
介绍
实现一个用于在两个状态值间切换的 Hook!
乍一听非常简单——但是需要注意,这可没说一定是布尔值的切换。
让我们先给出一些基本的代码实现,这样更容易理解要做些什么:
import { useState } from 'react';
// 注意,两个参数都是可选的
const useToggle = (leftValue, rightValue) => {
const [state, setState] = useState(leftValue);
const action = useMemo(() => {
// ... TODO: 在这里补全代码
return {
set, // 设置 state
toggle, // 反转 state
setLeft, // 设置state为 leftValue
setRight, // 设置state为 rightValue ?? !leftValue
};
}, []);
return [state, action];
};
实现
我们要做的就是实现四个函数,也就是代码略多一丢丢而已:
import { useState, useMemo } from 'react';
const useToggle = (leftValue, rightValue) => {
const [state, setState] = useState(leftValue);
const action = useMemo(() => {
// --- 下面是补全的代码 ---
// 由于rightValue参数可选,所以并不一定存在右值,可能需要我们自行对左值取反
const revLeftValue = rightValue === undefined ? !leftValue : rightValue;
const set = (v) => { setState(v); };
const setLeft = () => { setState(leftValue); };
const setRight = () => { setState(rightValue); };
const toggle = () => {
const newValue = state === leftValue ? revLeftValue : leftValue;
setState(newValue);
};
// --- 上面是补全的代码 ---
return {
setLeft,
setRight,
set,
toggle,
};
}, []);
return [state, action];
};
实际上也并不难对吧?
useUpdateEffect
介绍
和
useEffect
基本一致,但是不会处理首次渲染的hook。
有时候,我们只希望监听某个状态的更新,不需要在首次渲染时处理。本着减少代码量的纯真愿望,我们可以考虑使用useUpdateEffect
.
实现
这里的源码本来是做了两层封装,使得能够在实现useUpdateEffect
和useUpdateLayoutEffect
的时候复用代码。不过我们这里为了方便阅读,去掉了一层:
import { useRef } from 'react';
import { useEffect } from 'react';
export const useUpdateEffect = (effect, deps) => {
const isMounted = useRef(false);
// 这个是为了配合webpack插件 react-refresh 的热更新能力, 可以忽略
useEffect(() => {
isMounted.current = true;
}, []);
// 这里才是核心逻辑
useEffect(() => {
if (!isMounted.current) {
isMounted.current = true;
} else {
// 也要记得处理组件卸载后的回调
const unmountCallback = effect();
return unmountCallback();
}
}, deps);
}
useUpdateLayoutEffect
的实现也基本一致,只需要把上面的useEffect
替换成useLayoutEffect
就好了。
顺带再简单地提一句两者的区别:
layoutEffect
是在React渲染流程中的commit部分的layout阶段,也就是页面绘制前同步执行的(这个时候DOM已经解析完毕,可以被获取)。
effect
则是在页面绘制后异步执行的。所以,
layoutEffect
会阻塞页面绘制,且比effect
先执行。
useAsyncEffect
介绍
和
useEffect
基本一致,但是支持异步的hook。
最常见的场景就是,在页面首次渲染是请求页面数据。
通常我们会先写一个异步函数,然后把请求放到这个异步函数里面再一并放入useEffect
内执行——这样也非常麻烦,让我们来试着减少一点代码量:
import { useAsyncEffect } from 'ahooks';
import { useState } from 'react';
function mockCheck(): Promise<boolean> {
return new Promise((resolve) => {
setTimeout(() => {
resolve(true);
}, 3000);
});
}
export default () => {
const [pass, setPass] = useState<boolean>();
// TODO: 实现这个hook
useAsyncEffect(async () => {
const isPass = await mockCheck();
setPass(isPass);
}, []);
return (
<div>{pass ? '请求处理中...' : '请求完成!'}</div>
);
};
实现
支持异步并不难,核心是实现异步可中断,使用Generator
即可实现:
import { useEffect } from 'react';
const isFunction = (value) => {
return typeof value === 'function';
}
// 判断是否为可异步可迭代函数
const isAsyncGenerator = (val) => {
return isFunction(val[Symbol.asyncIterator]);
}
const useAsyncEffect = (effect, deps) => {
useEffect(() => {
// 中断控制
let canceled = false;
// 获取Promise
const e = effect();
// 利用generator语法实现异步可中断
const execute = async () => {
if (isAsyncGenerator(e)) {
while (true) {
const result = await e.next();
// 执行完毕 或者 中断
if (result.done || cancelled) break;
}
} else {
await e;
}
}
execute();
return () => {
// 组件卸载、依赖项更新等导致effect清除,中断
cancelled = true;
};
}, deps);
}
上面的代码已经基本实现了我们需要的功能。
但是也还存在问题: 异步函数可能已经中断了,但是仍然可能继续操作组件的状态,这就不符合预期。
这时候我们只需要通过传入一个回调,感知到是否中断,再判断是否要继续执行后续操作即可:
// 增加一个回调
useAsyncEffect(async (isCanceled: () => boolean) => {
const isPass = await mockCheck();
// 判断是否中断,中断则不再更新状态
if (!isCanceled()) {
setPass(isPass);
}
}, []);
相应地,hook的逻辑也要稍微调整一下:
import { useEffect } from 'react';
const isFunction = (value) => {
return typeof value === 'function';
}
const isAsyncGenerator = (val) => {
return isFunction(val[Symbol.asyncIterator]);
}
type Effect = (
isCanceled: () => boolean
) => Promise<any>;
const useAsyncEffect = (effect: Effect, deps) => {
return useEffect(() => {
let canceled = false;
// 主要的改动就是下面这里,传入这个回调即可
const e = effect(() => caceled);
const execute = async () => {
if (isAsyncGenerator(e)) {
while (true) {
const result = await e.next();
if (result.done || cancelled) break;
}
} else {
await e;
}
}
execute();
return () => {
cancelled = true;
};
}, deps);
}
参考资料
开源果然让世界更美好,非常感谢:
转载自:https://juejin.cn/post/7245658681730924603