低代码phase3

8 阅读8分钟

完美!现在我为你详细讲解第三阶段:渲染引擎与组件系统。这是低代码平台的核心。


🧠 第三阶段深入:渲染引擎 + 基础组件系统

第一部分:渲染引擎三剑客

你需要理解三个渲染组件的关系和用途:

PowerfulDynamicDraw    ← 高级渲染引擎(目前在用)
       ↑
  DynamicDraw          ← 标准动态渲染(备用)
       ↑
  SingleDraw           ← 单组件渲染(未用)

PowerfulDynamicDraw.vue:强大的渲染引擎

职责:

  • 渲染 views 数组中的所有组件
  • 处理编辑模式下的交互(选中、拖拽)
  • 提供视觉反馈(高亮边框、选中框)

完整代码分析:

<template>
  <div>
    <!-- 第1层:循环遍历 views 数组 -->
    <component
      v-for="(view, index) in views"    ⬅️ 遍历每个组件配置
      :key="view.id"                     ⬅️ 为了不重复渲染,用 id 作 key
      
      <!-- 第2层:传递数据 -->
      :views="view"                      ⬅️ 传递完整的组件配置对象
      :is="view.component"               ⬅️ 动态决定渲染哪个组件(TextCom、ButtonCom等)
      
      <!-- 第3层:传递编辑相关属性 -->
      :edit="edit"                       ⬅️ 是否编辑模式
      :currentIndex="currentIndex"       ⬅️ 当前选中的索引
      :currentCom="currentCom"           ⬅️ 当前选中的组件
      :centerCom="centerCom"             ⬅️ 是否是来自画布中心的组件
      :pattern="pattern"                 ⬅️ 定位模式
      
      <!-- 第4层:样式 -->
      :style="{
        width: view.style.width,
        height: view.style.height
      }"
      :class="{
        selected: index == currentIndex && edit && view == currentCom,  ⬅️ 选中时加边框
        componenthover: edit                                            ⬅️ 编辑模式时显示 hover 效果
      }"
      
      <!-- 第5层:事件 -->
      @click.stop="select(index, view)"           ⬅️ 点击选中组件
      @dragstart.stop="dragstart(view, index, $event)"  ⬅️ 开始拖拽组件
      @dragenter.stop="dragenterLight($event, view)"    ⬅️ 拖拽进入时高亮
      
      :draggable="edit"                  ⬅️ 只在编辑模式下可拖拽
    ></component>
  </div>
</template>

关键方法分析:

methods: {
  // ① 选中组件
  select(index, view) {
    // 向父组件(Center.vue)发送事件
    this.$emit("selectEvent", index, view)
    // 这会触发 Center.vue 的 select 方法
  },
  
  // ② 拖拽开始(画布内的组件拖拽到另一个地方)
  dragstart(view, index, e) {
    // 向父组件发送事件
    this.$emit("dragstartEvent", view, index, e)
    // 这会用来支持"移动已有组件"的功能
  },
  
  // ③ 拖拽进入时高亮(如果后来实现了 FlexBox 拖拽进去)
  dragenterLight(e, view) {
    e.preventDefault()
    // 清除其他组件的焦点
    this.$bus.emit("clearFocus")
    // 标记这个组件为焦点
    view.focus = true
  }
}

样式分析:

.selected {
  border: 2px solid rgba(0, 108, 255);  // 选中时的蓝色边框
}

.componenthover:hover {
  border: 1px dashed rgb(0, 108, 255);  // 编辑模式下鼠标悬浮时的虚线边框
}

关键理解:

PowerfulDynamicDraw 是一个纯展示+交互转发器。它的工作就是:

  1. 遍历 views 数组
  2. 为每个组件动态创建 <component :is="type"> 标签
  3. 在编辑模式下,捕获用户交互(选中、拖拽)
  4. 将交互事件发送给父组件处理

DynamicDraw.vue vs SingleDraw.vue

这两个是备选方案,功能相似但有细微差异:

<!-- DynamicDraw.vue: 多一层 wrapper div -->
<div v-for="(view, index) in views">
  <div class="block tpl-container">  ⬅️ 额外的包裹层
    <component :is="view.component" />
  </div>
</div>

