我与hooks的这一年, 万字长文总结

5,727 阅读8分钟

前言

这一年注定是不平凡的一年,经历了疫情在家两个月封城。刚好 hooks 出来了,学习了一下,发现真香,根本停不下来,分享一下用了将近一年的心得,在 2020 年最后一天上了末班车

动机(官方)

  • 组件之间很难重用有状态逻辑
  • 复杂的组件变得难以理解
  • 类 class 混淆了人和机器
  • 更符合 FP 的理解, React 组件本身的定位就是函数,一个吃进数据、吐出 UI 的函数

常用 hook

useState

   const [state, setState] = useState(initialState)
  • useState 有一个参数,该参数可以为任意数据类型,一般用作默认值
  • useState 返回值为一个数组,数组的第一个参数为我们需要使用的 state,第二个参数为一个 setFn。
  • 完整例子
function Love() {
    const [like, setLike] = useState(false)
    const likeFn = () => (newLike) => setLike(newLike)
    return (
      <>
        你喜欢我吗: {like ? 'yes' : 'no'}
        <button onClick={likeFn(true)}>喜欢</button>
        <button onClick={likeFn(false)}>不喜欢</button>
      </>
    )
  }

关于使用规则:

  1. 只在 React 函数中调用 Hook;
  2. 不要在循环、条件或嵌套函数中调用 Hook。 让我们来看看规则 2 为什么会有这个现象, 先看看 hook 的组成
function mountWorkInProgressHook() {
	// 注意,单个 hook 是以对象的形式存在的
	var hook = {
		memoizedState: null,
		baseState: null,
		baseQueue: null,
		queue: null,
		next: null
	};
	if (workInProgressHook === null) {
        firstWorkInProgressHook = workInProgressHook = hook;
        /* 等价
            let workInProgressHook = hooks
            firstWorkInProgressHook = workInProgressHook
        */
	} else {
		workInProgressHook = workInProgressHook.next = hook;
	}
	// 返回当前的 hook
	return workInProgressHook;
}

每个 hook 都会有一个 next 指针,hook 对象之间以单向链表的形式相互串联, 同时也能发现 useState 底层依然是 useReducer 再看看更新阶段发生了什么

// ReactFiberHooks.js
const HooksDispatcherOnUpdate: Dispatcher = {
      // ...
     useState: updateState,
  }
  function updateState(initialState) {
    return updateReducer(basicStateReducer, initialState);
  }

function updateReducer(reducer, initialArg, init) {
    const hook = updateWorkInProgressHook();
    const queue = hook.queue;
    if (numberOfReRenders > 0) {
        const dispatch = queue.dispatch;
        if (renderPhaseUpdates !== null) {
            // 获取Hook对象上的 queue,内部存有本次更新的一系列数据
            const firstRenderPhaseUpdate = renderPhaseUpdates.get(queue);
            if (firstRenderPhaseUpdate !== undefined) {
                renderPhaseUpdates.delete(queue);
                let newState = hook.memoizedState;
                let update = firstRenderPhaseUpdate;
                // 获取更新后的state
                do {
                    // useState 第一个参数会被转成 useReducer
                    const action = update.action;
                    newState = reducer(newState, action);
                    //按照当前链表位置更新数据
                    update = update.next;
                } while (update !== null);
                hook.memoizedState = newState;
                // 返回新的 state 以及 dispatch
                return [newState, dispatch];
            }
        }
    }
    // ...
}

