阅读 647

React Hooks学习总结

首先,本文只是本人在学习react-hooks的知识汇总,个人描述偏少,借鉴的文章较多。所有参考的文章都在文末列出,如果看过本篇意犹未尽,可以具体阅读其他的参考文章。

1.使用hooks的目的

1.1 类组件的特点

  • 大型组件很难拆分和重构,也很难测试。
  • 业务逻辑分散在组件的各个方法之中,导致重复逻辑或关联逻辑。
  • 组件类引入了复杂的编程模式,比如 render props 和高阶组件。
  • 类组件需要创建类组件的实例。

1.2 函数组件的特点

  • 不能包含状态
  • 不支持生命周期方法
  • 函数式组件不需要创建实例

1.3 函数式组件捕获了渲染所使用的值

function ProfilePage(props) {
  const showMessage = () => {
    alert('Followed ' + props.user);
  };

  const handleClick = () => {
    setTimeout(showMessage, 3000);
  };

  return (
    <button onClick={handleClick}>Follow</button>
  );
}
复制代码

结论:React Hooks 的设计目的,就是加强版函数组件,完全不使用"类",就能写出一个全功能的组件。

2.从渲染开始

函数式组件只是返回一个 React Element。我们渲染一个函数式组件时,只是执行了一次函数,得到了一个React Element,并没有产生实例,这使得函数式组件的propsstate等都是独立存在的,重新渲染函数式组件时只是重新执行了一次函数。

3.常用hooks

useState、useEffect、useLayoutEffect、useRef、useMemo、useCallback...

3.1 useState

使用方法:

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

与类组件相同,更改组件状态时会导致组件重新渲染,这里调用useState返回的setState也会导致函数组件重新执行。

function Acount() {
  console.log('---app run again----')
  const [num, setNum] = useState(0)
  console.log('---render----')
  console.log(`num:${num}`)
  return (
    <div className='App'>
      <p>{num}</p>
      <p>
        <button
          onClick={() => {
            setNum(num + 1)
          }}
        >
          +1
        </button>
      </p>
    </div>
  )
}
复制代码

点击两次按钮结果

img

可以在单个组件中使用多个 State Hook

使用多次useState

function Acount() {
  console.log('---app run again----')
  const [num1, setNum1] = useState(0)
  const [num2, setNum2] = useState(0)
  console.log('---render----')
  console.log(`num:${num1}`)
  console.log(`num:${num2}`)
  return (
    <div className='App'>
      <p>{num1}</p>
      <p>{num2}</p>
      <p>
        <button
          onClick={() => {
            setNum1(num1 + 1)
          }}
        >
          num1  +1
        </button>
        <button
          onClick={() => {
            setNum2(num2 + 1)
          }}
        >
          num1  +2
        </button>
      </p>
    </div>
  )
}
复制代码

img

结果互不影响。

useState的模拟实现(链表实现):

每次调用useState时,会生成一个链表节点,会把初始化的状态存起来,并将每一次调用生成的节点链接成链表,由于闭包的作用,返回的setState所能获取的节点都是那一次生产的链表节点,且每次重新渲染时会依照链表顺序进行遍历。

let firstWorkInProgressHook = {memoizedState: null, next: null};
let workInProgressHook;

function useState(initState) {
    let currentHook = workInProgressHook.next ? workInProgressHook.next : {memoizedState: initState, next: null};

    function setState(newState) {
        currentHook.memoizedState = newState;
        render();
    }
  	// 这就是为什么 useState 书写顺序很重要的原因
		// 假如某个 useState 没有执行,会导致指针移动出错,数据存取出错
    if (workInProgressHook.next) {
        // 这里只有组件刷新的时候,才会进入
        // 根据书写顺序来取对应的值
        workInProgressHook=workInProgressHook.next;
    } else {
        // 只有在组件初始化加载时,才会进入
        // 根据书写顺序,存储对应的数据
        // 将 firstWorkInProgressHook 变成一个链表结构
        workInProgressHook.next = currentHook;
        // 将 workInProgressHook 指向 {memoizedState: initState, next: null}
        workInProgressHook = currentHook;
    }
    return [currentHook.memoizedState, setState];
}

