每週至少一篇原創技術文章
週一早上【8:50】準時推送
偶爾也會分享生活的點滴與感悟
這是本公衆號的第 3 篇原創文章前端
今天我們要討論的樹,它不是現實結構的樹,也不是數據結構要討論的樹,而是「從業務視角抽象出來的樹形結構」。vue
樹形結構能夠用在不少的業務上,好比組織結構中的上下級關係、商品分類管理、文件系統、後臺系統中的頁面和組件關係等等。node
下面請繫好安全帶,咱們將從數據庫設計、設計模式、前端組件三個方面來介紹關於樹狀結構設計的方方面面,助你通關全棧樹狀結構!數據庫
樹狀結構最簡單最經常使用的方法實際上是直接存儲在JSON裏面。如今有不少主流的NoSQL庫,好比MongoDB等,並且也有不少關係型數據庫也開始支持JSON存儲,好比MySQL。後端
使用JSON的好處是,「維護整棵樹比較方便」,直接整存整取就行了,不用去管中間是怎麼修改的,怎麼映射到數據庫的。但缺點是不過高效,好比想要編輯某個葉子節點,查詢和更新都沒有純關係型數據那麼方便。因此若是你的業務足夠簡單,數據量也很小,可使用JSON。不然,仍是推薦使用關係型數據來實現。設計模式
那如何在關係型數據應該如何設計,才能高效地存儲和操做樹形結構呢?咱們用下圖來做爲例子:安全
區域關係前端框架
❝
ps:這裏假設有多棵樹,根節點是亞洲、美洲等
❞數據結構
咱們首先想到的是用parent_id, 這個字段用來存儲「父節點」,根節點的parent_id爲0,這樣就能夠經過遞歸查詢獲得一棵樹。架構
很明顯,若是隻是一個parent_id,咱們若是想得到一棵樹,當這棵樹的深度比較深時,咱們須要查詢不少次數據庫,效率很是低。那有沒有什麼辦法能夠一次性把整棵樹都查出來呢?咱們嘗試加一個root_id,用來表示這棵樹的根節點。
id parent_id root_id name 1 0 0 亞洲 2 0 0 美洲 3 1 1 中國 4 1 1 日本 5 1 1 韓國 6 3 1 四川省
那麼問題來了,若是是想查詢某一個節點的子樹,該怎麼作呢?是否是以爲不太方便?下面咱們推薦另一種表示方式:「full id path」,每一個節點記錄下從根節點到本身的id路徑,以下:
id full_id_path name 1 /1 亞洲 2 /2 美洲 3 /1/3 中國 4 /1/4 日本 5 /1/5 韓國 6 /1/3/6 四川省 7 /1/3/6/7 成都市
這樣若是咱們想查詢一棵樹,只須要使用like語句前綴匹配就好了。好比想查詢「四川省」下面有哪些市:
SELECT * FROM table WHERE full_id_path like "/1/3/6%";
若是是更新了一棵樹中節點的關係,只須要維護好這個節點及其子節點的full_id_path字段就好了。通常來說,這種full id path設計可以知足絕大多數樹形結構的業務要求。
但若是你的id是「UUID」類型的,若是深度比較高,那full_id_path字段就會比較長,且並不易讀。這個時候咱們建議使用一個惟一的,有業務意義的code來表示路徑,字段名改成叫full_path。好比要表示成都市:
'/Asian/China/SiChuan/Chengdu'
數據庫的設計仍是要根據業務來,沒有絕對的銀彈。有時候,咱們會加上level字段表示每一個節點在樹中的層級位置,用於「在應用代碼層面更方便、高效地拼接樹」。
接下來咱們介紹在代碼層面,如何去優雅地使用樹形結構。其實前輩們已經爲咱們總結出了一種很是優秀的設計模式——組合模式,又稱爲「部分總體模式」,專門針對樹形結構。
組合模式的精髓在於,你不用把一棵樹「樹」的概念和一個單獨的「節點」分開處理,而是都視爲同一種對象來處理。下面咱們依然以上面的區域關係爲例,來介紹組合模式如何使用。
首先咱們來看一個經典的組合模式中的三個概念:
下面上Java代碼實現:
/** * 組合模式抽象接口 */ public interface LocationComponent { String getPath(); void display(); void add(LocationComponent component); void remove(LocationComponent component); Map<String, LocationComponent> getChildren(); }
/** * 容器對象,表示有孩子的節點 */ public class LocationComposite implements LocationComponent { private Long id; private String name; private String fullPath; private Map<String, LocationComponent> children = new HashMap<>(); @Override public String getPath() { return fullPath; } @Override public void display() { System.out.println(name); } @Override public void add(LocationComponent component) { component.fullPath = this.fullPath + "/" + component.id; children.put(component.getPath(), component); } @Override public void remove(LocationComponent component) { children.remove(component.getPath()); } @Override public Map<String, LocationComponent> getChildren() { return children; } }
/** * 葉子節點 */ public class LocationLeaf implements LocationComponent { private Long id; private String name; private String fullPath; @Override public String getPath() { return fullPath; } @Override public void display() { System.out.println(name); } @Override public void add(LocationComponent component) { throw new UnsupportedOperationException(); } @Override public void remove(LocationComponent component) { throw new UnsupportedOperationException(); } @Override public Map<String, LocationComponent> getChildren() { throw new UnsupportedOperationException(); } }
那麼問題來了,我有必要把節點分紅Leaf和Composite嗎?Leaf也實現Component接口,但拋那麼多UnsupportedOperationException意義何在?我爲何不用同一個對象來表示Composite和Leaf?
其實從這裏咱們就能夠看到,經典的設計模式也不是銀彈。「咱們學設計模式,學的是思想,而不是固定的套路,最終仍是要結合業務」。好比上面的代碼,明顯就不適合咱們的「區域」業務,好比我想在高新區下面再細分「街道」,這個代碼就很難擴展了。
但若是你的業務是作一個「文件系統」,咱們能夠很明顯的知道,文件和文件夾的區別。文件就是一個Leaf,它必然不支持add、remove、getChildren等操做,而文件夾是必須有這些操做的,是一個Composite。這個時候就能夠用上面的代碼設計了。同時,上面的Map也能夠換成List等其它容器類型。
因此咱們要活學活用,針對咱們的區域業務,能夠直接用一個Component來表示:
/** * 區域接口,可擴展成無限深度 */ public class AreaComponent { private Long id; private String name; private String fullPath; private Map<String, AreaComponent> children = new HashMap<>(); public String getPath() { return fullPath; } public void display() { System.out.println(name); } public void add(AreaComponent component) { component.fullPath = this.fullPath + "/" + component.id; children.put(component.getPath(), component); } public void remove(AreaComponent component) { children.remove(component.getPath()); } public Map<String, AreaComponent> getChildren() { return children; } }
做爲一個有志向的全棧工程師,固然不能只知足於數據庫和後端層面。前端組件代碼也要本身上手~
相信如今的前端小夥伴們都應該熟悉一種或多種使人聞風喪膽的「三大」前端主流框架。如今的前端框架都推薦「組件化」開發,把頁面分紅一個一個小的組件。很明顯,組件層層嵌套,其實本質上也是一個樹的形式,最終也會渲染出一個DOM樹對象。
組件樹
咱們以Vue爲例,對於上文提到的區域劃分業務,若是後端返回的是一條條帶有full_path的扁平數據,前端應該如何優雅地構建基於業務的樹形結構呢?答案就是使用「遞歸組件」。
遞歸組件,簡單來講就是「在組件中內使用組件自己」, 對於Vue來講,其核心就在於使用name字段。效果大概是這樣:
請忽略個人審美
仍是按照慣例,上代碼。先來一個表示「區域」的組件:
<template> <div class="node" :style="{paddingLeft: self.level * 20 + 'px'}"> <p>{{self.name}}</p> <div v-for="child in children" :key="child.id"> <Area v-if="`children.length != 0`" :self="child" :all="all"/> </div> </div> </template> <script> export default { name: 'Area', props: { self: Object, all: Array }, computed: { children() { // 根據full_path和level,過濾出子組件 return this.all.filter(item => item.full_path.startsWith(this.self.full_path) && item.level == this.self.level + 1); } } } </script>
入口:
<template> <div id="app"> <Area :self="all[0]" :all="all" /> </div> </template> <script> import Area from './components/Area.vue' export default { name: 'App', components: { Area }, data() { return { // 全部數據 all: [ { id: 1, level: 1, full_path: '/1', name: '亞洲' }, { id: 2, level: 1, full_path: '/2', name: '美洲' }, { id: 3, level: 2, full_path: '/1/3', name: '中國' }, { id: 4, level: 2, full_path: '/1/4', name: '日本' }, { id: 5, level: 3, full_path: '/1/3/5', name: '四川省' }, { id: 6, level: 3, full_path: '/1/3/6', name: '浙江省' }, { id: 7, level: 4, full_path: '/1/3/5/7', name: '成都市' } ] } } } </script>
固然了,這只是其中一種寫法,你也能夠在外面組裝好一個帶children的對象傳進去。
又到了咱們一天一度的先後端撕逼環節。做爲一個僞裝是全棧的後端同窗來講,筆者認爲針對這個問題,我有必要說一句公道話:在前端組裝比較好。
衆所周知,在當今前端愈來愈繁榮的大環境下,前端承擔着愈來愈重要的角色,有不少數據的計算也會放在前端。針對於這種樹狀結構的拼裝來講,放在先後端其實均可以的。可是放在前端有一個好處,那就是「能夠將計算消耗的時間和資源從服務端轉移到客戶端」。
如今的後端架構也愈來愈傾向於讀寫分離,因此在讀的時候,多半不會進行太多的操做,不須要組裝整棵樹。這種狀況下,建議直接把數據返回前端,由前端來組裝成整棵樹。
固然,這只是一個建議,具體在什麼時機組裝,仍是由業務規則以及團隊商量決定~
好了,以上就是從數據庫到前端的樹形結構實現,有任何問題歡迎留言討論~
都看到這裏了,說明你承認個人文章。
若是對你有幫助,不妨支持我一下:
你的一個小小的閱讀、關注、留言、轉發、在看,
都是我寫做路上最大的鼓勵。
猛戳下面那個二維碼關注: