Blog Logo

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

写于2020-11-19 11:07 阅读耗时13分钟 阅读量


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样式实现列表下拉效果。

实现方法:

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条聊天消息:

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对象:

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

// 使用[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共享。


实现方法:

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条消息放入该变量中

实现方法:

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的神奇之处就来了。 实现方法:

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是否重复执行。

实现方法:

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.新增一个临时变量,专门用于存放聊天消息列表的数组; 另一个变量,专门用于渲染聊天消息列表的数组。

export type RoomState = {
  // 渲染的数组
  messages: List<ChatMessage>
  // 存放的数组
  tempMessage: List<ChatMessage>
}

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


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渲染方法:

// 渲染的数组,触发渲染
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的到来,相信又会淘汰一批前端老人吧。

Headshot of Maxi Ferreira

怀着敬畏之心,做好每一件事。