background
In a project where the front and back ends are separated, we need to obtain data through an asynchronous request, which requires a lot of processing, such as:
- Loading is displayed, indicating that data is being requested
- Every asynchronous operation requires a try-catch to catch an error
- When a request fails, you need to handle the error case
Everyone on the team needs to write repetitive code when dealing with interfaces. So this article takes a step-by-step approach to how to package a powerful hooks that address the above pain points and allow development to focus only on business logic.
Goals and benefits
UseRequest provides the following functions:
- Manual request/automatic request
- Conditional/dependent requests
- Polling/shake-off/dependency requests
- Page/load more
The following calls are expected:
Const {loading, run, data} = useRequest(() => {// here to write specific asynchronous request}, {// some configuration parameters});Copy the code
As you can see in the code above, useRequest can pass in two arguments, the first is a function type called requestFn for an asynchronous request, and the second is some extended argument called options, which returns an object
parameter | instructions | type |
---|---|---|
loading | Whether the requestFn is running | boolean |
data | The data returned by requestFn is undefined by default | any |
run | Perform requestFn | Function |
error | The exception thrown by requestFn is undefined by default | undefined / Error |
The second parameter is used for some extended functions, and the following parameters are agreed:
The title | instructions | type | The default value |
---|---|---|---|
auto | Initialize the automatic execution of the requestFn | boolean | false |
onSuccess | RequestFn resolve the trigger | Function | – |
onError | RequestFn reject the trigger | Function | – |
cacheKey | Once this value is set, caching is enabled | string | – |
Technical solution
In writing hooks, we extend hooks step by step to implement a powerful asynchronous request state management library
The basic package
The second optional argument is not considered, only if the requestFn is passed in
Implementation framework
function useRequest<D.P extends any[] > (requestFn: RequestFn<D, P>, options: BaseOptions<D, P> = {}) {
const [loading, setLoading] = useState(false);
const [data, setData] = useState<D>();
const [err, setErr] = useState();
const run = useCallback(async(... params: P) => { setLoading(true);
let res;
try {
res = awaitrequestFn(... params); setData(res); }catch (error) {
setErr(error);
}
setLoading(false);
returnres; } []);return {
loading,
data,
err,
run,
};
}
Copy the code
The above code implements basic asynchronous management
- When the status of an asynchronous request changes, Loading can update in time
- Err is also updated when a request throws an error
- Can you decide when to execute an asynchronous function
The code above has been able to implement the most basic capabilities, and on top of that, the following extends the automatic execution capabilities
Again, use useEffect to listen for changes in auto
const { defaultParams } = options
useEffect(() = > {
if(auto) { run(... (defaultParamsas P));
}
}, [auto]);
Copy the code
When auto is true, the asynchronous function is run directly. Once the above functionality is implemented, we can call it as follows:
function generateName() :Promise<string> {
return new Promise(resolve= > {
setTimeout(() = > {
resolve('name');
}, 5000);
});
}
function Index() {
const { run, data, loading, err } = useRequest(async() = >await generateName());
useEffect(() = >{ run(); } []);console.log(data, loading);
console.log(err);
return (
<div style={{ fontSize: 14}} >
<p>data: {data}</p>
<p>loading: {String(loading)}</p>
</div>
);
}
Copy the code
When run is called, the asynchronous interface is called and the values returned by useRequest are updated
The cache
How to cache requests this is a common interview question. We expect to implement such functionality
-
The result can be cached after the first request
-
It can be retrieved from the cache when requested again
-
And when reading the cache, it can automatically initiate a request to pull the latest resources to update the cache
First of all, we need to think about the design of the cache system, which needs to meet the following characteristics
-
Ability to add cache
-
Ability to cache expiration times
-
Delete the cache
Here we use Map to cache, Map is a group of key-value pair structure, with very fast search speed, with the help of GET, set API to complete the storage of data
class BaseCache {
protected value: Map<CachedKeyType, T>
constructor() {
this.value = new Map<CachedKeyType, T>();
}
public getValue = (key: CachedKeyType): T= > {
return this.value.get(key )
}
public setValue = (key: CachedKeyType, data: T): void= > {
this.setValue(key, data)
}
public remove = (key: CachedKeyType) = > {
this.value.delete(key)
}
}
Copy the code
In addition to the expiration time logic, setCache will automatically delete the result in the cache after the expiration time is exceeded. The change is as follows:
const setCache = (key: CachedKeyType, cacheTime: number, data: any) = > {
const currentCache = cache.getValue(key);
if(currentCache? .timer) {clearTimeout(currentCache.timer);
}
let timer: Timer | undefined = undefined;
if (cacheTime > -1) {
// Data is not active in cacheTime, then deleted
timer = setTimeout(() = > {
cache.remove(key);
}, cacheTime);
}
const value = {
data,
timer,
startTime: new Date().getTime(),
}
cache.setValue(key, value);
};
Copy the code
In the above code, when setting the value, first determine whether the timer exists, if so, cancel the timer, release the memory. If the current cache duration is set, you need to add a timer to delete the current cache. Finally, there are two parts in the Cache
- Data: information about the request
- Timer: indicates the ID of a timer
If we want to implement fetch directly from the cache and automatically execute the request interface in the background, we need to cache two parts:
- Request the results
- Request parameters
So we need to construct a request object, which contains the following functions:
- Provides the ability to actively call asynchronous functions
- Save request results
- Save request parameters
- Provides a hook that can be triggered when a value changes
The specific code is as follows:
class Request<D.P extends any[] >{
that: any = this;
options: BaseOptions<D, P>;
requestFn: BaseRequestFnType<D, P>;
state: RequestsStateType<D, P> = {
loading: false.run: this.run.bind(this.that),
data: undefined.params: [] as any.changeData: this.changeData.bind(this.that),
};
constructor(requestFn: BaseRequestFnType<D, P>, options: BaseOptions<D, P>, changeState: (data: RequestsStateType<D, P>) => void) {
this.options = options;
this.requestFn = requestFn;
this.changeState = this.changState;
}
async run(. params: P) {
this.setState({
loading: true,
params,
});
let res;
try {
res = await this.requestFn(... params);this.setState({
data: res,
error: undefined.loading: false});if (this.options.onSuccess) {
this.options.onSuccess(res);
}
return res;
} catch (error) {
this.setState({
data: undefined,
error,
loading: false});if (this.options.onError) {
this.options.onError(error);
}
returnerror; }}setState(s = {} as Partial<BaseReturnValue<D, P>>) {
this.state = { ... this.state, ... s, };if (this.onChangeState) {
this.onChangeState(this.state); }}changeData(data: D) {
this.setState({ data, }); }}Copy the code
This object holds all the information related to the Request, so the useRequest needs to be changed before. All the state needs to be retrieved from the Request object, so change the useRequest implemented before. When we call the interface, we first try to fetch data from the cache, and if the cache exists, we return the data directly from the cache
const { cacheKey } = options
const cacheKeyRef = useRef(cacheKey)
cacheKeyRef.current = cacheKey
const [requests, setRequests] = useState<RequestsStateType<D, P> | null> (() = > {
if (cacheKey && cacheKeyRef.current) {
return getCache(cacheKeyRef.current);
}
return null;
});
Copy the code
The initial value is fetched directly from the cache via cacheKey, and if present, the contents of the cache are returned
We also need to set the cached values
useUpdateEffect(() = > {
if (cacheKeyRef.current) {
setCache(cacheKeyRef.current, cacheTime, requests);
}
}, [requests]);
Copy the code
If the requests values change, update the values in the cache. If the requests values change, we need to update the values in the cache as well as the run function provided externally. When we run, we need to distinguish whether the values exist in the cache. The changed code looks like this:
const run = useCallback(async(... params: P) => {let currentRequest;
if (cacheKeyRef.current) {
currentRequest = getCache(cacheKeyRef.current);
}
if(! currentRequest) {const requestState = new Request(requestFn, options, onChangeState).state;
setRequests(requestState);
returnrequestState.run(... params); }returncurrentRequest.run(); } []);Copy the code
Finally, the result is returned. If there is cached content, we need to use cached data
if (requests) {
return requests;
} else {
return {
loading: auto,
data: initData,
error: undefined,
run,
};
}
Copy the code
We tried calling the value of useRequest and found that requests was always the original value in the Request object. This is because we didn’t change the content of Requests in time when the asynchronous function was called, so we’re writing a function to change the content of Requests
const onChangeState = useCallback((data: RequestsStateType<D, P>) = >{ setRequests(data); } []);Copy the code
Put the above code together and you’ll be able to cache the results. Let’s write a demo to see what happens
function generateName() :Promise<number> {
return new Promise(resolve= > {
setTimeout(() = > {
resolve(Math.random());
}, 1000);
});
}
function Article() {
const { data } = useRequest(generateName, {
cacheKey: 'generateName'.auto: true});return <p>123{data}</p>;
}
function Index() {
const { run, data, loading } = useRequest(generateName, {
cacheKey: 'generateName'});const [bool, setBool] = useState(false);
useEffect(() = >{ run(); } []);return (
<div style={{ fontSize: 14}} >
<p>data: {data}</p>
<p>loading: {String(loading)}</p>
<button onClick={()= >setBool(! bool)} type="button"> repeat</button>
{bool && <Article />}
</div>
);
}
Copy the code
The Article and Index components both call the same interface, and the CacheKey is the same when the Article immediately gets the value of the first run
The first time it is run it is 0.92, then the Article interface calls it and gets a value of 0.92, which can be updated behind the scenes, and the next time the Article component is rendered it is updated to the latest value
To load more
On this basis we expect useRequest to be able to extend and load more functions, which need to meet the following functions
- Click to load more
- Drop down to load more
Because there is too much data in the actual service to completely cover, only the following conditions are covered:
Request body-pagesize: request per page tree - offset: return structure - list: array contents - total: return total number of entriesCopy the code
Let’s talk about the implementation
const { initPageSize = 10, ref, threshold = 100. restOptions } = options;const [list, setList] = useState<LoadMoreItemType<D>[]>([]);
const [loadingMore, setLoadingMore] = useState(false);
constresult = useRequest(requestFn, { ... restOptions,onSuccess: res= > {
setLoadingMore(false);
console.log('onSuccess', res.list);
setList(prev= > prev.concat(res.list));
if(options.onSuccess) { options.onSuccess(res); }},onError: (. params) = > {
setLoadingMore(false);
if(options.onError) { options.onError(... params); }}});const { data, run, loading } = result;
Copy the code
The list variable is used to save the loaded variable, loadingMore indicates whether it is being loaded or not, before making an asynchronous request with the previously provided useRequest, it should be noted that onSuccess and onError need to be rewrapped to change the current data state
When ref is passed in, it means you need to slide to the bottom of a container and load more operations
useEffect(() = > {
if(! ref || ! ref.current) {return noop;
}
const handleScroll = () = > {
if(! ref || ! ref.current) {return;
}
if(ref.current.scrollHeight - ref.current.scrollTop <= ref.current.clientHeight + threshold) { loadMore(); }}; ref.current.addEventListener('scroll', handleScroll);
return () = > {
if (ref && ref.current) {
ref.current.removeEventListener('scroll', handleScroll); }}; }, [ref && ref.current, loadMore]);Copy the code
The loadMore code basically passes the length of the current list as offset into the run provided by useRequest earlier
const loadMore = useCallback(
(customObj = {}) = > {
console.log(noMore, loading, loadingMore, 'customObj');
if (noMore || loading || loadingMore) {
return;
}
setLoadingMore(true);
run({
current,
pageSize: initPageSize,
offset: list.length, ... customObj, }); }, [noMore, loading, loadingMore, current, run, data, list] );Copy the code
So you can load more, and when you get to the bottom you can load more automatic loading and you can do that, so you don’t have to write the example here
conclusion
Providing a packaged useRequest can save a lot of business code duplication. The overall implementation of the above is based on the open source hooks library of Ant. Some of the less common functions are not explained one by one