function Counter() {
    // 每次组件重新渲染的时候,这里的 useState 都会重新执行
    const [name, setName] = useState('计数器');
    const [number, setNumber] = useState(0);
    return (
        <>
            <p>{name}:{number}</p>
            <button onClick={() => setName('新计数器' + Date.now())}>新计数器</button>
            <button onClick={() => setNumber(number + 1)}>+</button>
        </>
    )
}

function render() {
    // 每次重新渲染的时候,都将 workInProgressHook 指向 firstWorkInProgressHook
    workInProgressHook = firstWorkInProgressHook;
    ReactDOM.render(<Counter/>, document.getElementById('root'));
}

render();
复制代码

由模拟实现看使用原则:

不要在循环,条件或嵌套函数中调用 Hook

3.2 useReducer

使用方法:

const [state, dispatch] = useReducer(reducer, initState);

使用时dispatch(action)来使用。具体使用方法同redux

适用情况:

主要时为了弥补useState的不足

  • 1.复杂的state操作

  • 2.嵌套的state对象

示例:

改造前:全部使用useState

示例链接

模拟了一次登录

function LoginPage() {
    const [name, setName] = useState(''); // 用户名
    const [pwd, setPwd] = useState(''); // 密码
    const [isLoading, setIsLoading] = useState(false); // 是否展示loading,发送请求中
    const [error, setError] = useState(''); // 错误信息
    const [isLoggedIn, setIsLoggedIn] = useState(false); // 是否登录

    const login = (event) => {
        event.preventDefault();
        setError('');
        setIsLoading(true);
        login({ name, pwd }) // 请求登录接口
            .then(() => {
                setIsLoggedIn(true);
                setIsLoading(false);
            })
            .catch((error) => {
                // 登录失败: 显示错误信息、清空输入框用户名、密码、清除loading标识
                setError(error.message);
                setName('');
                setPwd('');
                setIsLoading(false);
            });
    }
    return ( 
        //  返回页面JSX Element
    )
}
复制代码

使用useReducer进行改造

将繁琐的setState变成了action-reducer,使得更改状态的逻辑更加统一,便于管理。

const initState = {
    name: '',
    pwd: '',
    isLoading: false,
    error: '',
    isLoggedIn: false,
}
function loginReducer(state, action) {
    switch(action.type) {
        case 'login':
            return {
                ...state,
                isLoading: true,
                error: '',
            }
        case 'success':
            return {
                ...state,
                isLoggedIn: true,
                isLoading: false,
            }
        case 'error':
            return {
                ...state,
                error: action.payload.error,
                name: '',
                pwd: '',
                isLoading: false,
            }
        default: 
            return state;
    }
}
function LoginPage() {
    const [state, dispatch] = useReducer(loginReducer, initState);
    const { name, pwd, isLoading, error, isLoggedIn } = state;
    const login = (event) => {
        event.preventDefault();
        dispatch({ type: 'login' });
        login({ name, pwd })
            .then(() => {
                dispatch({ type: 'success' });
            })
            .catch((error) => {
                dispatch({
                    type: 'error'
                    payload: { error: error.message }
                });
            });
    }
    return ( 
        //  返回页面JSX Element
    )
}
复制代码

相比之下代码有更好的可读性,我们也能更清晰的了解state的变化逻辑。

useEffect中使用dispatch时,不需要在依赖数组中引入dispatch的依赖,因为React会保证dispatch在组件的声明周期内保持不变,可以有效减少依赖数组的大小。

function Counter() {
  const [count, setCount] = useState(0);
  const [step, setStep] = useState(1);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + step);
    }, 1000);
    return () => clearInterval(id);
  }, [step]);// 修改step会重启定时器 

  return (
    <>
      <h1>{count}</h1>
      <input value={step} onChange={e => setStep(Number(e.target.value))} />
    </>
  );
}
复制代码

