news 2026/10/27 21:34:18

从物料注册到快照栈:可扩展拖拽建页内核设计与实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从物料注册到快照栈:可扩展拖拽建页内核设计与实践

简介:这是一份围绕 CMS 拖拽生成页面思路的轻量源码示例,面向希望理解可视化建站的前端初学者、课堂教学者和小型站点开发者,重点解决从左侧组件库拖入画布、在右侧自由排版并把拖入内容替换为自定义内容的基础实现问题。压缩包共 9 个文件,约 63KB,包含 5 个 JavaScript 脚本、3 个 CSS 样式和 1 个 HTML 入口页,分别用于拖拽与克隆逻辑、界面样式和示例页面组织,并可见 dragula 相关依赖文件。已有 3705 人浏览学习,具备较好的入门参考热度。读者可从中梳理 HTML5 拖拽事件的开始、移动与结束处理,理解组件化设计、CSS 布局、数据绑定以及拖拽时的视觉提示和完成动画等交互反馈,也能把这份简易原型用于个人博客、小型企业站或教学演示,并在此基础上扩展更完整的 CMS 页面搭建与权限协作流程。

1. 运营要改一个按钮文案,不该再排一次版

某内容平台的后台有个真实痛点:活动页上「立即报名」要改成「立即预约」,走完需求评审、排期、发版,两天过去了,热点早凉了。后来他们做了一套简易的 CMS 拖拽建页,页面不再是代码,而是一段 JSON,运营在画布上把按钮拖进去、改文案、点保存,线上立刻变。

这套东西的核心其实不复杂:物料注册表决定「能拖什么」,拖拽克隆决定「拖进来的是原件还是副本」,落点计算决定「插到第几个」,快照栈决定「能不能后悔」。四件事拼起来,就是一个能跑、能扩、能交付的拖拽建页内核。这篇适合两类人:想给后台加可视化搭建的中级前端,以及需要一个最小可用 demo 又要能继续往里加物料的开发者。

2. 架构选型:编辑器、Schema、渲染器三件套怎么分工

2.1 为什么不直接存一段 HTML 片段

见过不少简易版的第一版实现,是直接把画布的innerHTML序列化扔进数据库,前端v-html渲染回来。能跑,demo 演示效果也不错,但第二个需求就会翻车——运营说「把所有按钮的圆角统一改成 8px」,你只能对着几千条 HTML 字符串跑正则。

页面必须用结构化数据描述,这是拖拽建页的地基。我在项目里用的是 JSON Schema,好处和代价摆在一起看更清楚:

对比项HTML 片段JSON Schema
批量改属性正则替换,极易误伤遍历节点改props,精确
跨端渲染只能浏览器看同一份 JSON 可出小程序、出静态页
版本 diff字符串 diff,噪声大结构化 diff,可做变更审计
组件复用复制粘贴引用同一个物料类型
实现成本低需要维护 schema 版本迁移

代价里最难啃的是版本迁移。我一般会在根节点上钉一个version字段,每次 schema 结构变更时写一个迁移函数,读库的时候先跑一遍迁移链,再交给渲染器。这一步现在不做,等到线上有几千个页面的时候再补,就是血泪经验了。

// src/types/schema.ts export type NodeId = string /** 一整个页面的根结构,落库存的就是它 */ export interface PageSchema { version: string // schema 版本号,用于读取时做迁移 root: ComponentNode // 根节点,固定是一个 page 容器 } /** 画布上的每一个组件都是一棵子树 */ export interface ComponentNode { id: NodeId // 全局唯一,选中、拖拽、撤销全靠它定位 type: string // 物料类型,如 'button' / 'container' props: Record<string, any> // 组件对外属性,会序列化进 JSON style: Record<string, string> // 只存布局样式,不存编辑器辅助样式 children: ComponentNode[] // 容器类物料才有,叶子节点为 [] }

id这一项看着不起眼,实际上是整套拖拽的锚点。落点计算要拿它比对祖先关系,撤销重做要靠它做 diff,属性面板要拿它定位当前编辑的节点。生成方式用nanoid或者时间戳加自增都可以,唯一要求是同一页面内不重复。style和props分开存,是因为它们的编辑入口不同:属性面板改props,样式面板改style,混在一起以后做「重置样式」会很别扭。

2.2 技术栈与目录结构

