STAY HUNGRY , STAY FOOLISH.

求知若饥,虚心若愚。

       浏览:

老师客户端聊天消息列表性能优化

1.消息列表引起老师端卡死白板白屏

me_error

这篇文章是血的教训,罚款500大洋换来的~挺好,说明自己还有发展的空间,解决并发问题有所欠缺,经验不够。

事情的缘由:
自己目前在公司负责一款并发使用场景很大的产品,对客户端的性能是有一定要求的。

事情发生的原因:
老师在使用客户端给学生们讲课,在与学生互动时,送花和聊天消息巨多的时候,消息列表的DOM频繁渲染,且数量未得到有效控制,导致客户端卡死白屏。

事情解决方案:
1.使用虚拟列表List,不是来一条消息,生成一个DOM(列表DOM是固定的)
2.使用带性能的数组List,节省内存
3.数组限制最新500条,减少内存消耗
4.关闭console.log,避免内存泄漏
5.使用React.memo控制列表渲染频率,避免无效渲染
6.使用useMemo控制数组List,减少性能消耗
7.使用节流throttle,降低渲染频率,1s渲染只一次


2.具体实施方案

1.使用虚拟列表

使用第三方库:rc-virtual-list
github地址:https://github.com/react-component/virtual-list

使用前:

v1


v2

当收到一条聊天消息,就会生成一个div,总共1500多个div生成出来,DOM只会越来越多。


使用后:

list

只会生成可视区域的几个div,然后通过css样式实现列表下拉效果。

实现方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import React from "react";
import VirtualList from 'rc-virtual-list';
import { Message } from './message';

const ChatList: React.FC<any> = ({
messages
}) => {
return (
<VirtualList
itemHeight={80}
itemKey="ts"
data={messages}
height={ document.body.clientHeight - 250 }
children={(item, index) =>
<Message key={index} {...item}/>
}
/>
);
};
export default ChatList;

注意:必须设置itemHeight和height,itemHeight是一条消息的最小高度,height是可视区高度,否则无效。


2.使用带性能的数组List

使用第三方库:immutable.js
该库同属于facebook团队开源出来的,经典的react.js也是出于他们团队。
react地址:https://github.com/facebook/react
immutable地址:https://github.com/facebook/immutable-js/

Imutualble概念:顾名思义,对象一旦被创建便不能更改,对immutable对象的修改添加删除都会返回一个新的immutable对象,同时为了避免deepCopy的性能损耗,immutable引入了Structural Sharing(结构共享),如果对象只是一个节点发生变化,只修改这个节点和受它影响的父节点,其他节点共享。

使用immutable,可以优化下性能:

  • 节省内存
  • 并发安全
  • 拥抱函数式编程

举例:
这里有100,000条聊天消息:

1
2
3
4
5
6
7
8
9
10
11
12
13
var msgs = {

t79444dae: { msg: 'Task 50001', completed: false },
t7eaf70c3: { msg: 'Task 50002', completed: false },
t2fd2ffa0: { msg: 'Task 50003', completed: false },
t6321775c: { msg: 'Task 50004', completed: false },
t2148bf88: { msg: 'Task 50005', completed: false },
t9e37b9b6: { msg: 'Task 50006', completed: false },
tb5b1b6ae: { msg: 'Task 50007', completed: false },
tfe88b26d: { msg: 'Task 50008', completed: false },

(100,000 items)
}

我要把第50,005条聊天消息的completed改为ture。
用普通的JavaScript对象:

1
2
3
4
5
6
7
8
9
unction toggleTodo (todos, id) {
return Object.assign({ }, todos, {
[id]: Object.assign({ }, todos[id], {
completed: !todos[id].completed
})
})
}

var nextState = toggleTodo(todos, 't2148bf88')

这项操作运行了134ms
为什么用了这么长时间呢?
因为当使用Object.assign,JavaScript会从旧对象(浅)复制每个属性到新的对象。
我们有100,000条聊天消息,就意味着有100,000个属性需要被(浅)复制。
这就是为什么花了这么长时间的原因。
在JavaScript中,对象默认是可变的。
当你复制一个对象时,JavaScript不得不复制每一个属性来保证这两个对象相互独立。


