Skip to content

前端数据可视化

概述

数据可视化是将数据转化为图形的过程,帮助用户直观理解数据。本章节介绍前端数据可视化的核心技术和常用库。


一、可视化技术选型

1. 技术对比

技术优点缺点适用场景
SVG矢量图、可交互、DOM 操作数量大时性能差图表、图标
Canvas高性能、像素操作放大模糊、交互复杂大数据量、游戏
WebGL极高性能、3D 支持学习成本高3D、海量数据

2. 库选择

定位特点GitHub Stars
ECharts全能型配置简单、图表丰富55k+
D3.js底层库灵活强大、学习曲线陡105k+
Chart.js轻量级简单易用、响应式60k+
AntV G2企业级图形语法、蚂蚁出品12k+
Three.js3D 图形WebGL 封装、3D 场景90k+

二、Canvas 基础

1. 基本绑定

javascript
const canvas = document.getElementById('canvas')
const ctx = canvas.getContext('2d')

// 设置画布大小(处理高清屏)
const dpr = window.devicePixelRatio || 1
canvas.width = canvas.clientWidth * dpr
canvas.height = canvas.clientHeight * dpr
ctx.scale(dpr, dpr)

2. 基本图形

javascript
// 矩形
ctx.fillStyle = '#3498db'
ctx.fillRect(10, 10, 100, 50)

ctx.strokeStyle = '#e74c3c'
ctx.lineWidth = 2
ctx.strokeRect(10, 80, 100, 50)

// 路径
ctx.beginPath()
ctx.moveTo(200, 10)
ctx.lineTo(250, 50)
ctx.lineTo(200, 90)
ctx.closePath()
ctx.fill()

// 圆形
ctx.beginPath()
ctx.arc(350, 50, 40, 0, Math.PI * 2)
ctx.fill()

// 圆弧
ctx.beginPath()
ctx.arc(450, 50, 40, 0, Math.PI)
ctx.stroke()

// 贝塞尔曲线
ctx.beginPath()
ctx.moveTo(10, 200)
ctx.quadraticCurveTo(100, 150, 200, 200)  // 二次
ctx.stroke()

ctx.beginPath()
ctx.moveTo(250, 200)
ctx.bezierCurveTo(300, 150, 400, 250, 450, 200)  // 三次
ctx.stroke()

3. 文本

javascript
ctx.font = '24px Arial'
ctx.fillStyle = '#333'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText('Hello Canvas', 200, 100)

// 描边文字
ctx.strokeStyle = '#3498db'
ctx.lineWidth = 1
ctx.strokeText('Outline', 200, 150)

4. 渐变与阴影

javascript
// 线性渐变
const linearGradient = ctx.createLinearGradient(0, 0, 200, 0)
linearGradient.addColorStop(0, '#3498db')
linearGradient.addColorStop(1, '#e74c3c')
ctx.fillStyle = linearGradient
ctx.fillRect(10, 10, 200, 100)

// 径向渐变
const radialGradient = ctx.createRadialGradient(100, 200, 10, 100, 200, 80)
radialGradient.addColorStop(0, '#fff')
radialGradient.addColorStop(1, '#3498db')
ctx.fillStyle = radialGradient
ctx.beginPath()
ctx.arc(100, 200, 80, 0, Math.PI * 2)
ctx.fill()

// 阴影
ctx.shadowColor = 'rgba(0, 0, 0, 0.3)'
ctx.shadowBlur = 10
ctx.shadowOffsetX = 5
ctx.shadowOffsetY = 5
ctx.fillRect(250, 10, 100, 100)

5. 变换

javascript
// 保存状态
ctx.save()

// 平移
ctx.translate(100, 100)

// 旋转
ctx.rotate(Math.PI / 4)

// 缩放
ctx.scale(1.5, 1.5)

// 绘制
ctx.fillRect(-25, -25, 50, 50)

// 恢复状态
ctx.restore()

6. 动画

javascript
let x = 0

function animate() {
  // 清除画布
  ctx.clearRect(0, 0, canvas.width, canvas.height)

  // 绘制
  ctx.fillStyle = '#3498db'
  ctx.beginPath()
  ctx.arc(x, 100, 20, 0, Math.PI * 2)
  ctx.fill()

  // 更新位置
  x += 2
  if (x > canvas.width) x = 0

  requestAnimationFrame(animate)
}

