useState和useEffect的原理

811 阅读6分钟

一、组件更新过程

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);

过程:

  • 首次渲染,render(<App />)
  • render会调用App函数,得到虚拟DIV,创建真实DIV
  • 用户点击Button,调用setN(n+1),render函数被再一次调用
  • render进一步调用App函数,得到虚拟DIV,Diff,更新真实DIV
  • 每一次setN都会再次调用render,进而调用App

问题就来了每次setN触发的App函数,都会运行useState(0),每次运行区别是什么?

二、简单实现useState

  • setN会触发re-render
  • useState会读取新的数据值,不然的话每次重新执行App都会被初始化,每一次读的都是同一个变量很容易联想到闭包

function render() {
  ReactDOM.render(<App />, root);
}
function myUseState(initialValue) {
  let state = initialValue;
  function setState(newValue) {
    state = newValue;
    render();
  }
  return [state,setState]
}

闭包隐藏并保护一个变量,每次调用setN改变的是同一个变量

这时我们发现,点击 Button 的时候,count 并不会变化,为什么呢?我们没有存储 state,每次渲染 Counter 组件的时候,state 都是新重置的。

自然我们就能想到,把 state 提取出来,存在 useState 外面。

var _state; // 把 state 存储在外面

function useState(initialValue) {
  _state = _state === undefined ? initialValue : _state; 
                // 如果没有 _state,说明是第一次执行,把 initialValue 复制给它
  function setState(newState) {
    _state = newState;
    render();
  }
  return [_state, setState];
}

核心就在于setN改变的变量是函数外部的所以函数会记住上次的值

有一个很大的问题:它只能使用一次,因为只有一个 _state,数组完美解决

let _state = [];
let index = 0;

function useState(initialValue) {
  const currentIndex = index;
  _state[currentIndex] = _state[currentIndex] === undefined ? initialValue : _state;
  function setState(newState) {
    _state[currentIndex] = newState;
    render();
  }
  index += 1;
  return [_state[currentIndex], setState];
}

有一个很大的问题:每次render()重新执行App函数都会执行useState,然后产生新下标。

let _state = [];
let index = 0;

function useState(initialValue) {
  const currentIndex = index;
  _state[currentIndex] = _state[currentIndex] === undefined ? initialValue : _state;
  function setState(newState) {
    _state[currentIndex] = newState;
    index = 0   //重置
    render();
  }
  index += 1;
  return [_state[currentIndex], setState];
}

React的数据更新依赖的还是useState这个函数返回的第一个值,setN只是修改第一个参数而已。

每次调用useState,index就会被固定数值成为currentIndex,之后setN只会修改这个n,因为下标不会变化,setN改变的数据是固定的(闭包保护了setN不会篡改currentIndex)。

根据上面代码发现useState的顺序是在太重要了,每次re-render的useState的顺序不能改变,所以不能出现在if语句中。每个组件都有自己的_state和index

三、更新原理

React会维护一个虚拟DOM树(始终存在),以及在页面存在的真实DOM树,

App() => useState(0) ===> App1(虚拟Dom)

   ||

setN(n+1)                                    Diff算法(把差别做成对象patch,开始更新虚拟DOM树)

re-render()  

 ||

App() => useState(0) ===> App2(虚拟Dom)

  • 每个函数组件对应一个React节点
  • 每个节点保存着state和index 
  • useState会读取state[index] -把state存储在useState函数外
  • index由useState出现的顺序决定 
  • setState会修改state,并触发更新-index存储在setState函数外部 

四、始终如一的数据

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

  function setState(newState) {
    _state[currentIndex] = newState;
    index = 0   //重置
    render();
  }

先点击log再点击setN,打印出的是旧数据

let n;
for (n = 0; n++; n < 6) {
  setTimeout(() => {
    console.log(n);
  },1000);
}

会打出6个6

for (let n = 0; n++; n < 6) {
  setTimeout(() => {
    console.log(n);
  },1000);
}

会打出123546,

因此我们推断每次re-render的state并不是同一个,而是新作用域创建的新变量

  • 使用window.xxx 全局的变量来保证你的数据是始终如一的
  • useRef
const refContainer = useRef(initialValue);

useRef 返回一个可变的 ref 对象,其 .current 属性被初始化为传入的参数(initialValue)。返回的 ref 对象在组件的整个生命周期内保持不变。

我们经常使用 ref 方式来访问 DOM 。如果你将 ref 对象以 <div ref={myRef} /> 形式传入组件,则无论该节点如何改变,React 都会将 ref 对象的 .current 属性设置为相应的 DOM 节点。

useRef() 比 ref 属性更有用。它可以很方便地保存任何可变值,其类似于在 class 中使用实例字段的方式。

ref 对象内容发生变化时,useRef 并不会通知你。变更 .current 属性不会引发组件重新渲染。

  • useContext

如果说ref只是一个组件始终如一,你们context是所有组件始终如一,也就是全局变量

const themes = {
  light: {
    foreground: "#000000",
    background: "#eeeeee"
  },
  dark: {
    foreground: "#ffffff",
    background: "#222222"
  }
};

const ThemeContext = React.createContext(themes.light);

function App() {
  return (
    <ThemeContext.Provider value={themes.dark}>
      <Toolbar />
    </ThemeContext.Provider>
  );
}

表示这个变量的作用范围

使用useContext来读取

五、useEffect

let memoizedState = []; // hooks 存放在这个数组
let cursor = 0; // 当前 memoizedState 下标

function useState(initialValue) {
  memoizedState[cursor] = memoizedState[cursor] || initialValue;
  const currentCursor = cursor;
  function setState(newState) {
    memoizedState[currentCursor] = newState; 
    cursor = 0;
    render();
  }
  return [memoizedState[cursor++], setState]; // 返回当前 state,并把 cursor 加 1
}

function useEffect(callback, depArray) {
  const hasNoDeps = !depArray;
  const deps = memoizedState[cursor];
  const hasChangedDeps = deps
    ? !depArray.every((el, i) => el === deps[i])
    : true;
  if (hasNoDeps || hasChangedDeps) {
    callback();
    memoizedState[cursor] = depArray;
  }
  cursor++;
}

代码关键在于:

  1. 初次渲染的时候,按照 useState,useEffect 的顺序,把 state,deps 等按顺序塞到 memoizedState 数组中。
  2. 更新的时候,按照顺序,从 memoizedState 中把上次记录的值拿出来。
  3. useState,useEffect 和使用的不是同一个数据
  4. 核心就在于每次更新把cursor赋值为零,然后更新时按照hooks顺序,依次从 memoizedState 中把上次记录的值拿出来,useEffect接受useState(返回新值)和旧值进行比较

六、总结

每次重新渲染,组件函数就会执行 

对应的所有state都会出现 分身,(新作用域)

如果没有类似于setimeout的引用就会被垃圾回收掉

为什么会出现分身,因为每次重新执行App函数都会重新const一个新的state数组(过时的闭包)

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

上面代码就是过时的闭包,log函数和const [n, setN]组成了闭包,但是log函数里的n只是旧的n,点击+1之后已经是n的"分身"了,由于log还在引用旧n,所以暂时没有被垃圾回收掉。

这个是React的设计缺陷,不是为了对比两个虚拟DOM