react-hooks简单入门

939 阅读18分钟

函数组件和类组件

  • 类组件:以class声明,拥有正常的生命周期函数,state,props
  • 函数组件: 本质是一个函数,接受一个props为参数,只相当于一个render方法,不存在生命周期函数,也不能维护自己到state

为什么要使用hooks

  • 可以让传统的函数组件有内部状态state,并且可以通过一些hooks来模拟/替换class组件中的生命周期函数。
  • 在传统的react开发流程中,我们的自定义组件通常需要定义几个生命周期函数,在不同的生命周期处理各自的业务逻辑,有很多情况下他们是重复的。使用hooks可以简化这些重复的逻辑。
  • this指向问题,class组件内部需要手动绑定this,而函数组件本身是一个函数,不需要绑定this

useState 函数组件有状态了

const [state, setState] = useState(initialState);
  • useState: 是一个方法,接收一个初始值initialState作为参数,返回一个数组,第一项为当前的state的值,第二项为更新state的方法
  • initialState可以是一个方法, 也可以是基本数据类型或者一个对象。
  • 这里的setState方法与class组件中的setState有所不同,此setState 不会合并state中的值,而是整体的替换。hooks里需要通过setState({ ...state, changedState:changedValue})的方式手动merge
  • hooks中的setState是不支持第二个参数的
  • 和class组件中this.setState()一样,hooks中的setState也是异步的,连续调用两次setState,数据只改变一次。可以通过setState((preValue) => preValue + 1)

使用多个hooks,顺序很重要


import React, { useState } from 'react';
function Example(){
    const [ age , setAge ] = useState(18)
    const [ sex , setSex ] = useState('男')
    const [ work , setWork ] = useState('前端程序员')
    return (
        <div>
            <p onClick={() => setAge(age + 1)}> 今年:{age}岁</p>
            <p>性别:{sex}</p>
            <p>工作是:{work}</p>
        </div>
    )
}
export default Example;
  • 所有的hooks保存在一个全局变量上,这个变量是一个链式结构
  • 函数组件重新render读取state的时候是根据这个链式结构来读取到
  • 当我们使用多个hooks时,不能在if...else.../ for循环语句里使用hooks,并且它只能使用在最顶级的作用域里。
  • 需要保证每次rerender的时候这些hooks都被执行到并且执行顺序不能改变
// hook的基本结构
{
   memoizedState: 当前值,
   queue: 更新队列,
   next: 指向下一个hook
}


const [ age , setAge ] = useState(18)
const [ sex , setSex ] = useState('男')
const [ work , setWork ] = useState('前端程序员')

// 首次render
memoizedState: {
   memoizedState: 18,
   queue: null,
   next: {
       memoizedState: '男',
       queue: null,
       next: {
           memoizedState: '前端程序员',
           queue: null,
           next : {
               ...
               next : {
                   ...
               }
           }
       },
   }
}


useEffect 替换生命周期函数,整合重复操作

为什么使用useEffect

在类组件中,我们经常在一些生命周期函数里处理一些额外的操作(数据请求、js事件绑定/解绑、DOM操作、样式的修改),我们把这些操作叫做副作用,很多时候这些操作都是重复的。

useEffect 就是用来替换常用的生命周期函数(componentDidMount, ComponentDidUpdate, componentWillUnmount),并把这些重复的操作整合到一起。


useEffect(() => {
    // DOM更新之后要执行某些操作。
    
    return () => {
        // 清除副作用
    } 
},deps)

useEffect接受两个参数:

effect

  • 是一个匿名函数,这个函数会在DOM 更新之后被执行
  • 可以返回一个匿名函数,这个函数叫清除函数,它会在组件卸载前执行(替换componentWillUnmount)。

deps

  • deps是一个可选参数,它是一个数组,数组里项可以是state、props、function,表示这个effect依赖的对象
  • 默认情况下: 会在dom每次更新(包括第一次渲染)后调用effect(替换componentDidMount, ComponentDidUpdate)。
  • 第二个参数是个空数组: 表示只会在第一次render结束后调用一次effect(替换ComponentDidMount)
  • 第二个是非空数组: 表示数组里依赖的某属性变化后就会执行effect,注意这里的变化进行的是引用地址的比较

关于清除函数的执行时机

  • 默认情况下或deps不为空时,如果非首次渲染,它的执行次序是
// setState ->  rerender -> dom更新、ui渲染 ->  执行上一次的清除函数 -> 执行effect函数

  • deps的数组为空:则清除函数会在组件销毁前执行