<!-- SingleDraw.vue: 结构相同但事件处理略有不同 -->
<div v-for="(view, index) in views">
  <div class="block tpl-container">
    <component :is="view.component" />
  </div>
</div>

为什么 PowerfulDynamicDraw 更"强大"?

PowerfulDynamicDraw 直接在 <component> 上处理事件,而不需要包裹层。这样:

  • 更直接、更简洁
  • 减少一层 DOM
  • 事件处理更精确

第二部分:基础组件系统

基础组件的通用结构

所有基础组件(TextCom、ButtonCom、ImgCom 等)都遵循相同的模式:

<template>
  <!-- 外层容器:使用 views.style(位置、大小、边距等) -->
  <div class="container" :style="views.style">
    
    <!-- 内层内容:使用 views.内层样式(如 textStyle、btnStyle) -->
    <div :style="views.textStyle">
      {{ views.content }}
    </div>
  </div>
</template>

<script>
export default {
  props: ['views', 'edit']
  // 仅此而已!
}
</script>

TextCom:最简单的组件

<template>
  <div class="text" :style="views.style">
    <div :style="views.textStyle">
      {{views.content}}
    </div>
  </div>
</template>

<script>
export default {
  props: ['views'],
  data() {
    return {
      textarea1: '',
    }
  }
}
</script>

核心理解:

  • 外层 <div> 的样式来自 views.style
  • 内层文本的样式来自 views.textStyle
  • 文本内容来自 views.content

数据结构回顾:

{
  component: "TextCom",
  content: "编辑文字",      ⬅️ 在 TextProp 中修改
  style: {
    top: '', left: '',
    width: ' ', height: ''   ⬅️ 在样式面板修改
  },
  textStyle: {
    fontSize: "14px",
    color: "#000",           ⬅️ 在 TextStyle 中修改
    textAlign: 'left'
  },
  sonStyle: 'textStyle'      ⬅️ 指向"子样式"属性名
}

ButtonCom:带交互的组件

<template>
  <div :style="views.style" class="button-com" @click="btnClick">
    <button 
      :type="views.btnType"                    ⬅️ button 类型(button/submit/reset)
      :style="[views.btnStyle, {'pointer-events': pointEvent}]"
      @click="btnClick"
      disabled                                 ⬅️ 在编辑模式下禁用,防止跳转
    >
      {{views.props.content}}                 ⬅️ 按钮文本
    </button>
  </div>
</template>

<script>
export default {
  props: ['views', 'edit'],
  methods: {
    btnClick(e) {
      if (this.edit) {
        e.preventDefault()  // 编辑模式下阻止默认行为
      }
    }
  },
  computed: {
    pointEvent() {
      // 编辑模式:禁用鼠标事件(pointer-events: none)
      // 预览模式:启用鼠标事件(pointer-events: initial)
      return this.edit ? 'none' : 'initial'
    }
  }
}
</script>

关键设计:

在编辑模式下,按钮被设为 disabledpointer-events: none,这样用户点击时只会选中组件,而不会触发按钮本身的行为。


ImgCom:使用 Element Plus 的组件

<template>
  <div class="card" :style="views.style">
    <el-image                    ⬅️ Element Plus 的图片组件
      :src="views.src"
      :style="views.imgStyle"
      fit="cover"
      :alt="views.alt"
    >
      <template v-slot:error>
        <div class="image-slot">
          <i class="el-icon-picture-outline"></i>
          <p>点击上传图片</p>
        </div>
      </template>
    </el-image>
  </div>
</template>

<script>
export default {
  props: ["views"],
  // 其他省略...
}
</script>

要点:

  • 使用 Element Plus 的 <el-image> 组件,提供更好的图片处理
  • 有错误插槽(图片加载失败时显示)
  • fit="cover" 确保图片覆盖整个容器

VideoCom:使用 Video.js 的组件

<template>
  <div :style="views.style">
    <video 
      id="video"
      ref="video"
      :poster="views.poster"         ⬅️ 视频封面
      :style="views.videoStyle"
      class="video-js vjs-default-skin"
      :src="videoSrc"
      :controls="views.controls"     ⬅️ 显示控制条
    >
      <source :src="videoSrc" />
    </video>
  </div>
</template>

