likes
comments
collection
share

React Hook大师之路:ahooks常用Hook源码学习

作者站长头像
站长
· 阅读数 43

我正在参加「掘金·启航计划」

序言

上一次写技术文章,已经将近是半年前了。

过去的半年里,接触到了非常多的技术大佬,其中还不乏有很多同龄人甚至中小学生,让我的技术视野变得更加开阔,也因此更深刻地认识到自己的不足。

我直接恍惊起而长嗟啊,这帮人怎么能这么卷啊,我觉都睡不好了啊喂!

曾经也幻想过有朝一日能成为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.

实现

这里的源码本来是做了两层封装,使得能够在实现useUpdateEffectuseUpdateLayoutEffect的时候复用代码。不过我们这里为了方便阅读,去掉了一层:

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);
}

参考资料

开源果然让世界更美好,非常感谢:

ahooks 官方文档及其Github

ahooks analysis: ahooks源码分析

转载自:https://juejin.cn/post/7245658681730924603
评论
请登录