Level2极速行情渲染优化实践

与前端高性能渲染探索


By 李志文 - 2021/07

目录概览


  • 一、Level2 极速行情的场景简介
  • 二、性能优化思路与策略
  • 三、前端高性能渲染方案
  • 四、Level2 高性能渲染优化实践
  • FAQ

一、Level2 极速行情的场景

基本情况


  • React 组件渲染
  • 极速行情:通过主进程 Node 模块转发服务,ipc 方式通信订阅
  • 极速行情Plus(千档行情):开发独立的 SDK 对接,在 worker 中执行行情订阅、数据聚合、计算与预处理等
  • 主要数据:Order快照、Tick逐笔
  • 同时订阅云行情:获取涨跌停、当前最高价、当前最低价、昨收价等数据
  • 应用场景:根据行情实时价格进行债券等品种的 T0 闪电交易

存在的问题


  • 高频代码订阅(一只高频代码、五只普通代码),页面卡顿
  • 必现场景:新债上市首日/次日,瞬时大量成交,逐笔(tick)数据每秒上千条

基本分析(未优化前-v1)


  • 全量接收,异步处理,异步渲染
  • 数据收发处理:高频而简单粗暴的 JSON.stringfiyJSON.parse
  • 数据订阅逻辑简单,存在大量重复处理、无效处理等计算性能的浪费
  • 大量高频数据全部接收并处理,但实际只要最新的10或20条,大部分数据用不上
  • 多个代码订阅,DOM 结构多,数据变更频率快,页面更新成本高

二、性能优化思路与策略

2.1 React 性能优化指南

React 性能优化的核心方向

  • 1. 减少计算量

    减少渲染的节点、通过索引减少渲染复杂度

  • 2. 缓存

    利用缓存、避免不必要的组件重新渲染或计算

  • 3. 优化组件渲染

    根据组件和状态的关系, 精确判断组件更新的时机和范围,只重新渲染变更的组件

React 优化技巧

  • shouldComponentUpdate(nextProps, nextState)

    • props 比较:当前子组件需要的数据是否真正变更?
    • props 优化原则:尽量只传当前子组件必要的数据
  • static getDerivedStateFromProps(nextProps, prevState)

    • re-rendering 之前被调用
    • 根据 Props 的改变决定是否更新子组件的 state
    • 更快的自定义更新逻辑
    • 纯函数,不要产生副作用

React 优化技巧


  • React.memo、 React.useMemo、React.useCallback
    • 核心思想:尽可能的缓存计算结果,避免重复计算
    • 依赖未发生改变时,不触发重新计算
    • 减少组件 Render 过程耗时
  • unstable_batchedUpdates: state 批量更新
  • 动画:使用 CSS、直接修改 DOM 属性
  • debounce、throttle 节流与防抖
  • 懒加载、虚拟列表懒渲染
  • More...

一顿操作猛如虎

效果怎么样?

好了一些 --- 可以多支持一个高频代码的订阅了。但是。。。

performance-slow log::

performance-slow log2::

performance Commiting slow ::

performance Commiting slow ::

React Profilter

React Profilter

为什么?

  • 大量 DOM 结构,更新渲染耗时成本高
  • 数据极速变更,React 每一次 diff 的最终结果仍然会是大量的 DOM 更新:diff 不仅没有带来优化,反而增加了 CPU 计算时间成本?

初步结论:

DOM 渲染机制 - 无法满足含有大量 DOM 结构页面的高频更新需求

