Three.js / G6 / 可视化 面试题(完整答案版)

6 阅读16分钟

锚点项目:自研 G6 工业流程图编排引擎 (x-g6-package)、Three.js 工业管道 3D 编排器、Electron 200 路地震波形


一、AntV G6 自研流程图编排引擎

Q1:为什么要在 G6 之上自研 x-g6-package?现成的不够吗?

标准答案

原版 G6 的不足(针对工业流程图场景):

  • 节点/端口/边模型不够规范化,复杂图编辑要写大量样板;
  • 拖拽锚点的交互需要业务方自己拼;
  • 内置的边路由算法(如折线)在工业图常常穿节点 / 转折过多
  • 缺乏统一的"编辑器"抽象(撤销/重做、剪贴板、对齐线)。

x-g6-package 做的

  • 节点 / 端口 / 边模块化:统一协议(id、类型、ports[]);
  • 拖拽锚点:原生支持端口拖拽连线,吸附 + 类型校验;
  • 混合路由:曼哈顿优先 → 拥挤区切 A* → 必要时退化贝塞尔;
  • 编辑器抽象:Command 模式做撤销/重做、剪贴板、键盘快捷键、对齐辅助线;
  • 业务侧只关心节点定义和数据流,UI 编辑能力全包。

核心代码骨架(节点/端口协议 + 注册):

// 节点协议
interface NodeSchema {
  id: string
  type: string        // 节点类型(决定视觉 + 行为)
  x: number; y: number
  data: Record<string, any>
  ports: PortSchema[]
}
interface PortSchema {
  id: string
  side: 'top' | 'right' | 'bottom' | 'left'
  dataType: 'energy' | 'signal' | 'control'  // 端口数据类型
  direction: 'in' | 'out'
}

// 节点注册中心
class NodeRegistry {
  private types = new Map<string, NodeTypeDef>()
  register(type: string, def: NodeTypeDef) {
    this.types.set(type, def)
    G6.registerNode(type, {
      draw(cfg, group) { return def.render(cfg, group) },
      getAnchorPoints() { return def.anchors }
    })
  }
}

// 业务侧只声明
nodeRegistry.register('pump', {
  render: (cfg, group) => group.addShape('image', { attrs: { img: '/pump.svg', width: 60, height: 60 } }),
  anchors: [[0.5, 0], [1, 0.5], [0.5, 1], [0, 0.5]],
  ports: [
    { id: 'in', side: 'left', dataType: 'energy', direction: 'in' },
    { id: 'out', side: 'right', dataType: 'energy', direction: 'out' },
  ]
})

追问应对

  • Q:G6 升级你怎么跟?

    锁了 G6 大版本,包装层做 adapter(IGraph / INode 由我们抽象)。升级 G6 只改 adapter 内部,业务侧零感知。小版本(patch)跟着升。


Q2:自定义混合路由算法怎么设计?

标准答案

  • 核心思路:分场景选最佳路由策略,避免单一算法在所有情况都差;
  • 流程
    1. 先尝试曼哈顿正交路由(工业图主流);
    2. 检测路径是否穿越节点 / 转折数过多 → 切 A* 启发式搜索(网格上找最短无冲突路径);
    3. A* 还失败(节点密度极高) → 退化到贝塞尔曲线
  • 优化
    • 节点位置变化时增量重算受影响的边;
    • 路由结果缓存(key = 起点/终点/障碍 hash);
    • 大图(>500 节点)启 Web Worker 离主线程算。

核心代码骨架

class HybridRouter {
  route(from: Point, to: Point, obstacles: Rect[]): Point[] {
    // 1. 试曼哈顿
    const manhattan = this.manhattan(from, to, obstacles)
    if (manhattan && !this.hasCollision(manhattan, obstacles) && this.turns(manhattan) <= 3) {
      return manhattan
    }
    // 2. 试 A*
    const grid = this.buildGrid(from, to, obstacles, gridSize: 10)
    const astar = this.aStar(grid, from, to)
    if (astar) return this.smooth(astar)  // 拉直多余拐点
    // 3. 兜底贝塞尔
    return this.bezier(from, to)
  }

