Skip to content

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 官方文档,以及主流封装库仓库说明。


目录 ​

  1. 三者关系与学习顺序
  2. 版本与选型基线
  3. ECharts 核心知识体系
  4. ECharts 在不同框架中的实现方式
  5. Canvas 核心知识体系
  6. Canvas 在不同框架中的实现方式
  7. WebSocket 核心知识体系
  8. WebSocket 在不同框架与服务端中的实现方式
  9. ECharts + Canvas + WebSocket 综合实战
  10. 性能、安全与排查清单
  11. 学习路线与练习项目
  12. 官方与权威资料

1. 三者关系与学习顺序 ​

1.1 三者分别解决什么问题 ​

技术核心定位最适合场景不适合场景
ECharts声明式图表库折线图、柱状图、饼图、地图、仪表盘、业务看板完全自定义像素级动画、游戏、复杂图像处理
CanvasHTML 绘图画布与 2D/位图渲染 API自定义图形、动画、粒子、画板、游戏、图像处理、大量点绘制普通 DOM 表单、需要天然可访问性的文本内容
WebSocket浏览器与服务端的双向实时通信协议/APIIM、协同编辑、实时指标、游戏状态、交易行情、设备状态只需要服务端单向推送的简单流式场景,此时 SSE 可能更简单

1.2 推荐学习顺序 ​

  1. 先学 ECharts:快速掌握业务图表的 option、series、dataset、tooltip、legend、dataZoom。
  2. 再学 Canvas:理解 ECharts 等图表库背后的渲染基础,能处理自定义绘图需求。
  3. 最后学 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 核对:

包当前版本用途
echarts6.1.0Apache ECharts 主包
vue-echarts8.0.1Vue 3 常用 ECharts 组件封装
echarts-for-react3.0.6React 常用 ECharts 组件封装
ngx-echarts22.0.0Angular 常用 ECharts 指令封装
ws8.21.0Node.js WebSocket 服务端/客户端库
socket.io / socket.io-client4.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 echarts
js
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 的关键差异:

维度CanvasDOM/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 ​

状态数值含义
CONNECTING0正在连接
OPEN1已连接,可以发送
CLOSING2正在关闭
CLOSED3已关闭或连接失败

发送前要判断:

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 请求:

  1. 客户端先发一个 HTTP 请求,带特殊头:
http
GET /ws HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: <base64 编码的 16 字节随机数>
Sec-WebSocket-Version: 13
  1. 服务端同意后返回 101 Switching Protocols,并回一个 Sec-WebSocket-Accept(由 key + 固定 GUID 经 SHA-1 + base64 算得),完成握手。
  2. 之后这条 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 ws
js
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 ​

练习:

  1. 用 series.data 做一个柱状图。
  2. 改成 dataset + encode。
  3. 加 tooltip、legend、dataZoom。
  4. 接入接口异步加载数据。
  5. 用 setOption 做每秒更新的折线图。

验收标准:

  • 能解释 dataset 和 series 的关系。
  • 能处理容器 resize。
  • 能在组件卸载时释放实例。
  • 能知道 Canvas 和 SVG 渲染器怎么选。

11.2 第二阶段:Canvas ​

练习:

  1. 画矩形、圆、线、文字。
  2. 实现 Retina 清晰显示。
  3. 实现一个可拖拽小球。
  4. 实现 requestAnimationFrame 动画。
  5. 做一个自定义迷你折线图。

验收标准:

  • 能解释 CSS 尺寸和位图尺寸。
  • 能把鼠标坐标转换为 canvas 坐标。
  • 能处理组件卸载和动画停止。
  • 能说明 tainted canvas 的原因。

11.3 第三阶段:WebSocket ​

练习:

  1. 用浏览器原生 WebSocket 连接服务端。
  2. 用 Node ws 写一个广播服务。
  3. 设计统一消息协议。
  4. 加心跳、重连、订阅恢复。
  5. 把实时数据推给 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 ​

12.2 Canvas ​

12.3 WebSocket ​

12.4 框架生命周期 ​

基于 VitePress 构建的个人学习文档知识库