React函数组件Hooks-useState详解

1,306 阅读5分钟

image.png

一、useState 原理和源码

useState的运行过程

function App() {
  const [n, setN] = React.useState(0);
  //也可以是复杂对象,如:const [user, setUser] = React.useState({name:'F'});
  return (
    <div className="App">
      <p>{n}</p>
      <p>
        <button onClick={() => setN(n + 1)}>+1</button>
      </p>
    </div>
  );
}

ReactDOM.render(<App />, rootElement);

运行过程:

  • 首次渲染 render<App/>
  • 调用 App(),得到虚拟Div对象,进而创建真实的Div到页面。
  • 当用户点击button时,调用 setN(n+1),再次 render<App/>
  • React对比两个DIV,DOM Diff局部更新Div。

在这个过程中,每次调用App函数,都会运行 useState。那么,App重新执行的时候,执行 useState(0) 的结果,即n每次的值会不同吗?

function App() {
  const [n, setN] = React.useState(0);
  console.log(`n:${n}`)
  return (
    <div className="App">
      <p>{n}</p>
      <p>
        <button onClick={() => setN(n + 1)}>+1</button>
      </p>
    </div>
  );
}

几个问题:

  • 执行setN的时候会发生什么?
    • 答:重新渲染UI(因为n+1了)
  • n会变吗?
    • 答:不会。是要把n变了,而不是已经变了,也就是说setN并不会改变n
  • App()会重新执行吗?
    • 答:会
  • 如果App()会重新执行,那么 useState(0) 的时候,n每次的值会有不同吗?
    • 答:,通过log,每次点击按钮,把n+1后,执行的App()里,每次log出的n的值都是不同的。为什么同样的一句代码,在几次执行的过程中,会有不同的结果呢?

分析

  • setN 一定会修改某个数据x, 将n+1存入x
  • setN 一定会触发 <App/>,进行重新渲染
  • useState 肯定会从x读取n的最新值
  • 每个数据都有自己的数据x, 我们将其命名为state

useState源码的初级思路

import React from "react";
import ReactDOM from "react-dom";
const rootElement = document.getElementById("root");

let _state;

function myUseState(initialValue) {
  _state = _state === undefined ? initialValue : _state;
  function setState(newState) {
    _state = newState;
    render();
  }
  return [_state, setState];
}

// 教学需要,不用在意 render 的实现
const render = () => ReactDOM.render(<App />, rootElement);

function App() {
  const [n, setN] = myUseState(0);
  return (
    <div className="App">
      <p>{n}</p>
      <p>
        <button onClick={() => setN(n + 1)}>+1</button>
      </p>
    </div>
  );
}

ReactDOM.render(<App />, rootElement);

实现两个/多个useState(大体思路)

如果一个组件用了两个useState怎么办??

  • 由于所有数据都放在 _state,所以会冲突

改进思路

  • 试着把 _state 做成一个对象,比如 _state= {n:0, m :0}。但是这样做的话, useState(0) 就不知道是n:0还是m:0
  • 试着把 _state 做成数组,比如 _state= [0,0], 这种方法似乎可行

代码实例

import React from "react";
import ReactDOM from "react-dom";
const rootElement = document.getElementById("root");

let _state = [];
let index = 0;

function myUseState(initialValue) {
  const currentIndex = index;
  index += 1;
  _state[currentIndex] = _state[currentIndex] || initialValue;
  const setState = newState => {
    _state[currentIndex] = newState;
    render();
  };
  return [_state[currentIndex], setState];
}

// 教学需要,不用在意 render 的实现
const render = () => {
  index = 0;//渲染的时候需要重新赋值,否则index就会一直加
  ReactDOM.render(<App />, rootElement);
};

function App() {
  const [n, setN] = myUseState(0);
  const [m, setM] = myUseState(0);
  console.log(_state);
  return (
    <div className="App">
      <p>{n}</p>
      <p>
        <button onClick={() => setN(n + 1)}>+1</button>
      </p>
      <p>{m}</p>
      <p>
        <button onClick={() => setM(m + 1)}>+1</button>
      </p>
    </div>
  );
}

ReactDOM.render(<App />, rootElement);

useState 的原理和上文自己实现的代码类似。有一个state数组,存放一个函数组件里的所有数据。这些数据通过下标index区分。每个数据在调用 useState() 的时候,都会按顺序有自己的index。调用这个函数返回的n就用来读取值。

点击按钮会调用 setN(n+1),在这个函数里,把新的值赋给state数组里的对应项,然后再次渲染,只要渲染就肯定会调用App()函数,就又会执行一次 useState,再次执行的时候会做一次判断,如果state里已经有值了,就继续使用上次的值,也就是更新之后的值。这样就保证了,在多次渲染时,即使是执行了同一行代码 useState(0),也会有最新的结果。