<script>
export default {
  props: ["views"],
  data() {
    return {
      player: null,
      videoSrc: '',
    }
  },
  watch: {
    // 监听视频 URL 变化,自动更新
    'views.src': {
      deep: true,
      immediate: true,
      handler(newVal) {
        this.videoSrc = newVal
      },
    },
  },
}
</script>

注意:

  • 使用 watch 监听 views.src 的变化
  • 当父组件更新视频 URL 时,自动同步

第三部分:FlexBox(容器组件)

FlexBox 是最复杂的组件,因为它可以包含其他组件

FlexBox 的结构

{
  component: "FlexBox",
  style: {
    display: "flex",
    flexDirection: "row",
    justifyContent: "center",
    alignItems: "center",
    width: "100%",
    height: "60px"
  },
  children: [        ⬅️ 这是关键!包含子组件
    {
      component: "TextCom",
      content: "子组件1",
      // ...
    },
    {
      component: "ButtonCom",
      // ...
    }
  ]
}

FlexBox.vue 代码分析

<template>
  <!-- 外层容器 -->
  <div 
    class="myflexbox"
    :style="views.style"          ⬅️ Flexbox 自己的样式
    :class="{
      showBorder: edit,
      mask: views.focus && edit    ⬅️ 选中时显示半透明遮罩
    }"
    @drop.stop="dropParent($event)"  ⬅️ 接收拖拽的组件
  >
    <!-- 添加组件的按钮(当 FlexBox 被选中时显示) -->
    <CenterButton 
      @click.stop="addFlexBox(views)"
      v-if="views.focus && edit"
    ></CenterButton>
    
    <!-- 子组件列表 -->
    <component
      v-for="(node, index) in views.children"  ⬅️ 递归渲染子组件!
      :key="node.id"
      :is="node.component"
      :views="node"
      :edit="edit"
      @click.stop="focusChild(index, key)"
      @dragstart.stop="dragstart(views.children, node, index)"
    ></component>
  </div>
</template>

<script>
export default {
  methods: {
    // 处理拖拽到 FlexBox 的新组件
    dropParent(e) {
      if (!this.edit) return
      
      const newData = this.centerCom === true 
        ? this.dropCenterCom(e)      // 移动已有组件
        : this.dropCommon(e)         // 添加新组件
      
      // 加入 children 数组
      this.views.children.push(newData)
      
      // 向右侧发送事件,显示属性面板
      this.$bus.emit('views', newData)
    },
    
    // 选中子组件
    focusChild(index) {
      const view = this.views.children[index]
      view.focus = true
      this.$bus.emit('views', view)
    }
  }
}
</script>

关键特性:

  1. 递归渲染:FlexBox 的 children 中的组件可以继续是 FlexBox,形成嵌套
  2. 独立的 drop 处理:可以将组件拖拽进 FlexBox 内部
  3. 选中反馈:显示半透明遮罩和"添加组件"按钮

第四部分:属性面板系统

属性面板的分类

属性面板分为两大类:

  1. 数据属性面板(Property)

    • 修改数据内容(如文本、图片 URL、视频 URL 等)
    • 示例:TextProp、ButtonProp、ImgProp
  2. 样式属性面板(Style)

    • 修改 CSS 样式(如颜色、字体、布局等)
    • 示例:TextStyle、ButtonStyle

TextProp:最简单的属性面板

<template>
  <div>
    <div class='module border smaller'>文本内容</div>
    <!-- 双向绑定:修改这里会直接更新 views.content -->
    <textarea class='content' v-model="views.content"></textarea>
  </div>
</template>

<script>
export default {
  props: ["views"],  // 接收当前选中的组件
  // 仅此而已!
}
</script>

工作流程:

用户在 TextProp 中输入文本
  ↓
v-model 绑定更新 views.content
  ↓
Center.vue 中的 this.views[currentIndex].content 同时改变(对象引用)
  ↓
PowerfulDynamicDraw 监听到 views 变化
  ↓
TextCom 重新渲染,显示新文本

ButtonProp:模块化的属性面板

<template>
  <div>
    <div class='module'>常规</div>
    <!-- 动态加载子组件 -->
    <component 
      v-for="(datas, index) in datas"
      :key="index"
      :is="datas.flag"              ⬅️ 动态组件类型
      :datas="datas"                ⬅️ 传递配置
      :views="views"                ⬅️ 传递当前组件
    ></component>
  </div>
