【转载|翻译】如何找出并解决React中的重复渲染

转载并翻译的FreeCodeCamp中Nayeem Reza的一篇文章:How to identify and resolve wasted renders in React
原文链接:https://www.freecodecamp.org/news/how-to-identify-and-resolve-wasted-renders-in-react-cc4b1e910d10/

最近,我在想我正在开发的react应用的性能分析的问题,突然想到设置一些性能指标。我发现我要解决的第一件事就是每个网页中的渲染浪费问题。你可能在想,什么是渲染浪费?我们来深入一下。

最开始,React就改变了网页开发的哲学,也改变了前端开发者的思考方式。引入VirtualDOM后,React使得UI的更新尽可能的高效,网页应用的体验也变得整洁。你可曾想过怎样能让你的React应用更快?为什么中型的React web应用还是表现不佳?问题就在于我们如何使用React.

React是怎样工作的

像React这样的现代前端库并不能让我们的应用更快。首先,开发者应该理解React是怎样工作的。组件在应用的生命周期内如何贯穿组件的生命周期?所以,在深入优化技巧之前,我们需要对React如何工作有一个更好的理解。

在React核心中,我们有JSX语法和构建、比较VirtualDOM的强大能力。它们发布以后,React影响了其他许多前端库。比如,Vue也依赖于VirtualDOM。

每一个React应用从一个根组件开始。我们可以将整个应用看作一个树型结构,每个节点就是一个组件。React中的组件基于数据渲染UI。那就意味着它接收props和state

UI = CF(data) // Component Function

用户通过UI交互,改变data。用户在我们的应用中能做的一切就是交互。比如:点击按钮、滑动图片、拖拽列表项、调用API的AJAX请求。所有这些交互只是改变了数据,UI从来不会变。

这里,定义应用state的就只有data.不仅仅是我们存在数据库中的东西,甚至是不同的前端状态,比如当前选中的选项卡,或者当给钱啊是否选中复选框,都是data的一部分。每当data改变时,React通过组件函数重新渲染UI,but only virtually:

UI1 = CF(data1) UI2 = CF(data2)

React通过DOMdiff算法比较当前UI和新UI(re-render之后的UI)的VirtualDOM之间的不同.

Changes = Difference(UI1, UI2)

然后React只将UI变化的部分应用到浏览器的真实UI上,当与组件关联的数据修改时,React确定真实DOM是否需要更新。这避免了React在浏览器中许多'expensive'DOM操作,例如:创建DOM节点,对已有节点不必要地访问。

组件的重复区分和渲染可能是任何React应用中性能问题的主要来源之一。在DOM diff算法无法有效协调的情况下构建React应用,就会导致整个应用重复渲染,也就是渲染的浪费,进而得到一个缓慢的体验感。

在初始渲染过程中,React造的DOM像这样

假设一部分数据改变了,我们期望的是重渲染直接依赖于这部分数据的组件,甚至直接跳过其他组件的diff过程。假设上图中组件2的部分数据改变,数据从R传到B,再传到2.如果R重新渲染,接着它的每一个子组件A,B,C,D都会重新渲染。这个过程中,React真正做的是(注意与上图比较)

上图中,所有的黄色节点都被重新渲染并diff,这就导致时间和计算资源的浪费。这就是我们主要优化的---将每个组件配置为在必要时再渲染和diff。这就回收了浪费的CPU周期。首先,我们先研究怎样识别出我们的应用渲染浪费了。

识别渲染浪费

有一些不同的方法来识别。最简单的就是React dev tools中的Highlight Updates选项。

当与app交互时,就会有颜色的边框闪烁提示。这就能看出重复渲染的组件了。这可以让我们发现不需要的重渲染。

跟随下面的例子。

注意,当我们输入第二个todo项时,每次按键,第一个todo还在闪烁。这意味着它连同输入被React重新渲染。这就是所谓的“渲染浪费”。我们知道这是不必要的,因为第一个todo的内容并没有改变,但是React可不知道。

即使React只更新了改变的DOM节点,重渲染仍花了一些时间。在许多情况下,这并不是问题,但是当交互变得缓慢时,我们就要考虑以下阻止这些冗余渲染了。

使用shouldComponentUpdate方法

默认情况下,React会渲染虚拟DOM并在树中每个组件props和state变化时比较它们的不同。这显然是不合理的:随着应用的增长,每次变化都试图重新渲染并比较整个虚拟DOM最终会拖慢应用。

React提供了一个简单的生命周期方法shouldComponentUpdate来指示一个组件是否需要重新渲染,它在重新渲染开始前触发,默认返回为 true.

function shouldComponentUpdate(nextProps, nextState) {
    return true
}

当任意组件的shouldComponentUpdate函数返回true时,它允许触发diff渲染过程,这就给了我们控制重新渲染的能力。假设我们要阻止组件被重新渲染,我们只需在函数中返回false即可。正如我们看到的,我们可以比较当前和下一个props和state来决定是否重新渲染。

function shouldComponentUpdate(nextProps, nextState) {
    return nextProps.id !== this.props.id
}

使用纯组件pure component

你使用React,你一定知道React.Component是什么,但是React.PureComponent是个啥玩意儿?上面我们讨论了shouldComponentUpdate函数,在纯组件中,有一个默认的shouldComponentUpdate实现,进行浅层的prop和state比较。所以,纯组件就是只会在props和state与之前不一样时才重新渲染的组件。