  private aStar(grid: number[][], start: Point, goal: Point): Point[] | null {
    const open = new PriorityQueue<Node>()
    const closed = new Set<string>()
    open.push({ pos: start, g: 0, f: this.heuristic(start, goal), parent: null })
    while (!open.empty()) {
      const cur = open.pop()
      if (this.equal(cur.pos, goal)) return this.reconstruct(cur)
      closed.add(this.key(cur.pos))
      for (const next of this.neighbors(cur.pos, grid)) {
        if (closed.has(this.key(next))) continue
        const g = cur.g + 1
        open.push({ pos: next, g, f: g + this.heuristic(next, goal), parent: cur })
      }
    }
    return null
  }

  // 增量重算:只重算与移动节点相关的边
  reroute(movedNodeId: string, allEdges: Edge[], allNodes: Node[]) {
    const affected = allEdges.filter(e => e.from === movedNodeId || e.to === movedNodeId)
    affected.forEach(e => {
      e.points = this.route(e.fromPoint, e.toPoint, this.obstacles(allNodes, e))
    })
  }
}

追问应对

  • Q:A* 的启发函数为啥用曼哈顿距离?

    工业流程图路由是 4-邻接(只走横竖),曼哈顿距离是 4-邻接下的最优 admissible 启发,保证 A* 找到最短路径。8-邻接才用欧几里得。


Q3:撤销/重做怎么实现?

标准答案

  • Command 模式:每个编辑操作(addNode、moveNode、deleteEdge)封成 Command 对象,含 execute()undo()
  • Command Stack:栈结构存历史,撤销弹出栈顶执行 undo()
  • 合并策略:连续的同类操作(如拖拽中的多次 move)合并成一次 Command;
  • 持久化:编辑过程定期把 Command stack 序列化到 localStorage,刷新不丢;
  • 协同(如有):Command 广播给协同端,OT/CRDT 做冲突合并。

核心代码骨架

interface Command {
  type: string
  execute(): void
  undo(): void
  // 合并:返回 true 表示和上一个命令合并
  canMerge?(prev: Command): boolean
  merge?(prev: Command): Command
}

class CommandManager {
  private undoStack: Command[] = []
  private redoStack: Command[] = []
  private mergeWindow = 300  // ms 内的同类命令合并
  private lastTime = 0

  exec(cmd: Command) {
    cmd.execute()
    const now = Date.now()
    const prev = this.undoStack.at(-1)
    if (prev && cmd.canMerge?.(prev) && now - this.lastTime < this.mergeWindow) {
      this.undoStack[this.undoStack.length - 1] = cmd.merge!(prev)
    } else {
      this.undoStack.push(cmd)
    }
    this.lastTime = now
    this.redoStack = []   // 新操作清空 redo
  }

  undo() {
    const cmd = this.undoStack.pop()
    if (cmd) { cmd.undo(); this.redoStack.push(cmd) }
  }

  redo() {
    const cmd = this.redoStack.pop()
    if (cmd) { cmd.execute(); this.undoStack.push(cmd) }
  }
}

// 示例 Command
class MoveNodeCommand implements Command {
  type = 'move'
  constructor(
    private node: GraphNode,
    private from: Point,
    private to: Point
  ) {}
  execute() { this.node.position = this.to }
  undo() { this.node.position = this.from }
  canMerge(prev: Command) {
    return prev instanceof MoveNodeCommand && prev.node.id === this.node.id
  }
  merge(prev: MoveNodeCommand) {
    // 合并后保留最初的 from 和最终的 to
    return new MoveNodeCommand(this.node, prev.from, this.to)
  }
}

追问应对

  • Q:栈太大占内存怎么办?

    设上限(如 100 步),超过 LRU 淘汰最早的。如果是大图操作(移动 1000 个节点),Command 存差异而不是全量快照。


二、Three.js 工业管道 3D 编排器

Q4:3D 管道编辑器的核心交互是什么?怎么实现?

标准答案

