函数组件和类组件
函数组件与类组件有什么区别呢?
function getName(params:{name:string}){const count = 0;return params.name +'-'+count;
}
getName({name:"test"})
getName({name:"哈哈哈"})
getName是一个纯函数,不产生任何副作用,执行结束后,它的执行上下文和活动对象会被销毁,前后两次调用互不影响。对于不使用任何Hooks的函数组件而言,它也是纯函数,那么对于函数组件前后两次渲染,你能得出与调用getName函数类似的结论吗?
下面用类组件和函数组件实现相同的功能来对比二者的区别。在浏览器上显示一个按钮,单击按钮调用props中的方法来更新父组件的状态,隔1s之后打印this.props.count的值。类组件的代码如下:
import { Button } from 'antd'
import React, { Component } from 'react'class ClassCom extends Component {onClick = () => {this.props.updateCount()setTimeout(() => {console.log("类组件",this.props.count)}, 1000)}render() {return (<Button onClick={this.onClick}>这是类组件</Button>)}
}function FunCom(props) {const onClick = () => {props.updateCount()setTimeout(() => {console.log("函数组件",props.count)}, 1000)}return (<Button onClick={onClick}>这是函数组件</Button>)
}export default class FunComVsClassCom extends Component {constructor(props) {super(props)this.state = {count: 0}}updateCount = () => {this.setState({count:this.state.count+1})}render() {return (<div><FunComcount={this.state.count}updateCount={this.updateCount}/><ClassComcount={this.state.count}updateCount={this.updateCount}/></div>)}
}
单击FuncCom和ClassCom组件中的按钮都会使得父级重新刷新,从而导致FuncCom和ClassCom重新渲染。ClassCom是类组件,重新渲染不会创建新的组件实例,在setTimeout的回调函数中this.props拿到了最新的值。FuncCom是函数组件,重新渲染会创建新的执行环境和活动变量,所以访问props,无论何时拿到的都是调用FunCom时传递给它的参数,该参数不可变。
React Ref API
Ref的功能强大,它能够让组件与DOM元素,或类组件与其父级之间建立直接联系。总体而言,使用Ref出于以下3个目的。
- 访问DOM元素。
- 访问组件的实例。
- 将Ref作为mutable数据的存储中心
-
创建Ref
创建Ref有两种方式,分别为useRef和React.createRef。useRef是一种Hooks,只能在函数组件中使用。React.createRef的使用位置不限制,但不要在函数组件中使用它,如果在函数组件中使用它创建Ref,那么函数组件每次重新渲染都会创建新的Ref。 -
访问DOM元素或组件实例
要想通过Ref访问DOM元素,必须将Ref绑定到浏览器内置的组件上。等组件装载了之后使用ref.current字段访问DOM元素。export default function App() { const inputRef = useRef(null) const onClick=()=>{if(inputRef.current){inputRef.current.focus()} }return (<input ref={inputRef}/>) }
-
将Ref作为mutable数据的存储中心
将Ref作为mutable数据的存储中心,使用场景主要是函数组件,在类组件中大可不必如此。这是因为函数组件每一次重新渲染都会执行函数体,使函数体的各个变量都被重新创建。如果函数体中声明了一些只用于缓存的数据,即不会导致组件重新渲染的变量,那么将这些数据放在ref中能避免它们被反复创建。
将Ref作为mutbale数据的存储中心,不需要将其绑定到React element上,创建之后就能直接使用,修改mutableRef.current的值,组件不会重新刷新。
React Hooks
React Hooks在React16.8时正式发布。它使函数组件拥有自己的状态。对类组件没有影响。一般函数组件相比起类组件存在如下3个优点:
- 类组件必须时刻关注this关键字的指向。
- 相同的生命周期在类组件中最多定义一个,这导致彼此无关的逻辑代码被糅杂在同一个函数中。
- 不同的生命周期函数可能包含相同的代码。最常见的便是componentDidMount和componentDidUpdate。
useState
useState是一个与状态管理相关的Hooks,能让函数组件拥有状态,是最常用的Hooks之一,useState的基本用法。
-
useState的参数不是函数
此时,useState的参数将作为状态的初始值,如果没有传参数,那么状态的初始值为undefined。const [name,setName] = useState("test") const [age,setAge] = useState()
-
useState的参数是函数
此时,函数的返回值是状态的初始值。某些时候,状态的初始值要经过计算才能得到。此时推荐将函数作为useState的参数,该函数只在组件初始渲染时执行一次。const [count,setCount] = useState(()=>{//这个函数只在初始渲染时执行,后续的重新渲染不再执行return 0 })
-
修改状态的值
修改状态有两种方式://用法一 setCount((count)=>{return count+1 }) //用法二 setCount(0)
如果setCount的参数时函数,那么count现在的值将以参数的形式传递给函数,函数的返回值用于更新状态。如果setCount的参数不是函数,那么该参数将用于更新状态。状态值发生变化将导致组件重新渲染,重新渲染时,useState返回的第一个值始终是状态最新的值,不会重置为初始值。
useRef
使用useState能让函数组将拥有状态,状态拥有不变性,它在组件前后两次渲染中相互独立。使用useRef能为组件创建一个可变的数据,该数据在组件的所有渲染中保持唯一的引用,所以对它取值始终会得到最新的值。
useEffect
函数组件可以多次调用useEffect,每使用一次就定义一个effect,这些effect的执行顺序与它们被定义的顺序一致,建议将不同职责的代码放在不同的effect中。接下来从effect的清理工作和依赖这两个方面介绍useEffect。
-
effect的清理工作
effect没有清理工作就意味着它没有返回值。effect的清理工作由effect返回的函数完成,该函数在组件重新渲染后和组件卸载时调用。useEffect(()=>{document.body.addEventListener('click',()=>{})//在返回的函数中定义与该effect相关的清理工作return()=>{document.body.removeEventListener('click',()=>{})} })
该effect在组件首次渲染和之后的每次重新渲染时都会执行,如果组件的状态更新频繁,那么组件重新渲染也会很频繁,这将导致body频繁绑定click事件又解绑click事件/是否有办法使组件只在首次渲染时给body绑定事件呢?那就是依赖。
-
effect的依赖
前面示例定义的effect没有指明依赖,因此组件的每一轮渲染都会执行它们。useEffect(()=>{document.body.addEventListener('click',()=>{})//在返回的函数中定义与该effect相关的清理工作return()=>{document.body.removeEventListener('click',()=>{})} },[])//传空意味着该effect只在组件初始渲染时执行,它的清理工作在组件卸载时执行。useEffect(()=>{document.body.addEventListener('click',()=>{})//在返回的函数中定义与该effect相关的清理工作return()=>{document.body.removeEventListener('click',()=>{})} },[name])//在组件初始渲染时会执行,当name发生变化导致组件重新渲染也会执行,相应的,组件卸载时和由name变化导致组件重新渲染之后将清理上一个effect
注意:给effect传递依赖项,React会将本次渲染时依赖项的值与上一次渲染时依赖项的值进行浅对比,如果它们当中的一个有变化,那么该effect会被执行,否则不会执行。为了让effect拿到他所需状态和props的最新值,effect中所有要访问的外部变量都应该作为依赖项。函数组件每次渲染时,effect都是一个不同的函数,在函数组件内的每一个位置(包括事件处理函数、effects、定时器等)只能拿到定义他们的那次渲染的状态和props。
useReducer
useReducer是除useState之外另一个与状态管理相关的Hooks。这个Hooks笔者用得比较少,需要的同学可以参考下官方文档。
https://zh-hans.react.dev/reference/react/useReducer
用法如下:
import { useReducer } from 'react';function reducer(state, action) {if (action.type === 'incremented_age') {return {age: state.age + 1};}throw Error('Unknown action.');
}export default function Counter() {const [state, dispatch] = useReducer(reducer, { age: 42 });return (<><button onClick={() => {dispatch({ type: 'incremented_age' })}}>Increment age</button><p>Hello! You are {state.age}.</p></>);
}
自定义Hooks
如果在多个组件中使用了相同的useEffect或useState逻辑,推荐将这些相同的逻辑封装到函数中,这些函数被称为自定义的Hooks。下面举例3个自定义的Hooks的示例。
-
useForceUpdate:返回一个让组件重新渲染的函数。
function useForceUpdate(){const [,setTick]=useState(0)return ()=>setTick(t=>t+1)}const forceUpdate = useForceUpdate();const handleClick = () => {// 调用 forceUpdate 函数来强制组件重新渲染forceUpdate();};
-
usePrevVal:获取状态的上一次的值,它利用了Ref的可变性,以及effect在DOM被绘制到屏幕上才执行的特性。
function usePrevVal(status){const ref = useRef()const [prevVal,setPrevVal] = useState()useEffect(()=>{setPrevVal(ref.current)ref.current = status},[status])return prevVal }
-
useVisible:检测dom元素是否在浏览器视口内,它在effect中创建observer来异步观察目标元素是否与顶级文档视口相交。
function useVisible(root:React.RefObject<HTMLElement>,rootMargin?:string) {const [isVisible, setIsVisible] = useState(false);useEffect(() => {const observer = new IntersectionObserver((entries) => {entries.forEach(entry => {setIsVisible(entry.isIntersecting);});}, {rootMargin});if (root.current) {observer.observe(root.current);}return () => {observer.disconnect() };}, [root,rootMargin]);return isVisible; }
React Context API
在React应用中,为了让数据在组件间共享,常见的方式是让它们以props的形式自顶向下传递。如果数据在组件树的不同层级共享,那么这些数据必须传递到目的地,这种情况称为prop-drilling。Context如同管道,他将数据从入口直接传递到出口,使用Context可以避免出现prop-drilling。
总体而言,使用Context分为以下三步:
-
创建Context对象
const MyContext = React.createContext({lang:"zh_CN",changeLang:()=>{throw Error('xxxx')} })
-
用Context.Provider包裹组件树
用Context.Provider圈选Context的作用域,只有作用域内的组件才能消费Context中的数据,此处是管道的入口,在这里放入想要传递的数据。class ContextDemo extends React.Component{render(){<MyContext.Providervalue={someValue}>//children</MyContext.Provider>} }
-
订阅Context
订阅Context的位置是管道的出口,对于Context对象而言,管道入口只有一个,但出口可以有多个。订阅Context有3种方式。-
类组件的静态属性contextType。
在类组件中使用contextType去订阅Context。用法如下。class MyNestedClass extends React.Component{static contextType = MyContext }
contextType订阅了Context之后,除了不能在构造函数中使用this.context访问到contextvalue之外,在类组件的其他位置都能使用this.context访问到数据。React组件的shouldComponentUpdate的第三个参数是组件即将接收的context。
-
useContext。
在函数组件中通过useContext订阅Context时,useContext的使用次数不限。用法如下。function MyNestedFunc(){const myContext = useContext(MyContext) }
-
Context.Consumer。
Context.Consumer是react组件,在Context作用域的任何位置都能使用它,它只接收一个名为children的props,children必须是一个返回React.ReactNode的函数,该函数以context作为参数。用法如下:<MyContext.Consumer>{(context)=><MyNestedCom lang={context.lang}/>} </MyContext.Consumer>
-
无论如何订阅Context,只要context的值被更新,那么订阅该Context的组件一定会重新渲染,而不管context更新的那部分值是否被自己使用,也不管祖先组件是否跳过重新渲染。所以推荐将不同职责的数据保存到不同的context中,以减少不必要的重新渲染。
如果给Context.Provider的value属性传递一个对象字面量,那么Context.Provider的父组件每次重新刷新都会使得context的值发生变化,进而导致订阅该context的组件重新渲染,应当避免。
深入理解React的渲染流程
-
类组件的生命周期流程图如下(18版本之后)。父组件重新渲染、调用this.setState()、调用this.forceUpdate()以及订阅Context的value发生变更都会导致类组件更新。
函数组件的生命周期流程如下所示:
装载是运行的惰性初始化程序指传递给useState和useReducer的函数。父组件重新渲染、状态发生变更以及订阅的Context的value发生变更都会导致函数组件更新。有图可知,上一次的effect会在组件更新后被清理,清理effect和运行effect都不会阻塞浏览器绘制。 -
渲染流程
渲染是React让组件根据当前的props和状态描述它要展示的内容;重新渲染是React让组件重新描述它要展示的内容。渲染和更新DOM不是同一件事情,组件经过了渲染,DOM不一定会更新。React渲染一个组件,如果组件返回的输出与上次的相同,那么它的DOM节点不需要有任何更新。
将组件显示到屏幕上,React的工作分为如下两个阶段:- Render阶段(渲染阶段):计算组件的输出并收集所有需要应用到DOM上的变更。
- Commit阶段(提交阶段):将Render阶段计算出的变更应用到DOM上。
在Commit阶段React会更新DOM节点和组件实例的Ref。如果是类组件,React会同步运行componentDidMount或componentDidUpdate生命周期方法;如果是函数组件,React会同步运行useLayoutEffect Hooks,当浏览器绘制DOM之后,再运行所有的useEffect Hooks。
初始化渲染之后,下面的方式会让React重新渲染组件。
- 类组件—— 调用this.setState方法或调用this.forceUpdate方法
- 函数组件—— 调用useState返回的setState或调用useReducer返回的dispatch
- 其他——组件订阅的Context的value发生变更或重新调用ReactDOM.render(<AppRoot>)
-
提高渲染性能
要将组件显示在界面上,组件必须经过渲染流程,但是渲染有时候会被认为是浪费时间。如果渲染的输出结果没有改变,它对应的DOM节点也不需要更新,该组件的渲染工作就真的是在浪费时间。React组件的输出结果始终基于当前props和状态的值,因此,如果我们知道组件的propss和状态没有改变,那么便能让组件跳过重新渲染。- shouldComponentUpdate:返回false,react将跳过重新渲染该组件的过程。使用它最常见的场景是检测组件的props和状态是否自上次以来发生变更,如果没有变更则返回false。
- PureComponent:它在Component的基础上添加了默认的 shouldComponentUpdate去比较组件的props和状态自上次渲染以来是否变更。
- React.memo:这是一个高阶组件,接收自定义组件作为参数,返回一个被包裹的组件。被包裹的组件的默认行为是检测props是否有更改,如果没有,则跳过重新渲染的过程。
- 如果组件在渲染过程中返回的元素的引用与上一次渲染时的引用完全相同,那么React不会重新渲染该组件。
function ShowChildren(props:{children}){const [count,setCount] = useState(0)return(<div>{count}<button onClick={()=>setCount(c=>c+1)}>click</button>{props.children} //点击按钮不会使其重新渲染<Children/> //点击按钮会使其重新渲染</div>)
}
默认情况下,只要组件重新渲染,React就会重新渲染所有被它嵌套的后代组件,即便组件的props没有变更。如果试图通过meo和PureComponent优化组件的渲染性能,那么要注意每个props的引用是否变更。
const MemoizedChildren = React.memo(Children)
function Parent(){const onClick=()=>{}return <MemoizedChildren onClick={onClick}/>
}
Parent被重新渲染会创建新的onClick函数,所以对MemoizedChildren 而言,props.onClick的引用发生变化,因此Children组件会重新渲染,如果必须让组件跳过重新渲染,可以使用useCallback。
const MemoizedChildren = React.memo(Children)
function Parent(){// 使用useCallback优化回调函数const handleClick = useCallback(() => {console.log('Button clicked! Count:', count);}, []);return <MemoizedChildren onClick={handleClick }/>
}
- useCallback和useMome
-
功能不同:useCallback用于记忆化回调函数,而useMemo用于记忆化计算结果。
-
参数不同:useCallback接受一个回调函数和一个依赖项数组作为参数,只有当依赖项发生变化时,才会返回一个新的记忆化的回调函数。useMemo接受一个计算函数和一个依赖项数组作为参数,只有当依赖项发生变化时,才会重新计算并返回一个新的记忆化的计算结果。
-
返回值类型不同:useCallback返回一个记忆化的回调函数,而useMemo返回一个记忆化的计算结果。
-
使用场景不同:useCallback主要用于优化传递给子组件的回调函数,避免不必要的重新创建和渲染。useMemo主要用于优化计算操作,避免不必要的重复计算。
import React, { useState, useCallback, useMemo } from 'react';function MyComponent() {const [count, setCount] = useState(0);// 使用useCallback优化回调函数const handleClick = useCallback(() => {console.log('Button clicked! Count:', count);}, [count]);// 使用useMemo优化计算结果const doubledCount = useMemo(() => {console.log('Calculating doubled count...');return count * 2;}, [count]);return (<div><button onClick={handleClick}>Click Me</button><p>Count: {count}</p><p>Doubled Count: {doubledCount}</p></div>);
}
可以看到,在上面的例子中,handleClick回调函数通过useCallback进行记忆化,只有当count发生变化时才会重新创建。而doubledCount则通过useMemo进行记忆化,只有当count发生变化时才会重新计算。这样可以避免在每次组件渲染时不必要地重新创建回调函数和重复计算结果。