锚点项目:自研 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:自定义混合路由算法怎么设计?
标准答案:
- 核心思路:分场景选最佳路由策略,避免单一算法在所有情况都差;
- 流程:
- 先尝试曼哈顿正交路由(工业图主流);
- 检测路径是否穿越节点 / 转折数过多 → 切 A* 启发式搜索(网格上找最短无冲突路径);
- 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 投影 + 屏幕距离阈值自动吸附端口是什么?
标准答案:
问题:拖拽管道末端时,怎么自动捕捉到附近设备的端口?
做法:
- 给每个设备维护 AABB,端口位置已知;
- 拖拽中实时把"管道末端 + 候选端口"用 camera project 到屏幕空间;
- 算屏幕距离,小于阈值(如 30px)就高亮提示;
- 释放时若仍在阈值内 → snap 吸附;
- 为什么屏幕距离不是世界距离:用户视觉判断基于像素,世界距离会因相机远近差异巨大。
核心代码骨架:
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或 ElectronipcMain做窗口间事件总线,主窗口选区 / 缩放 → 广播 → 各窗口重渲染; - 波形数据共享:用
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.js | 3D 全能 | 学习曲线长 | 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 数量。