useEffect注意点:

  • 需要保证在effect里使用的state、props都必须存在与deps里
  • 一般不在useEffect的effect函数中执行操作DOM/样式的相关操作:useEffect中定义的函数的执行不会阻碍浏览器更新视图,在浏览器完成布局与绘制之后,会延迟调用effect。 而componentDidMonut和componentDidUpdate中的代码都是同步执行的。

useEffect使用Demo

useLayoutEffect

它和 useEffect 的结构相同,区别只是调用时机不同。它的effect函数执行是同步执行的,所以一般操作DOM或修改样式都使用这个hook

useContext: 全局共享数据

Context API

Context 是React中用来共享那些对于一个组件树而言是“全局”的数据(主题/语言/用户信息)。它解决的是多级组件之间传参的问题

  // 祖先组件 创建一个context对象
  const MyContext = React.createContext(defaultValue);
  
  // 生成的context对象具有两个组件类对象
  {
    Provider: React.ComponentType<{value: T}>,
    Consumer: React.ComponentType<{children: (value: T)=> React.ReactNode}>
  }
  
  
  // 祖先组件  MyContext.Provider 
  <MyContext.Provider value={/* 某个值,可以在我的圈子内共享 */}>
      <ComponentA />
      <ComponentB />
  </MyContext.Provider>
  
  // 子孙组件 MyContext.Consumer    
  <MyContext.Consumer>
    {value => /* 基于 context 值进行渲染, 当前的 value 值由上层组件中距离当前组件最近的 <MyContext.Provider> 的 value prop 决定。*/}
  </MyContext.Consumer>
useContext 让父子组件传值更简单

useContext是基于Context API实现的,它可以帮助我们跨越组件层级直接传递变量,实现共享。

使用useContext就表示当前组件被<MyContext.Consumer>包裹,并且它的返回值就是<MyContext.Provider>上的value属性

  const context = useContext(MyContext)
  // context相当于 <MyContext.Provider>上接受的value属性
 

useContext使用Demo

需要注意的是useContextredux的作用是不同的,一个解决的是组件之间值传递的问题,一个是应用中统一管理状态的问题,但通过和useReducer的配合使用,可以实现类似Redux的作用。

useReducer

什么是reducer

reducer其实就是一个函数,这个函数接收两个参数,一个是状态state,一个用来控制业务逻辑的判断参数action

  function countReducer(state, action) {
    switch(action.type) {
        case 'add':
            return state + 1;
        case 'sub':
            return state - 1;
        default: 
            return state;
    }
}
  • useReducer的使用

useState的替代方案,一般用在state逻辑较复杂且包含多个子值,或者下一个 state依赖于之前的state等场景下。useReducer可以将更新和操作解耦

两种使用方式

// 指定初始值的使用方式
const [state, dispatch] = useReducer(reducer, initState);


/**
*  reducer:reducer函数
*  initState:初始值
*  
**/

// 惰性初始化,初始值需要经过比较复杂的计算时使用
const [state, dispatch] = useReducer(reducer, initialArg, init);

/**
*  reducer:reducer函数
*  initialArg:传给init的参数
*  init:指定的初始化函数
*  
**/


/**
* state: 返回的状态值
* dispatch: 触发reducer的方法
**/

useReducer使用Demo

useReducer配合useContext来实现简易的redux

我们知道实现redux需要满足两点条件

  • 一个全局的状态,并且做统一管理
  • 更新这些状态,实现业务逻辑

useContext:可访问全局状态,避免一层层的传递状态。

useReducer:通过action的传递,更新复杂逻辑的状态,可以实现类似Redux中的Reducer部分

需要注意的是,useReducerdispatch操作必须是同步的,如果需要执行异步操作,需要模拟类似react-redux的实现方式。

具体实现

useCallback

useCallback主要用来解决使用React hooks产生的无用渲染的性能问题。

在class组件中,我们渲染时的性能优化一般可以通过shouldCompnentUpdate函数来进行, 但是在函数组件里,由于它不具备生命周期函数,也就是说我们没有办法通过组件更新前条件来决定组件是否更新。函数组件的每一次调用都会执行内部的所有逻辑,就带来了非常大的性能损耗。useMemo和useCallback都是解决上述性能问题的。

组件多次复用的性能问题


// Counter.tsx 多次复用Count, Button, Title
function Counter() {
    const [age, setAge] = useState(18);
    const [salary, setSalary] = useState(5000);
    
    const incrementAge = () => {
        setAge(age + 1)
    }
    const incrementSalary = () => {
        setSalary(salary + 1000)
    }

    return (
      <div>
        <Title/>
        <Count text="age" count={age}/>
        <Button handleClick={incrementAge}>
          修改年龄
        </Button>
        <Count text="salary" count={salary}/>
        <Button handleClick={incrementSalary}>
          修改工资
        </Button>
      </div>
    );
}