animate()

三、SVG 基础

1. 基本图形

html
<svg width="500" height="300" viewBox="0 0 500 300">
  <!-- 矩形 -->
  <rect x="10" y="10" width="100" height="50" fill="#3498db" />

  <!-- 圆形 -->
  <circle cx="200" cy="35" r="25" fill="#e74c3c" />

  <!-- 椭圆 -->
  <ellipse cx="300" cy="35" rx="40" ry="25" fill="#2ecc71" />

  <!-- 线条 -->
  <line x1="10" y1="100" x2="150" y2="100" stroke="#333" stroke-width="2" />

  <!-- 折线 -->
  <polyline points="10,150 50,120 90,180 130,140"
            fill="none" stroke="#3498db" stroke-width="2" />

  <!-- 多边形 -->
  <polygon points="200,100 250,150 200,200 150,150"
           fill="#9b59b6" />

  <!-- 路径 -->
  <path d="M 300 100 L 350 150 Q 400 100 350 200 Z"
        fill="#f39c12" />
</svg>

2. 样式与渐变

html
<svg width="400" height="200">
  <defs>
    <!-- 线性渐变 -->
    <linearGradient id="gradient1" x1="0%" y1="0%" x2="100%" y2="0%">
      <stop offset="0%" style="stop-color:#3498db" />
      <stop offset="100%" style="stop-color:#e74c3c" />
    </linearGradient>

    <!-- 径向渐变 -->
    <radialGradient id="gradient2" cx="50%" cy="50%" r="50%">
      <stop offset="0%" style="stop-color:#fff" />
      <stop offset="100%" style="stop-color:#3498db" />
    </radialGradient>

    <!-- 滤镜 -->
    <filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
      <feDropShadow dx="3" dy="3" stdDeviation="3" flood-color="#000" flood-opacity="0.3"/>
    </filter>
  </defs>

  <rect x="10" y="10" width="150" height="80" fill="url(#gradient1)" />
  <circle cx="280" cy="50" r="40" fill="url(#gradient2)" />
  <rect x="10" y="110" width="100" height="60" fill="#3498db" filter="url(#shadow)" />
</svg>

3. 动画

html
<svg width="400" height="200">
  <!-- SMIL 动画 -->
  <circle cx="50" cy="100" r="20" fill="#3498db">
    <animate attributeName="cx" from="50" to="350" dur="2s" repeatCount="indefinite" />
  </circle>

  <!-- 变换动画 -->
  <rect x="150" y="80" width="40" height="40" fill="#e74c3c">
    <animateTransform attributeName="transform" type="rotate"
                      from="0 170 100" to="360 170 100"
                      dur="2s" repeatCount="indefinite" />
  </rect>

  <!-- 路径动画 -->
  <circle r="10" fill="#2ecc71">
    <animateMotion dur="3s" repeatCount="indefinite">
      <mpath href="#motionPath" />
    </animateMotion>
  </circle>
  <path id="motionPath" d="M 250,100 Q 300,50 350,100 T 450,100"
        fill="none" stroke="#ddd" />
</svg>

4. JavaScript 操作

javascript
// 创建 SVG 元素
const svgNS = 'http://www.w3.org/2000/svg'

function createSVGElement(tag, attrs) {
  const el = document.createElementNS(svgNS, tag)
  for (const [key, value] of Object.entries(attrs)) {
    el.setAttribute(key, value)
  }
  return el
}

const svg = document.querySelector('svg')

// 创建圆形
const circle = createSVGElement('circle', {
  cx: 100,
  cy: 100,
  r: 50,
  fill: '#3498db'
})

svg.appendChild(circle)

// 事件监听
circle.addEventListener('click', () => {
  circle.setAttribute('fill', '#e74c3c')
})

// 动画
let radius = 50
function animate() {
  radius = 50 + 20 * Math.sin(Date.now() / 200)
  circle.setAttribute('r', radius)
  requestAnimationFrame(animate)
}
animate()

四、ECharts

1. 基本使用

javascript
import * as echarts from 'echarts'

// 初始化
const chart = echarts.init(document.getElementById('chart'))

