前端大屏现场配置里的二维码拖拽和缩放:坐标、边界、持久化如何设计
二维码拖拽这个功能,看起来很小。一个 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))
}
}
如果允许横向和纵向分别缩放,scaleX、scaleY 都要参与计算。
拖拽实现
用 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 缩放、接口持久化、异常恢复和现场操作体验。把这种小功能写扎实,比单纯堆页面更能体现项目经验。

浙公网安备 33010602011771号