// Count.tsx 
function Count(props: {
  text: string,  
  count: number
}) {
  console.log(`Rendering ${props.text}`)
  return (
    <div>
      {props.text} - {props.count}
    </div>
  )
}

// Title.tsx
function Title() {
  console.log('Rendering Title')
  return (
    <h2>useCallback</h2>
  )
}

// Button.tsx
function Button(props: {
  handleClick: () => void
  children: string
}) {
  console.log('Rendering button', props.children)
  return (
    <button onClick={props.handleClick}>
      {props.children}
    </button>
  )
}


具体实现

当我们每次点击按钮时,看到以下日志:

Rendering Title
Rendering age
Rendering button 修改年龄
Rendering salary
Rendering button 修改工资

每次状态改变都触发了所有组件的rerender,然而我们期望是当我们修改年龄时,只有依赖age的那个组件rerender

使用 React.memo 优化

不同class组件中可以使用shoudComponentUpdate, PureComponent来做性能优化, React为函数式组件提供了叫React.memo一个高阶组件.

我们可以通过将组件包装在React.memo 中调用,通过这种记忆组件渲染结果的方式来提高组件的性能。这意味着当props没有变化时, React将跳过渲染组件的操作并直接复用最近一次渲染的结果。

const MyComponent = React.memo(function MyComponent(props) {
  /* 使用 props 渲染 */
});

React.memo 仅检查 props 变更。如果函数组件被 React.memo 包裹,且其实现中拥有 useStateuseContext 的 Hook,当 context 发生变化时,它仍会重新渲染。

使用了 React.memo 后,我们看到点击增加年龄的按钮时,日志变为了

// Rendering age
// Rendering button 修改年龄
// Rendering button 修改工资

依然有不相关的 rerender Rendering button 修改工资出现。说明修改工资这个组件的props发生里变化。

简单分析一下:

  • 点击增加年龄按钮触发setAge()方法
  • age改变导致组件重新渲染,重新执行Counter()方法
  • Counter()内部的方法重新被创建
  • 修改工资 Button 传入的 props 发生了变化
  • Button()重新render

因此这个 Button 传入的 props 发生了变化,这时候React.memo没有阻止 rerender。而我们的useCallback这个`hook就是为了解决这个问题。

在js中,当函数执行时,会创建一个被称为执行环境的对象,这个对象在每次函数执行时都是不同的,当多次执行该函数时会创建多个执行环境。这个执行环境会在函数执行完毕后销毁。所以每次rerender时都会创建新的执行环境,并为其内部的方法重新分配空间

什么是 useCallback`

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

返回一个memoized回调函数。 把内联回调函数依赖项数组作为参数传入 useCallback,它将返回该回调函数的 memoized 版本,该回调函数仅在某个依赖项改变时才会更新。

在上述例子中


const incrementAge = useCallback(
  () => {
    setAge(age + 1)
  },
  [age],
)


// Rendering salary
// Rendering button 修改工资

useMemo

useMemo和useCallback类似,都是用来做性能优化的。

  • useMemo:缓存的是值
  • useCallback: 缓存的是函数

先来看一个例子


import React, { useState } from 'react'

function Counter() {
  const [counterOne, setCounterOne] = useState(0)
  const [counterTwo, setCounterTwo] = useState(0)

  const incrementOne = () => {
    setCounterOne(counterOne + 1)
  }

  const incrementTwo = () => {
    setCounterTwo(counterTwo + 1)
  }

  const isEven = () => {
    let i = 0
    while (i < 1000000000) i += 1
    return counterOne % 2 === 0
  }

  return (
    <div>
      <button
        onClick={incrementOne}
      >Count One = {counterOne}</button>
      <span>
        {
          isEven() ? 'even' : 'odd'
        }
      </span>
      <br />
      <button
        onClick={incrementTwo}
      >Count Two = {counterTwo}</button>
    </div>
  )
}

export default Counter

具体实现

点击第一个按钮有较长的延迟,因为我们的判断偶数的逻辑中包含了大量的计算逻辑。但是,我们点击第二个按钮,也有较长的延迟!

这是因为,每次 state 更新时,组件会 rerender,isEven 会被执行,这就是我们点击第二个按钮时,也会卡的原因。我们需要优化,告诉 React 不要有不必要的计算,特别是这种计算量复杂的。