核心操作

  • 管道控制点拖拽(OrbitControls + DragControls 组合);
  • 右键弹菜单:raycaster 找点击对象 → 屏幕坐标弹 Vue 菜单;
  • 控制点 / 箭头操作;
  • 90° 弯折、绕任意轴旋转、对齐设备端口、平行 / 垂直地面;
  • 连接设备 / 切断管道;
  • 改色。

核心代码骨架(拖拽 + 右键菜单):

import * as THREE from 'three'
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'

class PipeEditor {
  scene = new THREE.Scene()
  camera: THREE.PerspectiveCamera
  renderer = new THREE.WebGLRenderer({ antialias: true })
  raycaster = new THREE.Raycaster()
  controls: OrbitControls
  selected: THREE.Object3D | null = null
  dragging = false

  constructor(canvas: HTMLCanvasElement) {
    this.camera = new THREE.PerspectiveCamera(60, canvas.width / canvas.height, 0.1, 5000)
    this.controls = new OrbitControls(this.camera, canvas)
    canvas.addEventListener('pointerdown', this.onPointerDown)
    canvas.addEventListener('pointermove', this.onPointerMove)
    canvas.addEventListener('pointerup', this.onPointerUp)
    canvas.addEventListener('contextmenu', this.onContextMenu)
  }

  pick(event: PointerEvent): THREE.Intersection | null {
    const mouse = this.toNDC(event)
    this.raycaster.setFromCamera(mouse, this.camera)
    const hits = this.raycaster.intersectObjects(
      this.scene.children.filter(o => o.userData.pickable), true
    )
    return hits[0] ?? null
  }

  onPointerDown = (e: PointerEvent) => {
    const hit = this.pick(e)
    if (!hit) return
    if (hit.object.userData.type === 'control_point') {
      this.selected = hit.object
      this.dragging = true
      this.controls.enabled = false   // 拖控制点时禁用相机
    }
  }

  onPointerMove = (e: PointerEvent) => {
    if (!this.dragging || !this.selected) return
    // 屏幕坐标投影到点击平面
    const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0)  // y=0 地面
    const mouse = this.toNDC(e)
    this.raycaster.setFromCamera(mouse, this.camera)
    const point = new THREE.Vector3()
    this.raycaster.ray.intersectPlane(plane, point)
    this.selected.position.copy(point)
    this.updatePipeFromControlPoint(this.selected)   // 更新管道几何
  }

  onPointerUp = () => {
    if (this.dragging && this.selected) {
      this.trySnapToPort(this.selected)   // 释放时尝试吸附
    }
    this.dragging = false
    this.selected = null
    this.controls.enabled = true
  }

  onContextMenu = (e: MouseEvent) => {
    e.preventDefault()
    const hit = this.pick(e as any)
    if (!hit) return
    // 调 Vue 端弹菜单
    eventBus.emit('context-menu', {
      x: e.clientX, y: e.clientY,
      target: hit.object.userData,
    })
  }

  toNDC(e: PointerEvent): THREE.Vector2 {
    const rect = this.renderer.domElement.getBoundingClientRect()
    return new THREE.Vector2(
      ((e.clientX - rect.left) / rect.width) * 2 - 1,
      -((e.clientY - rect.top) / rect.height) * 2 + 1
    )
  }
}

追问应对

  • Q:拖拽时只能投影到地面平面,不在地面的点怎么办?

    用点击时计算的"动态平面"——以当前选中点为基准,构造与相机视线垂直的平面,鼠标移动投影到这个平面。这样无论点在哪都能跟手。


Q5:AABB 投影 + 屏幕距离阈值自动吸附端口是什么?

标准答案

问题:拖拽管道末端时,怎么自动捕捉到附近设备的端口?

做法

  1. 给每个设备维护 AABB,端口位置已知;
  2. 拖拽中实时把"管道末端 + 候选端口"用 camera project 到屏幕空间
  3. 算屏幕距离,小于阈值(如 30px)就高亮提示;
  4. 释放时若仍在阈值内 → snap 吸附;
  5. 为什么屏幕距离不是世界距离:用户视觉判断基于像素,世界距离会因相机远近差异巨大。

