React Hooks 实践

1,412 阅读5分钟

前言

Hooks 是 React 16.8 的新增特性。它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性。那么,我们为什么要使用 Hooks 呢?

  • 组件之间复用状态

在 16.8 之前,React并没有提供复用状态的一些方法。通常我们会使用 render props 和 higher-order components 等模式去解决这个问题。但是,如果用React DevTools 观察,会发现这些抽象层组件会造成 wrapper hell 。而 Hooks 允许我们在不修改组件结构的情况下进行状态的复用。

  • 组件越来越复杂

在我们用 class 编写组件时,每个生命周期常常包含一些不相关的逻辑。比如在 componentDidMount 时,可能会进行监听、获取数据等逻辑的调用,然后在 componentWillUnmount 进行清除。这些完全不相关的代码在同一个生命周期中组合在一起,十分不合理。Hooks 可以让我们将相互关联的部分拆分成更小的函数。

基本规则

  • 只在最顶层使用 Hook

不要在循环,条件或嵌套函数中调用 Hook, 确保总是在你的 React 函数的最顶层以及任何 return 之前调用他们。遵守这条规则,你就能确保 Hook 在每一次渲染中都按照同样的顺序被调用。

  • 只在 React 函数中调用 Hook

不要在普通的 JavaScript 函数中调用 Hooks。仅从函数组件或自定义 Hooks 中调用 Hooks。

  • Hook 并非普通函数,一般使用 use 开头命名。例如,useEffect 等。

实战进阶

不要将 hooks 方法类比 class component 生命周期。

首先,我们需要理解函数组件和类组件是有很大不同的。更新状态的时候,函数组件会被重新调用,每次函数执行拥有独立的状态。所以说函数组件相较于类组件缺失了 constructor 构造时。而对于 Effect Hook ,应该把它理解是为方便我们在函数组件中执行一些副作用操作的方法。在默认情况下,它在第一次渲染之后和每次更新之后都会执行。

了解到函数组件每次调用时,都拥有独立的变量这点,我们需要考虑闭包问题。在一般的使用过程中,是不会有闭包问题的。但是在延迟调用的场景下,一定会存在闭包问题。例如下面这段代码,我们在定时器时间内改变 count 的值,打印值依然为 0。

const [count, setCount] = useState(0);
useEffect(() => {
    const timer = setTimeout(() => {
      console.log(count);
    }, 3000);
    return () => {
      clearTimeout(timer);
    }
}, []);

穿透闭包的 useRef

useRef 其实是类组件 React.creatRef() 的等价替代。但是两者 ref 的引用是有着本质区别的:createRef 每次渲染都会返回一个新的引用,而 useRef 每次会返回相同的引用。如果在类组件中,如果我们使用的是 this.state.count 得到实时结果,useRef 也可以起到相同作用,解决闭包带来的不方便性。

另外,与视图渲染无关 state ,可以用 useRef 替换, 而且修改 ref 值不会引起视图更新。

bad:

const [timer, setTimer] = useState(-1);
useEffect(
    () => {
       if (data !== 200) {
       setTimer(1000);
     }
  },
  [data]
);

good:

const timerRef = useRef(-1);
useEffect(
    () => {
       if (data !== 200) {
       timerRef.current = 1000;
     }
  },
  [data]
);

函数组件的 re-render 问题

函数组件也存在类组件相同的 re-render 问题。例如,下面代码将不相关的逻辑组件合在一个组件中,导致 App 组件更新渲染时,产生新的 props 对象传给 Others 组件,引起 Others 组件的不必要渲染。

bad:

// setValue 会造成Others 组件渲染
export default function App() {
  let [value, setValue] = useState('');
  return (
    <div>
      <input value={value} onChange={(e) => setValue(e.target.value)} />
      <p>{value}</p>
      <Others />
    </div>
  );
}

good:

// v1
const MemoOthers = memo(Others);
export default function App() {
  let [value, setValue] = useState('');
  return (
    <div>
      <input value={value} onChange={(e) => setValue(e.target.value)} />
      <p>{value}</p>
      <MemoOthers />
    </div>
  );
}

==========================================================

