useState 原理

408 阅读5分钟

使用 react hooks 一段时间了,一直不知道内部具体逻辑是什么。趁着周末研究了一下,一起来看看吧。

用法

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

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

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

先看这一段react代码, 想象一下点击button会发生什么?

const [n, setN] = React.useState(0);

没了解useState原理前,我对这段代码的理解是这样:n 的初始值就是0,setN 就是改变n的函数。

实际上,这种理解方式是错误的。

首次渲染 render App 组件,执行App 函数。得到虚拟 div,创建真实div。

那点击button会发生什么呢?

调用onClick 函数,函数执行。

这个时候页面更新,再次 render App 组件,重新渲染 App 组件。

得到虚拟 div,DOM DIFF 发现只用将 n 的值更新为 1,更新真的div。

点击 button

const [n, setN] = React.useState(0);
console.log(n); // 1

这个时候打印出 n,变为了 1。 useState 到底做了什么,让 n 从 0 变为 1?

分析

setN

执行 setN 的时候发生了什么?n 会改变吗?App() 会重新执行吗?

n的分身

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

先点击 +1 按钮,再点击 log,打印出来的 n 变化为 1。

而先点击 log 按钮,再点击 +1,打印出来的 n 还是 0。

说明 setN 并没有改变原来 n 的值,此时内存中有两个 n。

那么 n 是如何变化的?

实际上,点击 log 按钮之后,n 值是0,3s 后被打印出来。

再点击 +1,触发 setN,setN 先把 n + 1 存入一个中转数据中(命名为state,表示状态), 然后触发重新渲染 re-render,创造一个全新的 n,这个 n 的值为 state。

如果你觉得每次创建一个新的 n 太浪费内存了,希望有一个 n 贯穿始终。 也不是没有办法。(可以看看 vue3,vue3 是只有一个 n。)

useRef

useRef.current 会实时更新的,但 Ref 不会重新渲染App,需要我们手动重新渲染。

function App() {
  // Ref 是个很简单的对象 {current: 0}
  const nRef = React.useRef(0); 
  // 打印出的 n 会改变
	const log = () => setTimeout(() => console.log(n), 3000);
	// useState 中 setState 会执行 re-render
  const update = React.useState(null)[1];
  return (
    <div className="App">
      // 但是这里并不能实时更新,因为Ref不会重新渲染App
      <p>{nRef.current}</p>
      <p>
        <button onClick={() => {nRef.current += 1};update(nRef.current);}>+1</button>
		<button onClick={log}>log</button>
      </p>
    </div>
  );
}

useContext

useContext 不仅能贯穿始终,还能贯穿不同组件。

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

const themeContext = React.createContext(null);

function App() {
  const [theme, setTheme] = React.useState("red");
  return (
// themeContext.Provider 表示 value 这个的作用域从这里开始
    <themeContext.Provider value={{ theme, setTheme }}>
      <div className={`App ${theme}`}>
        <p>{theme}</p>
        <ChildA />
        <ChildB />
      </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);

n 的分身总结

每次重新渲染,组件函数就会执行。对应的所有 state 都会出现分身。 如果你不希望新的 state出现,可以使用 useRef/useContext 等。

setState

如果 App() 会重新执行,那么 setState(0) 的时候,n 每次的值会有不同吗?

会的,setState 会从state读取 n 的最新值。

简单实现 useState

根据以上分析,我们来实现一个简单的 useState

一个组件只有一个 useState

// _state 存储状态的中间值
let state
const myUseState = (initialValue) => {
	state = state === undefined ? initialValue : state
  const setState = (newValue) => {
		state = newValue
		// 重新渲染
		render();
	}
	return [state, setState];
}

// 在这里先简化 render 写法
const render = () => {
	ReactDOM.render(<App />, rootElement);
}

一个组件使用两个 useState

我们自己实现的 myUseState 会出现问题。

由于所有数据都放在 _state,所以会出现冲突。

怎么办呢?

把 _state 做成数组,第一个值放在数组第一位,第二个值放第二位,依次排列。

比如 _state = [0, 0]

// 修改初始状态
let _state = [];
// 第几个值
let index = 0;
const myUseState = (initialValue) => {
	// 保存index状态
	const currentIndex = index
	_state[currentIndex ] = _state[currentIndex ] === undefined 
													? initialValue : _state[currentIndex ]
  const setState = (newValue) => {
		_state[currentIndex ] = newValue
		render();
	}
  index += 1;
	return [state[currentIndex] setState];
}

const render = () => {
  // 每次渲染前,重置 index
	index = 0
	ReactDOM.render(<App />, rootElement);
}

App 用了 _state 和 index,其他组件呢?

给每一个组件创建一个自己的 _state 和 index 不就行啦。

目前这些变量都是放在全局作用域的,如何重名了怎么办呢?

React 将这些变量放在了对应的虚拟节点对象上,避免了全局作用域重名问题。

_state 数组方案缺点

useState 调用顺序

若第一次渲染时 n 是第一个,m 是第二个,k 是第三个。 第二次渲染必须保证顺序完全一致。(顺便说一句,Vue3 解决了这个问题)

所以 React 不允许出现类型下面的代码。

function App() {
  const [n, setN] = React.useState(0);
  let m, setM;
  // *********************************
	/* 这段代码会提示错误
		Previous render   Nextrender
	1. useState          useState
	2. undefined         useState	
  */
  if (n % 2 === 1) {
    [m, setM] = React.useState(0);
  }
  // *********************************
  return (
    <div className="App">
      {n} <button onClick={() => setN(n + 1)}>+1</button>
			<br/>
      {m} <button onClick={() => setM(m + 1)}>+1</button>
    </div>
  );
}

useState 总结

  1. 图解 App 更新过程

左边是真实DOM,有一个div。 中间是React维护的虚拟DOM树,有一个对象。 右边是函数组件,属于代码层面。

运行App(),调用 useState(),将_state 和 index 放在虚拟DOM对象上。

第一次运行得到虚拟 dom App1,App1渲染到虚拟DOM树上,虚拟DOM树就会映射到div上。

点击button后,App()重新渲染,再次执行useState(),得到App2。 通过 DIFF 算法,比较 App1和App2,将它们的差别之处生成一个对象 Patch。

Patch运行到虚拟DOM上,最后更新页面。

  1. useState 实现原理 每个组件对应一个 React 节点,每个节点保存着 state 和 index。 useState 会读取 state[index],index由useState出现的顺序决定。 setState 会修改 state,并触发更新。

这里对 React 的实现做了简化,React 节点应该是 FiberNode, _state 为 memorizedState。