核心代码骨架

class PortSnapper {
  threshold = 30  // px

  findNearbyPort(controlPoint: THREE.Vector3, devices: Device[], camera: THREE.Camera, viewport: {w:number,h:number}): Port | null {
    // 1. 控制点投影到屏幕
    const cpScreen = this.toScreen(controlPoint, camera, viewport)

    // 2. AABB 粗筛:哪些设备的包围盒投影在控制点 200px 范围内
    const candidates = devices.filter(d => {
      const aabbScreen = this.aabbToScreen(d.aabb, camera, viewport)
      return this.expandRect(aabbScreen, 200).contains(cpScreen)
    })

    // 3. 细查每个候选设备的端口
    let best: { port: Port; dist: number } | null = null
    for (const dev of candidates) {
      for (const port of dev.ports) {
        const portWorld = dev.transform(port.localPos)
        const portScreen = this.toScreen(portWorld, camera, viewport)
        const dist = cpScreen.distanceTo(portScreen)
        if (dist < this.threshold && (!best || dist < best.dist)) {
          best = { port, dist }
        }
      }
    }
    return best?.port ?? null
  }

  private toScreen(v: THREE.Vector3, camera: THREE.Camera, vp: {w:number,h:number}): THREE.Vector2 {
    const ndc = v.clone().project(camera)
    return new THREE.Vector2(
      (ndc.x + 1) / 2 * vp.w,
      (-ndc.y + 1) / 2 * vp.h
    )
  }

  private aabbToScreen(aabb: THREE.Box3, camera: THREE.Camera, vp: {w:number,h:number}): Rect {
    // 8 个角点都投影,取屏幕空间的包围矩形
    const corners = [
      new THREE.Vector3(aabb.min.x, aabb.min.y, aabb.min.z),
      new THREE.Vector3(aabb.min.x, aabb.min.y, aabb.max.z),
      // ... 8 个
    ]
    const screens = corners.map(c => this.toScreen(c, camera, vp))
    return Rect.fromPoints(screens)
  }
}

追问应对

  • Q:屏幕投影每帧算性能 OK 吗?

    单个 project 调用是矩阵乘法,几十微秒。设备数 < 1000 时整体 < 5ms 一帧,60fps 没问题。再多就用空间索引(八叉树)做粗筛优化。


Q6:InstancedMesh + Command 撤销队列怎么联动?

标准答案

场景:管道有几千段相同形状的法兰,渲染压力大。

  • InstancedMesh:N 个相同几何合成一个 draw call,每个实例只占一个 4x4 矩阵;
  • 批量更新:操作(如对齐、批量旋转)改 instanceMatrix 数组的子段;
  • Command 撤销:每个 Command 记录"实例索引集合 + 旧矩阵集合",undo 时 set 回去再 needsUpdate = true
  • 性能:原来 5000 个 Mesh → 1 个 InstancedMesh,FPS 从 ~15 提到 60。

核心代码骨架

class FlangeBatch {
  mesh: THREE.InstancedMesh

  constructor(geometry: THREE.BufferGeometry, material: THREE.Material, capacity: number) {
    this.mesh = new THREE.InstancedMesh(geometry, material, capacity)
    this.mesh.frustumCulled = false  // 大场景关闭剔除避免误剔
  }

  setMatrix(index: number, matrix: THREE.Matrix4) {
    this.mesh.setMatrixAt(index, matrix)
    this.mesh.instanceMatrix.needsUpdate = true
  }

  setMatrices(updates: Array<{index: number; matrix: THREE.Matrix4}>) {
    for (const u of updates) {
      this.mesh.setMatrixAt(u.index, u.matrix)
    }
    this.mesh.instanceMatrix.needsUpdate = true
  }

  getMatrix(index: number): THREE.Matrix4 {
    const m = new THREE.Matrix4()
    this.mesh.getMatrixAt(index, m)
    return m
  }
}

// Command 配合
class AlignFlangesCommand implements Command {
  type = 'align_flanges'
  private oldMatrices: THREE.Matrix4[] = []