// 配置项
const option = {
  title: {
    text: '销售数据'
  },
  tooltip: {
    trigger: 'axis'
  },
  legend: {
    data: ['销量', '利润']
  },
  xAxis: {
    type: 'category',
    data: ['一月', '二月', '三月', '四月', '五月']
  },
  yAxis: {
    type: 'value'
  },
  series: [
    {
      name: '销量',
      type: 'bar',
      data: [120, 200, 150, 80, 70]
    },
    {
      name: '利润',
      type: 'line',
      data: [30, 50, 40, 20, 15]
    }
  ]
}

chart.setOption(option)

// 响应式
window.addEventListener('resize', () => {
  chart.resize()
})

2. 常用图表类型

javascript
// 饼图
const pieOption = {
  series: [{
    type: 'pie',
    radius: '50%',
    data: [
      { value: 1048, name: '搜索引擎' },
      { value: 735, name: '直接访问' },
      { value: 580, name: '邮件营销' }
    ]
  }]
}

// 散点图
const scatterOption = {
  xAxis: { type: 'value' },
  yAxis: { type: 'value' },
  series: [{
    type: 'scatter',
    data: [[10, 20], [15, 30], [20, 25], [25, 35]]
  }]
}

// 雷达图
const radarOption = {
  radar: {
    indicator: [
      { name: '销售', max: 100 },
      { name: '管理', max: 100 },
      { name: '技术', max: 100 },
      { name: '客服', max: 100 },
      { name: '研发', max: 100 }
    ]
  },
  series: [{
    type: 'radar',
    data: [{
      value: [80, 90, 70, 85, 95],
      name: '团队A'
    }]
  }]
}

// 热力图
const heatmapOption = {
  xAxis: { type: 'category', data: ['周一', '周二', '周三'] },
  yAxis: { type: 'category', data: ['早', '中', '晚'] },
  visualMap: { min: 0, max: 100 },
  series: [{
    type: 'heatmap',
    data: [
      [0, 0, 50], [0, 1, 80], [0, 2, 60],
      [1, 0, 70], [1, 1, 90], [1, 2, 40],
      [2, 0, 30], [2, 1, 60], [2, 2, 85]
    ]
  }]
}

3. 交互与事件

javascript
// 点击事件
chart.on('click', (params) => {
  console.log('点击:', params.name, params.value)
})

// 图例切换
chart.on('legendselectchanged', (params) => {
  console.log('图例变化:', params.selected)
})

// 数据区域缩放
chart.on('datazoom', (params) => {
  console.log('缩放:', params.start, params.end)
})

// 高亮
chart.dispatchAction({
  type: 'highlight',
  seriesIndex: 0,
  dataIndex: 2
})

// 显示 tooltip
chart.dispatchAction({
  type: 'showTip',
  seriesIndex: 0,
  dataIndex: 1
})

4. 动态数据

javascript
// 实时更新
function updateData() {
  const newData = data.map(() => Math.random() * 100)

  chart.setOption({
    series: [{
      data: newData
    }]
  })
}

setInterval(updateData, 2000)

// 增量更新(大数据量)
chart.appendData({
  seriesIndex: 0,
  data: [[Date.now(), Math.random() * 100]]
})

五、D3.js

1. 选择与数据绑定

javascript
import * as d3 from 'd3'

// 选择元素
const svg = d3.select('#chart')
  .append('svg')
  .attr('width', 500)
  .attr('height', 300)

// 数据绑定
const data = [30, 86, 168, 281, 303]

svg.selectAll('rect')
  .data(data)
  .join('rect')
  .attr('x', (d, i) => i * 50)
  .attr('y', d => 300 - d)
  .attr('width', 40)
  .attr('height', d => d)
  .attr('fill', '#3498db')

2. 比例尺

javascript
// 线性比例尺
const xScale = d3.scaleLinear()
  .domain([0, 100])   // 数据范围
  .range([0, 500])    // 像素范围

xScale(50)  // 250

// 序数比例尺
const colorScale = d3.scaleOrdinal()
  .domain(['A', 'B', 'C'])
  .range(['#3498db', '#e74c3c', '#2ecc71'])

colorScale('A')  // '#3498db'