不同的组件都有一份属于自己的state和index,保存在自己的虚拟DOM上。如果点击了按钮,执行 setState(),会修改state,并且触发更新,就会再次渲染,再次调用App(),就会再次执行 useState,它会读取 state[index],而且是最新值,然后生成新的虚拟DOM

_state 数组方案的缺点:

  • useState调用顺序。如果第一次渲染是n是第一个,m是第二个,k是第三个。则要求第二次渲染时必须保障顺序一致。也就是不能使用 if...else 打乱顺序
  • App用了 _stateindex ,其他组件用什么?
    • 解决方法: 给每个组件创建一个_stateindex
  • _stateindex 放在全局作用域重名了怎么办?
    • 解决方法:放在组件对应的虚拟节点对象上

image.png

小结

  • 每个函数组件对应一个React节点, 即FiberNode
  • 每个节点保存着state和index, state即memorizedState, index的实现使用了链表结构
  • useState 会读取 state[index]
  • index由 useState 调用的顺序决定
  • setState 会修改state,并触发更新

二、useRef和useContext

setN(n+1) 不会改变n

import React from "react";
import ReactDOM from "react-dom";
const rootElement = document.getElementById("root");

function App() {
  const [n, setN] = React.useState(0);
  const log = () => setTimeout(() => console.log(`n: ${n}`), 3000);
  return (
    <div className="App">
      <p>{n}</p>
      <p>
        <button onClick={() => setN(n + 1)}>+1</button>
        <button onClick={log}>log</button>
      </p>
    </div>
  );
}

ReactDOM.render(<App />, rootElement);

上面的代码出现了问题

  • 点击+1再点击log 没有bug
  • 点击log再点击+1,出现bug,为什么log打印的是上一次的数据

疑惑解答: 因为有多个n

image.png

这就是因为 setN 并不会改变n。先+1,进行渲染时n就是1,原来的0没用了就会被自动回收。然后再log,打印出的就是1。可是如果先log,三秒后打印n的值。再+1,在第二次渲染App里,n是1,可是第一次渲染的n=0还是存在着的,这个n并没有被改变,所以打印出的还是0

因为只要修改数据,就会触发UI更新,那就一定会再次调用App()。比如第一次调用App,是第一次渲染,n是0.把n+1,就会第二次调用App,是第二次渲染,在这个新的App里,n是1.但是原来的App里的n还是0,并没有被改变。它只是生成了另一份新的数据。

所以就说明,只要我改变数据,渲染一次,就会生成一份n。

如果希望有一个贯穿始终的状态,应该怎么做

  • 使用全局变量,比如 window.xxx
  • 使用 useRef,useRef不仅可以用于div,还能用于任意数据。但是useRef不会在属性变动时自动触发更新,只能手动设置更新,但是不推荐使用手动更新
  • 使用useContext, useContext不仅可以贯穿始终,还可以贯穿不同组件

useRef

import React from "react";
import ReactDOM from "react-dom";
const rootElement = document.getElementById("root");

function App() {
  const nRef = React.useRef(0);
  const log = () => setTimeout(() => console.log(`n: ${nRef.current}`), 1000);
  return (
    <div className="App">
      <p>{nRef.current} 这里并不能实时更新</p>
      <p>
        <button onClick={() => (nRef.current += 1)}>+1</button>
        <button onClick={log}>log</button>
      </p>
    </div>
  );
}

ReactDOM.render(<App />, rootElement);

不用 useState,因为 useState 生成的n会执行一次就生成一份。用 useRef 。数据保存在 useRef.current 里,这个只有一份。修改也是把 useRef.current 的值+1,所以这时先log,再+1,打印出的值就是新的n。因为没有生成新的 useRef.current,不管更新多少次,都是同一个对象

bug : UI不会自动更新,点+1,页面上的n并不会变化。虽然实际上 useRef.current的值已经变了,但是不会更新到UI上。

useContext

import React from "react";
import ReactDOM from "react-dom";
import "./styles.css";
const rootElement = document.getElementById("root");

const themeContext = React.createContext(null);

function App() {
  const [theme, setTheme] = React.useState("red");
  return (
    <themeContext.Provider value={{ theme, setTheme }}>
      <div className={`App ${theme}`}>
        <p>{theme}</p>
        <div>
          <ChildA />
        </div>
        <div>
          <ChildB />
        </div>
      </div>
    </themeContext.Provider>//可以理解为一个作用域
  );
}

function ChildA() {
  const { setTheme } = React.useContext(themeContext);
  return (
    <div>
      <button onClick={() => setTheme("red")}>red</button>
    </div>
  );
}

