🎬 面试开场怎么引出这个话题
「在中电做过一个业务流程图设计器,基于 jsPlumb,支持节点拖拽、连线绘制、画布缩放平移、撤销重做。印象最深的坑是画布缩放后,拖拽新节点的坐标要除以 scale — 用户在缩小的画布上点击位置,实际节点位置要换算回原始坐标系。还有一个比较有意思的是撤销重做用命令模式(Command Pattern)实现的,每个操作封装成可撤销的对象。另外上千节点时连线重绘卡顿,后来做了脏标记 + requestAnimationFrame 批处理。」
✅ 抛出 4 个可深挖的点:① 坐标系换算 ② 命令模式(设计模式)③ 性能优化 ④ 连线吸附校验。
这个话题能体现你的设计模式交互细节功底,适合中高级岗位。
1

数据模型:节点 + 连线 + 配置

📋 流程图的数据结构
// 流程图本质就是"图"——节点(node)+ 边(edge)
{
  nodes: [
    {
      id: 'node_1',
      type: 'start',        // 节点类型:start/end/approve/condition
      label: '发起申请',
      x: 100, y: 200,     // 在画布坐标系的位置(不是屏幕坐标!)
      config: { ... }       // 节点参数:审批人、条件等
    },
    ...
  ],
  edges: [
    {
      id: 'edge_1',
      source: 'node_1',      // 起点节点 id
      sourcePort: 'bottom',   // 起点锚点(上下左右)
      target: 'node_2',
      targetPort: 'top',
      label: '同意'
    }
  ]
}
关键设计: 节点坐标存"画布坐标"而非"屏幕坐标"。画布坐标是绝对的、固定的;屏幕坐标 = 画布坐标 × scale + offset,会随缩放和平移变化。所有业务逻辑(连线、碰撞、保存)都基于画布坐标,只在渲染时换算到屏幕坐标。这是后面所有坑的根源。
🧠 类比
想象一张巨型地图(画布坐标),你用一个放大镜看局部(屏幕坐标)。
地图上北京的位置(画布坐标)永远是固定的,但你移动放大镜(平移)或换不同倍数的放大镜(缩放),北京在放大镜里的位置(屏幕坐标)会变。
存数据要存地图上的真实位置,不要存放大镜里看到的位置。
2

画布缩放后的坐标换算坑

⚠️ 真实事故:节点放错位置

现象: 画布缩放到 50%,用户点击想在某位置放节点,结果节点跑偏了 — 点的位置和节点出现的位置不一致。缩放越小,偏移越大。

根因: 用户的点击坐标 e.clientX/Y屏幕坐标,但节点要存在画布坐标。屏幕坐标 → 画布坐标的换算公式:画布坐标 = (屏幕坐标 - offset) / scale。忘了除以 scale,就会偏移。
🔑 坐标换算公式(必须背熟)
// 画布状态:scale(缩放倍数)+ offsetX/Y(平移偏移)
let scale = 1
let offsetX = 0, offsetY = 0

// 屏幕坐标 → 画布坐标(点击放置节点时用)
function screenToCanvas(screenX, screenY) {
  return {
    x: (screenX - offsetX) / scale,
    y: (screenY - offsetY) / scale
  }
}

// 画布坐标 → 屏幕坐标(渲染节点时用)
function canvasToScreen(canvasX, canvasY) {
  return {
    x: canvasX * scale + offsetX,
    y: canvasY * scale + offsetY
  }
}

// 缩放时,围绕鼠标位置缩放(不是左上角)
function zoomAt(mouseX, mouseY, newScale) {
  // 关键:缩放前后,鼠标对应的画布坐标不变
  const before = screenToCanvas(mouseX, mouseY)
  scale = newScale
  const after = screenToCanvas(mouseX, mouseY)
  // 反推新的 offset,让 before === after
  offsetX = mouseX - before.x * scale
  offsetY = mouseY - before.y * scale
}
进阶坑: 缩放要以鼠标位置为中心,不能以画布左上角为中心。否则放大时,鼠标对准的节点会"跑掉"。公式:缩放前后,鼠标指向的画布坐标保持不变,反推新的 offset。
3

撤销重做:命令模式(Command Pattern)

