首先,本文并不会讲解hooks的基本用法,本文从一个hooks中"奇怪"(其实符合逻辑)的"闭包陷阱"的场景切入,试图讲清楚背后的因果。同时,在许多react hooks奇技淫巧的的文章里,也能看到useRef的身影,那么为什么使用useRef又能摆脱这个"闭包陷阱"?我想搞清楚这些问题,将能较大的提升对react hooks的理解。

react hooks一出现便受到许多开发人员的追捧,或许在使用react hooks的时候遇到"闭包陷阱"是每个开发人员在开发的时候都遇到过的事情,有的两眼懵逼、有的稳如稳如老狗瞬间就定义到问题出现在何处。

以下react示范demo,均为react 16.8.3版本 你一定遭遇过以下这个场景

function App() {
    const [count, setCount] = useState(1);
    useEffect(() => {
        setInterval(() => {
            console.log(count);
        }, 1000)
    }, [])
}

在这个定时器里面去打印count的值,会发现,不管在这个组件中的其他地方使用setCount将count设置为任何值,还是设置多少次,打印的都是1.是不是有一种,尽管历经千帆,我记得还是你当初的模样的感觉?哈哈哈哈....接下来我将尽力的尝试将我立即的,为什么会发生这个情况说清楚,并且浅谈一些hooks的其他特性。

# 一个熟悉的闭包场景

首先从一个各位jser都很熟悉的场景入手

for(var i = 0; i < 5; i++) {
    setTimeout(() => {
        console.log(i);
    }, 0)
}

我就不说为什么最终,打印的都是5的原因了,直接贴出使用闭包打印0-4的代码

for(var i = 0; i < 5; i++) {
    (function(i) {
        setTimeout(() => {
            console.log(i);
        })
    })(i)
}

这个原理其实就是使用闭包,定时器的回调函数去引用立即执行函数里定义的变量,形成闭包保存了立即执行函数执行时 i 的值,异步定时器的回调函数才如我们想要的打印了顺序的值。

其实,useEffect的那个场景的原因,跟这个,简直是一样的,useEffect 闭包陷阱场景的出现,是react组件更新流程以及 useEffect 的实现的自然而然的结果

# 浅谈hooks原理,理解useEffect的"闭包陷阱"出现原因

首先,可能都听过react的Fiber架构,其实一个Fiber节点就对应一的是一个组件。对于classComponent而言,有state是一件正常的事情,Fiber对象上有一个memoizedState用于存放组件的state。ok,现在看hooks所针对的FunctionComponent。无论开发者怎么折腾,一个对象都只能有一个state属性或者memoizedState属性,可是,谁知道可爱的开发者们会在FunctionComponent里写上多少个useState,useEffect等等?所以,react用来链表这种数据结构来存储FunctionComponent里面的hooks,比如

function App() {
    const [count, setCount] = useState(1);
    const [name, setName] = useState('chechengyi');
    useEffect(() => {

    }, [])

    const text = useMemo(() => {
        return 'ddd'
    }, [])
}

在组件第一次渲染的时候,为每个hooks都创建了一个对象

type Hook = {
    memoizedState: any,
    baseState: any,
    baseUpdate: Update<any, any> | null,
    queue: UpdateQueue<any, any> | null,
    next: Hook | null
}

最终形成一个链表

hooks链表

这个对象的memoizedState属性就是用来存储组件上一次更新后的state, next毫无疑问是指向下一个hooks。在组件更新的过程中,hooks函数执行的顺序是不变的,就可以根据这个链表拿到当前hooks对应的Hooks对象,函数式组件就是这样拥有了state的能力。当然,具体的实现肯定比这三言两语复杂的多。

所以,知道为什么不能将hooks写到if else 语句中了吧,因为这样可能会导致顺序错乱,导致当前的hooks拿到的不是自己对应的Hooks对象

useEffect接收了两个参数,一个回调函数和一个数组。数组里面就是useEffect的依赖,当为[]的时候,回调函数只会在组件第一次渲染的时候执行一次。如果有依赖其他项,react会判断其依赖是否改变,如果改变了就会执行回调函数,说回最初的场景

function App() {
    const [count, setCount] = useState(1);
    useEffect(() => {
        setInterval(() => {
            console.log(count);
        }, 1000)
    }, [])
    function click() {setCount(2)}
}

