Vue + Koa從零打造一個H5頁面可視化編輯器——Quark-h5

  • 2019 年 11 月 14 日
  • 筆記

作者:圍的圍 https://juejin.im/post/5dc81428e51d4523632ee793

前言

想必你一定使用過易企秀或百度H5等微場景生成工具製作過炫酷的h5頁面,除了感嘆其神奇之處有沒有想過其實現方式呢?本文從零開始實現一個H5編輯器項目完整設計思路和主要實現步驟,並開源前後端程式碼。有需要的小夥伴可以按照該教程從零實現自己的H5編輯器。(實現起來並不複雜,該教程只是提供思路,並非最佳實踐)

Github: https://github.com/huangwei9527/quark-h5

演示地址:http://47.104.247.183:4000/

編輯器預覽:

技術棧

前端: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				// 原 src 目錄,改成 client 用作前端項目目錄      |-- server				// 新增 server 用於服務端項目目錄      |-- engine-template		// 新增 engine-template 用於頁面模板庫目錄      |-- docs				// 新增 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					--------前端項目介面程式碼          |--common					--------前端介面對應靜態資源          |--components				--------組件          |--config					--------配置文件          |--eventBus					--------eventBus          |--filter					--------過濾器          |--mixins					--------混入          |--pages					--------頁面          |--router					--------路由配置          |--store					--------vuex狀態管理          |--service					--------axios封裝          |--App.vue					--------App          |--main.js					--------入口文件          |--permission.js			--------許可權控制      |-- server					--------伺服器端項目程式碼          |--confog					--------資料庫鏈接相關          |--middleware				--------中間件          |--models					--------Schema和Model          |--routes					--------路由          |--views					--------ejs頁面模板          |--public					--------靜態資源          |--utils					--------工具方法          |--app.js					--------服務端入口      |-- common					--------前後端公用程式碼模組(如加解密)      |-- engine-template			--------頁面模板引擎,使用webpack打包成js提供頁面引用      |-- docs					--------預留編寫項目文檔目錄      |-- config.json				--------配置文件  

前端編輯器實現

編輯器的實現思路是:編輯器生成頁面JSON數據,服務端負責存取JSON數據,渲染時從服務端取數據JSON交給前端模板處理。

數據結構

確認了實現邏輯,數據結構也是非常重要的,把一個頁面定義成一個JSON數據,數據結構大致是這樣的:

