背景
运营
:亲,我想要展示用户的请求信息,能不能搞一下嘛~😊
我
:哟?你tm说话就说话,别靠那么近,我tm不喜欢男的,你找产品/后端对一下拉个会呀。(yue!!!)
运营
:亲,不要嘛,来嘛,找产品又要多好几天,你说是不是,求求你了嘛。
我
:你赶快滚,一会小心我打的你亲妈都不认识。需求你整理一下,我马上给你搞完。行吗大姐。
我
:亲,后端大哥哥,这运营新加的需求,你搞个接口呗!
后端大佬
:滚!
我
:播放运营录音(小哥哥,加个页面嘛~)
后端大佬
:yue!!2小时后给你接口。
我
:yue!!!tm的想个办法解决这个事,这搞也不是个事。
注:纯属虚构,如有雷同,纯属雷同。yue!!!
前言
为提高运营/产品等消化运营支持/用户中心等逻辑性低,展示性强的业务需求,提高投入产出比,降低开发成本,提高运营/产品的灵活性。解决yue的事情!! 冲!
页面组成部分
物料区,编辑区,展示区,属性修改区。统一的json数据的导入导出。后端维护项目-页面地址-json的匹配关系。前端渲染抽出统一的renderJson方法,发布npm包统一维护和管理。
项目初始化
通过vue/cli初始化项目
// 这里我们选择vue3版本
vue create lowcode
cd lowcode
yarn
yarn serve
全局配置数据
// data.json
{
"container": {
"width": "1000",
"height": "1000"
},
"blocks": [
{
"top": 100,
"left": 100,
"zIndex": 1,
"key": "input"
},
{
"top": 200,
"left": 200,
"zIndex": 1,
"key": "text"
},
{
"top": 300,
"left": 300,
"zIndex": 1,
"key": "button"
}
]
}
container
用于配置渲染区的大小关系,blocks
用于在渲染区渲染对应的组建以及位置关系。通key
在我们的物料区寻找对应的组件。
物料区
物料是动态可配置的,所以我们需要单独提出来物料的配置项,可注册方法。同时提供预览的模式和渲染的模式。
// block-config.js
// 物料注册
import { ElButton, ElInput } from 'element-plus'
const registerConfig = () => {
const componentList = []
const componentMap = {}
return {
componentList,
componentMap,
register(component) {
componentList.push(component)
componentMap[component.key] = component
},
}
}
const registe = registerConfig()
registe.register({
key: 'text',
label: '文本',
preview: () => '预览文本',
render: () => '渲染文本',
})
registe.register({
key: 'button',
label: '按钮',
preview: () => <ElButton>预览按钮</ElButton>,
render: () => <ElButton>渲染按钮</ElButton>,
})
registe.register({
key: 'input',
label: '输入框',
preview: () => <ElInput placeholder="预览输入框"></ElInput>,
render: () => <ElInput placeholder="渲染输入框"></ElInput>,
})
export default registe
componentList
用于物料区的渲染,componentMap
用于渲染区的渲染,全局配置中通过key
的一一对应关系找到我们注册的组物料建
物料区的拖拽功能
// src/hook/useDragger
const useDragger = (containerRef, data) => {
let currentComponent = null
const dragenter = (e) => {
e.dataTransfer.dropEffect = 'move'
}
const dragover = (e) => {
e.preventDefault()
}
const dragleave = (e) => {
e.dataTransfer.dropEffect = 'none'
}
const drop = (e) => {
console.log('currentComponent: ', currentComponent)
data.value = {
...data.value,
blocks: [
...data.value.blocks,
{
top: e.offsetY,
left: e.offsetX,
zIndex: 1,
key: currentComponent.key,
alignCenter: true, // 标示在拖拽之后需要组件在鼠标中间,在组建渲染的onMonted中修改组件位置
},
],
}
currentComponent = null
}
const dragstart = (e, component) => {
/**
* dragenter 进入元素触发
* dragover 目标在元素中经过时触发,必须禁用默认行为,否则不触发drop
* dragleave 离开元素时触动,增加警用标示
* drop 松手是触发,将组建给添加到渲染区
*/
currentComponent = component
containerRef.value.addEventListener('dragenter', dragenter)
containerRef.value.addEventListener('dragover', dragover)
containerRef.value.addEventListener('dragleave', dragleave)
containerRef.value.addEventListener('drop', drop)
}
const dragend = () => {
containerRef.value.removeEventListener('dragenter', dragenter)
containerRef.value.removeEventListener('dragover', dragover)
containerRef.value.removeEventListener('dragleave', dragleave)
containerRef.value.removeEventListener('drop', drop)
}
return {
dragstart,
dragend,
}
}
export default useDragger
// editor.jsx
const containerRef = ref(null)
const { dragstart, dragend } = useDragger(containerRef, data)
// editor-block.jsx
onMounted(() => {
if (props.block.alignCenter) {
const { offsetWidth, offsetHeight } = blockRef.value
props.block.top = props.block.top - offsetHeight / 2
props.block.left = props.block.left - offsetWidth / 2
props.block.alignCenter = false
}
})
containerRef
是我们的渲染区域的dom节点,用来监听darg事件,data
是渲染的数据指的就是我们的data.json
,currentComponent
左侧物料区拖拽的物料,我监听左侧物料区的dragstart
事件,保留正在拖拽的物料组件
渲染区的拖拽功能
获取焦点hook
// useFoucs.js
import { computed } from 'vue'
const useFocus = (data, callback) => {
const clearFoucsData = () => {
data.value.blocks.forEach((block) => {
block.focus = false
})
}
const mousedown = (e, block) => {
e.preventDefault()
e.stopPropagation()
if (e.shiftKey) {
if(foucsData.value.foucs.length <= 1) {
block.focus = true
} else {
block.focus = !block.focus
}
} else {
if (!block.focus) {
clearFoucsData()
block.focus = true
}
}
callback(e)
}
const foucsData = computed(() => {
const foucs = []
const unfoucs = []
data.value.blocks.forEach((block) => {
block.focus ? foucs.push(block) : unfoucs.push(block)
})
return {
foucs,
unfoucs,
}
})
const containMousedown = () => {
clearFoucsData()
}
return {
mousedown,
containMousedown,
foucsData,
}
}
export default useFocus
监听mousedown
事件拿到点击的block
数据,同时将block.focus至为true
,同时计算一个foucsData
数据,方便后续批量移动选中的block
。callback
这个参数很重要,当我们点击的时候,会马上拖拽,所以需要有个立即执行的函数去监听鼠标的移动并修改组件位置。
渲染区组件拖拽
// useMove.js
const useMove = (foucsData) => {
let dragSate = {
startX: 0,
startY: 0,
}
const mousemove = (e) => {
let moveX = e.clientX - dragSate.startX
let moveY = e.clientY - dragSate.startY
foucsData.value.foucs.forEach((block, index) => {
block.top = dragSate.statePos[index].top + moveY
block.left = dragSate.statePos[index].left + moveX
})
}
const mouseup = () => {
document.removeEventListener('mousemove', mousemove)
document.removeEventListener('mouseup', mouseup)
markLine.x = null
markLine.y = null
}
const mouseEvent = (e) => {
dragSate = {
startX: e.clientX,
startY: e.clientY,
statePos: foucsData.value.foucs.map(({ top, left }) => ({ top, left })),
}
document.addEventListener('mousemove', mousemove)
document.addEventListener('mouseup', mouseup)
}
return {
mouseEvent,
}
}
export default useMove
我们在上面说的回调中监听页面的mousemove
用于计算鼠标的偏移量,同时记录鼠标点击的位置点dragSate
,同时需要获取foucsData
中所有被选择的block
的初始位置,在move
修改所有的block的位置即刻达到批量移动的目的。
渲染区辅助线功能
如上所见我们需要找到最后一个被选中的组件,所以在
useFoucs
记录一下lastSelectBlock
,方便计算和没有选中的组件的位置关系。这里来到了我们最难的一部分了,需要计算辅助线的位置关系,这里B
指的是我们的lastSelectBlock
最后选择的组件,A
指的是我们未选中的组件。那我B
和A
的关系一共10
种,分别是:
顶对顶 顶对底 中对中 底对顶 底对底 左对左 左对右 中对中 右对左 右对右 下面是这十种对应关系图
// useFoucs.js
// +以下代码
const mouseEvent = (e) => {
const { width: BWidth, height: BHeight } = lastSelectBlock.value
dragSate = {
startX: e.clientX,
startY: e.clientY,
startTop: lastSelectBlock.value.top,
startLeft: lastSelectBlock.value.left,
statePos: foucsData.value.foucs.map(({ top, left }) => ({ top, left })),
lines: (() => {
const { unfoucs } = foucsData.value
let lines = { x: [], y: [] }
unfoucs.forEach((block) => {
const { top: ATop, left: ALeft, width: AWidth, height: AHeight } = block
lines.y.push({ shotTop: ATop, top: ATop }) // 顶对顶
lines.y.push({ shotTop: ATop, top: ATop - BHeight }) // 顶对底
lines.y.push({ shotTop: ATop + AHeight / 2, top: ATop + AHeight / 2 - BHeight / 2 }) // 中对中
lines.y.push({ shotTop: ATop + AHeight, top: ATop + AHeight }) // 底对顶
lines.y.push({ shotTop: ATop + AHeight, top: ATop + AHeight - BHeight }) // 底对底
lines.x.push({ shotLeft: ALeft, left: ALeft })
lines.x.push({ shotLeft: ALeft + AWidth, left: ALeft + AWidth })
lines.x.push({ shotLeft: ALeft + AWidth / 2, left: ALeft + AWidth / 2 - BWidth / 2 })
lines.x.push({ shotLeft: ALeft + AWidth, left: ALeft + AWidth - BWidth })
lines.x.push({ shotLeft: ALeft, left: ALeft - BWidth })
})
return lines
})(),
}
document.addEventListener('mousemove', mousemove)
document.addEventListener('mouseup', mouseup)
}
const markLine = reactive({
x: 0,
y: 0,
})
const mousemove = (e) => {
let moveX = e.clientX - dragSate.startX
let moveY = e.clientY - dragSate.startY
// 计算当前元素最新的left和top值, 去生成的线里面找, 当小于5的时候,显示线
const left = e.clientX - dragSate.startX + dragSate.startLeft
const top = e.clientY - dragSate.startY + dragSate.startTop
let y = null
let x = null
// 每次移动都去lines中找线
for (let i = 0; i < dragSate.lines.y.length; i++) {
const { top: t, shotTop } = dragSate.lines.y[i]
if (Math.abs(t - top) < 5) {
y = shotTop
break
}
}
for (let j = 0; j < dragSate.lines.y.length; j++) {
const { left: l, shotLeft } = dragSate.lines.x[j]
if (Math.abs(l - left) < 5) {
x = shotLeft
break
}
}
markLine.x = x
markLine.y = y
foucsData.value.foucs.forEach((block, index) => {
block.top = dragSate.statePos[index].top + moveY
block.left = dragSate.statePos[index].left + moveX
})
}
渲染区回退和撤销功能
// useCommand.js
/* eslint-disable no-debugger */
import { emitter } from '../common/util'
import { onUnmounted } from 'vue'
import deepcopy from 'deepcopy'
const useCommand = (data) => {
const state = {
current: -1, // 当前前进后退的指针
queue: [], // 记录操作的命令
commands: {}, // 保存命令和执行功能的映射关系
commandArr: [], // 存放所有的命令
distroyArr: [], // 存放销毁的命令
}
// 按钮区功能
const buttons = [
{
label: '撤回',
icon: 'icon-huitui',
handler: () => {
state.commands.undo()
},
},
{
label: '重做',
icon: 'icon-zhongzuo',
handler: () => {
state.commands.redo()
},
},
]
const registry = (command) => {
state.commandArr.push(command)
state.commands[command.name] = () => {
const { redo, undo } = command.execute()
if (command.pushQuene) {
let { current, queue } = state
// 这里很重要,在撤销的中间,可能有很多步操作,因为操作只修改current的值,所以在每次拖拽的时候要计算正确的queue值。
if (queue.length > 0) {
state.queue = queue.slice(0, current + 1)
}
state.queue.push({ redo, undo })
state.current = current + 1
} else {
redo && redo()
undo && undo()
}
}
}
registry({
name: 'redo',
execute() {
return {
redo: () => {
const item = state.queue[state.current + 1]
if (item && item.redo) {
item.redo()
state.current + 1
}
},
}
},
})
registry({
name: 'undo',
execute() {
return {
undo: () => {
if (state.current >= 0) {
const item = state.queue[state.current]
if (item && item.undo) {
item.undo()
state.current--
}
}
},
}
},
})
registry({
name: 'drag',
pushQuene: true,
init() {
this.before = null
const start = () => {
this.before = deepcopy(data.value.blocks)
}
const end = () => {
state.commands.drag()
}
emitter.on('start', start)
emitter.on('end', end)
return () => {
emitter.off('start', start)
emitter.off('end', end)
}
},
execute() {
const before = this.before
const after = data.value.blocks
return {
redo() {
data.value = { ...data.value, blocks: after }
},
undo() {
data.value = { ...data.value, blocks: before }
},
}
},
})
// 初始化所有的init
state.commandArr.forEach((command) => command.init && state.distroyArr.push(command.init()))
onUnmounted(() => {
state.distroyArr.forEach((fn) => fn && fn())
})
return {
buttons,
state,
}
}
export default useCommand
这里的逻辑有点复杂,且听我慢慢道来。首先state
里面保存的是
current: -1, // 当前前进后退的指针
queue: [], // 记录操作的命令
commands: {}, // 保存命令和执行功能的映射关系
commandArr: [], // 存放所有的命令
distroyArr: [], // 存放销毁的命令queue
保存每次操作的动作指令,举个例子,拖拽的时候我们在execute
返回俩个函数redo回退
,undo撤销
这俩函数的作用就是修改data的blocks
数据,让页面会退和撤销的。queue[{redo:fn,undo:fn}]
。commands用来将我们注册的命令制作成一个map,我们每次调用state.commands[name]
,才能调用真正的execute
函数。commandArr
用来存放所有注册的命令,用来初始化命令。distroyArr
用来注销命令的绑定。我们来看具体的执行流程:
那么撤销按钮同理。执行
state.commands.redo
方法即可。这里我们就不一一介绍导入和导出的功能,比较简单,就是展示data
数据,应用data
数据的过程。这里还有一个问题需要解决,就是拖动也是要能会退和撤销的。 我们只需要在mounsemove
和mouseup
的时候去emit('start')和emit('end')
即可。
//useMove.js
在mousemove中增加
if (!dragSate.dragging) {
dragSate.dragging = true
emitter.emit('start')
}
在mouseup中增加
if (dragSate.dragging) {
emitter.emit('end')
}
编辑区功能
编辑区功能这里时间有限就介绍一下文本组件的编辑功能。其他组件的编辑功能大概相似,最主要的就是数据的双向绑定。所以实现的思路是一样的,就不一一列举。
// editor-operator.jsx
/* eslint-disable no-unused-vars */
/* eslint-disable no-debugger */
import { defineComponent, inject, watch, reactive } from 'vue'
import {
ElForm,
ElFormItem,
ElButton,
ElInputNumber,
ElColorPicker,
ElSelect,
ElOption,
ElInput,
} from 'element-plus'
import deepcopy from 'deepcopy'
export default defineComponent({
props: {
block: { type: Object }, // 用户最后选中的元素
data: { type: Object }, // 当前所有的数据
updateContainer: { type: Function },
updateBlock: { type: Function },
},
setup(props) {
console.log(props, '----props')
const config = inject('config') // 组件的配置信息
const state = reactive({
editData: {},
})
const reset = () => {
console.log('props.block: ', props.block)
if (!props.block) {
// 说明要绑定的是容器的宽度和高度
state.editData = deepcopy(props.data.container)
} else {
state.editData = deepcopy(props.block)
}
}
const apply = () => {
if (!props.block) {
// 更改组件容器的大小
props.updateContainer({ ...props.data, container: state.editData })
} else {
// 更改组件的配置
props.updateBlock(state.editData, props.block)
}
}
watch(() => props.block, reset, { immediate: true })
return () => {
let content = []
if (!props.block) {
content.push(
<div>
<ElFormItem label="容器宽度">
<ElInputNumber v-model={state.editData.width}></ElInputNumber>
</ElFormItem>
<ElFormItem label="容器高度">
<ElInputNumber v-model={state.editData.height}></ElInputNumber>
</ElFormItem>
</div>
)
} else {
let component = config.componentMap[props.block.key]
console.log('component: ', component)
console.log('state: ', state)
if (component && component.props) {
// {text:{type:'xxx'},size:{},color:{}}
// {text:xxx,size:13px,color:#fff}
content.push(
Object.entries(component.props).map(([propName, propConfig]) => {
return (
<ElFormItem label={propConfig.label}>
{{
input: () => <ElInput v-model={state.editData.props[propName]}></ElInput>,
color: () => (
<ElColorPicker v-model={state.editData.props[propName]}></ElColorPicker>
),
select: () => (
<ElSelect v-model={state.editData.props[propName]}>
{propConfig.options.map((opt) => {
return <ElOption label={opt.label} value={opt.value}></ElOption>
})}
</ElSelect>
),
table: () => (
<TableEditor
propConfig={propConfig}
v-model={state.editData.props[propName]}
></TableEditor>
),
}[propConfig.type]()}
</ElFormItem>
)
})
)
}
if (component && component.model) {
// default 标签名
content.push(
Object.entries(component.model).map(([modelName, label]) => {
return (
<ElFormItem label={label}>
{/* model => {default:"username"} */}
<ElInput v-model={state.editData.model[modelName]}></ElInput>
</ElFormItem>
)
})
)
}
}
return (
<ElForm labelPosition="top" style="padding:30px">
{content}
<ElFormItem>
<ElButton type="primary" onClick={() => apply()}>
应用
</ElButton>
<ElButton onClick={reset}>重置</ElButton>
</ElFormItem>
</ElForm>
)
}
},
})
// block-config.js增加
registe.register({
key: 'text',
label: '文本',
preview: () => '预览文本',
render: (config) => {
const { props } = config
return (
<span style={{ color: props.color, fontSize: props.size }}>{props.text || '渲染文本'}</span>
)
},
props: {
text: createInputProp('文本内容'),
color: createColorProp('字体颜色'),
size: createSelectProp('字体大小', [
{ label: '14px', value: '14px' },
{ label: '20px', value: '20px' },
{ label: '24px', value: '24px' },
]),
},
})
// useCommand.js 增加
// 带有历史记录常用的模式
registry({
name: 'updateContainer', // 更新整个容器
pushQuene: true,
execute(newValue) {
let state = {
before: data.value, // 当前的值
after: newValue, // 新值
}
return {
redo: () => {
data.value = state.after
},
undo: () => {
data.value = state.before
},
}
},
})
registry({
name: 'updateBlock', // 更新某个组件
pushQuene: true,
execute(newBlock, oldBlock) {
let state = {
before: data.value.blocks,
after: (() => {
let blocks = [...data.value.blocks] // 拷贝一份用于新的block
const index = data.value.blocks.indexOf(oldBlock) // 找老的 需要通过老的查找
if (index > -1) {
blocks.splice(index, 1, newBlock)
}
return blocks
})(),
}
return {
redo: () => {
data.value = { ...data.value, blocks: state.after }
},
undo: () => {
data.value = { ...data.value, blocks: state.before }
},
}
},
})
// data.json 增加
{
"top": 200,
"left": 200,
"zIndex": 1,
"key": "text",
"props": {
"text": "文本文字",
"color": "red",
"size": "14px"
}
},
// editor-block.jsx 增加
const componentRender = component.render({
size: props.block.hasResize ? { width: props.block.width, height: props.block.height } : {},
props: props.block.props,
// model: props.block.model => {default:'username'} => {modelValue: FormData.username,"onUpdate:modelValue":v=> FormData.username = v}
model: Object.keys(component.model || {}).reduce((prev, modelName) => {
let propName = props.block.model[modelName] // 'username'
prev[modelName] = {
modelValue: props.formData[propName], // zfjg
'onUpdate:modelValue': (v) => (props.formData[propName] = v),
}
return prev
}, {}),
})
整体的流程逻辑是,有lastSelectBlock = props.block
那么我们就循环渲染component = config.componentMap[props.block.key]
component对应的就是我们在block-config中注册的物料信息。对应的文本就是:
我们根据不同的
props[key]
值渲染不同的组件,同时将我们的值也绑定到对应的key
值上,然后我们在block
的渲染的时候,将我们计算好的props
回传给render
函数(这里的render值的是我们在block-config中定义的render)
,这样我们就能在render中拿到props数据
,进行值得修改和应用。
好了写的差不多了,大致介绍了所有重点的内容,打了这么多字,就点个赞再走吧,亲!! 如果你需要上面的源码,请加我群,如果码过期了,请加我微信laoguo4578963。群不做任何推广和广告,你也可以加了群拿到源码立马退出。我就不一一回复了,各位亲!😊
源码奉上,掘金不让贴码github.com/CookGuo/vue…