L1 · 原生无限画布
零依赖:三个变量(scale / panX / panY)+ 一条锚点缩放公式,就是一块能缩放、能平移、卡片能拖着走、还带连线和网格的无限画布。适合静态作品集、展示页、任何不需要持久化编辑的场景。
试这几下:
- 滚轮缩放 —— 注意缩放始终锚定鼠标位置,鼠标底下那个点不漂移
- 拖空白平移画布,拖卡片移动单张卡 —— 连线实时跟随
- 缩放到很小再点适应全部,画布按内容包围盒回正
结构
整个画布只有三层,各司其职:
.viewport 裁剪窗口,overflow:hidden,自己永远不动,接收所有鼠标事件
├─ .grid 网格:CSS background,不进 transform 层
└─ .world 世界层:唯一被 transform 的元素
├─ svg.wires 连线(与节点同层,缩放时自动同步)
└─ .card ×N 普通 absolute 定位 div,left/top 是 world 坐标
要点
- 锚点缩放一条公式:
ratio = 1 - next/scale; panX += (ax - panX) * ratio—— 保证锚点处的 world 坐标缩放前后不变。这条公式 L1 到 L4 通用,值得背下来 - 网格用 CSS background 而不是画图:
background-size乘 scale、background-position对28 * scale取模,跟随视口几乎零开销;千万别用 SVG 画一整张网格 will-change: transform把 world 层提升成独立合成层,平移缩放走 GPU,不触发重排- 坐标换算只有一对函数:
screenToWorld/worldToScreen,拖卡片时把指针换算到 world 坐标再减初始偏移,天然适配任意缩放级别 - 一套 Pointer Events 通吃鼠标/触屏/触控笔;
setPointerCapture保证快速甩动不脱手;touch-action: none防止触屏把拖动抢去做页面滚动 - wheel 监听要
{ passive: false }才能preventDefault,否则浏览器照常滚动页面 - 连线是 world 层里一个
overflow: visible的<svg>,三次贝塞尔从源卡右侧到目标卡左侧,只在卡片移动时重画
局限(升级信号)
源码(折叠)
demo 源码:assets/demo-l1-vanilla.html(自包含单页,零依赖)
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>L1 · 零依赖无限画布 — vanilla JS + CSS transform</title>
<style>
* { box-sizing: border-box; margin: 0; }
html, body { height: 100%; overflow: hidden; }
body { font-family: system-ui, "PingFang SC", "Microsoft YaHei", sans-serif; }
/* 视口:裁剪窗口,自身不动 */
.viewport {
position: relative; width: 100%; height: 100vh; overflow: hidden;
background: #f7f8fa; cursor: grab; touch-action: none;
}
.viewport.panning { cursor: grabbing; }
/* 网格:CSS background 而不是画图 —— size 跟缩放、position 跟平移 */
.grid {
position: absolute; inset: 0; pointer-events: none;
background-image: radial-gradient(circle, rgba(30,41,59,.18) 1px, transparent 1px);
background-size: 28px 28px;
}
/* 世界层:唯一被 transform 的元素,所有内容住在里面 */
.world {
position: absolute; top: 0; left: 0;
transform-origin: 0 0; will-change: transform;
}
svg.wires { position: absolute; top: 0; left: 0; overflow: visible; pointer-events: none; }
svg.wires path { fill: none; stroke: #94a3b8; stroke-width: 1.5; }
.card {
position: absolute; width: 190px; background: #fff;
border: 1px solid #dbe1ea; border-radius: 10px;
box-shadow: 0 2px 8px rgba(15,23,42,.08);
cursor: grab; user-select: none;
}
.card.dragging { cursor: grabbing; box-shadow: 0 10px 26px rgba(15,23,42,.20); }
.card h4 { font-size: 13px; padding: 9px 12px 8px; border-bottom: 1px solid #eef1f6; color: #0f172a; }
.card h4 .kind { float: right; font-size: 10px; font-weight: normal; color: #6d8fd4; }
.card p { font-size: 12px; color: #64748b; padding: 8px 12px 10px; line-height: 1.55; }
.hud {
position: absolute; left: 12px; bottom: 12px; z-index: 5;
display: flex; gap: 8px; align-items: center;
font-size: 12px; color: #475569;
}
.hud button {
font: inherit; padding: 4px 12px; border-radius: 7px; cursor: pointer;
border: 1px solid #cbd5e1; background: #fff; color: #334155;
}
.hud button:hover { border-color: #94a3b8; }
.hud .zoom { min-width: 46px; text-align: center; font-variant-numeric: tabular-nums; }
.tip {
position: absolute; right: 12px; top: 12px; z-index: 5;
font-size: 11.5px; color: #94a3b8; background: rgba(255,255,255,.85);
border: 1px solid #e2e8f0; border-radius: 8px; padding: 6px 10px; line-height: 1.7;
}
</style>
</head>
<body>
<div class="viewport" id="viewport">
<div class="grid" id="grid"></div>
<div class="world" id="world">
<svg class="wires" id="wires" width="1" height="1"></svg>
<!-- 卡片由 JS 渲染:普通 absolute 定位 div,样式全用 HTML/CSS 写 -->
</div>
<div class="tip">滚轮缩放(锚定鼠标)· 拖空白平移 · 拖卡片移动</div>
<div class="hud">
<button id="zoomOut">−</button>
<span class="zoom" id="zoomLabel">100%</span>
<button id="zoomIn">+</button>
<button id="fit">适应全部</button>
</div>
</div>
<script>
/* ============ 数据:world 坐标下的卡片与连线 ============ */
const cards = [
{ id: 'a', x: 120, y: 140, title: '需求收集', kind: '输入', body: '普通的 absolute 定位 div,鼠标事件直接绑,样式全是 HTML/CSS。' },
{ id: 'b', x: 470, y: 60, title: '方案设计', kind: '加工', body: '拖我试试 —— 屏幕位移除以 scale 换算回 world 坐标。' },
{ id: 'c', x: 470, y: 300, title: '技术评审', kind: '加工', body: '连线是 world 层里的一个 <svg>,缩放时与节点同步变换。' },
{ id: 'd', x: 830, y: 180, title: '发布上线', kind: '输出', body: '网格用 CSS background 实现,跟随缩放几乎零开销。' },
];
const wires = [ ['a','b'], ['a','c'], ['b','d'], ['c','d'] ];
/* ============ 视口状态:三个数就是全部 ============ */
let scale = 1, panX = 0, panY = 0;
const GRID = 28, SCALE_MIN = 0.2, SCALE_MAX = 3;
const viewport = document.getElementById('viewport');
const world = document.getElementById('world');
const grid = document.getElementById('grid');
const wiresSvg = document.getElementById('wires');
const zoomLabel = document.getElementById('zoomLabel');
function applyTransform() {
world.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`;
// 网格跟随:size 乘 scale,position 取模平移 —— 关键技巧
grid.style.backgroundSize = `${GRID * scale}px ${GRID * scale}px`;
grid.style.backgroundPosition = `${panX % (GRID * scale)}px ${panY % (GRID * scale)}px`;
zoomLabel.textContent = Math.round(scale * 100) + '%';
}
const screenToWorld = (sx, sy) => ({ x: (sx - panX) / scale, y: (sy - panY) / scale });
/* 缩放:以锚点(ax, ay)为不动点 */
function zoomAt(ax, ay, next) {
next = Math.min(SCALE_MAX, Math.max(SCALE_MIN, next));
if (next === scale) return;
const ratio = 1 - next / scale;
panX += (ax - panX) * ratio;
panY += (ay - panY) * ratio;
scale = next;
applyTransform();
}
/* ============ 渲染卡片 & 连线 ============ */
const cardEls = {};
for (const c of cards) {
const el = document.createElement('div');
el.className = 'card';
el.dataset.id = c.id;
el.style.left = c.x + 'px';
el.style.top = c.y + 'px';
el.innerHTML = `<h4>${c.title}<span class="kind">${c.kind}</span></h4><p>${c.body}</p>`;
world.appendChild(el);
cardEls[c.id] = el;
}
function drawWires() {
const seg = [];
for (const [from, to] of wires) {
const a = cards.find(c => c.id === from), b = cards.find(c => c.id === to);
const ea = cardEls[from], eb = cardEls[to];
const x1 = a.x + ea.offsetWidth, y1 = a.y + ea.offsetHeight / 2;
const x2 = b.x, y2 = b.y + eb.offsetHeight / 2;
const mx = (x1 + x2) / 2;
seg.push(`<path d="M ${x1} ${y1} C ${mx} ${y1}, ${mx} ${y2}, ${x2} ${y2}"/>`);
}
wiresSvg.innerHTML = seg.join('');
}
/* ============ 交互:一套 pointer events 通吃 ============ */
let mode = null; // 'pan' | 'card'
let dragCard = null, dragOffset = null, lastX = 0, lastY = 0;
viewport.addEventListener('pointerdown', (e) => {
const cardEl = e.target.closest('.card');
viewport.setPointerCapture(e.pointerId);
if (cardEl) {
mode = 'card';
dragCard = cards.find(c => c.id === cardEl.dataset.id);
const w = screenToWorld(e.clientX, e.clientY);
dragOffset = { x: w.x - dragCard.x, y: w.y - dragCard.y };
cardEl.classList.add('dragging');
} else {
mode = 'pan';
lastX = e.clientX; lastY = e.clientY;
viewport.classList.add('panning');
}
});
viewport.addEventListener('pointermove', (e) => {
if (mode === 'pan') {
panX += e.clientX - lastX; panY += e.clientY - lastY;
lastX = e.clientX; lastY = e.clientY;
applyTransform();
} else if (mode === 'card') {
const w = screenToWorld(e.clientX, e.clientY);
dragCard.x = w.x - dragOffset.x;
dragCard.y = w.y - dragOffset.y;
const el = cardEls[dragCard.id];
el.style.left = dragCard.x + 'px';
el.style.top = dragCard.y + 'px';
drawWires();
}
});
function endDrag() {
if (mode === 'card') cardEls[dragCard.id].classList.remove('dragging');
viewport.classList.remove('panning');
mode = null; dragCard = null;
}
viewport.addEventListener('pointerup', endDrag);
viewport.addEventListener('pointercancel', endDrag);
/* 滚轮缩放(指数步进,锚定鼠标位置) */
viewport.addEventListener('wheel', (e) => {
e.preventDefault();
const rect = viewport.getBoundingClientRect();
zoomAt(e.clientX - rect.left, e.clientY - rect.top, scale * Math.exp(e.deltaY * -0.002));
}, { passive: false });
/* HUD */
document.getElementById('zoomIn').onclick = () => zoomAt(viewport.clientWidth / 2, viewport.clientHeight / 2, scale * 1.25);
document.getElementById('zoomOut').onclick = () => zoomAt(viewport.clientWidth / 2, viewport.clientHeight / 2, scale / 1.25);
document.getElementById('fit').onclick = fitAll;
function fitAll() {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const c of cards) {
const el = cardEls[c.id];
minX = Math.min(minX, c.x); minY = Math.min(minY, c.y);
maxX = Math.max(maxX, c.x + el.offsetWidth);
maxY = Math.max(maxY, c.y + el.offsetHeight);
}
const pad = 60;
const sw = viewport.clientWidth / (maxX - minX + pad * 2);
const sh = viewport.clientHeight / (maxY - minY + pad * 2);
scale = Math.min(SCALE_MAX, Math.min(sw, sh));
panX = (viewport.clientWidth - (maxX - minX) * scale) / 2 - minX * scale;
panY = (viewport.clientHeight - (maxY - minY) * scale) / 2 - minY * scale;
applyTransform();
}
drawWires();
fitAll();
</script>
</body>
</html>