使用Immutable.js

1
2
3
4
5
6
7
8
9
10
// 使用[updeep](https://github.com/substantial/updeep)
function toggleTodo (todos, id) {
return u({
[id]: {
completed: (completed) => !completed
}
}, todos)
}

var nextState = toggleTodo(todos, 't2148bf88')

这项操作运行了1.2ms。速度提升了100倍。

为什么会这么快呢?
可持久化的数据结构强制约束所有的操作,将返回新版本数据结构,并且保持原有的数据结构不变,而不是直接修改原来的数据结构。
这意味着所有的可持久化数据结构是不可变的。
鉴于这个约束,第三方库immutable.js在应用可持久化数据结构后可以更好的优化性能。

最后一句话总结:使用immutable定义的数组和对象,在react render渲染的时候,可以实现结构共享、DOM共享。


实现方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { List } from 'immutable';
import { ChatMessage } from '../../utils/types';

interface ChatPanelProps {
messages: List<ChatMessage>
value: string
sendMessage: (evt: any) => void
handleChange: (evt: any) => void
}

const ChatPanel: React.FC<ChatPanelProps> = ({
messages,
value,
sendMessage,
handleChange,
}) => {
return (
<ChatList messages={messages}/>
)
}

3.数组限制最新500条

消息列表定义了一个messages,按理messages说只是一个变量,当messages.push(xxx)执行10000000….次后,该变量会越来越大,压测或并发大的时候,肯定会引起内存的增大。
简单粗暴的解决方式是,只取最新的500条消息放入该变量中

实现方法:

1
2
3
4
5
6
7
8
9
10
11
12
updateChannelMessage(msg: ChatMessage) {
let { messages } = this.state
messages = messages.push(msg)
if (messages.size >= 500) {
messages = messages.slice(-500)
}
this.state = {
...this.state,
messages
};
this.commit(this.state);
}

注意:该message使用的是immutable.js的List,对应的用法和传统JS数组不同。


4.关闭console.log,避免内存泄漏

logger

之前在接收/发送学生消息的时候,都会打印消息类型、内容及消息人,一有学生进入进出,一有学生举手送花,一有学生发送聊天消息,老师这边都会打印日志,而且是频繁打印。

打印日志,之前以为不会占用内存。但是取消打印日志后,内存竟然真的降了。
查阅相关资料后,发现不停的打印日志,确实会导致内存增加。
原因是因为传递给console.log的对象不能被垃圾回收


5.使用React.memo控制列表渲染频率,避免无效渲染

此话怎么解释呢,就拿聊天区举例,聊天区有两个核心组件:
一、输入框发送聊天消息组件
二、聊天消息列表组件

聊天父组件A,拥有输入框子组件B和列表子组件C,父组件A里有聊天消息数组messages和老师输入的内容value。
当老师发送聊天消息时,子组件B的input输入框的value值会发生变化,从而引发父组件A的渲染。
但是问题来了,父组件A有子组件B和C,父组件一渲染,会引起子组件C的再次渲染,尽管子组件C什么都没动!

说的可能有些绕,直接看图:
聊天组件:ChatPanel
输入框组件:ChatTool
列表组件:ChatList

render_error

老师在聊天输入框中,每输入一个字符,就会引起聊天消息列表的再次渲染,这是很可怕的!消息明明都还没有点击“发送”,输入的值都还没传到消息列表去,就一直渲染。

遇到这种情况,React.memo的神奇之处就来了。
实现方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import React from "react";
import VirtualList from 'rc-virtual-list';
import { Message } from './message';

const ChatList: React.FC<any> = ({
messages
}) => {
return (
<VirtualList
itemHeight={80}
itemKey="ts"
data={messages}
height={ document.body.clientHeight - 250 }
children={(item, index) =>
<Message key={index} {...item}/>
}
/>
);
};
export default React.memo(ChatList);

