当前位置: 首页 > 科技观察

当我们讨论Hooks时,我们在讨论什么

时间:2023-03-22 10:09:53 科技观察

在使用React开发的那段时间里,我最大的感受就是“这是React最好的时代,也是最坏的时代”!“好”是开启了hooks不同的开发模式需要在思维上更加关注数据之间的依赖关系,同时写法更简单,总体上提高了开发效率;“坏”是类组件和功能组件在项目中经常并存,而类组件则以类编程思想为主。在开发过程中,更多的关注整个组件的渲染周期。在维护一个项目的时候,往往需要在两种思维模式之间左右跳转。这还不是最糟糕的一点。一天,老王问我:“你一直在《周刊一瞥》里转载hooks的文章,你认为hooks有哪些容易造成内存泄漏的要点?”引发了我的深思(因为脑子一片空白)。我们一直在讨论hooks,我们在讨论什么?虽然社区中关于hooks的讨论很多,但更多的是科普HooksAPI的使用方法,或者与类组件生命周期、redux进行比较,却缺乏最重要的关于hooks的信息。对最佳实践的讨论和共识,我认为这是“最糟糕”的一点。今天,我们不妨讨论一下hooks带来的变化,以及我们如何去拥抱这些变化。注《每周一览》是团队翻译分享网络新鲜产品的栏目。自从React16.8发布以来,Hooks已经深入人心,最大的变化有三个:思维方式的变化、渲染过程中作用域的变化、数据流向的变化。思维模式从React官网我们可以了解到,Hooks的设计动机是简化组件间状态逻辑的复用,支持开发者将关联逻辑抽象成更小的函数,降低认知成本,而不必去理解JSClassPeople中的命令令人窒息。在这样的动机下,hooks弱化了组件生命周期的概念,强化了state和behavior之间的依赖关系,这往往会导致我们更关注“做什么”而不是“怎么做”。1]。假设有这样一个场景:组件Detail依赖父组件传入的查询参数来请求数据,那么无论是基于类组件还是Hooks,我们都需要定义一个异步请求方法getData。不同的是,在类组件的开发模式中,我们要考虑的更倾向于“怎么做”:在组件挂载时请求数据,在组件挂载时比较新旧查询值已更新,并在必要时再次调用getData函数。classDetail扩展React.Component{state={keyword:'',}componentDidMount(){this.getData();}getSnapshotBeforeUpdate(prevProps,prevState){if(this.props.query!==prevProps.query){returntrue;}returnnull;}componentDidUpdate(prevProps,prevState,snapshot){if(snapshot){this.getData();}}asyncgetData(){//这是一段异步请求数据的代码console.log(`Datarequest,the参数是:${this.props.query}`);this.setState({keyword:this.props.query})}render(){return(

keyword:{this.state.keyword}

);}}在应用了Hooks的函数组件中,我们思考“要做什么”:用不同的查询值展示不同的数据。functionDetail({query}){const[keyword,setKeyword]=useState('');useEffect(()=>{constgetData=async()=>{console.log(`数据请求,参数为:${query}`);setKeyword(query);}getData();},[query]);return(

keyword:{keyword}

);}在这个显性下这样在这种情况下,开发者在编码过程中的思维方式也应该随之改变,需要考虑数据与数据、数据与行为的同步关系。这种模式可以更简洁地组合相关代码,甚至可以将它们抽象成自定义的钩子,实现逻辑共享,看起来有点插件化编程的味道。虽然DanAbramov在他的博客中提到,从生命周期的角度思考,决定什么时候执行副作用是逆势而行的[2],但是了解组件渲染过程中各个钩子的执行时机,将有助于我们与React的理解保持一致让您更准确地专注于“做什么”。Donavon以图表的形式对钩子范式和生命周期范式[3]进行了梳理和比较,可以帮助我们理解钩子在组件中的工作机制。每次组件更新时,都会再次调用组件函数生成新的作用域。这一变化也对我们的开发者提出了新的编码要求。作用域在类组件中,一旦组件被实例化,它就有自己的作用域,从创建到销毁,作用域都保持不变。因此,在组件的整个生命周期中,每次渲染时内部变量总是指向同一个引用,我们可以很方便地在每次渲染时通过this.state获取最新的state值,或者使用this.xx来获取到相同的内部变量。classTimerextendsReact.Component{state={count:0,interval:null,}componentDidMount(){constinterval=setInterval(()=>{this.setState({count:this.state.count+1,})},1000);this.setState({interval});}componentDidUnMount(){if(this.state.interval){clearInterval(this.state.interval);}}render(){return(
计数器是:{this.state.count}
);}}在Hooks中,render和state的关系更像是闭包和局部变量。每进行一次渲染,都会生成一个新的状态变量,React会将当前渲染的状态值写入其中,在当前渲染过程中保持不变。也就是说,每个渲染都是相互独立的,都有自己的状态值。同样,组件中的函数、定时器、副作用等也是相互独立的,内部访问的是当前渲染的状态值,所以经常会遇到timer/subscriber读取不到最新值的情况.functionTimer(){const[count,setCount]=useState(0);useEffect(()=>{constinterval=setInterval(()=>{setCount(count+1);//永远只有1},1000);return()=>{clearInterval(interval);}},[]);return(
计数器为:{count}
);}如果我们想得到最新的值,有两种方案:一种是使用setCount的lambada形式,传入一个状态值为上一个参数的函数;另一种是使用useRef钩子将最新值存储在其当前属性中。functionTimer(){const[count,setCount]=useState(0);useEffect(()=>{constinterval=setInterval(()=>{setCount(c=>c+1);},1000);return()=>{clearInterval(interval);}},[]);return(
计数器为:{count}
);}在hook-flow图中,我们可以理解,当父组件重新-渲染时,它的所有(状态、局部变量等)都是新的。一旦子组件依赖了父组件的一个对象变量,无论该对象是否发生变化,子组件都会得到一个新的对象,这样子组件对应的diff就失效了,这部分逻辑仍将被重新执行。在下面的例子中,我们的副作用依赖包括了父组件传入的对象参数,父组件每次更新都会触发一次数据请求。functionInfo({style,}){console.log('Infoisrendered');useEffect(()=>{console.log('reloaddata');//每次重新渲染时,数据都会被reloaded},[style]);return(ThisisthetextinInfo

);}functionPage(){console.log('Pageisrendered');const[count,setCount]=useState(0);conststyle={color:'red'};//当计数器+1时,触发Page的重新渲染,进而触发Info的重新渲染return(

计数值:{count}

setCount(count+1)}>+1
);}ReactHooks为我们提供了一个解决方案,useMemo允许我们缓存传入的对象,并且仅在依赖项发生变化时才重新计算和更新相应的对象。functionPage(){console.log('页面呈现');const[color]=useState('red');const[count,setCount]=useState(0);conststyle=useMemo(()=>({color}),[color]);//只有当颜色大幅度变化时,样式才会发生变化//当计数器为+1时,会触发Page的重新渲染,进而触发Page的重新渲染theInfo//但是因为style被缓存了,所以Info中不会触发数据重载return(

Countvalue:{count}

setCount(count+1)}>+1
);}数据流ReactHooks为数据流带来了两个变化:一是支持更友好地使用上下文进行状态管理,避免层数过多时中间层携带不相关的参数;二是允许函数参与数据流,避免向底层组件传递冗余参数。useContext作为hooks的核心模块之一,可以获取传入上下文的当前值,从而达到跨层通信的目的。React官网有详细的介绍。需要注意的是,一旦context的值发生变化,所有使用该context的组件都会被重新渲染。为了避免不相关的组件重绘,我们需要合理构建上下文,比如从第一节提到的新思维模式出发,按照状态的相关性来组织上下文,将相关的状态存储在同一个上下文中。以往,如果父子组件使用相同的数据请求方法getData,并且该方法依赖于上层传入的查询值,通常需要将query和getData方法传递给子组件,而子组件通过判断查询值决定是否重新执行getData。classParentexendsReact.Component{state={query:'keyword',}getData(){consturl=`https://mocks.alibaba-inc.com/mock/fO87jdfKqX/demo/queryData.json?query=${this.state.query}`;//请求数据...console.log(`请求路径为:${url}`);}render(){return(//传递了一个不是子组件渲染的查询值);}}classChildextendsReact.Component{componentDidMount(){this.props.getData();}componentDidUpdate(prevProps){//if(prevProps.getData!==this.props.getData){//条件永远为真//this.props.getData();//if(prevProps.query!==this.props.query){//只有用查询值来判断this.props.getData();}}render(){return(//...);}}在ReactHooks中,useCallback允许我们缓存一个函数,当且仅当依赖项发生变化时,该函数是更新。这样我们就可以在子组件中配合useEffect来实现按需加载。通过钩子的配合,函数不再只是一个方法,而是可以作为一个值参与到应用程序的数据流中。functionParent(){const[count,setCount]=useState(0);const[query,setQuery]=useState('关键字');constgetData=useCallback(()=>{consturl=`https://mocks.alibaba-inc.com/mock/fO87jdfKqX/demo/queryData.json?query=${query}`;//请求数据...console.log(`请求路径为:${url}`);},[query]);//GetData只有在query发生变化时才会更新//count值的变化不会导致Child重新请求数据return(<>

Thecountvalueis:{count}

setCount(count+1)}>+1{setQuery(e.target.value)}}/>);}functionChild({getData}){useEffect(()=>{getData();},[getData]);//函数可以作为依赖参与数据流return(//...);}总结回到最初的问题:“hooks有哪些容易导致内存泄漏的点?”,我理解内存泄漏的风险在于hooks带来的作用域的改变。由于每次渲染都是独立的,一旦一个副作用引用了一个局部变量,在组件销毁时没有及时释放,就很容易造成内存泄漏。关于如何更好地使用hooks,SandroDolidze在他的博客中列出了一个checkList[4],我认为这是一个很好的建议,可以帮助我们编写正确的hooks应用程序。遵循Hooks规则;函数体中不使用任何副作用,而是放到useEffect中执行;取消订阅/处置/销毁所有已使用的资源;更喜欢useReducer或useState的函数更新,防止在hooks中读写相同的值;不要在渲染函数中使用可变变量,而是使用useRef;如果useRef中保存的内容的生命周期比组件本身的生命周期短,那么在处理资源时不要释放该值;小心无限循环和内存泄漏;可以在需要性能时记忆函数和对象;正确设置依赖关系(undefined=>每次渲染;[a,b]=>当a或b改变时;[]=>只执行一次);在重用案例中使用自定义挂钩。本文主要从自身经历出发,对比总结hooks在开发过程中带来的变化,以及如何应对这些变化。如有误会请指正~