  constructor(
    private batch: FlangeBatch,
    private indices: number[],
    private newMatrices: THREE.Matrix4[]
  ) {}

  execute() {
    // 快照旧矩阵用于 undo
    this.oldMatrices = this.indices.map(i => this.batch.getMatrix(i))
    this.batch.setMatrices(this.indices.map((i, k) => ({ index: i, matrix: this.newMatrices[k] })))
  }

  undo() {
    this.batch.setMatrices(this.indices.map((i, k) => ({ index: i, matrix: this.oldMatrices[k] })))
  }
}

追问应对

  • Q:标准 InstancedMesh 不能改单个实例材质怎么办?

    instanceColor 给每个实例自定义颜色;要做更复杂的差异化(如高亮态)就分组——状态相同的归一个 InstancedMesh,切换时只动这一组,仍比单 Mesh 快。极端情况自己写 InstancedBufferAttribute 传任意属性,shader 里读。


Q7:GLB / Draco / 网格优化你做了什么?

标准答案

  • 几何优化:合并相同材质的几何、移除重复顶点、降三角面(Blender Decimate);
  • GLB 打包:贴图 + 几何 + 材质 + 动画一个文件,HTTP 请求数 -50%+;
  • Draco 压缩:几何用 Draco 编码,体积通常压到 20%,加载时 wasm 解码;
  • 贴图:KTX2 / Basis 压缩(GPU 直接吃)、PBR 贴图合并通道(R 粗糙度 + G 金属度);
  • LOD:远距离用低多边形 + 远剪裁;
  • 结果:典型工厂模型 80MB → 6MB,加载 12s → 2s。

核心代码骨架

import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js'
import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js'

class ModelLoader {
  private loader: GLTFLoader

  constructor(renderer: THREE.WebGLRenderer) {
    const draco = new DRACOLoader().setDecoderPath('/draco/')
    const ktx2 = new KTX2Loader().setTranscoderPath('/ktx2/').detectSupport(renderer)
    this.loader = new GLTFLoader()
      .setDRACOLoader(draco)
      .setKTX2Loader(ktx2)
  }

  async load(url: string, lodDistance = 100): Promise<THREE.LOD> {
    const gltf = await this.loader.loadAsync(url)
    const lod = new THREE.LOD()
    // 主模型 + 远距离低面数版本
    lod.addLevel(gltf.scene, 0)
    const lowPoly = this.simplify(gltf.scene, ratio: 0.3)
    lod.addLevel(lowPoly, lodDistance)
    return lod
  }

  private simplify(scene: THREE.Object3D, ratio: number): THREE.Object3D {
    // 离线 Blender 已经做了,运行时按 ratio 选择预生成的低面版
    return scene.clone()  // 简化示意
  }
}

追问应对

  • Q:Draco 压缩 / 解压性能损耗值得吗?

    解压在 worker 线程跑,主线程不阻塞。下载省 80% 流量(特别是移动端 4G),代价是首次解压几百 ms。net 收益巨大,工业大模型必开。


三、Electron 地震波形(高密度可视化)

Q8:同屏 200 路地震波形怎么保证流畅?

标准答案

核心策略:Canvas 分层渲染 + 虚拟化采样

  • 分层
    • 底层(grid / axis)一次性绘制,长期不重画;
    • 中层(波形)每帧重画,但用 OffscreenCanvas 在 Worker 算;
    • 上层(光标 / 选区 / 标注)单独 canvas,鼠标移动只重画这一层;
  • 虚拟化采样:屏幕一行像素对应原数据数千点 → min-max 降采样保留波形包络,绘制点数和屏幕宽度同阶;
  • 增量绘制:仅重画 dirty rect;
  • 缓存:相同区段绘制结果 cache,平移时复用;
  • Worker 化:FFT / 滤波 / 包络丢 Web Worker;
  • 结果:200 路 30fps 稳定。

核心代码骨架(min-max 降采样):

