有讚美業店鋪裝修前端解決方案

有贊技術發表於2019-04-01

一、背景介紹

做過電商專案的同學都知道,店鋪裝修是電商系統必備的一個功能,在某些場景下,可能是廣告頁製作、活動頁製作、微頁面製作,但基本功能都是類似的。所謂店鋪裝修,就是使用者可以在 PC 端進行移動頁面的製作,只需要通過簡單的拖拽就可以實現頁面的編輯,屬於使用者高度自定義的功能。最終編輯的結果,可以在 H5、小程式進行展示推廣。

有讚美業是一套美業行業的 SaaS 系統,為美業行業提供資訊化和網際網路化解決方案。有讚美業本身提供了店鋪裝修的功能,方便使用者自定義網店展示內容,下面是有讚美業店鋪裝修功能的截圖:

有讚美業店鋪裝修

上面的圖片是 PC 端的介面,下面兩張圖分別是 H5 和小程式的最終展示效果。可以簡單地看到,PC 端主要做頁面的編輯和預覽功能,包括了豐富的業務元件和詳細的自定義選項;H5 和小程式則承載了最終的展示功能。

再看看有讚美業當前的技術基本面:目前我們的 PC 端是基於 React 的技術棧,H5 端是基於 Vue 的技術棧,小程式是微信原生開發模式。

在這個基礎上,如果要做技術設計,我們可以從以下幾個角度考慮:

  • 三端的檢視層都是資料驅動型別,如何管理各端的資料流程?

  • 三個端三種不同技術棧,業務中卻存在相同的內容,是否存在程式碼複用的可能?

  • PC 最終生成的資料,需要與 H5、小程式共享,三端共用一套資料,應該通過什麼形式來做三端資料的規範管理?

  • 在擴充套件性上,怎麼低成本地支援後續更多元件的業務加入?

二、方案設計

所以我們針對有讚美業的技術基本面,設計了一個方案來解決以上幾個問題。

首先擺出一張架構圖:

店鋪裝修架構圖

2.1 資料驅動

首先關注 CustomPage 元件,這是整個店鋪裝修的總控制檯,內部維護三個主要元件 PageLeft、 PageView 和 PageRight,分別對應上面提到的 PC 端3個模組。

為了使資料共享,CustomPage 通過 React context 維護了一個”作用域“,提供了內部三個元件共享的“資料來源”。 PageLeft 、 PageRight 分別是左側元件和右側編輯元件,共享 context.page 資料,資料變更則通過 context.pageChange 傳遞。整個過程大致用程式碼表示如下:

// 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 編輯生成資料,最終會與 H5、小程式共享,所以協商好資料格式和欄位含義很重要。為了解決這個問題,我們抽取了一個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: '居右',
}];
複製程式碼

2.2 跨端複用

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`);
});
複製程式碼

2.3 拖拽實現

拖拽功能是通過 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包來做資料規範的統一。

當然除了基本架構以外,還會有很多技術細節需要處理,比如需要保證預覽元件不可點選等,這些則需要在實際開發中具體處理。

相關文章