2.2 基于数据处理的优化策略


  • 核心:减少计算、减小数据传输性能消耗
    • 计算合并、提前预处理,避免重复计算
    • 逻辑重构,数据裁剪、避免不必要的计算
    • 将计算迁移到其他线程(Web Workers、main progress)
  • 数据订阅集中管理
    • 修改前:
      • 业务调用处使用 ipcRender 方式进行事件监听,接收所有的行情响应并进行 JSON.parse 等消耗性能的解析
      • 没有共享,相同代码在多个地方会被处理多次,极其浪费性能
    • 优化:
      • 设计并开发了 MdsMsgCenter 服务类,用于统一管理行情的订阅与数据处理
      • 所有业务调用均改为使用该服务类方法实现
  • 多级缓存、频率控制
    • 修改前:
      • 收到转发数据即立即通过 ipcMain 派发事件的方式发送给渲染进程,没有多余的处理
    • 优化:
      • 主要策略:将计算、格式化处理、聚合策略等消耗性能的处理放到主进程或 worker 进程中
      • 在主进程中接收到数据时,即进行 JSON.parse 解析并缓存结果
      • 数据节流:增加 tick 分笔数据的缓存聚合策略,按可配置的时间间隔以节流模式派发数据
      • 所有的数据接收均统一格式化、缓存等处理,避免多次处理的性能浪费
      • 数据预处理与裁剪:移除不必要的字段,对数值、时间等的格式化提前预处理
  • 减少 emit/send 事件,改用 callback 方式
    • 修改前:
      • 所有订阅均通过 ipc 事件方式,进行全局监听; Event 的 emit 方法会对数据进行深拷贝,高频调用会存在性能影响
    • 优化:
      • 设计 MdsMsgCenter 集中管理,实现回调函数方式的订阅,传参为引用方式,不作深拷贝(存在副作用:业务调用时需注意不修改入参数据)
      • 极速行情Plus 等的订阅调用均改为回调函数订阅模式
      • 为了兼容闪电交易等业务的全局监听模式(如有必要后续可优化),同时提供了 emit/on/off 方式的事件监听
      • 通过 MdsMsgCenter emit 数据时,先判断是否存在该事件的监听,避免无意义的性能浪费
  • 增加订阅类型的过滤
    • 修改前:
      • 当前 mds 的订阅逻辑是会同时订阅 order 和 tick 数据(因为分开订阅会有一些异常情况);
      • T0 交易等只订阅 order 快照数据(高频 tick 数据虽无用但也在同时接收和处理)
    • 优化:
      • 支持按数据类型订阅,收到数据时,若无该类型(如tick分笔)的订阅,则不作任何处理
      • 订阅优化:若已无相关代码的订阅却收到消息,立即发送取消订阅请求

效果如何?

  • 可以支持 1~3 个高频代码和 11~8 个普通频率代码的订阅。
  • 不稳定,更多较快的代码订阅,卡顿的可能性较高。
  • 只能通过节流方式对 DOM 更新进行频率控制,牺牲 tick 逐笔的更新频次。

为什么?

  • 相对 DOM 高频更新的时间成本,数据预处理计算优化节省的时间微不足道
  • 大量 DOM 的高频更新难以避免卡顿问题

浏览器的重排(reflow)与重绘(repaint):

浏览器的重排与重绘

  • 重排:HTML结构发生改变(元素增删、位置大小),DOM 结构需要重新计算与渲染
  • 重绘:外观改变,需重新绘制
  • 重排必然会出现重绘
  • 重排和重绘的代价很高,可能会导致 UI 展示迟缓与卡顿

浏览器端的 UI 高性能渲染,还可以怎么做?

三、前端高性能渲染方案

DOM 变更的CPU时间成本比较高:使用 canvas 渲染。

3.1 什么是 canvas?

canvas 是一种 html5 DOM 元素,同时存在一些相关的 JavaScript 标准 API。

基于这些 API 可以实现在 canvas 元素上绘制路径、矩形、圆形、字符以及添加图像的能力。

Canvas 示例

<canvas id="canvas" style="width: 480px;height:600px" width="960" height="1200">
    <div>当前浏览器不支持canvas!</div>
</canvas>
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2D');

// 画一条线
ctx.moveTo(0,0);
ctx.lineTo(200,200);
ctx.stroke();

3.2 canvas 常见的应用场景


  • 图表绘制
  • 各种小游戏、安卓快应用
  • webGL(用于渲染交互式3D图形,基于 OpenGL ES 的 JavaScript API)渲染
  • More...

为什么网页开发不用 canvas?

  • 画成了图片,没有了 HTML 的语义化能力(盲文、网页阅读器等的支持)
  • 元素事件处理缺失:只能基于 canvas 根元素自行实现委托监听与计算模拟实现,相对比较复杂。
  • 够用了:大部分场景下,DOM 渲染模式不存在无法优化的性能问题。
  • More...

网页开发是否可以用 canvas?