// 每个像素从原数据 N 个点中取 min 和 max,画一条竖线代替这段波形
function downsampleMinMax(data: Float32Array, width: number): {min: Float32Array, max: Float32Array} {
  const bucketSize = Math.floor(data.length / width)
  const min = new Float32Array(width)
  const max = new Float32Array(width)
  for (let i = 0; i < width; i++) {
    const start = i * bucketSize
    const end = Math.min(start + bucketSize, data.length)
    let mn = Infinity, mx = -Infinity
    for (let j = start; j < end; j++) {
      const v = data[j]
      if (v < mn) mn = v
      if (v > mx) mx = v
    }
    min[i] = mn
    max[i] = mx
  }
  return { min, max }
}

function drawWaveform(ctx: CanvasRenderingContext2D, data: Float32Array, w: number, h: number, gain: number) {
  const { min, max } = downsampleMinMax(data, w)
  const midY = h / 2
  ctx.beginPath()
  for (let x = 0; x < w; x++) {
    ctx.moveTo(x, midY - max[x] * gain)
    ctx.lineTo(x, midY - min[x] * gain)
  }
  ctx.stroke()
}
// 分层 Canvas 容器
class LayeredCanvas {
  bg = document.createElement('canvas')    // axis / grid
  data = document.createElement('canvas')  // 波形
  fg = document.createElement('canvas')    // 光标 / 选区
  bgCtx; dataCtx; fgCtx

  constructor(container: HTMLElement, w: number, h: number) {
    for (const c of [this.bg, this.data, this.fg]) {
      c.width = w; c.height = h
      c.style.cssText = 'position:absolute;top:0;left:0;'
      container.appendChild(c)
    }
    this.bgCtx = this.bg.getContext('2d')!
    this.dataCtx = this.data.getContext('2d')!
    this.fgCtx = this.fg.getContext('2d')!
    this.drawGrid()  // 只画一次
  }

  redrawData(channels: Float32Array[]) {
    this.dataCtx.clearRect(0, 0, this.data.width, this.data.height)
    const channelHeight = this.data.height / channels.length
    channels.forEach((data, i) => {
      this.dataCtx.save()
      this.dataCtx.translate(0, i * channelHeight)
      drawWaveform(this.dataCtx, data, this.data.width, channelHeight, gain: 0.5)
      this.dataCtx.restore()
    })
  }

  moveCursor(x: number) {
    this.fgCtx.clearRect(0, 0, this.fg.width, this.fg.height)
    this.fgCtx.beginPath()
    this.fgCtx.moveTo(x, 0); this.fgCtx.lineTo(x, this.fg.height)
    this.fgCtx.stroke()
  }
}

追问应对

  • Q:FPS 瓶颈是 CPU 还是 GPU?

    主要在 CPU 的降采样和绘制路径计算。降采样放 Worker 后主线程稳定 30fps。GPU 几乎跑不满(普通 Canvas 2D 占 GPU 很低)。再往上 WebGL 能进一步压低 CPU 但开发成本高,当前性能足够就没做。


Q9:多窗口波形联动切换怎么设计?

标准答案

  • 任意顺序叠加的波形切换流水线:选取标注 → 缩放 → 滤波 → 中心扫零 → 去均值/去趋势 → 归一化 → 积分/微分;
  • 每个算子是独立 stage:声明输入输出格式,能任意排序叠加;
  • 多窗口联动:用 BroadcastChannel 或 Electron ipcMain 做窗口间事件总线,主窗口选区 / 缩放 → 广播 → 各窗口重渲染;
  • 波形数据共享:用 SharedArrayBuffer 让多个 worker / 渲染进程共用一份内存。

核心代码骨架

// 算子流水线
interface WaveStage {
  name: string
  apply(input: Float32Array, params: any): Float32Array
}

const stages: Record<string, WaveStage> = {
  bandpass: { name: 'bandpass', apply: (input, { low, high, fs }) => butterFilter(input, low, high, fs) },
  detrend: { name: 'detrend', apply: (input) => removeLinearTrend(input) },
  normalize: { name: 'normalize', apply: (input) => { const max = Math.max(...input.map(Math.abs)); return input.map(v => v / max) } },
  integrate: { name: 'integrate', apply: (input, { dt }) => cumtrapz(input, dt) },
}

