前言
想必你一定使用過易企秀或百度H5等微場景生成工具製作過炫酷的h5頁面,除了感嘆其神奇之處有沒有想過其實現方式呢?本文從零開始實現一個H5編輯器專案完整設計思路和主要實現步驟,並開源前後端程式碼。有需要的小夥伴可以按照該教程從零實現自己的H5編輯器。(實現起來並不複雜,該教程只是提供思路,並非最佳實踐)
Github: https://github.com/huangwei9527/quark-h5
演示地址:http://47.104.247.183:4000/
演示帳號密碼均admin
編輯器預覽:
技術棧前端: vue: 模組化開發少不了angular,react,vue三選一,這裡選擇了vue。 vuex: 狀態管理 sass: css預編譯器。 element-ui:不造輪子,有現成的優秀的vue元件庫當然要用起來。沒有的自己再封裝一些就可以了。 loadsh:工具類
服務端: koa:後端語言採用nodejs,koa文件和學習資料也比較多,express原班人馬打造,這個正合適。 mongodb:一個基於分散式檔案儲存的資料庫,比較靈活。
閱讀前準備1、了解vue技術棧開發 2、了解koa 3、了解mongodb
工程搭建基於vue-cli3環境搭建
如何規劃好我們專案的目錄結構?首先我們需要有一個目錄作為前端專案,一個目錄作為後端專案。所以我們要對vue-cli 生成的專案結構做一下改造:····|-- client\t\t\t\t// 原 src 目錄,改成 client 用作前端專案目錄|-- server\t\t\t\t// 新增 server 用於服務端專案目錄|-- engine-template\t\t// 新增 engine-template 用於頁面模板庫目錄|-- docs\t\t\t\t// 新增 docs 預留編寫專案文件目錄····複製程式碼
這樣的話 我們需要再把我們webpack配置檔案稍作一下調整,首先是把原先的編譯指向src的目錄改成client,其次為了 npm run build 能正常編譯 client 我們也需要為 babel-loader 再增加一個編譯目錄: 根目錄新增vue.config.js,目的是為了改造專案入口,改為:client/main.js module.exports = { pages: { index: { entry: "client/main.js" } } } 複製程式碼 babel-loader能正常編譯 client, engine-template目錄, 在vue.config.js新增如下配置 // 擴充套件 webpack 配置 chainWebpack: config => { config.module .rule('js') .include.add(/engine-template/).end() .include.add(/client/).end() .use('babel') .loader('babel-loader') .tap(options => { // 修改它的選項... return options }) } 複製程式碼這樣我們搭建起來一個簡易的專案目錄結構。
工程目錄結構|-- client\t\t\t\t\t--------前端專案介面程式碼 |--common\t\t\t\t\t--------前端介面對應靜態資源 |--components\t\t\t\t--------元件 |--config\t\t\t\t\t--------配置檔案 |--eventBus\t\t\t\t\t--------eventBus |--filter\t\t\t\t\t--------過濾器 |--mixins\t\t\t\t\t--------混入 |--pages\t\t\t\t\t--------頁面 |--router\t\t\t\t\t--------路由配置 |--store\t\t\t\t\t--------vuex狀態管理 |--service\t\t\t\t\t--------axios封裝 |--App.vue\t\t\t\t\t--------App |--main.js\t\t\t\t\t--------入口檔案 |--permission.js\t\t\t--------許可權控制|-- server\t\t\t\t\t--------伺服器端專案程式碼 |--confog\t\t\t\t\t--------資料庫連結相關 |--middleware\t\t\t\t--------中介軟體 |--models\t\t\t\t\t--------Schema和Model |--routes\t\t\t\t\t--------路由 |--views\t\t\t\t\t--------ejs頁面模板 |--public\t\t\t\t\t--------靜態資源 |--utils\t\t\t\t\t--------工具方法 |--app.js\t\t\t\t\t--------服務端入口|-- common\t\t\t\t\t--------前後端公用程式碼模組(如加解密)|-- engine-template\t\t\t--------頁面模板引擎,使用webpack打包成js提供頁面引用|-- docs\t\t\t\t\t--------預留編寫專案文件目錄|-- config.json\t\t\t\t--------配置檔案複製程式碼
前端編輯器實現編輯器的實現思路是:編輯器生成頁面JSON資料,服務端負責存取JSON資料,渲染時從服務端取資料JSON交給前端模板處理。
資料結構確認了實現邏輯,資料結構也是非常重要的,把一個頁面定義成一個JSON資料,資料結構大致是這樣的:
頁面工程資料介面
{\ttitle: '', // 標題\tdescription: '', //描述\tcoverImage: '', // 封面\tauther: '', // 作者\tscript: '', // 頁面插入指令碼\twidth: 375, // 高\theight: 644, // 寬\tpages: [], // 多頁頁面\tshareConfig: {}, // 微信分享配置\tpageMode: 0, // 渲染模式,用於擴充套件多種模式渲染,翻頁h5/長頁/PC頁面等等}複製程式碼多頁頁面pages其中一頁資料結構:
{\tname: '',\telements: [], // 頁面元素\tcommonStyle: {\t\tbackgroundColor: '',\t\tbackgroundImage: '',\t\tbackgroundSize: 'cover'\t},\tconfig: {}}複製程式碼元素資料結構:
{\telName: '', // 元件名\tanimations: [], // 圖層的動畫,可以支援多個動畫\tcommonStyle: {}, // 公共樣式,預設樣式\tevents: [], // 事件配置資料,每個圖層可以新增多個事件\tpropsValue: {}, // 屬性引數\tvalue: '', // 繫結值\tvalueType: 'String', // 值型別\tisForm: false // 是否是表單控制元件,用於表單提交時獲取表單資料}複製程式碼編輯器整體設計一個元件選擇區,提供使用者選擇需要的元件一個編輯預覽畫板,提供使用者拖拽排序頁面預覽的功能一個元件屬性編輯,提供給使用者編輯元件內部props、公共樣式和動畫的功能 如圖:使用者在左側元件區域選擇元件新增到頁面上,編輯區域通過動態元件特性渲染出每個元素元件。
最後,點選儲存將頁面資料提交到資料庫。至於資料怎麼轉成靜態 HTML方法有很多。還有頁面資料我們全部都有,我們可以做頁面的預渲染,骨架屏,ssr,編譯時優化等等。而且我們也可以對產出的活動頁做資料分析~有很多想象的空間。
核心程式碼編輯器核心程式碼,基於 Vue 動態元件特性實現:
為大家附上 Vue 官方文件:cn.vuejs.org/v2/api/#is
畫板元素渲染編輯畫板只需要迴圈遍歷pages[i].elements陣列,將裡面的元素元件JSON資料取出,通過動態元件渲染出各個元件,支援拖拽改變位置尺寸.
元素元件管理在client目錄新建plugins來管理元件庫。也可以將該元件庫發到npm上工程中通過npm管理
元件庫編寫元件,考慮的是元件庫,所以我們竟可能讓我們的元件支援全域性引入和按需引入,如果全域性引入,那麼所有的元件需要要註冊到Vue component 上,並匯出:
client/plugins下新建index.js入口檔案
```/** * 元件庫入口 * */import Text from './text'// 所有元件列表const components = [\tText]// 定義 install 方法,接收 Vue 作為引數const install = function (Vue) {\t// 判斷是否安裝,安裝過就不繼續往下執行\tif (install.installed) return\tinstall.installed = true\t// 遍歷註冊所有元件\tcomponents.map(component => Vue.component(component.name, component))}// 檢測到 Vue 才執行,畢竟我們是基於 Vue 的if (typeof window !== 'undefined' && window.Vue) {\tinstall(window.Vue)}export default {\tinstall,\t// 所有元件,必須具有 install,才能使用 Vue.use()\tText}```複製程式碼
元件開發示例: text文字元件
client/plugins下新建text元件目錄
|-- text --------text元件 |--src --------資源 \t|--index.vue --------元件 |--index.js --------入口複製程式碼
text/index.js
// 為元件提供 install 方法,供元件對外按需引入import Component from './src/index'Component.install = Vue => {\tVue.component(Component.name, Component)}export default Component複製程式碼
text/src/index.vue
// 引入元件庫import QKUI from 'client/plugins/index'// 註冊元件庫Vue.use(QKUI)// 使用:<qk-text text="這是一段文字"></qk-text>複製程式碼
按照這個元件開發方式我們可以擴充套件任意多的元件,來豐富元件庫
需要注意的是這裡的元件最外層寬高都要求是100%
配置檔案Quark-h5編輯器左側選擇元件區域可以通過一個配置檔案定義可選元件 新建一個ele-config.js配置檔案:
export default [\t{\t\ttitle: '基礎元件',\t\tcomponents: [\t\t\t{\t\t\t\telName: 'qk-text', // 元件名,與元件庫名稱一致\t\t\t\ttitle: '文字',\t\t\t\ticon: 'iconfont iconwenben',\t\t\t\t// 給每個元件配置預設顯示樣式\t\t\t\tdefaultStyle: {\t\t\t\t\theight: 40\t\t\t\t}\t\t\t}\t\t]\t},\t{\t\ttitle: '表單元件',\t\tcomponents: []\t},\t{\t\ttitle: '功能元件',\t\tcomponents: []\t},\t{\t\ttitle: '業務元件',\t\tcomponents: []\t}]複製程式碼
公共方法中提供一個function 通過元件名和預設樣式獲取元素元件JSON,getElementConfigJson(elName, defaultStyle)方法
元素屬性編輯公共屬性樣式編輯公共樣式屬性編輯比較簡單就是對元素JSON物件commonStyles欄位進行編輯操作
props屬性編輯1.為元件的每一個prop屬性開發一個屬性編輯元件. 例如:QkText元件需要text屬性,新增一個attr-qk-text元件來操作該屬性 2.獲取元件prop物件 3.遍歷prop物件key, 通過key判斷顯示哪些屬性編輯元件
元素新增動畫實現動畫效果引入Animate.css動畫庫。元素元件動畫,可以支援多個動畫。資料存在元素JSON物件animations數組裡。
選擇面板hover預覽動畫監聽mouseover和mouseleave,當滑鼠移入時將動畫className新增入到元素上,滑鼠移出時去掉動畫lassName。這樣就實現了hover預覽動畫
編輯預覽動畫元件編輯時支援動畫預覽和單個動畫預覽。
封裝一個動畫執行方法
/** * 動畫方法, 將動畫css加入到元素上,返回promise提供執行後續操作(將動畫重置) * @param $el 當前被執行動畫的元素 * @param animationList 動畫列表 * @param isDebugger 動畫列表 * @returns {Promise<void>} */export default async function runAnimation($el, animationList = [], isDebug , callback){\tlet playFn = function (animation) {\t\treturn new Promise(resolve => {\t\t\t$el.style.animationName = animation.type\t\t\t$el.style.animationDuration = `${animation.duration}s`\t\t\t// 如果是迴圈播放就將迴圈次數置為1,這樣有效避免編輯時因為預覽迴圈播放元件播放動畫無法觸發animationend來暫停元件動畫\t\t\t$el.style.animationIterationCount = animation.infinite ? (isDebug ? 1 : 'infinite') : animation.interationCount\t\t\t$el.style.animationDelay = `${animation.delay}s`\t\t\t$el.style.animationFillMode = 'both'\t\t\tlet resolveFn = function(){\t\t\t\t$el.removeEventListener('animationend', resolveFn, false);\t\t\t\t$el.addEventListener('animationcancel', resolveFn, false);\t\t\t\tresolve()\t\t\t}\t\t\t$el.addEventListener('animationend', resolveFn, false)\t\t\t$el.addEventListener('animationcancel', resolveFn, false);\t\t})\t}\tfor(let i = 0, len = animationList.length; i < len; i++){\t\tawait playFn(animationList[i])\t}\tif(callback){\t\tcallback()\t}}複製程式碼
animationIterationCount 如果是編輯模式的化動畫只執行一次,不然無法監聽到動畫結束animationend事件
執行動畫前先將元素樣式style快取起來,當動畫執行完再將原樣式賦值給元素
let cssText = this.$el.style.cssText;runAnimations(this.$el, animations, true, () => {\tthis.$el.style.cssText = cssText})複製程式碼
元素新增事件
提供事件mixins混入到元件,每個事件方法返回promise,元素被點選時按順序執行事件方法
頁面插入js指令碼參考百度H5,將指令碼以script標籤形式嵌入。頁面載入後執行。 這裡也可以考慮mixins方式混入到頁面或者元件,可根據業務需求自行擴充套件,都是可以實現的。
redo/undo歷史操作紀錄歷史操作紀錄存在狀態機store.state.editor.historyCache陣列中。每次修改編輯操作都把整個pageDataJson欄位push到historyCache點選redo/undo時根據index獲取到pageDataJson重新渲染頁面psd設計圖匯入生成h5頁面將psd每個設計圖中的每個圖層匯出成圖片儲存到靜態資源伺服器中,
服務端安裝psd依賴
cnpm install psd --save複製程式碼
加入psd.js依賴,並且提供介面來處理資料
var PSD = require('psd');router.post('/psdPpload',async ctx=>{\tconst file = ctx.request.files.file; // 獲取上傳檔案\tlet psd = await PSD.open(file.path)\tvar timeStr = + new Date();\tlet descendantsList = psd.tree().descendants();\tdescendantsList.reverse();\tlet psdSourceList = []\tlet currentPathDir = `public/upload_static/psd_image/${timeStr}`\tfor (var i = 0; i < descendantsList.length; i++){\t\tif (descendantsList[i].isGroup()) continue;\t\tif (!descendantsList[i].visible) continue;\t\ttry{\t\t\tawait descendantsList[i].saveAsPng(path.join(ctx.state.SERVER_PATH, currentPathDir + `/${i}.png`))\t\t\tpsdSourceList.push({\t\t\t\t...descendantsList[i].export(),\t\t\t\ttype: 'picture',\t\t\t\timagesrc: ctx.state.BASE_URL + `/upload_static/psd_image/${timeStr}/${i}.png`,\t\t\t})\t\t}catch (e) {\t\t\t// 轉換不出來的圖層先忽略\t\t\tcontinue;\t\t}\t}\tctx.body = {\t\telements: psdSourceList,\t\tdocument: psd.tree().export().document\t};})複製程式碼
最後把獲取的資料轉義並返回給前端,前端獲取到資料後使用系統統一方法,遍歷新增統一圖片元件
psd原始檔大小最好不要超過30M,過大會導致瀏覽器卡頓甚至卡死儘可能合併圖層,並柵格化所有圖層較複雜的圖層樣式,如濾鏡、圖層樣式等無法讀取html2canvas生成縮圖這裡只需要注意下圖片跨域問題,官方提供html2canvas: proxy解決方案。它將圖片轉化為base64格式,結合使用設定(proxy: theProxyURL), 繪製到跨域圖片時,會去訪問theProxyURL下轉化好格式的圖片,由此解決了畫布汙染問題。 提供一個跨域介面
/** * html2canvas 跨域介面設定 */router.get('/html2canvas/corsproxy', async ctx => {\tctx.body = await request(ctx.query.url)})複製程式碼
渲染模板實現邏輯
在engine-template目錄下新建swiper-h5-engine頁面元件,這個元件接收到頁面JSON資料就可以把頁面渲染出來。跟編輯預覽畫板實現邏輯差不多。
然後使用vue-cli庫打包命令將元件打包成engine.js庫檔案。ejs模板引入該頁面元件配合json資料渲染出頁面
適配方案提供兩種方案解決螢幕適配 1、等比例縮放 在將json元素轉換為dom元素的時候,對所有的px單位做比例轉換,轉換公式為 new = old * windows.x / pageJson.width,這裡的pageJson.width是頁面的一個初始值,也是編輯時候的預設寬度,同時viewport使用device-width。 2.全屏背景, 頁面垂直居中 因為會存在上下或者左右有間隙的情況,這時候我們把背景顏色做全屏處理
頁面垂直居中只適用於全屏h5, 以後擴充套件長頁和PC頁就不需要垂直居中處理。
模板打包package.json中新增打包命令
"lib:h5-swiper": "vue-cli-service build --target lib --name h5-swiper --dest server/public/engine_libs/h5-swiper engine-template/engine-h5-swiper/index.js"
執行npm run lib:h5-swiper 生成引擎模板js如圖
頁面渲染ejs中引入模板
<script src="/third-libs/swiper.min.js"></script>
使用元件
<engine-h5-swiper :pageData="pageData" />
後端服務初始化專案工程目錄上文已給出,也可以使用 koa-generator 腳手架工具生成
ejs-template 模板引擎配置app.js
//配置ejs-template 模板引擎render(app, {\troot: path.join(__dirname, 'views'),\tlayout: false,\tviewExt: 'html',\tcache: false,\tdebug: false});複製程式碼koa-static靜態資源服務因為html2canvas需要圖片允許跨域,所以在靜態資源服務中所有資源請求設定'Access-Control-Allow-Origin':'*'
app.js
//配置靜態webapp.use(koaStatic(__dirname + '/public'), { gzip: true, setHeaders: function(res){\tres.header( 'Access-Control-Allow-Origin', '*')}});複製程式碼修改路由的註冊方式,通過遍歷routes資料夾讀取檔案app.js
const fs = require('fs')fs.readdirSync('./routes').forEach(route=> { let api = require(`./routes/${route}`) app.use(api.routes(), api.allowedMethods())})複製程式碼新增jwt認證,同時過濾不需要認證的路由,如獲取tokenapp.js
const jwt = require('koa-jwt')app.use(jwt({ secret: 'yourstr' }).unless({ path: [ /^\\/$/, /\\/token/, /\\/wechat/, { url: /\\/papers/, methods: ['GET'] } ]}));複製程式碼中介軟體實現統一介面返回資料格式,全域性錯誤捕獲並響應middleware/formatresponse.js
module.exports = async (ctx, next) => {\tawait next().then(() => {\t\tif (ctx.status === 200) {\t\t\tctx.body = {\t\t\t\tmessage: '成功',\t\t\t\tcode: 200,\t\t\t\tbody: ctx.body,\t\t\t\tstatus: true\t\t\t}\t\t} else if (ctx.status === 201) { // 201處理模板引擎渲染\t\t} else {\t\t\tctx.body = {\t\t\t\tmessage: ctx.body || '介面異常,請重試',\t\t\t\tcode: ctx.status,\t\t\t\tbody: '介面請求失敗',\t\t\t\tstatus: false\t\t\t}\t\t}\t}).catch((err) => {\t\tif (err.status === 401) {\t\t\tctx.status = 401;\t\t\tctx.body = {\t\t\t\tcode: 401,\t\t\t\tstatus: false,\t\t\t\tmessage: '登入過期,請重新登入'\t\t\t}\t\t} else {\t\t\tthrow err\t\t}\t})}複製程式碼koa2-cors跨域處理當介面釋出到線上,前端通過ajax請求時,會報跨域的錯誤。koa2使用koa2-cors這個庫非常方便的實現了跨域配置,使用起來也很簡單
const cors = require('koa2-cors');app.use(cors());複製程式碼連線資料庫我們使用mongodb資料庫,在koa2中使用mongoose這個庫來管理整個資料庫的操作。
建立配置檔案根目錄下新建config資料夾,新建mongo.js
// config/mongo.jsconst mongoose = require('mongoose').set('debug', true);const options = { autoReconnect: true}// username 資料庫使用者名稱// password 資料庫密碼// localhost 資料庫ip// dbname 資料庫名稱const url = 'mongodb://username:password@localhost:27017/dbname'module.exports = { connect: ()=> { mongoose.connect(url,options) let db = mongoose.connection db.on('error', console.error.bind(console, '連線錯誤:')); db.once('open', ()=> { console.log('mongodb connect suucess'); }) }}複製程式碼把mongodb配置資訊放到config.json中統一管理
然後在app.js中引入const mongoConf = require('./config/mongo');mongoConf.connect();複製程式碼... 服務端具體介面實現就不詳細介紹了,就是對頁面的增刪改查,和使用者的登入註冊難度不大
啟動執行啟動前端npm run dev-client複製程式碼啟動服務端npm run dev-server複製程式碼注意: 如果沒有生成過引擎模板js檔案的,需要先編輯引擎模板,否則預覽頁面載入頁面引擎.js 404報錯
編譯engine.js模板引擎npm run lib:h5-swiper