// 时间比例尺
const timeScale = d3.scaleTime()
  .domain([new Date(2024, 0, 1), new Date(2024, 11, 31)])
  .range([0, 500])

// 带宽比例尺(柱状图)
const bandScale = d3.scaleBand()
  .domain(['A', 'B', 'C', 'D'])
  .range([0, 400])
  .padding(0.1)

bandScale.bandwidth()  // 每个柱子的宽度

3. 坐标轴

javascript
const margin = { top: 20, right: 20, bottom: 30, left: 40 }
const width = 500 - margin.left - margin.right
const height = 300 - margin.top - margin.bottom

const svg = d3.select('#chart')
  .append('svg')
  .attr('width', width + margin.left + margin.right)
  .attr('height', height + margin.top + margin.bottom)
  .append('g')
  .attr('transform', `translate(${margin.left},${margin.top})`)

// X 轴
const xScale = d3.scaleBand()
  .domain(['A', 'B', 'C', 'D'])
  .range([0, width])

svg.append('g')
  .attr('transform', `translate(0,${height})`)
  .call(d3.axisBottom(xScale))

// Y 轴
const yScale = d3.scaleLinear()
  .domain([0, 100])
  .range([height, 0])

svg.append('g')
  .call(d3.axisLeft(yScale))

4. 过渡动画

javascript
// 基本过渡
svg.selectAll('rect')
  .data(newData)
  .transition()
  .duration(1000)
  .ease(d3.easeElastic)
  .attr('height', d => d)
  .attr('y', d => height - d)

// Enter/Update/Exit 模式
const bars = svg.selectAll('rect').data(data)

// Enter - 新增元素
bars.enter()
  .append('rect')
  .attr('x', (d, i) => i * 50)
  .attr('y', height)
  .attr('width', 40)
  .attr('height', 0)
  .attr('fill', '#3498db')
  .transition()
  .duration(500)
  .attr('y', d => height - d)
  .attr('height', d => d)

// Update - 更新元素
bars.transition()
  .duration(500)
  .attr('y', d => height - d)
  .attr('height', d => d)

// Exit - 移除元素
bars.exit()
  .transition()
  .duration(500)
  .attr('height', 0)
  .attr('y', height)
  .remove()

5. 交互

javascript
svg.selectAll('rect')
  .on('mouseover', function(event, d) {
    d3.select(this)
      .transition()
      .duration(200)
      .attr('fill', '#e74c3c')

    // 显示 tooltip
    tooltip
      .style('opacity', 1)
      .html(`Value: ${d}`)
      .style('left', `${event.pageX + 10}px`)
      .style('top', `${event.pageY - 10}px`)
  })
  .on('mouseout', function() {
    d3.select(this)
      .transition()
      .duration(200)
      .attr('fill', '#3498db')

    tooltip.style('opacity', 0)
  })
  .on('click', function(event, d) {
    console.log('Clicked:', d)
  })

六、大数据量渲染

1. Canvas 优化

javascript
// 1. 离屏 Canvas
const offscreenCanvas = document.createElement('canvas')
const offscreenCtx = offscreenCanvas.getContext('2d')

// 在离屏 Canvas 预渲染
function prerender() {
  offscreenCtx.clearRect(0, 0, width, height)
  // 绑制复杂图形...
}

// 主 Canvas 只做复制
function render() {
  ctx.drawImage(offscreenCanvas, 0, 0)
}

// 2. 分层渲染
// 静态层 - 坐标轴、背景
// 动态层 - 数据点、动画

// 3. 视口裁剪
function renderVisibleData(data, viewport) {
  const visibleData = data.filter(d =>
    d.x >= viewport.left && d.x <= viewport.right &&
    d.y >= viewport.top && d.y <= viewport.bottom
  )

  visibleData.forEach(d => {
    ctx.fillRect(d.x, d.y, 2, 2)
  })
}

// 4. 数据聚合
function aggregateData(data, resolution) {
  const buckets = new Map()

  data.forEach(d => {
    const key = `${Math.floor(d.x / resolution)}_${Math.floor(d.y / resolution)}`
    if (!buckets.has(key)) {
      buckets.set(key, { count: 0, x: 0, y: 0 })
    }
    const bucket = buckets.get(key)
    bucket.count++
    bucket.x += d.x
    bucket.y += d.y
  })

  return Array.from(buckets.values()).map(b => ({
    x: b.x / b.count,
    y: b.y / b.count,
    size: Math.log(b.count + 1)
  }))
}

