外观
ECharts、Canvas 与 WebSocket 系统学习文档
核对时间:2026-07-06
适合人群:前端工程师、数据可视化开发者、全栈工程师、前端架构师候选人
学习目标:系统掌握 ECharts 图表开发、Canvas 底层绘图与动画、WebSocket 实时通信,并能在 Vue/Nuxt、React/Next、Angular、Svelte、Node.js 项目中落地。
资料原则:优先依据 Apache ECharts 官方文档、MDN Web Docs、React/Vue/Angular/Svelte/Nuxt/Next 官方文档,以及主流封装库仓库说明。
目录
- 三者关系与学习顺序
- 版本与选型基线
- ECharts 核心知识体系
- ECharts 在不同框架中的实现方式
- Canvas 核心知识体系
- Canvas 在不同框架中的实现方式
- WebSocket 核心知识体系
- WebSocket 在不同框架与服务端中的实现方式
- ECharts + Canvas + WebSocket 综合实战
- 性能、安全与排查清单
- 学习路线与练习项目
- 官方与权威资料
1. 三者关系与学习顺序
1.1 三者分别解决什么问题
| 技术 | 核心定位 | 最适合场景 | 不适合场景 |
|---|---|---|---|
| ECharts | 声明式图表库 | 折线图、柱状图、饼图、地图、仪表盘、业务看板 | 完全自定义像素级动画、游戏、复杂图像处理 |
| Canvas | HTML 绘图画布与 2D/位图渲染 API | 自定义图形、动画、粒子、画板、游戏、图像处理、大量点绘制 | 普通 DOM 表单、需要天然可访问性的文本内容 |
| WebSocket | 浏览器与服务端的双向实时通信协议/API | IM、协同编辑、实时指标、游戏状态、交易行情、设备状态 | 只需要服务端单向推送的简单流式场景,此时 SSE 可能更简单 |
1.2 推荐学习顺序
- 先学 ECharts:快速掌握业务图表的 option、series、dataset、tooltip、legend、dataZoom。
- 再学 Canvas:理解 ECharts 等图表库背后的渲染基础,能处理自定义绘图需求。
- 最后学 WebSocket:把实时数据推到 ECharts 或 Canvas 上,做动态大屏、实时监控、行情图、游戏状态同步。
1.3 三者在真实项目中的组合方式
text
浏览器页面
├─ ECharts:展示业务图表、指标趋势、地图、仪表盘
├─ Canvas:绘制特殊动画、复杂拓扑、粒子背景、轨迹回放
└─ WebSocket:持续接收实时数据
服务端
├─ WebSocket 网关:连接管理、鉴权、心跳、限流
├─ 业务服务:产生订单、告警、行情、设备、日志等数据
└─ 消息系统:Redis Pub/Sub、Kafka、RabbitMQ、NATS 等2. 版本与选型基线
2.1 当前 npm 版本核对
以下版本通过 npm view 在 2026-07-06 核对:
| 包 | 当前版本 | 用途 |
|---|---|---|
echarts | 6.1.0 | Apache ECharts 主包 |
vue-echarts | 8.0.1 | Vue 3 常用 ECharts 组件封装 |
echarts-for-react | 3.0.6 | React 常用 ECharts 组件封装 |
ngx-echarts | 22.0.0 | Angular 常用 ECharts 指令封装 |
ws | 8.21.0 | Node.js WebSocket 服务端/客户端库 |
socket.io / socket.io-client | 4.8.3 | 实时通信框架,提供重连、房间、降级传输等能力 |
2.2 什么时候用 ECharts,什么时候直接用 Canvas
优先用 ECharts:
- 需求是标准图表:折线、柱状、饼图、散点、雷达、漏斗、桑基、关系图、地图。
- 需要 tooltip、legend、dataZoom、visualMap、brush、dataset、主题、可访问性等现成能力。
- 图表需要被产品、运营、业务人员持续迭代,声明式 option 更容易维护。
优先用 Canvas:
- 图形结构不是标准图表,例如流程节点拖拽、手写板、游戏地图、粒子、轨迹回放。
- 需要逐帧动画或像素处理,例如滤镜、截图、图片裁剪、画笔、热力轨迹。
- 需要极高绘制自由度,图表库的坐标系和组件反而限制表达。
2.3 什么时候用 WebSocket,什么时候不用
优先用 WebSocket:
- 浏览器和服务端都要频繁发送消息。
- 对延迟敏感,例如聊天、游戏、协同、交易、设备控制。
- 需要服务端主动推送,并且客户端也会持续上报操作。
优先考虑 SSE 或 HTTP Stream:
- 只需要服务端到浏览器单向推送。
- 典型场景是 AI token 流、构建日志、任务进度、通知流。
- 希望复用普通 HTTP 基础设施,减少长连接状态管理复杂度。
3. ECharts 核心知识体系
3.1 ECharts 的心智模型
ECharts 不是“手动绘图 API”,而是“声明式图表配置系统”。你主要写 option,ECharts 根据配置生成图表。
核心对象:
echarts:全局入口,负责init、use、主题注册等。echartsInstance:通过echarts.init(dom)得到的图表实例。option:图表配置对象,包含标题、坐标轴、数据集、系列、交互组件。series:决定图表类型和每组数据的渲染方式。dataset:把数据源与图表映射解耦,适合复杂数据表。component:tooltip、legend、grid、dataZoom、visualMap、toolbox、brush 等。
最小流程:
js
import * as echarts from 'echarts';
const el = document.querySelector('#chart');
const chart = echarts.init(el, null, { renderer: 'canvas' });
chart.setOption({
title: { text: '月度销售额' },
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: ['1月', '2月', '3月'] },
yAxis: { type: 'value' },
series: [
{ name: '销售额', type: 'bar', data: [120, 200, 150] }
]
});
window.addEventListener('resize', () => chart.resize());3.2 安装与按需引入
完整引入适合学习和内部工具:
bash
npm install echartsjs
import * as echarts from 'echarts';按需引入适合生产项目控制体积:
js
import * as echarts from 'echarts/core';
import { BarChart, LineChart } from 'echarts/charts';
import {
DatasetComponent,
GridComponent,
LegendComponent,
TooltipComponent,
TransformComponent
} from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
echarts.use([
BarChart,
LineChart,
DatasetComponent,
GridComponent,
LegendComponent,
TooltipComponent,
TransformComponent,
CanvasRenderer
]);按需引入时要记住:图表类型、组件、渲染器都要注册。只注册 BarChart 而不注册 GridComponent、CanvasRenderer,常见结果就是图表不显示或功能缺失。
3.3 容器、初始化与生命周期
ECharts 初始化依赖真实 DOM 容器尺寸。常见规则:
- 容器必须有明确宽高,例如
height: 320px或由父级布局撑开。 - 在
display: none的容器里初始化,可能拿不到正确尺寸;应在显示后初始化或调用resize()。 - 页面销毁、路由离开、组件卸载时调用
chart.dispose(),避免内存泄漏。 - 不要每次数据变化都重新
init,应复用实例并调用setOption()。
基础封装:
js
function mountChart(el, option) {
const chart = echarts.init(el, null, {
renderer: 'canvas',
useDirtyRect: true
});
chart.setOption(option);
const resizeObserver = new ResizeObserver(() => chart.resize());
resizeObserver.observe(el);
return () => {
resizeObserver.disconnect();
chart.dispose();
};
}3.4 option 的结构
一个可维护的 option 通常分为 6 层:
js
const option = {
title: { text: '订单趋势' },
color: ['#2563eb', '#16a34a', '#f97316'],
tooltip: { trigger: 'axis' },
legend: { top: 0 },
grid: { left: 40, right: 24, top: 48, bottom: 32 },
dataset: {
source: [
['date', '订单数', '成交额'],
['周一', 120, 3200],
['周二', 180, 4600],
['周三', 150, 3900]
]
},
xAxis: { type: 'category' },
yAxis: [
{ type: 'value', name: '订单数' },
{ type: 'value', name: '成交额' }
],
series: [
{ type: 'bar', name: '订单数' },
{ type: 'line', name: '成交额', yAxisIndex: 1, smooth: true }
]
};关键理解:
xAxis/yAxis定义坐标系。series.type决定图表类型。dataset.source保存表格型数据。series.encode可以显式指定字段到 x、y、tooltip 的映射。
3.5 dataset、encode 与 transform
不推荐在复杂项目里到处拼 series.data。更稳的方式是用 dataset 管理数据源,再用 encode 映射字段。
js
const option = {
dataset: {
source: [
{ product: '手机', sales: 430, profit: 120 },
{ product: '电脑', sales: 520, profit: 210 },
{ product: '耳机', sales: 260, profit: 80 }
]
},
xAxis: { type: 'category' },
yAxis: {},
series: [
{
type: 'bar',
encode: {
x: 'product',
y: 'sales',
tooltip: ['sales', 'profit']
}
}
]
};transform 适合把过滤、排序、聚合等数据处理放到图表配置层:
js
const option = {
dataset: [
{
id: 'raw',
source: [
['product', 'sales'],
['手机', 430],
['电脑', 520],
['耳机', 260]
]
},
{
id: 'sorted',
fromDatasetId: 'raw',
transform: {
type: 'sort',
config: { dimension: 'sales', order: 'desc' }
}
}
],
xAxis: { type: 'category' },
yAxis: {},
series: [{ type: 'bar', datasetId: 'sorted' }]
};3.6 动态数据与增量更新
异步加载:
js
chart.showLoading();
try {
const data = await fetch('/api/sales').then((res) => res.json());
chart.setOption({
dataset: { source: data }
});
} finally {
chart.hideLoading();
}实时更新:
js
const points = [];
function pushPoint(point) {
points.push(point);
if (points.length > 200) points.shift();
chart.setOption(
{
dataset: { source: points }
},
{
notMerge: false,
lazyUpdate: true
}
);
}更新策略:
- 普通数据变化:
chart.setOption(nextOption)。 - 系列结构变化很大:考虑
replaceMerge: ['series']。 - 完全重置图表:使用
notMerge: true,但不要滥用。 - 高频实时数据:用
requestAnimationFrame或节流,把多条消息合并成一次setOption()。
3.7 事件与 action
监听点击:
js
chart.on('click', (params) => {
console.log(params.seriesName, params.name, params.value);
});监听图例变化:
js
chart.on('legendselectchanged', (params) => {
console.log(params.selected);
});主动触发行为:
js
chart.dispatchAction({
type: 'highlight',
seriesIndex: 0,
dataIndex: 2
});常见 action:
highlight/downplay:高亮或取消高亮。showTip/hideTip:显示或隐藏 tooltip。legendSelect/legendUnSelect:控制图例选择。dataZoom:控制缩放窗口。
3.8 交互组件
| 组件 | 用途 |
|---|---|
tooltip | 悬浮提示,支持 axis/item 触发 |
legend | 系列开关、图例展示 |
dataZoom | 缩放和拖动数据窗口 |
visualMap | 用颜色、大小等视觉变量表达数值 |
brush | 框选数据点 |
toolbox | 保存图片、数据视图、还原、缩放等工具 |
timeline | 时间轴,适合多阶段数据 |
3.9 样式、主题与可访问性
基础样式:
color:全局色板。backgroundColor:图表背景。textStyle:全局文字样式。itemStyle:图形样式。lineStyle:线条样式。areaStyle:面积图填充。label:数据标签。
暗色主题:
js
const chart = echarts.init(el, 'dark');ARIA 与 decal:
js
const option = {
aria: {
enabled: true,
decal: { show: true }
},
series: [
{ type: 'pie', data: [{ name: 'A', value: 40 }, { name: 'B', value: 60 }] }
]
};可访问性要点:
- 颜色不要作为唯一信息载体,可用
aria.decal增加纹理区分。 - 图表旁边保留关键指标文本,方便读屏和复制。
- tooltip 中的重要信息不要只靠鼠标 hover 才能看到。
3.10 Canvas 渲染器与 SVG 渲染器
ECharts 支持 Canvas 与 SVG 渲染器。
优先 Canvas:
- 数据点多。
- 动画多。
- 图形复杂。
- 常见业务大屏、监控看板、实时曲线。
优先 SVG:
- 图形数量较少。
- 需要更清晰的矢量缩放。
- 页面需要更好的 DOM 可检查性。
- 对低端设备内存更敏感的简单图表。
js
echarts.init(el, null, { renderer: 'svg' });3.11 ECharts 性能优化
常用优化手段:
- 固定容器尺寸,避免频繁布局抖动。
- 复用实例,不重复
init。 - 高频数据先缓冲,再按帧或按固定间隔
setOption。 - 大数据折线启用
sampling降采样。 - 散点或折线大数据场景关注
large、progressive等配置。 - 关闭不必要动画:
animation: false。 - tooltip formatter 不要做重计算。
- 不可信数据不要直接拼成 HTML tooltip。
示例:
js
const option = {
animation: false,
series: [
{
type: 'line',
sampling: 'lttb',
showSymbol: false,
data: largeData
}
]
};3.12 setOption 的合并语义(内部机制)
setOption 默认是「合并」而不是「整体替换」,这是最容易踩坑的机制之一。
js
chart.setOption(nextOption, {
notMerge: false, // 默认 false:与旧 option 合并
lazyUpdate: false, // true:延迟到下一次渲染帧统一更新
silent: false, // true:不触发事件
replaceMerge: [], // 指定要「整体替换」的组件/系列,如 ['series']
})关键规则:
- 普通字段(如
tooltip、title)新值覆盖旧值。 - 数组(
series、xAxis等)按索引逐项合并:第 0 项合并到第 0 项,第 1 项合并到第 1 项。 - 所以「从 3 条折线改成 1 条折线」时,如果只传 1 条 series,旧的第 2、3 条会残留。要用
replaceMerge: ['series']整体替换 series。 notMerge: true完全丢弃旧 option,重新构建整个图表——简单粗暴,但会丢失一些交互状态,别滥用。
实践建议:
- 数据变了:直接
setOption(next)让 series.data 合并即可。 - 系列数量/类型变了:
setOption(next, { replaceMerge: ['series'] })。 - 彻底切换图表:
notMerge: true或干脆dispose后重新init。
3.13 地图与 registerMap
ECharts 的地图(map 系列或 geo 组件)本身不内置 GeoJSON 数据,需要你注册地图边界:
js
import * as echarts from 'echarts';
import chinaGeoJson from './china.json'; // 需要自己准备 GeoJSON
echarts.registerMap('china', chinaGeoJson);js
chart.setOption({
geo: { map: 'china', roam: true }, // roam: 允许缩放/拖动
series: [
{
type: 'map',
map: 'china',
data: [{ name: '广东', value: 120 }, { name: '江苏', value: 90 }],
},
],
});注意点:
- GeoJSON 不会随
echarts主包一起发布(老版本曾内置中国地图,现已移除),要自行获取并注意数据源的合规性。 geo组件只是「底图」,map系列才把数据映射到区域颜色。- 地图的
name必须和 GeoJSON 里的区域名严格一致,否则数据对不上。
3.14 graphic 组件:在图表上叠加自定义图形
graphic 让你不离开 ECharts 就能在图表上画水印、角标、高亮区域、自定义形状:
js
chart.setOption({
graphic: [
{
type: 'text',
left: 'center',
top: 12,
style: { text: '内部数据', fill: 'rgba(0,0,0,0.15)', fontSize: 20 },
},
{
type: 'rect',
left: '20%',
right: '30%',
top: 40,
bottom: 80,
style: { fill: 'rgba(37, 99, 235, 0.08)' },
},
],
});- 支持
text、rect、circle、line、group、image等类型。 - 相比 Canvas 手画,它跟随 ECharts 的坐标系/布局一起管理,省去手动定位。
- 适合轻量叠加;真正的复杂逐帧动画仍建议交给 Canvas 层。
4. ECharts 在不同框架中的实现方式
4.1 原生 JavaScript
html
<div id="chart" style="height: 360px"></div>
<script type="module">
import * as echarts from 'echarts';
const el = document.querySelector('#chart');
const chart = echarts.init(el);
chart.setOption({
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: ['Mon', 'Tue', 'Wed'] },
yAxis: { type: 'value' },
series: [{ type: 'line', data: [120, 200, 150] }]
});
const ro = new ResizeObserver(() => chart.resize());
ro.observe(el);
window.addEventListener('beforeunload', () => {
ro.disconnect();
chart.dispose();
});
</script>4.2 Vue 3:使用 vue-echarts
适合大多数 Vue 3 项目。组件会帮你处理不少生命周期细节。
vue
<script setup>
import { computed } from 'vue';
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { LineChart } from 'echarts/charts';
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components';
import VChart from 'vue-echarts';
use([CanvasRenderer, LineChart, GridComponent, TooltipComponent, LegendComponent]);
const props = defineProps({
rows: {
type: Array,
default: () => []
}
});
const option = computed(() => ({
tooltip: { trigger: 'axis' },
legend: {},
xAxis: { type: 'category' },
yAxis: { type: 'value' },
dataset: { source: props.rows },
series: [{ type: 'line', encode: { x: 'date', y: 'value' } }]
}));
</script>
<template>
<VChart class="chart" :option="option" autoresize />
</template>
<style scoped>
.chart {
width: 100%;
height: 360px;
}
</style>4.3 Vue 3:手动封装组件
适合需要精细控制实例、事件、WebSocket 实时更新的场景。
vue
<script setup>
import { onMounted, onUnmounted, ref, watch } from 'vue';
import * as echarts from 'echarts';
const props = defineProps({
option: {
type: Object,
required: true
}
});
const chartEl = ref(null);
let chart;
let ro;
onMounted(() => {
chart = echarts.init(chartEl.value, null, { renderer: 'canvas', useDirtyRect: true });
chart.setOption(props.option);
ro = new ResizeObserver(() => chart.resize());
ro.observe(chartEl.value);
});
watch(
() => props.option,
(next) => {
chart?.setOption(next, { lazyUpdate: true });
},
{ deep: true }
);
onUnmounted(() => {
ro?.disconnect();
chart?.dispose();
});
</script>
<template>
<div ref="chartEl" class="chart"></div>
</template>
<style scoped>
.chart {
width: 100%;
height: 360px;
}
</style>4.4 Nuxt
Nuxt 有服务端渲染,ECharts 依赖浏览器 DOM,应放到客户端组件里,或使用 <ClientOnly>。
vue
<template>
<ClientOnly>
<SalesChart :rows="rows" />
</ClientOnly>
</template>实践要点:
- 图表组件文件中避免在顶层直接访问
window、document。 - 初始化放进
onMounted。 - 如果封装库内部访问浏览器 API,使用
.client.vue或<ClientOnly>包裹。
4.5 React:使用 echarts-for-react
tsx
import ReactECharts from 'echarts-for-react';
export function SalesChart({ rows }: { rows: Array<Record<string, unknown>> }) {
const option = {
tooltip: { trigger: 'axis' },
dataset: { source: rows },
xAxis: { type: 'category' },
yAxis: { type: 'value' },
series: [{ type: 'bar', encode: { x: 'date', y: 'value' } }]
};
return (
<ReactECharts
option={option}
notMerge={false}
lazyUpdate
style={{ width: '100%', height: 360 }}
/>
);
}4.6 React:手动封装 Hook
tsx
import { useEffect, useRef } from 'react';
import * as echarts from 'echarts';
export function ManualChart({ option }: { option: echarts.EChartsOption }) {
const elRef = useRef<HTMLDivElement | null>(null);
const chartRef = useRef<echarts.ECharts | null>(null);
useEffect(() => {
if (!elRef.current) return;
const chart = echarts.init(elRef.current, null, { useDirtyRect: true });
chartRef.current = chart;
const ro = new ResizeObserver(() => chart.resize());
ro.observe(elRef.current);
return () => {
ro.disconnect();
chart.dispose();
chartRef.current = null;
};
}, []);
useEffect(() => {
chartRef.current?.setOption(option, { lazyUpdate: true });
}, [option]);
return <div ref={elRef} style={{ width: '100%', height: 360 }} />;
}React 要点:
- ECharts 是外部系统,初始化和销毁放在
useEffect的 setup/cleanup。 - React Strict Mode 开发环境可能执行一次额外 setup/cleanup,清理逻辑必须完整。
- 需要布局前测量时,可用
useLayoutEffect,但普通图表通常useEffect足够。
4.7 Next.js
Next.js App Router 默认偏服务端。需要浏览器 API 的图表组件应声明为客户端组件。
tsx
'use client';
import ReactECharts from 'echarts-for-react';
export function ClientChart({ option }: { option: Record<string, unknown> }) {
return <ReactECharts option={option} style={{ height: 360 }} />;
}页面中使用:
tsx
import { ClientChart } from './ClientChart';
export default async function Page() {
const rows = await getRows();
const option = buildOption(rows);
return <ClientChart option={option} />;
}4.8 Angular:使用 ngx-echarts
ts
import { ApplicationConfig } from '@angular/core';
import { provideEchartsCore } from 'ngx-echarts';
import * as echarts from 'echarts/core';
import { BarChart } from 'echarts/charts';
import { GridComponent, TooltipComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
echarts.use([BarChart, GridComponent, TooltipComponent, CanvasRenderer]);
export const appConfig: ApplicationConfig = {
providers: [provideEchartsCore({ echarts })]
};ts
import { Component } from '@angular/core';
import { NgxEchartsDirective } from 'ngx-echarts';
@Component({
selector: 'app-sales-chart',
standalone: true,
imports: [NgxEchartsDirective],
template: `<div echarts [options]="option" class="chart"></div>`,
styles: [`.chart { width: 100%; height: 360px; }`]
})
export class SalesChartComponent {
option = {
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: ['Mon', 'Tue', 'Wed'] },
yAxis: { type: 'value' },
series: [{ type: 'bar', data: [120, 200, 150] }]
};
}4.9 Svelte:手动集成
svelte
<script>
import { onMount } from 'svelte';
import * as echarts from 'echarts';
export let option;
let el;
let chart;
let ro;
onMount(() => {
chart = echarts.init(el);
chart.setOption(option);
ro = new ResizeObserver(() => chart.resize());
ro.observe(el);
return () => {
ro.disconnect();
chart.dispose();
};
});
$: if (chart && option) {
chart.setOption(option, { lazyUpdate: true });
}
</script>
<div bind:this={el} class="chart"></div>
<style>
.chart {
width: 100%;
height: 360px;
}
</style>5. Canvas 核心知识体系
5.1 Canvas 的心智模型
Canvas 是一块位图画布。你通过 JavaScript 调用绘图命令,把像素画到画布上。
与 DOM/SVG 的关键差异:
| 维度 | Canvas | DOM/SVG |
|---|---|---|
| 渲染模型 | 命令式、立即绘制、结果是像素 | 声明式、保留节点 |
| 图形对象 | 画完后不再保留对象 | 每个元素仍在 DOM/SVG 树中 |
| 事件 | 只能监听 canvas 整体,再自行命中检测 | 元素天然有事件 |
| 性能 | 大量图形、频繁刷新更有优势 | 少量可交互图形更容易维护 |
| 可访问性 | 需要额外文本和 ARIA 辅助 | DOM 文本更天然 |
5.2 2D 上下文
js
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');常用 API 分类:
| 类别 | 常用方法/属性 |
|---|---|
| 矩形 | fillRect、strokeRect、clearRect |
| 路径 | beginPath、moveTo、lineTo、arc、rect、closePath、fill、stroke |
| 文本 | font、fillText、strokeText、measureText |
| 图片 | drawImage |
| 变换 | translate、rotate、scale、transform、setTransform |
| 状态 | save、restore |
| 像素 | getImageData、putImageData、createImageData |
| 样式 | fillStyle、strokeStyle、lineWidth、globalAlpha、shadowBlur |
5.3 坐标系与 HiDPI
Canvas 有两个尺寸:
- CSS 尺寸:页面上显示多大。
- 位图尺寸:内部像素缓冲区多大。
在 Retina/高分屏上,直接用 CSS 尺寸会模糊。推荐按 devicePixelRatio 缩放。
js
function setupCanvas(canvas) {
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(rect.width * dpr);
canvas.height = Math.round(rect.height * dpr);
const ctx = canvas.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
return { ctx, width: rect.width, height: rect.height, dpr };
}5.4 基础绘制
js
function drawDemo(ctx) {
ctx.clearRect(0, 0, 400, 240);
ctx.fillStyle = '#eff6ff';
ctx.fillRect(0, 0, 400, 240);
ctx.strokeStyle = '#2563eb';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(40, 180);
ctx.lineTo(120, 100);
ctx.lineTo(200, 140);
ctx.lineTo(320, 60);
ctx.stroke();
ctx.fillStyle = '#111827';
ctx.font = '14px sans-serif';
ctx.fillText('Canvas 折线示意', 40, 32);
}5.5 动画循环
Canvas 动画通常是“清屏 -> 更新状态 -> 重绘”。
js
let rafId = 0;
let lastTime = performance.now();
function frame(now) {
const delta = Math.min(32, now - lastTime);
lastTime = now;
update(delta);
draw();
rafId = requestAnimationFrame(frame);
}
rafId = requestAnimationFrame(frame);
function stop() {
cancelAnimationFrame(rafId);
}要点:
- 用
requestAnimationFrame跟随浏览器刷新节奏。 - 用
delta做时间步进,不要假设每帧都是 16.67ms。 - 页面隐藏、组件卸载时取消动画。
5.6 鼠标与触摸坐标转换
事件里的 clientX/clientY 是视口坐标,需要换算到 canvas CSS 坐标。
js
function getCanvasPoint(canvas, event) {
const rect = canvas.getBoundingClientRect();
return {
x: event.clientX - rect.left,
y: event.clientY - rect.top
};
}如果绘制时使用了坐标变换,还要反向换算到业务坐标。
5.7 命中检测
Canvas 不保留图形对象,点击哪个圆、哪条线,需要自己判断。
js
function hitCircle(point, circle) {
const dx = point.x - circle.x;
const dy = point.y - circle.y;
return Math.hypot(dx, dy) <= circle.r;
}复杂场景可以维护一份场景数据:
js
const shapes = [
{ id: 'a', type: 'circle', x: 80, y: 120, r: 20 },
{ id: 'b', type: 'rect', x: 140, y: 90, width: 80, height: 40 }
];5.8 图片与跨域
Canvas 可以绘制图片:
js
const image = new Image();
image.crossOrigin = 'anonymous';
image.src = 'https://example.com/photo.png';
image.onload = () => {
ctx.drawImage(image, 0, 0, 300, 200);
};安全要点:
- 绘制跨域图片后,如果图片响应没有允许跨域,canvas 会变成 tainted canvas。
- tainted canvas 不能再调用
toDataURL()、toBlob()、getImageData()读取像素。 - 做头像裁剪、图片编辑、截图导出时,必须提前设计 CORS。
5.9 OffscreenCanvas 与 Worker
OffscreenCanvas 允许在 Worker 中渲染,适合重绘压力大的场景。
主线程:
js
const canvas = document.querySelector('canvas');
const offscreen = canvas.transferControlToOffscreen();
const worker = new Worker('/canvas-worker.js', { type: 'module' });
worker.postMessage({ canvas: offscreen }, [offscreen]);Worker:
js
self.onmessage = (event) => {
const { canvas } = event.data;
const ctx = canvas.getContext('2d');
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#2563eb';
ctx.fillRect(20, 20, 120, 80);
requestAnimationFrame(draw);
}
draw();
};注意:不是所有 Canvas 相关能力在 Worker 中都和主线程完全一致,落地前要核对浏览器兼容性。
5.10 Canvas 性能优化
常用优化:
- 避免每帧创建大量对象。
- 静态背景画到离屏 canvas,动态层单独刷新。
- 图片先转成
ImageBitmap再绘制。 - 高频事件用节流或只记录状态,绘制统一放到动画帧。
- 大量路径尽量批量绘制,减少频繁切换样式。
- 只重绘脏区域,但复杂度高,适合明确的局部变化场景。
- 文本绘制相对昂贵,动态文本数量大时要缓存。
- 像素级处理要小心
getImageData,它会带来较大开销。
5.11 isPointInPath / isPointInStroke 与 Path2D
5.7 里手写 hitCircle 只是最简单的情况。Canvas 原生提供了命中检测 API,能省很多几何计算:
js
ctx.beginPath();
ctx.arc(circle.x, circle.y, circle.r, 0, Math.PI * 2);
if (ctx.isPointInPath(point.x, point.y)) {
// 点击在圆内
}isPointInPath(x, y):判断点是否在当前路径内部。isPointInStroke(x, y):判断点是否在路径描边上(适合命中线条)。Path2D:把路径保存成对象,可重复检测、可复用绘制:
js
const circlePath = new Path2D();
circlePath.arc(100, 100, 40, 0, Math.PI * 2);
ctx.fill(circlePath);
ctx.isPointInPath(circlePath, x, y); // 传入 Path2D 对象💡 思路:维护一份
Path2D[](或形状元数据 + Path2D)作为「场景模型」,绘制和命中检测共用,避免几何逻辑写两遍。
5.12 渐变与合成模式
填充不只有纯色,还有渐变和图案:
js
// 线性渐变
const lg = ctx.createLinearGradient(0, 0, 200, 0);
lg.addColorStop(0, '#2563eb');
lg.addColorStop(1, '#16a34a');
ctx.fillStyle = lg;
// 径向渐变
const rg = ctx.createRadialGradient(100, 100, 10, 100, 100, 80);
rg.addColorStop(0, '#ffffff');
rg.addColorStop(1, '#2563eb');
// 图案填充(用图片/另一个 canvas 作为纹理)
const pattern = ctx.createPattern(image, 'repeat');
ctx.fillStyle = pattern;合成模式 globalCompositeOperation 控制「新画的像素如何与已有像素叠加」:
| 值 | 效果 | 常用场景 |
|---|---|---|
source-over | 新图覆盖在旧图上(默认) | 常规绘制 |
destination-over | 新图画在旧图下方 | 背景、光环 |
destination-out | 用新图「擦除」旧图 | 橡皮擦、蒙版 |
lighter | 颜色相加变亮 | 光效、粒子发光 |
5.13 快照与导出(toDataURL / toBlob)
把画布内容导出成图片是高频需求:
js
// 导出为 PNG 的 Data URL
const dataUrl = canvas.toDataURL('image/png');
// 导出为 Blob(更适合上传、下载)
canvas.toBlob((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'chart.png';
a.click();
URL.revokeObjectURL(url);
}, 'image/png');toDataURL/toBlob是获取像素的操作,也是性能敏感操作,别每帧调用。- 两者都会触发前面 5.8 提到的 tainted canvas 限制:画了跨域图片又没配 CORS,导出会直接抛错。
- 高分屏导出时要先在代码里把位图尺寸按
devicePixelRatio放大,否则导出的图很糊。
6. Canvas 在不同框架中的实现方式
6.1 原生 Canvas 小图表
html
<canvas id="chart" style="width: 100%; height: 260px"></canvas>
<script>
const canvas = document.querySelector('#chart');
function render() {
const { ctx, width, height } = setupCanvas(canvas);
const values = [120, 200, 150, 280, 240];
const max = Math.max(...values);
const gap = 16;
const barWidth = (width - gap * (values.length + 1)) / values.length;
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = '#f8fafc';
ctx.fillRect(0, 0, width, height);
values.forEach((value, index) => {
const barHeight = (value / max) * (height - 48);
const x = gap + index * (barWidth + gap);
const y = height - barHeight - 24;
ctx.fillStyle = '#2563eb';
ctx.fillRect(x, y, barWidth, barHeight);
ctx.fillStyle = '#111827';
ctx.font = '12px sans-serif';
ctx.fillText(String(value), x, y - 8);
});
}
function setupCanvas(canvas) {
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(rect.width * dpr);
canvas.height = Math.round(rect.height * dpr);
const ctx = canvas.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
return { ctx, width: rect.width, height: rect.height };
}
render();
new ResizeObserver(render).observe(canvas);
</script>6.2 React Canvas 组件
tsx
import { useEffect, useRef } from 'react';
type Point = { x: number; y: number };
export function CanvasLineChart({ points }: { points: Point[] }) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const render = () => {
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(rect.width * dpr);
canvas.height = Math.round(rect.height * dpr);
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, rect.width, rect.height);
ctx.strokeStyle = '#2563eb';
ctx.lineWidth = 2;
ctx.beginPath();
points.forEach((point, index) => {
if (index === 0) ctx.moveTo(point.x, point.y);
else ctx.lineTo(point.x, point.y);
});
ctx.stroke();
};
render();
const ro = new ResizeObserver(render);
ro.observe(canvas);
return () => ro.disconnect();
}, [points]);
return <canvas ref={canvasRef} style={{ width: '100%', height: 260 }} />;
}6.3 Vue 3 Canvas 组件
vue
<script setup>
import { onMounted, onUnmounted, ref, watch } from 'vue';
const props = defineProps({
points: {
type: Array,
default: () => []
}
});
const canvasRef = ref(null);
let ro;
function render() {
const canvas = canvasRef.value;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(rect.width * dpr);
canvas.height = Math.round(rect.height * dpr);
const ctx = canvas.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, rect.width, rect.height);
ctx.strokeStyle = '#16a34a';
ctx.lineWidth = 2;
ctx.beginPath();
props.points.forEach((point, index) => {
if (index === 0) ctx.moveTo(point.x, point.y);
else ctx.lineTo(point.x, point.y);
});
ctx.stroke();
}
onMounted(() => {
render();
ro = new ResizeObserver(render);
ro.observe(canvasRef.value);
});
watch(() => props.points, render, { deep: true });
onUnmounted(() => {
ro?.disconnect();
});
</script>
<template>
<canvas ref="canvasRef" class="canvas"></canvas>
</template>
<style scoped>
.canvas {
width: 100%;
height: 260px;
}
</style>6.4 Angular Canvas 组件
ts
import { AfterViewInit, Component, ElementRef, Input, OnChanges, OnDestroy, ViewChild } from '@angular/core';
@Component({
selector: 'app-canvas-line',
standalone: true,
template: `<canvas #canvas class="canvas"></canvas>`,
styles: [`.canvas { width: 100%; height: 260px; display: block; }`]
})
export class CanvasLineComponent implements AfterViewInit, OnChanges, OnDestroy {
@ViewChild('canvas') canvasRef!: ElementRef<HTMLCanvasElement>;
@Input() points: Array<{ x: number; y: number }> = [];
private ro?: ResizeObserver;
ngAfterViewInit() {
this.render();
this.ro = new ResizeObserver(() => this.render());
this.ro.observe(this.canvasRef.nativeElement);
}
ngOnChanges() {
this.render();
}
ngOnDestroy() {
this.ro?.disconnect();
}
private render() {
const canvas = this.canvasRef?.nativeElement;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(rect.width * dpr);
canvas.height = Math.round(rect.height * dpr);
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, rect.width, rect.height);
ctx.strokeStyle = '#f97316';
ctx.beginPath();
this.points.forEach((point, index) => {
if (index === 0) ctx.moveTo(point.x, point.y);
else ctx.lineTo(point.x, point.y);
});
ctx.stroke();
}
}6.5 Svelte Canvas 组件
svelte
<script>
import { onMount } from 'svelte';
export let points = [];
let canvas;
let ro;
function render() {
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(rect.width * dpr);
canvas.height = Math.round(rect.height * dpr);
const ctx = canvas.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, rect.width, rect.height);
ctx.strokeStyle = '#7c3aed';
ctx.beginPath();
points.forEach((point, index) => {
if (index === 0) ctx.moveTo(point.x, point.y);
else ctx.lineTo(point.x, point.y);
});
ctx.stroke();
}
onMount(() => {
render();
ro = new ResizeObserver(render);
ro.observe(canvas);
return () => ro.disconnect();
});
$: render();
</script>
<canvas bind:this={canvas} class="canvas"></canvas>
<style>
.canvas {
width: 100%;
height: 260px;
display: block;
}
</style>7. WebSocket 核心知识体系
7.1 WebSocket 的心智模型
WebSocket 在浏览器中通过一次 HTTP Upgrade 握手建立长连接,建立后客户端和服务端可以双向发送消息。
浏览器端最小示例:
js
const socket = new WebSocket('wss://example.com/ws');
socket.addEventListener('open', () => {
socket.send(JSON.stringify({ type: 'hello' }));
});
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data);
console.log(message);
});
socket.addEventListener('close', (event) => {
console.log(event.code, event.reason, event.wasClean);
});
socket.addEventListener('error', () => {
console.log('WebSocket error');
});7.2 readyState
| 状态 | 数值 | 含义 |
|---|---|---|
CONNECTING | 0 | 正在连接 |
OPEN | 1 | 已连接,可以发送 |
CLOSING | 2 | 正在关闭 |
CLOSED | 3 | 已关闭或连接失败 |
发送前要判断:
js
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(payload));
}7.3 消息协议设计
不要直接裸发各种字符串。推荐统一消息信封:
ts
type WsMessage<T = unknown> = {
type: string;
id?: string;
room?: string;
payload?: T;
timestamp: number;
};示例:
json
{
"type": "metric:update",
"id": "msg_001",
"room": "dashboard:main",
"payload": {
"name": "cpu",
"value": 72.4
},
"timestamp": 1783339200000
}这样做的好处:
- 前后端可以按
type路由。 id支持 ack、去重、排查。room支持多租户或频道。timestamp支持延迟分析和乱序处理。
7.4 心跳、重连与背压
浏览器 WebSocket API 不暴露协议层 ping/pong 方法,因此前端通常做应用层心跳:
js
socket.send(JSON.stringify({ type: 'ping', timestamp: Date.now() }));服务端可以使用协议层 ping/pong,例如 Node ws 支持 ping()。
重连要点:
- 只对非主动关闭进行重连。
- 使用指数退避,避免服务恢复时所有客户端同时重连。
- 设置最大重连间隔。
- 重新连接后恢复订阅,例如重新加入 room。
- 对关键消息设计 ack 和补偿查询。
背压要点:
- 浏览器可通过
socket.bufferedAmount观察待发送字节数。 - 如果
bufferedAmount持续升高,说明发送速度超过网络/服务端处理速度。 - 对非关键消息丢弃或合并,对关键消息排队并限长。
7.5 一个可复用的浏览器客户端
ts
type WsClientOptions = {
url: string;
heartbeatMs?: number;
maxReconnectMs?: number;
onMessage?: (message: unknown) => void;
};
export class WsClient {
private socket: WebSocket | null = null;
private reconnectAttempt = 0;
private heartbeatTimer = 0;
private closedByUser = false;
constructor(private options: WsClientOptions) {}
connect() {
this.closedByUser = false;
this.socket = new WebSocket(this.options.url);
this.socket.addEventListener('open', () => {
this.reconnectAttempt = 0;
this.startHeartbeat();
});
this.socket.addEventListener('message', (event) => {
const data = safeJsonParse(event.data);
this.options.onMessage?.(data);
});
this.socket.addEventListener('close', () => {
this.stopHeartbeat();
if (!this.closedByUser) this.scheduleReconnect();
});
}
send(message: unknown) {
if (this.socket?.readyState !== WebSocket.OPEN) return false;
if (this.socket.bufferedAmount > 1024 * 1024) {
return false;
}
this.socket.send(JSON.stringify(message));
return true;
}
close() {
this.closedByUser = true;
this.stopHeartbeat();
this.socket?.close(1000, 'client closed');
}
private startHeartbeat() {
const interval = this.options.heartbeatMs ?? 30000;
this.heartbeatTimer = window.setInterval(() => {
this.send({ type: 'ping', timestamp: Date.now() });
}, interval);
}
private stopHeartbeat() {
window.clearInterval(this.heartbeatTimer);
}
private scheduleReconnect() {
const max = this.options.maxReconnectMs ?? 30000;
const delay = Math.min(max, 1000 * 2 ** this.reconnectAttempt);
this.reconnectAttempt += 1;
window.setTimeout(() => this.connect(), delay);
}
}
function safeJsonParse(value: string) {
try {
return JSON.parse(value);
} catch {
return value;
}
}7.6 二进制消息
浏览器 WebSocket 支持文本和二进制。二进制可用 Blob 或 ArrayBuffer。
js
socket.binaryType = 'arraybuffer';
socket.addEventListener('message', (event) => {
if (event.data instanceof ArrayBuffer) {
const view = new DataView(event.data);
console.log(view.getFloat32(0));
}
});适合二进制的场景:
- 音视频片段。
- 游戏状态压缩。
- 高频行情数据。
- IoT 设备数据。
7.7 WebSocket 安全要点
必须做:
- 生产环境使用
wss://。 - 服务端校验
Origin。 - 鉴权:Cookie、短期 token、首次消息鉴权,避免长期 token 暴露在 URL 日志里。
- 限制消息大小,例如 Node
ws的maxPayload。 - 对消息 JSON 做 schema 校验。
- 做连接数、消息频率、房间订阅数限制。
- 心跳超时后关闭连接。
- 不要相信客户端传来的 userId、role、room 权限。
常见误区:
- 以为 WebSocket 自动遵循浏览器 CORS。浏览器握手会带
Origin,但服务端必须自己校验。 - 以为连接成功就等于鉴权成功。握手、登录态、业务授权是三件事。
- 以为断线重连后状态还在。重连后必须重新订阅或重新同步状态。
7.8 WebSocket 握手与 HTTP Upgrade(内部机制)
WebSocket 建立连接是一次「协议升级」,不是普通的 HTTP 请求:
- 客户端先发一个 HTTP 请求,带特殊头:
http
GET /ws HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: <base64 编码的 16 字节随机数>
Sec-WebSocket-Version: 13- 服务端同意后返回
101 Switching Protocols,并回一个Sec-WebSocket-Accept(由 key + 固定 GUID 经 SHA-1 + base64 算得),完成握手。 - 之后这条 TCP 连接从 HTTP 切换成 WebSocket 帧协议,双向收发二进制帧,不再走 HTTP 的请求/响应模型。
两个协议细节:
ws://走明文 TCP,wss://是ws + TLS(相当于 HTTPS),生产环境必须用wss://。- 浏览器会自动带
Origin头,但服务端必须自己校验,WebSocket 不受浏览器 CORS 策略保护。
💡 理解「握手是 HTTP、之后是帧协议」这一点,能帮你理解为什么:WebSocket 需要网关/负载均衡专门支持 Upgrade,且无法像普通 HTTP 那样被简单缓存或复用。
7.9 关闭码(close code)速查表
close 事件的 event.code 能告诉你连接是正常关闭还是异常:
| code | 含义 | 说明 |
|---|---|---|
| 1000 | 正常关闭 | 主动、干净地关闭 |
| 1001 | 离开 | 页面导航离开、服务端重启 |
| 1006 | 异常关闭 | 没有收到关闭帧(网络断了、进程崩溃),最常见的「非正常」码 |
| 1008 | 策略违规 | 违反服务端规则(如未鉴权、Origin 不合法) |
| 1009 | 消息过大 | 超过服务端 maxPayload 限制 |
| 1011 | 服务端内部错误 | 服务端异常 |
| 1012 | 服务重启 | 服务端正在重启 |
前端重连判断的常见做法:只有 1006(异常)或根本没收到关闭帧才自动重连,1000/1008 这类主动/策略关闭不要盲目重连(否则会无限重连打爆服务端)。
8. WebSocket 在不同框架与服务端中的实现方式
8.1 Node.js:使用 ws 搭建服务端
bash
npm install wsjs
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({
port: 8080,
maxPayload: 1024 * 64
});
wss.on('connection', (socket, request) => {
const origin = request.headers.origin;
if (!isAllowedOrigin(origin)) {
socket.close(1008, 'invalid origin');
return;
}
socket.isAlive = true;
socket.on('pong', () => {
socket.isAlive = true;
});
socket.on('message', (raw) => {
const message = safeJsonParse(raw.toString());
if (message.type === 'subscribe') {
socket.room = message.room;
socket.send(JSON.stringify({ type: 'subscribed', room: message.room }));
return;
}
broadcast({
type: 'chat:message',
payload: message.payload,
timestamp: Date.now()
});
});
});
const heartbeat = setInterval(() => {
for (const socket of wss.clients) {
if (socket.isAlive === false) {
socket.terminate();
continue;
}
socket.isAlive = false;
socket.ping();
}
}, 30000);
wss.on('close', () => {
clearInterval(heartbeat);
});
function broadcast(message) {
const text = JSON.stringify(message);
for (const client of wss.clients) {
if (client.readyState === client.OPEN) {
client.send(text);
}
}
}
function isAllowedOrigin(origin) {
return origin === 'https://example.com' || origin === 'http://localhost:5173';
}
function safeJsonParse(value) {
try {
return JSON.parse(value);
} catch {
return { type: 'invalid' };
}
}真实项目建议:
- 不要把所有逻辑写在
connection回调里,拆成鉴权、订阅、消息路由、广播模块。 - 集群部署时,单机
wss.clients只能看到本进程连接;跨实例广播要接 Redis Pub/Sub、Kafka、NATS 等。 - 负载均衡层要支持 WebSocket Upgrade,必要时配置 sticky session。
8.2 Socket.IO 方案
Socket.IO 不是“纯 WebSocket API 封装”,而是实时通信框架。它提供:
- 自动重连。
- 事件模型。
- room / namespace。
- fallback transport。
- ack。
- 中间件。
适合:
- 团队希望快速实现房间、事件、重连。
- 不强制要求使用原生 WebSocket 协议。
- 前后端都能接受 Socket.IO 客户端和服务端配套。
不适合:
- 必须与标准 WebSocket 客户端互通。
- 协议层需要极简。
- 需要自己完全控制消息格式和连接行为。
8.3 React Hook
tsx
import { useEffect, useRef, useState } from 'react';
export function useWebSocket(url: string) {
const socketRef = useRef<WebSocket | null>(null);
const [status, setStatus] = useState<'connecting' | 'open' | 'closed'>('connecting');
const [messages, setMessages] = useState<unknown[]>([]);
useEffect(() => {
const socket = new WebSocket(url);
socketRef.current = socket;
setStatus('connecting');
socket.addEventListener('open', () => setStatus('open'));
socket.addEventListener('message', (event) => {
setMessages((current) => [...current.slice(-99), safeJsonParse(event.data)]);
});
socket.addEventListener('close', () => setStatus('closed'));
return () => {
socket.close(1000, 'component unmounted');
socketRef.current = null;
};
}, [url]);
const send = (message: unknown) => {
const socket = socketRef.current;
if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(message));
return true;
}
return false;
};
return { status, messages, send };
}
function safeJsonParse(value: string) {
try {
return JSON.parse(value);
} catch {
return value;
}
}8.4 Vue 3 Composable
ts
import { onUnmounted, ref } from 'vue';
export function useWebSocket(url: string) {
const status = ref<'connecting' | 'open' | 'closed'>('connecting');
const messages = ref<unknown[]>([]);
let socket: WebSocket | null = null;
function connect() {
socket = new WebSocket(url);
status.value = 'connecting';
socket.addEventListener('open', () => {
status.value = 'open';
});
socket.addEventListener('message', (event) => {
messages.value = [...messages.value.slice(-99), safeJsonParse(event.data)];
});
socket.addEventListener('close', () => {
status.value = 'closed';
});
}
function send(message: unknown) {
if (socket?.readyState !== WebSocket.OPEN) return false;
socket.send(JSON.stringify(message));
return true;
}
onUnmounted(() => {
socket?.close(1000, 'component unmounted');
});
connect();
return { status, messages, send };
}
function safeJsonParse(value: string) {
try {
return JSON.parse(value);
} catch {
return value;
}
}8.5 Angular Service
ts
import { Injectable, OnDestroy } from '@angular/core';
import { BehaviorSubject, Subject } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class WebSocketService implements OnDestroy {
private socket?: WebSocket;
private messagesSubject = new Subject<unknown>();
private statusSubject = new BehaviorSubject<'closed' | 'connecting' | 'open'>('closed');
messages$ = this.messagesSubject.asObservable();
status$ = this.statusSubject.asObservable();
connect(url: string) {
this.close();
this.statusSubject.next('connecting');
this.socket = new WebSocket(url);
this.socket.addEventListener('open', () => this.statusSubject.next('open'));
this.socket.addEventListener('message', (event) => {
this.messagesSubject.next(this.safeJsonParse(event.data));
});
this.socket.addEventListener('close', () => this.statusSubject.next('closed'));
}
send(message: unknown) {
if (this.socket?.readyState !== WebSocket.OPEN) return false;
this.socket.send(JSON.stringify(message));
return true;
}
close() {
this.socket?.close(1000, 'service closed');
this.socket = undefined;
}
ngOnDestroy() {
this.close();
}
private safeJsonParse(value: string) {
try {
return JSON.parse(value);
} catch {
return value;
}
}
}8.6 Nuxt 与 Next.js
Nuxt:
vue
<script setup>
const wsUrl = 'wss://example.com/ws';
</script>
<template>
<ClientOnly>
<RealtimePanel :url="wsUrl" />
</ClientOnly>
</template>Next.js:
tsx
'use client';
import { useWebSocket } from './useWebSocket';
export function RealtimePanel({ url }: { url: string }) {
const { status, messages, send } = useWebSocket(url);
return (
<section>
<p>{status}</p>
<button onClick={() => send({ type: 'ping', timestamp: Date.now() })}>
Ping
</button>
<pre>{JSON.stringify(messages.at(-1), null, 2)}</pre>
</section>
);
}关键点:WebSocket 是浏览器 API,SSR 环境没有 window.WebSocket。在 Nuxt/Next 中,连接逻辑要放到客户端组件或 mounted/effect 生命周期里。
9. ECharts + Canvas + WebSocket 综合实战
9.1 实时指标看板架构
text
指标采集服务
-> 消息队列 / Redis PubSub
-> WebSocket 网关
-> 浏览器 WebSocket 客户端
-> 数据缓冲区
-> ECharts 折线图 / Canvas 自定义动画9.2 WebSocket 推送指标数据
消息格式:
json
{
"type": "metric:update",
"payload": {
"name": "cpu",
"value": 72.4
},
"timestamp": 1783339200000
}前端缓冲:
js
const points = [];
let pending = false;
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data);
if (message.type !== 'metric:update') return;
points.push({
time: new Date(message.timestamp).toLocaleTimeString(),
value: message.payload.value
});
if (points.length > 120) points.shift();
if (!pending) {
pending = true;
requestAnimationFrame(() => {
pending = false;
updateChart();
});
}
});9.3 更新 ECharts
js
const chart = echarts.init(document.querySelector('#chart'));
chart.setOption({
animation: false,
tooltip: { trigger: 'axis' },
xAxis: { type: 'category' },
yAxis: { type: 'value' },
dataset: { source: [] },
series: [
{
name: 'CPU',
type: 'line',
showSymbol: false,
encode: { x: 'time', y: 'value' }
}
]
});
function updateChart() {
chart.setOption(
{
dataset: { source: points }
},
{ lazyUpdate: true }
);
}9.4 同时用 Canvas 做告警动效
js
const canvas = document.querySelector('#alarm-canvas');
let alarmLevel = 0;
function drawAlarm() {
const { ctx, width, height } = setupCanvas(canvas);
ctx.clearRect(0, 0, width, height);
const radius = 24 + alarmLevel * 12;
const alpha = Math.max(0.1, 0.6 - alarmLevel * 0.08);
ctx.fillStyle = `rgba(239, 68, 68, ${alpha})`;
ctx.beginPath();
ctx.arc(width / 2, height / 2, radius, 0, Math.PI * 2);
ctx.fill();
alarmLevel = (alarmLevel + 1) % 8;
requestAnimationFrame(drawAlarm);
}
drawAlarm();组合原则:
- 结构化指标、趋势、筛选、tooltip:交给 ECharts。
- 非标准视觉、逐帧动画、背景动效:交给 Canvas。
- 数据通道:交给 WebSocket。
- 高频消息:先缓冲,再统一刷新视图。
10. 性能、安全与排查清单
10.1 ECharts 排查清单
- 容器是否有明确宽高?
- 是否在隐藏容器中初始化?
- 是否重复
echarts.init导致实例堆积? - 路由离开时是否
dispose? - 按需引入时是否注册了图表、组件、渲染器?
- 高频数据是否做了节流或按帧合并?
tooltip.formatter是否过重?- 是否把不可信数据拼进 HTML tooltip?
10.2 Canvas 排查清单
- 是否处理了
devicePixelRatio? - resize 后是否重新设置 canvas 位图尺寸?
- 动画卸载时是否
cancelAnimationFrame? - 是否每帧创建大量对象、渐变、图片?
- 是否频繁
getImageData? - 跨域图片是否正确配置 CORS?
- 是否为重要内容提供 DOM 文本替代?
10.3 WebSocket 排查清单
- 生产环境是否使用
wss://? - 服务端是否校验
Origin? - 是否有鉴权和业务授权?
- 是否限制单连接消息频率与消息大小?
- 是否有心跳和超时关闭?
- 是否区分主动关闭和异常断线?
- 重连是否指数退避?
- 重连后是否恢复订阅?
- 是否观察
bufferedAmount防止客户端发送堆积? - 多实例部署是否有跨进程广播方案?
11. 学习路线与练习项目
11.1 第一阶段:ECharts
练习:
- 用
series.data做一个柱状图。 - 改成
dataset + encode。 - 加
tooltip、legend、dataZoom。 - 接入接口异步加载数据。
- 用
setOption做每秒更新的折线图。
验收标准:
- 能解释
dataset和series的关系。 - 能处理容器 resize。
- 能在组件卸载时释放实例。
- 能知道 Canvas 和 SVG 渲染器怎么选。
11.2 第二阶段:Canvas
练习:
- 画矩形、圆、线、文字。
- 实现 Retina 清晰显示。
- 实现一个可拖拽小球。
- 实现 requestAnimationFrame 动画。
- 做一个自定义迷你折线图。
验收标准:
- 能解释 CSS 尺寸和位图尺寸。
- 能把鼠标坐标转换为 canvas 坐标。
- 能处理组件卸载和动画停止。
- 能说明 tainted canvas 的原因。
11.3 第三阶段:WebSocket
练习:
- 用浏览器原生 WebSocket 连接服务端。
- 用 Node
ws写一个广播服务。 - 设计统一消息协议。
- 加心跳、重连、订阅恢复。
- 把实时数据推给 ECharts。
验收标准:
- 能解释
readyState。 - 能区分 WebSocket、SSE、Socket.IO。
- 能处理异常断线和主动关闭。
- 能说清楚鉴权、Origin 校验、限流、消息大小限制。
11.4 综合项目建议
项目一:实时运维大屏
- WebSocket 推送 CPU、内存、QPS、错误率。
- ECharts 展示折线、仪表盘、排名柱状图。
- Canvas 绘制告警闪烁、拓扑节点动画。
项目二:在线协同白板
- Canvas 实现画笔、橡皮、图形、选区。
- WebSocket 同步绘制操作。
- ECharts 展示协作统计,例如在线人数、操作频率。
项目三:行情监控面板
- WebSocket 接收行情 tick。
- ECharts 展示 K 线、成交量、盘口趋势。
- Canvas 绘制高频闪烁价格带或订单簿热力图。
12. 官方与权威资料
12.1 ECharts
- Apache ECharts 官方文档:https://echarts.apache.org/handbook/en/
- 安装与按需引入:https://echarts.apache.org/handbook/en/basics/import/
- 图表容器与尺寸:https://echarts.apache.org/handbook/en/concepts/chart-size/
- dataset:https://echarts.apache.org/handbook/en/concepts/dataset/
- data transform:https://echarts.apache.org/handbook/en/concepts/data-transform/
- 事件与 action:https://echarts.apache.org/handbook/en/concepts/event/
- 动态数据:https://echarts.apache.org/handbook/en/how-to/data/dynamic-data/
- 样式与主题:https://echarts.apache.org/handbook/en/concepts/style/
- Canvas vs SVG:https://echarts.apache.org/handbook/en/best-practices/canvas-vs-svg/
- ARIA 可访问性:https://echarts.apache.org/handbook/en/best-practices/aria/
- 安全指南:https://echarts.apache.org/handbook/en/best-practices/security/
- ECharts API:https://echarts.apache.org/en/api.html
- vue-echarts:https://github.com/ecomfe/vue-echarts
- echarts-for-react:https://github.com/hustcc/echarts-for-react
- ngx-echarts:https://github.com/xieziyu/ngx-echarts
12.2 Canvas
- MDN Canvas API:https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API
- CanvasRenderingContext2D:https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D
- OffscreenCanvas:https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas
- ImageData:https://developer.mozilla.org/en-US/docs/Web/API/ImageData
- requestAnimationFrame:https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame
- devicePixelRatio:https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio
- Canvas 安全与 tainted canvas:https://developer.mozilla.org/en-US/docs/Web/HTML/CORS_enabled_image
12.3 WebSocket
- MDN WebSocket API:https://developer.mozilla.org/en-US/docs/Web/API/WebSocket
- WebSocket readyState:https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/readyState
- WebSocket bufferedAmount:https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/bufferedAmount
- WebSocket binaryType:https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/binaryType
- CloseEvent:https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent
- WHATWG WebSockets 标准:https://websockets.spec.whatwg.org/
- Node
ws:https://github.com/websockets/ws - Socket.IO 官方文档:https://socket.io/docs/v4/
12.4 框架生命周期
- React
useEffect:https://react.dev/reference/react/useEffect - Vue 模板引用:https://vuejs.org/guide/essentials/template-refs.html
- Vue 生命周期:https://vuejs.org/guide/essentials/lifecycle.html
- Nuxt
<ClientOnly>:https://nuxt.com/docs/api/components/client-only - Next.js
'use client':https://nextjs.org/docs/app/api-reference/directives/use-client - Angular 生命周期:https://angular.dev/guide/components/lifecycle
- Svelte 生命周期:https://svelte.dev/docs/svelte/lifecycle-hooks