plBlog

pl-drag-template

Github地址:https://github.com/livelyPeng/pl-drag-template

前言

想必你一定使用过易企秀或百度H5等微场景生成工具制作过炫酷的h5页面,除了感叹其神奇之处有没有想过其实现方式呢?本文从零开始实现一个H5编辑器项目完整设计思路和主要实现步骤,并开源前后端代码。有需要的小伙伴可以按照该教程从零实现自己的H5编辑器。(实现起来并不复杂,该教程只是提供思路,并非最佳实践)

一个h5可视化编辑器种子, 高仿凡科建站模板。

点击查看pl-drag-template在线demo

大概图形: image

拖动左边组件到画板区域释放即可,或者点击左边区域的组件。

注意: 最好使用谷歌打开,点击保存按钮就是一串json数据,你可以吧这个数据拿到其他手机平台进行渲染啦。有问题就加群 里面代码注释齐全,谁都看懂的哦

在这个模板的基础上,你就可以实现类似凡科的模板(当然你还可以实现其他的类似模板)。如下图就是我们产品的模样

image

项目目录

 src {
     apiUrl: 请路径存放
     assets: 项目资产存在(图片等)
     components: 公用组件存放
     module: 模块位置  {
         画板模块的配置如下: {
            components: 当前模块的私有组件 {
              attributeConfig: 右边属性配置组件
              ... 其他的都是画板页面的组件
            }
            pluginLibrary: 画板的插件/模块/组件(非常重要)
            routers: 当前模块的路由表
            style: 当前画板的样式
            utils: 公用js存放库
            vuex: 当前模块的状态存储
            viewPage: 当前模块的页面
            index.js: 导出当前模块
         }
     }
     vuex: 整个项目的状态存储汇集地方
     themes: 整个项目的公用样式表集中地方
     utils: 整个项目的工具文件夹
  }

 

技术栈

前端:
vue: 模块化开发少不了angular,react,vue三选一,这里选择了vue。
vuex: 状态管理
less: css预编译器。
element-ui:不造轮子,有现成的优秀的vue组件库当然要用起来。没有的自己再封装一些就可以了。
loadsh:工具类

工程搭建

基于vue-cli2环境搭建

  • 如何规划好我们项目的目录结构?首先我们需要有一个目录作为前端项目,一个目录作为后端项目。所以我们要对vue-cli 生成的项目结构做一下改造:
