🪂 又一个 H5 拖放平台
!本篇文章过于久远,其中观点和内容可能已经不准确,请见谅!~
想让你了解的是一个拖拽生成的低代码平台,虽然是低配的,但是实现起来还挺有意思。
H5 本来是 HTML5 的缩写,与 CSS3 和 ES5 代表着当时多年迟滞的高级浏览器特性更新,随着移动互联网的发展,逐渐的含义在运营层面变成了互动网页,更是基本上特指手机端的互动营销页面。H5 的形式在当时是一个非常热门的营销方式,毕竟快速接入,获客方便。
目前的 H5 逐步发展成熟,包括 Hybrid H5 的普遍应用,还有组件库、SPA 等等概念的兴起,说起来各大公司的实际业务更多是在做 H5,在追求急速敏捷的开发阶段,搭建式的 H5 平台也异常丰富。
广义上的 h5 编辑平台可以理解成支持图文形式、所见所得的拖拽生成,配合数据层、组件库、素材库等,最后生成 h5 网页的形式。
从功能上来看:
- 图文编辑器:适配于各大公众号和内容平台,主要是流式布局,重点在图文排版。
- H5 编辑器:作为互动单页或翻页,主要是绝对布局,重点在设计、互动和动画。
从方向来看:
- 设计向: 目标是产品原型,重点在布局,自适应,大部分元素是矢量和图形。
- 原型向:重点在生成配置,根据配置额外再做生成,不追求所见所得。
- 业务向:做一个业务层的快搭平台,快速搭营销落地页、活动页、抽奖页、落地页等等。既有面向内部运营的业务,面向 c 端的邀请函,面向开发的低代码。
我们的产品属性属于面向 c 端的一个快搭业务,追求设计、互动和动画,设计师做好模板,用户自己额外再微调定制。
在项目初期,我们调研了市面上主流的 H5 平台:
- 易企秀:成熟的模板市场,但定制能力有限
- 婚礼纪:模板强大生态多样
这些平台各有优势,但都可惜没法直接用,我们业务上需求也并不是很复杂,能用就行慢慢迭代,初期指定的目标就是多页动画、图片文字可 DIY。
说起拖拽组件,那么最核心的是什么呢?对咯,就是底层设计,就是数据结构、布局规则、交互方法。
支持哪些特性和功能直接能够反映在最后的数据结构上,只有确定了布局规则才能确定所见即所得的法子,只有缕清了交互才能着手去做。
我们做了 4 个模式来支持这个需求:
- factory — PC 端编辑器,设计师全功能设计工作台
- diy — 手机端编辑,用户简单的文字图片替换和预览
- runner — 纯预览播放器,Swiper 驱动的多页翻页展示
- transfer — 第三方格式转换器
- 单页、多页和长页都支持
- 图片、文字、矢量图等素材,预设的表单等组件
- 动画:进入、强调、退出
- 画布缩放、拖动、旋转、分组
- 图层混合、页面滤镜
用
TypeScript 自然也要提前写个类型,方便后续使用,根据经验加一点点讨论,确定出基本结构。整个页面数据用 Immutable.js 的 Record 来管理,这样结构性共享和比较都很方便,改一个元素不会牵连到整棵树。
最顶层是 Page:
Page {title: string // 页面标题desc: string // 描述width: number // 画布宽度,默认 375(iPhone 宽度)height: number // 画布高度,默认 667views: List<View> // 有序列表,每个 View 就是一页defaultViewState: ViewState // 默认的页面视觉状态viewEffect: string // 翻页效果:swiper|scroll|fade|cube|flip|creative-1~6music: string // 背景音乐地址musicLoop: boolean // 默认 trueblobs: { [key]: string } // 内嵌资源,base64 或 SVG 字符串}
View 就是一张幻灯片:
View {id: stringelements: List<Elem> // 页面上的元素列表state: ViewState // 背景色、滤镜等}
元素是核心,所有元素共享一组基础属性:
ElemBaseProps {name, id, x, y, width, heightrotation // 旋转角度blend // CSS mix-blend-modeopacity // 0-100filter // 滤镜预设名animations // 动画列表backgroundColorborderRadius // 四个角独立控制keepRatio // 锁定宽高比locked, visiblediy // 是否允许用户 DIY 编辑}
这里有个比较关键的设计决策:元素的 x、y 坐标是中心点,不是左上角。这么做的好处是旋转时天然绕视觉
中心转,不需要额外算偏移;对齐两个元素也是对齐中心点,直觉上更自然。
具体有 4 种元素类型:
- ElemImg(
type: "image"):加src、fit(cover/contain/none)、fitPos - ElemText(
type: "text"):加content、fontSize、fontFamily、color、lineHeight、textAlign、letterSpacing、textEffect等一整套文字属性 - ElemShape(
type: "shape"):加fill、stroke、strokeWidth,以及一个 SVG 模板字符串(支 持{{expression}}模板语法) - ElemGroup(
type: "group"):加elems: List<Elem>,递归结构,组里可以嵌套组
元素全部用绝对定位,画布本身是一个 8000×8000 的虚拟空间,通过 CSS transform 的 scale 和 translate 来
实现缩放和平移。
定位的核心逻辑通过 CSS 变量实现,因为 x/y 是中心点,所以需要算一次偏移:
--ele-x: calc({x}px - (var(--ele-w) / 2))--ele-y: calc({y}px - (var(--ele-h) / 2))--ele-w: {width}px--ele-h: {height}pxtransform: translate(var(--ele-x), var(--ele-y))
组内的元素坐标则是百分制的,相对于组的尺寸来算。这样组 resize 的时候子元素会等比缩放:
--ele-w: calc({width}% / 100 * var(--ele-group-w))--ele-x: calc({x}% * var(--ele-group-w) - var(--ele-w) / 2)
不同模式的缩放策略不一样:编辑器里用户自己滚轮缩放、diy 和播放模式下根据
window.innerWidth / page.width 自适应。画布交互由
MoveBox 类统一管理,它继承自 EventEmitter,处理所有鼠标事件。交互模式包括:选择、拖动画布、缩放、放置文字、放置图形。拖拽过程中有一个性能优化:不更新 React 状态,而是直接改 DOM 上的 CSS 变量。只有鼠标松开时才把最终值提交到 Immutable 数据模型里。这样拖拽全程不会有 React re-render,保持 60fps。
另外实现了三套吸附系统,可以独立开关:
- 元素吸附:和其他元素的边缘/中心对齐
- 画布吸附:对齐到页面边界和中心线
- 像素吸附:坐标取整
吸附检测用的距离阈值默认 5px,旋转时吸附到 45° 的整数倍。还有框选功能,通过 SAT(分离轴定理)碰撞检测来支持旋转元素的正确框选。
没有搞什么组件注册表,就是朴素的类型判断:
{elem.type === 'image' && <ElementImage ... />}{elem.type === 'text' && <ElementText ... />}{elem.type === 'shape' && <ElementShape ... />}{elem.type === 'group' && <ElementGroup ... />}
每个元素渲染成三层嵌套的 DOM 结构:
<div class="h5kit-element"><!-- 外层:定位 --><div class="h5kit-element-anim"><!-- 中层:动画 --><div class="h5kit-element-rotation"><!-- 内层:旋转 --><div class="h5kit-element-content"><!-- 内容 --></div></div></div></div>
把动画、旋转、内容分到不同的层上,各自独立控制互不干扰。动画包裹在最外面,所以动画生效时连旋转都带上;旋转只管自己的 transform;内容层只管渲染。
图片用的是
background-image 而不是 <img> 标签,这样 cover/contain 的适配直接用 CSS 搞定。图片来源走了三级查找:先查 blobs 字典(内嵌资源),再试 HTTP URL 或 data URI,最后查localForage(IndexedDB 里存的 Blob),尽可能的避免资源重复嵌入问题。Shape 用的是 SVG 模板引擎。模板里可以用
{{expression}} 写 JavaScript 表达式,运行时沙盒求值,上下文里注入了 __WIDTH__ 和 __HEIGHT__ 两个变量。比如画一个自适应的圆:
<circle cx="{{__WIDTH__/2}}" cy="{{__HEIGHT__/2}}"r="{{Math.min(__WIDTH__,__HEIGHT__)/2}}"/>
处理顺序是先 HTML 解码,再求值模板表达式,最后把剩余的
__WIDTH__/__HEIGHT__ 字面量替换成实际尺寸。填充色和描边色通过动态注入 CSS 规则来应用,用 !important 覆盖 SVG 内联样式。右侧属性面板基于 TweakPane 做了二次开发(fork 了一份放在项目里),加了几个自定义插件:图片预览上传、SVG 预览、多行文本、贝塞尔曲线选择器等。这样的好处是不用处理太复杂的表单逻辑,只需要一个编辑面板的控制,其他全都封到 TweakPane 中,毕竟也不涉及太多的取值和动态表单。也就不需要引入额外的 formify 之类的库了。
选中单个元素时展示完整属性:位置、尺寸、旋转、圆角、透明度、混合模式、滤镜、背景色等。选中多个元素时展示批量操作:分组/取消分组、六宫格对齐、四宫格分布。文字、图片、图形各自还有独立的类型面板。
在 animate.css 的基础上做的动效,总共大概 90 多个,分三类:
- 进入动画(46 个):fadeIn 系列、slideIn 系列、bounceIn 系列、zoomIn 系列、rotateIn 系列,还有
自定义的 curve 曲线动画和百分比距离动画(比如
fadeInDown30per只移动 30% 的距离) - 退出动画(28 个):fadeOut、slideOut、bounceOut 等
- 强调动画(16 个):bounce、pulse、shake、swing、tada、jello、heartBeat 等
每个动画条目有自己的配置:延迟、时长、缓动曲线、重复次数(1-5 或无限循环)。
播放时按 View 遍历每个元素,再遍历每个元素上的动画列表,逐个执行。具体操作就是找到
.h5kit-element-anim 节点,清掉旧的 class,设置 inline style(duration、delay、repeat、timing),加上 animate__animated 和对应的动画 class,监听 animationend 事件。编辑器和播放器有个区别:编辑器里动画播完后会清掉 CSS class,让元素回到初始位置方便继续编辑;播放模式下无限循环动画不会监听
animationend,让它一直播。多页之间的切换效果基于 Swiper,支持 swiper、scroll、fade、cube、flip 以及 6 种 creative 效果。
自己写了一个轻量的状态管理框架叫 Novus,核心思路类似 zustand 但简单很多。
两个 Model:
- DesignModel:页面数据、元素、撤销重做
- ConfModel:画布状态、焦点、面板、吸附设置
订阅机制用了依赖数组,只有指定的 Model 变了才触发对应的订阅者。通知调度用
requestIdleCallback 做16ms 的时间切片,防止短时间内大量状态变更导致渲染风暴。同一个微任务内的多次 setState 会合并成一次通知。React 侧提供了
connect HOC 和 useNovus Hook,Hook 里用 ES Proxy 做了第一轮渲染时的自动依赖收集。撤销重做维护了 undoList 和 redoList,最多 50 条历史,每次
updatePageWithHistory 推入当前快照并清空重做栈。做下来整体还是比较顺利的,毕竟需求明确,不追求大而全。回头来看几个比较值得记录的点:
- 中心点坐标系这个设计虽然一开始觉得别扭,但后面旋转、对齐、分组都因此简化了不少逻辑
- 拖拽时直接操作 CSS 变量绕过 React,这个优化效果立竿见影,画布上几十个元素拖起来也不会卡
- SVG 模板引擎简单但够用,
{{expression}}的方式让设计师可以灵活地画各种自适应图形 - 三级图片资源查找(blobs → URL → localForage)覆盖了离线和在线场景
后续计划是补上 undo/redo 的快捷键、支持更多 SVG 滤镜预设、以及把 diy 模式下的交互体验再打磨一下。整体来说,对于一个面向 C 端的轻量 H5 定制场景,这套方案够用了。相比活动 H5 的那种带有数据联动的形式,这种还是更简单些,不需要复杂的取值和数据同步逻辑。
絮絮叨叨的流水账记下来,感觉比写文档还费劲,不过也算是对自己工作的一个总结。
具体效果可以前往预览:H5 拖放编辑器
感谢您的阅读,本文由 Ubug 版权所有。如若转载,请注明出处:Ubug(https://ubug.io/blog/h5kit)