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: {
    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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM