阅读 5700

让我们坐上 Hooks 的托马斯小火车

在 React 16 中,除去 Fiber 架构外,Hooks 是最令人激动的一个特性,相比于 class component,Hooks 加持后的 function component 在写法与思路上都大有不同,很多时候显得更为简洁与清爽(熵更低,弱化生命周期的概念),同时解决了令人烦恼的 this 指针指向问题,还是很香的。

但理性来说,到目前为止,hooks 还是一个坑很多的阶段,并且也缺乏一个成体系的最佳实践,以下谈谈我对 hooks 的一些浅薄的认识。

那么,是时候发车了。

常用Hooks

useState

我们可以把这里的 state 看做我们在 class component 中使用的 this.state。

我们的每一次 setState 操作,在改变了值之后,都会引发 rerender 操作,从而触发页面的更新(但是如果没有改变的话,则不会触发 rerender,我们在后面将会利用这一特性做一件有趣的事情)。

同时,setState 可以以函数作为参数,这个时候我们可以获取到最新的 state 值(在第一个回调参数)。

import React, { useState } from 'react';

function App() {
    const [ state, setState ] = useState(0);
    return (
        <span>{state}</span>
    )
}
复制代码

useEffect

useEffect可以说是所有 hooks API 中最像是声明周期的钩子了,很容易让人理解成为,如果依赖数组为空,那么它等价为 componentDidMount,但是真的这样吗?

我们可以这样去理解我们的函数组件,函数组件的每次运行都相当于 class component 中的一次 render,每轮都会保留它的闭包,所以,我们的 useEffect 实际保留了它运行轮次的 state 和 props 状态(如果依赖不更新,那么状态不更新),这也就是 useEffect 和 componentDidMount 生命周期的关系。