···
·
|-- client                // 原 src 目录,改成 client 用作前端项目目录
|-- server                // 新增 server 用于服务端项目目录
|-- engine-template        // 新增 engine-template 用于页面模板库目录
|-- docs                // 新增 docs 预留编写项目文档目录
·
···

 

  • 这样的话 我们需要再把我们webpack配置文件稍作一下调整

  • module.exports = {
      resolve: {
        extensions: [\'.ts\', \'.js\', \'.vue\', \'.json\'],
        alias: {
          // \'vue$\': \'vue/dist/vue.esm.js\',
          \'@\': utils.resolve(\'src\')
        }
      },
      externals: {
        \'vue\': \'Vue\',
        "echarts": "echarts",
        \'vue-router\': \'VueRouter\',
        \'vuex\': \'Vuex\',
        \'element-ui\': \'ELEMENT\',
        \'moment\': \'moment\'
      },
      module: {
        rules: [
          ...(config.dev.useEslint ? [createLintingRule()] : []),
          {
            test: /\.vue$/,
            loader: \'vue-loader\',
            options: {
              transformAssetUrls: {
                video: [\'src\', \'poster\'],
                source: \'src\',
                img: \'src\',
                image: \'xlink:href\'
              }
            }
          }, {
            test: /\.js$/,
            loader: \'babel-loader\',
            exclude: file => /node_modules/.test(file) && !/\.vue\.js/.test(file) && !/element-ui(\\|\/)(src|packages)/.test(file) && !/pl-table/.test(file)
          }, {
            test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
            loader: \'url-loader\',
            options: {
              limit: 10000,
              name: utils.assetsPath(\'img/[name].[hash].[ext]\')
            }
          }, {
            test: /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/,
            loader: \'url-loader\',
            options: {
              limit: 10000,
              name: utils.assetsPath(\'media/[name].[hash].[ext]\')
            }
          }, {
            test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
            loader: \'url-loader\',
            options: {
              limit: 10000,
              name: utils.assetsPath(\'fonts/[name].[hash].[ext]\')
            }
          }, {
            test: /\.less$/,
            use: [{
              loader: process.env.NODE_ENV === \'production\' ? MiniCssExtractPlugin.loader : \'vue-style-loader\'
            }, {
              loader: \'css-loader\',
              options: {
                sourceMap: cssSourceMap
              }
            }, {
              loader: \'less-loader\',
              options: {
                sourceMap: cssSourceMap
              }
            }, {
              loader: \'sass-resources-loader\',
              options: {
                resources: [
                  path.resolve(__dirname, \'../src/themes/publicStyle/common.less\')
                ]
              }
            }]
          }, {
            test: /\.css$/,
            use: [{
              loader: process.env.NODE_ENV === \'production\' ? MiniCssExtractPlugin.loader : \'vue-style-loader\',
            }, {
              loader: \'css-loader\',
              options: {
                sourceMap: cssSourceMap
              }
            }]
          }]
      },
      plugins: [
        new VueLoaderPlugin(),
        // 复制静态资源到目录中,如果有更多需要复制的资源,请在这里添加
        new CopyWebpackPlugin([{
          from: utils.resolve(\'static\'),
          to: config.build.assetsSubDirectory,
          ignore: [\'.*\']
        }])
      ]
    }

这样我们搭建起来一个简易的项目目录结构。

前端编辑器实现

编辑器的实现思路是:编辑器生成页面JSON数据,服务端负责存取JSON数据,渲染时从服务端取数据JSON交给前端模板处理。

 

数据结构(非常重要)

/*
 *   注意注意注意: pluginLibrary里面组件的name值必须写,然后必须写下面的elName组件名
 *   1. elName: \'pl-text\', // 非常重要请正确写上对应的vue组件的组件名,name值 如export default {name: \'PlButton\'} 那么elName就是pl-button
 *   2. 除了容器的对象plContainer属性,(注意:看容器的属性请看下面的容器基本结构)其他配置表属性的介绍如下
 *    title: 组件提示文字(左边组件按钮区域用到了)
 *    icon: 组件图标(左边组件按钮区域用到了,使用的是 Iconfont-阿里巴巴矢量图标库)
 *    以下全是组件本身的属性,不是左边组件按钮区域列表的属性
 *    elName: 组件名
 *    pointList: 控制组件拖动的方向(拖动的小圆点)  pointList: [\'lt\' 左上, \'rt\' 右上, \'lb\' 左下, \'rb\' 右下, \'l\' 左, \'r\' 右, \'t\' 上, \'b\' 下],
 *     // [\'lt\', \'rt\', \'lb\', \'rb\', \'l\', \'r\', \'t\', \'b\' ]
 *    value: \'\' // 输入框的值,主要用在这个画板元素上的输入框类型组件上
 *    contenteditable: 组件输入状态是否可以被拖动
 *    placeholder: 输入框类型的组件,空文本提示文字
 *    commonStyle:初始化的样式,就是css不多介绍
 *    options:{ // 组件配置项
 *        classList: [], 当前组件的类集合
          lineHeightChange: true // 表示行高需要随着拖动的高度变化(只有可以拖动的元素有效)
 *    }
 *    module: boolean 为true代表当前组件不是个画板元素,而是作为一个模块的身份。(但是它依然存放在容器中) 什么是非画板元素,就是不能再自由容器中拖动和自由组合,非画板元素是模块组件
 *    containerOptions: {} 如果我配置了module为true,代表当前是个模块,模块身份可以去配置容器对象的属性
 *    propsValue: {} // 里面包含了组件所有的data对象属性,它不需要再基本结构中配置,他会在生成组件的时候会放到该配置中来
 */
import {pageWh, defaultStyle, moduleContainer} from \'./config\'

// 容器的基本结构
export const plContainer = {
  elName: \'pl-container\',
  title: \'自由容器\',
  icon: \'iconfont iconrongqi\',
  pointList: [\'b\'], // 模块拖动的方向有哪些
  // 容器最外层盒子的样式
  containerStyle: { // 容器大盒子的样式
    marginBottom: 10
  },
  allowed: true, // 代表我当前容器是个画板,拖动画板元素可以放到容器上面
  showTitle: true, // 是否显示头部
  // 容器头部的样式
  titleStyle: {
    height: 50,
    lineHeight: 50
  },
  titleBarName: \'标题栏\',
  // 容器画板的默认样式
  commonStyle: {
    width: pageWh.width,
    height: 250,
    position: \'relative\',
    minHeight: 50, // 容器里面的画板最小高度值
    backgroundColor: \'#fff\'
  },
  childNode: [] // 容器子节点的集装箱
}

// 基础组件
const BasicComponents = [
  {
    title: \'基础组件\',
    components: [
      plContainer,
      {
        elName: \'pl-text\',
        title: \'文本\',
        icon: \'iconfont iconwenbenyu\',
        pointList: [], // 控制组件拖动的方向
        contenteditable: false,
        placeholder: \'点击输入内容\',
        commonStyle: {
          ...defaultStyle,
          padding: 8,
          fontSize: 15,
          lineHeight: 17,
          height: \'auto\',
          textAlign: \'left\',
          minWidth: 35,
          width: 160
        }
      },
      {
        elName: \'pl-button\',
        title: \'按钮\',
        icon: \'iconfont iconanniu\',
        pointList: [\'lt\', \'rt\', \'lb\', \'rb\', \'l\', \'r\', \'t\', \'b\'], // 控制组件拖动的方向
        contenteditable: false,
        options: {
          classList: [],
          lineHeightChange: true // 表示行高需要随着拖动的高度变化
        },
        commonStyle: {
          ...defaultStyle,
          fontSize: 15,
          lineHeight: 36,
          height: 36,
          textAlign: \'center\',
          minWidth: 35,
          minHeight: 36,
          width: 80
        }
      },
      {
        elName: \'cube-nav\',
        title: \'魔方导航\',
        icon: \'iconfont iconfenlei\',
        module: true,
        containerOptions: {
          ...moduleContainer,
          titleBarName: \'魔方导航模块\'
        },
        options: {
          classList: []
        }
      },
      {
        elName: \'carousel\',
        title: \'多图文轮播\',
        icon: \'iconfont iconlunbotu\',
        module: true,
        containerOptions: {
          ...moduleContainer,
          titleBarName: \'多图文轮播\'
        },
        options: {
          classList: []
        }
      }
    ]
  }
]

const components = [...BasicComponents]

// 遍历判断找出画板元素的组件
// 在拖拽元素到画板的时候,会判断当前拖动的组件是否在这里面存在,存在才可以添加组件到画板容器
// 必须是画板组件
export const drawingComponent = components.map(item => item.components.map(con => {
  if (!con.module && con.elName !== \'pl-container\') return con.elName
}))[0].filter(item => item)

export default components

 

页面整体结构

 

 

 

 

核心代码

编辑器核心代码,基于 Vue 动态组件特性实现:

 

 

 

 

// 获取需要绘画的节点数据(整个可视化编辑器的最重要的东西)
export const getNodeElement = (nodeData, type) => {
  // 如果不存在该组件就直接返回
  if (!nodeData || !componentsName.includes(camelCase(nodeData.elName).toLowerCase())) {
    Message.error({message: \'没有该模块!\', type: \'warning\', duration: 2000})
    return null
  }
  //  需要添加的节点元素对象
  let nodeElement
  // 获取当前组件的data数据(非常重要,它将是你原始组件的初始化数据,你右边的属性控制就是去更改的它)
  let props = getComponentProps(nodeData.elName)
  // 获取需要添加的节点元素的数据结构
  nodeElement = deepClone(getElementConfig({...nodeData, needProps: props}))
  // 注意注意注意: 如果我进来的不是容器,那么就需要包装一层容器,在返回节点
  // type如果存在,代表我是往容器里面加节点不需要被容器包裹,就不需要执行if语句了
  if (nodeElement.elName !== \'pl-container\' && type !== \'我是往容器里面加节点不需要被容器包裹\') {
    // 获取pl-container容器组件的data数据
    let props = getComponentProps(\'pl-container\')
    // 获取容器的基本结构
    let containerNodeData = getElementConfig({...plContainer, needProps: props})

    // 什么是非画板元素,就是不能再自由容器中拖动和自由组合,非画板元素是模块组件
    // 下面if语句是做非画板元素的关键,意思就是非画板元素,它也属于自由容器中,但是它不能拖动
    // 如果当前组件是一个模块, 就需要执行下面的语句
    if (nodeElement.module) {
      // 如果是模块,那么就去看是否改变了容器的样式,没有改变默认给个改变容器的基本值
      let cops = judgeObject(nodeElement.containerOptions) ? nodeElement.containerOptions : moduleContainer
      // 合并容器的属性(很好理解就是去覆盖掉原来容器的属性,因为原来容器的属性是为了画板而生的,但是模块本身也是被容器包裹的,所以需要去覆盖容器的配置)
      let newContainer = {...containerNodeData, ...cops}
      // 删除当前需要添加的节点,里面的配置容器对象
      delete nodeElement.containerOptions
      // 然后再把需要添加的节点放入容器中
      newContainer.childNode.push(nodeElement)
      return deepClone(newContainer)
    }

    // 把需要添加的元素放入到容器节点中
    containerNodeData.childNode.push(nodeElement)
    // 导出容器
    return deepClone(containerNodeData)
  }
  // 返回当前组件
  return nodeElement
}

组件库

编写组件,考虑的是组件库,所以我们竟可能让我们的组件支持全局引入和按需引入,如果全局引入,那么所有的组件需要要注册到Vue component 上,并导出:

/**
 * 组件库入口
 * */
// 基础组件
import plEditDiv from \'./editDiv\' // 必须放第一个位置引入 因为下面的组件有用到它
import plText from \'./text\'
import plButton from \'./Button\'
import plContainer from \'./container\'
import cubeNav from \'./cubeNav\'
import carousel from \'./carousel\'
// 所有组件列表
const components = [
  plEditDiv,
  plText,
  plButton,
  plContainer,
  cubeNav,
  carousel
]

let plRegisterComponentsObject = {}
let componentsName = []

components.forEach(item => {
  plRegisterComponentsObject[item.name] = item
  // 导出当前组件的组件名
  if (item.name && typeof item.name === \'string\') {
    componentsName.push(item.name.toLowerCase())
  }
})

// 定义 install 方法,接收 Vue 作为参数
const install = function (Vue) {
  // 判断是否安装,安装过就不继续往下执行
  if (install.installed) return
  install.installed = true
  // 遍历注册所有组件
  components.map(component => Vue.component(component.name, component))
}

export {
  componentsName,
  plEditDiv,
  cubeNav,
  plButton,
  carousel,
  plText,
  plContainer,
  plRegisterComponentsObject
}

export default {
  install
}

 

启动运行

npm run dev

分类:

技术点:

相关文章:

  • 2021-11-25
  • 2022-01-15
  • 2022-01-22
  • 2021-12-27
  • 2022-02-27
  • 2021-09-11
  • 2022-12-23
  • 2021-09-19
猜你喜欢
  • 2021-05-18
  • 2021-12-04
  • 2021-09-15
  • 2021-12-17
  • 2021-12-14
相关资源
相似解决方案