完美!现在让我为你详细讲解第四阶段:完整工作流程与事件系统。这是项目的"神经中枢"。
⚡ 第四阶段深入:完整工作流程与事件总线系统
第一部分:事件总线(Mitt)架构
什么是事件总线?
Mitt 是一个极简的事件发射器库,用于组件间通信。
// main.ts 中的初始化
const app = createApp(App)
app.config.globalProperties.$bus = mitt() // ⬅️ 全局事件总线
// 在任何组件中使用
this.$bus.on('eventName', callback) // 监听事件
this.$bus.emit('eventName', data) // 发送事件
为什么用事件总线?
在低代码平台中,各个功能模块(Left、Center、Right、Top 等)分散在不同的文件,它们需要互相通信。事件总线是一个全局消息传递系统,任何组件都可以向其他组件发送消息,而无需直接建立依赖关系。
第二部分:Top.vue 与 centerEvent.js 的完整交互
Top.vue:工具栏的心脏
Top.vue 是工具栏,包含���有全局操作按钮。它的工作原理是:
用户点击按钮
↓
Top.vue 通过 $bus.emit 发送事件
↓
centerEvent.js 中的事件监听器接收事件
↓
执行对应的业务逻辑(修改 Center.vue 的数据)
Top.vue 的按钮分布
<div class="top-left">
<!-- 左侧:Logo -->
<img class="top-left-logo" />
</div>
<div class="top-center">
<!-- 中间:PC/手机切换 -->
<div class="top-center-pc" @click="toPC">PC</div>
<div class="top-center-phone" @click="toPhone">手机</div>
</div>
<div class="top-right">
<!-- 右侧:操作按钮 -->
<div class="top-right-operator">
<!-- 撤销/重做 -->
<img @click="backOff" /> <!-- Ctrl+Z 效果 -->
<img @click="forward" /> <!-- Ctrl+Y 效果 -->
</div>
<div class="top-right-function">
<!-- 核心功能按钮 -->
<button @click="deleteEvent">删除</button>
<button @click="absolute = !absolute">静态/绝对定位</button>
<button @click="save">保 存</button>
<button @click="$refs.file.click()">导入 Json</button>
<button @click="reset">重 置</button>
<button @click="switchState">{{ edit ? '预览' : '编辑' }}</button>
<button @click="release">发 布</button>
</div>
</div>
Top.vue 的方法解析
methods: {
// ① PC 视图
toPC() {
this.isPC = true
this.$bus.emit('toPc') // ⬅️ 发送事件给 Center.vue
},
// ② 手机视图
toPhone() {
this.isPC = false
this.$bus.emit('toPhone') // ⬅️ 发送事件给 Center.vue
},
// ③ 撤销(历史记录)
backOff() {
this.$bus.emit('backOff') // ⬅️ 在 centerEvent.js 中处理
},
// ④ 重做
forward() {
this.$bus.emit('forward')
},
// ⑤ 删除选中的组件
deleteEvent() {
this.$bus.emit('showDeleteDialog') // ⬅️ 显示删除确认框
},
// ⑥ 保存到本地存储
save() {
this.$bus.emit('save')
},
// ⑦ 重置为空
reset() {
this.$bus.emit('reset')
},
// ⑧ 编辑/预览切换
switchState() {
this.$bus.emit('switchState')
},
// ⑨ 发布到服务器
release() {
this.$bus.emit('release')
},
// ⑩ 导入 JSON 文件
importJSON() {
const file = document.getElementById('file').files[0]
const reader = new FileReader()
reader.readAsText(file)
reader.onload = () => {
const ImportJSON = JSON.parse(reader.result)
this.inputJsonEvent(ImportJSON)
}
},
inputJsonEvent(data) {
this.$bus.emit('inputJson', data) // ⬅️ 传递数据
}
}
Top.vue 的监听
mounted() {
// ① 从 Center.vue 接收撤销步数
this.$bus.on('getStep', (step) => {
this.centerStep = step
// 如果是第一步,撤销按钮被禁用(opacity: 0.2)
})
// ② 监听是否有选中组件
this.$bus.on('selectEvent', (index) => {
this.hasSelectEvent = index === -1 ? false : true
// 如果没选中,删除按钮被禁用
})
// ③ 监听编辑状态变化
this.$bus.on('switchState', () => {
this.edit = !this.edit
})
}
定位模式切换(关键特性)
watch: {
absolute: {
immediate: true,
handler(newVal) {
// 每次切换按钮状态时,发送事件
this.$bus.emit('switchPattern', newVal)
// true → 绝对定位(absolute)
// false → 静态定位(static)
}
}
}
这个机制控制了拖拽组件时的定位模式:
- 静态定位(static):组件堆叠排列(自动布局)
- 绝对定位(absolute):可以自由拖拽到任意位置
第三部分:centerEvent.js - 事件处理总枢纽
架构概览
// centerEvent.js 的工作方式
export function eventHandle(eventArr, that) {
// eventArr 是事件名称数组
// that 是 Center.vue 组件实例
for (let eName of eventArr) {
switch(eName) {
case 'saveJson': saveJson(that); break;
case 'backOff': backOff(that); break;
case 'forward': forward(that); break;
// ... 等等
}
}
}
在 Center.vue 的 mounted 中:
mounted() {
eventHandle([
'saveJson', 'backOff', 'forward', 'onCenter', 'refreshCurrentViews',
'rootDelete', 'inputJson', 'toPc', 'toPhone', 'switchState', 'release',
'clearFocus', 'sendDeleteIndex', 'sonAddFlexBox', 'refreshCurrentCom',
'updateCurrentCom', 'exportHtml', 'offCenter', 'switchPattern',
'save', 'reset', 'onkeydown'
], this) // ⬅️ 一次性注册所有事件
}
完整事件列表与详解
| 事件名 | 发送方 | 接收方 | 作用 |
|---|---|---|---|
backOff | Top.vue | Center.vue | 撤销 |
forward | Top.vue | Center.vue | 重做 |
toPc | Top.vue | Center.vue | 切换到 PC 视图 |
toPhone | Top.vue | Center.vue | 切换到手机视图 |
switchState | Top.vue | Center.vue | 切换编辑/预览模式 |
release | Top.vue | Center.vue | 发布页面 |
save | Top.vue | Center.vue | 保存到 localStorage |
reset | Top.vue | Center.vue | 重置为空 |
inputJson | Top.vue | Center.vue | 导入 JSON |
switchPattern | Top.vue | Center.vue | 切换定位模式 |
selectEvent | PowerfulDynamicDraw | Top.vue | 通知按钮启用/禁用 |
views | Center/Right 之间 | Right.vue | 发送选中的组件 |
clearFocus | 多处 | 所有地方 | 清除焦点 |
updateCurrentCom | 多处 | Center.vue | 更新当前组件 |
refreshCurrentViews | 多处 | Center.vue | 更新当前组件索引 |
deleteFlexBoxCom | 多处 | DynamicDraw | 重置索引 |
onkeydown | Center.vue | 全局 | 键盘快捷键处理 |
7 个关键事件详细分析
事件 1:撤销 (backOff)
function backOff(that) {
that.$bus.on("backOff", () => {
// 检查是否还有之前的快照
if (that.step > 1) {
// 回退一步
that.step = that.step - 1
// 从 sessionStorage 恢复该步的状态
that.views = JSON.parse(sessionStorage.getItem(String(--that.step)))
}
})
}
工作原理:
用户点击"撤销"按钮
↓
Top.vue 发送 'backOff' 事件
↓
centerEvent.js 的 backOff 函数接收
↓
检查 sessionStorage[step-1] 是否存在
↓
存在则恢复该步的 views 快照
↓
Center.vue 的 views 更新
↓
PowerfulDynamicDraw 自动重新渲染
事件 2:保存 (save)
function save(that) {
that.$bus.on('save', () => {
// 将当前状态保存到浏览器本地存储
localStorage.setItem('views', JSON.stringify(that.views))
that.$message.success("保存成功")
})
}
持久化机制:
sessionStorage ← 撤销/重做用(内存,关闭浏览器就清空)
localStorage ← 长期保存(硬盘,关闭浏览器仍保留)
在 Center.vue 的 created 中:
created() {
const views = localStorage.getItem('views')
this.views = views ? JSON.parse(views) : []
}
事件 3:编辑/预览模式 (switchState)
function switchState(that) {
that.$bus.on("switchState", () => {
that.$bus.emit("clearFocus") // 清除选中状态
that.edit = !that.edit // 切换模式
if (!that.edit) { // 进入预览模式时
localStorage.setItem("1", JSON.stringify(that.views))
}
that.releaseVisiable = !that.releaseVisiable // 显示预览窗口
})
}
影响:
edit = true(编辑):可拖拽、可选中、显示边框edit = false(预览):组件不可交互、只读显示
事件 4:发布 (release)
function release(that) {
that.$bus.on("release", () => {
let id = nanoid() // 生成唯一 ID
let form = new FormData()
form.append('id', id)
form.append('result', JSON.stringify(that.views))
// 发送到后端
that.$axios(register(form))
.then((res) => {
// 生成发布链接
that.src = window.location.origin + '/release/' + id
// 显示二维码/链接窗口
that.sendVisiable = true
})
})
}
发布流程:
用户点击"发布"按钮
↓
生成唯一 ID(nanoid)
↓
将 views 序列化为 JSON
↓
发送到后端服务器(/api/register)
↓
服务器存储该页面配置
↓
返回可访问的链接
↓
用户可通过链接分享页面
事件 5:清除焦点 (clearFocus)
function clearFocus(that) {
that.$bus.on("clearFocus", () => {
that.showButton = false
clearFocusHandle(that.views) // 递归清除所有焦点
})
}
function clearFocusHandle(arr) {
for (let i = 0; i < arr.length; i++) {
arr[i].focus = false // 清除该组件的焦点
if (arr[i].children) {
clearFocusHandle(arr[i].children) // 递归处理子组件
}
}
}
为什么需要清除焦点?
当用户点击新组件时,需要先清除其他组件的选中状态,防止多个组件同时处于选中状态。
事件 6:键盘快捷键 (onkeydown)
function onkeydown(that) {
window.document.getElementsByClassName('DynamicDraw')[0].onkeydown = (e) => {
// ① 复制:Ctrl + C
if (e.ctrlKey && e.key == 'c' && that.currentCom.component) {
that.copyViews = deepClone(that.currentCom)
that.$message.success('复制成功,请在弹性盒子中粘贴')
}
// ② 粘贴:Ctrl + V
if (e.ctrlKey && e.key == 'v' && that.copyViews.component &&
that.currentCom.component == 'FlexBox') {
// 只能粘贴到 FlexBox 中
that.currentCom.children.push(deepClone(that.copyViews))
}
// ③ 样式刷:Ctrl + B
if (e.ctrlKey && e.key == 'b' && that.currentCom.component) {
if (that.currentCom.component == 'FlexBox') {
that.brushStyle = deepClone(that.currentCom.style)
} else {
// 对于基础组件,复制其"子样式"
that.brushStyle = deepClone(that.currentCom[that.currentCom['sonStyle']])
}
that.$message.success('样式刷成功,按 ctrl + Q 粘贴样式')
}
// ④ 涂抹(粘贴样式):Ctrl + Q
if (e.ctrlKey && e.key == 'q' && that.brushStyle && that.currentCom.component) {
if (that.currentCom.component == 'FlexBox') {
that.currentCom.style = deepClone(that.brushStyle)
} else {
that.currentCom[that.currentCom['sonStyle']] = deepClone(that.brushStyle)
}
}
}
}
快��键一览表:
| 快捷键 | 功能 | 限制条件 |
|---|---|---|
| Ctrl+C | 复制组件 | 必须先选中组件 |
| Ctrl+V | 粘贴组件 | 必须选中的是 FlexBox |
| Ctrl+B | 复制样式 | 用于"样式刷"功能 |
| Ctrl+Q | 粘贴样式 | 将样式应用到其他组件 |
事件 7:删除组件 (showDeleteDialog)
function showDeleteDialog(that) {
that.$bus.on("showDeleteDialog", () => {
// 直接删除,不弹窗
that.delCom()
})
}
在 Center.vue 中:
methods: {
delCom() {
// 从当前视图中删除选中的组件
this.currentViews.splice(this.currentViewsIndex, 1)
this.currentIndex = -1
this.$bus.emit("deleteFlexBoxCom") // 重置其他组件的索引
this.cleanSendView() // 清除右侧属性面板
}
}
第四部分:全局功能组件
PageHeader.vue:顶部导出栏
<template>
<header class="header">
<div class="logo">
<div class="logo-img"></div>
<div class="logo-text">LowCode AnyLayout</div>
</div>
<div class="operate">
<button @click="exportHtml">导出 HTML</button>
<button @click="exportVue">导出 Vue</button>
<button @click="sendSaveJsonEvent">导出 Json</button>
</div>
</header>
</template>
<script>
export default {
methods: {
exportHtml() {
this.$bus.emit("exportHtml", false) // 导出为 HTML
},
exportVue() {
this.$bus.emit("exportHtml", true) // 导出为 Vue 组件
},
sendSaveJsonEvent() {
this.$bus.emit('saveJson') // 导出为 JSON
}
}
}
</script>
DeleteDialog.vue:删除确认框
<template>
<el-dialog title="提示" :model-value="dialogVisible">
<span>确定删除这个组件吗</span>
<template #footer>
<el-button @click="unVisiable">取 消</el-button>
<el-button type="primary" @click="delCom">确 定</el-button>
</template>
</el-dialog>
</template>
<script>
export default {
props: ['dialogVisible', 'edit'],
emits: ['delComEvent', 'update:dialogVisible'],
methods: {
delCom() {
this.$emit("delComEvent") // 通知父组件删除
},
unVisiable() {
this.$emit("update:dialogVisible", false)
}
}
}
</script>
CenterButton.vue:添加组件按钮
<template>
<div class="btn" @click="$emit('click')">
<img :src="addBtnIcon" alt="">
</div>
</template>
<script>
export default {
data() {
return {
addBtnIcon: new URL('@/assets/add-btn.png', import.meta.url).href
}
}
}
</script>
出现时机:当用户选中 FlexBox 容器时,在容器中心显示一个"+"按钮,用于添加子组件。
ReleaseDialog.vue:预览/发布窗口
<template>
<el-dialog :model-value="releaseVisiable" width="850px">
<iframe
v-if="releaseVisiable"
ref="iframe"
class="screen"
:src="origin + '/release/1'"
@load="load"
></iframe>
<template #footer>
<el-button @click="unVisiable">关闭</el-button>
</template>
</el-dialog>
</template>
<script>
export default {
props: ['releaseVisiable', 'views'],
data() {
return {
origin: window.location.origin
}
},
methods: {
unVisiable() {
this.$bus.emit("switchState") // 关闭预览,回到编辑模式
}
}
}
</script>
🎯 完整业务流程示例
场景:用户添加、编辑、删除组件,最后发布
┌─────────────────────────────────────────────────────────────┐
│ 1️⃣ 用户拖拽"文本"组件到 Canvas │
├─────────────────────────────────────────────────────────────┤
│ Left.vue dragstart → dataTransfer.setData("attr", "TextCom")│
│ Center.vue drop → deepClone(commonData['TextCom']) │
│ → views.push(newData) │
│ → PowerfulDynamicDraw 渲染新组件 │
│ → $bus.emit('views', newData) │
│ → Right.vue 显示 TextProp │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 2️⃣ 用户在 TextProp 中修改文本内容 │
├─────────────────────────────────────────────────────────────┤
│ <textarea v-model="views.content" /> │
│ → views.content = "新文本" │
│ → Center.vue 的 this.views[index].content 同时改变 │
│ → PowerfulDynamicDraw 检测到 views 变化 │
│ → TextCom 重新渲染 {{ views.content }} │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 3️⃣ 用户点击组件 │
├─────────────────────────────────────────────────────────────┤
│ PowerfulDynamicDraw @click → select(index, view) │
│ → $emit("selectEvent", index, view) │
│ → Center.vue select() → this.currentCom = view │
│ → $bus.emit('views', view) │
│ → Right.vue 更新属性面板 │
│ → Top.vue 接收 selectEvent,启用"删除"按钮 │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 4️⃣ 用户点击"撤销"按钮 │
├─────────────────────────────────────────────────────────────┤
│ Top.vue backOff() → $bus.emit('backOff') │
│ → centerEvent.js backOff() │
│ → this.views = JSON.parse(sessionStorage[step-1]) │
│ → PowerfulDynamicDraw 重新渲染之前的状态 │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 5️⃣ 用户点击"删除"按钮 │
├─────────────────────────────────────────────────────────────┤
│ Top.vue deleteEvent() → $bus.emit('showDeleteDialog') │
│ → Center.vue delCom() │
│ → this.currentViews.splice(this.currentViewsIndex, 1) │
│ → this.views[selectedIndex] 被移除 │
│ → PowerfulDynamicDraw 重新渲染,组件消失 │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 6️⃣ 用户点击"保存"按钮 │
├─────────────────────────────────────────────────────────────┤
│ Top.vue save() → $bus.emit('save') │
│ → centerEvent.js save() │
│ → localStorage.setItem('views', JSON.stringify(views)) │
│ → 提示"保存成功" │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 7️⃣ 用户点击"发布"按钮 │
├─────────────────────────────────────────────────────────────┤
│ Top.vue release() → $bus.emit('release') │
│ → centerEvent.js release() │
│ → 生成唯一 ID │
│ → 向后端 POST /api/register │
│ → 服务器存储该页面的 views 配置 │
│ → 返回可访问的链接(/release/:id) │
│ → ReleaseDialog 显示预览窗口(iframe 加载预览页面) │
└─────────────────────────────────────────────────────────────┘
📝 可背诵的核心表述
表述 1:事件总线的作用
事件总线(Mitt)是低代码平台的通信枢纽。它使得各个独立的组件(Left、Center、Right、Top)可以相互通信,而无需直接建立父子关系。Top.vue 通过事件总线发送指令,centerEvent.js 作为监听器,根据事件类型调用对应的处理函数,修改 Center.vue 的数据。
表述 2:撤销/重做机制
使用 sessionStorage 实现。每次 views 数组变化时,自动保存一个快照到 sessionStorage[step]。用户点击撤销时,回退到上一个快照(step-1),重做时前进到下一个快照(step+1)。最多保留 100 个快照,超出时删除最早的 50 个。
表述 3:快捷键系统
通过 onkeydown 事件监听,实现四个快捷键:
- Ctrl+C:复制组件配置
- Ctrl+V:粘贴到 FlexBox 中
- Ctrl+B:复制组件样式
- Ctrl+Q:粘贴样式到其他组件(样式刷功能)
表述 4:发布流程
用户点击发布后,系统生成唯一 ID,将 views 序列化为 JSON,POST 到后端 /api/register���后端存储该页面配置,返回可访问的链接。用户可通过链接分享页面,也可在 ReleaseDialog 中预览最终效果。
表述 5:数据持久化
分为两个层级:
- sessionStorage:用于撤销/重做,会话结束后清空
- localStorage:用于长期保存,浏览器重新打开时可恢复
🤔 关键追问清单
-
"为什么要用 eventHandle 函数而不是直接在 mounted 中写事件监听?"
- 答:通过 eventHandle 统一管理所有事件,代码更清晰、更易维护。可以轻松添加或删除事件。
-
"sessionStorage 中的快照什么时候被清空?"
- 答:浏览器关闭或标签页关闭时自动清空。重新打开页面时,从 localStorage 恢复最后保存的状态。
-
"为什么粘贴只能在 FlexBox 中进行?"
- 答:因为基础组件(TextCom、ButtonCom等)没有 children 数组,无法包含子组件。只有容器类组件(FlexBox)才有 children。
-
"Top.vue 如何知道什么时候启用/禁用删除按钮?"
- 答:通过监听事件总线的 'selectEvent'。如果 index === -1(没有选中组件),则禁用;否则启用。
-
"发布后的页面是如何加载的?"
- 答:在 ReleaseDialog 中使用
<iframe :src="'/release/:id'" />。当用户访问/release/:id时,后端返回预生成的 HTML 页面,包含保存的 views 数据。
- 答:在 ReleaseDialog 中使用
🚀 实战建议
现在你可以尝试:
练习 1:追踪一个完整的删除流程
- 添加一个组件到画布
- 点击删除按钮
- 在浏览器开发者工具中追踪:
- Top.vue 发送的事件
- centerEvent.js 接收到的事件
- Center.vue 的 views 数组变化
练习 2:测试撤销/重做
- 添加组件 A
- 修改组件 A 的属性
- 添加组件 B
- 点击撤销两次,应该回到"只有组件 A"的状态
- 点击重做两次,应该回到"有组件 A 和 B,且 A 被修改"的状态
练习 3:理解快捷键
- 添加一个 FlexBox
- 在 FlexBox 中添加一个 TextCom
- 选中 TextCom,按 Ctrl+B(复制样式)
- 添加另一个 TextCom
- 选中新的 TextCom,按 Ctrl+Q(粘贴样式)
- 观察样式是否同步
现在你是否准备好学习第五阶段了? 🎯
第五阶段会讲:
- 工作流完成:从编辑到发布
- Release.vue 的实现
- 数据导出(HTML、Vue、JSON)
- 完整项目打包与部署
或者你想深入某个具体功能?比如:
- 如何实现一个新的组件类型?
- 如何添加新的属性面板?
- 如何修改快捷键?