选型上我用Vue 3 + TypeScript + Vite + Pinia。Vue 3 的<component :is>天然适合做递归渲染,reactive的深层响应式也让 schema 改动后画布自动刷新,省掉一轮手动 diff。TypeScript 在这个场景里不是可选项——schema 结构会随着物料增加不断变复杂,没有类型约束,改一个字段名要全局搜。

拖拽实现上,我用的是原生 HTML5 Drag and Drop API,没有引 sortablejs 或 dnd-kit。原因很直接:简易拖拽建页的交互边界是清晰的(从物料面板拖到画布、画布内挪位置),原生 API 的dragstart / dragover / drop三件套够用,零依赖,而且落点计算这段逻辑自己写一遍,才能理解为什么那么多拖拽库在处理嵌套容器时会出问题。当然,一旦需求扩展到多选拖拽、跨 iframe 拖拽、拖拽排序动画,老老实实上库,别硬扛。

目录结构按「编辑器 / 物料 / 渲染器」三层切开,边界很清楚:

src/ ├─ editor/ # 编辑器本身,只在后台加载 │ ├─ Canvas.vue # 画布,负责拖拽事件和落点高亮 │ ├─ MaterialPanel.vue # 左侧物料面板 │ ├─ PropsPanel.vue # 右侧属性面板,按 propsSchema 自动生成表单 │ ├─ drag.ts # 拖拽协议:payload 结构、MIME、全局 dragState │ ├─ tree.ts # 树的增删改查、祖先判定 │ ├─ useHistory.ts # 快照式撤销重做 │ └─ useSelection.ts # 选中态与悬浮态 ├─ materials/ # 物料库,编辑器与渲染器共用 │ ├─ index.ts # 注册表,导出 materialMap │ ├─ Button/index.ts │ └─ Container/index.ts ├─ renderer/ │ └─ NodeRenderer.vue # 递归渲染器,预览态和发布态都用它 ├─ store/ │ └─ editor.ts # Pinia:schema、选中 id、历史栈 └─ types/ └─ schema.ts

关键点是materials/和renderer/不依赖editor/。这条依赖方向很重要:编辑器可以按需加载(后台才用),而渲染器必须能被 C 端页面直接引用。如果渲染器里import了画布的选中框组件,包体积会莫名其妙涨一大截。

2.3 数据流:一次拖拽在四个模块之间怎么走

把一次完整拖拽拆开看,链路是这样的:

  1. 物料面板上某个物料触发dragstart,写入拖拽协议(source: 'material');
  2. 鼠标进入画布,dragover持续触发,每次都用当前坐标算出落点(父节点 + 插入下标 + before/after/inside);
  3. 落点变化时更新高亮指示线,同时preventDefault()允许放置;
  4. 松手触发drop,读协议、深拷贝出带新id的节点、插入 schema;
  5. 提交一次快照,画布重渲染,属性面板自动切到新节点。

第 2 步是整个链路里最容易写歪的地方。很多人会在dragover里直接操作 DOM 插入占位元素,样式一多就乱;正确做法是只维护一份「当前落点」状态,让视图去响应它。落点状态大概长这样:

// src/editor/drag.ts export interface DropTarget { parentId: NodeId // 落进哪个容器 index: number // 插到 children 的第几位 position: 'before' | 'after' | 'inside' rect: DOMRect | null // 用来画高亮线,不参与逻辑判断 }

parentId和index是逻辑,rect纯粹是给 UI 用的。这个分离做对了,后面加「拖拽到空白区域自动落到根容器末尾」这类规则,只需要改计算函数,不用碰视图。

3. 物料系统与拖拽协议:从注册一个组件到 dragstart

3.1 物料描述文件长什么样

物料不是「一个 Vue 组件」,而是「一个组件 + 一份元信息」。元信息负责回答四个问题:这物料叫什么、能不能装子节点、拖进来时默认属性是什么、属性面板该给它生成哪些表单控件。

