数据结构
组件数据结构
- props:组件的属性,包括样式属性和一些其他属性,比如
url
,action
等。 - id:组件的
id
,唯一标识,使用第三方库uuid
生成。 - name:组件的名称,用于动态组件渲染的
:is
属性
编辑器数据结构
- components:组件列表,当前画布添加了哪些组件。
- currentElement:激活的组件,表示当前正在编辑的组件。
其他知识点
- Module: 给
vuex
模块化提供类型,第一个参数是当前模块的类型,第二个参数是整个store
的类型。
import type { Module } from 'vuex';
import type { GlobalStore } from './index';
import { v4 as uuidv4 } from 'uuid';
// 组件数据结构
export interface ComponentData {
// 这个元素的属性
props: { [key: string]: unknown };
// id, uuid v4 生成
id: string;
// 业务组件库的名称 l-text , l-image 等,动态组件渲染的组件名称。
name: string;
}
// 编辑器数据结构
export interface EditorStore {
// 供中间编辑器渲染的数据
components: ComponentData[];
// 当前编辑的是哪一个元素 , uuid
currentElement: string;
}
// 测试数据
const testComponents: ComponentData[] = [
{
id: uuidv4(),
name: 'l-text',
props: {
text: 'hello',
fontSize: '20px',
tag: 'div',
},
},
{
id: uuidv4(),
name: 'l-text',
props: {
text: 'hello2',
fontSize: '14px',
tag: 'div',
color: 'red',
},
},
{
id: uuidv4(),
name: 'l-text',
props: {
text: 'hello3',
tag: 'div',
fontSize: '12px',
fontWeight: '800',
actionType: 'url',
url: 'http://www.baidu.com',
},
},
];
const editorStore: Module<EditorStore, GlobalStore> = {
state: {
// 组件列表
components: testComponents,
// 当前操作的组件
currentElement: '',
},
};
export default editorStore;
基础布局
使用 ant-design-vue
进行基础的布局,包含 header
,组件列表区域,画布组件编辑区域,组件属性编辑区域。
<template>
<div class="editor"
id="editor-layout-main">
<!-- header -->
<a-layout :style="{ background: '#fff' }">
<a-layout-header class="header">
<div class="page-title"
:style="{ color: '#fff' }">
慕课乐高
</div>
</a-layout-header>
</a-layout>
<a-layout>
<!-- 左侧组件列表 -->
<a-layout-sider width="300"
style="background:yellow">
<div class="sidebar-container">
组件列表
</div>
</a-layout-sider>
<!-- 中间画布编辑区域 -->
<a-layout style="padding:0 24px 24px">
<a-layout-content class="preview-container">
<p>画布区域</p>
<!-- 组件列表 -->
<div class="preview-list"
id="canvas-area">
<!-- 使用动态组件进行渲染 -->
<component v-for="component in components"
:key="component.id"
:is="component.name"
v-bind="component.props"></component>
</div>
</a-layout-content>
</a-layout>
<!-- 右侧组件属性编辑 -->
<a-layout-sider width="300"
style="background:purple"
class="setting-container">
组件属性
</a-layout-sider>
</a-layout>
</div>
</template>
<script lang="ts">
import { computed, defineComponent } from 'vue';
import { useStore } from 'vuex';
import { GlobalStore } from '../store/index'
import LText from '../components/LText.vue'
export default defineComponent({
components: {
LText
},
setup() {
// 从 store 里获取数据,使用泛型以获得类型
const store = useStore<GlobalStore>()
// 从 store 里回组件列表
const components = computed(() => store.state.editor.components)
return {
components
}
}
})
</script>
<style scoped>
.editor {
width: 100%;
height: 100%;
}
.ant-layout-has-sider {
height: calc(100% - 64px);
}
.preview-list {
background: #fff;
position: relative;
}
</style>
课程里同时使用 option Api
和 Composition API
,我认为这不是一个好的方式,所以我打算使用 setup
语法糖进行改写,但是在使用动态组件 :is
时,如果不显式的注册组件,最后渲染出来的结果就是一个自定义标签,而不是我们书写的组件。
// template
<component v-for="component in components"
:key="component.id"
:is="component.name"
v-bind="component.props"></component>
// 使用 setup 语法糖时
<script lang="ts" setup>
import { computed } from 'vue';
import { useStore } from 'vuex';
import { GlobalStore } from '../store/index'
// 只是引用而没有显式的注册
import LText from '../components/LText.vue'
// 从 store 里获取数据,使用泛型以获得类型
const store = useStore<GlobalStore>()
// 从 store 里回组件列表
const components = computed(() => store.state.editor.components)
</script>
dom
结构会渲染成这样,这显然不是我们想要的。
对此,官网给出了解释,我们来看一下。
所以当我们使用 setup
语法糖的时候,就没有办法显式的注册组件(也可能是我不知道方法),那么就只能用第二种方式,绑定一个导入的组件对象,这样的话就要多写一个组件对象和组件名称的映射表,这样就解决问题了。
// template
<component v-for="component in components"
:key="component.id"
:is="componentMap[component.name]"
v-bind="component.props"></component>
// 使用 setup 语法糖时
<script lang="ts" setup>
// 组件实例映射表的类型
export interface ComponentMap {
[key: string]: Component;
}
import { computed, Component } from 'vue';
import { useStore } from 'vuex';
import { GlobalStore } from '../store/index'
import LText from '../components/LText.vue'
// 从 store 里获取数据,使用泛型以获得类型
const store = useStore<GlobalStore>()
// 从 store 里回组件列表
const components = computed(() => store.state.editor.components)
// 组件实例映射表
const componentMap: ComponentMap = {
'l-text': LText
}
</script>
L-Text 组件
配置组件的通用默认属性以及 l-text
组件的特有默认属性。
import { mapValues, without } from 'lodash-es';
// 通用的默认属性
export const commonDefaultProps = {
// actions
actionType: '',
url: '',
// size
height: '',
width: '318px',
paddingLeft: '0px',
paddingRight: '0px',
paddingTop: '0px',
paddingBottom: '0px',
// border type
borderStyle: 'none',
borderColor: '#000',
borderWidth: '0',
borderRadius: '0',
// shadow and opacity
boxShadow: '0 0 0 #000000',
opacity: '1',
// position and x,y
position: 'absolute',
top: '0',
left: '0',
right: '0',
bottom: '0',
};
// l-text 组件特有默认属性
export const textDefaultProps = {
// basic props - font styles
text: '正文内容',
fontSize: '14px',
fontFamily: '',
fontWeight: 'normal',
fontStyle: 'normal',
textDecoration: 'none',
lineHeight: '1',
textAlign: 'left',
color: '#000000',
backgroundColor: '',
...commonDefaultProps,
};
// 排除非样式属性
export const textStylePropNames = without(
Object.keys(textDefaultProps),
'actionType',
'url',
'text',
);
// 转换成组件的props属性
export const transformToComponentProps = <T extends { [key: string]: any }>(
props: T,
) => {
return mapValues(props, (item) => {
return {
type: item.constructor,
default: item,
};
});
};
封装一个 hooks
,挑选出样式属性,并返回一个点击事件处理函数。
import { computed } from 'vue';
import { pick } from 'lodash-es';
// 使用 lodash 的 pick 方法挑选出样式属性,并返回一个点击事件处理函数
const useComponentCommon = <T extends { [key: string]: any }>(
props: T,
picks: string[],
) => {
const styleProps = computed(() => pick(props, picks));
const handleClick = () => {
if (props.actionType === 'url' && props.url) {
window.location.href = props.url;
}
};
return {
styleProps,
handleClick,
};
};
export default useComponentCommon;
- 这里偷了个懒,没有去使用
setup
语法糖进行改写。 :is
绑定的tag
属性是渲染后的标签类型。
<template>
<!-- 使用动态组件进行渲染 -->
<component :is="tag"
:style="styleProps"
@click="handleClick"
class="l-text-component">
{{ text }}
</component>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { transformToComponentProps, textDefaultProps, textStylePropNames } from '../defaultProps'
import useComponentCommon from '../hooks/useComponentCommon'
const defaultProps = transformToComponentProps(textDefaultProps)
export default defineComponent({
// 合并 props
props: {
tag: {
type: String,
default: 'div'
},
...defaultProps
},
setup(props) {
// 获取到样式属性
const { styleProps, handleClick } = useComponentCommon(props, textStylePropNames)
return {
styleProps,
handleClick
}
}
})
</script>
<style scoped>
h2.l-text-component,
p.l-text-component {
margin-bottom: 0;
}
button.l-text-component {
padding: 5px 10px;
cursor: pointer;
}
.l-text-component {
box-sizing: border-box;
white-space: pre-wrap;
position: relative !important;
}
</style>
最终结果如下,l-text
组件就渲染到画布区域了,有点丑,但是重点又不是样式,不重要了。