第十章 React中的闭包 下

182 阅读8分钟

文章出处:www.advanced-react.com/

专栏地址:juejin.cn/column/7443…

React中的过时闭包:useCallback

如果你还记得 使用 useMemo、useCallback 和 React.memo 进行记忆化(缓存)章节,上面代码看起来会觉得很熟悉。事实上,我们不过是实现useCallback能为我们做的事。每次我们调用useCallback,我们都生成了一个闭包,而我们传递给它的函数则被缓存了:

// that inline function is cached exactly as in the section before
const onClick = useCallback(() => {}, []);

如果useCallback内的函数需要访问状态或者属性,我们则需要把这些状态或者属性传入到依赖数组里:

const Component = () => {
    const [state, setState] = useState();
    
    const onClick = useCallback(() => {
        // access to state inside
        console.log(state);
        
        // need to add this to the dependencies array
    }, [state])
}

而依赖数组的作用,正是让React去更新这个已经缓存的闭包,正如我们这行代码value !== prevValue一样。如果我忘记传入依赖数组,这个闭包就成为过时闭包了:

const Component = () => {
    const [state, setState] = useState();
    
    const onClick = useCallback(() => {
        // state will always be the initial state value here
        // the closure is never refreshed
        console.log(state);
        
        // forgot about dependencies
    }, [])
}

那么,每次我调用回调时,在控制台打印的都是undefined

代码示例: advanced-react.com/examples/10…

React中的过时闭包:Refs

Refs是第二个容易出现过时闭包问题的API。

如果我用Ref来缓存onClick函数,会如何?这是网上一些文章推荐的、用来缓存组件属性的做法。表面上看,这样确实使代码更简单了:把函数传入useRef即可,通ref.current访问即可。而且,不用担心依赖数组的问题。

const Component = () => {
    const ref = useRef(() => {
        // click handler
    });
    
    return <HeavyComponent onClick={ref.current}>
}

然而,我们组件内的每个函数都会形成一个闭包,包括我们传递给 useRef 的函数也是如此。我们的 ref 在被创建时只会初始化一次,而且它自身永远不会更新。这基本上就是我们一开始创建时的逻辑。只不过,我们传递的不是(某个具体的)值,而是我们想要保留的函数。就像下面这样:

const ref = {};

const useRef = (callback) => {
    if (!ref.current) {
        ref.current = callback;
    }
    
    return ref.current;
}

在下面这个例子中,闭包是在组件一开始的时候创建的,当这个组件被挂载时,这个闭包会被保存,而且永远不会更新了。当我们尝试去做个函数内储存的状态和属性时,得到是其初始化时的值:

const Component = ({ someProp }) => {
    const [state, setState] = useState();
    
    const ref = useRef(() => {
        // both of them will be stale and will never change
        console.log(someProp)
        console.log(state)
    });
}

为了修复这个问题,我们需要在函数内的值发生变化时,更新这个ref。本质上,我们需要像为useCallback钩子添加依赖数组一样,通过useEffect更新ref:

const Component = ({ someProp }) => {
    const [state, setState] = useState();
    
    const ref = useRef(() => {
        // both of them will be stale and will never change
        console.log(someProp)
        console.log(state)
    });
    
    useEffect(() => {
        ref.current = () => {
            console.log(someProp);
            console.log(state);
        }
    }, [state, someProp])
}

代码示例: advanced-react.com/examples/10…

React中的过时闭包:React.memo

现在,我们回到最初迷惑我们的代码。再次看看这段产生问题的代码:

const HeavyComponentMemo = React.memo(
    HeavyComponent,
    (before, after) => {
            return before.title === after.title;
        },
    );
    const Form = () => {
        const [value, setValue] = useState();
        const onClick = () => {
            // submit our form data here
            console.log(value);
        };
    return (
        <>
            <input
                type="text"
                value={value}
                onChange={(e) => setValue(e.target.value)}
            />
            <HeavyComponentMemo
                title="Welcome to the form"
                onClick={onClick}
            />
        </>
    );
}

每次我们点击这个按钮,后台打印的都是undefined。在onClick函数内的value从不发生更新,你可以告诉我为什么吗?

这也是一个过时闭包。当我们创造了onClick函数后,一个value为默认值undefined的闭包就生成了。我们把这个闭包和title属性传递了被缓存的HeavyComponentMemo组件。而在比较函数内部,只对比了前后的title属性。而这个title一直没有变化,所以比较函数的返回值永远为true,因此HeavyComponent组件从不更新,所以onClick所指向的,还是那个默认值为undefined的闭包。

那么,我们该如何解决这个问题呢?

理论上,我们需要比较每一个属性的前后值,所以比较函数需要涵盖onClick函数:

(before, after) => {
    return (
        before.title === after.title
        before.onClick === after.onClick
    )
}

然而,在这种情况下,这意味着我们只是在重新实现 React 的默认行为,并且做的事情与不带比较函数的 React.memo所做的完全一样。所以我们可以直接舍弃它,只保留HeavyComponent这种形式就好了。

但是这么做的话,要把onClick包裹在useCallback里。而这个useCallback又依赖value的变化,所以每次键入都会引起更新。而此时,这个HeavyComponent组件也会因此重新渲染,又回到了最初的bug。

我们可以尝试运用组合的方式,试着提取并分离状态或者HeavyComponent。这是我们在前几章中探究过的技巧。但这并不容易:inputHeavyComponent两者都依赖于那个状态。

我们可以尝试很多其他办法。不过,我们并不需要进行大规模的重构来摆脱那个闭包陷阱。这里有一个很棒的窍门能帮到我们。

使用Refs来逃离闭包陷阱