// src/materials/types.ts import type { Component } from 'vue' export type PropField = | { key: string; label: string; type: 'string' | 'number' } | { key: string; label: string; type: 'select'; options: string[] } | { key: string; label: string; type: 'color' } export interface MaterialConfig { type: string // 唯一标识,写进 schema 的 node.type name: string // 面板上显示的中文名 icon: string // 面板图标标识 component: Component // 画布、预览、发布三处共用同一个组件 isContainer: boolean // 决定能不能接收子节点、能不能判定 inside defaultProps: Record<string, any> defaultStyle: Record<string, string> propsSchema: PropField[] // 属性面板据此自动渲染表单 maxChildren?: number // 容器类物料限制子节点数量,防止拖出畸形页面 }

component三处共用是这套设计里最值钱的一条。编辑器里看到的按钮、预览里看到的按钮、发布后线上看到的按钮,是同一个组件实例的同一份代码。只要不走这条路,就一定会出现「编辑器里好好的,发布上去样式错了」这种玄学问题。

注册表本身就是一个 Map,顺便在开发环境做个重复注册的检查:

// src/materials/index.ts import { buttonMaterial } from './Button' import { containerMaterial } from './Container' import type { MaterialConfig } from './types' const all: MaterialConfig[] = [buttonMaterial, containerMaterial] export const materialMap = new Map<string, MaterialConfig>() for (const m of all) { if (materialMap.has(m.type)) { // 重复 type 会让 schema 里的节点渲染错组件,必须尽早暴露 throw new Error(`重复注册的物料类型: ${m.type}`) } materialMap.set(m.type, m) } /** 左侧面板按分组展示时用这个列表,顺序即展示顺序 */ export const materialList = all

一个具体物料长这样,Button用h函数写,避免为了一个按钮建一个 SFC:

// src/materials/Button/index.ts import { defineComponent, h } from 'vue' import type { MaterialConfig } from '../types' const ButtonView = defineComponent({ name: 'ButtonView', props: { text: { type: String, default: '按钮' }, variant: { type: String, default: 'primary' }, disabled: { type: Boolean, default: false } }, setup(props) { return () => h( 'button', { class: ['cms-btn', `cms-btn--${props.variant}`], disabled: props.disabled }, props.text ) } }) export const buttonMaterial: MaterialConfig = { type: 'button', name: '按钮', icon: 'button', component: ButtonView, isContainer: false, defaultProps: { text: '按钮', variant: 'primary', disabled: false }, defaultStyle: { width: '120px', height: '40px' }, propsSchema: [ { key: 'text', label: '文案', type: 'string' }, { key: 'variant', label: '风格', type: 'select', options: ['primary', 'default', 'text'] }, { key: 'disabled', label: '禁用', type: 'select', options: ['false', 'true'] } ] }

逻辑说明:ButtonView的props声明,就是 schema 里node.props的契约。defaultProps必须和组件props的默认值对齐,否则拖进来第一次渲染和重置属性后的渲染会出现两种样子,这种不一致排查起来非常费时间。style单独放,是因为它由画布上的拖拽缩放直接写,不经过属性面板。

参数说明:propsSchema里的key对应node.props的键名,type决定属性面板生成输入框、下拉还是取色器。新增物料时,只要实现MaterialConfig并在all数组里追加一行,左侧面板、属性面板、渲染器三处都会自动认出来,不需要改任何一处编辑器代码。这就是注册表模式在这个场景下的价值。

3.2 拖拽协议:dataTransfer 里到底放什么

拖拽过程中,浏览器只给了你一个通道传数据:dataTransfer。协议设计得糙一点没关系,但必须自描述,否则将来加「从画布外部拖图片进来」会没法扩展。

// src/editor/drag.ts export const DRAG_MIME = 'application/x-cms-node' /** 拖拽载荷:区分「新拖入」和「画布内移动」两种情况 */ export interface DragPayload { source: 'material' | 'canvas' // material=从物料面板新拖入,canvas=画布内挪位置 type: string // 物料类型,canvas 场景下用于快速校验 nodeId?: NodeId // source=canvas 时才有,指向被移动的那个节点 } /** dragover 阶段浏览器不让读 dataTransfer,所以额外存一份内存状态 */ export let dragState: { payload: DragPayload | null; target: DropTarget | null } = { payload: null, target: null } export function updateDragState(patch: Partial<typeof dragState>) { dragState = { ...dragState, ...patch } } export function startDrag(e: DragEvent, payload: DragPayload) { e.dataTransfer?.setData(DRAG_MIME, JSON.stringify(payload)) // copy:新拖入;move:画布内挪动。光标会跟着变,用户一眼能分清 e.dataTransfer!.effectAllowed = payload.source === 'material' ? 'copy' : 'move' updateDragState({ payload, target: null }) } export function endDrag() { updateDragState({ payload: null, target: null }) }