假如我们不想在step改变后重启定时器,我们该如何从effect中移除对step的依赖呢?

依靠distpatch在组件的生命周期内保持不变的特性,可以使用useReducer来处理那些依赖于另一个状态的状态更新。

const initialState = {
  count: 0,
  step: 1,
};

function reducer(state, action) {
  const { count, step } = state;
  if (action.type === 'tick') {
    return { count: count + step, step };
  } else if (action.type === 'step') {
    return { count, step: action.step };
  } else {
    throw new Error();
  }
}

const [state, dispatch] = useReducer(reducer, initialState);
const { count, step } = state;

useEffect(() => {
  const id = setInterval(() => {
    dispatch({ type: 'tick' }); // Instead of setCount(c => c + step);
  }, 1000);
  return () => clearInterval(id);
}, [dispatch]);
复制代码

3.3 useEffect和useLayoutEffect

使用

1.useEffect(() => {

......

return cleanUpfunc

}, [deps]);

2.useLayoutEffect(() => {

......

return cleanUpfunc

}, [deps]);

依赖数组

useState的原理一样,useEffect也应当拥有一个链式或其他形式的存储区用于存放本次渲染的状态,即依赖数组。通过依赖数组可以控制effectsstate更新时的执行时机,避免effects不必要的重复调用。

注:

  • 1.依赖数组为空时,导致只有第一次渲染能够执行useEffect中的方法。

  • 2.第二个参数的比较是一个浅比较。

  • 3.effect hooks的返回值为清除函数,相当于componentWillUnmount的作用

例子: 有一个输入框和一个提交按钮,点击提交之后将输入框的内容同步到Count状态

function NewCounter() {
  const [input, setInput] = useState(0)
  const [count, setCount] = useState(0)
  useEffect(() => {
    setTimeout(() => {console.log(`count is ${count}`);}, 3000);
  }, [count])
  const handleChange = (e) => {
    setInput(e.target.value)
  }
  return (
    <div className="App">
      <input type="text" value={input} onChange={handleChange}/>
      <button onClick={() => setCount(input)}>提交</button>
    </div>
  );
}
复制代码

这个例子在输入与上一次相同的内容时不会执行useEffect的中方法。

执行时机

为了方便理解,一下对两种hooks的执行时机以及class的生命周期函数进行一次对比。

export default function LifeTime () {
  const [item, setItem] = useState(0)
  useEffect(() => {
    console.log(item);
    console.log('useEffect');
    return () => {
      console.log('effectDestory')
    }
  })
  useLayoutEffect(() => {
    console.log(item);
    console.log('useLayoutEffect');
    return () => {
      console.log('LayoutEffectDestory')
    }
  })
  function handleBtn() {
    console.log('changeItem');
    setItem(6)
  }
  return (
    <div>
      <button onClick={handleBtn}>change</button>
    </div>
  );
}
复制代码

初次渲染的时候

img

点击按钮,更改item,执行了组件销毁,并再次触发渲染

img

在父组件上引入一个类组件后进行对比,这里看似componentDidMount的执行处于useLayoutEffectuseEffect之间。

img

实际上在更改了类组件与函数组件的顺序后,说明类组件的生命周期方法和useLayoutEffect执行时机没有区别。

img

useEffect 在渲染时是异步执行,并且要等到浏览器将所有变化渲染到屏幕后才会被执行。

useLayoutEffect 在渲染时是同步执行,其执行时机componentDidMountcomponentDidUpdate 一致。

所以建议将修改 DOM 的操作放到 useLayoutEffect 里,以减少浏览器进行回流、重绘。

具体的执行过程: www.cnblogs.com/iheyunfei/p…

不过,正如DAN在《useEffect 完整指南》中所说“effects的心智模型和componentDidMount以及其他生命周期是不同的,试图找到它们之间完全一致的表达反而更容易使你混淆。”

