HooksuseState 原理解析

大纲链接 §

[toc]


逐步分析 useState 基本原理和源码近似实现

1. 最简单的 useState 实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
function App() {
  console.log('App 运行了')
  const [n, setN] = useState(0)
  console.log(`n: ${n}`)
  return (
    <div className="app">
      <p>{n}</p>
      <p>
        <button onClick={() => setN(n + 1)}>+1</button>
      </p>
    </div>
  )
}
ReactDOM.render(<App />, rootElement)

脑补点击button会发生什么

  • 首次渲染 render <App />
    • 调用App函数,得到虚拟div,创建真实div
  • 用户点击button按钮,调用setN(n + 1),再次 render <App />
    • 调用App函数,得到虚拟div,经过DOM Diff,局部更新真实div
  • 每次调用App(),都会运行useState(0)
    • n次运行useState,每次得到的n并不相同
  • useState如何实现每次运行时,得到n的结果不一样?

脑补点击button会发生什么

  • 执行setN的时候,会发生什么?n会变吗?App()会重新执行吗?
  • 如果App()会重新执行,那么useState(0)的时候 ,n每次的值会有不同吗?
  • 通过console.log可以得出答案
    • App()会重新执行
    • setN并不会改变state.n本身
    • n的值会改变

分析推论

  • setN
    • setN一定会修改数据x,将n + 1存入x
      • 而不是直接赋值给原数据n
    • setN一定会触发<App />重新渲染re-render
  • useState
    • useState一定会从x读取n最新值
  • x
    • 每个组件有自己的数据x,将其命名为state

尝试实现 React.useState

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import React from 'react'
import ReactDOM from 'react-dom'
const rootElement = document.getElementById('root')

function myUseState(initialValue) {
  console.log('myUseState运行')
  let _state = initialValue

  function setState(newState) {
    _state = newState
    render()
  }
  return [_state, setState]
}
// 借用ReactDOM.render
const render = ReactDOM.render(<App />, roootElement)

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

ReactDOM.render(<App />, rootElement)
  • 点击按钮,视图不更新,打印n没有变化
  • 因为每次运行myUseState会将state重置
  • 需要一个不会被myUseState重置的变量
  • 利用闭包,将变量声明在myUseState外部

再次尝试实现 React.useState

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import React from 'react'
import ReactDOM from 'react-dom'
const rootElement = document.getElementById('root')

let _state
function myUseState(initialValue) {
  console.log('myUseState运行')
  // _state = _state === undefined ? initialValue : _state
  // _state = _state || initialValue // 有0值判断的bug
  _state = _state ?? initialValue // 空值合并运算符 保证常量不为 null 或者 undefined

  function setState(newSstate) {
    _state = newState
    render()
  }
  return [_state, setState]
}
// 借用ReactDOM.render
const render = ReactDOM.render(<App />, roootElement)

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

ReactDOM.render(<App />, rootElement)

以上代码存在的问题:如果一个组件用了 两个 useState 怎么办


2. 如何让两个 useState 不冲突

  • 由于所有数据都放在同一个 _state 中,所以会冲突
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import React from "react";
import ReactDOM from "react-dom";
const rootElement = document.getElementById("root");

let _state;

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

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

function App() {
  const [n, setN] = myUseState(0);
  const [m, setM] = myUseState(0);
  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);

改进思路

  • _state做成一个对象
    • 比如_state = {n: 0, m: 0}
    • 不行,因为useState(0)并不知道变量的命名,是n还是m
  • _state做成数组
    • 比如_state = [0, 0]

使用多个useState

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
let _state = []
let index = 0

function myUseState(initialValue) {
  const currentIndex = index
  console.log('currentIndex', currentIndex)
  index += 1
  // 等价于 // _state[currentIndex] = _state[currentIndex] === undefined ? initialValue : _state[currentIndex]
  _state[currentIndex] = _state[currentIndex] ?? initialValue
  const setState = newState => {
    console.log('index', index)
    _state[currentIndex] = newState
    console.log('_state', _state)
    render()
  }
  return [_state[currentIndex], setState]
}

const render = () => {
  index = 0 // 重置index 防止每次渲染不断向_state中添加
  ReactDOM.render(<App />, rootElement)
}

function App() {
  const [n, setN] = myUseState(0);
  const [m, setM] = myUseState(0);
  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);
  • 代码示例,注意index += 1currentIndex和 在渲染方法中重置index = 0
  • 不可以直接使用index,需要先使用中间变量保存下currentIndex
    • index闭包变量,index += 1 用来累计加一
  • 这样会导致 _state 越来越长,每次运行Appindex没有被重置
  • 理论上在每次渲染App前,就应该重置index = 0
    • 防止每次渲染不断向_state数组中添加新项,而不是修改对应值