这时就需要 useMemo hook 登场了。

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
  • 返回一个 memoized 值。 把“创建”函数和依赖项数组作为参数传入 useMemo,它仅会在某个依赖项改变时才重新计算 memoized 值。这种优化有助于避免在每次渲染时都进行高开销的计算。

  • 传入 useMemo 的函数会在渲染期间执行。不要在这个函数内部执行与渲染无关的操作。

  • 如果没有提供依赖项数组,useMemo 在每次渲染时都会计算新的值。

useRef

const refContainer = useRef(initialValue);

useRef 返回一个可变的 ref 对象,其 .current 属性被初始化为传递的参数(initialValue)。返回的对象将存留在整个组件的生命周期中。

useRef一般有两种用途

  • 获取DOM节点,这一点和class组件中的ref类似。
  • 用来保存变量
获取上一轮的 props 或 state

function Example () {
  const [count, setCount] = useState(0);

  const prevCountRef = useRef();
  
  useEffect(() => {
    prevCountRef.current = count
  });

  const prevCount = prevCountRef.current
  console.log(prevCount, count, '之前的状态和现在的状态')
  return (
    <div>
      <div>{count}</div>
      <button onClick={() => {setCount(count+1)}}>+</button>
    </div>
  )
}


自定义hook

自定义 Hook 是一个函数,其名称以 “use” 开头,函数内部可以调用其他的 Hook。

通过自定义 Hook,可以将组件中重复的逻辑提取到可重用的函数中。

function Example () {
  const [count, setCount] = useState(0);

  const prevCount = usePrevious(count)
  console.log(prevCount, count, '之前的状态和现在的状态')
  return (
    <div>
      <div>{count}</div>
      <button onClick={() => {setCount(count+1)}}>+</button>
    </div>
  )
}

function usePrevious (value) {
  const ref = useRef()
  useEffect(() => {
    ref.current = value
  })
  return ref.current
}

总结

  • useState: 用来声明状态state,修改值需要手动合并
  • useEffect: 用来替换类组件中的生命周期函数,简化重复的操作
  • useContext: 全局共享状态,解决祖先/子孙组件之间的传参问题
  • useReducer: useState的替换方案,将操作和更新解绑,配合useContxet可以实现简易redux
  • useCallback: 对函数进行缓存,优化性能
  • useMemo: 对值进行缓存,优化性能
  • useRef:获取DOM节点或组件实例, 保存变量

Capture Value 捕获属性

react会在每次rerender时捕获自己独立的state、props、effects、事件处理函数

闭包

函数每次执行时会形成新的执行环境,这个对象上存在一个[[Scope]]的属性,它指向到是它所在环境的作用域链.之后会生成一个活动对象(AO),这个AO上保存这当前函数到变量、参数、方法,并且会将这个AO对象放在[[Scope]]的最顶端。一般来说,这个AO对象会在函数执行完成时随执行环境清除而清除。

但是,当我们在函数内部返回一个函数并在其外部被一个变量接收时,这个变量(返回的函数)的作用域链指向的是它所处环境的的作用域链,只要这个函数存在则它的作用域链就会一直存在,这样它的作用域链上的变量得不到释放,即能在函数外部访问作用域内部的变量,这样就形成里闭包

形成闭包最简单的方式就是在函数内部返回另一个函数。


function a() {
    var b = 2;
    function c() {
        var d = 4;
        console.log(b)
    }
    
    return c
}

var d = a() // a的[[scope]]指向全局环境,并生成自己的AO,放在[[scope]]的最顶端

d() // 2  c的[[scope]]指向a的[[scope]],并生成自己的AO,放在[[scope]]的最顶端

而在函数组件内部,正是因为js闭包机制,所以才有了Capture Value属性

每次 Render 都有自己的 Props、State

export default class ClassCounter extends React.Component{
    constructor(props){
        super(props);
        this.state = {
          count: 0
        }
    }
    
    handleAlertClick() {
        setTimeout(() => {
          alert('You clicked on: ' + this.state.count);
        }, 3000);
    }
    
    return (
    <div>
      <p>You clicked {this.state.count} times in class Component</p>
      <button onClick={() => this.setState(this.state.count + 1)}>
        Click me
      </button>
      <button onClick={this.handleAlertClick.bind(this)}>
        Show alert
      </button>
    </div>
  );
}


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

  function handleAlertClick() {
    setTimeout(() => {
      alert('You clicked on: ' + count);
    }, 3000);
  }

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
      <button onClick={handleAlertClick}>
        Show alert
      </button>
    </div>
  );
}
// 连续点击3次`Click me`
// 再点击一次`Show alert`,并在3s内点击两次`Click me`
// alert时count的值是多少?

