• Vue + element从零打造一个H5页面可视化编辑器——pl-drag-template


    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: {
         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,
               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,
               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
  • 相关阅读:
    路由的使用
    组件之间的参数传递
    vue组件的全局注册和局部注册
    git版本回退(回退至上个版本,回退至指定版本) git放弃本地所有未提交的修改
    vue工程中的文件
    新建vue项目(webpack-simple)
    NPM install -save 和 -save-dev 傻傻分不清
    动态增加表单vue element ui
    JAVA声明一个对象数组
    调用测试用
  • 原文地址:https://www.cnblogs.com/plBlog/p/12567533.html
Copyright © 2020-2023  润新知