React 拖拽作業組件設計
效率如何提升一直是業務研發中永恆的話題。類似於前端的拖拽式建站平台,在數據業務研發中,我們的後端、數據研發以及演算法工程師同樣期望利用可視化拖拽技術與表單配置來降低自己的開發成本與門檻,更快速靈活地執行業務策略。他們將自己的業務封裝為下圖所示的模塊,可能是一個演算法包,可能是一個數據源,也可能是一個計算組件,通過頁面中簡單的拖拽連線操作來快速實現業務搭建。因此,我們基於 React 封裝了一套拖拽作業組件來面對不同的業務場景,來看看我們具體是如何實現的。
組件思路
從組件設計角度,我們將作業組件分解為數據層、中間層與視圖層。而從組件功能角度,我們將作業組件劃分為模塊選擇欄(Elements),畫布(Screen)和作業圖(Map)三個模塊。作業組件的核心在於模塊節點、模塊間連線以及畫布的狀態,我們將這些狀態與操作狀態的方法抽象出來,形成 FlowStore。
class FlowStore { private nodes: INode[]; private links: ILink[]; private screen: IScreen; ... getData() { ... } setData() { ... } addNode(node: INode) { ... } ...}
FlowStore 通過 createFlow 來建立,所返回的 FlowProvider 中包裹的視圖層組件可以獲取到 FlowStore。
function createFlow() { class FlowProvider extends React.Component { getChildContext() { // 初始化 FlowStore return { flowStore: new FlowStore() }; } render() { return ( <div className="flow">{this.props.children}</div> ); } }}
那麼視圖層組件具體是如何獲取到最新的 FlowStore 及其方法的呢?我們增加了統一的 Decorator 作為中間層,監聽 FlowStore 中的數據變化完成 View 的 reRender,同時將 FlowStore 類成員函數透傳給 View。
export default function flowDecorator(mapFlowStoreToProps) { return function wrapWithFlowElements(WrappedComponent) { return class FlowDecorator extends React.Component { constructor(props, context) { super(props, context); this.flowStore = this.context.flowStore; this.state = this.flowStore.getData(); } handleChange() { this.setState(this.flowStore.getData()); } // 建立 FlowStore 監聽 componentDidMount() { this.unSubscribe = this.flowStore.subscribe( this.handleChange.bind(this) ); this.handleChange(); } // 取消 FlowStore 監聽 componentWillUnmount() { this.unSubscribe.apply(this.flowStore); } // 提取 FlowStore 的類成員函數 getflowStoreFunc() { return Object.getOwnPropertyNames(Object.getPrototypeOf(this.flowStore)) .reduce((pre, cur) => { return { ...pre, [cur]: this.flowStore[cur].bind(this.flowStore) }; }, {}); } render() { const finalProps = mapFlowStoreToProps(this.state, this.props); const flowStoreFunc = this.getflowStoreFunc(); return <WrappedComponent {...finalProps} {...flowStoreFunc} />; } } };}
我們針對 FlowStore 的數據變化進行了優化,防止 View 層頻繁 reRender。除了在 FlowDecorator 中增加 shouldComponentUpdate 外,在作業組件相對定製的場景中,我們是知道哪些操作應該觸發 reRender,而哪些並不需要。我們給相應方法增加了會觸發更新的 Decorator,同時添加簡易的 batchUpdate 機制,來保證性能更優。
function UpdateStore(target, propertyName, descriptor) { const oldValue = descriptor.value; descriptor.value = function() { oldValue.apply(this, arguments); if (!this.batchUpdate) { this.batchUpdate = true; // 通過 setTimeout 延遲完成批量更新 setTimeout(() => { // 觸發 FlowDecorator 的監聽,從而觸發 View reRender this._runListener(); this.batchUpdate = false; }, 0); } return; }; return descriptor;}
在整個設計過程中,我們借鑒了 Redux 體系思想,進行些許簡化。對數據層進行抽象的同時,保留了 View 層的靈活性和可擴展性,View 完全可由用戶自行定義。
拖拽實現
為了滿足相似場景下的復用,組件內置的 FlowElements,FlowMap 與 FlowScreen,實現了作業組件的拖拽功能。
在 React 中有兩種較為常見的拖拽實現方案,一種基於 HTML5 的 Drag 與 Drop API,從模塊選擇欄拖入畫布就應用了 react-dnd 配合 HTML5Backend,具體代碼就不在贅述,可參考官網 Demo。而畫布中的節點與連線的拖動則是另一種實現方式,通過 mousedown -> mousemove -> mouseup 完成拖動,這裡為何捨棄 react-dnd 呢,後文會給你答案。
// 滑鼠位置的移動帶動節點的移動,滑鼠抬起時停止節點移動toggleDragNode(isDraggingNode: Boolean) { if (isDraggingNode) { Event.on(window, "mousemove", this.onDragNodeMouseMove.bind(this)); Event.on(window, "mouseup", this.onDragNodeMouseUp.bind(this)); } else { Event.off(window, "mousemove"); Event.off(window, "mouseup"); }}// 滑鼠按下時獲取節點的當前位置<g className="map-node" onMouseDown={this.onDragNodeMouseDown.bind(this, node)}></g>
在拖拽的研發中,我們感覺到最複雜的並不是拖拽的實現,而是節點的位置確定與畫布的動態擴展。
對於作業組件的畫布而言,並非全量展示給用戶,而會有一個可見區域。我們需要自行控制畫布的 scrollTop 與 scrollLeft。注意,scroll 無法直接賦值給畫布元素,只能通過 ref 拿到真實渲染後的 DOM 元素進行修改。同時監聽畫布的滾動事件,保持與 FlowStore 中的 scroll 數據同步。
由於節點移動到邊界會帶來畫布的移動,滑鼠也會超出組件可見區域,通過滑鼠的相對位移來確定節點位置並不合適。因此我們採用滑鼠的當前位置來作為節點的位置,確定節點在畫布中的位置便尤為重要。
如上圖所示,節點的 positionX = clientX - offsetLeft + scrollLeft - origin.x,positionY = clientY - offsetTop + scrollTop - origin.y。當節點處於畫布最邊緣時,即 scrollLeft = 0 or(width - visibleWidth)或 scrollTop = 0 or(height - visibleHeight)時,畫布需要相對應擴展,其中 width / height 會自動進行增長,origin 作為坐標系的原點,也會隨之調整。這裡為方便讀者理解,已經做了一定簡化,實際組件還考慮了畫布縮放(scale)與畫布自適應(padding),會更加複雜。
而當滑鼠超出組件可見區域時,節點的位置將不再隨著滑鼠的移動而移動,將會始終貼著可見區域的邊緣,通過 requestAnimationFrame 不斷移動節點與畫布,直至貼合畫布邊緣。
踩過的坑
SVG 中 HTML5 Darg / Drop 不起作用?
SVG 不支持 HTML5 Darg 與 Drop,因此在畫布中無法使用 React-Dnd 配合 HTML5Backend 完成拖拽,儘管可以配合 react-dnd-touch-backend,但會喪失中間過程,使拖拽效果很不理想。
兩個 Context 衝突了?
在開發中出現兩個 Context,一個來源於 React-Dnd 的 DragContext,另一個來源於 FlowStore 向子孫組件傳遞的 Context,不處理將會發生覆蓋,我們選擇在 createFlow 內完成聚合:
getChildContext() { return { flowStore: new FlowStore(), dragDropManager: this.context.dragDropManager };}
首次渲染,畫布無法居中?
本地開發首次渲染時,畫布無法相對可視化區域居中。這是由於 dev 環境下 webpack 將樣式從 js 中抽離出來,非同步添加 <link href="localhost:xxxx/xxxx" /> 到 head 中。當組件 ComponentDidMount 時,css 可能尚未渲染完成,造成 ComponentDidMount 中對 DOM 元素計算出現問題。
Element offset 值不對?
HTMLElement.offsetLeft / offsetTop 是指當前元素相對於父元素節點的左邊界/上邊界的偏移量。因此要求當前元素相對於整個頁面的偏移量,我們需要不斷向父級元素迭代,累加相應的 offset 值。
function getOffset(domNode) { let offsetTop = 0; let offsetLeft = 0; let targetDomNode = domNode; while (targetDomNode !== window.document.body && targetDomNode != null) { offsetLeft += targetDomNode.offsetLeft; offsetTop += targetDomNode.offsetTop; targetDomNode = targetDomNode.offsetParent; } return { offsetTop, offsetLeft };}
有讀者提到,也可以直接使用 getBoundingClientRect 。
節點 onClick 無法觸發?
點擊節點時無法觸發節點的 onClick 事件,是由於 onClick 事件可分解為 onMouseDown 與 onMouseUp,節點的 onMouseDown 事件使節點上方新渲染出一個 dragNode,那麼 onMouseUp 事件自然觸發在它上面,造成該節點的 onClick 未能成功觸發。給 dragNode 添加 pointer-events: none 即可解決。
寫在最後
在組件定義中,我們將本文所述的拖拽作業組件歸為業務組件。今天我們的組件會被應用於演算法模型的搭建,而明天則會被用於數據依賴關係的表達,那麼如何讓組件更好地適應不同的業務呢?我們抽象了數據層的點線模型與畫布模型,而視圖層提供更靈活的創建方式。與通用組件不同,業務組件需要面對某一類業務場景,這些場景的相同點是什麼,不同點又是什麼,是值得我們去推敲的。因此對於業務組件的研發而言,業務場景的提煉與抽象是很重要的,畢竟我們所看重的是組件更為快速復用的能力。
推薦閱讀: