制定专属自己的 React Hooks

4,282 阅读12分钟

Hooks 是 16.7.0-alpha 新加入的新特性,目的解决状态逻辑复用,使无状态组件拥有了许多状态组件的能力,如自更新能力(useState)、访问ref(useRef)、上下文context(useContext)、更高级的setState(useReducer)及周期方法(useEffect/useLayoutEffect)及缓存(useMemo、useCallback)。其底层实现没有太多变动,整体更接近函数式语法,逻辑内聚,高阶封装这两大特点,让你同时领悟到 Hooks 的强大与优雅。

如果你已经厌倦写诸如修改网页标题,判断用户离线状态,监听页面大小,用户手机状态(电池、螺旋仪...),说明你已经不甘心做一个重复劳动的开发者 ,那么自定义hooks非常适合你。只想关注Custom Hooks,F 传送!!!!

在阅读本文之前,建议unLearning,也就是忘记你之前学会的“React” ,它已经不是那个“它”了,否则只会给你带来“误导”。

ps: 为了更好的阅读体验,- 表示删减代码, + 表示新增代码,* 表示修改行

本文custom Hooks repo

个人Blog

useState

看这篇解析之前,我们已经知道自己的水平,岂能像新手一样先看api? 当然是要先从源码入手。

alt

function useState<S>(initialState: (() => S) | S) {
  const dispatcher = resolveDispatcher();
  return dispatcher.useState(initialState);
}

WTF ??? alt

回到正题,做人怎么能手高眼低呢?

高手,当然是要先从源码入手。

我们先看下官方的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的方案 —— 就是 useStatedispatch 函数 。

 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 。 alt 剖析 引用

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,这个属性原来设计来是用来存储 ClassComponentstate 的,因为在 ClassComponentstate 是一整个对象,所以可以和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,
  );
}

alt

咳,这个方法可谓是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 可返回一个清理函数,大多数运用于 addEventListenerremoveEventListener

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下,但实际运行结果时会发现有一瞬间跳动效果。

alt

当把Message组件内的 useEffect 换成useLayoutEffect就正常了。 Edit charming-surf-wz9fk

原因是虽然useEffect在浏览器绘制后执行,也代表着它会在新渲染之前触发。需要执行新的渲染之前它会先刷新现有的effects。

什么?你不信?

alt 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次,弹出来的会是什么?

alt 与classComponent不同,它访问的而是this。而不是闭包。

componentDidUpdate() {
    setTimeout(() => {
      console.log(`You clicked ${this.state.count} times`);
    }, 3000);
  }

alt

在定时器里执行的事件,完全依赖于闭包。可能你不认同,但是事实确是如此。

关于依赖项不要对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改变时,requesttagType 的值仍然是 hot。 你可能只是想挂载的请求它,但是 现在只需要记住:如果你设置了依赖项,effect中用到的所有组件内的值都要包含在依赖中。这包括 props,state,函数 — 组件内的任何东西。

后话

在组件年内请求数据时,经常会这么写

function App(){
    async function request () {
        // ...
        setList(result)
        setLoaded(false)
    }
    useEffect(()=>{
        request()
    },[])
    return null
}

在正常情况下访问当然没问题,当组件体积庞大或者请求速度慢时,你会收到“惊喜”。

alt 意思是还没请求完毕你就去到别的页面,导致effects内的 setList/setLoaded 无从下手送温暖。 这也是闭包的弊端 —— 无法及时销毁。还有一个解决方案是 AbortController

其实搞定这两个api就能完成80%的业务了。符合二八定律,即20%的功能完成80%的业务。封装自定义hooks大多数也需要它们。

useLayoutEffect

useLayoutEffect 名字与 useEffect 相差了一个 Layout。 顾名思义,它们的区别就是执行时机不一样,表示在 Layout 后触发。即 render 后。

源码如下:

alt

签名与 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];
}

第三个参数类似于 reduxinitialState,用于设置初始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? 但后来想想也对,叫备忘录也没错,毕竟是状态逻辑复用。

useMemoreselect库功能相同,都是依赖于传入的值,有固定的输入就一定有固定的输出。不必重新去计算,优化性能。在依赖不改变的情况下避免重新去计算浪费性能。

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

alt 该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

或许不需要单独拿出来。但还是忍不住凑字数。

alt

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

Edit serverless-morning-r2svr

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

Edit serverless-morning-r2svr

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判断防止报错。

Edit serverless-morning-r2svr

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

Edit serverless-morning-r2svr

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

Edit serverless-morning-r2svr

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),
        ), []),
  };
};

Edit serverless-morning-r2svr

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

Edit serverless-morning-r2svr

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

Edit serverless-morning-r2svr

useLockBodyScroll

这个钩子偶然看到的,针对防止遮罩滚动穿透。原地址

/**
 * 锁定body滚动条,多用于modal,后台
 */
const useLockBodyScroll = () => {
  useLayoutEffect(() => {
    const originalStyle = window.getComputedStyle(document.body)
      .overflow;
    document.body.style.overflow = 'hidden';
    return () => {
      document.body.style.overflow = originalStyle;
    };
  }, []);
};

Edit serverless-morning-r2svr

useTheme

你甚至可以自己切换主题配色,就像这样

/**
 * 更换主题
 * @param {*} theme 主题数据
 */
const useTheme = theme => {
  useLayoutEffect(() => {
    for (const key in theme) {
      document.documentElement.style.setProperty(
        `--${key}`,
        theme[key],
      );
    }
  }, [theme]);
};

Edit serverless-morning-r2svr

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

Edit serverless-morning-r2svr

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

Edit serverless-morning-r2svr

生命周期:从类组件到函数组件的过渡

截至目前 react 最新版本为 16.9 ,从图例中,探索各生命周期的实现方案。虽然没有理由再去使用 LifyCycle 了,但是了解下还是可以的。

alt

componentDidMount 与 componentWillUnmount

由于是函数组件,没有被实例化,就没有一套完整的 LifeCycle 。componentWillMountcomponentDidMount 只有顺序之分,放在组件顶部。

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。结合 useRefuseEffect 实现,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-usehook-guide 思路去开发的。相信掌握了hooks,你离成功剩下的只差一个Idea了。

Hooks描述
React Usehooks 工具库
useHistory管理历史记录栈
useScript动态添加脚本
useAuth用户状态
useWhyDidYouUpdatehook版Why-Did-You-Update
useDarkMode切换夜间模式

参考文献

如果你还觉得不错,star一下也是不错的like-hooks