React Hooks 温故而知新

用户头像
Verlime
关注
发布于: 2020 年 07 月 21 日
React Hooks 温故而知新

React Hooks 是 React 16.8 新增的特性,它可以让你在不编写 class 的情况下使用 state 以及其它的 React 特性。



首先来看一下使用 Hooks 编写的代码是什么样子的:



import React, { useState, useEffect } from ‘react’;
export default () => {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = count;
});
return (
<div>
<p>Current count is: {count}</p>
<button onClick={() => setCount(count + 1)}>increment count</button>
</div>
);
};



这段代码实现了一个简单的计数器功能,点击按钮使 count 增加,同时使页面标题的显示与 count 的变化同步。这里引入了 useStateuseEffect,我们稍后再来介绍他们,先来对比一下与 class component 的区别:



import React, { Component } from ‘react’;
export default class extends Component {
state = {
count: 0,
};
componentDidMount() {
document.title = this.state.count;
}
componentDidUpdate() {
document.title = this.state.count;
}
render() {
return (
<div>
<p>Current count is: {this.state.count}</p>
<button onClick={() => this.setState({ count: this.state.count + 1 })}>
increment count
</button>
</div>
);
}
}



通过对比发现使用 Hooks 编写代码简练多了,state 初始化及变更 state 的方式发生了改变,生命周期函数也不用再编写了,Hooks 使我们拥有了在函数组件里使用 state 及生命周期的能力。React 的组件分类也进行了重新定义,以前是有状态组件、无状态组件,现在划分为了类组件及函数组件。



那么接下来就来详细分析一下 Hooks 的具体用法。



useState

首先是 useState,这个是干什么用的?useState 可以让我们在函数组件内部使用 state 进行状态管理,语法如下:



// state, 和 setState 可以任意命名,比如 count, setCount
const [state, setState] = useState(initialState)



useState 接收一个初始的状态 initialState,它返回一个数组,进行解构后一个是 state,一个是更新 state 的函数 setState,每次 setState 调用后,就会将组件的一次重新渲染加入队列(重新渲染组件或者和其它的 setState 进行批量更新)。



initialState 作为初始状态,可以是基本类型,也可以是对象(例如{a:1})或者函数,它只在组件首次渲染时被用到,首次渲染后 state 与 initialState 相同。



在每次重新渲染后,useState 都会返回最新的 state。而 setState 就相当于是类组件中的 this.setState(),但是 Hooks 里的 setState 并不会进行新 state 与旧 state 的合并,而是直接覆盖。



另外 useState() 接收的参数也是任意的,可以是基本类型,也可以是对象,还可以传入一个函数,在这个函数里可以拿到旧的 state,例如:



{/* <button onClick={() => setCount(count + 1)}>increment count</button> */}
<button onClick={() => setCount((oldCount) => oldCount + 1)}>
increment count
</button>



useState 也可以使用多次,进行多个状态管理:



const [count, setCount] = useState(0);
const [color, setColor] = useState('red');



useEffect

那么 useEffect 又是干什么的呢?useEffect 可以让我们在函数组件中进行副作用操作,比如获取数据、DOM 操作、日志记录等都属于副作用。



useEffect 可以看做是 componentDidMount, componentDidUpdate, componentWillUnmount 这三个生命周期函数的组合。



例如在前面的例子中我们使用 useEffect 来更新页面标题:



useEffect(() => {
document.title = count;
});



每次使用 setCount 更新状态,useEffect 里的函数都执行了一次,这就相当于是在类组件中同时使用了 componentDidMountcomponentDidUpdate



componentDidMount() {
document.title = this.state.count;
}
componentDidUpdate() {
document.title = this.state.count;
}



可以看到,在两个生命周期里写了同样的逻辑,这无疑造成了代码的冗余,而 useEffect 则会在每次渲染之后都执行,包括首次渲染,这样我们把类似于示例中的相同操作编写一次放在 useEffect 中即可。



对于上面示例中的副作用是无需清除的,但是还有些副作用是需要清除的,例如手动绑定的 DOM 事件。



在类组件中清除副作用可以在 componentWillUnmount 生命周期方法中进行,那么在 Hooks 里该如何实现呢?



其实,useEffect 已经提供了清除的机制,每个副作用都可以添加一个可选的返回函数,用来在组件卸载的时候进行清除操作,比如下面代码:



import React, { useState, useEffect } from ‘react’;
export default () => {
const [size, setSize] = useState({ width: 0, height: 0 });
const handleResize = () => {
const win = document.documentElement.getBoundingClientRect();
setSize({ width: win.width, height: win.height });
};
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
});
return (
<div>
<p>Current size is:</p>
<p>
width: {size.width}, height: {size.height}
</p>
</div>
);
};



