浅谈前端可视化编辑器的实现

背景

前端可视化编辑器在现在大公司中都有各样的实现方式,不同的业务会赋予不一样的功能,可以参考下面这个文章:https://github.com/taowen/awesome-lowcode,里面介绍了国内很多厂商低代码平台的实现方式。
行业里有许多利用 AI 去切割页面、生产页面的案例,但在短时间要实现产出,需要消耗大量的人力和精力,不建议在一般团队去做这样的事情。

目的

设计这样一款可视化编辑器,需要达成以下目的

  • 可视化编辑,可以在画布拖拽元素

  • 丰富的样式配置

  • 将常用业务抽离成组件,并可以配置组件的参数

设计思路

将这个项目分为三个部分:

  • 编辑器
    提供用户管理项目、拖拽元素、配置参数、预览发布等能力

  • 组件库
    抽离业务逻辑,将其封装成组件库管理维护

  • 服务端
    存储管理项目,最重要的是将编辑器生产出来的数据进行解析生成页面

技术栈

  1. 由于团队里对vue比较熟悉,前端的编辑器和组件库都使用 vue 作为开发框架

  2. 服务端使用 egg.js,考虑到 egg 较为成熟,不需要自己再从0搭起。当然,也可以选择其他框架。

技术可以根据个人或者团队偏好自己决定。

设计思路

image

一、JSON Schema

整个项目除了设计成三个核心工程之外,还有一段JSON Schema,这段JSON在整个项目中起到至关重要的部分,它是构成页面的基石。JSON Schema 是由编辑器生产,并且每个生产出的JSON Scheme的格式必须保持一致,这样,服务端渲染的时候才可以对项目的JSOn进行解析,并且最终渲染出页面。
将 JSON 划分为三个层级

1. 项目级

项目级的 JSON 主要是介绍项目的基本信息

