前端大屏现场配置里的二维码拖拽和缩放:坐标、边界、持久化如何设计

二维码拖拽这个功能,看起来很小。一个 mousedown,一个 mousemove,改一下 left/top,再保存到后台,好像就完了。

但放到大屏项目里,它会变成一个挺真实的工程问题:屏幕尺寸不固定,二维码可能要横向和纵向分别缩放,拖拽不能出界,松手后要持久化,刷新后要恢复,现场操作还不能因为频繁请求卡住。

这篇文章就用二维码位置配置为例,聊聊大屏现场配置类交互怎么设计。

问题背景

互动大屏经常需要现场调整元素位置:

  • 二维码位置。
  • 奖品展示位置。
  • 排行榜头像位置。
  • 富豪榜或冠军展示位置。
  • 背景模式下的装饰元素。

这些位置不能写死。因为现场屏幕比例、投影裁切、浏览器缩放都可能不一样。

所以前端要提供一种能力:运营人员在大屏上直接拖拽、缩放,调整完后保存,下次进入还能恢复。

常见问题

初版拖拽通常会遇到:

  • 元素拖出屏幕外。
  • 缩放后边界判断不准。
  • 刷新后位置恢复了,但缩放没恢复。
  • 鼠标松开在浏览器外,mouseup 没触发。
  • 拖动过程中频繁调用保存接口。
  • 大屏尺寸变化后,原坐标不再适合。
  • document.onmousemove 被其他组件覆盖。

这类功能小,但细节很容易漏。

初始写法

常见代码大概是:

move(e) {
  const el = e.currentTarget
  const disX = e.clientX - el.offsetLeft
  const disY = e.clientY - el.offsetTop

  document.onmousemove = event => {
    let left = event.clientX - disX
    let top = event.clientY - disY

    left = Math.max(0, Math.min(left, window.innerWidth - el.offsetWidth))
    top = Math.max(0, Math.min(top, window.innerHeight - el.offsetHeight))

    this.position.x = left
    this.position.y = top
  }

  document.onmouseup = () => {
    document.onmousemove = null
    document.onmouseup = null
    this.savePosition()
  }
}

这段能跑,但有几个隐患:

  • document.onmousemove 是覆盖式绑定,不利于多个拖拽组件共存。
  • 边界只考虑了 offsetWidth,没有考虑 transform: scale()
  • 坐标用像素保存,换屏幕后可能不适配。
  • 保存接口没有防抖和失败恢复。

坐标模型

我更推荐把位置拆成两层:

  • 运行时使用像素,拖拽更直接。
  • 保存时使用比例,跨屏幕更稳定。
function toRatioPosition(position, container) {
  return {
    x: position.x / container.width,
    y: position.y / container.height,
    scaleX: position.scaleX,
    scaleY: position.scaleY
  }
}

function fromRatioPosition(config, container) {
  return {
    x: Math.round(config.x * container.width),
    y: Math.round(config.y * container.height),
    scaleX: config.scaleX || 1,
    scaleY: config.scaleY || 1
  }
}

如果旧系统已经保存的是像素,也可以先兼容像素,后面再逐步迁移到比例。

边界计算

缩放后的边界不能只用 offsetWidth

function clampPosition(position, element, container) {
  const width = element.offsetWidth * position.scaleX
  const height = element.offsetHeight * position.scaleY

  return {
    ...position,
    x: Math.max(0, Math.min(position.x, container.width - width)),
    y: Math.max(0, Math.min(position.y, container.height - height))
  }
}

如果允许横向和纵向分别缩放,scaleXscaleY 都要参与计算。

拖拽实现

addEventListener 比覆盖 document.onmousemove 更稳:

