重构现有应用程序或组件以使用 React Hooks 会带来一系列独特的挑战。在本文中,我们将介绍适用于各种应用程序类型的重构的一些通用问题,从基本问题开始,然后再讨论更高级的用例。
我们将介绍以下内容:
-
将类组件转换为函数组件
-
没有状态或生命周期方法的类组件
-
具有道具、默认道具值和propType声明的类组件
-
具有状态的类组件:单个或几个多个键
-
-
权衡采用增量 Hooks
-
简化生命周期方法
-
比较useEffect对象值
-
利用JSON.stringify
-
使用手动条件检查
-
使用useMemo钩子
-
-
修复由于以下原因而中断的测试useEffect
-
一种更安全的重构渲染道具 API 的方法
-
处理状态初始化器
要继续阅读本文,您应该对React Hooks 的工作原理有所了解。让我们开始吧!
将类组件转换为函数组件
当您着手重构您的应用程序以使用 React Hooks 时,您将面临的第一个问题恰好是其他挑战的根源:您如何在不破坏任何功能的情况下将您的类组件重构为功能组件?
让我们看一下您将遇到的一些最常见的用例,从最简单的开始。
没有状态或生命周期方法的类组件
对于高级开发人员,上面的 gif 可以提供足够的上下文来发现从类到函数组件的重构差异。让我们详细探讨一下;下面的代码展示了你将拥有的最基本的用例,一个只呈现一些 JSX 的类组件:
// before import React, {Component} from 'react'; class App extends Component { handleClick = () => { console.log("helloooooo") } render() { return ( <div> Hello World <button onClick={this.handleClick}> Click me! </button> </div> ) } } export default App
重构这个组件非常简单:
// after import React from 'react' function App() { const handleClick = () => { console.log("helloooooo") } return ( <div> Hello World <button onClick={handleClick}> Click me! </button> </div> ) } export default App
在上面的代码中,我们将class关键字替换为 JavaScript 函数。我们没有使用render()函数,而是直接通过父App()函数返回,它是一个组件。最后,在我们的函数组件中,我们不使用this. 相反,我们将其替换为函数范围内的 JavaScript 值。
具有道具、默认道具值和propType声明的类组件
类组件是另一个没有太多开销的简单用例:
// before class App extends Component { static propTypes = { name: PropTypes.string } static defaultProps = { name: "Hooks" } handleClick = () => { console.log("helloooooo") } render() { return <div> Hello {this.props.name} <button onClick={this.handleClick}> Click me! </button> </div> } }
重构后,我们有以下代码:
function App({name = "Hooks"}) { const handleClick = () => { console.log("helloooooo") } return <div> Hello {name} <button onClick={handleClick}>Click me! </button> </div> } App.propTypes = { name: PropTypes.number }
如您所见,该组件作为功能组件看起来要简单得多。组件函数的propsbecome 函数参数,默认 props 通过 ES6 默认参数语法处理,并static propTypes替换为App.propTypes.
具有状态的类组件:单个或几个多个键
当您拥有一个具有实际状态对象的类组件时,该场景会变得更加有趣。您的许多类组件将属于此类别或此类别的稍微复杂的版本。
考虑以下类组件:
class App extends Component { state = { age: 19 } handleClick = () => { this.setState((prevState) => ({age: prevState.age + 1})) } render() { return <div> Today I am {this.state.age} Years of Age <div> <button onClick={this.handleClick}>Get older! </button> </div> </div> } }
该组件仅跟踪状态对象中的单个属性。够简单!
我们可以重构这段代码以使用useStateHook,它用于管理 React 中的状态,如下所示。请注意我们如何将 state 的默认值作为 的参数传递useState():
function App() { const [age, setAge] = useState(19); const handleClick = () => setAge(age + 1) return <div> Today I am {age} Years of Age <div> <button onClick={handleClick}>Get older! </button> </div> </div> }
这看起来简单多了!
如果这个组件有更多的状态对象属性,你可以使用多个useState调用,如下所示:
function App() { const [age, setAge] = useState(19); const [status, setStatus] = useState('married') const [siblings, setSiblings] = useState(10) const handleClick = () => setAge(age + 1) return ( <div> Today I am {age} Years of Age <div> <button onClick={handleClick}>Get older! </button> </div> </div> ) }
您可以在 中创建对象useState(),但是管理该状态将相当困难,导致所有字段在屏幕上重新呈现。尽管此示例相当简单,但我建议您查看本指南以获取更多示例。
权衡采用增量 Hooks
虽然重写您的应用程序和组件以使用 Hooks 听起来很棒,但它确实是有代价的,时间和人力是先行者。
如果您正在处理大型代码库,则可能需要在采用 Hooks 的早期阶段进行一些权衡。作为示例场景,让我们考虑以下组件:
const API_URL = "https://api.myjson.com/bins/19enqe"; class App extends Component { state = { data: null, error: null, loaded: false, fetching: false, } async componentDidMount() { const response = await fetch(API_URL) const { data, status } = { data: await response.json(), status: response.status } // error? if (status !== 200) { return this.setState({ data, error: true, loaded: true, fetching: false, }) } // no error this.setState({ data, error: null, loaded: true, fetching: false, }) } render() { const { error, data } = this.state; return error ? <div> "Sorry, an error occurred :(" </div> : <pre>{JSON.stringify(data, null, ' ')}</pre> } }
上面的组件在挂载时向远程服务器发出请求以获取一些数据,然后根据结果设置状态。与其关注异步逻辑,不如关注setState调用:
class App extends Component { ... async componentDidMount() { ... if (status !== 200) { this.setState({ data, error: true, loaded: true, fetching: false, }) } this.setState({ data, error: null, loaded: true, fetching: false, }) } render() { ... } }
这些setState调用接收一个具有四个属性的对象。虽然这只是一个示例,但一般情况是您的组件setState使用大量对象属性进行调用。
使用 React Hooks,您可能会继续将每个对象值拆分为单独的useState调用。您可以使用带有 的对象useState,但这些属性是不相关的,并且在此处使用对象可能会使以后将代码分解为独立的自定义 Hook 变得更加困难。
超过 20 万开发人员使用 LogRocket 来创造更好的数字体验了解更多 →
因此,重构可能类似于以下代码:
... const [data, setData] = useState(null); const [error, setError] = useState(null); const [loaded, setLoading] = useState(false); const [fetching, setFetching] = useState(false); ...
您还必须将 this.setState调用更改为如下所示:
// no more this.setState calls - use updater functions. setData(data); setError(null); setLoading(true); fetching(false);
虽然这可行,但如果您在组件中有很多setState调用,那么您最终将多次编写此代码或将它们分组到另一个自定义 Hook 中。
如果您想在代码库中实现对 Hooks 的增量采用,而代码更改较少且setState签名略有相似,该怎么办?在这种情况下,您必须做出权衡;在这里,我们将介绍useReducerHook。 useReducer具有以下签名:
const [state, dispatch] = useReducer(reducer)
reducer是一个接受状态和动作并返回 a 的函数newState:
const [state, dispatch] = useReducer((state, action) => newState)
从 reducer 返回的newState数据然后通过state变量被组件消耗。
如果你之前使用过 Redux,那么你就知道你action的对象一定是具有特定type属性的。但是,情况并非如此useReducer。相反,该reducer函数接受state和 some action,然后返回一个新的状态对象。我们可以利用这一点进行不那么痛苦的重构,如下所示:
... function AppHooks() { ... const [state, setState] = useReducer((state, newState) => ( {...state, ...newState} )); setState({ data, error: null, loaded: true, fetching: false, }) }
我们没有更改组件中各处的大部分this.setState调用,而是选择了一种更简单的增量方法,该方法不涉及大量代码更改。
不用this.setState({data, error: null, loaded: null,fetching: false}), 和 Hooks,你可以只删除this.,setState调用仍然有效。下面的代码使这成为可能:
const [state, setState] = useReducer((state, newState) => ( { ...state, ...newState } ));
当您尝试更新状态时,传入的任何内容setState(通常称为dispatch)都会作为第二个参数传递给 reducer。我们称之为newState.
不像在 Redux 中那样实现复杂的switch语句,我们只是返回一个新的状态对象,它用传入的新值覆盖先前的状态。这类似于setState工作原理,更新状态属性而不是替换整个对象。
使用此解决方案,可以更轻松地在您的代码库中采用增量 Hooks,无需大量代码更改且具有类似的setState签名。以下是代码更改较少的完整重构代码:
function AppHooks() { const initialState = { data: null, error: null, loaded: false, fetching: false, } const reducer = (state, newState) => ({ ...state, ...newState }) const [state, setState] = useReducer(reducer, initialState); async function fetchData() { const response = await fetch(API_URL); const { data, status } = { data: await response.json(), status: response.status } // error? if (status !== 200) { setState({ data, error: true, loaded: true, fetching: false, }) } // no error setState({ data, error: null, loaded: true, fetching: false, }) } useEffect(() => { fetchData() }, []) const { error, data } = state return error ? Sorry, and error occured :( : <pre>{JSON.stringify(data, null, ' ')}</pre> }
简化生命周期方法
您将面临的另一个常见挑战是重构组件的 、 和生命周期方法中componentDidMount的componentWillUnmount逻辑componentDidUpdate。
useEffectHook 是提取这种逻辑的完美场所。默认情况下,其中的效果函数useEffect将在每次渲染后运行。如果你熟悉 Hooks,这是常识:
import { useEffect } from 'react' useEffect(() => { // your logic goes here // optional: return a function for canceling subscriptions return () => {} })
Hook的一个有趣特性useEffect是您可以传入的第二个参数,即依赖数组。考虑以下示例:
import { useEffect } from 'react' useEffect(() => { }, []) // array argument
在此处传递一个空数组将仅在组件挂载时运行效果函数,并在组件卸载时对其进行清理。这适用于您想要在组件挂载时跟踪或获取一些数据的情况。
下面是一个将值传递给依赖数组的示例:
import { useEffect } from 'react' useEffect(() => { }, [name]) // array argument with a value
这里的含义是,当组件挂载时将调用效果函数,并且在name变量的值发生变化时再次调用。
来自 LogRocket 的更多精彩文章:
-
不要错过来自 LogRocket 的精选时事通讯The Replay
-
了解LogRocket 的 Galileo 如何消除噪音以主动解决应用程序中的问题
-
使用 React 的 useEffect优化应用程序的性能
-
在多个 Node 版本之间切换
-
了解如何使用 AnimXYZ 为您的 React 应用程序制作动画
-
探索 Tauri,一个用于构建二进制文件的新框架
-
比较NestJS 与 Express.js
比较useEffect对象值
useEffectHook 接受一个可能执行一些副作用的函数参数:
useEffect(doSomething)
Hook 还接受第二useEffect个参数,即函数中的效果所依赖的值数组。例如:
useEffect(doSomething, [name])
在上面的代码中,该doSomething函数只会在name值更改时运行。此功能非常有用,因为您可能不希望在每次渲染后运行效果,这是默认行为。
然而,这带来了另一个问题。为了仅在发生更改时useEffect调用该doSomething函数name,它将先前的name值与其当前值进行比较,即prevName === name.
虽然这对原始 JavaScript 值类型非常有效,但如果name是对象呢?在 JavaScript 中,对象通过引用进行比较。从技术上讲,如果name是一个对象,它在每次渲染上总是不同的。因此,prevName === name支票将永远是假的。
暗示,该doSomething函数将在每次渲染后运行,这可能是一个性能问题,具体取决于您的应用程序类型。让我们回顾一下这个问题的解决方案。考虑下面的简单组件:
function RandomNumberGenerator () { const name = 'name' useEffect( () => { console.log('Effect has been run!') }, [name] ) const [randomNumber, setRandomNumber] = useState(0) return ( <div> <h1>{randomNumber}</h1> <button onClick={() => { setRandomNumber(Math.random()) }} > Generate random number! </button> </div> ) }
该组件呈现一个按钮和一个随机数。单击按钮后,将生成一个新的随机数:
请注意,useEffectHook 的效果取决于name变量:
useEffect(() => { console.log("Effect has been run!") }, [name])
在此示例中,name变量是一个简单的字符串。该效果将在组件挂载时运行。因此,console.log("Effect has been run!")将被调用。
在随后的渲染中,将进行浅比较,番茄畅听VIP解锁版,一款拥有海量音频的听书软件,资源全都无限制免费畅听!例如,prevName === name,其中表示新渲染之前prevName的先前值。name
字符串是按值比较的,所以"name" === "name"总是如此。因此,效果不会运行。因此,您只能获得一次日志输出Effect has been run!:
现在,将name变量更改为对象:
function RandomNumberGenerator() { // look here const name = {firstName: "name"} useEffect(() => { console.log("Effect has been run!") }, [name]) const [randomNumber, setRandomNumber] = useState(0); return ( <div> <h1>{randomNumber}</h1> <button onClick={()=> setRandomNumber(Math.random())}>Generate random number!</button> </div> ); }
在这种情况下,浅层检查在第一次渲染后再次执行。但是,由于对象是按引用而不是按值比较的,因此比较失败。例如,以下表达式返回false:
{firstName: "name"} === {firstName: "name"}
因此,效果会在每次渲染后运行,你会得到很多日志:
利用JSON.stringify
要阻止这种情况发生,请运行以下代码:
...useEffect(() => { console.log("Effect has been run!") }, [JSON.stringify(name)])
通过使用JSON.stringify(name),被比较的值现在是一个字符串,并将按值进行比较。虽然这可行,但您应该谨慎行事。您应该只JSON.stringify在具有简单值和易于序列化数据类型的对象上使用。
使用手动条件检查
使用手动条件检查涉及跟踪先前的值,在这种情况下,name,并对其当前值进行深度比较检查。但是,您会注意到它涉及更多代码:
// the isEqual function can come from anywhere // - as long as you perform a deep check. // This example uses a utility function from Lodash import {isEqual} from 'lodash' function RandomNumberGenerator() { const name = {firstName: "name"} useEffect(() => { if(!isEqual(prevName.current, name)) { console.log("Effect has been run!") } }) const prevName = useRef; useEffect(() => { prevName.current = name }) const [randomNumber, setRandomNumber] = useState(0); return <div> <h1> {randomNumber} </h1> <button onClick={() => { setRandomNumber(Math.random()) }}> Generate random number! </button> </div> }
接下来,在运行效果之前,我们将检查值是否不相等:
!isEqual(prevName.current, name)
是什么prevName.current?使用 Hooks,您可以使用useRefHook 来跟踪值。在上面的示例中,下面的代码段负责:
const prevName = useRef; useEffect(() => { prevName.current = name })
上面的命令会跟踪之前Hookname中使用的命令。useEffect我知道这可能会让人难以理解,因此我在下面包含了完整代码的注释良好的版本:
/** * To read the annotations correctly, read all turtle comments first // - from top to bottom. * Then come back to read all unicorns - from top to bottom. */ function RandomNumberGenerator() { // 1. The very first time this component is mounted, // the value of the name variable is set below const name = {firstName: "name"} // 2. This hook is NOT run. useEffect only runs sometime after render // 6. After Render this hook is now run. useEffect(() => { // 7. When the comparison happens, the hoisted value // of prevName.current is "undefined". // Hence, "isEqual(prevName.current, name)" returns "false" // as {firstName: "name"} is NOT equal to undefined. if(!isEqual(prevName.current, name)) { // 8. "Effect has been run!" is logged to the console. //console.log("Effect has been run!") } }) // 3. The prevName constant is created to hold some ref. const prevName = useRef; // 4. This hook is NOT run // 9. The order of your hooks matter! After the first useEffect is run, // this will be invoked too. useEffect(() => { // 10. Now "prevName.current" will be set to "name". prevName.current = name; // 11. In subsequent renders, the prevName.current will now hold the same // object value - {firstName: "name"} which is alsways equal to the current // value in the first useEffect hook. So, nothing is logged to the console. // 12. The reason this effect holds the "previous" value is because // it'll always be run later than the first hook. }) const [randomNumber, setRandomNumber] = useState(0) // 5. Render is RUN now - note that here, name is equal to the object, // {firstName: "name"} while the ref prevName.current holds no value. return {randomNumber} { setRandomNumber(Math.random()) }}> Generate random number! }
使用useMemo钩子
在我看来,useMemoHook 提供了一个非常优雅的解决方案:
function RandomNumberGenerator() { // look here const name = useMemo(() => ({ firstName: "name" }), []) useEffect(() => { console.log("Effect has been run!") }, [name]) const [randomNumber, setRandomNumber] = useState(0) return ( <div> <h1>{randomNumber}</h1> <button onClick={()=> setRandomNumber(Math.random()) }> Generate random number! </button> </div> ) }
useEffectHook 仍然依赖于name值,但在这里,值name被记忆,由提供useMemo:
const name = useMemo(() => ({ firstName: "name" }), [])
useMemo接受一个返回特定值的函数。在这种情况下,对象{firstName: "name"}. to 的第二个参数useMemo是一个依赖数组,其工作方式与useEffect. 如果没有传递数组,则在每次渲染时重新计算该值。
传递一个空数组会计算安装组件时的值,而无需跨渲染重新计算值。name这通过跨渲染的引用保持值相同。
Hook现在useEffect应该可以按预期工作而无需多次调用效果,即使它name是一个对象。name现在是一个跨渲染具有相同引用的记忆对象:
...useEffect(() => { console.log("Effect has been run!") }, [name]) // name is memoized!
修复由于以下原因而中断的测试useEffect
在重构应用程序或组件以使用 Hooks 时,您可能面临的更令人不安的问题之一是,您的一些旧测试现在可能会无缘无故地失败。
如果您发现自己处于这个位置,请理解测试失败确实是有原因的,可悲的是。使用useEffect,需要注意的是效果回调不是同步运行的;它在渲染后的稍后时间运行。因此,与和useEffect并不完全相同。componentDidMountcomponentDidUpdate
componentWillUnmount
由于这种异步行为,当您引入useEffect.
作为一种解决方案,在这种情况下,使用act()from 的实用程序ReactTestUtils会有很大帮助。如果您使用React 测试库进行测试,那么它在后台与act(). 使用 React 测试库,您仍然需要将测试中的状态更新或触发事件等手动更新包装到act():
act(() => { /* fire events that update state */ }); /* assert on the output */
我建议在 GitHub 上查看此讨论中的示例,以及有关在 中进行异步调用的act()讨论。最后,您将act()在此 GitHub 存储库中找到有关其工作原理的惊人示例。
如果你使用像Enzyme这样的测试库,并且在测试中有几个实现细节,比如调用instance()and之类的方法state(),你会遇到另一个与失败测试相关的问题。在这些情况下,您的测试可能会因将组件重构为功能组件而失败。
一种更安全的重构渲染道具 API 的方法
我倾向于到处使用渲染道具 API。值得庆幸的是,重构使用 render props API 的组件以使用基于 Hooks 的实现并不是什么大问题。但是,有一个小问题。考虑以下公开渲染道具 API 的组件:
class TrivialRenderProps extends Component { state = { loading: false, data: [] } render() { return this.props.children(this.state) } }
虽然这是一个人为的例子,但它已经足够好了!下面是我们将如何使用此组件的示例:
function ConsumeTrivialRenderProps() { return <TrivialRenderProps> {({loading, data}) => { return <pre> {`loading: ${loading}`} <br /> {`data: [${data}]`} </pre> }} </TrivialRenderProps> }
渲染ConsumeTrivialRenderProps组件只显示从渲染道具 API 接收到的loading和值:data
到目前为止,一切都很好!render props 的问题在于它会让你的代码看起来比你想要的更嵌套。值得庆幸的是,如前所述,将TrivialRenderProps组件重构为 Hooks 实现并不是什么大问题。
为此,您只需将组件实现包装在自定义 Hook 中并返回与以前相同的数据。正确完成后,重构的 Hooks API 将按如下方式使用:
function ConsumeTrivialRenderProps() { const { loading, setLoading, data } = useTrivialRenderProps() return <pre> {`loading: ${loading}`} <br /> {`data: [${data}]`} </pre> }
这看起来更整洁!下面是我们的自定义useTrivialRenderPropsHook:
function useTrivialRenderProps() { const [data, setData] = useState([]) const [loading, setLoading] = useState(false) return { data, loading, } }
就是这样!
// before class TrivialRenderProps extends Component { state = { loading: false, data: [] } render() { return this.props.children(this.state) } } // after function useTrivialRenderProps() { const [data, setData] = useState([]) const [loading, setLoading] = useState(false) return { data, loading, } }
在处理大型代码库时,您可能会在许多不同的地方使用特定的渲染道具 API。更改组件的实现以使用 Hooks 意味着您必须更改组件在许多不同地方的使用方式。
我们可以在这里做一些权衡吗?绝对地!您可以重构组件以使用 Hook,但也可以公开渲染道具 API。通过这样做,您可以在整个代码库中逐步采用 Hook,而不必一次更改大量代码。下面是一个例子:
// hooks implementation function useTrivialRenderProps() { const [data, setData] = useState([]) const [loading, setLoading] = useState(false) return { data, loading, } } // render props implementation const TrivialRenderProps = ({children, ...props}) => children(useTrivialRenderProps(props)); // export both export { useTrivialRenderProps }; export default TrivialRenderProps;
通过导出这两种实现,您可以在整个代码库中逐步采用 Hook。以前的渲染道具消费者和新的 Hook 消费者都可以完美地工作:
// this will work function ConsumeTrivialRenderProps() { return <TrivialRenderProps> {({loading, data}) => { return <pre> {`loading: ${loading}`} <br /> {`data: [${data}]`} </pre> }} </TrivialRenderProps> } // so will this function ConsumeTrivialRenderProps() { const { loading, setLoading, data } = useTrivialRenderProps() return <pre> {`loading: ${loading}`} <br /> {`data: [${data}]`} </pre> }
有趣的是,新的渲染道具实现也使用了 Hooks 下的 Hooks:
// render props implementation const TrivialRenderProps = ({children, ...props}) => children(useTrivialRenderProps(props));
处理状态初始化器
具有基于某些计算初始化某些状态属性的类组件并不少见。下面是一个基本示例:
class MyComponent extends Component { constructor(props) { super(props) this.state = { token: null } if (this.props.token) { this.state.token = this.props.token } else { token = window.localStorage.getItem('app-token'); if (token) { this.state.token = token } } } }
虽然我们的示例很简单,但它显示了一个通用问题。一旦您的组件安装,您可能会constructor根据一些计算在 中设置一些初始状态。
在这个例子中,我们检查是否token传入了一个 prop,或者本地存储中是否有一个app-token键,然后我们根据它设置状态。在重构为 Hooks 后,你如何处理这样的逻辑来设置初始状态?
也许 Hook 的一个鲜为人知的特性是你传递给HookuseState的参数, ,也可能是一个函数!initialStateuseState
useState(initialState)
无论您从此函数返回什么,都将用作initialState. 下面的代码展示了组件被重构为使用 Hooks 后的样子:
function MyComponent(props) { const [token, setToken] = useState(() => { if(props.token) { return props.token } else { tokenLocal = window.localStorage.getItem('app-token'); if (tokenLocal) { return tokenLocal } } }) }
从技术上讲,逻辑几乎保持不变。这里重要的是,useState如果您需要根据某些逻辑初始化状态,则可以使用函数 in。
结论
重构您的应用程序以使用 Hooks 并不是您必须要做的事情。您应该为自己和您的团队权衡不同的选择。您可以选择保留基于类的组件,因为它们仍然可以与基于功能的组件一起使用。但是,如果您选择重构组件以使用 Hooks API,那么我希望您在本文中找到了一些很棒的技巧。如果您有任何问题,请务必在下方留言,祝您编码愉快!