pl-drag-template
Github地址:https://github.com/livelyPeng/pl-drag-template
前言
想必你一定使用過易企秀或百度H5等微場景生成工具制作過炫酷的h5頁面,除了感嘆其神奇之處有沒有想過其實現方式呢?本文從零開始實現一個H5編輯器項目完整設計思路和主要實現步驟,並開源前后端代碼。有需要的小伙伴可以按照該教程從零實現自己的H5編輯器。(實現起來並不復雜,該教程只是提供思路,並非最佳實踐)
一個h5可視化編輯器種子, 高仿凡科建站模板。
拖動左邊組件到畫板區域釋放即可,或者點擊左邊區域的組件。
注意: 最好使用谷歌打開,點擊保存按鈕就是一串json數據,你可以吧這個數據拿到其他手機平台進行渲染啦。有問題就加群 里面代碼注釋齊全,誰都看懂的哦
在這個模板的基礎上,你就可以實現類似凡科的模板(當然你還可以實現其他的類似模板)。如下圖就是我們產品的模樣
項目目錄
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