</template>

<script>
export default {
  props: ["views"],
  data() {
    return {
      datas: [
        {
          flag: "colorChoose",       // 动态组件名称
          label: "背景颜色",
          style: "btnStyle",         // 修改 views.btnStyle
          comprop: "backgroundColor" // 修改哪个属性
        },
        {
          flag: "selectGroup",
          label: "按钮类型",
          style: "props",
          comprop: "btnType",
          options: [
            { label: "button", content: "按钮" },
            { label: "submit", content: "提交" },
            { label: "reset", content: "重置" }
          ]
        },
        {
          flag: "txtInput",
          label: "按钮内容",
          style: "props",
          comprop: "content"
        }
      ]
    }
  }
}
</script>

架构模式:

ButtonProp
  ↓ 内部包含多个"模块组件"
  ├─ colorChoose       (颜色选择器)
  ├─ selectGroup       (下拉选择)
  └─ txtInput          (文本输入)

每个"模块组件"都知道如何修改特定的属性。这种设计允许:

  • 代码复用:colorChoose 可被多个面板使用
  • 灵活配置:通过 datas 数组轻松添加/删除属性
  • 动态渲染:is="datas.flag" 动态决定渲染哪个组件

TextStyle:分组的样式面板

<template>
  <div>
    <!-- 字体组 -->
    <div class='module'>字体</div>
    <component 
      v-for="(datas, index) in datas[0]"
      :key="'First' + index"
      :is="datas.flag"
      :datas="datas"
      :views="views"
    ></component>
    
    <!-- 文本组 -->
    <div class='module'>文本</div>
    <component 
      v-for="(datas, index) in datas[1]"
      :key="'second' + index"
      :is="datas.flag"
      :datas="datas"
      :views="views"
    ></component>
    
    <!-- 定位组 -->
    <div class='module'>定位</div>
    <component 
      v-for="(datas, index) in datas[2]"
      :key="'third' + index"
      :is="datas.flag"
      :datas="datas"
      :views="views"
    ></component>
  </div>
</template>

<script>
export default {
  props: ["views"],
  data() {
    return {
      datas: [
        [  // 字体属性
          { flag: "colorChoose", label: "字体颜色", style: "textStyle", comprop: "color" },
          { flag: "selectCols", label: "字体粗细", style: "textStyle", comprop: "fontWeight", options: ['100', '200', ...] },
          // ...
        ],
        [  // 文本属性
          { flag: "numInput", label: "行高", style: "textStyle", comprop: "lineHeight" },
          // ...
        ],
        [  // 定位属性
          { flag: "numInput", label: "间距", style: "textStyle", comprop: "letterSpacing" },
          // ...
        ]
      ]
    }
  }
}
</script>

关键设计模式:

datas[0] = 字体相关属性(颜色、��体、大小等)
datas[1] = 文本相关属性(行高、缩进、对齐等)
datas[2] = 定位相关属性(间距、间隔等)

第五部分:模块化 UI 组件

这些是最底层的输入组件,被各种属性面板复用:

src/components/module/
├─ numInput.vue       (数字输入框)
├─ colorChoose.vue    (颜色选择器)
├─ txtInput.vue       (文本输入框)
├─ selectCols.vue     (下拉选择)
├─ selectGroup.vue    (单选按钮组)
└─ switchBtn.vue      (开关按钮)

每个模块组件都接收:

{
  datas: {
    label: "显示标签",
    style: "textStyle",      // 指向哪个样式对象
    comprop: "fontSize"      // 指向该对象的哪个属性
  },
  views: {...}               // 当前组件配置
}

然后自动修改:

views[datas.style][datas.comprop] = newValue

🎯 完整渲染流程(可背诵版本)

从数据到渲染的完整路径

1️⃣ Center.vue 维护 views 数组
   views = [
     { component: "TextCom", content: "Hello", style: {...}, textStyle: {...} },
     { component: "ButtonCom", props: {...}, style: {...}, btnStyle: {...} }
   ]