🧠 为什么用命令模式
撤销重做的本质:每个操作必须知道怎么执行、怎么反执行。把"操作"抽象成一个对象(有 execute 和 undo 方法),压入栈。
Ctrl+Z = 弹出栈顶命令,调用它的 undo;再 Redo = 压回去,调 execute。
这是设计模式里命令模式的教科书应用。
📋 命令模式实现
// 1. 定义命令接口:每个命令都能执行和撤销
class Command {
  execute() {}   // 执行
  undo() {}      // 撤销(反执行)
}

// 2. 具体命令:添加节点
class AddNodeCommand extends Command {
  constructor(graph, node) {
    super()
    this.graph = graph
    this.node = node
  }
  execute() { this.graph.nodes.push(this.node) }
  undo() {
    const i = this.graph.nodes.findIndex(n => n.id === this.node.id)
    this.graph.nodes.splice(i, 1)
  }
}

// 3. 移动节点命令(要存"移动前"和"移动后"的位置)
class MoveNodeCommand extends Command {
  constructor(node, oldX, oldY, newX, newY) {
    super()
    this.node = node
    this.oldX = oldX; this.oldY = oldY
    this.newX = newX; this.newY = newY
  }
  execute() { this.node.x = this.newX; this.node.y = this.newY }
  undo() { this.node.x = this.oldX; this.node.y = this.oldY }
}

// 4. 命令管理器:维护两个栈
class CommandManager {
  constructor() {
    this.undoStack = []
    this.redoStack = []
  }
  execute(cmd) {
    cmd.execute()
    this.undoStack.push(cmd)
    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) }
  }
}
关键细节:新操作清空 redoStack(否则逻辑混乱);② 移动命令要合并 — 拖拽过程中产生几十个 move 命令,应该合并成一个(用 debounce 或记录 dragStart/dragEnd);③ 栈要有上限(比如 50),防止内存爆炸。
4

上千节点:连线重绘卡顿

⚠️ 现象

节点数量到几百个时,拖动一个节点,所有和它相连的线都要重绘,jsPlumb 默认是同步重绘,拖动时帧率掉到 10fps,明显卡顿。

根因: 每次拖动触发 N 次重绘(每个相关连线一次),每次重绘都走 DOM 操作(SVG path 重算),累积起来卡。解法: 脏标记 + requestAnimationFrame 批处理,一帧内只重绘一次。
🔑 脏标记 + rAF 批处理
// 拖动节点时,不立即重绘,只标记"脏了"
let isDirty = false
let rafId = null

function markDirty() {
  if (isDirty) return  // 已经标记过,跳过
  isDirty = true
  rafId = requestAnimationFrame(() => {
    repaintAllDirtyEdges()  // 一帧只重绘一次
    isDirty = false
  })
}

// 节点拖动事件里调 markDirty,而不是直接 repaint
node.on('drag', () => {
  updateNodePosition(node)
  markDirty()  // 原来是直接 repaint,现在批处理
})
其他性能手段:视口剔除:画布外的节点和连线不渲染(类似虚拟滚动);② 连线简化:复杂贝塞尔曲线改成折线;③ 缩放时降级:缩放到很小时,节点只画方框不画文字;④ Web Worker 算布局:自动排版(如 dagre)放 Worker,不阻塞拖拽。
5

连线吸附与规则校验

📋 连线的业务规则
  • 锚点限制: start 节点只能从"下"出,end 节点只能从"上"入
  • 类型校验: condition 节点的两个出口必须连不同分支
  • 防环: 连线不能形成环(DFS 检测)
  • 唯一性: 同两个节点不能连多条线(或限制最多 N 条)
  • 吸附: 拖拽连线接近目标节点时自动吸附(connector + tolerance)
⚠️ 防环检测

连线时必须检测会不会形成环(否则流程引擎执行会死循环)。用 DFS 染色法:从 target 出发能否走到 source,能走到就是成环,拒绝连接。

function wouldCreateCycle(edges, source, target) {
  // 从 target 出发,看能不能回到 source
  const visited = new Set()
  function dfs(node) {
    if (node === source) return true  // 回到起点,成环
    if (visited.has(node)) return false
    visited.add(node)
    return edges.some(e => e.source === node && dfs(e.target))
  }
  return dfs(target)
}
6

jsPlumb 选型 & 替代方案

