🦄 问题
后端同学在使用我们研发的 工作台 时发现了一些性能上的问题。在他的电脑上快速发送消息时会感觉到明显的消息 loading 等待时间较长的问题。但是我们使用 HTTP 2 本身就可以多路复用。并且在正式环境中。后端发送消息的接口平均响应在 20 ms。因此可以排除是后端接口的原因。那么只能从前端方面入手,看看是什么原因造成的 loading 时间等待较长的原因了。
先看看优化之前的效果图 (因为再录制屏幕,这个 loading 等待较长的问题会尤为明显,正常情况下,快速发消息持续 15s 就会复现。):
🤔 分析
为什么会有这么长的 loading 等待时间呢?
那我们先打开 DevTools 进行一下性能分析吧!
点击录制,然后等待录制结果。我们进行分析!
可以看到,首先任务被 React 切分的很漂亮 😀。其次仔细观察可以发现有很多任务耗时都在 200+ms,那究竟是什么引起的这个问题呢?我先随便先展开一个长任务进行分析。
我们可以清晰的看到,在发送消息的过程中,React 会做多次的渲染,这个种情况非常的不正常。为了找到问题的根本,我们还需要进一步确认。
接下来我们打开 React Developer Tools 进行分析。查看是具体是哪里造成了这么多次的渲染。
效果图如下:
竟然渲染了这么多次!我认为我们已经找到了造成快速发送消息 loading 等待时间较长的元凶!
接下来我们需要仔细阅读代码,查看是哪里造成了多次的渲染!
😄 优化
👉 ImChatBody
首先我们来查看阅读 ImChatBody 组件的相关代码,在这个过程中我们可以看到如下代码:
export function ImChatBody(props: ImChatBodyProps): JSX.Element {
...
const renderMessageList = (): ReactNode => {
const getGroupInfo = () => {
...
}
return (
<>
{messages.map((message, idx) => {
return (
<Fragment key={message.localId || message.ID}>
...
<ImMessageItem
...
message={message}
...
/>
</Fragment>
)
})}
</>
)
}
return (
...
{renderMessageList()}
...
</div>
)
}
对于这种 JSX 代码,我们需要警惕,为什么呢?
首先这个代码写成了函数的形式,在当页面任何一个状态发送改变时,都会从新进行递归更新页面,都会执行到这个 renderMessageList() 函数,尽管 React 会复用可复用的 Fiber,但是在 diff 阶段,reconcileChildrenArray 函数中,会使用最新的 JSX 对象和 Fiber 对象进行对比。通常函数执行很多次,那么可以想象这个对性能的消耗。我们也可以看看这个函数的执行次数。
如下图:
所以我们可以清晰的看到这个 List 渲染了这么多次。因此这是一个非常值得优化的点、那我们需要怎么优化呢?
- 首先需要将其改为一个正常的组件。
- 在使用 memo 对其进行性能优化。
- 完成组件的改造之后再进行 console 的打印,以确定其的优化是否 OK。
const RenderMessageList = memo(function RenderMessageList({
messages,
...
}: {
messages: Message[]
...
}) {
...
const getGroupInfo = useMemo(
() =>
...
[messages, ...],
)
return (
<>
{messages.map((message, idx) => {
...
return (
<MessageItem
key={message.localId || message.ID}
message={message}
...
/>
)
})}
</>
)
})
我们在对其进行 console 的打印输出,查看该组件的渲染次数。
有的同学可能会问,为什么需要使用 memo 呢?
因为函数组件的特性,其实和 renderMessageList 这个样子写所得到的效果是一样的。我们使用 memo 的原因就是想减少 RenderMessageList 组件的渲染次数。那 memo 就比较合适。当不传递 第二个参数时,它会对 props 进行引用的对比来减少渲染的次数。
就目前的效果来看,对于 renderMessageList 函数的优化是成功的。
👉 ImMessageItem
接下来我们需要将注意力放到 ImMessageItem 这个组件上。该组件的主要作用就是根据消息的类型,来渲染不同的消息。
但是我们可以从 renderMessageList 函数中可以看到,我们是直接遍历 Messages 这个数组进行消息的渲染。但是在测试过程中发现,每次发送新消息,新消息都会触发生成三次新的 Messages 数组,因为引用的不同,包括 Messages 数组中的每个对象的引用也跟着改变,那么对于之前的消息来说,props 改变,ImMessageItem 组件也会从新渲染。
如图:
我们可以看到,会有很多次的渲染,console 会被分为三个 16 次,我们的消息页,一页也是 16 条 消息。
因此我们需要考虑怎么进行优化呢?
- 对于之前的老数据来说,我们需要让其不进行渲染。
- ImMessageItem 组件只渲染新的消息。
思路现在有了,但是应该考虑怎么实现呢?因为每个 Message 的引用都不同,因此 useMemo, useCallback 也都使用不了。我们的想法是不渲染老的消息数据,对象的引用不能用于判断,那我们只能使用 memo 的第二个参数,传递函数手动控制组件的渲染了。
- 将 ImMessageItem 组件周围的代码再封装一个组件。
- 使用 memo 对其进行更加详细的优化。可以考虑使用 equals 函数进行内容的判断。(因为对于老的消息数据内容是不会改变的)
- console 验证组件的渲染次数
const MessageItem = memo(
function MessageItem({
message,
...
}: {
message: Message
...
}) {
return (
<Fragment key={message.localId || message.ID}>
...
<ImMessageItem
...
message={message}
...
/>
</Fragment>
)
},
function areEqual(prevProps, nextProps) {
return equals(prevProps.message, nextProps.message)
},
)
在这里有的同学可能会觉得,直接使用 equals 也会有性能问题,每次都会进行内容的深入比较,也是有问题的。我也是明确这一点的,在这里我其实做了一些衡量的,首先对于之前的写法会造成大量的渲染,直接导致消息的loading等待时间较长。相比较于 equals 函数,虽然也会造成一些性能上的问题,但是二者比较之下,equals 所造成的性能要远小于目前的写法,我觉得这样的性能牺牲是值得的!
如图:
我们可以看到效果还是非常不错的!👍🏻
👉 MessageContentRenderer
我们接下来将注意力聚焦在 messageContentRenderer 函数上,这个函数主要的作用就是渲染不同消息类型,总体来说这部分的代码问题相对来说比较多。
function messageContentRenderer({
message,
...
}: {
message: Message
...
}): null | JSX.Element {
const AccountWrapper: FC<{ children: JSX.Element; account: Account }> = ({
children,
account,
}) => {
return ticketPermission ? (
<FastAssign
ticketPermission={ticketPermission}
...
>
{children}
</FastAssign>
) : (
<>{children}</>
)
}
const header: undefined | JSX.Element =
isShowMessageHeader && !mergeWithPrevMessage ? (
<MessageHeader message={message} AccountWrapper={AccountWrapper} />
) : undefined
const createContentRender = (
content: JSX.Element,
showSendState?: boolean,
): JSX.Element => (
<MessageContentWithMenu
message={message}
...
>
{content}
</MessageContentWithMenu>
)
switch (message.type) {
case MessageType.text:
return (
<ImMessageText
from={message.from}
...
customContentRender={content => createContentRender(content, true)}
/>
)
case MessageType.htmlText:
return (
<ImMessageHTMLText
message={message}
...
customContentRender={createContentRender}
/>
)
case MessageType.richText:
return (
<ImMessageRichText
message={message}
...
customContentRender={content => createContentRender(content, true)}
/>
)
case MessageType.file:
const isImageMessage = Boolean(message.payload.thumbnailPreviewUrl)
return (
<ImMessageCustomFile
message={message}
...
customContentRender={createContentRender}
/>
)
case MessageType.sticker:
return (
<StickerMessage
message={message}
...
customContentRender={createContentRender}
/>
)
case MessageType.revoked:
return <RevokedMessage message={message} header={header} />
}
}
仔细阅读代码我们可以发现像 AccountWrapper、header、createContentRender 这些 JSX 我们需要使用 useMemo、useCallback 对其进行保存引用,减少其更新,进一步的减少各个消息组件 props 的引用改变导致的重复渲染。
export const MessageContentRenderer = memo(function MessageContentRenderer({
message,
...
}: {
message: Message
...
}) {
const AccountWrapper: FC<{
children: JSX.Element
account: Account
}> = useMemo(
() => ({ ... }) => {
return ticketPermission ? (
<FastAssign
...
>
{children}
</FastAssign>
) : (
<>{children}</>
)
},
[...],
)
const header: undefined | JSX.Element = useMemo(
() =>
isShowMessageHeader && !mergeWithPrevMessage ? (
<MessageHeader ... />
) : undefined,
[...],
)
const CreateContentRender: FC<{ showSendState?: boolean }> = useMemo(
() => props => (
<MessageContentWithMenu
...
>
{props.children}
</MessageContentWithMenu>
),
[...],
)
switch (message.type) {
case MessageType.text:
return (
<ImMessageText
...
message={message}
...
CreateContentRender={CreateContentRender}
/>
)
case MessageType.htmlText:
return (
<ImMessageHTMLText
message={message}
...
CreateContentRender={CreateContentRender}
/>
)
case MessageType.richText:
return (
<ImMessageRichText
message={message}
...
CreateContentRender={CreateContentRender}
/>
)
case MessageType.file:
const isImageMessage = Boolean(message.payload.thumbnailPreviewUrl)
return (
<ImMessageCustomFile
message={message}
...
CreateContentRender={CreateContentRender}
/>
)
case MessageType.sticker:
return (
<StickerMessage
message={message}
...
CreateContentRender={CreateContentRender}
/>
)
case MessageType.revoked:
return <RevokedMessage ... />
}
})
对于 messageContentRenderer 函数的优化其实也是一样的,我们可以将其改为组件,使用 memo 对其进行性能上的优化。另外就是也使用了一些常规的优化手法。值得一说的就是 props.children 优化的思路。这种优化方法主要的思路就是避免在状态更新时,执行 React.createElement。而是让 React 直接渲染 children 即可。
我们可以在 ImMessageText 组件中 console 打印一下渲染次数。
如图:
效果也是比较成功的。👍🏻
👀 效果展示
本次优化还有一些比较小的优化点在本篇文章中并没有展示出来。只展出了一些比较重要的优化点。 最后我们再来看看 React Developer Tools 检测的渲染情况。
如图:
本次的优化其实并没有做到完美,但是综合考虑了时间成本,并且和目前所得到的性能表现来看,还是一次成功性能优化。因此后续的优化可以留到以后再出现性能问题的时候在做处理。
🏎 招聘
大家好呀!我司(坚果云)正在招聘前端开发工程师。能力要求如下:
- 熟练使用至少一种现代前端框架,包括但不限于 React, Vue.js, Angular
- 具备较强的学习能力
- 熟练使用 TypeScript 编写代码
- 熟练使用 RxJS 描述复杂的业务逻辑
- 对 CSS 有深入了解
- 除了 React,能够熟练使用 Vue 编写前端单页应用
- 熟悉 Electron, React Native
- 有 GitHub 开源代码、个人作品
如果你有意向请发送简历到 joinus@nutstore.net 。 备注 Destiny__ 让我们知道你是从这篇文章看到的招聘信息!
(以上个人技能只需满足部分即可)另外,我们周末双休。
👏 往期精彩
- 👏简单设计一个 Form 表单!
- 👏React 组件优化第二弹来袭!!
- 👏这些避免React组件重复渲染的手段你都知道吗?
- 👏项目引入ESBuild,编译直接快上天!!
- 👏在React中使用WebComponents组件的最佳实践
- 👏我是怎样将50+MB的app打包文件优化为4.2MB的?
- 👏styleds-components 的原理你能讲一下吗?
- 👏面试官:你能讲一下extends和寄生式组合继承原型之间的区别?
- 👏前端Leader让我给同事讲讲事件循环
- 👏React与Vue状态更新原理对比
- 👏当React遇到树形穿梭框咋办?
- 👏使用React时要避免的 10 大错误
- 👏webpack打包优化方向指南(理论篇)
- 👏vue遇到拖拽动态生成组件怎么办?
- 👏JS Coding 技巧,大多数人都不会!!
- 👏TypeScript 它不香吗?还不快来!