{  id: '', // 项目唯一标志  name: '', // 项目名称  label: [], // 项目标签  description: '', // 项目描述  author: '', // 作者  pages: [] // 页面}

2. 页面级

页面级的 JSON 可以理解为我们的一个 html 页面,里面包括页面脚本,页面样式、元素等

{  id: '', // 页面唯一标志  type: '', // 页面类型  route: '', // 页面路由  title: '', // 页面标题  style: { // 页面根元素的样式    width: '',    height: '',    ......  },  elements: [], // 组件  plugins: [], // 页面插件服务}

3. 组件级

组件是组成页面的核心部分,可以理解为页面都是由一个个具有特定功能的积木堆积而成。

{  id: '', // 组件唯一标志  elementName: '', // 组件名  style: { // 组件通用样式    position: '',    width: '',    height: '',    border: ''  },  props: {}, // 组件属性参数  children: [], // 子组件  ......}

其中值得一提的是,props 可以理解 vue 里的 props,通过给组件传入 props,组件可以根据参数给于相对应的表现,相信学习过 vue 的组件相关知识的人一定理解。

最后一个完整的项目的 JSON 长这样

{  id: '_clv_fPQu',  name: 'FAB页',  label: ['fab', '游戏'],  description: 'Feature、Advantage和Benefit,按照这样的顺序来介绍,就是说服性演讲的结构,它达到的效果就是让客户相信你的是最好的',  author: 'lujintao',  pages: [    {      id: '_Tmy_CdpY',      type: 'pc',      route: 'fab',      title: '游戏页',      style: {        width: '750px',        height: '1334px',        position: 'relative',        margin: '0 auto',        padding: '',        overflow: '',        backgroundColor: '',        backgroundImage: '',        backgroundSize: '',        backgroundRepeat: '',        backgroundPosition: ''      },      elements: [        {          id: '_aIv_SesL',          elementName: 'aicc-image',          style: {            position: 'absolute',            width: '100px',            height: '100px',            border: '1px solid #eeeeee'          },          props: {            src: '***/test1.png'          },          children: [],        }      ],      plugins: [        {          name: 'mobile',          state: true        },        {          name: 'weixinShare',          state: true,          data: {            title: '微信分享的标题',            description: '微信分享的文案',            img: '***/wxshare.png'          }        }      ],    }  ]}

二、组件库

作为页面的积木,组件是十分重要的部分。组件除了需要有像文本组件、图片组件、视频组件等一些基础的功能,还需要一些贴近业务的组件。比如在官网制作中经常可以看到的轮播图组件,亦或者导航栏组件等等,这些组件都需要结合业务进行提炼出通用能力,才能方便用户快速搭建一个完整页面的。
搭建组件库需要开发两份代码,一份是组件本身,一份是用来配置组件props的配置面板。

1. 组件

组件的作用有两个,一个是提供给编辑器,在画板上能够展示组件,另外一个作用则是将组件打包后提供给服务端作为脚本插入。
比如写一个视频组件,代码很简单,定义好 props 就可以了

<template>    <video :src="src" :autoplay="autoplay" /></template><script>export default {  name: 'aicc-video',  props: {    src: {      type: String,      default: ''    },    autoplay: {      type: String,      default: ''    }  }}</script>
  • 在编辑器里的引入方式
import AICCVideo from '@/components/AICCVideo'
  • 在服务端的引入方式,在浏览器环境下,组件是已经被打包好作为script脚本插入。
<script src="https:**/**/aiccvideo.min.js">

2. 组件配置面板

跟组件需要应用到服务端不同,组件配置面板所要做的事情就是生产出自定义数据(JSON Scheme),因此只需要提供给编辑器注册。
还是拿上面的视频组件举例

<template>  <div class="props-video">    <input v-model="propsValue.src" />    <input v-model="propsValue.autoplay" />  </div></template><script>export default {  name: 'props-video',  props: {    value: { // 配置面板在编辑器里挂载的时候是通过v-model="props"传入的      type: Object,      default: () => {}    }  },  data() {    propsValue: {}  },  watch: {    value(val) { this.propsValue = val },    // 配置面板更新的时候,子传父    propsValue(val) { this.$emit('input', val) }  }}</script>

组件配置面板在编辑器的引入方式和组件的引入一致,只需要 require 进来注册挂载到 component 组件上就好了,同时 v-model 绑定当前组件的 props 数据。

三、编辑器

编辑器作为用户使用的唯一窗口,在几个板块里显得重中之重。我们已经知道,整个项目的基石是第一点所提到的 JSON Schema。编辑器也不例外,编辑器本质上做的事情就是修改/添加/删除这段 JSON 数据的各种参数。

1. 如何维护数据

在编辑器中,只需要维护一份 JSON 数据,所以考虑使用 vuex 去管理项目数据。根据 vuex 官方描述,十分符合编辑器这种中大型的项目

Vuex 是一个专为 Vue.js 应用程序开发的状态管理模式。它采用集中式存储管理应用的所有组件的状态,并以相应的规则保证状态以一种可预测的方式发生变化

实现过程

  1. 写一个 name 为 editor 的 store

    const state = {  projectData: {}, // 工程项目数据  activePageUUID: '', // 当前正在编辑的页面  activeElementUUID: '', //当前被选中的组件UUID}
    
    // 初始化项目export function initProject({ commit, state }, data) { let projectData = data if (!data) projectData = initProject() // 空项目时生成默认 JSON Data dispatch('setActivePageUUID', projectData.pages[0].uuid)}// 设置当前被选中的页面的UUIDexport function setActivePageUUID({ commit }, uuid) {  state.activePageUUID = uuid}// 设置当前被选中的元素的UUIDexport function setActiveElementUUID({ commit }, uuid) {  state.activeElementUUID = uuid}
    
    // 当前选中页面的 JSON 数据export function activePage(state) {  const idx = state.projectData.pages.findIndex(p => { return state.activePageUUID === p.uuid })  return state.projectData.pages[idx]}// 当前选中组件的 JSON 数据export function activeElement(state) {  const pIdx = state.projectData.pages.findIndex(p => { return state.activePageUUID === p.uuid })  const activePage = state.projectData.pages[pIdx]  const eIdx = activePage.elements.findIndex(e => { return state.activeElementUUID === e.uuid })}
    
  • getter.js

  • action.js

  • state.js: 存储要维护的数据

  1. 编辑器里各页面绑定数据,通过 mapGetters 获取当前页面,当前元素,以及项目数据
<template>  <div>    <component :is="activeElement.elementName" v-model="activeElement.props" />  </div></template> <script>import { mapGetters, mapState } from 'vuex'export default {  computed: {    ...mapGetters({      'activeElement': 'editor/activeElement'    }),    ...mapState({      pages: state => state.editor.projectData.pages,      activePageUUID: state => state.editor.activePageUUID,      activeElementUUID: state => state.editor.activeElementUUID,    })  }}</script>

2. 如何实现画布拖拽

对于可视化编辑器来说,用户希望可以能够拖拽画布中的元素进行更改位置。实现思路十分简单

  • 选中元素,监听 mousedown 事件

  • 获取当前按下元素的 offsetTop 和 offsetLeft

  • 获取当前按下鼠标的坐标位置(e.clientX, e.cliengY)

  • 监听 mousemove 事件,获取鼠标移动的长度,计算出距离

  • 元素的新坐标 = 距离 + 按下时元素的 offset 信息

代码如下

function mousedown(e) {  let newTop = null  let newLeft = null  // 记录按下时当前元素位置  const cTop = e.currentTarget.offsetTop  const cLeft = e.currentTarget.offsetLeft  // 记录按下时当前鼠标位置  const mouseX = e.clientX  const mouseY = e.clientY    const move = mEvent => {    // 只是单纯移动位置,不需要传递事件给后代    mEvent.stopPropagation()    mEvent.preventDefault()    const cX = mEvent.clientX    const cY = mEvent.clientY    // 移动的位置    const distanceX = cX - mouseX    const distanceY = cY - mouseY    // 新坐标    newTop = distanceX + cTop    newLeft = distanceY + cLeft  }  const up = () => {    document.removeEventListener('mousemove', move, true)    document.removeEventListener('mouseup', up, true)  }  document.addEventListener('mousemove', move, true)  document.addEventListener('mouseup', up, true)}

这里其实有优化的空间,在元素进行拖拽的时候,如果实时去改变top,left时,会引起重排。解决办法也很简单,在 mousemove 的过程中我们使用 transform 去实时显示当前组件的位置,等 mouseup 释放鼠标的时候,我们再把真实的坐标位置赋值给组件的 Style 里。

四、服务端渲染

我们在前面编辑器里生产出来的项目 JSON 数据,最终需要让服务端这边进行 DSL 解析。由于我们采纳的技术栈是 Vue,想要生成一个结构化的页面也十分容易,使用 vue 的 render 函数。
具体可以参考官方文档:渲染函数 & jSX
拿第一部分 JSON Schema 的示例,渲染步骤大致如下:

  • 拿到页面的 page 信息生成页面的 title/seo 等页面配置信息,根据路由生成对应的 ${name}.html 文件

  • 挂载打包好的组件库 <script src="*/*/aiccvideo.cdn.js">

  • 由于使用的render函数,不需要编译器,只需要挂载 vue.runtime.js 脚本即可

  • 使用模板引擎进行字符串替换,替换的信息有页面信息、render 函数里的组件数组等

  • 遍历 elements,用 vue 的 render 函数 createElement('组件名', { props: { ...生产出来的element JSON数据 }})

  • 解析 style 的 JSON 数据,生成 选择器{ ${key}: ${value} }的样式表

  • 写入文件fs.writeFileSync(文件路径,htmlString)

结语

文章到这就差不多结束了,因为只是简单说明一下整个项目怎么搭建,在具体细节里没有太详尽描述。要写下来,每一块都可以作为一个单元去写。比如

  • 组件库如何打包

  • 项目的管理维护,怎么使用 lerna 管理项目

  • 如何实现拖拽元素实时改变元素大小

  • 如何保证提交数据的格式符合要求

  • 如何实现插件化服务,如微信分享等

……
要上线一个完整能够生产的产品,细节的地方还有很多技术点可以讨论,在这里仅仅只是谈及整个项目技术的组成部分和基本实现原理。
欢迎有更好的想法~

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 218,122评论 6 505
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,070评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,491评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,636评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,676评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,541评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,292评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,211评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,655评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,846评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,965评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,684评论 5 347
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,295评论 3 329
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,894评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,012评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,126评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,914评论 2 355

推荐阅读更多精彩内容