从根本上来讲,react-hooks的作用是一种同步的作用同步hooks函数内部的内容与外部的props以及state,所以才会在每次render之后执行useEffect里面的函数,这时可以获取到当前render结束后的propsstate,来保持一种同步。

3.4 useMemo和useCallback

useMemo和useCallback主要用来解决使用React hooks产生的无用渲染的性能问题。由于使用function的形式来声明组件,失去了shouldCompnentUpdate(在组件更新之前)这个生命周期,也就是说我们没有办法通过组件更新前条件来决定组件是否更新。而且在函数组件中,也不再区分mountupdate两个状态,这意味着函数组件的每一次调用都会执行内部的所有逻辑,就带来了非常大的性能损耗

使用

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
const memoizedCallback = useCallback(
  () => {
    doSomething(a, b);
  },
  [a, b]
);
复制代码

useMemouseCallback都会在组件第一次渲染的时候执行,之后会在其依赖的变量发生改变时再次执行;并且这两个hooks都返回缓存的值,useMemo缓存函数的返回值,useCallback缓存的函数。

3.5 useMemo

看一个例子:


export default function WithoutMemo() {
    const [count, setCount] = useState(1);
    const [val, setValue] = useState('');
 
    function expensive() {
        console.log('compute');
        let sum = 0;
        for (let i = 0; i < count * 100; i++) {
            sum += i;
        }
        return sum;
    }
 
    return( <div>
        <h4>{count}-{val}-{expensive()}</h4>
        <div>
            <button onClick={() => setCount(count + 1)}>+c1</button>
            <input value={val} onChange={event => setValue(event.target.value)}/>
        </div>
    </div>);
}
复制代码

这里创建了两个state,然后通过expensive函数,执行一次计算,拿到count对应的某个值。我们可以看到:无论是修改count还是val,由于组件的重新渲染,都会触发expensive的执行(能够在控制台看到,即使修改val,也会打印);

img

但是这里的昂贵计算只依赖于count的值,在val修改的时候,是没有必要再次计算的。在这种情况下,我们就可以使用useMemo,只在count的值修改时,执行expensive计算。

export default function WithMemo() {
    const [count, setCount] = useState(1);
    const [val, setValue] = useState('');
    const expensive = useMemo(() => {
        console.log('compute');
        let sum = 0;
        for (let i = 0; i < count * 100; i++) {
            sum += i;
        }
        return sum;
    }, [count]);
 
    return (<div>
        <h4>{count}-{expensive}</h4>
        {val}
        <div>
            <button onClick={() => setCount(count + 1)}>+c1</button>
            <input value={val} onChange={event => setValue(event.target.value)}/>
        </div>
    </div>);
}
复制代码

img

使用useMemo来执行计算,然后将计算值返回,并且将count作为依赖值传递进去。这样,就只会在count改变的时候触发expensive执行,在修改val的时候,返回上一次缓存的值。

3.6 useCallback

segmentfault.com/a/119000002…

由于函数式组件内的函数在每次渲染都不同,而useCallback本质上是添加了一层依赖检查。它解决了每次渲染都不同的问题,我们可以使函数本身只在需要的时候才改变。

使用场景

  • 1.有一个父组件,其中包含子组件,子组件接收一个函数作为props
  • 2.dom的事件处理函数

示例

组件中的getData方法通过props的形式传给子组件

// 用于记录 getData 调用次数
let count = 0;

function App() {
  const [val, setVal] = useState("");

  function getData() {
    setTimeout(() => {
      setVal("new data " + count);
      count++;
    }, 500);
  }

  return <Child val={val} getData={getData} />;
}

function Child({val, getData}) {
  useEffect(() => {
    getData();
  }, [getData]);

  return <div>{val}</div>;
}
复制代码