好,开动脑筋开始想象起来,组件第一次渲染执行App(),执行useState设置了初始值状态为1,所以此时的count为1。然后执行了useEffect,回调函数执行,设置了一个定时器每隔1s打印一次count.

接着想象如果click函数被触发了,调用setCount(2)肯定会触发react的更新,更新到当前组件的时候也是执行App(),之前的链表已经形成了哈,此时useState将Hook对象上保存的状态置为2,那么此时count也为2了。然后在执行useEffect由于依赖数组是一个空的数组,所以此时回调并不会被执行。

ok,这次更新的过程中根本就没有涉及到这个定时器,这个定时器还在坚持的,默默地,每隔1s打印一次count.注意这里打印的count,是组件第一次渲染时候App()时的count,count的值为1,因为在定时器的回调里面被引用了,形式了闭包一直被保存

# 难道真的要在依赖数组里写上的值,才能拿到新鲜的值?

仿佛都习惯性都去任务,只有在依赖数组里写上我们所需要的值,才能在更新的过程中拿到最新鲜的值。那么看一下这个场景

function App() {
    return <Demo1/>
}
function Demo1() {
    const [num1, setNum1] = useState(1);
    const [num2, setNum2] = useState(10);

    const text = useMemo(() => {
        return `num1: ${num1} | num2: ${num2}`
    }, [num2])

    function handClick() {
        setNum1(2)
        setNum2(20)
    }
    return (
        <div>
            {text}
            <div><button onClick={handClick}>click!</button></div>
        </div>
    )
}

text是一个useMemo,它的依赖数组里面只有num2,没有num1,却同时使用了这两个state.当点击button的时,num1和num2的值都改变了。那么,只写明了依赖num2的text中能否拿到num1最新鲜的值呢?

如果你装了 react 的 eslint 插件,这里也许会提示你错误,因为在text中你使用了 num1 却没有在依赖数组中添加它。 但是执行这段代码会发现,是可以正常拿到num1最新鲜的值的。

如果理解了之前第一点说的“闭包陷阱”问题,肯定也能理解这个问题。

为什么呢,在说一遍,这个依赖数组存在的意义,是react为了判定,在本次更新中,是否需要执行其中的回调函数,这里依赖了num2,而num2改变了,回调函数自然会执行,这时形式的闭包引用的就是最新的num1和num2,所以,自然能够拿到新鲜的值。问题的关键,在于回调函数执行的时机,闭包就像是一个照相机,把回调函数执行的那个时机的值保存了下来。之前说的定时器的回调函数我想就像是从一个1000年穿越到现代的人,虽然来了现代,但是身上的血液,头发都是1000年前的

# 3.为什么使用useRef能够每次拿到新鲜的值

大白话说:因为初始化的useRef执行之后,返回的都是同一个对象。

var A = {name: 'chechengyi'};
var B = A;
B.name = 'baobao';
console.log(A.name); //baobao

对,这就是这个场景成立的最根本原因

也就是说,在组件每一次渲染的过程中。比如ref = useRef()所返回的都是同一个对象,每次组件更新所生成的ref指向的都是同一片内存空间,那么当然能够每次都拿到最新鲜的值了。犬夜叉看过吧,一口古井连接了现代世界与500年前的战国时代,这个同一个对象也将这些个保存于不同闭包时机的变量联系了起来

使用一个例子或许好理解一点

// 将这些相关的变量写在函数外,以模拟react hooks对应的对象
let isC = false;
let isInit = true; // 模拟组件第一次加载
let ref = {
    current: null;
}

function useEffect(cb) {
    // 这里用来模拟useEffect依赖为[]的时候只执行一次
    if(isC) return;
    isC = true;
    cb();
}
function useRef(value) {
    // 组件是第一次加载的话设置值,否则返回对象
    if(isInit) {
        ref.current = value;
        isInit = false;
    }
    return Ref;
}
function App() {
    let ref_ = useRef(1);
    ref_.current++;
    useEffect(() => {
        setInterval(() => {
            console.log(ref.current); // 3 两秒输出3
        }, 2000)
    })
}
// 连续执行两次 第一次组件加载,第二次组件更新
App();
App();

所以,提出一个合理的设想。只要我们能保证每次组件更新的时候,useState返回的是同一个对象的话,?我们也可能绕开闭包陷阱这个情景吗?试一下吧