注意:之前ChatList组件是export default ChatList,现在ChatList组件是export default React.memo(ChatList)

React.memo是当组件props传来的值发生变化才会触发渲染,没有发生变化则不会触发渲染

优化后的效果:

render

可以发现列表组件ChatList只在初始化页面的时候,被渲染一次,输入字符后不会引起列表组件ChatList再次渲染。


6.使用useMemo控制数组List,减少性能消耗

React.memo作用于组件的渲染是否重复执行,同理,我们可以控制变量的计算useMemo,函数的逻辑useCallback是否重复执行。

实现方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import React, { useMemo } from 'react';
import { ChatMessage } from '../../utils/types';

interface ChatPanelProps {
messages: List<ChatMessage>
value: string
sendMessage: (evt: any) => void
handleChange: (evt: any) => void
}

const ChatPanel: React.FC<ChatPanelProps> = ({
messages,
value,
sendMessage,
handleChange,
}) => {
const messageList = useMemo(
() => {
return messages.toJSON()
},[messages])
return (
<ChatList messages={messageList}/>
)
}

7.使用节流throttle,降低渲染频率,1s只渲染一次

函数节流(throttle):高频事件触发,但在n秒内只会执行一次,所以节流会稀释函数的执行频率
说完函数节流,再说一说函数防抖(debounce):触发高频事件后n秒内函数只会执行一次,如果n秒内高频事件再次被触发,则重新计算时间

函数节流(throttle)与 函数防抖(debounce)都是为了限制函数的执行频次,以优化函数触发频率过高导致的响应速度跟不上触发频率,出现延迟,假死或卡顿的现象

实现方法:
1.新增一个临时变量,专门用于存放聊天消息列表的数组;
另一个变量,专门用于渲染聊天消息列表的数组。

1
2
3
4
5
6
export type RoomState = {
// 渲染的数组
messages: List<ChatMessage>
// 存放的数组
tempMessage: List<ChatMessage>
}

2.在老师发送消息及接收学生消息的地方,使用节流去控制渲染频率。
思路分析:
1.收到或发送多条消息,直接存到tempMessage,但不发生页面渲染。
2.控制每1s只渲染一次,即把tempMessage赋值给messages,发生页面渲染。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import { throttle } from 'lodash';

// 节流关键函数
const push = throttle(function() {
// 1s只执行一次,触发渲染
roomStore.updateMessages();
}, 1000)

// 老师发送消息
const sendMessage = (content: string) => {
const message = {
account: me.account,
id: me.uid,
headImg: me.headImg,
role: `${me.role}`,
text: content,
ts: +Date.now()
}
// 无限制接收,反正不渲染
roomStore.updateChannelTempMessage(message);
// 调用节流函数
push()
}

// 接收学生消息
rtmClient.on("ChannelMessage", ({ message }: { message: { text: string } }) => {
if (cmd === ChatCmdType.chat) {
const message = {
headImg: p.headImg,
account: p.userName,
role: p.role,
text: data,
ts: +Date.now(),
id: fromUserId,
}
// 无限制接收,反正不渲染
roomStore.updateChannelTempMessage(chatMessage)
// 调用节流函数
push()
}
})

updateChannelTempMessage存放和updateMessages渲染方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 渲染的数组,触发渲染
updateMessages() {
this.state = {
...this.state,
messages: this.state.tempMessage,
}
this.commit(this.state);
}
// 存放的数组
updateChannelTempMessage(msg: ChatMessage) {
let { tempMessage } = this.state
tempMessage = tempMessage.push(msg)
if (tempMessage.size >= 500) {
tempMessage = tempMessage.slice(-500)
}
this.state = {
...this.state,
tempMessage,
};
this.commit(this.state);
}

3.总结

前端是门学无止境的技术,入门容易,精通难~
对于初学者而言,不就是写页面,html、css、js一套,so简单。
其实不然,每门编程语言的诞生都有它存在的理由,只是说前端技术变化真的太快,react17和vue3的到来,相信又会淘汰一批前端老人吧。