完美!现在我为你详细讲解第三阶段:渲染引擎与组件系统。这是低代码平台的核心。
🧠 第三阶段深入:渲染引擎 + 基础组件系统
第一部分:渲染引擎三剑客
你需要理解三个渲染组件的关系和用途:
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 是一个纯展示+交互转发器。它的工作就是:
- 遍历 views 数组
- 为每个组件动态创建
<component :is="type">标签- 在编辑模式下,捕获用户交互(选中、拖拽)
- 将交互事件发送给父组件处理
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>
关键设计:
在编辑模式下,按钮被设为
disabled且pointer-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>
关键特性:
- 递归渲染:FlexBox 的 children 中的组件可以继续是 FlexBox,形成嵌套
- 独立的 drop 处理:可以将组件拖拽进 FlexBox 内部
- 选中反馈:显示半透明遮罩和"添加组件"按钮
第四部分:属性面板系统
属性面板的分类
属性面板分为两大类:
-
数据属性面板(Property)
- 修改数据内容(如文本、图片 URL、视频 URL 等)
- 示例:TextProp、ButtonProp、ImgProp
-
样式属性面板(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)
- 每个"模块组件"都独立处理一种输入类型,知道如何修改特定属性
- 这样可以在多个属性面板间复用模块组件
🤔 关键追问清单
-
"PowerfulDynamicDraw 中,
:is="view.component"是如何工作的?"- 动态组件特性,根据 view.component 的值(如 "TextCom")动态决定渲染哪个组件
-
"为什么基础组件只需要 props: ['views']?"
- 因为 views 对象包含了该组件所需的所有数据和样式,组件只需读取即可
-
"修改属性面板中的值,为什么基础组件会自动更新?"
- 因为属性面板和基础组件都引用同一个 views 对象(对象引用),修改该对象后,Vue 的反应性系统会触发重新渲染
-
"FlexBox 和基础组件的最大区别是什么?"
- FlexBox 有 children 数组,可以包含其他组件;基础组件没有
-
"为什么 TextStyle 中的 datas 是一个三维数组?"
- 为了分组显示样式属性,提高用户界面的可读性和易用性
🚀 实战理解建议
现在建议你做这个练习:
任务 1:追踪一个完整的修改流程
- 在 Canvas 中添加一个文本组件
- 在 Right.vue 中修改文本内容
- 打开浏览器开发者工具,追踪数据变化
- 验证 Center.vue 的 views 数组是否同时改变
任务 2:理解 views 对象结构
- 在浏览器控制台打印 Center.vue 的 this.views
- 观察数组中每个对象的结构
- 对比 commonData.js 中的模板,理解哪些字段是模板提供的,哪些是运行时生成的
任务 3:修改一个属性面板
- 打开 TextProp.vue 或 ButtonProp.vue
- 添加一个新的输入字段(如修改字体大小的输入框)
- 测试修改是否立即反映到画布上的组件
你现在理解了吗?如果还有疑问,可以:
✅ 问我具体的代码细节 ✅ 让我绘制数据流程图 ✅ 请求我演示如何添加新组件类型 ✅ 要求我解释某个复杂的交互逻辑
或者,你准备好进入第四阶段了吗? 🚀
第四阶段会讲:
- 事件总线(Mitt)的完整用法
- 所有事件的发送和接收
- 删除、复制、粘贴、撤销等高级操作