在线案例

let _state = null; 

function useState(initialValue) {
  const state = _state | initialValue;
  function setState(newState) {
    _state = newState;
    // 会重新执行组件函数
    // render();
  }
  return [state, setState];
}



function Component() {
  const [count, setCount] = useState(0);

  const handleAlertClick = () => {
    setTimeout(() => {
      console.log("You clicked on: " + count);
    }, 3000);
  };
  // 暴露页面上可以执行的函数
  return [handleAlertClick, setCount];
}

// 首次 render  count = 0
const [handleAlertClick, setCount] = Component();

// 点击按钮,形成闭包,此时闭包[[Scope]]上的count=0,执行setCount,_count 变为1,
// 此时会重新执行Component(),生成新的执行环境,并返回新的 handleClick,setCount,
setCount(count + 1); 
setCount(count + 1); // count = 1 _count=2
setCount(count + 1); // count = 2 _count=3
// 模拟点击showAlert, 
handleAlertClick(); //  count = 3  3s后alert的是3
setCount(count + 1); // count = 3  _count=4
setCount(count + 1); // count = 4  _count=5

每一次渲染都有它自己的 Props、State and Effects,每一个组件内的函数(包括事件处理函数,effects,定时器或者API调用等等)会捕获某次渲染中定义的props和state,并且在这次渲染中它的state是固定不变的。也就是说他们都有Capture Value属性,这是函数组件区别与class组件到的特性之一。

如何绕过 Capture Value

function Counter() {
  const [count, setCount] = useState(0);
  const latestCount = useRef(count)
  function handleAlertClick() {
    setTimeout(() => {
      alert('You clicked on: ' + latestCount.current);
    }, 3000);
  }

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => {
        setCount(count + 1);
        latestCount.current = count + 1;
      }}>
        Click me
      </button>
      <button onClick={handleAlertClick}>
        Show alert
      </button>
    </div>
  );
}

由于Capture Value的存在,我们在class组件中有些比较合理的想法,在函数组件中使用似乎就会有点问题

不要对 Dependencies 撒谎

考虑这么一个需求: 定义一个count,让这个count每秒加一,并且显示在页面上。

按照我们class组件的想法,在componentDidMount里,定义一个计时器setInterval, 并且在componentWillUnmount里清除计时器

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

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []); // 传空数组只执行一次

  return <h1>{count}</h1>;
}

这看起来似乎没什么问题,但是由于 useEffect 符合 Capture Value 的特性,拿到的 count 值永远是初始化的 0。相当于 setInterval 永远在 首次render的Scope 中执行,你后续的 setCount 操作并不会产生任何作用。

这显然和我们的需求不符,于是我们在deps里添加一个属性count,告诉reactcount变化后再执行

useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1);
  }, 1000);
  return () => clearInterval(id);
}, [count]);

这种方式满足了了我们的需求,但是我们的诚实也带来了一定的代价

  • 计时器不准了,因为每次 count 变化时都会销毁并重新计时。
  • 频繁 生成/销毁 定时器带来了一定性能负担。

怎么既诚实又高效?

setState有一种回调函数式的调用方式setState((preState) => preState + 1)


useEffect(() => {
 const id = setInterval(() => {
   setCount(c => c + 1);
 }, 1000);
 return () => clearInterval(id);
}, []);

当某个值依赖多个值变化时?

某一天,我们改变了需求,希望显示在页面上的值,依赖两个数据的变化

useEffect(() => {
 const id = setInterval(() => {
   setCount(c => c + step);
 }, 1000);
 return () => clearInterval(id);
}, [step]);

我们会发现不得不依赖step这个变量,那有没有什么办法能将更新和动作解耦呢?

金手指模式

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({ type: "tick" }) 所以不管更新时需要依赖多少变量,在调用更新的动作里都不需要依赖任何变量。 具体更新操作在 reducer 函数里写就可以了。

关于组件内部的函数

  • 如果某些函数仅在某个effect中调用,可以把它们的定义移到effect中
  • 如果某些函数不依赖于组件中的任何数据,可以把它们的定义移到组件外部
  • 如果这个函数需要通过props传给子组件,一般最好使用useCallback做一下缓存
  • 如果这个函数的作用是做大批量的计算,且返回会值需要显示在页面上,最好使用useMemo做一下缓存

一些已经支持hook的库

React Redux 从 v7.1.0 开始支持hook的api

React Router 从 v5.1 开始支持 hook

Mobx + Hooks

umi Hooks

react-use

useHooks