2. WebGL 渲染

javascript
// 使用 PixiJS
import * as PIXI from 'pixi.js'

const app = new PIXI.Application({
  width: 800,
  height: 600,
  antialias: true
})

document.body.appendChild(app.view)

// 创建粒子容器
const container = new PIXI.ParticleContainer(100000, {
  scale: true,
  position: true
})

app.stage.addChild(container)

// 批量创建粒子
const texture = PIXI.Texture.from('particle.png')
const particles = []

for (let i = 0; i < 100000; i++) {
  const particle = new PIXI.Sprite(texture)
  particle.x = Math.random() * 800
  particle.y = Math.random() * 600
  particle.scale.set(0.1)
  container.addChild(particle)
  particles.push(particle)
}

// 动画
app.ticker.add(() => {
  particles.forEach(p => {
    p.x += (Math.random() - 0.5) * 2
    p.y += (Math.random() - 0.5) * 2
  })
})

3. 虚拟滚动

javascript
// 只渲染可视区域的数据
class VirtualScroller {
  constructor(container, data, itemHeight) {
    this.container = container
    this.data = data
    this.itemHeight = itemHeight
    this.visibleCount = Math.ceil(container.clientHeight / itemHeight)

    this.setup()
  }

  setup() {
    // 创建滚动容器
    this.scrollContainer = document.createElement('div')
    this.scrollContainer.style.height = `${this.data.length * this.itemHeight}px`
    this.scrollContainer.style.position = 'relative'
    this.container.appendChild(this.scrollContainer)

    // 监听滚动
    this.container.addEventListener('scroll', () => this.render())

    this.render()
  }

  render() {
    const scrollTop = this.container.scrollTop
    const startIndex = Math.floor(scrollTop / this.itemHeight)
    const endIndex = Math.min(startIndex + this.visibleCount + 1, this.data.length)

    // 清除旧元素
    this.scrollContainer.innerHTML = ''

    // 渲染可见元素
    for (let i = startIndex; i < endIndex; i++) {
      const item = document.createElement('div')
      item.style.position = 'absolute'
      item.style.top = `${i * this.itemHeight}px`
      item.style.height = `${this.itemHeight}px`
      item.textContent = this.data[i]
      this.scrollContainer.appendChild(item)
    }
  }
}

七、常见图表实现

1. 柱状图

javascript
function drawBarChart(ctx, data, options) {
  const { width, height, padding = 40 } = options
  const chartWidth = width - padding * 2
  const chartHeight = height - padding * 2

  // 计算比例
  const maxValue = Math.max(...data.map(d => d.value))
  const barWidth = chartWidth / data.length * 0.8
  const gap = chartWidth / data.length * 0.2

  // 绘制坐标轴
  ctx.beginPath()
  ctx.moveTo(padding, padding)
  ctx.lineTo(padding, height - padding)
  ctx.lineTo(width - padding, height - padding)
  ctx.stroke()

  // 绘制柱子
  data.forEach((d, i) => {
    const x = padding + i * (barWidth + gap)
    const barHeight = (d.value / maxValue) * chartHeight
    const y = height - padding - barHeight

    ctx.fillStyle = d.color || '#3498db'
    ctx.fillRect(x, y, barWidth, barHeight)

    // 标签
    ctx.fillStyle = '#333'
    ctx.textAlign = 'center'
    ctx.fillText(d.label, x + barWidth / 2, height - padding + 20)
    ctx.fillText(d.value, x + barWidth / 2, y - 5)
  })
}

2. 折线图