逻辑说明:startDrag里同时做了两件事——写dataTransfer、更新内存里的dragState。写两遍不是冗余,是必须的。drop事件里能读dataTransfer,但dragover事件里读不到(浏览器出于数据隔离的考虑,只允许在drop时读取内容)。而落点高亮恰恰发生在dragover阶段,所以必须有一份内存副本兜底。这个坑几乎每个手写拖拽的人都会踩一次。

参数说明:source字段决定了drop时的分支逻辑——material走「新建节点」路径,canvas走「移动节点」路径。这两条路径的差别不小:新建要深拷贝并重新生成整棵子树的id,移动要先把原节点从原位置摘掉再插到新位置(顺序错了会插出一份幽灵节点)。用一个字段区分,比用两个不同的 MIME 类型更省事。

3.3 落点计算:before / after / inside 三态判定

落点计算是拖拽建页里唯一有点算法味道的部分。核心是回答:鼠标当前落在某个节点的哪一侧,以及要不要落进它的内部。

// src/editor/tree.ts export type DropPosition = 'before' | 'after' | 'inside' /** * 根据鼠标坐标和节点矩形,判定插入位置 * @param rect 目标节点的包围盒 * @param clientX/Y 鼠标视口坐标 * @param isContainer 目标节点是否是容器物料 */ export function resolveDropPosition( rect: DOMRect, clientX: number, clientY: number, isContainer: boolean ): DropPosition { const x = clientX - rect.left const y = clientY - rect.top if (isContainer) { // 容器内缩 20% 作为「内部区域」,边缘留给前后插入 const padX = rect.width * 0.2 const padY = rect.height * 0.2 if (x > padX && x < rect.width - padX && y > padY && y < rect.height - padY) { return 'inside' } } // 非容器,或者落在容器边缘:按纵向中线判前后 return y < rect.height / 2 ? 'before' : 'after' } /** 把落点换算成「父节点 + 插入下标」 */ export function resolveInsertIndex( node: ComponentNode, position: DropPosition, parentChildren: ComponentNode[] ): { parentId: NodeId; index: number } { if (position === 'inside') { return { parentId: node.id, index: node.children.length } } const idx = parentChildren.findIndex(c => c.id === node.id) return { parentId: '', // 由调用方补上父 id index: position === 'before' ? idx : idx + 1 } }

逻辑说明:inside的判定用 20% 内缩,是为了给容器的四个边留出「插入到容器前后」的操作区。如果整块容器区域都判inside,用户想把一个区块拖到另一个区块上方就永远做不到。20% 这个值不是定理,容器很矮的时候(比如一行高度 40px 的横条)可以调到 30%,避免前后区域太窄点不中。

参数说明:clientX/Y用视口坐标而不是pageX/Y,配合getBoundingClientRect()才是一套;混用会导致画布滚动后落点整体偏移,表现为「越往下拖越不准」。parentChildren传的是目标节点的兄弟数组,用来算before/after的下标,inside分支的下标固定是children.length(追加到末尾),如果想插到容器头部,把 index 改成 0 即可。

4. 画布与组件树:拖拽克隆、嵌套容器和撤销重做

4.1 递归渲染器:一份 schema 还原整页

渲染器是全篇最短的代码之一,也是最重要的一段。它做的事情只有一件:拿着ComponentNode,从materialMap里取出对应组件,渲染自己,然后递归渲染children。

