極客時間前端實戰訓練營
愛共享 愛生活 加油 2021
背景
前端視覺化編輯器在現在大公司中都有各樣的實現方式,不同的業務會賦予不一樣的功能,可以參考下面這個文章:https://github.com/taowen/awesome-lowcode,裡面介紹了國內很多廠商低程式碼平臺的實現方式。
行業裡有許多利用 AI 去切割頁面、生產頁面的案例,但在短時間要實現產出,需要消耗大量的人力和精力,不建議在一般團隊去做這樣的事情。
目的
設計這樣一款視覺化編輯器,需要達成以下目的
-
視覺化編輯,可以在畫布拖拽元素
-
豐富的樣式配置
-
將常用業務抽離成元件,並可以配置元件的引數
設計思路
將這個專案分為三個部分:
-
編輯器
提供使用者管理專案、拖拽元素、配置引數、預覽釋出等能力 -
元件庫
抽離業務邏輯,將其封裝成元件庫管理維護 -
服務端
儲存管理專案,最重要的是將編輯器生產出來的資料進行解析生成頁面
技術棧
-
由於團隊裡對vue比較熟悉,前端的編輯器和元件庫都使用 vue 作為開發框架
-
服務端使用 egg.js,考慮到 egg 較為成熟,不需要自己再從0搭起。當然,也可以選擇其他框架。
技術可以根據個人或者團隊偏好自己決定。
設計思路
image一、JSON Schema
整個專案除了設計成三個核心工程之外,還有一段JSON Schema,這段JSON在整個專案中起到至關重要的部分,它是構成頁面的基石。JSON Schema 是由編輯器生產,並且每個生產出的JSON Scheme的格式必須保持一致,這樣,服務端渲染的時候才可以對專案的JSOn進行解析,並且最終渲染出頁面。
將 JSON 劃分為三個層級
1. 專案級
專案級的 JSON 主要是介紹專案的基本資訊
{ id: '', // 專案唯一標誌 name: '', // 專案名稱 label: [], // 專案標籤 description: '', // 專案描述 author: '', // 作者 pages: [] // 頁面}
2. 頁面級
頁面級的 JSON 可以理解為我們的一個 html 頁面,裡面包括頁面指令碼,頁面樣式、元素等
{ id: '', // 頁面唯一標誌 type: '', // 頁面型別 route: '', // 頁面路由 title: '', // 頁面標題 style: { // 頁面根元素的樣式 width: '', height: '', ...... }, elements: [], // 元件 plugins: [], // 頁面外掛服務}
3. 元件級
元件是組成頁面的核心部分,可以理解為頁面都是由一個個具有特定功能的積木堆積而成。
{ id: '', // 元件唯一標誌 elementName: '', // 元件名 style: { // 元件通用樣式 position: '', width: '', height: '', border: '' }, props: {}, // 元件屬性引數 children: [], // 子元件 ......}
其中值得一提的是,props 可以理解 vue 裡的 props,通過給元件傳入 props,元件可以根據引數給於相對應的表現,相信學習過 vue 的元件相關知識的人一定理解。
最後一個完整的專案的 JSON 長這樣
{ id: '_clv_fPQu', name: 'FAB頁', label: ['fab', '遊戲'], description: 'Feature、Advantage和Benefit,按照這樣的順序來介紹,就是說服性演講的結構,它達到的效果就是讓客戶相信你的是最好的', author: 'lujintao', pages: [ { id: '_Tmy_CdpY', type: 'pc', route: 'fab', title: '遊戲頁', style: { width: '750px', height: '1334px', position: 'relative', margin: '0 auto', padding: '', overflow: '', backgroundColor: '', backgroundImage: '', backgroundSize: '', backgroundRepeat: '', backgroundPosition: '' }, elements: [ { id: '_aIv_SesL', elementName: 'aicc-image', style: { position: 'absolute', width: '100px', height: '100px', border: '1px solid #eeeeee' }, props: { src: '***/test1.png' }, children: [], } ], plugins: [ { name: 'mobile', state: true }, { name: 'weixinShare', state: true, data: { title: '微信分享的標題', description: '微信分享的文案', img: '***/wxshare.png' } } ], } ]}
二、元件庫
作為頁面的積木,元件是十分重要的部分。元件除了需要有像文字元件、圖片元件、視訊元件等一些基礎的功能,還需要一些貼近業務的元件。比如在官網製作中經常可以看到的輪播圖元件,亦或者導航欄元件等等,這些元件都需要結合業務進行提煉出通用能力,才能方便使用者快速搭建一個完整頁面的。
搭建元件庫需要開發兩份程式碼,一份是元件本身,一份是用來配置元件props的配置面板。
1. 元件
元件的作用有兩個,一個是提供給編輯器,在畫板上能夠展示元件,另外一個作用則是將元件打包後提供給服務端作為指令碼插入。
比如寫一個視訊元件,程式碼很簡單,定義好 props 就可以了
<template> <video :src="src" :autoplay="autoplay" /></template><script>export default { name: 'aicc-video', props: { src: { type: String, default: '' }, autoplay: { type: String, default: '' } }}</script>
- 在編輯器裡的引入方式
import AICCVideo from '@/components/AICCVideo'
- 在服務端的引入方式,在瀏覽器環境下,元件是已經被打包好作為script指令碼插入。
<script src="https:**/**/aiccvideo.min.js">
2. 元件配置面板
跟元件需要應用到服務端不同,元件配置面板所要做的事情就是生產出自定義資料(JSON Scheme),因此只需要提供給編輯器註冊。
還是拿上面的視訊元件舉例
<template> <div class="props-video"> <input v-model="propsValue.src" /> <input v-model="propsValue.autoplay" /> </div></template><script>export default { name: 'props-video', props: { value: { // 配置面板在編輯器裡掛載的時候是通過v-model="props"傳入的 type: Object, default: () => {} } }, data() { propsValue: {} }, watch: { value(val) { this.propsValue = val }, // 配置面板更新的時候,子傳父 propsValue(val) { this.$emit('input', val) } }}</script>
元件配置面板在編輯器的引入方式和元件的引入一致,只需要 require 進來註冊掛載到 component 元件上就好了,同時 v-model 綁定當前元件的 props 資料。
三、編輯器
編輯器作為使用者使用的唯一視窗,在幾個板塊裡顯得重中之重。我們已經知道,整個專案的基石是第一點所提到的 JSON Schema。編輯器也不例外,編輯器本質上做的事情就是修改/新增/刪除這段 JSON 資料的各種引數。
1. 如何維護資料
在編輯器中,只需要維護一份 JSON 資料,所以考慮使用 vuex 去管理專案資料。根據 vuex 官方描述,十分符合編輯器這種中大型的專案
Vuex 是一個專為 Vue.js 應用程式開發的狀態管理模式。它採用集中式儲存管理應用的所有元件的狀態,並以相應的規則保證狀態以一種可預測的方式發生變化
實現過程
-
寫一個 name 為 editor 的 store
const state = { projectData: {}, // 工程專案資料 activePageUUID: '', // 當前正在編輯的頁面 activeElementUUID: '', //當前被選中的元件UUID}
// 初始化專案export function initProject({ commit, state }, data) { let projectData = data if (!data) projectData = initProject() // 空專案時生成預設 JSON Data dispatch('setActivePageUUID', projectData.pages[0].uuid)}// 設定當前被選中的頁面的UUIDexport function setActivePageUUID({ commit }, uuid) { state.activePageUUID = uuid}// 設定當前被選中的元素的UUIDexport function setActiveElementUUID({ commit }, uuid) { state.activeElementUUID = uuid}
// 當前選中頁面的 JSON 資料export function activePage(state) { const idx = state.projectData.pages.findIndex(p => { return state.activePageUUID === p.uuid }) return state.projectData.pages[idx]}// 當前選中元件的 JSON 資料export function activeElement(state) { const pIdx = state.projectData.pages.findIndex(p => { return state.activePageUUID === p.uuid }) const activePage = state.projectData.pages[pIdx] const eIdx = activePage.elements.findIndex(e => { return state.activeElementUUID === e.uuid })}
-
getter.js
-
action.js
-
state.js: 儲存要維護的資料
- 編輯器裡各頁面繫結資料,通過 mapGetters 獲取當前頁面,當前元素,以及專案資料
<template> <div> <component :is="activeElement.elementName" v-model="activeElement.props" /> </div></template> <script>import { mapGetters, mapState } from 'vuex'export default { computed: { ...mapGetters({ 'activeElement': 'editor/activeElement' }), ...mapState({ pages: state => state.editor.projectData.pages, activePageUUID: state => state.editor.activePageUUID, activeElementUUID: state => state.editor.activeElementUUID, }) }}</script>
2. 如何實現畫布拖拽
對於視覺化編輯器來說,使用者希望可以能夠拖拽畫布中的元素進行更改位置。實現思路十分簡單
-
選中元素,監聽 mousedown 事件
-
獲取當前按下元素的 offsetTop 和 offsetLeft
-
獲取當前按下滑鼠的座標位置(e.clientX, e.cliengY)
-
監聽 mousemove 事件,獲取滑鼠移動的長度,計算出距離
-
元素的新座標 = 距離 + 按下時元素的 offset 資訊
程式碼如下
function mousedown(e) { let newTop = null let newLeft = null // 記錄按下時當前元素位置 const cTop = e.currentTarget.offsetTop const cLeft = e.currentTarget.offsetLeft // 記錄按下時當前滑鼠位置 const mouseX = e.clientX const mouseY = e.clientY const move = mEvent => { // 只是單純移動位置,不需要傳遞事件給後代 mEvent.stopPropagation() mEvent.preventDefault() const cX = mEvent.clientX const cY = mEvent.clientY // 移動的位置 const distanceX = cX - mouseX const distanceY = cY - mouseY // 新座標 newTop = distanceX + cTop newLeft = distanceY + cLeft } const up = () => { document.removeEventListener('mousemove', move, true) document.removeEventListener('mouseup', up, true) } document.addEventListener('mousemove', move, true) document.addEventListener('mouseup', up, true)}
這裡其實有優化的空間,在元素進行拖拽的時候,如果實時去改變top,left時,會引起重排。解決辦法也很簡單,在 mousemove 的過程中我們使用 transform 去實時顯示當前元件的位置,等 mouseup 釋放滑鼠的時候,我們再把真實的座標位置賦值給元件的 Style 裡。
四、服務端渲染
我們在前面編輯器裡生產出來的專案 JSON 資料,最終需要讓服務端這邊進行 DSL 解析。由於我們採納的技術棧是 Vue,想要生成一個結構化的頁面也十分容易,使用 vue 的 render 函式。
具體可以參考官方文件:渲染函式 & jSX
拿第一部分 JSON Schema 的示例,渲染步驟大致如下:
-
拿到頁面的 page 資訊生成頁面的 title/seo 等頁面配置資訊,根據路由生成對應的 ${name}.html 檔案
-
掛載打包好的元件庫
<script src="*/*/aiccvideo.cdn.js">
-
由於使用的render函式,不需要編譯器,只需要掛載 vue.runtime.js 指令碼即可
-
使用模板引擎進行字串替換,替換的資訊有頁面資訊、render 函式裡的元件陣列等
-
遍歷 elements,用 vue 的 render 函式
createElement('元件名', { props: { ...生產出來的element JSON資料 }})
-
解析 style 的 JSON 資料,生成
選擇器{ ${key}: ${value} }
的樣式表 -
寫入檔案
fs.writeFileSync(檔案路徑,htmlString)
結語
文章到這就差不多結束了,因為只是簡單說明一下整個專案怎麼搭建,在具體細節裡沒有太詳盡描述。要寫下來,每一塊都可以作為一個單元去寫。比如
-
元件庫如何打包
-
專案的管理維護,怎麼使用 lerna 管理專案
-
如何實現拖拽元素實時改變元素大小
-
如何保證提交資料的格式符合要求
-
如何實現外掛化服務,如微信分享等
……
要上線一個完整能夠生產的產品,細節的地方還有很多技術點可以討論,在這裡僅僅只是談及整個專案技術的組成部分和基本實現原理。
歡迎有更好的想法~