结合实际让我们看下面一组 hooks

    let isMounted = false
    if(!isMounted) {
        [name, setName] = useState("张三");
        [age] = useState("25");
        isMounted = true
    }
    [sex, setSex] = useState("男");
    return (
        <button
            onClick={() => {
            setName(李四");
            }}
        >
            修改姓名
        </button>
  );

首次渲染时 hook 顺序为

name => age => sex

二次渲染的时根据上面的例子,调用的 hook 的只有一个

setSex

所以总结一下初始化阶段构建链表,更新阶段按照顺序去遍历之前构建好的链表,取出对应的数据信息进行渲染当两次顺序不一样的时候就会造成渲染上的差异。

为了避免出现上面这种情况我们可以安装 eslint-plugin-react-hooks

// 你的 ESLint 配置
{
  "plugins": [
    // ...
    "react-hooks"
  ],
  "rules": {
    // ...
    "react-hooks/rules-of-hooks": "error", // 检查 Hook 的规则
    "react-hooks/exhaustive-deps": "warn" // 检查 effect 的依赖
  }
}

useEffect

useEffect(effect, array)

effect 每次完成渲染之后触发, 配合 array 去模拟类的生命周期

  • 如果不传,则每次 componentDidUpdate 时都会先触发 returnFunction(如果存在),再触发 effect
  • [] 模拟 componentDidMount
  • [id] 仅在 id 的值发生变化以后触发
  • 清除 effect
useEffect(() => {
  ChatAPI.subscribeToFriendStatus(props.id, handleStatusChange);
  return () => {
    ChatAPI.unsubscribeFromFriendStatus(props.id, handleStatusChange);
  };
});

useLayoutEffect

  • 跟 useEffect 使用差不多,通过同步执行状态更新可解决一些特性场景下的页面闪烁问题
  • useLayoutEffect 会阻塞渲染,请谨慎使用 对比看看
import React, { useLayoutEffect, useEffect, useState } from 'react';
import './App.css'
function App() {
    const [value, setValue] = useState(0);
    useEffect(() => {
        if (value === 0) {
            setValue(10 + Math.random() * 200);
        }
      }, [value]);
    const test = () => {
        setValue(0)
    }
    const color = !value  ? 'red' : 'yellow'
	return (
		<React.Fragment>
            <p style={{ background: color}}>value: {value}</p>
			<button onClick={test}>点我</button>
		</React.Fragment>
	);
}
export default App;

useContext

const context = useContext(Context)

useContext 从名字上就可以看出,它是以 Hook 的方式使用 React Context, 先简单介绍 Context 的概念和使用方式

import React, { useContext, useState, useEffect } from "react";
const ThemeContext = React.createContext(null);
const Button = () => {
  const { color, setColor } = React.useContext(ThemeContext);
  useEffect(() => {
    console.info("Context changed:", color);
  }, [color]);
  const handleClick = () => {
    console.info("handleClick");
    setColor(color === "blue" ? "red" : "blue");
  };
  return (
    <button
      type="button"
      onClick={handleClick}
      style={{ backgroundColor: color, color: "white" }}
    >
      toggle color in Child
    </button>
  );
};
// app.js
const App = () => {
  const [color, setColor] = useState("blue");

  return (
    <ThemeContext.Provider value={{ color, setColor }}>
      <h3>
        Color in Parent: <span style={{ color: color }}>{color}</span>
      </h3>
      <Button />
    </ThemeContext.Provider>
  );
};

useReducer

const [state, dispatch] = useReducer(reducer, initialArg, init)

语法糖跟 redux 差不多,放个基础 🌰

function init(initialCount) {
    return {count: initialCount};
}
function reducer(state, action) {
    switch (action.type) {
        case 'increment':
            return {count: state.count + 1};
        case 'decrement':
            return {count: state.count - 1};
        case 'reset':
            return init(action.payload);
        default:
            throw new Error();
    }
}
function Counter({initialCount}) {
    const [state, dispatch] = useReducer(reducer, initialCount, init);
    return (
        <>
        Count: {state.count}
<button
    onClick={() => dispatch({type: 'reset', payload: initialCount})}>
    Reset
</button>
<button onClick={() => dispatch({type: 'increment'})}>+</button>
<button onClick={() => dispatch({type: 'decrement'})}>-</button>
</>
);
}

useRef

const refContainer = useRef(initialValue);

useRef 返回一个可变的 ref 对象,其 .current 属性被初始化为传入的参数(initialValue)。返回的 ref 对象在组件的整个生命周期内保持不变

  • 解决引用问题--useRef 会在每次渲染时返回同一个 ref 对象

  • 解决一些 this 指向问题

  • 对比 createRef -- 在初始化阶段两个是没区别的,但是在更新阶段两者是有区别的。

  • 我们知道,在一个局部函数中,函数每一次 update,都会在把函数的变量重新生成一次。 所以我们每更新一次组件, 就重新创建一次 ref, 这个时候继续使用 createRef 显然不合适,所以官方推出 useRef。useRef 创建的 ref 仿佛就像在函数外部定义的一个全局变量,不会随着组件的更新而重新创建。但组件销毁,它也会消失,不用手动进行销毁

    总结下就是 ceateRef 每次渲染都会返回一个新的引用,而 useRef 每次都会返回相同的引用

useMemo

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

一个常用来做性能优化的 hook,看个 🌰

const MemoDemo = ({ count, color }) => {
   useEffect(() => {
       console.log('count effect')
   }, [count])
   const newCount = useMemo(() => {
       console.log('count 触发了')
       return Math.round(count)
   }, [count])
   const newColor = useMemo(() => {
       console.log('color 触发了')
       return color
   }, [color])
   return <div>
       <p>{count}</p>
       <p>{newCount}</p>
   {newColor}</div>
}

我们这个时候将传入的 count 值改变 的,log 执行循序

count 触发了

count effect

  • 可以看出有点类似 effect, 监听 a、b 的值根据值是否变化来决定是否更新 UI
  • memo 是在 DOM 更新前触发的,就像官方所说的,类比生命周期就是 shouldComponentUpdate
  • 对比 React.Memo 默认是是基于 props 的浅对比,也可以开启第二个参数进行深对比。在最外层包装了整个组件,并且需要手动写一个方法比较那些具体的 props 不相同才进行 re-render。使用 useMemo 可以精细化控制,进行局部 Pure

useCallback

const memoizedCallback = useCallback(
 () => {
   doSomething(a, b);
 },
 [a, b],
);

useCallback 的用法和上面 useMemo 差不多,是专门用来缓存函数的 hooks

// 下面的情况可以保证组件重新渲染得到的方法都是同一个对象,避免在传给onClick的时候每次都传不同的函数引用
import React, { useState, useCallback } from 'react'

function MemoCount() {
   const [value, setValue] = useState(0)

   memoSetCount = useCallback(()=>{
       setValue(value + 1)
   },[])

   return (
       <div>
           <button
               onClick={memoSetCount}
               >
               Update Count
           </button>
           <div>{value}</div>
       </div>
   )
}
export default MemoCount

自定义 hooks

自定义 Hook 是一个函数,其名称以 “use” 开头,函数内部可以调用其他的 Hook 一般我将 hooks 分为这几类

util

顾名思义工具类,比如 useDebounce、useInterval、useWindowSize 等等。例如下面 useWindowSize

import { useEffect, useState } from 'react';
export default function useWindowSize(el) {
   const [windowSize, setWindowSize] = useState({
       width: undefined,
       height: undefined,
   });
   useEffect(
       () => {

           function handleResize() {
               setWindowSize({
                   width: window.innerWidth,
                   height: window.innerHeight,
               });
           }

           window.addEventListener('resize', handleResize);
           handleResize();
           return () => window.removeEventListener('resize', handleResize);
       },
       [el],
   );
   return windowSize;
}

API

像之前的我们有一个公用的城市列表接口,在用 redux 的时候可以放在全局公用,不用的话我们就可能需要复制粘贴了。有了 hooks 以后我们只需要 use 一下就可以在其他地方复用了

import { useState, useEffect } from 'react';
import { getCityList } from '@/services/static';
const useCityList = (params) => {
   const [cityList, setList] = useState([]);
   const [loading, setLoading] = useState(true)
   const getList = async () => {
       const { success, data } = await getCityList(params);
       if (success) setList(data);
       setLoading(false)
   };
   useEffect(
       () => {getList();},
       [],
   );
   return {
       cityList,
       loading
   };
};
export default useCityList;
// bjs
function App() {
   // ...
   const { cityList, loading } = useCityList()
   // ...
}

logic

逻辑类,比如我们有一个点击用户头像关注用户或者取消关注的逻辑,可能在评论列表、用户列表都会用到,我们可以这样做

import { useState, useEffect } from 'react';
import { followUser } from '@/services/user';
const useFollow = ({ accountId, isFollowing }) => {
    const [isFollow, setFollow] = useState(false);
    const [operationLoading, setLoading] = useState(false)
    const toggleSection = async () => {
        setLoading(true)
        const { success } = await followUser({ accountId });
        if (success) {
            setFollow(!isFollow);
        }
        setLoading(false)
    };
    useEffect(
        () => {
            setFollow(isFollowing);
        },
        [isFollowing],
    );
    return {
        isFollow,
        toggleSection,
        operationLoading
    };
};
export default useFollow;

只需暴露三个参数就能满足大部分场景

UI

还有一些和 UI 一起绑定的 hook, 但是这里有点争议要不要和 ui 一起混用。就我个人而言一起用确实帮我解决了部分复用问题,我还是分享出来。

import React, { useState } from 'react';
import { Modal } from 'antd';
// TODO 为了兼容一个页面有多个 modal, 目前想法通过唯一 key 区分,后续优化
export default function useModal(key = 'open') {
    const [opens, setOpen] = useState({
        [key]: false,
    });
    const onCancel = () => {
        setOpen({ [key]: false });
    };
    const showModal = (type = key) => {
        setOpen({ [type]: true });
    };
    const MyModal = (props) => {
        return <Modal key={key} visible={opens[key]} onCancel={onCancel} {...props} />;
    };
    return {
        showModal,
        MyModal,
    };
}
// 使用
function App() {
    const { showModal, MyModal } = useModal();
    return <>
          <button onClick={showModal}>展开</button>
          <MyModal onOk={console.log} />
       </>
}

逻辑跨端

最近听了第十五届的 D2 大会当轩大佬的《跨端的另一种思路》——Write Once 的分享,核心就是如何渲染、如何布局等 UI 层面的变化要远远大于业务逻辑层面,甚至是小程序和 Flutter,其大致的开发范式都没有发生太大的改变,Flutter 开发范式和 React 非常相似,同样是声明式 UI,同样存在 VirtualDOM。

同样一段 useCount 的代码,通过抽象 AST 到 dart, 如下

上面的算是高级应用,我们日举个简单例子。一个项目要做 pc 站点又要做移动端,在不考虑双端业务是否合理的情况下,这种情况 ui 能复用的地方不太多,但是业务逻辑能大量通过 hooks 进行复用,也算是是一个伪逻辑跨端

总结

越来越多的 react 配套的三方库都上了 hooks 版,像 react-router、redux 都出了 hooks。同时也出现了一些好用的 hooks 库,比如 ahooks 这种。自从用了 hooks 以后我就两个字,真香