javascript
function drawLineChart(ctx, data, options) {
  const { width, height, padding = 40 } = options
  const chartWidth = width - padding * 2
  const chartHeight = height - padding * 2

  const maxValue = Math.max(...data.map(d => d.value))
  const xStep = chartWidth / (data.length - 1)

  // 绘制网格线
  ctx.strokeStyle = '#eee'
  for (let i = 0; i <= 5; i++) {
    const y = padding + (chartHeight / 5) * i
    ctx.beginPath()
    ctx.moveTo(padding, y)
    ctx.lineTo(width - padding, y)
    ctx.stroke()
  }

  // 绘制折线
  ctx.beginPath()
  ctx.strokeStyle = '#3498db'
  ctx.lineWidth = 2

  data.forEach((d, i) => {
    const x = padding + i * xStep
    const y = height - padding - (d.value / maxValue) * chartHeight

    if (i === 0) {
      ctx.moveTo(x, y)
    } else {
      ctx.lineTo(x, y)
    }
  })

  ctx.stroke()

  // 绘制数据点
  data.forEach((d, i) => {
    const x = padding + i * xStep
    const y = height - padding - (d.value / maxValue) * chartHeight

    ctx.beginPath()
    ctx.arc(x, y, 4, 0, Math.PI * 2)
    ctx.fillStyle = '#3498db'
    ctx.fill()
    ctx.strokeStyle = '#fff'
    ctx.lineWidth = 2
    ctx.stroke()
  })
}

3. 饼图

javascript
function drawPieChart(ctx, data, options) {
  const { width, height } = options
  const centerX = width / 2
  const centerY = height / 2
  const radius = Math.min(width, height) / 2 - 20

  const total = data.reduce((sum, d) => sum + d.value, 0)
  let startAngle = -Math.PI / 2

  data.forEach((d, i) => {
    const sliceAngle = (d.value / total) * Math.PI * 2
    const endAngle = startAngle + sliceAngle

    // 绘制扇形
    ctx.beginPath()
    ctx.moveTo(centerX, centerY)
    ctx.arc(centerX, centerY, radius, startAngle, endAngle)
    ctx.closePath()
    ctx.fillStyle = d.color || `hsl(${i * 60}, 70%, 60%)`
    ctx.fill()

    // 绘制标签
    const midAngle = startAngle + sliceAngle / 2
    const labelX = centerX + Math.cos(midAngle) * (radius * 0.7)
    const labelY = centerY + Math.sin(midAngle) * (radius * 0.7)

    ctx.fillStyle = '#fff'
    ctx.textAlign = 'center'
    ctx.textBaseline = 'middle'
    ctx.fillText(`${((d.value / total) * 100).toFixed(1)}%`, labelX, labelY)

    startAngle = endAngle
  })
}

八、高频面试题

1. Canvas 和 SVG 的区别?

Canvas:
- 基于像素的位图
- 不支持事件绑定到图形
- 放大会模糊
- 适合大量数据、游戏、动画
- 性能更好

SVG:
- 基于 XML 的矢量图
- 支持 DOM 事件
- 放大不失真
- 适合图表、图标、交互图形
- 图形少时性能好

2. 如何优化大数据量的图表渲染?

1. 数据聚合
   - 降采样
   - 分桶统计

2. 分层渲染
   - 静态层和动态层分离
   - 离屏 Canvas

3. 视口裁剪
   - 只渲染可见区域

4. WebGL
   - 使用 GPU 加速

5. 虚拟滚动
   - 按需渲染

6. Web Worker
   - 数据计算放到子线程

3. ECharts 和 D3 如何选择?

选 ECharts:
- 需要快速开发
- 标准图表需求
- 团队不熟悉可视化

选 D3:
- 需要高度定制
- 复杂交互需求
- 独特的可视化效果

4. 如何实现图表的响应式?

javascript
// 1. 监听 resize
window.addEventListener('resize', () => {
  chart.resize()
})

// 2. 使用 ResizeObserver
const observer = new ResizeObserver(() => {
  chart.resize()
})
observer.observe(container)

// 3. 百分比尺寸
const width = container.clientWidth
const height = container.clientHeight

// 4. viewBox (SVG)
<svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet">

5. 如何实现图表动画?

javascript
// Canvas
function animate() {
  ctx.clearRect(0, 0, width, height)
  // 更新状态
  // 绑制图形
  requestAnimationFrame(animate)
}

// SVG
element.animate([
  { transform: 'scale(0)' },
  { transform: 'scale(1)' }
], {
  duration: 500,
  easing: 'ease-out'
})

// D3
selection
  .transition()
  .duration(500)
  .attr('height', newHeight)

// ECharts
chart.setOption(option, { notMerge: false })
// 内置动画