另外使用 useEffect 还有一个问题,就是每次进行 setState 操作时,可能都会触发副作用的执行, 即使更改的 state 和这个副作用并没有关系,比如下面的代码:



import React, { useState, useEffect } from ‘react’;
export default () => {
const [count, setCount] = useState(0);
const [color, setColor] = useState('red');
useEffect(() => {
console.log('count');
document.title = count;
});
return (
<div>
<p style={{ color: color }}>Current count is: {count}</p>
<button onClick={() => setCount(count + 1)}>increment count</button>
<br />
<button onClick={() => setColor(color === ‘red’ ? ‘blue’ : ‘red’)}>
switch color
</button>
</div>
);
};



我们发现在改变颜色时,修改页面标题的副作用也在反复执行,这无疑造成了资源的浪费,更不是我们想要的效果。当然,useEffect 给我们提供了第二个参数,可以对渲染进行控制,它接收一个数组作为参数,可以传入多个值,我们来对上面代码进行改进:



useEffect(() => {
console.log('count');
document.title = count;
}, [count]);



我们传入了 [count] 作为第二个参数,这样在每次渲染时都会先对 count 的新值和旧值进行对比,只有变化的时候这个副作用才会执行。



另外使用 Hooks 还需要遵循如下规则:

  • 只在函数组件中使用 Hooks,不要在普通函数中使用

  • 只在最顶层使用 Hooks,不要在条件、循环或者嵌套函数中使用 Hooks



当然为了保证这些规则,我们可以使用 ESLint 插件 eslint-plugin-react-hooks 进行约束。



useContext

首先看一下 Context 是怎么干什么用的:Context 提供了一种跨层级传递数据的方式,有了它就无需在每层组件都手动传递 props 了。



Context API 使用示例



通过下面例子回顾一下 Context API 的使用:



// 创建一个 Context 对象
const ThemeContext = React.createContext(‘light’);
const ContextAPIDemo = () => {
return (
// 每个 Context 对象都会返回一个 Provider React 组件,
// 它接收一个 value 属性,传递给消费组件,
// 允许消费组件订阅 context 的变化
<ThemeContext.Provider value="dark">
<MiddleComponent />
</ThemeContext.Provider>
);
};
// 一个中间组件,使用 Context 传递数据并不需要中间组件透传
const MiddleComponent = () => {
return (
<div>
<ThemedButton />
</div>
);
};
class ThemedButton extends Component {
// 把创建的 ThemeContext 对象赋值给 contextType 静态属性,
// 这样就可以通过 this.context 访问到 ThemeContext 里面的数据
static contextType = ThemeContext;
render() {
return <Button theme={this.context} />;
}
}
const Button = (props) => {
const btnBgColor = props.theme === ‘dark’ ? ‘#333’ : ‘white’;
return <button style={{ backgroundColor: btnBgColor }}>Toggle Theme</button>;
};



这里使用 React.createContext() 创建了一个 ThemeContext,然后通过 Provider 把数据传递给消费组件,即 ThemedButton,最后在消费组件里把创建的 ThemeContext 赋值给了 contextType 静态属性,这样我们在消费组件里就可以通过 this.context 的方式访问数据了。



当然在消费组件里还可以使用 Consumer来获取 Context 的数据:



const ThemedButton = () => {
return (
<ThemeContext.Consumer>
{(theme) => <Button theme={theme} />}
</ThemeContext.Consumer>
);
};



Ok,做了简单的回顾之后,就来看一下在 Hooks 里该如何使用 Context。



useContext 的使用

接下来我们使用 useContext 改造下上面的示例:



// 省略其它代码…
const Button = () => {
const theme = useContext(ThemeContext);
const btnBgColor = theme === ‘dark’ ? ‘#333’ : ‘white’;
return <button style={{ backgroundColor: btnBgColor }}>Toggle Theme</button>;
};



改造后就完全可以不用 ConsumercontextType 静态属性了,哪里需要哪里就直接使用 useContext 即可。



useContext 接收一个 Context 对象作为参数,返回的是通过 Provider 传入的 value 数据。



由于示例中一直是一个固定的 ThemeContet,如果需要一个动态的 Context 该怎么办?我么可以通过 Providervalue 传入回调函数进行处理:



const ContextAPIDemo = () => {
const [theme, setTheme] = useState(initialState.theme);
const toggleTheme = (val) => {
setTheme(val === 'dark' ? 'light' : 'dark');
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
<MiddleComponent />
</ThemeContext.Provider>
);
};



