React Hooks:正确运用Memoization(记忆化)解决性能问题

1,982 阅读6分钟

React Hooks让我们的工作方方面面都变得更好。但是但有时候一些性能问题也是很棘手的。我们可以运用Hooks编写高性能应用,但是前提是你对于下面的这些问题有清醒的认知。

你该不该使用Memoization?

Memoization:在计算机科学中,记忆化(英语:memoization而非memorization)是一种提高程序运行速度的优化技术。通过储存大计算量函数的返回值,当这个结果再次被需要时将其从缓存提取,而不用再次计算来节省计算时间。 记忆化是一种典型的时间存储平衡方案。 --维基百科

React在大多数使用场景中已经足够高性能。如果你的应用足够快并且没有任何渲染问题,那么就没必要往下看了。不要尝试去解决假想的渲染问题,所以在提高性能之前,确认下你是不是熟悉React Profiler

如果你已经明确知道加载慢的问题在哪,Memoization是最好的尝试方法。

React.memo 是提高性能工具,同时也是一个HOC(高阶组件)。它和React.PureComponent很相似,只不过它应用于函数组件而不是class组件。如果函数组件根据给定的相同的props渲染相同的结果,React会记住这些,跳过渲染组件,并且复用最后一次渲染结果。

默认情况下它会对复杂的props对象进行浅比较。如果你想要控制比较过程,也可以提供一个自定义的比较函数作为钩子函数的第二个参数。

无Memoization:

我们来举一个不使用Memoization的例子,然后看为什么这样会造成问题。

function List({ items }) {
    log('renderList');
    return items.map((item, key) => <div key={key}>item: {item.text}</div>);
}
export default function App() {
    log('renderApp');
    function List({ items }) {
        log('renderList');
        return items.map((item, key) => <div key={key}>item: {item.text}</div>);
    }
    export default function App() {
        log('renderApp');
        const [count, setCount] = useState(0);
        const [items, setItems] = useState(getInitialItems(10));
        return (
            <div>
                <h1>{count}</h1>
                <button onClick={() => setCount(count + 1)}>inc</button>
                <List items={items} />
            </div>
        );
    }
    const [count, setCount] = useState(0);
    const [items, setItems] = useState(getInitialItems(10));
    return (
        <div>
            <h1>{count}</h1>
            <button onClick={() => setCount(count + 1)}>inc</button>
            <List items={items} />
        </div>
    );
}

NO1:Live Demo

每次点击increnderApprenderList都会打印,甚至List中没有任何改变。如果这棵数据树足够大,那么它会很容易引发性能问题。我们需要减少渲染次数。

简单Memoization

    const List = React.memo(({ items }) => {
        log('renderList');
        return items.map((item, key) => <div key={key}>item: {item.text}</div>);
    });
    export default function App() {
        log('renderApp');
        const [count, setCount] = useState(0);
        const [items, setItems] = useState(getInitialItems(10));
        return (
            <div>
                <h1>{count}</h1>
                <button onClick={() => setCount(count + 1)}>inc</button>
                <List items={items} />
            </div>
        );
    }

NO2:Live Demo

在这个例子中Memoization做了很多工作,并且减少了很多渲染次数。在加载过程中renderApprenderList都打印了,但是点击inc的时候只有renderApp打印了。

Memoization & callback

我们来做一个小修改,在所有List条目中添加上inc按钮。记住,向组件中传入回调函数来记忆化组件会造成微妙的bug

    function App() {
        log('renderApp');

        const [count, setCount] = useState(0);
        const [items, setItems] = useState(getInitialItems(10));

        return (
            <div>
                <div style={{ display: 'flex' }}>
                    <h1>{count}</h1>
                    <button onClick={() => setCount(count + 1)}>inc</button>
                </div>
                <List items={items} inc={() => setCount(count + 1)} />
            </div>
        );
    }

NO3:Live Demo

在这个例子中,我们的Memoization失败了。因为我们使用了内联匿名函数,每次渲染的时候都会生成新的渲染结果,这样React.mome就失效了。所以在我们记住这个组件之前,我们要想办法记住这个函数。

useCallback

十分幸运的是,React有两个内建钩子可以提供上面的功能:useMemouseCallbackuseMemo对于复杂计算很有用,useCallback对于传递回调函数给需要提高性能的子组件十分有用。

    function App() {
        log('renderApp');

        const [count, setCount] = useState(0);
        const [items, setItems] = useState(getInitialItems(10));

        const inc = useCallback(() => setCount(count + 1));

        return (
            <div>
                <div style={{ display: 'flex' }}>
                    <h1>{count}</h1>
                    <button onClick={inc}>inc</button>
                </div>
                <List items={items} inc={inc} />
            </div>
        );
    }

NO4:Live Demo

在这个例子中,我们的Memoization又失败了。renderList在每次inc被点击的时候都会调用。useCallback的默认行为是函数实例每次挂载的时候都计算新值。因为内联匿名函数每次渲染的时候都会创建新实例,默认设置下useCallback无效。

useCallback with input

const inc = useCallback(() => setCount(count + 1), [count]);

NO5:Live Demo

useCallback接收的第二个参数是一个输入变量数组,当且仅当这些输入变量变化的时候,useCallback会返回新值。在这个例子中,当count变化的时候useCallback会返回新值。每次count在渲染的时候改变,useCallback都会跟着改变。这样也没有实现很好的记忆功能。

useCallback with input of empty array

const inc = useCallback(() => setCount(count + 1), []);

NO6:Live Demo

useCallback也可以接收一个空数组作为第二个参数,这样内联匿名函数就只会调用一次。这次代码实现了记忆功能,点击按钮会打印renderApp,主要按钮的功能也正常,但是内部inc按钮停止正常工作了。

计数器从0增加到1后就停止工作了。匿名函数被创建一次,调用了多次。因为匿名函数被创建的时候count是0,他的表现就像下面这样: const inc = useCallback(() => setCount(1), []); 这个问题的根本原因在于,我们试图同时读写state。幸运的是,react提供了解决上面问题的方法:

useState with functional updates

const inc = useCallback(() => setCount(c => c + 1), []);

NO7:Live Demo

useState返回的setters可以将函数作为参数,这个函数中可以读取上一state的值。在这个例子中,Memoization正确工作,没有bug。

useReducer

const [count, dispatch] = useReducer(c => c + 1, 0);

NO8:Live Demo

useReducer memorizationuseState中的例子一样正确运行了。因为dispatch要保证渲染过程中参考对象一致,就不需要useCallback了,dispatch使得代码更少的犯Memoization相关错误。

useReducer vs useState

useReducer 更适合管理复杂对象或者下一state依赖上一state的情况。使用useReducer的常见模式是和useContext一起使用,避免在大型组件树中显示传递回调。 我比较推荐用useState来管理组件内部数据,useReducer适合管理需要在父子组件中传递的特殊双向数据。

总之,React.memouseReducer是最好的朋友;React.memouseState是时而产生冲突的兄弟,会造成一些问题;谨慎使用useCallback

生活的一部分是工作,工作的一部分是解决问题取悦生活,所以好好生活,好好工作,好好热爱(●ˇ∀ˇ●)

原文地址