低代码phase4

9 阅读6分钟

完美!现在让我为你详细讲解第四阶段:完整工作流程与事件系统。这是项目的"神经中枢"。


⚡ 第四阶段深入:完整工作流程与事件总线系统

第一部分:事件总线(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)  // ⬅️ 一次性注册所有事件
}

完整事件列表与详解

事件名发送方接收方作用
backOffTop.vueCenter.vue撤销
forwardTop.vueCenter.vue重做
toPcTop.vueCenter.vue切换到 PC 视图
toPhoneTop.vueCenter.vue切换到手机视图
switchStateTop.vueCenter.vue切换编辑/预览模式
releaseTop.vueCenter.vue发布页面
saveTop.vueCenter.vue保存到 localStorage
resetTop.vueCenter.vue重置为空
inputJsonTop.vueCenter.vue导入 JSON
switchPatternTop.vueCenter.vue切换定位模式
selectEventPowerfulDynamicDrawTop.vue通知按钮启用/禁用
viewsCenter/Right 之间Right.vue发送选中的组件
clearFocus多处所有地方清除焦点
updateCurrentCom多处Center.vue更新当前组件
refreshCurrentViews多处Center.vue更新当前组件索引
deleteFlexBoxCom多处DynamicDraw重置索引
onkeydownCenter.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:用于长期保存,浏览器重新打开时可恢复

🤔 关键追问清单

  1. "为什么要用 eventHandle 函数而不是直接在 mounted 中写事件监听?"

    • 答:通过 eventHandle 统一管理所有事件,代码更清晰、更易维护。可以轻松添加或删除事件。
  2. "sessionStorage 中的快照什么时候被清空?"

    • 答:浏览器关闭或标签页关闭时自动清空。重新打开页面时,从 localStorage 恢复最后保存的状态。
  3. "为什么粘贴只能在 FlexBox 中进行?"

    • 答:因为基础组件(TextCom、ButtonCom等)没有 children 数组,无法包含子组件。只有容器类组件(FlexBox)才有 children。
  4. "Top.vue 如何知道什么时候启用/禁用删除按钮?"

    • 答:通过监听事件总线的 'selectEvent'。如果 index === -1(没有选中组件),则禁用;否则启用。
  5. "发布后的页面是如何加载的?"

    • 答:在 ReleaseDialog 中使用 <iframe :src="'/release/:id'" />。当用户访问 /release/:id 时,后端返回预生成的 HTML 页面,包含保存的 views 数据。

🚀 实战建议

现在你可以尝试:

练习 1:追踪一个完整的删除流程

  1. 添加一个组件到画布
  2. 点击删除按钮
  3. 在浏览器开发者工具中追踪:
    • Top.vue 发送的事件
    • centerEvent.js 接收到的事件
    • Center.vue 的 views 数组变化

练习 2:测试撤销/重做

  1. 添加组件 A
  2. 修改组件 A 的属性
  3. 添加组件 B
  4. 点击撤销两次,应该回到"只有组件 A"的状态
  5. 点击重做两次,应该回到"有组件 A 和 B,且 A 被修改"的状态

练习 3:理解快捷键

  1. 添加一个 FlexBox
  2. 在 FlexBox 中添加一个 TextCom
  3. 选中 TextCom,按 Ctrl+B(复制样式)
  4. 添加另一个 TextCom
  5. 选中新的 TextCom,按 Ctrl+Q(粘贴样式)
  6. 观察样式是否同步

现在你是否准备好学习第五阶段了? 🎯

第五阶段会讲:

  • 工作流完成:从编辑到发布
  • Release.vue 的实现
  • 数据导出(HTML、Vue、JSON)
  • 完整项目打包与部署

或者你想深入某个具体功能?比如:

  • 如何实现一个新的组件类型?
  • 如何添加新的属性面板?
  • 如何修改快捷键?