class WavePipeline {
  private chain: Array<{stage: string; params: any}> = []
  add(stage: string, params: any) { this.chain.push({ stage, params }); return this }
  remove(index: number) { this.chain.splice(index, 1); return this }
  move(from: number, to: number) {
    const [item] = this.chain.splice(from, 1)
    this.chain.splice(to, 0, item)
    return this
  }
  run(input: Float32Array): Float32Array {
    return this.chain.reduce((acc, { stage, params }) => stages[stage].apply(acc, params), input)
  }
}
// 多窗口联动
const bc = new BroadcastChannel('wave-sync')

// 主窗口选区
function onSelectRange(start: number, end: number) {
  bc.postMessage({ type: 'range', start, end })
}

// 子窗口监听
bc.onmessage = (e) => {
  if (e.data.type === 'range') {
    renderRange(e.data.start, e.data.end)
  } else if (e.data.type === 'zoom') {
    applyZoom(e.data.factor)
  }
}

// 共享内存(需要 COOP/COEP header)
const sab = new SharedArrayBuffer(waveformBytes)
const view = new Float32Array(sab)
// 主进程加载一次,子 worker 通过 postMessage 传 sab 引用即可只读访问,零拷贝

追问应对

  • Q:SharedArrayBuffer 浏览器支持有限制?

    需要服务器响应头加 Cross-Origin-Opener-Policy: same-origin + Cross-Origin-Embedder-Policy: require-corp。Electron 内置 Chromium 默认放开,但 Web 部署要配 header。我们的工业软件主要 Electron,不受限。


四、可视化通用问题

Q10:ECharts / D3 / G6 / Three.js / AntV 怎么选?

标准答案

工具强项弱项场景
ECharts图表多、配置驱动、性能稳自由度低仪表盘、大屏
D3极致自由、SVG 友好上手陡、需自己写创意图、特殊统计图
G6关系图专业图表类少流程图、关系图
Three.js3D 全能学习曲线长3D 模型/场景
AntV F2/F7移动端轻桌面体验一般小程序图表

核心代码骨架(同一需求三种实现对比):

// ECharts: 配置驱动
echarts.init(el).setOption({
  xAxis: { type: 'category', data: ['A','B','C'] },
  yAxis: { type: 'value' },
  series: [{ type: 'bar', data: [10, 20, 30] }]
})

// D3: 命令式
d3.select(el).selectAll('rect').data([10,20,30]).enter().append('rect')
  .attr('x', (_, i) => i*40).attr('y', d => 100-d).attr('width', 30).attr('height', d => d)

// Three.js: 3D 柱状
const geom = new THREE.BoxGeometry(1, 1, 1)
const mat = new THREE.MeshStandardMaterial({ color: 0x16455e })
[10,20,30].forEach((v, i) => {
  const mesh = new THREE.Mesh(geom, mat)
  mesh.scale.y = v
  mesh.position.x = i * 1.5
  mesh.position.y = v / 2
  scene.add(mesh)
})

追问应对

  • Q:能不能不选 ECharts,全用 D3?

    可以但成本高。D3 是底层工具,常见图表(柱/折/饼)也要手写一遍。ECharts 是封装好的"常用图表合集",开发效率高一个数量级。除非有强烈的设计需求否则没必要。


Q11:大屏可视化你怎么做的?

标准答案

  • 布局:CSS Grid + rem 缩放,按设计稿基准(1920×1080)自适应;
  • 数据:WebSocket 推送增量,前端做时间窗口聚合(如 5s 窗口);
  • 动效:避免全屏 transition,用 GPU 加速属性(transform / opacity);
  • 数字滚动:用 requestAnimationFrame + 缓动函数;
  • 地图:MapboxGL 或 ECharts geo + GeoJSON;
  • 性能:图表节流 throttle 渲染、tab 不在前台暂停 RAF。

核心代码骨架(rem 缩放 + 数字滚动):