3. useState 不能写在 if 条件判断语句里

_state数组方案缺点:if可能会打乱调用顺序

  • useState调用顺序
    • 若第一次渲染时,n是第一个,m是第二个,k是第三个
    • 则第二次渲染时必须保证调用顺序完全一致,不能跳过,颠倒顺序,否则数据就乱了

React不允许出现如下代码,查看报错

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// ...
function App() {
  const [n, setN] = React.useState(0);
  let m, setM
  if(n % 2 === 1) { // if 打乱了调用顺序
    const [m, setM] = React.useState(0);
  }
  // 此时如果还有一个数据k,就会被提前到第二的位置
  // const [k, setK] = React.useState(0);
  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>
  );
}
// ...
  • 报错 React Hook "React.useState" is called conditionally. React Hooks must be called in the exact same order in every component render.
  • 点击后报错:

useStateOrder

  • 参考 React 源码赏析一则
  • 另一个原因是JS本身就不建议在条件语句中声明变量
  • Vue3 使用另一种思路解决了这个问题…

代码中还存在两个问题

  • App用了_stateindex,那其他组件用什么
    • 解决办法:给每个组件,创建一个_stateindex
  • 放在全局作用域里重名怎么办
    • 解决办法:放在组件对应 虚拟节点对象上

useState原理图示

  • 上图只画了App组件的更新过程,两个ChildA组件也有同样的过程
  • 每个组件节点都会使用自己独立的_stateindex

useState小结

  • 每个函数组件对应一个React节点*
    • *注意:目前代码对React的实现做了简化:
      • React节点应该是FiberNode
      • _state的真实名称为memorizedState
      • index的实现则用到了链表
  • 每个节点保存着各自的stateindex
  • useState会读取state[index]
  • indexuseState出现的顺序决定
  • setState会修改state,并触发更新

参考



使用 useRefuseContext解决 数据状态分身问题

在使用useState过程中的错误理解:setN(n + 1)会改变n

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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>
  )
}

两种操作,为什么log出了旧数据?

  • 1.点击+1,再点击log:无bug
  • 2.点击log,再点击+1:有bug

因为有多个n,见时序图

多个n

  • setN(n + 1)永不会改变n
  • 每次setN(n + 1)就会产生函数作用域内新的n,和旧的n同时存在与内存中
  • 类似经典问题在for循环中打印5个5

希望有一个贯穿始终的状态

useRef 的一个作用:固定数据状态

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import React from "react";
import ReactDOM from "react-dom";
const rootElement = document.getElementById("root");

function App() {
  const nRef = React.useRef(0); // 类似对象的引用 {current: 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);
  • const nRef = React.useRef(0); // 类似对象的引用 {current: 0}
  • 读取值 nRef.current
    • 类似Vue3ref响应式数据读取值需要.value
  • 有一个bug是:当nRef.current += 1时改变了原来的值,但不会自动重新渲染App,因为不符合 「函数式」

可以手动触发App更新

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import React from "react";
import ReactDOM from "react-dom";
const rootElement = document.getElementById("root");

function App() {
  const nRef = React.useRef(0); // 类似对象的引用 {current: 0}
  const log = () => setTimeout(() => console.log(`n: ${nRef.current}`), 1000);

  // 手动触发`App`更新
  // const [n, setN] = React.useState(null)
  // const setN = React.useState(null)[1]
  const update = React.useState(null)[1]
  return (
    <div className="App">
      <p>{nRef.current} 这里并不能实时更新</p>
      <p>
        <button onClick={
          () => {
            nRef.current += 1
            // setN(nRef.current)
            update(nRef.current)
          }
        }>+1</button>
        <button onClick={log}>log</button>
      </p>
    </div>
  );
}

ReactDOM.render(<App />, rootElement);
  • 一直使用同一个对象nRef解决bug

useContext 的作用

实现切换主题的功能

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
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);
  • 初始化上下文,即全局变量const themeContext = React.createContext(null);
  • themeContext.Provider表示作用域范围,在标签内<themeContext.Provider>...</themeContext.Provider>
    • 只有此作用域里的组件(子孙组件)可以使用提供的数据 value={{ theme, setTheme }}
  • 在子组件中读取const { setTheme } = React.useContext(themeContext);,即子孙组件调用父组件提供的全局方法

总结

  • 每次重新渲染,组件函数就会执行
  • 对应所有state都会出现数据的「分身」
  • 可以使用useRef/useContext,来得到符合预期的数据

Vue 3 对比 React



参考文章

亮点

  1. 分析 useState 原理和源码
  2. useRef 的作用
  3. useContext 的作用

相关文章


  • 作者: Joel
  • 文章链接:
  • 版权声明
  • 非自由转载-非商用-非衍生-保持署名