分析下代码的执行过程:

    1. App渲染Child,将valgetData传进去
    1. Child使用useEffect获取数据。因为对getData有依赖,于是将其加入依赖列表
    1. getData执行时,调用setVal,导致App重新渲染
    1. App重新渲染时生成新的getData方法,传给Child
    1. Child发现getData的引用变了,又会执行getData

3 -> 5 是一个死循环

useEffect(() => {
  getData();
}, []);
// 由于使用了外部传递的props,依据规则需要将getData放入依赖数组中。
// 如果装了 hook 的lint 插件,会提示:React Hook useEffect has a missing dependency

// 改变策略,对getData本身进行缓存
const getData = useCallback(() => {
  setTimeout(() => {
    setVal("new data " + count);
    count++;
  }, 500);
}, []);
复制代码

有助于性能改善的,有 2 种场景:

  • 函数定义时需要进行大量运算
  • 需要比较引用的场景,如上文提到的useEffect,又或者是配合React.Memo使用
const Child = React.memo(function({val, onChange}) {
  console.log('render...');
  
  return <input value={val} onChange={onChange} />;
});

function App() {
  const [val1, setVal1] = useState('');
  const [val2, setVal2] = useState('');

  const onChange1 = useCallback( evt => {
    setVal1(evt.target.value);
  }, []);

  const onChange2 = useCallback( evt => {
    setVal2(evt.target.value);
  }, []);

  return (
  <>
    <Child val={val1} onChange={onChange1}/>
    <Child val={val2} onChange={onChange2}/>
  </>
  );
}
复制代码

上面的例子中,如果不用useCallback, 任何一个输入框的变化都会导致另一个输入框重新渲染。

执行时机

**官方的提示:**传入 useMemo 的函数会在渲染期间执行。请不要在这个函数内部执行与渲染无关的操作,诸如副作用这类的操作属于 useEffect 的适用范畴,而不是 useMemo

3.7 useRef

因为函数组件能够捕获propsstate的特点,就需要有打破这个特点的方法。

使用方式

const FocusInput = function () {
  const refInput = useRef()
  const handleFocus = () => {
    refInput.current.focus();
  }
  return (
    <div>
      <input type="" ref={refInput}/>
      <button onClick={handleFocus}>Focus</button>
    </div>
  );
}
复制代码

createRef 和 useRef 的使用方式完全一样

但其本质却不同。

 function Ref () {
  const [ renderIndex, setRenderIndex ] = useState(1);
  const refFromUseRef = useRef();
  const refFromCreateRef = React.createRef();
  if(!refFromUseRef.current) {
    refFromUseRef.current = renderIndex;
  }
  if(!refFromCreateRef.current) {
    refFromCreateRef.current = renderIndex;
  }
  const handleClick = () => {
    setRenderIndex(pre => pre + 1);
  }
  return (
    <div>
      <p>current render index {renderIndex}</p>
      <p>
        refFromUseRef<span>value: {refFromUseRef.current}</span>
      </p>
      <p>
        refFromCreateRef<span>value: {refFromCreateRef.current}</span>
      </p>
      <button onClick={handleClick}> 
        re-render
      </button>
    </div>
  );
}
复制代码

img

useRefreact hook 中的作用, 它像一个变量, 类似于 this , 它就像一个盒子, 你可以存放任何东西. createRef 每次渲染都会返回一个新的引用,而 useRef 每次都会返回相同的引用。

4.总结

  • 1.由于闭包,几乎所有的hook都能捕获当前渲染的状态,stateprops,每次渲染都有自己的事件处理函数

  • 2.除了state hook,其他hook都可以通过配置依赖项来优化执行次数。

  • 3.可以通过useRef获取来消除由于闭包造成的每次渲染状态都不同的影响。

5.参考文章

《React Hooks 入门教程》

《useEffect 完整指南》

《你所不知道的useCallback》

《深入理解 React useLayoutEffect 和 useEffect 的执行时机》

《React Hooks 详解 【近 1W 字】+ 项目实战》