带你从0构建前端低代码平台

5,827 阅读10分钟

背景

运营:亲,我想要展示用户的请求信息,能不能搞一下嘛~😊 image.png
:哟?你tm说话就说话,别靠那么近,我tm不喜欢男的,你找产品/后端对一下拉个会呀。(yue!!!)
运营:亲,不要嘛,来嘛,找产品又要多好几天,你说是不是,求求你了嘛。
image.png你赶快滚,一会小心我打的你亲妈都不认识。需求你整理一下,我马上给你搞完。行吗大姐。
:亲,后端大哥哥,这运营新加的需求,你搞个接口呗!
后端大佬:滚!
:播放运营录音(小哥哥,加个页面嘛~)
后端大佬: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的一一对应关系找到我们注册的组物料建

物料区的拖拽功能

ydk7k-trb3h.gif

// 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.jsoncurrentComponent左侧物料区拖拽的物料,我监听左侧物料区的dragstart事件,保留正在拖拽的物料组件

渲染区的拖拽功能

获取焦点hook

wtq9t-q097i.gif

// 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数据,方便后续批量移动选中的blockcallback这个参数很重要,当我们点击的时候,会马上拖拽,所以需要有个立即执行的函数去监听鼠标的移动并修改组件位置。

渲染区组件拖拽

mdxvx-tmaln.gif

// 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的位置即刻达到批量移动的目的。

渲染区辅助线功能

p6iub-jzbig.gif 如上所见我们需要找到最后一个被选中的组件,所以在useFoucs记录一下lastSelectBlock,方便计算和没有选中的组件的位置关系。这里来到了我们最难的一部分了,需要计算辅助线的位置关系,这里B指的是我们的lastSelectBlock最后选择的组件,A指的是我们未选中的组件。那我BA的关系一共10种,分别是:

顶对顶 顶对底 中对中 底对顶 底对底 左对左 左对右 中对中 右对左 右对右 下面是这十种对应关系图 WechatIMG565.jpeg

// 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
    })
  }

渲染区回退和撤销功能

image.png

// 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用来注销命令的绑定。我们来看具体的执行流程:

image.png 那么撤销按钮同理。执行state.commands.redo方法即可。这里我们就不一一介绍导入和导出的功能,比较简单,就是展示data数据,应用data数据的过程。这里还有一个问题需要解决,就是拖动也是要能会退和撤销的。 我们只需要在mounsemovemouseup的时候去emit('start')和emit('end')即可。

image.png

//useMove.js 
在mousemove中增加
if (!dragSate.dragging) {
  dragSate.dragging = true
  emitter.emit('start')
}
在mouseup中增加
if (dragSate.dragging) {
  emitter.emit('end')
}

编辑区功能

编辑区功能这里时间有限就介绍一下文本组件的编辑功能。其他组件的编辑功能大概相似,最主要的就是数据的双向绑定。所以实现的思路是一样的,就不一一列举。

xdv7b-2sz5z.gif

// 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中注册的物料信息。对应的文本就是:

image.png 我们根据不同的props[key]值渲染不同的组件,同时将我们的值也绑定到对应的key值上,然后我们在block的渲染的时候,将我们计算好的props回传给render函数(这里的render值的是我们在block-config中定义的render),这样我们就能在render中拿到props数据,进行值得修改和应用。

image.png

image.png

好了写的差不多了,大致介绍了所有重点的内容,打了这么多字,就点个赞再走吧,亲!! 如果你需要上面的源码,请加我群,如果码过期了,请加我微信laoguo4578963。群不做任何推广和广告,你也可以加了群拿到源码立马退出。我就不一一回复了,各位亲!😊

源码奉上,掘金不让贴码github.com/CookGuo/vue…