<!-- src/renderer/NodeRenderer.vue --> <script setup lang="ts"> import { computed } from 'vue' import type { ComponentNode } from '@/types/schema' import { materialMap } from '@/materials' const props = defineProps<{ node: ComponentNode }>() const material = computed(() => { const m = materialMap.get(props.node.type) if (!m) { // 物料被删掉但存量页面还在引用,降级成一个占位块而不是整页白屏 return null } return m }) </script> <template> <component :is="material.component" v-if="material" v-bind="node.props" :style="node.style" :data-node-id="node.id" > <template v-if="material.isContainer"> <NodeRenderer v-for="child in node.children" :key="child.id" :node="child" /> </template> </component> <div v-else class="cms-missing" :data-node-id="node.id"> 未知物料:{{ node.type }} </div> </template>

逻辑说明:Vue SFC 里递归组件可以直接用文件名自引用,不需要额外注册。>// src/editor/useDrop.ts import { nanoid } from 'nanoid' import type { ComponentNode, NodeId } from '@/types/schema' import { materialMap } from '@/materials' /** 深拷贝一棵子树,并为每个节点重新生成 id */ export function cloneSubtree(node: ComponentNode): ComponentNode { return { ...node, id: nanoid(), props: { ...node.props }, style: { ...node.style }, children: node.children.map(cloneSubtree) } } /** 按物料类型造一个新节点 */ export function createNode(type: string): ComponentNode { const m = materialMap.get(type) if (!m) throw new Error(`未注册的物料: ${type}`) return { id: nanoid(), type, props: { ...m.defaultProps }, style: { ...m.defaultStyle }, children: [] } } /** 判断 id 是否为 ancestor 的后代,防止把父节点拖进自己的子节点 */ export function isDescendant(ancestor: ComponentNode, id: NodeId): boolean { return ancestor.children.some(c => c.id === id || isDescendant(c, id)) }

逻辑说明:cloneSubtree里的三段浅拷贝一个都不能省。props和style如果是引用传递,新节点和源节点会共享同一份对象,改一个另一个跟着变——这在物料面板预览节点上特别容易发生,因为预览节点通常是从createNode造出来的模板,直接深拷贝就能规避。children用map(cloneSubtree)递归,保证嵌套容器里的每个子孙节点都换上新 id,否则撤销栈按 id 比对时会互相干扰。

参数说明:isDescendant是防止树成环的唯一防线。把父容器拖进它自己的子容器,children会形成自引用,渲染时递归到栈溢出直接白屏,而且这个错误发生在运行时,构建阶段完全看不出来。所以drop之前一定要做一次判定:如果dragPayload.source === 'canvas'且目标父节点是被拖节点的后代,直接取消这次放置。

4.3 快照式撤销重做:比命令模式省事

撤销重做我一开始想用命令模式,写了两天放弃了。原因很实际:树的增删改查有十几种操作,每个操作都要配一个精确的逆操作,嵌套容器一深,逆操作的下标算错一次就整棵树乱掉。改成快照式之后代码量少了三分之二。

快照式的思路是:每次操作完成后,把整份 schema 深拷贝压进栈,撤销就是把指针往回挪一格。代价是内存,但一个页面的 schema 撑死几十 KB,五十步历史也就几 MB,完全可以接受。

// src/editor/useHistory.ts import { ref, shallowRef } from 'vue' import type { PageSchema } from '@/types/schema' const MAX_STEPS = 50 export function useHistory(initial: PageSchema) { const snapshots = shallowRef<PageSchema[]>([structuredClone(initial)]) const cursor = ref(0) function commit(next: PageSchema) { // 在历史中间做新操作,旧的重做分支直接丢弃,避免出现分叉历史 const kept = snapshots.value.slice(0, cursor.value + 1) kept.push(structuredClone(next)) if (kept.length > MAX_STEPS) kept.shift() // 超上限丢最老的,防止内存无限涨 snapshots.value = kept cursor.value = kept.length - 1 } function undo(): PageSchema | null { if (cursor.value <= 0) return null cursor.value -= 1 return structuredClone(snapshots.value[cursor.value]) } function redo(): PageSchema | null { if (cursor.value >= snapshots.value.length - 1) return null cursor.value += 1 return structuredClone(snapshots.value[cursor.value]) } const canUndo = () => cursor.value > 0 const canRedo = () => cursor.value < snapshots.value.length - 1 return { commit, undo, redo, canUndo, canRedo } }

逻辑说明:commit被调用的时机很关键——一次拖拽全程只 commit 一次,在drop处理完之后。如果写成dragover里每次都 commit,一次拖拽会压进几十个快照,撤销一次等于往回退一像素。属性面板的输入同理,输入框input事件不 commit,change或者失焦时才 commit,否则用户改三个字要按三次撤销。

参数说明:structuredClone是浏览器原生 API,比JSON.parse(JSON.stringify())快,而且能正确处理undefined、Date这些 JSON 会丢的类型。如果目标浏览器版本比较老需要降级,就用lodash.cloneDeep或者自己写递归。MAX_STEPS = 50是个经验值,页面节点特别多的时候可以降到 20,你会在长按撤销的时候明显感觉到内存和速度的差别。

5. 避坑与排查:拖拽建页最容易翻车的五个位置

5.1 拖拽本身的三类故障

现象一:松手之后什么都没发生,drop永远不触发。

原因是dragover里没有调用preventDefault()。HTML5 拖拽规范里,dragover的默认行为是「拒绝放置」,只有显式preventDefault()才会把它变成合法的放置目标。解决方式是在画布的dragover处理函数第一行就e.preventDefault(),并且e.dataTransfer.dropEffect要跟dragstart里的effectAllowed对得上(copy配copy,move配move),否则光标会显示禁止图标但事件照常触发,很容易误判。

现象二:落点高亮一直在闪,鼠标稍微一动就跳到别的节点。

原因是dragover每秒触发几十次,每次都直接setState更新落点,视图跟着高频重渲染,视觉上就是抖。解决方式有两条:一是对坐标做 16ms 的节流;二是只在落点的parentId + index + position真正变化时才更新状态,坐标本身不参与响应式。我一般会写一个三元组做比较,相等就直接 return。

现象三:把容器拖进它自己的子容器,页面直接白屏。

原因是树成了环,递归渲染没有终止条件。上文isDescendant就是解决方案:在drop里,如果payload.source === 'canvas',先取出被拖节点,判断目标父节点是否在它的后代里,是就直接return。另外建议在NodeRenderer里加一个递归深度上限(比如 20 层)做兜底,真出问题时至少能渲染出半页而不是整页崩溃,这就是「黑匣子」的价值。

5.2 数据与渲染不一致的两类问题

现象四:删掉中间一个组件,后面组件的文案集体错位了一个。

原因在v-for的:key用了数组下标。Vue 的 diff 算法按 key 复用实例,下标不变但内容整体前移,实例就被复用到错误的节点上。解决方法只有一个::key永远用node.id。同理,v-for里不要再包一层v-if做过滤,Vue 3 里v-if优先级高于v-for,拿不到循环变量,会直接报错。

现象五:编辑器里选中框、hover 描边,发布到线上还在。

原因是编辑器给组件加的辅助样式(outline、box-shadow、position: relative)写在了同一个 class 上,而渲染器和编辑器共用组件。解决办法是给编辑器容器加一个根 class(比如cms-editor-root),所有辅助样式都写成.cms-editor-root [data-node-id]:hover { ... }这种后代选择器,绝不污染组件自身的 class。这样预览和发布态天然干净,不需要额外处理。

额外一条经验:属性面板改了值,画布却不更新,大概率是因为 schema 被整体替换时用的是ref而不是reactive,或者赋值时用了Object.assign打平了一层,丢了深层响应式。我一般统一走「深拷贝一份 → 修改 → 整份替换 → commit」这条路径,虽然看起来重,但行为可预测,比跟响应式系统博弈划算得多。

6. 二次开发:加一个「倒计时」物料并让它走完整条链路

真正的考验不是跑通 demo,而是别人接手后能不能在半小时内加一个新物料。我把这条路径固化成一份清单,加物料时照着走,基本不会漏:

步骤改哪里容易漏的点
1. 写组件src/materials/Countdown/index.ts组件 props 要和defaultProps完全对齐
2. 声明属性同文件的propsSchemaselect 的 options 是字符串数组,不是对象数组
3. 注册src/materials/index.ts的all数组忘了加这一行,面板里永远不出现
4. 验证渲染拖进画布、切预览、看发布态三处样式必须一致

第一步的代码,最短可以写成这样:

// src/materials/Countdown/index.ts import { defineComponent, h, ref, onMounted, onUnmounted } from 'vue' import type { MaterialConfig } from '../types' const CountdownView = defineComponent({ name: 'CountdownView', props: { seconds: { type: Number, default: 60 }, suffix: { type: String, default: '秒后结束' } }, setup(props) { const left = ref(props.seconds) let timer: number | undefined onMounted(() => { // 编辑器里也走真实倒计时,方便运营确认效果;预览态同理 timer = window.setInterval(() => { if (left.value > 0) left.value -= 1 }, 1000) }) onUnmounted(() => window.clearInterval(timer)) return () => h('span', { class: 'cms-countdown' }, `${left.value}${props.suffix}`) } }) export const countdownMaterial: MaterialConfig = { type: 'countdown', name: '倒计时', icon: 'clock', component: CountdownView, isContainer: false, defaultProps: { seconds: 60, suffix: '秒后结束' }, defaultStyle: { fontSize: '20px', color: '#ff4d4f' }, propsSchema: [ { key: 'seconds', label: '总秒数', type: 'number' }, { key: 'suffix', label: '后缀文案', type: 'string' } ] }

这里有个细节值得单拎出来:倒计时组件带了定时器,onUnmounted里必须清掉。编辑器频繁地拖入、删除、撤销,组件实例的创建销毁次数远高于线上页面,定时器不清会越积越多,最后整个编辑器卡死。类似的还有轮播、动画类物料,凡是有副作用的都要在卸载钩子里收尾。

验证方式我一般按三步走:先在画布上拖三个倒计时,删中间那个,撤销两次,看数字和实例是不是都对得上;再切预览态,确认定时器还在跑、辅助样式没跟过来;最后把页面 schema 导出成 JSON,手动删掉version字段再导入,看迁移函数是不是能兜住。这三步做完,一个物料才算真接进来了。

从那以后我每次加新物料,都强制走一遍「拖入 → 删除 → 撤销 → 预览 → 导出重导」这条链路,不再跳过任何一步。前面省下的五分钟,后面往往要用两小时来查。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/27 21:33:32

爱之谷ECShop私密商城PHP源码二次开发与隐私发货改造

简介&#xff1a;这套爱之谷成人用品商城整站模板面向使用 ECShop v2.7.3 搭建垂直电商站点的开发者与站长&#xff0c;提供一套带整站数据的php源码方案&#xff0c;适合具备基础PHP与ECShop使用经验、希望研究商城模板结构、页面组织与数据配套方式的读者参考&#xff0c;也可…

作者头像 李华
网站建设 2026/10/27 21:33:14

双向链表数据结构详解:原理、实现与应用场景

1. 双向链表基础概念解析双向链表&#xff08;Doubly Linked List&#xff09;是链表数据结构的一种重要变体&#xff0c;相比单链表在节点结构上进行了关键性升级。每个节点除了保存数据域&#xff08;data&#xff09;外&#xff0c;还包含两个指针域&#xff1a;一个指向前驱…

作者头像 李华
网站建设 2026/10/27 21:33:10

无人机结构巡检系统的三维路径规划与优化

1. 无人机结构巡检飞行规划系统概述在工业检测和基础设施维护领域&#xff0c;无人机结构巡检已经成为替代人工高空作业的革命性解决方案。我开发的这套系统专门针对已知三维结构&#xff08;如桥梁、塔架、建筑外立面等&#xff09;的自动化检测需求&#xff0c;通过STL格式的…

作者头像 李华
网站建设 2026/10/27 21:32:52

专业写作工具评测与选型指南

1. 专业写作工具的核心价值与选择标准在文字创作领域&#xff0c;工具的选择直接影响着写作效率和质量。真正专业的写作软件绝不仅仅是简单的文字录入工具&#xff0c;而是能够提供完整的创作环境、科学的流程管理和精准的辅助功能的工作平台。这类软件通常具备以下几个核心特征…

作者头像 李华
网站建设 2026/10/27 21:32:04

高效桌面便签工具StickyNotes的设计与实现

1. 为什么我们需要一款好用的桌面便签工具作为一名长期与电脑为伴的文字工作者&#xff0c;我经历过无数次这样的场景&#xff1a;正在赶稿时突然闪过一个绝妙的选题灵感&#xff1b;调试代码时发现某个需要后续优化的技术细节&#xff1b;或是接到临时任务需要记录几个关键时间…

作者头像 李华
网站建设 2026/10/27 21:30:35

营销技能拆解:从用户洞察到数据复盘的实战框架

看到“marketingskills”这个英文标题&#xff0c;第一反应可能是个泛泛而谈的概念&#xff0c;但真正在营销口摸爬滚打过的人都懂&#xff0c;这四个字母背后&#xff0c;藏着的是从“玄学”到“工程学”的距离。尤其这几年&#xff0c;内容种草、私域运营、直播电商、搜索电商…

作者头像 李华