然后在目标组件可以通过 useContext 拿到回调函数,例如:



const Button = () => {
const { theme, toggleTheme } = useContext(ThemeContext);
const btnBgColor = theme === ‘dark’ ? ‘#333’ : ‘white’;
return (
<button
style={{ backgroundColor: btnBgColor }}
onClick={() => toggleTheme(theme)}
>
Toggle Theme
</button>
);
};



useReducer

提起 Reducer,用过 Redux 的同学应该都不陌生,Reducer Hook 里的 Reducer 其实跟 Redux 里的 Reducer 是同一个意思,接收旧的 state,返回新的 state,形如:(state, action) => newState



useReducer 的基本语法是这样的:



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



它接收三个参数:reducer, initialArg 及可选的 init函数:



  • reducer: 接收旧的 state,返回新的 state,形如:`(state, action) => newState

  • initialArg: 初始 state

  • init: 作为一个函数传入,惰性地初始化 state,state 将会被设置为 init(initialArg),这样可以将计算 state 的逻辑提取到外部,同时如果有重置 state 的需求的话也会很方便



下面就来看一下具体的用法:



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



useMemo

useMemo 的语法是这样的:



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



接收一个函数和依赖数组作为参数,然后只在依赖项变化时才会计算 memoizedValue 的值,基于这一点,我们可以作为性能优化的一个手段。



例如下面代码,我们通过对 computedNum 这个计算方法添加了 useMemo,把 count 作为依赖,只有 count 变化时 computedNum 才会计算,这样可以防止无关的操作也会引起函数的执行(如果不加,在点击 change color 时,computedNum也会反复计算)。



export default () => {
const [count, setCount] = useState(0);
const [color, setColor] = useState(‘blue’);
const computedNum = useMemo(() => {
console.log('render when count change');
return count + 1;
}, [count]);
return (
<div>
<p>Current color is: {color}</p>
<p>Current count is: {count}</p>
<p>Computed num is: {computedNum}</p>
<button onClick={() => setCount(count + 1)}>increment count</button>
<button onClick={() => setColor(color === ‘blue’ ? ‘green’ : ‘blue’)}>
change color
</button>
</div>
);
};



当然,我们也可以用 useMemo 去包裹一个组件,从而实现类似 PureComponent 的效果,防止组件反复的渲染。



<>
{useMemo(
() => (
<ColorDemo color={color} />
),
[color]
)}
<>



useCallback

useCallback 用法如下:



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



useCallback 返回了一个 memoized 回调函数,它跟 useMemo 类似,useCallback(fn, deps) 相当于 useMemo(() => fn, deps)。它的第二个参数跟 useMemo 的一样,传入一个依赖项数组,只有依赖变化时才会生成新的函数,否则一直是同一个函数,这在进行组件性能优化时非常有用,避免多余的开销。比如使用 props 传递函数时,使用 callback 可以避免每次渲染都生成一个新的函数,示例如下:



let firstOnClick;
const Button = (props) => {
if (!firstOnClick) {
firstOnClick = props.onClick;
}
console.log(firstOnClick === props.onClick);
return <button onClick={props.onClick}>increment count</button>;
};
export default () => {
const [count, setCount] = useState(0);
const [color, setColor] = useState('blue');
// 不用 useCallback 包裹的话,点击 change color 按钮,Button 组件里每次也都会生成新的函数
// const handleIncrementCount = () => {
// setCount(count + 1);
// };
// 使用 useCallback 包裹后,只有点击 increment count 时 count 发生变化,才会生成新的函数,
// 修改颜色并不会生成新函数
const handleIncrementCount = useCallback(() => {
setCount(count + 1);
}, [count]);
return (
<div>
<p>Current color is: {color}</p>
<p>Current count is: {count}</p>
<Button onClick={handleIncrementCount} />
<button onClick={() => setColor(color === 'blue' ? 'green' : 'blue')}>
change color
</button>
</div>
);
};



useRef

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



通常它被用来获取子组件(或 DOM 元素):



export default () => {
const btnRef = useRef(null);
useEffect(() => {
btnRef.current.addEventListener('click', () => {
alert('click me');
});
}, []);
return (
<div>
<button ref={btnRef}>click</button>
</div>
);
};



另外,由于 useRef 在每次渲染时返回的都是同一个 ref 对象,因此可以用 ref.current 保存一个变量,它不会随着组件的重新渲染而受到影响,例如下面一个定时器的例子:



export default () => {
const [count, setCount] = useState(0);
const ref = useRef(0);
useEffect(() => {
const timer = setInterval(() => {
console.log('interval');
setCount(++ref.current);
}, 1000);
return () => clearInterval(timer);
}, []);
return (
<div>
<p>Current count is: {count}</p>
</div>
);
};



同时,变更 .current 属性也不会引起组件的重新渲染。



useImperativeHandle

useImperativeHandle 可以让我们在使用 ref 时自定义暴露给父组件的实例值,这样可以隐藏掉一些私有方法或属性,下面是一个 useImperativeHandleforwardRef 配合使用的例子:



function Input(props, ref) {
const [val, setVal] = useState(0);
useEffect(() => {
setVal(props.count);
}, [props.count]);
const clearInput = useCallback(() => {
setVal('');
}, []);
useImperativeHandle(ref, () => ({
clear: () => {
clearInput();
},
}));
return (
<input type="text" value={val} onChange={(e) => setVal(e.target.value)} />
);
}
const FancyInput = forwardRef(Input);
export default () => {
const [count, setCount] = useState(0);
const fancyRef = useRef(null);
const handleClearInput = useCallback(() => {
fancyRef.current.clear();
}, []);
return (
<div>
<p>Current count is: {count}</p>
<button onClick={() => setCount(count + 1)}>increment count</button>
<hr />
<FancyInput ref={fancyRef} count={count} />
<button onClick={handleClearInput}>clear input</button>
</div>
);
};



useLayoutEffect

useLayoutEffectuseEffect 用法相同,只不过 useLayoutEffect 会在所有 DOM 更新之后同步调用,可以使用它读取 DOM 布局并同步触发重渲染,但是还是建议使用 useEffect,以避免阻塞 UI 更新。



这里有个示例,使用useEffectuseLayoutEffect 会有明显的差异:

const useLayoutEffectDemo = () => {
const [height, setHeight] = useState(100);
const boxRef = useRef(null);
useLayoutEffect(() => {
if (boxRef.current.getBoundingClientRect().height < 200) {
console.log('set height: ', height);
setHeight(height + 10);
}
}, [height]);
const style = {
width: '200px',
height: `${height}px`,
backgroundColor: height < 200 ? 'red' : 'blue',
};
return (
<div ref={boxRef} style={style}>
useLayoutEffect Demo
</div>
);
};



我们相当于给盒子设置了粗糙的过渡变化,使用 useEffect 这种过渡是生效的,但是换成 useLayoutEffect 之后,过渡效果已经没了,只会显示最终的效果。也就是说在浏览器执行绘制之前,useLayoutEffect 内部的更新计划将被同步刷新。



useDebugValue

用于开发自定义 Hooks 调试使用,例如:



useDebugValue(
size.width < 500 ? '---- size.width < 500' : '---- size.width > 500'
);



可以在 React DevTools 里查看相关的信息输出。



自定义 Hooks

React Hooks 的强大,不仅仅是因为官方的内置 Hooks,同时它还支持自定义 Hooks,提高了组件的复用性,从一定程度了取代了 HOC 和 render props 的复用方式。



自定义 Hook 需要以 use 开头,其内部还可以调用其他的 Hook,自定义 Hook 不需要具有特殊的标识,我们可以自定义参数及返回值,下面是一个自定义 Hook 示例,自定了一个 useSize,用来获取窗口的大小。



import { useState, useCallback, useEffect } from 'react';
const getDomElInfo = () => document.documentElement.getBoundingClientRect();
const getSize = () => ({
width: getDomElInfo().width,
height: getDomElInfo().height,
});
export default function useSize() {
const [size, setSize] = useState(() => getSize());
const onChange = useCallback(() => {
setSize(() => getSize());
}, []);
useEffect(() => {
window.addEventListener('resize', onChange, false);
return () => {
window.removeEventListener('resize', onChange, false);
};
}, [onChange]);
return size;
}



使用这个自定义 Hook 也很简单:



import useSize from './useSize';
export default () => {
const size = useSize();
return (
<div>
<p>
size: width-{size.width}, height-{size.height}
</p>
</div>
);
};



好了,关于 React Hooks 就介绍到这里,它不仅仅打破了原有的组件编写方式,更是一种新的思维, 非常值得学习。



相关参考



发布于: 2020 年 07 月 21 日 阅读数: 38
用户头像

Verlime

关注

一个前端工程师 2018.01.01 加入

每天进步一点点ヽ(•̀ω•́ )ゝ

评论

发布
暂无评论
React Hooks 温故而知新