从零手写一个毛玻璃风格的页面搭建台:单文件 HTML 实现组件放置、面板磁吸与图层管理

一、背景

做内部工具、活动页或原型时,常常需要"拖几个输入框、日期框、轮播图到页面上,看看版式"。现成方案很多,但要么依赖 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 核心模式

整套代码围绕四个设计决定展开:

  1. 组件注册表(Registry) :每种组件只是 REG 里的一条配置------默认尺寸、默认属性、渲染函数、可编辑字段。新增组件不需要改其他逻辑。
  2. 扁平数据模型 :画布状态就是一个 items 数组,每项存绝对坐标,DOM 全部平铺在画布上,不做真正的 DOM 嵌套。
  3. 事件委托 :只在画布容器上绑定 input / change / click / pointerdown,组件重新渲染后无需重复绑定。
  4. 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。后果有两层:

  1. 读取值时走了 it[k] 而不是 it.props[k],轮播的 slides 读出来是 undefined;
  2. 对 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,容量有限,大图可能存不进去;
  • 没有撤销/重做、多选和对齐辅助线。

六、总结

这个项目最大的收获,是"用数据描述一切"带来的简化:

  1. 注册表模式让新增组件只需写一条配置,组件库、渲染、属性表单全部自动生效;
  2. 扁平模型 + 几何包含绕开了 DOM 嵌套的复杂度,让"面板装组件、面板带着组件走"只需几行过滤代码;
  3. 事件委托 + Pointer Events让交互代码集中、兼容触屏,重新渲染后也不用重新绑定;
  4. CSS 变量让换主题和做截图专用样式都变成"覆盖一组变量";
  5. 一个具名与位置之争的 Bug提醒我,声明式配置越灵活,越要避免依赖数组下标。

后续可以继续做的方向:撤销/重做(给 items 做快照栈)、对齐辅助线、多选与成组、导出为独立可运行的 HTML、以及把组件归属显式化(引入 parent 字段)以支持更严格的容器语义。

如果你正在选型,建议:需要完整产品级体验时直接用 Puck、GrapesJS 这类成熟方案;想理解页面搭建器的内核,或只需要一个轻量的内部工具,这样的单文件实现已经足够起步。

相关推荐
葡萄城技术团队9 小时前
1000 人协同压测的环境瓶颈排查:CPU 争用与 Wi\-Fi
前端
excel10 小时前
Marked 与 Markdown 有什么区别?该怎么选?
前端
Da Da 泓11 小时前
五子棋 Web 端测试用例报告
前端·网络·websocket·网络协议·测试用例·项目
zxcvb15312 小时前
远程查看本地文档:一条轻量 Web 文件服务的工程化链路
前端·远程查看文档
niucloud-admin12 小时前
NIUCLOUD PHP SAAS上门家政使用文档:城市管理
java·前端·php
北岛贰12 小时前
啵啵音乐迎来全新升级!
前端
hkNaruto14 小时前
【AI】基于大模型的AI游戏地图生成、控制方案:AI Native Game Harness 迁移操作手册
前端·人工智能·游戏
IT_陈寒14 小时前
Java序列化踩坑记:对象循环引用让我加班两小时
前端·人工智能·后端
Acro15 小时前
快给macOS 上的Grok Bot换一套皮肤
前端