// v2
const InputWrapper = () => {
  let [value, setValue] = useState('');
  return (
    <>
      <input value={value} onChange={(e) => setValue(e.target.value)} />
      <p>{value}</p>
    </>
  );
}
export default function App() {
  return (
    <div>
      <InputWrapper />
      <Others />
    </div>
  );
}

由上面可以了解到一些基本的优化方法,memo 的使用类似于类组件 PureComponent 的功能。当然,更加推荐的还是将动态、静态组件抽离,从上到下控制状态的传递。

Tips1:state分离与合并

bad:

const [left, setLeft] = useState(0);
const [right, setRight] = useState(0);
element.addEventListener('mousemove', e => {
     setLeft(e.offsetX);
     setRight(e.offsetY);
});

==========================================================

const [status, setStatus] = useState({
  modal: false,
  dialog: false,
  collapsed: false
});
const click = () => {
  setStatus(status => {
    ...status,
    modal: !status.modal
  });
};

good:

const [position, setPosition] = useState({
     left: 0,
     right: 0,
});

==========================================================

// 分成多个hook组件
const [modalStatus, setModalStatus] = useState(false);
const click = () => {
  setModalStatus(status => !status);
};

Tips2:jsx 中多层三元运算可以提取组件时,尽量提取组件。

bad:

return (
  <div>
    {flag
      ? (
          <div>
            <p>title</p>
            <p>content</p>
          </div>
      )
      : (flag2 ? (
              <div>title3</div>
          ) : (
              <div>
                <p>title2</p>
              </div>
          )
      )
    }
  </div>
);

good:

const Case = flag => {
    if(flag) {
    return <Title />;
  } else {
    if (flag2) {
      return <Title3 />;
    } else {
      return <Title2 />;
    }
  }
}
return (
  <div>
    <Case />
  </div>
);

Tips3:jsx短路求值

bad:

return (
  <div>
    // flag为false、null、''、undefined。
    // flag为0、NaN,会渲染出来。
    {flag && <Title1 />}
  </div>
);

good:

return (
  <div>
    {Boolean(flag) && <Title1 />}
    {!!flag && <Title1 />}
  </div>
);

那么,是否还有更深入的优化方法?

慎用 useMemo 、useCallback

useMemo 以及 useCallback 的用法类似,是专门用来缓存函数以及数据的。

我们可以用 useMemo 缓存一些相对耗时的计算。对于对象和数组,如果某个子组件使用了它作为 props,减少它的重新生成,就能避免子组件不必要的重复渲染,提高性能。

useCallback 可以记住函数,避免函数重复生成,这样函数在传递给子组件时,可以避免子组件重复渲染,提高性能。

除此以外,不建议滥用这两个 Hooks,会占用额外的空间。而且 deps 的频繁变动,会造成不必要的计算开销。

bad:

const res = useMemo(() => x + y + 1, [x, y]);
const func = useCallback(() => {
  xxx;
}, []);

good:

// 简单计算直接声明即可,简单计算和创建函数开销并不大。
const res = x + y + 1;
const func = () => {xxx};

// 但是如果需要传递子组件,需要useCallback包裹,保证函数引用不变,不会重复渲染;
const func = useCallback(() => {
  xxx;
}, []);
return (
  <>
    <div>{res}</div>
    <Simple getData={func} />
  </>
)

优雅的自定义 Hooks

通过自定义 Hooks,可以将组件逻辑提取到可重用的函数中,以减少代码复杂度。比如,常见的数据请求过程,可以进行封装,让我们更加专注于视图代码的编写。

bad:

useEffect(() => {
  async function query() {
    const res = await queryTypeList();
    setTreeData(res.data)
  }
  void query();
}, []);

good:

// v1
// 可以使用hooks封装请求过程。 也可直接使用ahooks等封装好的产品。
const { loading, run } = useRequest(queryTypeList, {
    manual: true,
    onSuccess: (result, params) => {
      setTreeData(result.data);
    }
});

useEffect(() => {
  run();
}, []);

==========================================================
  
// v2
// 甚至可以自己封装更具体
const [loading, error, data, run] = useFetch(queryTypeList);
useEffect(() => {
  run();
}, []);
return (
    loading ? 'xxx' : (
      error ? 'xxx' : data
  )
);