function ChildB() {
  const { setTheme } = React.useContext(themeContext);
  return (
    <div>
      <button onClick={() => setTheme("blue")}>blue</button>
    </div>
  );
}

ReactDOM.render(<App />, rootElement);

它不仅可以贯穿一个组件的始终,也可以贯穿其他组件。它可以让在指定作用域内的所有子孙组件都能拿到最上层组件读写数据接口

小结

  • 每次重新渲染,函数组件就会执行
  • 函数组件对应的所有state就会被重新复制
  • 如果不想出现复制的state,可以使用useRef,或者useContext解决

useState注意事项

1.不可局部更新

import React, {useState} from "react";
import ReactDOM from "react-dom";

function App() {
  const [user,setUser] = useState({name:'Frank', age: 18})
  const onClick = ()=>{
    setUser({
      name: 'Jack'
    })
  }
  return (
    <div className="App">
      <h1>{user.name}</h1>
      <h2>{user.age}</h2>
      <button onClick={onClick}>Click</button>
    </div>
  );
}

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);

如果state是一个对象,里边有多个属性。在 setUser的时候,如果只修改其中一个属性,会导致另一个变成undefined。因为setState不会自动合并属性。

应该用 ... 语法,把这个对象的所有属性先拷贝过来,再设你要改变的属性。

const onClick = ()=>{
    setUser({
      ...user,// 自己手动合并(相当于手动copy了一份user)
      age:20
    })
}

2.地址要变

const onClick = ()=>{
    user.age=30
    setUser(user)
}

比如说,我想修改user.age,先把user.age赋一个新值,在set这个user。会发现,点击按钮,UI并没有更新。

因为React发现user的地址没变,就认为数据没变,就不会去更新UI。

所以要给一个新的地址,给set里传一个对象就可以了,这样就不是同一个地址了。

const onClick = ()=>{
    setUser({
      ...user
      name: 'Jack'
    })
  }

3.useState可以接受函数

const [user,setUser] = useState(
    () => ({name:'Frank', age: 18})
 )

返回一个初始值,效果和直接把初始值传进去一样。接受函数的好处是如果这个初始值的计算比较复杂,函数形式只会执行一次,也就只会在第一次计算。如果是直接传初始值,每次进来都要计算一次。(但是我们一般直接传初始值

优点:该函数返回初始值state,且执行一次。减少多余的计算过程

4.setState也可以接受函数

使用场景:我想先把n+1,再把n+2。如果像下边这样写,预期效果是点击按钮后,页面上的n 是3

import React, {useState} from "react";
import ReactDOM from "react-dom";

function App() {
  const [n, setN] = useState(0)
  const onClick = ()=>{
    setN(n+1)
    setN(n+2) // 你会发现 n 不能加 3
    // setN(i=>i+1)
    // setN(i=>i+2)
  }
  return (
    <div className="App">
      <h1>n: {n}</h1>
       
      <button onClick={onClick}>+3</button>
    </div>
  );
}

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);

可是点击按钮,发现n变成了1。

因为我们之前说过,setN(n+1) 不会改变n,它会生成一个新的n。即,执行了第一行 setN(n+1),此时n还是0,如果接下来又对这个n操作,setN(n+2),意思是把n加2,就是把0加2,那么结果就是2。不管setN有多少行,都只相当于执行最后一行。

如果想对一个state连续操作,就可以使用函数

const onClick = ()=>{
     setN(i=>i+1)
     setN(i=>i+2)
}

setN里没有n这个变量。只有一个函数,这个函数代表了一个操作,+1操作,+2操作。并没说把n加1。只是用了占位符i表示了一种操作。

React看到这两行,先把n+1,第二行再把新的值也就是n=1按照+2操作进行,所以页面上n变3。

更推荐使用函数写法

5.不要改变useState的位置,也不要使用if语句

const[n,setN]=React.useState(0)
const[user,setUser]=React.useState({name:"varown"})

不可以颠倒和更改他们的位置,保持和初写时的一致

6.useState的初始值,只在第一次有效

举个例子:

当我点击按钮修改name的值的时候,我发现在Child组件, 是收到了,但是并没有通过useState赋值给name!

代码演示

const Child = memo(({data}) =>{
    console.log('child render...', data)
    const [name, setName] = useState(data)
    return (
        <div>
            <div>child</div>
            <div>{name} --- {data}</div>
        </div>
    );
})

const Hook =()=>{
    console.log('Hook render...')
    const [count, setCount] = useState(0)
    const [name, setName] = useState('rose')

    return(
        <div>
            <div>
                {count}
            </div>
            <button onClick={()=>setCount(count+1)}>update count </button>
            <button onClick={()=>setName('jack')}>update name </button>
            <Child data={name}/>
        </div>
    )
}