2️⃣ PowerfulDynamicDraw 监听 views 变化
   <component 
     v-for="(view, index) in views"
     :is="view.component"
     :views="view"
   ></component>
   
   自动生成:
   <TextCom :views="views[0]" />
   <ButtonCom :views="views[1]" />

3️⃣ TextCom / ButtonCom 接收数据并渲染
   <div :style="views.style">
     <div :style="views.textStyle">
       {{ views.content }}
     </div>
   </div>

4️⃣ 用户在 Right.vue 的 TextProp 中修改内容
   <textarea v-model="views.content"></textarea>
   
5️⃣ 修改自动反映到 Center.vue(对象引用)
   this.views[currentIndex].content = "新文本"

6️⃣ PowerfulDynamicDraw 检测到变化,重新渲染
   TextCom 显示新文本

📝 可背诵的核心表述

表述 1:三层渲染体系

渲染系统分为三层:

  • 高层:PowerfulDynamicDraw(循环遍历 views,为每个组件创建动态组件标签)
  • 中层:基础组件(TextCom、ButtonCom 等,接收 views 对象,读取样式和数据)
  • 底层:模块化 UI 组件(colorChoose、numInput 等,修改样式属性)

表述 2:双向数据绑定

虽然不是 Vue 的 v-model,但通过对象引用实现了类似的效果:

  • Right.vue 的属性面板修改 views 对象的属性
  • Center.vue 的 views 数组同时改变(因为指向同一个对象)
  • PowerfulDynamicDraw 监听到 views 变化,基础组件自动重新渲染

表述 3:FlexBox 的特殊性

FlexBox 是容器组件,不同于基础组件:

  • children 数组,可以包含其他组件
  • 可以递归嵌套(FlexBox 中的 children 可继续是 FlexBox)
  • 有独立的 drop 处理,支持拖拽组件到容器内部

表述 4:属性面板的架构

属性面板采用组合模式

  • TextProp 本身很简单(直接修改 views.content)
  • ButtonProp 由多个"模块组件"组成(colorChoose、selectGroup、txtInput)
  • 每个"模块组件"都独立处理一种输入类型,知道如何修改特定属性
  • 这样可以在多个属性面板间复用模块组件

🤔 关键追问清单

  1. "PowerfulDynamicDraw 中,:is="view.component" 是如何工作的?"

    • 动态组件特性,根据 view.component 的值(如 "TextCom")动态决定渲染哪个组件
  2. "为什么基础组件只需要 props: ['views']?"

    • 因为 views 对象包含了该组件所需的所有数据和样式,组件只需读取即可
  3. "修改属性面板中的值,为什么基础组件会自动更新?"

    • 因为属性面板和基础组件都引用同一个 views 对象(对象引用),修改该对象后,Vue 的反应性系统会触发重新渲染
  4. "FlexBox 和基础组件的最大区别是什么?"

    • FlexBox 有 children 数组,可以包含其他组件;基础组件没有
  5. "为什么 TextStyle 中的 datas 是一个三维数组?"

    • 为了分组显示样式属性,提高用户界面的可读性和易用性

🚀 实战理解建议

现在建议你做这个练习:

任务 1:追踪一个完整的修改流程

  1. 在 Canvas 中添加一个文本组件
  2. 在 Right.vue 中修改文本内容
  3. 打开浏览器开发者工具,追踪数据变化
  4. 验证 Center.vue 的 views 数组是否同时改变

任务 2:理解 views 对象结构

  1. 在浏览器控制台打印 Center.vue 的 this.views
  2. 观察数组中每个对象的结构
  3. 对比 commonData.js 中的模板,理解哪些字段是模板提供的,哪些是运行时生成的

任务 3:修改一个属性面板

  1. 打开 TextProp.vue 或 ButtonProp.vue
  2. 添加一个新的输入字段(如修改字体大小的输入框)
  3. 测试修改是否立即反映到画布上的组件

你现在理解了吗?如果还有疑问,可以:

✅ 问我具体的代码细节 ✅ 让我绘制数据流程图 ✅ 请求我演示如何添加新组件类型 ✅ 要求我解释某个复杂的交互逻辑

或者,你准备好进入第四阶段了吗? 🚀

第四阶段会讲:

  • 事件总线(Mitt)的完整用法
  • 所有事件的发送和接收
  • 删除、复制、粘贴、撤销等高级操作