在上一章,我们探索了状态变化是如何自顶向下地触发应用的重新渲染,以及如何使用“状态下移”技巧来处理这个问题。然而,上一章的例子还是有点太简单了,而且状态也太孤立了。这样的话,把状态下移是比较简单的。那么,当情况变复杂时,我们该怎么办?
本章将继续研究重新渲染的工作原理,进行另一个性能调查。在这一章,你讲学到:
- 把组件当作属性传递将如何提升应用的性能。
- React是如何触发重新渲染的。
- 为什么组件作为属性传递不会被重新渲染函数影响。
- 什么是元素,它在哪里与组件不同,为什么要明晰这两者的区别。
- React 协调器与差异比对的基础知识。
- 什么是“子属性作为属性”模式,以及它如何防止重新渲染。
问题
想象一下,如果你接手了一个大型、复杂且性能敏感的项目。这个应用有一个可以滚动的区域。这个应用有精美的布局,带有一个固定的头部(导航栏之类的,始终固定在顶部),左侧有一个可折叠的侧边栏,而其余的功能则放在中间部分。
实现可滚动功能的代码如下
const App = () => {
return (
<div className="scrollable-block">
<VerySlowComponent />
<BunchOfStuff />
<OtherStuffAlsoComplicated />
</div>
);
};
仅仅是一个带有类名的div,并且其下方设置了CSS的overflow: auto属性。在这个div内部有许多运行速度很慢的组件。在你入职的第一天,就被要求实现一个很有创意的功能:当用户向下滚动一会儿后,会在该区域底部出现一个块,并且随着用户继续向下滚动,它会慢慢移动到顶部;或者如果用户向上滚动,它会慢慢向下移动并消失。有点像是一个带有一些有用链接的二级导航块。当然,滚动以及与之相关的所有操作都应该流畅且无卡顿。
实现这个需求最简单的方法就是在这个滑动块上添加一个用于捕捉滑动值并计算滑动块的onScroll函数:
const MainScrollableArea = () => {
const [position, setPosition] = useState(300);
const onScroll = (e) => {
// calculate position based on the scrolled value
const calculated = getPosition(e.target.scrollTop);
// save it to state
setPosition(calculated);
};
return (
<div className="scrollable-block" onScroll={onScroll}>
{/* pass position value to the new movable component */}
<MovingBlock position={position} />
<VerySlowComponent />
<BunchOfStuff />
<OtherStuffAlsoComplicated />
</div>
);
};
代码示例: advanced-react.com/examples/02…
但是,从性能和重新渲染器的角度而言,这样的代码还不是最优的。每一次滑动行为都会触发状态更新,而且我们知道,每一次状态更新不仅会触发该组件的重新渲染,还会重新渲染该组件下的子组件。因此,这个滑动功能将会变得非常卡顿。这样的效果不是我们想要达到的。
而且,正如你所见,我们无法把这个状态提取的到其他地方了。在onScroll函数中的setPosition函数已经放在了涵盖整个应用的div块里面了。
那么,我们该怎么办?使用Memoization或酷炫的Ref技巧?还不需要!在使用它们之前,还有一更简单的方法。我们还是把position状态和与该状态相关的部分提取到一个组件:
const ScrollableWithMovingBlock = () => {
const [position, setPosition] = useState(300);
const onScroll = (e) => {
const calculated = getPosition(e.target.scrollTop);
setPosition(calculated);
};
return (
<div className="scrollable-block" onScroll={onScroll}>
<MovingBlock position={position} />
{/* slow bunch of stuff used to be here, but not anymore */}
</div>
);
};
之后,把剩余的、运行慢的部分作为属性传递给该组件:
const App = () => {
const slowComponents = (
<>
<VerySlowComponent />
<BunchOfStuff />
<OtherStuffAlsoComplicated />
</>
);
return (
<ScrollableWithMovingBlock content={slowComponents} />
);
};
我们只需要在ScrollableWithMovingBlock组件中设置一个“content”属性来接收React元素即可(这个概念后面会进一步解释)。之后,ScrollableWithMovingBlock接收到该属性后,把该属性渲染到特定的位置即可。
// add "content" property to the component
const ScrollableWithMovingBlock = ({ content }) => {
const [position, setPosition] = useState(0);
const onScroll = () => {...} // same as before
return (
<div className="scrollable-block" onScroll={onScroll}>
<MovingBlock position={position} />
{content}
</div>
)
}
现在,我们再次测试状态更新与重新渲染的场景。如果一个状态被更新了,重新渲染会被再次触发。但是在当前的重新渲染,仅仅重新渲染ScrollableWithMovingBlock组件 - 仅渲染一个div块和一个移动div块。剩下的部分是通过属性传递进来的,所以它们并不是该组件的子组件。在当前的组件层级树中,它们是父组件(App组件)的子组件。React在重新渲染的时候,只会向下渲染,而不会向上渲染。所以,这些部分在position状态更新时,并不会从新渲染,从而使得滑动过程变得更加流畅。
代码示例: advanced-react.com/examples/02…
稍等一下,这里还是有一些令人迷惑的地方。确实,这些慢运行组件是声明在父组件中,但它们仍然是渲染在父组件中的。那为什么它们没有被重新渲染?这是一个值得探究的问题。
要搞清楚这个问题,我们需要厘清一些概念:在React中,重新渲染器到底意味着什么?组件与元素的区别是什么?以及关于协调器与比对算法的基础知识。
元素,组件与重新渲染器
首先我们要搞明白的是,什么是组件?这是一个组件的例子:
const Parent = (props) => {
return <Child />;
};
我们知道这是一个函数。而函数组件与函数最大的不同在于,函数组件会返回一个会被转化成DOM元素再渲染在浏览器的元素。如果函数组件有属性,属性会成为该函数组件的第一个参数。
const Parent = (props) => {
return <Child />;
};
这个函数会返回,而是Child组件的元素。每次我们通过尖括号调用这些组件,都是在生成一个元素。Parent组件的元素则是
而元素,正是用于定义什么内容要被渲染在屏幕的对象。事实上,jsx中类HTML的语法,不过是React.createElement函数的语法糖罢了。我们可以用React.createElement(child, null, null)来替代这一语法,代码也会如预期运行。
而的定义对象,会是这样的:
{
type: Child,
props: {}, // if Child had props
... // lots of other internal React stuff
}
这个对象告诉我们,Parent组件希望渲染一个无属性的Child组件。而作为返回值的Child组件也有自己的定义,因此,React会沿着定义链一直渲染。
需要注意的是,元素的type不仅仅只是组件;type也可以是普通的DOM元素。我们的Child组件可以返回一个h1标签:
const Child = () => {
return <h1>Some title</h1>;
};
在这个示例中,定义对象和之前的定义对象基本一致,唯一不同的在于type的值是一个字符串:
{
type: "h1",
... // props and internal react stuff
}
现在回到重新渲染器的视角。React在执行重新渲染的过程中,到底发生了什么?React会基于函数组件所返回的定义对象构建一棵树。事实上,有两棵树,一棵树记录重新渲染前的虚拟DOM,另一棵则记录重新渲染后的。通过比对这两棵树,React会提取要渲染到浏览器上的信息:哪些DOM节点需要被更新、删除或者添加。这个过程也被称作“协调器”算法。
而这个算法中对本章的问题有影响的部分是:如果一个对象(元素)在渲染前和渲染后是完全一样的,React将会跳过对该组件和其子组件的重新渲染。而所谓的完全相同,则意味着Object.is(ElementBeforeRerender,ElementAfterRerender)的返回值为true。React并不会对对象进行深层的比对。如果比对结果为true,React则不会变动这个组件,然后执行下一个组件。
如果比对结果为false,这是向React发送有内容发生变化的信号。React会检查type字段。如果前后的type都一样,React会重新渲染这个组件。如果type前后不一致,React会删除旧的组件,并挂载新的组件。对于这部分的细节,我们在第六章可以进一步了解到。
让我们看看这个 Parent/Child 示例,并想象Parent组件有状态:
const Parent = (props) => {
const [state, setState] = useState();
return <Child />;
};
当setState函数被执行时,React将会执行针对Parent函数的重新渲染。所以,它会调用Parent函数并比对前后的状态变化,并返回一个基于Parent函数所定义的对象。所以每次该函数被调用时,定义对象会被重新生成,而Object.is(前定义对象,后定义对象)的返回结果为false。因此,每次Parent重新渲染时,Child也会重新渲染。
现在,设想一下,如果我们把Child组件作为属性传进去,会发生什么
const Parent = ({ child }) => {
const [state, setState] = useState();
return child;
};
// someone somewhere renders Parent component like this
<Parent child={<Child />} />;
在这段代码中,所生成的定义对象以一个引用的形式被传递给Parent组件。在Parent组件被重新渲染时,React会对Parent进行前后比较,而比对到child时,child是一个在Parent外声明的函数,且该该函数的定义并没有发生变化。因此,针对child的比对结果为true,React并不会重新渲染child组件。
而我们优化滑动功能时,运用的这是这个原理!
const ScrollableWithMovingBlock = ({ content }) => {
const [position, setPosition] = useState(300);
const onScroll = () => {...} // same as before
return (
<div className="scrollable-block" onScroll={onScroll}>
<MovingBlock position={position} />
{content}
</div>
)
}
当可滚动且带有移动块ScrollableWithMovingBlock中的setPosition被触发,并且重新渲染发生时,React 会对该函数返回的所有那些对象定义进行比较,会发现前后的定义对象完全相同,然后就会跳过对相关内容(在我们的例子中,就是一堆运行速度很慢的组件)的重新渲染。
子组件作为属性
这个模式虽然很酷而且可行,但还是有个小问题:这样看起来怪怪的。把页面的所有内容都当做props传入其他组件,总会让人觉得怪怪的。让我们改善这个问题。
首先,我们要搞明白属性的本质。属性本质上是传递到函数组件的类型为对象的第一个参数。从这个对象提取的任何值,都是属性。在 Parent/Chld 的示例中,如果我们把child属性改名为children属性,代码照常运行:
// before
const Parent = ({ child }) => {
return child;
};
// after
const Parent = ({ children }) => {
return children;
};
而在组件调用方,代码是这样的,一样照常运行:
// before
<Parent child={<Child />} />
// after
<Parent children={<Child />} />
然而,对于子组件(children)属性,我们在 JSX 中有一套特殊的语法。那种我们在使用 HTML 标签时一直用到的很棒的嵌套组合形式,只是我们从来没有思考过它,也没有特别留意过它。
<Parent>
<Child />
</Parent>
这段代码和我们显示地声明childern属性,会运行地一模一样:
<Parent children={<Child />} />
// exactly the same as above
<Parent>
<Child />
</Parent>
它们都会返回这样的对象
{
type: Parent,
props: {
// element for Child here
children: {
type: Child,
...
},
}
}
它们会运行地一模一样。无论通过属性传递了什么内容,都不会受到接收这些属性的组件的状态变化的影响。所以我们可以将我们的应用程序从如下这种情况进行重写:
const App = () => {
const slowComponents = (
<>
<VerySlowComponent />
<BunchOfStuff />
<OtherStuffAlsoComplicated />
</>
);
return (
<ScrollableWithMovingBlock content={slowComponents} />
);
};
把代码改得更好看也更容易理解:
const App = () => {
return (
<ScrollableWithMovingBlock>
<VerySlowComponent />
<BunchOfStuff />
<OtherStuffAlsoComplicated />
</ScrollableWithMovingBlock>
);
};
然后,我们需要在组件定义处,修改字段的命名! 这是修改前:
const ScrollableWithMovingBlock = ({ content }) => {
// .. the rest of the code
return (
<div ...>
...
{content}
</div>
)
}
这是修改后:
const ScrollableWithMovingBlock = ({ children }) => {
// .. the rest of the code
return (
<div ...>
...
{children}
</div>
)
}
通过使用这个组合技巧,我们在一个慢速运行的应用中,实现了一个快速运行的移动块功能! 代码示例: advanced-react.com/examples/02…
知识概要
希望这(上述所讲内容)能讲得通,并且现在你对 “将组件作为属性” 以及 “将子组件作为属性” 这两种模式已经有了信心。在下一章节中,我们将会看一看将组件作为属性在性能之外的方面是如何发挥作用的。在此期间,以下是一些需要记住的要点:
- 组件是一个接收props为函数,以告诉浏览器要渲染什么内容的元素为返回值的函数。
const A = () => <B />
就是一个组件。 - 元素是用于描述渲染在浏览器的内容的对象。这个对象的type可以睡一个描述DOM的字符串,也可以是一个函数组件的引用。
const b = <B/>
就是一个元素。 - 重新渲染器就是React调用了组件函数。
- 通过
Object.is
来比对前后的tree,发现元素发生变化后,组件会重新渲染。 - 当元素被当做属性传入一个组件,这个组件因状态更新而重新渲染时,作为属性传入的元素并不会被重新渲染。
- “子组件(children)” 仅仅是属性,并且当它们通过 JSX 嵌套语法进行传递时,其行为表现就和其他任何属性一样:
<Parent>
<Child />
</Parent>
// the same as:
<Parent children={<Child />} />