这个技巧令人惊奇:它很简单,却可以永远地改变你在React中缓存函数的方式。也许,它对后面的章节也很重要,所以好好研究一下这个技巧吧。

让我们抛开React.memo的比较函数和如何实现onClick不谈,我们先实现一个有状态和缓存了HeavyComponent的纯组件:

const HeavyComponentMemo = React.memo(HeavyComponent);

const Form = () => {
    const [value, setValue] = useState();

    };
    return (
        <>
            <input
                type="text"
                value={value}
                onChange={(e) => setValue(e.target.value)}
            />
            <HeavyComponentMemo
                title="Welcome to the form"
                onClick={...}
            />
        </>
    );
}

现在,我们需要实现一个函数,这个函数在重新渲染前后指向的是同一个地址,而它可以在不重新创建的前提下访问最新的状态。

我们需要把这个函数存在Ref里:

const Form = () => {
    const [value, setValue] = useState();
    
    // adding an empty ref
    const ref = useRef();
}

为了使这个函数可以访问最新的状态,提需要在每次重新渲染时,被重新创建。我们无法逃离它,这是闭包的本质,React也无法改变它。我们可以在useEffect中更新Refs,而不是在渲染中更新:

const Form = () => {
    const [value, setValue] = useState();
    
    // adding an empty ref
    const ref = useRef();
    
    useEffect(() => {
        ref.current = () => {
            // our callback that we want to trigger
            // with state
            console.log(value);
        }
        
    // no dependencies array!
    })
}

没有依赖数组的useEffect会在每次重新渲染时被触发。而这,正是我们想要的。如此一来,ref.current的闭包会在每次重新渲染时重新生成,这样就能拿到最新的状态了。

但我们现在还不能把ref.current直接丢给HeavyComponentMemo,因每次重新渲染ref.current的引用是不一样的,缓存也会因此失效。

const Form = () => {
    const [value, setValue] = useState();
    
    // adding an empty ref
    const ref = useRef();
    
    useEffect(() => {
        ref.current = () => {
            // our callback that we want to trigger
            // with state
            console.log(value);
        }
        
    // no dependencies array!
    })
    
    return (
        <>
            {/* Can't do that, will break memoization*/}
            <HeavyComponentMemo onClick={ref.current} />
        </>
    )
}

那么,我们可以生成一个包裹在useCallback里的函数:

const Form = () => {
    const [value, setValue] = useState();
    
    // adding an empty ref
    const ref = useRef();
    
    useEffect(() => {
        ref.current = () => {
            // our callback that we want to trigger
            // with state
            console.log(value);
        }
        
    // no dependencies array!
    })
    
    const onClick = useCallback(() => {
        // empty dependency ! will never change
    }, [])
    
    return (
        <>
            {/* Can't do that, will break memoization*/}
            <HeavyComponentMemo onClick={onClick} />
        </>
    )
}

现在,onClick所指向的地址不会变化了。但是,这个函数啥用没有!

而接下来,正是使用魔法的时候:在这个被缓存的函数中,调用ref.current:

 useEffect(() => {
    ref.current = () => {
        console.log(value);
    };
});

const onClick = useCallback(() => {
    // call the ref here
    ref.current();

    // still empty dependencies array!
}, []);

但是当一个闭包将其周围的所有东西都 “冻结” 时,它并不会使对象变成不可变的或者被冻结的状态。对象是存储在内存的不同部分的,而且多个变量可以包含对同一个对象的引用。

const a = { value: 'one' };
// b is a different variable that references the same object
const b = a;

如果我通过其中一个引用去改变这个对象,然后再通过另一个引用去访问它,那么所做的更改将会体现出来,具体如下:

a.value = "two";

console.log(b.value); // will be "two"

在我们所讨论的这种情况中,甚至都不会出现上述情况:在 useCallback 内部和 useEffect 内部,我们拥有的是完全相同的引用。所以当我们在 useEffect 中改变 ref 对象的 current 属性时,我们能够在 useCallback 中访问到这个确切的属性。而这个属性恰好是一个捕获了最新状态数据的闭包。

完整的代码是这样的:

const Form = () => {
    const [value, setValue] = useState();
    const ref = useRef();

    useEffect(() => {
        ref.current = () => {
            // will be latest
            console.log(value);
        };
    }, []);

    const onClick = useCallback(() => {
        // will be latest
        ref.current?.();
    }, []);

    return (
        <>
            <input
                type="text"
                value={value}
                onChange={(e) => setValue(e.target.value)}
            />
            <HeavyComponentMemo
                title="Welcome closures"
                onClick={onClick}
            />
        </>
    );
};

如此一来,我们达到了两个目标:HeavyComponet组件被合适地缓存了,不会因为状态变化而频繁地重新渲染:onClick函数也可以在不破坏缓存的情况下访问最新的数据。现在,我们可以安全地传输数据去后端了!

代码示例: advanced-react.com/examples/10…

知识概要

希望这篇文章对你有帮助。在下一章,我们将探讨另一个逃离闭包陷阱的方法:截流和防抖函数。在此之前,希望你记住:

  • 每当在一个函数内生成另一个函数时,就生成了一个闭包。
  • 自React组件是函数组件后,组件里的每一个函数都是一个闭包,包括useCallbackuseRef这些钩子。
  • 当一个会生成闭包的函数被调用了,闭包内的数据就被“冻结了”,类似于一个快照。
  • 为了更新闭包内的数据,我们需要重新调用这个能生成闭包的函数。而这正是useCallback的依赖数组做的。
  • 如果我们不为钩子添加依赖数组,或者不更新指向ref.current的函数,这个闭包就“过时”了。
  • 我们可以利用Ref的可变性来避免进入“过时闭包”陷阱。我们可以在过时闭包外改变ref.current的指向,并访问它。