import React, { useEffect } from 'react';
function App() {
    useEffect(() => {
        console.log('I am mount');
        return () => {
            console.log('before next run, I am cleaned');
        }
    }, []);
复制代码

useLayoutEffect

useLayoutEffect 与 useEffect 的不同在于,useLayoutEffect 会在 DOM 渲染之前执行,而 useEffect 会在 DOM 渲染之后执行,所以我们可以利用这个特性,避免一些由于 DOM 渲染之后进行操作导致的白屏问题。

useCallback

useCallback 可以帮助我们缓存函数(useMemo同样可以做到,写法不同),通过手动控制依赖,做到减少因为函数的更新导致子组件的更新(带来的性能问题非常明显)

import React, { useCallback } from 'react';
function App() {
    const cb = useCallback(() => { console.log('callback') }, []);
    return (
        <button onClick={cb}></button>
    )
}
复制代码

useMemo

useMemo 可以为我们的 function component 提供缓存的能力,在一些重计算的场景下,可以减少重复计算的次数,起到明显的性能提升。 当然,useMemo同样可以用来缓存组件,起到类似与 class component 中 shouldComponentUpdate 的作用,让我们手动通过管理依赖的方式做到控制子组件的更新(当然这个手动管理的成本是非常高的)

useRef

因为在 hooks 中,我们所声明的所有变量是只属于它的闭包的,所以,我们无法做到变量的一个共享。因为 immutable 的 state 并不适合我们存储一些不参与 UI 显示的变量。hooks 为我们提供了 useRef 去存储 mutable 的不参与 UI 显示的变量,并且可以在每一轮 render 中共享。

useRef 不仅可以用来存储对 Dom 元素的引用(它的本意),更可以用来存储我们需要在每轮 render 中共享的 mutable 的变量(可能非常常用)。

import React, { useRef } from 'react';
function App() {
  const td = useRef(1);
  console.log(td.current); // 1
  ...
复制代码

useReducer

在当前版本中的 useReducer 事实上是对 useState 的一层封装,实现了 redux 的一套原理(之前的版本是 useState 是对 useReducer 的一层封装)

function useReducer(reducer, initialState) {
  const [state, setState] = useState(initialState);

  function dispatch(action) {
    const nextState = reducer(state, action);
    setState(nextState);
  }

  return [state, dispatch];
}
复制代码

useContext

假定我们已经有了一个 Context ,并且我们的子组件已经在 Provider 包裹下,我们可以直接使用 useContext 去获取值,而非使用回调去获取值。 同时,我们也可以对某些 Context 进行 useContext 的封装,让我们可以在不同的组件中方便的使用 Context 中的数据。

// 假定我们已经有 Context
function Child(props) {
    const { value } = useContext(Context);
    return (
      <div>
        {value}
      </div>
    )
}
复制代码

我们可以将 Context 、useReducer 与 useContext 结合起来,打造我们自己的 Redux

const CTX = React.createContext(null);
const reducer = (state, action) => {
    switch(action.type) {
        default:
            reutrn state;
    }
}
const Context = function({ children }) {
    const [state, dispatch] = useReducer(reducer, {});
    return (
        <CTX.Provider value={ state, dispatch }>
            {children}
        </CTX.Provider>
    )
}
复制代码

怎么理解Hooks中的状态

我们应该树立一个理念,在 function component 中,所有的状态,都是隶属于它的闭包的,所以导致了 我们每一轮的 Render 都会有自己的一个闭包,所有的 useEffect 与 useLayoutEffect 都在其最后一次更新的闭包中 Hooks处理请求

正确处理依赖

hooks 编程有些类似于响应式编程,同时,为了可以总是拿到最正确的值,正确的去书写 hooks依赖 是非常重要的,也就是所谓的对依赖诚实,这样才能保证我们最终发送请求之时,可以取到正确的 state 和 props。

放置依赖的请求于 useEffect 中

为了能够正确的处理请求,有一种想法是——将请求的函数放置于 useEffect 中,这样子就可以确保我们每时每刻都会去正确的处理其中的依赖问题。 处理竞态 我们知道,在 hooks 里,每一次 Render 以及 每一次 useEffect 的执行都是在它自己所处轮次的闭包中,所以,我们处理竞态的一个思路就来源于这里。

我们的依赖变化会触发我们的 ajax 操作,所以当第二次请求发生时,实际上上一次 effect 已经到了清理副作用时期,所以执行了 return 中的函数,将我们的flag置为true,这样,当我们的请求返回之时,其effect 所在的闭包是可以感知到执行结束的状态的,从而抛弃旧值,达到对竞态的正确处理。

useEffect(() => {
    let flag = false;
    ajax().then(res => {
        if (!flag) {
            //...do something
        }
    })
    return () => {
        flag = true;
    }
}, [deps])
复制代码

请求与触发分离

请求

我们可以将请求函数用普通函数的方法,放置于整个 function 中,这样足以确保我们这个函数能够拿到当前 render 轮次所依赖的 state 和 props,如果有性能方面的顾虑,可以考虑使用 useCallback 去进行包装(但此时一定要对依赖诚实)

function App() {
    const [flag, setFlag] = useState(0);

    const ajax = () => {
       _ajax(props)
    };

    useEffect(() => {
        ajax();
    }, [flag]);

    return (
        ...
    )
}
复制代码

触发

这个时候一定要注意的一点是,我们的触发 flag,一定要在最后修改(先进行预操作——其它的 state 修改),确定我们的 effect 更新时,索引用的,是最新的 ajax 请求函数。

非渲染参数使用 ref 进行保存 因为我们在 effect 中,永远可以正确的获取到 ref 值,所以,当我们的参数不参与渲染时,我们可以用 useRef 生成的 ref 对其进行管理,这样我们就可以不用去担心由于 ref 所引用参数的变化问题(同时,也不会触发页面的 rerender)

const name = useRef('小明')
const ajax = useCallback(() => {
    ajax({ name })
}, []);

// 修改 param 直接操作 ref
name.current = '123';
复制代码

极限性能Trick

减少计算

依赖数组欺骗

利用 setState 的回调处理获取 state 的问题 因为

const [state, setState] = useState(0);

// 利用 setState 的回调拿到最新的 state,返回原值,可以不触发 rerender(极端情况下可以用于性能优化)
const update = useCallback(() => {
    setState(state => {
        // 做你想做的任何事情
        return state;
    })
}, []);
复制代码

试想一个很骚的场景,如果我们使用 setter 嵌套(并且都返回原始值),那么我们是不是可以在无任何依赖情况下用 state 做任何想做的事情呢(代码可读性忽略)

const trigger = useCallback(() => {
    setState1(state1 => {
        setState2(state2 => {
            console.log(state1 + state2);
            return state2;
        })
        return state1;
    })
});
复制代码

利用 useReducer 和 setState 结合处理获取 state 和 props 的问题 因为上面的方法,我们只能确保我们可以无依赖的拿到 state ,但是我们却不能在无依赖的情况下拿到 props 那么我们可以怎么办呢。 我们可能把 useReducer 的 reducer 放在 function component 函数体内,利用 dispatch 最终触发的是最新的闭包中的 reducer 来确保我们可以拿到处于最新状态的 props

function App({ a, b, c }) {
    const reducer = (state, action) => {
        switch(action.type) {
            case 'init':
                // 这里永远可以拿到最新的 a
                return Object.assign(state, { a: a });
            default:
                return state;
        }
    }
    const [state, dispatch] = useReducer(reducer, {});
    return (
        <div>{ state.a }</div>
    )
}
复制代码

减少Render

我们可以做类似于 class component 中的 PureComponent 这样的操作,我们可以用 React.memo 包裹大部分的组件(会带来额外的比较,性能不一定是最佳的)

直接使用 React.memo

利用 React.memo,我们可以做到让 React 对我们的组件进行浅比较,

const Child = function({ a, b, c }) {
    return <div>{a}{b}{c}</div>
} 
export default React.memo(Child);
复制代码

使用 useMemo 进行细粒度的控制

function App({ a, b, c }) {
    const RenderComponent = useMemo(() => {
        return <div>{c}</div>
    }, [c]);
    return (
        <RenderComponent />
    )
}
复制代码

使用 useCallback 包裹函数,使函数变化减少

在这里,可以使用我上面所介绍的 trick ,减少依赖的数量,从而减少 rerender 的次数 Eg: trigger.jsx 开关

const useTrigger = () => {
    const [state, setState] = useState(false);
    const trigger = useCallback(() => {
        setState(ste => !ste);
    }, []);
    return { state, trigger };
}

// vs

const useTrigger = () => {
    const [state, setState] = useState(false);
    const trigger = useCallback(() => {
        setState(ste => !ste);
    }, [state]);
    return { state, trigger };
}
复制代码

Hooks 实践

谈谈表单

像双向数据绑定那样编写表单

const useInput = () => {
  const [value, setValue] = useState('');
  const onChange = val => {
    setValue(val.target.value);
  };
  return {
    value,
    onChange
  };
};
复制代码

表单提交

export const useSubmit = submitFunction => {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const [res, setRes] = useState(null);

  const trigger = useCallback(() => {
    try {
      if (_.isFunction(submitFunction)) {
        (async () => {
          let res = await submitFunction();
          if (res) {
            setRes(res);
          }
        })();
      }
    } catch (e) {
      setError(e);
    } finally {
      setLoading(true);
    }
  }, [submitFunction]);

  return [loading, res, error];
};
复制代码

利用 useMemo 操作 props 数据

很多时候,我们都会依赖于 props 去计算我们的 state,在 class component 中给我们提供了 getDerivedStateFromProps 生命周期供我们去做类似的操作,但是在 hooks 里,我们并没有这样的生命周期的概念,那我们应该如何去做呢?

我们可以利用 useMemo 去进行对 props 的计算操作,通过正确处理依赖,就可以籍由 useMemo 的记忆特性,让我们以最小的成本去正确的更新 state (高成本的方案是每一次去计算将值赋给闭包中的普通变量)。

import React, { useMemo } from 'react';

function App({ data }) {
    // 只有 data 更新时重新计算
    const info = useMemo(() => {
        // 对 data 进行一系列的计算操作
        return newData;
    }, [data]);
}
复制代码

利用 hooks 返回组件

之前所说的大都是利用 hooks 去处理逻辑问题,那么 hooks 是否可以像是高阶组件那样,为我们返回一个组件呢,答案是可以的,并且利用这样的能力,我们还可以简化很多情况下我们的编程。

import React, { useState, useCallback } from 'react';
import { Modal } from 'antd';

export default function useModal() {
  const [show, setShow] = useState<boolean>(false);

  const openModal = useCallback(() => {
    setShow(true);
  }, []);

  const closeModal = useCallback(() => {
    setShow(false);
  }, []);

  const CusModal: React.SFC = ({ children, ...props }) => {
    return (
      <Modal
        visible={show}
        {...props}>
        {children}
      </Modal>
    )
  }

  return {
    show,
    setShow,
    openModal,
    closeModal,
    CusModal
  }
}
复制代码

利用 ref hooks 进行一些无侵入操作(react 官方 不推荐) 因为 ref 可以拿到原始 dom,我们可以利用这个特性做一些操作,例如说侵入代码性的埋点迁移至 ref(减少对原始代码侵入)

eg:利用 ref 记录停留时间(可以做无侵入埋点)

export const useHoverTime = eventName => {
  const EV = `${ eventName}`;
  const ref = useRef(null);

  useEffect(() => {
    localStorage.setItem(EV, 0);
    return () => {
      const time = localStorage.getItem(EV);
      // do something 
      localStorage.setItem(EV, null);
    };
  }, []);

  useEffect(() => {
    let startTime = null;
    let endTime = null;
    const overHandler = () => {
      startTime = new Date();
    };
    const outHandler = () => {
      endTime = new Date();
      localStorage.setItem(
        EV,
        parseInt(localStorage.getItem(EV)) +
        parseInt(endTime - startTime)
      );
      startTime = 0;
      endTime = 0;
    };
    if (ref.current) {
      ref.current.addEventListener('mouseover', overHandler);
      ref.current.addEventListener('mouseout', outHandler);
    }
    return () => {
      if (ref.current) {
        ref.current.removeEventListener('mouseover', overHandler);
        ref.current.removeEventListener('mouseout', outHandler);
      }
    };
  }, [ref]);
  return ref;
};
复制代码

React-hook-form 利用 ref 进行的表单的注册和提交拦截(个人认为也是一种非常清奇的思路)

Hooks with Immer.js

immutable.js 的使用复杂度是非常高的,但是有时候我们又希望我们的 React App 性能更好,节省不必要的 rerender,那么 Immer.js 就是一个非常好的选择(事实上dva也使用了immer作为底层库)

我们可以在使用 useReducer 的时候,使用 Immer 进行状态的变更,从而使得我们最新的 state 是 immutable 的。

const reducer = (state, action) => {
  switch (action.type) {
    case 'initData':
      return produce(state, draft => {
        draft.data = action.data;
      });
复制代码

让 useReducer 用上 Redux 中间件生态

从何种角度看,useReducer + useContext + Context 的组合都在做传统 Redux 所在做的事情,那么,有没有可能让我们的原生 hooks 使用上 Redux 的中间件呢(本质上劫持了 action ,与 Redux 的 Api 无关)?! 是可以的,事实上,这里相当于把 Redux 中间件的实现迁移到了 hooks 上,我们当然可以自己实现,但是 react-use 这个库里帮我们做了集成,我们可以方便的直接使用它。

// 创建增强了中间件的 reducer , 这里的例子增加了 redux-logger 与 redux-thunk
const useLoggerReducer = createReducer(logger, thunk);

export default function App() {
  const [state, dispatch] = useLoggerReducer(reducer, initState);
复制代码

这样子,我们便可以利用 redux-thunk、redux-saga 等中间件进行异步任务的处理,使用 redux-logger 进行 action 的打印和前后 state 的 diff。

打造自己的 combindReducer (代码思路来源于 Medium)

const combineReducers = (reducers) => {
    const keys = Object.keys(reducers);
    const initObj = {};
    keys.forEach(key => {
        let draftState = reducers[key](undefined, { type: '' });
        if (!draftState) {
            draftState = {};
            console.warn(
                `[Error]: 在combineReducers 中 Reducer 需要初始化!`
            );
        }
        initObj[key] = draftState;
    })
    return (state, action) => {
        keys.forEach(key => {
            const prevState = initObj[key];
            initObj[key] = reducers[key](prevState, action);
        });
        return { ...initObj };
    }
}
复制代码

将它和我们的增强后的 useReducer 结合起来,我们便拥有了一个几乎可以媲美 redux 的 reducer。

Hooks 第三方工具集合 react-use

可能是目前社区中获得 star 和关注最多的自定义 hooks 项目,提供了非常多的自定义 hooks(很多是香的) react-use

Hooks 请求工具 swr

在 React hooks 雨后春笋般的请求库中,最为亮眼的当属于 swr。详情请见官方 github 仓库 swr

Hooks 第三方表单库 react-hook-form

一个很好用的 react-hook-form 表单库。详情请见官方 github 仓库 react-hook-form

关注下面的标签,发现更多相似文章
评论