function shouldComponentUpdate(nextProps, nextState) {
    return shallowCompare(this.props, nextProps) && shallowCompare(this.state, nextState)
}

在浅层比较中,基本数据类型像字符串,布尔,数字,通过值进行比较;复杂数据类型,比如数组、对象、函数通过引用比较(地址)

但是,如果我们有一个函数无状态组件在每次冲渲染之前要实现比较怎么办?React有一个高阶组件React.memo.它和React.PureComponent类似,只不过是用于函数组件。

const functionalComponent = (props) => {
    /* render using props */
}

export default React.memo(functionalComponent)

默认情况下,它做的事情和shouldComponentUpdate()一样,只是浅层的比较props对象,但是如果我们想要控制整个比较过程呢?可以自定义比较函数作为第二个参数。

const functionalComponent = (props) => {
    /* render using props */
}

const areEqual = (prevProps, nextPoprs) => {
    /*
        传入nextProps得到的渲染结果和prevProps一样时返回true,否则返回false
    */
}

export default React.memo(functionalComponent, areEqual)

使数据不可变

如果我们可以使用React.PureComponent,但仍然有一个有效的方式来判断任何复杂的props或state(如数组、对象等)何时自动改变。这就是不可变数据结构使生命周期更简单。

使用不可变数据结构的背后思想很简单。正如我们之前提到的,对于复杂的数据类型,比较在它们的引用上执行。当一个包含复杂数据的对象改变时,不是改变原有对象,而是复制原有对象的内容到新建的对象,对新建对象做出改变。

ES6的对象结构运算可以实现这一操作。

const profile = { name: 'Lebrown James', age: 35}

const updateProfile = (profile) => {
    return { ...profile, gender: 'male' }
}

也可以用数组

this.state = { languages: ['French', 'English' ]}

this.setState(state => ({
    words: [ ...state.languages, 'Spanish' ]
}))

对于同一个旧数据,避免传入新的引用

我们知道每当组件的props改变就会初发重新渲染。有时props没有改变,但我们用了一种React认为确实改变了props的方式编写代码,就会造成渲染浪费。所以,我们要确定传递不同的引用作为不同数据的props.同样,对于同样的数据也要避免传递新的引用。现在,我们来看一下这种问题的例子:

class BookInfo extends Component {
    render() {
        const book = {
            name: '1984',
            author: 'George Orwell',
            publishedAt: 'June 8, 1949',
        }

        return (
            <div>
                <BookDescription book={book} />
                <BookReview reviews={this.props.reviews} />
            </div>
        )
    }
}

我们在BookInfo组件中渲染了BookDescription和BookReview两个组件。代码是正确的,工作正常,但是有一个问题:每当我们获取一个新的reviews数据作为props时,BookDescription就会重新渲染。为什么会这样?BookInfo组件一接收到新的props,render函数就被调用来创建其元素树。render函数创建了一个新的book常量,也就是创建了一个新的引用。所以,BookDescription将会得到这个book常量作为引用,BookDescription也就会重新渲染。我们可以将这段代码分解为:

class BookInfo extends Component {
    
    const book = {
        name: '1984',
        author: 'George Orwell',
        publishedAt: 'June 8, 1949',
    }
    
    render() {
        return (
            <div>
                <BookDescription book={this.book} />
                <BookReview reviews={this.props.reviews} />
            </div>
        )
    }
}

现在,引用就总是一样的了-- this.book并且渲染时不会创建新的对象了。重新渲染的概念适用于每一个prop包括事件处理,比如:

class Foo extends Component {
    handleClickOne() {
        console.log('Click happened in One')
    }

    handleClickTwo() {
        console.log('Click happened in Two')
    }

    render() {
        return (
            <Fragment>
                <button onClick={this.handleClickOne.bind(this)}>
                    Click me One!
                </button>
                <button onClick={() => this.handleClickTwo()}>
                    Click me Two!
                </button>
            </Fragment>
        )
    }
}

这里,我们用了两种不同的方式(在render中使用bind方法和箭头函数)来调用事件处理方法但是每次在组件重新渲染时两种方法都会创建一个新的函数。所以,为了解决这个问题,我们可以在constructor中进行方法绑定

class Foo extends Component {
    constructor() {
        this.handleClickOne = this.handleClickOne.bind(this)
    }
    
    handleClickOne() {
        console.log('Click happened in One')
    }

    handleClickTwo() => {
        console.log('Click happened in Two')
    }

    render() {
        return (
            <Fragment>
                <button onClick={this.handleClickOne}>
                    Click me One!
                </button>
                <button onClick={this.handleClickTwo}>
                    Click me Two!
                </button>
            </Fragment>
        )
    }
}

总结

在内部,React使用了一些聪明的技术来最小化更新UI所需的代价高昂的DOM操作的数量。对于许多应用,使用React都能得到一个快速的用户界面,并且不用做太多的工作来优化性能。尽管如此,如果能遵循前面提到的技术来解决渲染的浪费,对于大型应用,也能在性能方面有非常流畅的体验。

原文地址:https://www.cnblogs.com/wydumn/p/12149216.html