3.3 canvas 组件的实现


  • 元素绘制:使用 canvas 相关 API 绘制元素 UI
  • 事件处理:监听 canvas 根元素的相关事件,获取事件触发的位置等数据,计算模拟 UI 元素的事件订阅与触发机制
  • 高性能渲染:精确控制逻辑,极限性能优化与调试
  • 缺点:工作量大,效率低下 -- 组件式封装

3.4 开源社区的 canvas 组件(类html组件能力)

  • react-ape React Renderer to build UI interfaces using canvas/WebGL
    • 类似 ReactDom,不同的是它将结果渲染到 canvas 上
    • 提供基础的 image、text、view、List 组件
    • 已两年无功能性更新

react-ape 示例:

<canvas id="root" width="468" height="788" style="width: 234px; height: 394px;"></canvas>
import React, { Component } from 'react';
import { View, render } from 'react-ape';

class DimensionsBasics extends React.Component {
  render() {
    return (
      <View>
        <View style={{width: 50, height: 50, backgroundColor: 'powderblue'}} />
        <View style={{width: 100, height: 100, backgroundColor: 'skyblue'}} />
      </View>
    );
  }
}

render(<DimensionsBasics />, document.getElementById('root'));
  • react-canvas

    • react 组件封装,同类中 stars 最多
    • 作者转行写 IOS 了去:最近更新四年前
  • revas 用React和CSS在Canvas上编写高性能交互界面

    • 参考 react-canvas,部分 API 和用法相似
    • Demo
  • easy-canvas 使用render函数在canvas中创建文档流布局,小程序海报图、小程序朋友圈分享图

3.5 高性能表格组件

  • canvas-datagrid Canvas based data grid web component. Capable of displaying millions of contiguous hierarchical rows and columns without paging or loading, on a single canvas element.

    • 功能相对比较丰富,可满足常见表格组件的各种功能需求
    • 高性能表格,百万级数据快速渲染
  • cheetah-grid The fastest open-source data table for web.

    • 使用 canvas 渲染的表格组件库
    • 类 bootstrap 风格,可自定义样式
    • 从名字就可以看出作者的目标,猎豹一样快的表格

一些其他 canvas 的相关开源库

  • node-canvas C++ 实现的在 NodeJS 中创建 canvas 元素,并支持与浏览器中 JavaScript 一致的相关 API
  • react-konva react 组件方式绘制 konva 图形
  • rasterizeHTML.js 将 HTML 渲染到 canvas -- foreignObject:: HTML -> SVG -> canvas
  • html-to-image Generates an image from a DOM node using HTML5 canvas and SVG. -- 同样基于 foreignObject 实现

四、Level2 高性能渲染优化实践

使用 canvas 重构极速行情的 UI 渲染

4.1 尝试: canvas 重写 tick 逐笔数据的渲染


  • 只用于展示,没有用户交互,相对比较简单
  • 要求极致的性能:能省则省
  • 不使用开源社区第三方组件库,采用 canvas 基础 API 绘制实现
  • A. 简单封装文本、矩形、圆角矩形、直线等的绘制方法
// 画文本
export function drawText() {}
// 画矩形
export function drawRect() {}
// 画圆角矩形
export function drawRadiusRect() {}
// 画直线
  • B. 在 React 组件 shouldComponentUpdate 方法中阻止重绘并更新 canvas
export default class StockOrder extends React.Component<StockOrderProps, never> {
    private canvasRef = React.createRef<HTMLDivElement>();
    private canvas: HTMLCanvasElement;
    formatData() {} // 数据格式化
    drawToCanvas() {} // 绘制逻辑
    componentDidMount() { // 初始化 canvas 画布
        if (this.canvasRef.current) {
            this.canvas = createHiDPICanvas(250, 540); // 生成高 DPI canvas
            this.canvasRef.current.appendChild(this.canvas);
            this.drawToCanvas();
        }
    }
    private rafhandle: number; // 节流句柄
    shouldComponentUpdate() {
        if (this.rafhandle) cancelAnimationFrame(this.rafhandle);
        this.rafhandle = requestAnimationFrame(() => this.drawToCanvas());
        return false; // 阻止组件重绘
    }
    render() { return <div className="StockOrderCanvas" ref={this.canvasRef}></div>; }
}

测试结果:效果不错,可以稳定支持 3 个极速行情代码同时订阅。