方案渲染优点缺点
jsPlumb(我们用的)SVG/DOMAPI 简单,生态成熟DOM 渲染,大节点数性能差
G6/AntVCanvas性能好,内置布局算法学习曲线陡,定制麻烦
X6/AntVSVG/Canvas专为流程图设计,功能强体积大
React Flow / Vue FlowDOM声明式,React/Vue 原生大节点数性能一般
原生 CanvasCanvas极致性能,完全可控要自己造所有轮子
选型原则: 节点数 < 200 → jsPlumb / Vue Flow(开发快);节点数 200~2000 → X6 / G6(Canvas 性能);节点数 > 2000 → 原生 Canvas + 视口剔除。我们当时节点数不多,jsPlumb 够用。
7

高频追问 & 容易翻车点

1画布缩放后,拖拽节点位置错乱怎么办? 中等
根因: 混淆了屏幕坐标和画布坐标。点击事件给的是屏幕坐标(clientX/Y),节点要存在画布坐标。
换算公式: 画布坐标 = (屏幕坐标 - offset) / scale。渲染时反向:屏幕坐标 = 画布坐标 × scale + offset。
缩放要以鼠标为中心: 缩放前后鼠标指向的画布坐标不变,反推新 offset。
💬 这是流程图最经典的坑,答出来说明你真做过。
2撤销重做怎么实现的? 中等
命令模式(Command Pattern)。 每个操作封装成命令对象,有 execute 和 undo 方法。两个栈:undoStack 和 redoStack。执行 → 压 undo 栈、清空 redo 栈;撤销 → 弹 undo 栈执行 undo、压 redo 栈;重做 → 反之。
细节: ① 移动命令要合并(拖拽产生多个 move 合并成一个);② 栈有上限防内存;③ 新操作必须清空 redoStack。
💬 追问「为什么新操作要清空 redo 栈?」→ 因为 redo 的前提是"撤销后没做新操作",一旦做新操作,之前的撤销路径就失效了,强行 redo 会导致状态不一致。
3怎么检测连线形成环? 基础
DFS 染色法。 从 target 节点出发 DFS,如果能回到 source 节点,说明成环,拒绝连接。复杂度 O(V+E)。这是图论基础,面试官爱问体现你的算法功底。
4节点很多时(上千个)性能怎么办? 中等
脏标记 + rAF 批处理:拖动时不立即重绘,标记脏,一帧只重绘一次;② 视口剔除:画布外的节点不渲染(类似虚拟滚动);③ Canvas 替代 DOM:jsPlumb 是 DOM/SVG 渲染,换 G6/X6 用 Canvas;④ 降级渲染:缩小时只画方框不画细节;⑤ Web Worker 算布局
5为什么用 jsPlumb 不用 AntV G6? 基础
当时节点数不多(几十个),jsPlumb API 简单、开发快。G6 虽然性能好(Canvas 渲染 + 内置布局),但学习曲线陡,小项目不值得。如果节点上千,会选 G6/X6。选型看规模:小 → jsPlumb/Vue Flow;中 → X6;大 → G6/原生 Canvas。
6节点的配置(审批人等)怎么存? 基础
节点对象的 config 字段。前端用表单编辑配置,存到节点的 config 对象里。整个流程图(节点 + 边 + config)序列化成 JSON 存后端,后端流程引擎(BPMN/Activiti)根据这个 JSON 执行。前端只负责"画和配",执行在后端
8

30 秒电梯陈述(背熟)

🎤 30 秒版
「在中电做过业务流程图设计器,基于 jsPlumb,支持节点拖拽、连线、画布缩放平移、撤销重做。印象最深两个点:一是画布缩放的坐标换算 — 屏幕坐标和画布坐标要除以 scale,缩放还要以鼠标为中心,这个坑踩了好久;二是撤销重做用命令模式实现,每个操作封装成可执行可撤销的对象,两个栈管理。另外上千节点时连线重绘卡顿,用脏标记 + requestAnimationFrame 批处理优化到 60fps。」
这段话的优势:
① 有具体技术难点(坐标换算)→ 真实
② 有设计模式(命令模式)→ 体现功底
③ 有性能优化(rAF 批处理)→ 有深度
④ 涉及图论(防环 DFS)→ 算法加分
这个话题能体现你的设计模式 + 交互 + 性能 + 算法综合能力,适合讲给注重基础的中高级岗位。

面试亮点专题 04 — jsPlumb 流程设计器深度话题

坐标换算 · 命令模式 · 脏标记批处理 · 防环 DFS · 视口剔除 · jsPlumb 选型

← 返回专题首页