Level2极速行情渲染优化实践
与前端高性能渲染探索
By 李志文 - 2021/07
目录概览
- 一、Level2 极速行情的场景简介
- 二、性能优化思路与策略
- 三、前端高性能渲染方案
- 四、Level2 高性能渲染优化实践
- FAQ
一、Level2 极速行情的场景
基本情况
React
组件渲染极速行情
:通过主进程 Node 模块转发服务,ipc 方式通信订阅极速行情Plus(千档行情)
:开发独立的 SDK 对接,在 worker 中执行行情订阅、数据聚合、计算与预处理等主要数据
:Order快照、Tick逐笔同时订阅云行情
:获取涨跌停、当前最高价、当前最低价、昨收价等数据应用场景
:根据行情实时价格进行债券等品种的 T0 闪电交易
存在的问题
- 高频代码订阅(一只高频代码、五只普通代码),页面卡顿
- 必现场景:新债上市首日/次日,瞬时大量成交,逐笔(tick)数据每秒上千条
基本分析(未优化前-v1)
- 全量接收,异步处理,异步渲染
- 数据收发处理:高频而简单粗暴的
JSON.stringfiy
与JSON.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
为什么?
- 大量 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 组件封装,同类中 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);
节流函数的效果怎么样?
FAQ
* * *