一、背景
做内部工具、活动页或原型时,常常需要"拖几个输入框、日期框、轮播图到页面上,看看版式"。现成方案很多,但要么依赖 React 等框架,要么体量很大。我想验证一件事:只用一个 HTML 文件(HTML + CSS + 原生 JavaScript),能不能做出一个可用的可视化搭建台?
同时,我希望界面采用 2026 年流行的极简毛玻璃(Glassmorphism)深色风格,并沿用 Tailwind 的"设计令牌"思路,但全部手写 CSS,不引入任何 CSS 框架。
这个项目经历了三个版本迭代:
- v1:组件库 + 点击放置 + 拖动缩放 + 网格吸附;
- v2:新增"面板"容器、磁吸、属性检查器、主题切换、截图导出;
- v3:修复一个运行时 Bug,补充布局模板、图层列表、复选框与列表框,并把"每个组件一个窗口"改为"只有面板是窗口"。
本文基于最终的 v3 源码,拆解其设计与实现。
"C:\Users\86182\Downloads\builder-v3.html"

二、目标
| 类别 | 目标 |
|---|---|
| 组件 | 输入框、日期框、轮播、图片、备忘录、按钮、标题、复选框、列表框、面板 |
| 交互 | 点击组件库中的组件后,在画布指定位置放置;也支持直接拖入 |
| 容器 | 面板可放在任意位置,可吸附,并能容纳其他组件 |
| 编辑 | 拖动、缩放、删除、属性设置、图层列表 |
| 外观 | 深色毛玻璃,可切换多套主题 |
| 输出 | 导出布局截图(PNG)与布局数据(JSON) |
| 约束 | 单文件,双击即可在浏览器运行;代码带中文注释 |
三、方法:技术选型
3.1 参考了哪些开源项目
动手前我查阅了 GitHub 上的同类项目,主要参考其"模式",而不是直接引入依赖:
- Puck:React 页面编辑器。开发者注册自己的组件,用户把组件拖到画布上,最终得到一份 JSON,再由应用渲染。这正是下文"注册表 + 数据驱动"的思路来源。
- GridStack.js:偏仪表盘的网格拖拽库,自带网格系统,适合复杂网格布局。
- GrapesJS、VvvebJs:功能完整的网页构建器,适合需要完整建站能力的场景。
- SortableJS:列表拖拽排序库,适合做排序与嵌套。
结论:这些项目体量都比较大,而我只需要"放置 + 拖动 + 容器 + 属性"这几个核心能力,所以选择自己实现一个最小内核。
3.2 核心模式
整套代码围绕四个设计决定展开:
- 组件注册表(Registry) :每种组件只是
REG里的一条配置------默认尺寸、默认属性、渲染函数、可编辑字段。新增组件不需要改其他逻辑。 - 扁平数据模型 :画布状态就是一个
items数组,每项存绝对坐标,DOM 全部平铺在画布上,不做真正的 DOM 嵌套。 - 事件委托 :只在画布容器上绑定
input/change/click/pointerdown,组件重新渲染后无需重复绑定。 - CSS 变量主题 :所有颜色走变量,换主题就是切换
body[data-theme]。
四、过程:关键实现拆解
4.1 数据模型与组件注册表
一个组件的数据长这样:
js
{ id:'i...', type:'input', x:100, y:80, w:280, h:80, r:12,
props:{ label:'姓名', ph:'请输入...', value:'' } }
其中 x/y/w/h/r 是所有组件通用的几何属性,props 是各组件私有属性。注册表(节选):
js
const REG = {
input: {
name:'输入框', w:280, h:80,
props:()=>({label:'姓名', ph:'请输入...', value:''}),
html:p=>`<input class="lab" data-k="label" value="${esc(p.label)}">
<input data-k="value" placeholder="${esc(p.ph)}" value="${esc(p.value)}">`,
f:[['label','标签','text'],['ph','占位提示','text'],['value','默认值','text']]
}
};
html 负责渲染,f 描述属性表单。左侧组件库、右侧属性面板都是遍历 REG 自动生成的,这就是"数据驱动"的好处。
4.2 点击放置:武装状态 + 虚影
点击左侧组件会进入"待放置"状态(代码里叫 armed)。此时画布变成十字光标,鼠标移动会显示一个虚线虚影,点击后真正落位。
落位矩形由 rectFor 计算,它同时完成三件事:吸附网格、限制在画布内、如果落点在某个面板里,就自动装入该面板:
js
function rectFor(type,px,py){
const d=REG[type];let w=d.w,h=d.h,x=snap(px),y=snap(py);
const host=items.filter(p=>p.type==='panel'&&px>=p.x&&py>=p.y&&px<=p.x+p.w&&py<=p.y+p.h)
.sort((a,b)=>a.w*a.h-b.w*b.h)[0]; // 取最小(最内层)的面板
if(host){
w=Math.min(w,host.w-24);h=Math.min(h,host.h-56);
x=clamp(x,host.x+12,host.x+host.w-w-12);y=clamp(y,host.y+44,host.y+host.h-h-12);
}
return {x:clamp(x,0,CW-w),y:clamp(y,0,CH-h),w,h};
}
要点是:虚影和最终落位共用同一个函数,所以"所见即所得"。
4.3 面板:不做 DOM 嵌套,靠几何包含关系
这是整个设计里最取巧、也最省事的一点。面板和子组件在 DOM 里是兄弟节点,没有父子关系;"谁在面板里"在需要时用几何包含临时算出来:
js
const kids = grip && it.type==='panel'
? items.filter(k=>k!==it && k.x>=it.x && k.y>=it.y
&& k.x+k.w<=it.x+it.w && k.y+k.h<=it.y+it.h)
: [];
拖动面板开始时记录子组件的初始坐标,移动时按同样的位移量更新它们。面板嵌套面板也自然成立。
层级用 z-index 控制:面板越大越靠下,普通组件永远在面板之上:
js
z-index: it.type==='panel' ? 2e6 - it.w*it.h : 3e6
这样做的代价是:组件归属不持久化,完全取决于当前几何位置。好处是数据结构极简,JSON 导出也是一维数组。
4.4 磁吸:网格 + 边缘 + 面板内边距
拖动时先吸附 20px 网格,再用 mag 函数做二次磁吸。它收集其他组件的边缘作为候选位置,面板额外提供"内边距线"(左右 12px、上 44px、下 12px),这样子组件很容易对齐到面板内侧:
js
function mag(it,x,y,w,h,skip,rs){
const X=[],Y=[];
items.forEach(o=>{
if(o===it||skip.includes(o))return;
const pn=o.type==='panel';
const ox=[o.x,o.x+o.w,...(pn?[o.x+12,o.x+o.w-12]:[])];
const oy=[o.y,o.y+o.h,...(pn?[o.y+44,o.y+o.h-12]:[])];
ox.forEach(b=>{X.push(b-(x+w));if(!rs)X.push(b-x)}); // 缩放时只吸附右/下边
oy.forEach(b=>{Y.push(b-(y+h));if(!rs)Y.push(b-y)});
});
const pick=a=>a.filter(d=>Math.abs(d)<T).sort((p,q)=>Math.abs(p)-Math.abs(q))[0]||0;
return [pick(X),pick(Y)];
}
返回的是需要修正的偏移量,阈值 T=10。skip 参数用来排除"随面板一起移动的子组件",否则它们会吸附到自己的位移上。
4.5 拖动与缩放:Pointer Events
拖动没有使用 HTML5 的 drag API,而是用 pointerdown + 全局 pointermove / pointerup。原因有两点:一是 Pointer Events 同时覆盖鼠标和触屏;二是需要精确控制吸附与边界,不受浏览器拖拽幽灵图的限制。
手柄元素加了 touch-action:none,避免触屏拖动时页面滚动。缩放同理,通过右下角 .rs 元素触发,并限制最小尺寸 80×40。
左侧组件库还额外支持 HTML5 的 draggable 直接拖入画布,这里用的是 dragover / drop,两套交互并存。
4.6 属性检查器:由字段声明自动生成
右侧"属性"标签页不手写表单。选中组件后,把 REG[type].f 和通用几何字段 COMMON 合并成行,按类型生成控件:
| 类型 | 控件 |
|---|---|
text |
单行输入 |
lines |
多行文本,每行一项(轮播幻灯片、复选框选项等) |
num / range |
数字框 / 滑块 |
color |
取色器 |
sel |
下拉框 |
file |
上传按钮 |
输入时同步写回数据,并重新渲染该组件。为了不在输入过程中丢失焦点,检查器只在"选中项变化"时重建。
4.7 主题:CSS 变量
全部颜色来自 body 上的变量,主题只是覆盖一组变量:
css
body{--bg:#070a14;--glass:rgba(255,255,255,.06);--line:rgba(255,255,255,.14);
--tx:#e8ecf8;--ac:#7dd3fc;--ac2:#c4b5fd;--b1:#3b5bdb;--b2:#7c3aed}
body[data-theme=aurora]{--bg:#04110f;--ac:#5eead4;--b1:#0d9488;--b2:#2563eb}
毛玻璃本身由 backdrop-filter: blur() saturate() 加半透明底色和 1px 描边构成,背景两团模糊光斑(--b1、--b2)负责提供"可被折射的颜色",否则玻璃效果看不出来。主题选择存入 localStorage,读写都包了 try/catch。
4.8 截图导出:html2canvas 的限制与绕行
截图使用 html2canvas 1.4.1(CDN 加载)。它不支持 backdrop-filter ,直接截会丢失毛玻璃。我的做法是:截图时给克隆出来的文档加一个 .shot 类,在这个类下:
- 去掉所有
backdrop-filter和阴影; - 把画布背景换成由
--b1、--b2构成的静态渐变; - 隐藏侧栏、工具栏、手柄和缩放角标,并让滚动容器
overflow:visible。
裁剪范围取所有组件的外接矩形并四周留 40px,scale:2 输出两倍清晰度:
js
const c = await html2canvas(cv,{x:x0,y:y0,width:x1-x0,height:y1-y0,scale:2,
backgroundColor:null,windowWidth:1600,windowHeight:1800,
onclone:d=>d.body.classList.add('shot')});
所以截图中的"玻璃"是用渐变和半透明底色模拟的,与屏幕显示略有差别。
4.9 图层列表与布局模板(v3 新增)
- 图层 :按
y、x排序列出全部组件;统计"有多少个面板几何包含它"作为缩进层级,点击选中,点 ✕ 删除。 - 布局模板 :
LAYOUTS是一组[x,y,w,h,标题]的面板矩形(左侧栏 + 主区、三栏等),点击后直接生成对应面板,用户再往面板里放组件即可。套用前会确认,因为它会清空当前画布。
4.10 Bug 复盘:Cannot read properties of undefined (reading 'join')
这是 v2 的真实报错,且在控制台重复出现。根因在属性检查器的行结构:
js
// 有问题的写法
const rows = [...REG[it.type].f.map(f=>[...f,1]), ...COMMON.map(f=>[...f,0])];
rows.map(([k,l,t,a,b,p]) => { /* p 本该是"是否属于 props" */ });
字段声明的长度不一致:['title','标题','text'] 只有 3 项,追加标志位后变成 [k,l,t,1],解构时这个 1 被当成了 a,而 p 变成 undefined。后果有两层:
- 读取值时走了
it[k]而不是it.props[k],轮播的slides读出来是undefined; - 对
lines类型调用v.join('\n')时抛出上述异常。
此外,所有 props 字段的 data-p 都变成了 "undefined",属性编辑也会写错位置。修复方式是改用具名对象 ,不再依赖数组位置,并给 join 加空值保护:
js
const rows = [
...REG[it.type].f.map(([k,l,t,a,b])=>({k,l,t,a,b,p:1})),
...COMMON.map(([k,l,t,a,b])=>({k,l,t,a,b,p:0}))
];
rows.map(({k,l,t,a,b,p}) => { /* ... */ });
// 以及
esc((v||[]).join('\n'))
教训很朴素:位置型的"元组"一旦长度不固定,就不要再往末尾追加标志位。 数据结构带名字,成本很低,却能避免这一类静默错位。
4.11 去窗口化:只有面板是窗口
v2 里每个组件都带标题栏和玻璃边框,页面像堆了一堆小窗口,也不像真实页面。v3 的改法:
- 只有
type==='panel'的组件挂载pn glass样式; - 普通组件没有背景和边框,内部控件自带半透明填充;
- 拖动手柄从"常驻标题栏"改成悬停或选中时浮在组件上方的小胶囊,预览模式下彻底隐藏;
- 选中态用
outline呈现,不占布局空间; - 圆角通过 CSS 变量
--r传给内部控件,面板与普通组件分别设置默认值(22 与 12)。
五、结果
5.1 最终功能
| 模块 | 内容 |
|---|---|
| 左侧栏 | 10 种组件、4 个页面布局模板 |
| 画布 | 20px 点阵网格、虚影预览、放置、拖动、缩放、Delete 删除、Esc 取消 |
| 面板 | 任意位置放置、嵌套、随面板移动子组件、边缘与内边距磁吸 |
| 右侧栏 | "属性"和"图层"两个标签页 |
| 主题 | 极夜、极光、落日、浅雾 |
| 输出 | 预览模式、PNG 截图、JSON 导出 |
| 持久化 | localStorage(键名带版本号),失败时静默忽略 |
5.2 验证情况与已知局限
我对最终文件做了 JavaScript 语法检查(node --check),通过;但没有在真实浏览器里做过系统测试,也没有性能数据,所以文中不给出基准数字,实际使用请自行验证,尤其是截图功能。
已知局限:
- 在画布上直接改文字后,右侧属性面板需要重新选中组件才会同步;
- 截图依赖联网加载的 html2canvas,离线时仅此功能不可用;
- 组件归属由几何位置决定,删除面板不会删除其内部组件;
- 图片以 Base64 存入
localStorage,容量有限,大图可能存不进去; - 没有撤销/重做、多选和对齐辅助线。
六、总结
这个项目最大的收获,是"用数据描述一切"带来的简化:
- 注册表模式让新增组件只需写一条配置,组件库、渲染、属性表单全部自动生效;
- 扁平模型 + 几何包含绕开了 DOM 嵌套的复杂度,让"面板装组件、面板带着组件走"只需几行过滤代码;
- 事件委托 + Pointer Events让交互代码集中、兼容触屏,重新渲染后也不用重新绑定;
- CSS 变量让换主题和做截图专用样式都变成"覆盖一组变量";
- 一个具名与位置之争的 Bug提醒我,声明式配置越灵活,越要避免依赖数组下标。
后续可以继续做的方向:撤销/重做(给 items 做快照栈)、对齐辅助线、多选与成组、导出为独立可运行的 HTML、以及把组件归属显式化(引入 parent 字段)以支持更严格的容器语义。
如果你正在选型,建议:需要完整产品级体验时直接用 Puck、GrapesJS 这类成熟方案;想理解页面搭建器的内核,或只需要一个轻量的内部工具,这样的单文件实现已经足够起步。