作過電商項目的同窗都知道,店鋪裝修是電商系統必備的一個功能,在某些場景下,多是廣告頁製做、活動頁製做、微頁面製做,但基本功能都是相似的。所謂店鋪裝修,就是用戶能夠在 PC 端進行移動頁面的製做,只須要經過簡單的拖拽就能夠實現頁面的編輯,屬於用戶高度自定義的功能。最終編輯的結果,能夠在 H五、小程序進行展現推廣。vue
有讚美業是一套美業行業的 SaaS 系統,爲美業行業提供信息化和互聯網化解決方案。有讚美業自己提供了店鋪裝修的功能,方便用戶自定義網店展現內容,下面是有讚美業店鋪裝修功能的截圖:react
上面的圖片是 PC 端的界面,下面兩張圖分別是 H5 和小程序的最終展現效果。能夠簡單地看到,PC 端主要作頁面的編輯和預覽功能,包括了豐富的業務組件和詳細的自定義選項;H5 和小程序則承載了最終的展現功能。npm
再看看有讚美業當前的技術基本面:目前咱們的 PC 端是基於 React 的技術棧,H5 端是基於 Vue 的技術棧,小程序是微信原生開發模式。小程序
在這個基礎上,若是要作技術設計,咱們能夠從如下幾個角度考慮:api
因此咱們針對有讚美業的技術基本面,設計了一個方案來解決以上幾個問題。數組
首先擺出一張架構圖:微信
首先關注 CustomPage 組件,這是整個店鋪裝修的總控制檯,內部維護三個主要組件 PageLeft、 PageView 和 PageRight,分別對應上面提到的 PC 端3個模塊。架構
爲了使數據共享,CustomPage 經過 React context 維護了一個」做用域「,提供了內部三個組件共享的「數據源」。 PageLeft 、 PageRight 分別是左側組件和右側編輯組件,共享 context.page
數據,數據變動則經過 context.pageChange
傳遞。整個過程大體用代碼表示以下:dom
// CustomerPage class CustomerPage extends React.Component { static childContextTypes = { page: PropTypes.object.isRequired, pageChange: PropTypes.func.isRequired, activeIndex: PropTypes.number.isRequired, }; getChildContext() { const { pageInfo, pageLayout } = this.state; return { page: { pageInfo, pageLayout }, pageChange: this.pageChange || (() => void 0), activeIndex: pageLayout.findIndex(block => block.active), }; } render() { return ( <div> <PageLeft /> <PageView /> <PageRight /> </div> ); } } // PageLeft class PageLeft extends Component { static contextTypes = { page: PropTypes.object.isRequired, pageChange: PropTypes.func.isRequired, activeIndex: PropTypes.number.isRequired, }; render() {...} } // PageRight class PageRight extends Component { static contextTypes = { page: PropTypes.object.isRequired, pageChange: PropTypes.func.isRequired, activeIndex: PropTypes.number.isRequired, }; render() {...} }
至於 H5 端,能夠利用 Vue 的動態組件完成業務組件的動態化,這種異步組件的方式提供了極大的靈活性,很是適合店鋪裝修的場景。異步
<div v-for="item in components"> <component :is="item.component" :options="convertOptions(item.options)" :isEdit="true"> </component> </div>
小程序由於沒有動態組件的概念,因此只能經過 if else
的麪條代碼來實現這個功能。更深刻的考慮複用的話,目前社區有開源的工具實現 Vue 和小程序之間的轉換,可能能夠幫助咱們作的更多,但這裏就不展開討論了。
PC 編輯生成數據,最終會與 H五、小程序共享,因此協商好數據格式和字段含義很重要。爲了解決這個問題,咱們抽取了一個npm包,專門管理3端數據統一的問題。這個包描述了每一個組件的字段格式和含義,各端在實現中,只須要根據字段描述進行對應的樣式開發就能夠了,這樣也就解決了咱們說的擴展性的問題。後續若是須要增長新的業務組件,只須要協商好並升級新的npm包,就能作到3端的數據統一。
/** * 顯示位置 */ export const position = { LEFT: 0, CENTER: 1, RIGHT: 2, }; export const positionMap = [{ value: position.LEFT, name: '居左', }, { value: position.CENTER, name: '居中', }, { value: position.RIGHT, name: '居右', }];
PageView 是預覽組件,是這個設計的核心。按照最直接的思路,咱們可能會用 React 把全部業務組件都實現一遍,而後把數據排列展現的邏輯實現一遍;再在 H5 和小程序把全部組件實現一遍,數據排列展現的邏輯也實現一遍。可是考慮到代碼複用性,咱們是否是能夠作一些「偷懶」?
若是不考慮小程序的話,咱們知道 PC 和 H5 都是基於 dom 的樣式實現,邏輯也都是 js 代碼,兩端都實現一遍的話確定作了不少重複的工做。因此爲了達到樣式和邏輯複用的能力,咱們想了一個方法,就是經過 iframe 嵌套 H5 的頁面,經過 postmessage 來作數據交互,這樣就實現了用 H5 來充當預覽組件,那麼 PC 和 H5 的代碼就只有一套了。按照這個實現思路,PageView 組件能夠實現成下面這樣:
class PageView extends Component { render() { const { page = {} } = this.props; const { pageInfo = {}, pageLayout = [] } = page; const { loading } = this.state; return ( <div className={style}> <iframe title={pageInfo.title} src={this.previewUrl} frameBorder="0" allowFullScreen="true" width="100%" height={601} ref={(elem) => { this.iframeElem = elem; }} /> </div>); } }
PageView 代碼很簡單,就是內嵌 iframe,其他的工做都交給 H5。H5 將拿到的數據,按照規範轉換成對應的組件數組展現:
<template> <div> <component v-for="(item, index) in components" :is="item.component" :options="item.options" :isEdit="false"> </component> </div> </template> <script> computed: { components() { return mapToComponents(this.list); }, }, </script>
由於有了 iframe ,還須要利用 postmessage 進行跨源通訊,爲了方便使用,咱們作了一層封裝(代碼參考自有贊餐飲):
export default class Messager { constructor(win, targetOrigin) { this.win = win; this.targetOrigin = targetOrigin; this.actions = {}; window.addEventListener('message', this.handleMessageListener, false); } handleMessageListener = (event) => { // 咱們能相信信息的發送者嗎? (也許這個發送者和咱們最初打開的不是同一個頁面). if (event.origin !== this.targetOrigin) { console.warn(`${event.origin}不對應源${this.targetOrigin}`); return; } if (!event.data || !event.data.type) { return; } const { type } = event.data; if (!this.actions[type]) { console.warn(`${type}: missing listener`); return; } this.actions[type](event.data.value); }; on = (type, cb) => { this.actions[type] = cb; return this; }; emit = (type, value) => { this.win.postMessage({ type, value, }, this.targetOrigin); return this; }; destroy() { window.removeEventListener('message', this.handleMessageListener); } }
在此基礎上,業務方就只須要關注消息的處理,例如 H5 組件接收來自 PC 的數據更新能夠這樣用:
this.messager = new Messager(window.parent, `${window.location.protocol}//mei.youzan.com`); this.messager.on('pageChangeFromReact', (data) => { ... });
這樣經過兩端協商的事件,各自進行業務邏輯處理就能夠了。
這裏有個細節須要處理,由於預覽視圖高度會動態變化,PC 須要控制外部視圖高度,因此也須要有動態獲取預覽視圖高度的機制。
// vue script updated() { this.$nextTick(() => { const list = document.querySelectorAll('.preview .drag-box'); let total = 0; list.forEach((item) => { total += item.clientHeight; }); this.messager.emit('vueStyleChange', { height: total }); } } // react script this.messsager.on('vueStyleChange', (value) => { const { height } = value; height && (this.iframeElem.style.height = `${height}px`); });
拖拽功能是經過 HTML5 drag & drop api 實現的,在此次需求中,主要是爲了實現拖動過程當中組件可以動態排序的效果。這裏有幾個關鍵點,實現起來可能會花費一些功夫:
目前社區有不少成熟的拖拽相關的庫,咱們選用了vuedraggable。緣由也很簡單,一方面是避免重複造輪子,另外一方面就是它很好的解決了咱們上面提到的幾個問題。
vuedraggable 封裝的很好,使用起來就很簡單了,把咱們前面提到的動態組件再封裝一層 draggable 組件:
<draggable v-model="list" :options="sortOptions" @start="onDragStart" @end="onDragEnd" class="preview" :class="{dragging: dragging}"> <div> <component v-for="(item, index) in components" :is="item.component" :options="item.options" :isEdit="false"> </component> </div> </draggable> const sortOptions = { animation: 150, ghostClass: 'sortable-ghost', chosenClass: 'sortable-chosen', dragClass: 'sortable-drag', }; // vue script computed: { list: { get() { return get(this.designData, 'pageLayout') || []; }, set(value) { this.designData.pageLayout = value; this.notifyReact(); }, }, components() { return mapToComponents(this.list); }, },
到這裏,全部設計都完成了。總結一下就是:PC 端組件間主要經過 React context 來作數據的共享;H5 和 小程序則是經過數據映射對應的組件數組來實現展現;核心要點則是經過 iframe 來達到樣式邏輯的複用;另外能夠經過第三方npm包來作數據規範的統一。
固然除了基本架構之外,還會有不少技術細節須要處理,好比須要保證預覽組件不可點擊等,這些則須要在實際開發中具體處理。