4.2 canvas 重写 Order 快照数据的渲染

  • 渲染性能大幅提升,可同时订阅 12 只高频代码
  • 订阅代码数量较多时,CPU 占用率较高,CPU 时间拥挤可能会导致软件卡顿、无响应等
  • 仍需支持一定的频率控制能力,以保证可调控的稳定性

更多的优化策略?

  • 全局存在多个定时器用于不同功能:对定时器类的动作分析并优化,支持启动与暂停;在行情界面时,停止不必要的定时器
  • 当前价、最新价等数据也该用 canvas 绘制渲染
  • Tick 与 Order 等数据的绘制合并到一个 canvas 上(当前是分开渲染的)
  • 行情数据源改进:当前同时从多个行情源订阅以获取不同的数据字段,数据源上做到数据合并,则可减少行情订阅量
  • More...

4.3 canvas 渲染性能优化策略

  • Web Workers 与 OffscreenCanvas
  • 避免浮点数的坐标点,用整数取而代之
  • 使用多层画布去画一个复杂的场景
  • 大图性能问题
    • 不要在用 drawImage 时缩放图像:在 OffscreenCanvas 中缓存图片的不同尺寸
    • 静态的背景图:使用 CSS 设置大的静态背景图
  • 用CSS transforms特性缩放画布
  • 关闭透明度,获得浏览器的内部优化: ctx = canvas.getContext('2d', { alpha: false });
  • More...

问题:高分辨率屏幕文本模糊


  • DPR 与 px 像素之间的问题
  • 按 DPR 的倍数放大 canvas 画布大小
  • 关于性能:较高的的缩放比,内存占用率也随之增加

问题:高分辨率屏幕文本模糊

export function createHiDPICanvas(width: number, height: number, ratio?: number) {
    const canvas = document.createElement('canvas');

    if (!ratio) ratio = Math.ceil(window.devicePixelRatio || 1);

    canvas.width = width * ratio;
    canvas.height = height * ratio;
    canvas.style.width = width + 'px';
    canvas.style.height = height + 'px';

    if (ratio !== 1) canvas.getContext('2d').scale(ratio, ratio);

    return canvas;
}

问题:使用 canvas 后内存持续增长?

现象: 内存持续增长不释放,最终导致崩溃

手动 GC:

问题:使用 canvas 后内存持续增长?

原因分析与测试:

  • 同时订阅多只极速代码,行情更新极快
  • 采用了 requestAnimationFrame 做节流,反而导致内存管理一直不自动 GC
  • 移除 requestAnimationFrame 逻辑后问题解决
// private rafhandle: number;
shouldComponentUpdate() {
    this.drawToCanvas();
    // if (this.rafhandle) cancelAnimationFrame(this.rafhandle);
    // this.rafhandle = requestAnimationFrame(() => this.drawToCanvas());
    return false;
}

关于 requestAnimationFrame

  • 在浏览器下一次重绘前执行:与浏览器刷新速率有关
  • 参数 callback:: 该回调函数会被传入DOMHighResTimeStamp参数,该参数与performance.now()的返回值相同,它表示requestAnimationFrame() 开始去执行回调函数的时刻
  • 返回值:一个 long 整数,请求 ID ,是回调列表中唯一的标识。可以传给 cancelAnimationFrame() 以取消回调函数
  • 思考:大量的 requestAnimationFrame 高频调用可能会导致垃圾回收机制不能及时执行?

关于 Node.js 的内存回收:手动 GC

  • 增加启动参数:--expose-gc
  • 全局变量: global.gc()
// Node.js 脚本启动参数
node --expose-gc test.mjs

// elctron 启动时指定参数
yarn electron dist --js-flags="--expose-gc"

// 在 electron 中指定默认参数
app.commandLine.appendSwitch('js-flags', '--expose-gc');

// 在业务逻辑中视具体情况调用。如内存剩余率小于 20%、每隔 N 秒等
if (global.gc) global.gc();

思考:没有节流真的可以吗

  • 增加订阅速率、增加订阅代码量:什么时候会卡顿?
    • 12 只代码、50ms 最小刷新间隔、50ms 制造一次数据
  • 并不需要游戏一般的刷新帧率
    • setTimeout 大法好?展示被延迟过长、最后一条可能会丢失
    • 节流函数
drawToCanvas = throttle(() => {}, 30);

节流函数的效果怎么样?