頁面工程數據介面

    {      	title: '', // 標題      	description: '', //描述      	coverImage: '', // 封面      	auther: '', // 作者      	script: '', // 頁面插入腳本      	width: 375, // 高      	height: 644, // 寬      	pages: [], // 多頁頁面      	shareConfig: {}, // 微信分享配置      	pageMode: 0, // 渲染模式,用於擴展多種模式渲染,翻頁h5/長頁/PC頁面等等      }  

多頁頁面pages其中一頁數據結構:

    {      	name: '',      	elements: [], // 頁面元素      	commonStyle: {      		backgroundColor: '',      		backgroundImage: '',      		backgroundSize: 'cover'      	},      	config: {}      }  

元素數據結構:

    {      	elName: '', // 組件名      	animations: [], // 圖層的動畫,可以支援多個動畫      	commonStyle: {}, // 公共樣式,默認樣式      	events: [], // 事件配置數據,每個圖層可以添加多個事件      	propsValue: {}, // 屬性參數      	value: '', // 綁定值      	valueType: 'String', // 值類型      	isForm: 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 = [      	Text      ]      // 定義 install 方法,接收 Vue 作為參數      const install = function (Vue) {      	// 判斷是否安裝,安裝過就不繼續往下執行      	if (install.installed) return      	install.installed = true      	// 遍歷註冊所有組件      	components.map(component => Vue.component(component.name, component))      }        // 檢測到 Vue 才執行,畢竟我們是基於 Vue 的      if (typeof window !== 'undefined' && window.Vue) {      	install(window.Vue)      }        export default {      	install,      	// 所有組件,必須具有 install,才能使用 Vue.use()      	Text      }  

組件開發

示例:text文本組件

client/plugins下新建text組件目錄

    |-- text                --------text組件          |--src              --------資源          	|--index.vue    --------組件          |--index.js         --------入口  

text/index.js

    // 為組件提供 install 方法,供組件對外按需引入      import Component from './src/index'      Component.install = Vue => {      	Vue.component(Component.name, Component)      }      export default Component  

text/src/index.vue

    <!--text.vue-->      <template>        <div class="qk-text">          {{text}}        </div>      </template>        <script>      	export default {      		name: 'QkText', // 這個名字很重要,它就是未來的標籤名<qk-text></qk-text>      		props: {      			text: {      				type: String,      				default: '這是一段文字'            		}      		}      	}      </script>        <style lang="scss" scoped>      </style>  

編輯器里使用組件庫:

    // 引入組件庫      import QKUI from 'client/plugins/index'      // 註冊組件庫      Vue.use(QKUI)        // 使用:      <qk-text text="這是一段文字"></qk-text>  

按照這個組件開發方式我們可以擴展任意多的組件,來豐富組件庫

需要注意的是這裡的組件最外層寬高都要求是100%

配置文件

Quark-h5編輯器左側選擇組件區域可以通過一個配置文件定義可選組件 新建一個ele-config.js配置文件:

    export default [      	{      		title: '基礎組件',      		components: [      			{      				elName: 'qk-text', // 組件名,與組件庫名稱一致      				title: '文字',      				icon: 'iconfont iconwenben',      				// 給每個組件配置默認顯示樣式      				defaultStyle: {      					height: 40      				}      			}      		]      	},      	{      		title: '表單組件',      		components: []      	},      	{      		title: '功能組件',      		components: []      	},      	{      		title: '業務組件',      		components: []      	}      ]  

公共方法中提供一個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){      	let playFn = function (animation) {      		return new Promise(resolve => {      			$el.style.animationName =  animation.type      			$el.style.animationDuration =  `${animation.duration}s`      			// 如果是循環播放就將循環次數置為1,這樣有效避免編輯時因為預覽循環播放組件播放動畫無法觸發animationend來暫停組件動畫      			$el.style.animationIterationCount =  animation.infinite ? (isDebug ? 1 : 'infinite') : animation.interationCount      			$el.style.animationDelay =  `${animation.delay}s`      			$el.style.animationFillMode =  'both'let resolveFn = function(){      				$el.removeEventListener('animationend', resolveFn, false);      				$el.addEventListener('animationcancel', resolveFn, false);      				resolve()      			}      			$el.addEventListener('animationend', resolveFn, false)      			$el.addEventListener('animationcancel', resolveFn, false);      		})      	}      	for(let i = 0, len = animationList.length; i < len; i++){      		await playFn(animationList[i])      	}      	if(callback){      		callback()      	}      }  

animationIterationCount 如果是編輯模式的化動畫只執行一次,不然無法監聽到動畫結束animationend事件

執行動畫前先將元素樣式style快取起來,當動畫執行完再將原樣式賦值給元素

    let cssText = this.$el.style.cssText;      runAnimations(this.$el, animations, true, () => {      	this.$el.style.cssText = cssText      })  

元素添加事件

提供事件mixins混入到組件,每個事件方法返回promise,元素被點擊時按順序執行事件方法

頁面插入js腳本

參考百度H5,將腳本以script標籤形式嵌入。頁面載入後執行。這裡也可以考慮mixins方式混入到頁面或者組件,可根據業務需求自行擴展,都是可以實現的。

redo/undo歷史操作紀錄

  1. 歷史操作紀錄存在狀態機store.state.editor.historyCache數組中。
  2. 每次修改編輯操作都把整個pageDataJson欄位push到historyCache
  3. 點擊redo/undo時根據index獲取到pageDataJson重新渲染頁面

psd設計圖導入生成h5頁面

將psd每個設計圖中的每個圖層導出成圖片保存到靜態資源伺服器中,

服務端安裝psd依賴

    cnpm install psd --save  

加入psd.js依賴,並且提供介面來處理數據

    var PSD = require('psd');      router.post('/psdPpload',async ctx=>{      	const file = ctx.request.files.file; // 獲取上傳文件      	let psd = await PSD.open(file.path)      	var timeStr = + new Date();      	let descendantsList = psd.tree().descendants();      	descendantsList.reverse();      	let psdSourceList = []      	let currentPathDir = `public/upload_static/psd_image/${timeStr}`      	for (var i = 0; i < descendantsList.length; i++){      		if (descendantsList[i].isGroup()) continue;      		if (!descendantsList[i].visible) continue;      		try{      			await descendantsList[i].saveAsPng(path.join(ctx.state.SERVER_PATH, currentPathDir + `/${i}.png`))      			psdSourceList.push({      				...descendantsList[i].export(),      				type: 'picture',      				imageSrc: ctx.state.BASE_URL + `/upload_static/psd_image/${timeStr}/${i}.png`,      			})      		}catch (e) {      			// 轉換不出來的圖層先忽略      			continue;      		}      	}      	ctx.body = {      		elements: psdSourceList,      		document: psd.tree().export().document      	};      })  

最後把獲取的數據轉義並返回給前端,前端獲取到數據後使用系統統一方法,遍歷添加統一圖片組件

  • psd源文件大小最好不要超過30M,過大會導致瀏覽器卡頓甚至卡死
  • 儘可能合併圖層,並柵格化所有圖層
  • 較複雜的圖層樣式,如濾鏡、圖層樣式等無法讀取

html2canvas生成縮略圖

這裡只需要注意下圖片跨域問題,官方提供html2canvas: proxy解決方案。它將圖片轉化為base64格式,結合使用設置(proxy: theProxyURL), 繪製到跨域圖片時,會去訪問theProxyURL下轉化好格式的圖片,由此解決了畫布污染問題。提供一個跨域介面

    /**       * html2canvas 跨域介面設置       */      router.get('/html2canvas/corsproxy', async ctx => {      	ctx.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, {      	root: path.join(__dirname, 'views'),      	layout: false,      	viewExt: 'html',      	cache: false,      	debug: false      });  

koa-static靜態資源服務

因為html2canvas需要圖片允許跨域,所以在靜態資源服務中所有資源請求設置'Access-Control-Allow-Origin':'*'

app.js

    //配置靜態web      app.use(koaStatic(__dirname + '/public'), { gzip: true, setHeaders: function(res){      	res.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認證,同時過濾不需要認證的路由,如獲取token

app.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) => {      	await next().then(() => {      		if (ctx.status === 200) {      			ctx.body = {      				message: '成功',      				code: 200,      				body: ctx.body,      				status: true      			}      		} elseif (ctx.status === 201) { // 201處理模板引擎渲染        		} else {      			ctx.body = {      				message: ctx.body || '介面異常,請重試',      				code: ctx.status,      				body: '介面請求失敗',      				status: false      			}      		}      	}).catch((err) => {      		if (err.status === 401) {      			ctx.status = 401;      			ctx.body = {      				code: 401,      				status: false,      				message: '登錄過期,請重新登錄'      			}      		} else {      			throw err      		}      	})      }    

koa2-cors跨域處理

當介面發布到線上,前端通過ajax請求時,會報跨域的錯誤。koa2使用koa2-cors這個庫非常方便的實現了跨域配置,使用起來也很簡單

    const cors = require('koa2-cors');      app.use(cors());  

連接資料庫

我們使用mongodb資料庫,在koa2中使用mongoose這個庫來管理整個資料庫的操作。

  • 創建配置文件

根目錄下新建config文件夾,新建mongo.js

    // config/mongo.js      const 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