function App() {
    return <Demo2 />
}
function Demo2() {
    const [obj, setObje] = useState({name: 'chechengyi'});

    useEffect(() => {
        setInterval(() => {
            console.log(obj)
        }, 2000)
    }, [])

    function handClick() {
        setObj((prevState) => {
            let nowObj = Object.assign(prevState, {
                name: 'baobao',
                age: 24
            }) // Object.assign 如果变量在第二个参数  得到的结果是对第二个参数对象的浅拷贝 如果是第一个参数是引用了...
            console.log(nowObj == preState); // true
            return nowObj
        })

        // setObj((prevState) => {
        //     let nowObj = Object.assign({
        //         name: 'baobao',
        //         age: 24
        //     }, prevState)
        //     console.log(nowObj === prevState); // true
        //     return nowObj
        // }) // setInterval 还是原来的值 {name: 'chechengyi'}
    }
    return (
        <div>
            <div>
                <span>name: {obj.name} | age: {obj.age}</span>
                <div><button onClick={handClick}>click!</button></div>
            </div>
        </div>
    )
}

简单说下这段代码,在执行 setObj 的时候,传入的是一个函数。这种用法就不用我多说了把?然后 Object.assign 返回的就是传入的第一个对象。总儿言之,就是在设置的时候返回了同一个对象。

执行这段代码发现,确实点击button后,定时器打印的值也变成了:

{
    name: 'baobao',
    age: 24
}

这个demo页面未更新,如果是对象进行浅比较,直接传入并不会更新

参考

Object.assign

# 完毕

通过一次“闭包陷阱” 浅谈 react hooks 全文再此就结束了。 反正写完了这篇文章,宝宝我对 hooks 的认识是比以前深了。

# demos

const [changeState, setChangeState] = useState(0)
const fn = useCallback(function(){
    setInterval(function(){
        let i = changeState
        // console.log(++i, 'i')
        setChangeState(i++) 
        console.log(i) // 1   1   1  
    }, 1000)
}, [changeState])
useEffect(()=>{ fn() }, [])

// demo2

function Memo1() {
  const [changeState, setChangeState] = React.useState(0)
  const fn = React.useCallback(function(){
    let i = changeState
    setChangeState(++i) // 1 
    console.log(changeState) // 渲染两次
     
      // setInterval(function(){
      //     let i = changeState
      //     setChangeState(i++) // i++ 显示0, ++i显示1
      //     console.log(i, changeState) // i++ ++i 都显示1, 0
      // }, 1000)
  }, [changeState])
  React.useEffect(()=>fn(), [])
  console.log('xxx')
  return (<div>world!!!{changeState}</div>)
}

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<div><h1>Hello,</h1><Memo1></Memo1></div>);



// demo3
function HelloWorld(){
    const [txt, settxt] = React.useState(1);
    //函数
    const handle = () => {
    settxt(txt=>txt+1);
    setTimeout(()=>{
        console.log(txt);  //1
        settxt(txt =>{
            console.log(txt); //2
            return txt;
        })
    },4000)
    };
    return(<div>
        <button style={{width:"100px",height:'100px',color:"#f00"}} onClick={handle}>点击我</button>
        <div>{txt}</div>
    </div>)
}
ReactDOM.render(<HelloWorld/>,document.getElementById('root'));
//  主要原因:第一个console.log();在函数setTimeOut里面被闭包了,
// 第二个console.log();通过setState重新获取了state的最新值不会,打印出来2。


function() {
    var i = 0;
    return function() {

    }
}

demo (opens new window)

# useState实现

let state = [];
let setters = [];
let index = 0;

function createState(index) {
    return function(newState) {
        state[index] = newState;
        render();
    }
}
function useState(initData) {
    state[index] = state[index] ? state[index] : initData;
    setters.push(createState(index));
    let value = state[index];
    let setter = setters[index];
    index++;
    return [value, setter];
}
function render() { // setcount执行后 重新挂载重新执行hooks 然后获取count
    index = 0;
    ReactDOM.render('', document.getElementById('root'));
}
export default function App() {
    const [count, setCount] = useState(0);
    const [name, setName] = useState('张三')return (
        <div>
            {count}
            <button onClick={() => setCount(count + 1)}>setCount</button>
            {name}
            <button onClick={() => setName(name)}>setName</button>
        </div>
    )
}

# 资料

原文 (opens new window)