// 大屏自适应:以 1920 为基准,1920 = 100rem
function setRem() {
  const baseWidth = 1920
  const fontSize = (window.innerWidth / baseWidth) * 100   // 1rem = 100px @ 1920
  document.documentElement.style.fontSize = fontSize + 'px'
}
window.addEventListener('resize', setRem)
setRem()
// CSS: .card { width: 4rem; height: 2rem; }  // 任何屏幕等比

// 数字滚动
function animateNumber(from: number, to: number, duration: number, onUpdate: (v: number) => void) {
  const start = performance.now()
  function frame(now: number) {
    const t = Math.min((now - start) / duration, 1)
    const eased = 1 - Math.pow(1 - t, 3)   // ease-out-cubic
    onUpdate(from + (to - from) * eased)
    if (t < 1) requestAnimationFrame(frame)
  }
  requestAnimationFrame(frame)
}

// 后台 tab 暂停
document.addEventListener('visibilitychange', () => {
  if (document.hidden) chartManager.pauseAll()
  else chartManager.resumeAll()
})

追问应对

  • Q:rem 缩放和 transform: scale 哪种好?

    transform scale 简单但字体会被拉伸糊,且不是真正的等比布局(事件区域可能错位)。rem 方案保持像素清晰、事件准确,开发期写起来麻烦点,但产出质量高。大屏一定用 rem。


Q12:Canvas vs SVG vs WebGL 怎么选?

标准答案

  • SVG:DOM 友好、易交互(每个元素是节点),但 >5000 节点卡;
  • Canvas 2D:万级元素 OK、绘制快但交互要手动 hit-test;
  • WebGL:十万+ 元素必须用,GPU 并行;
  • WebGPU:更新一代,长远方向,目前生态在追赶。

核心代码骨架(Canvas 手动 hit-test):

class HitTester {
  private items: Array<{id: string; bounds: Rect}> = []

  add(id: string, bounds: Rect) { this.items.push({ id, bounds }) }

  pick(x: number, y: number): string | null {
    // 反向遍历:后画的在上层
    for (let i = this.items.length - 1; i >= 0; i--) {
      if (this.items[i].bounds.contains(x, y)) return this.items[i].id
    }
    return null
  }
}

canvas.addEventListener('click', (e) => {
  const rect = canvas.getBoundingClientRect()
  const id = hitTester.pick(e.clientX - rect.left, e.clientY - rect.top)
  if (id) onItemClick(id)
})

// 进阶:用离屏 canvas 画 id 染色图做 pixel-perfect hit-test
function pickByColor(x: number, y: number, offscreen: OffscreenCanvas, colorToId: Map<string, string>) {
  const ctx = offscreen.getContext('2d')!
  const pixel = ctx.getImageData(x, y, 1, 1).data
  const color = `rgb(${pixel[0]},${pixel[1]},${pixel[2]})`
  return colorToId.get(color) ?? null
}

追问应对

  • Q:WebGL 选啥库?

    通用 3D 用 Three.js;2D 大数据可视化用 PixiJS(性能优秀)或 deck.gl(GIS 强);纯底层用 regl 或 twgl。我们项目都是 Three.js + ECharts 组合。


五、可能被追问

  • Q:你的 3D 编辑器最大场景多少元素?性能怎么样?

    测试过 1 万元件 + 5000 段管道场景,60fps 稳定。靠 InstancedMesh + 视锥剔除 + LOD 三件套。打开模型加载约 3s(Draco 解压)。

  • Q:自研 G6 包 vs X6 / ReactFlow?

    X6 是阿里另一套,定位类似 G6 但 API 不一样;ReactFlow 在 React 生态强但定制路由比较弱。我们选 G6 是 2022 年决策,那时 X6 还不够成熟;现在新项目可以 X6 替换,但价值是抽象层稳定,底层换实现成本可控。

  • Q:Three.js 内存泄漏怎么排查?

    Three.js 的 geometry / material / texture 都要手动 dispose()。组件 unmount 时遍历 scene 把所有几何/材质/纹理 dispose,渲染器也要 dispose。Chrome DevTools 拍 heap snapshot 对比两次 WebGLRenderingContext 数量。