function createDragController(options) {
  let dragging = false
  let offsetX = 0
  let offsetY = 0

  function onPointerMove(event) {
    if (!dragging) return

    const next = {
      ...options.getPosition(),
      x: event.clientX - offsetX,
      y: event.clientY - offsetY
    }

    options.setPosition(
      clampPosition(next, options.element, options.container())
    )
  }

  function onPointerUp() {
    if (!dragging) return
    dragging = false
    window.removeEventListener('mousemove', onPointerMove)
    window.removeEventListener('mouseup', onPointerUp)
    options.onCommit && options.onCommit(options.getPosition())
  }

  function onPointerDown(event) {
    const position = options.getPosition()
    dragging = true
    offsetX = event.clientX - position.x
    offsetY = event.clientY - position.y

    window.addEventListener('mousemove', onPointerMove)
    window.addEventListener('mouseup', onPointerUp)
  }

  options.element.addEventListener('mousedown', onPointerDown)

  return {
    destroy() {
      options.element.removeEventListener('mousedown', onPointerDown)
      window.removeEventListener('mousemove', onPointerMove)
      window.removeEventListener('mouseup', onPointerUp)
    }
  }
}

拖拽过程中只更新本地状态,松手时再保存。

缩放和持久化

缩放按钮不要直接操作 DOM,而是改状态:

function scale(position, axis, direction) {
  const step = direction > 0 ? 0.1 : -0.1
  const next = {
    ...position
  }

  if (axis === 'x') {
    next.scaleX = Math.max(0.5, Number((next.scaleX + step).toFixed(1)))
  }

  if (axis === 'y') {
    next.scaleY = Math.max(0.5, Number((next.scaleY + step).toFixed(1)))
  }

  return next
}

保存时加一个防抖,避免连续点缩放按钮时请求过多:

function debounce(fn, delay) {
  let timer = null

  return function(...args) {
    clearTimeout(timer)
    timer = setTimeout(() => fn.apply(this, args), delay)
  }
}

const saveConfig = debounce(async position => {
  await api.saveElementPosition({
    id: screenId,
    ...toRatioPosition(position, getContainerRect())
  })
}, 300)

Vue 组件中的使用

<template>
  <div
    ref="qrcode"
    class="qrcode"
    :style="style"
  >
    <img :src="qrcodeUrl" draggable="false" />
  </div>
</template>
computed: {
  style() {
    return {
      left: `${this.position.x}px`,
      top: `${this.position.y}px`,
      transform: `scale(${this.position.scaleX}, ${this.position.scaleY})`
    }
  }
},
mounted() {
  this.dragController = createDragController({
    element: this.$refs.qrcode,
    container: () => ({
      width: document.body.offsetWidth,
      height: document.body.offsetHeight
    }),
    getPosition: () => this.position,
    setPosition: position => {
      this.position = position
    },
    onCommit: position => {
      this.savePosition(position)
    }
  })
},
beforeDestroy() {
  this.dragController && this.dragController.destroy()
}

异常恢复

现场配置一定要考虑保存失败:

  • 保存成功后再更新“已保存”提示。
  • 保存失败时保留当前视觉位置,但提示用户未保存。
  • 下次刷新仍以服务端位置为准。
  • 如果服务端返回非法坐标,前端要 clamp 一次。

不要让接口失败把元素直接弹回去,这对现场操作人员很不友好。

方案对比

直接保存像素最简单,但换屏幕后容易偏。

保存比例更稳定,但旧数据迁移需要处理。对于新功能,我更倾向于比例保存;对于旧项目,可以兼容两套字段。

拖拽时实时保存能最大程度避免丢配置,但请求频率太高。松手保存加缩放防抖,一般是更合适的折中。

验证清单

1. 拖到四个角,确认不会出界。
2. 横向放大、纵向缩小后,再拖到边界,确认边界仍准确。
3. 连续点击缩放按钮,确认保存接口不会被刷爆。
4. 保存失败时,页面有提示,刷新后回到服务端旧配置。
5. 修改浏览器窗口尺寸,确认位置能按比例恢复。
6. 组件销毁后,window 上没有残留 mousemove 事件。

小结

二维码拖拽不是一个炫技功能,但它很能体现前端工程细节。

一个小小的位置配置,背后涉及浏览器事件、坐标模型、transform 缩放、接口持久化、异常恢复和现场操作体验。把这种小功能写扎实,比单纯堆页面更能体现项目经验。

posted @ 2026-06-22 15:04  万物皆object  阅读(3)  评论(0)    收藏  举报