高仿頭條-廣告系統中的級聯皮膚元件

juenanfeng發表於2019-05-01

公司最近的設計中用的很多的一個元件,大體是參考的頭條-廣告系統中的級聯皮膚。在此簡單記錄一下元件的設計和開發心得。

頭條的效果

需求分析

根據效果圖,首先需要把省市區的資料按列展示在左側區域,點選父級節點聯動展示子級資料,每次點選展開節點的下一級所在的列。
勾選父級節點,子級節點全選,反之全選子級節點,父節點變為勾選狀態。每次進行勾選之後,右側皮膚展示勾選結果。
這裡有一個細節,就是右側皮膚展示的選擇結果不是簡單的展示每一個被勾選中的節點。而是依據當某一個節點下的子節點全部被選中,則只展示父節點的原則,進行展示。我把它簡單稱為級聯資料的壓縮原則。
經過上面的分析可以發現這個元件本質上是一個扁平化了的checkbox-tree元件。之前關於類似省市區這種帶有級聯關係的資料選擇,傳統的互動設計往往就是採用的checkbox-tree。

開發這個元件之前,已經有過重構老專案中的checkbox-tree的經驗,參考的是element-ui的tree元件,學習了很多關於依賴於樹形結構的元件構建技巧。元件借鑑了element-ui和iview的tree元件,在此由衷感覺這些開源專案。 有了上述的分析,我們來正式開擼程式碼。因為是使用vue作為技術棧。第一步,也是最關鍵的一步,就是定義好元件的props和data。

props和data

 props: {
    data: {//展示資料
      type: Array,
      required: true
    },
    props: {//資料中的key和label欄位別名,
              因為外部資料(例如後端返回的樹形結構)中標誌label和key的欄位往往不是固定的
      type: Object,
      default() {
        return {
          key: "id",
          label: "label"
        };
      }
    },
    settings: {
    /*配置,允許自定義每一級,eg:[
        {
          level: 1,//列的級別,因為元件內部有一個虛擬的根節點,所以level從1開始
          title: "一級分類",//列的標題
          hasAllCheck: true,//是否展示全選checkbox
          showCheckBox: true//是否帶有checkbox
        },
        {
          level: 2,
          title: "二級分類",
          hasAllCheck: true,
          showCheckBox: true
        }
    ]*/
      type: Array,
      required: true
    },
    checkedLevel: {//資料展示的級別
      type: Number,
      required: true
    },
    zipLevel: {//資料壓縮的級別
      type: Number,
      required: true
    },
    isSingle: {//是否是單選模式,如果為true,則降級為一個級聯選擇器
      type: Boolean,
      default: false
    }
  }
data() {
    return {
      rootNode: null,//元件內部使用的樹形資料結構。採用Node型別對data進行包裝得到的樹的根節點
      flattenData: [],//扁平化後的資料,方便查詢任意節點
      curShowList: [],//控制當前皮膚的展開收起狀態
      checkedData: []//勾選中的資料
    };
  }
  
複製程式碼

Node資料型別

元件內部使用了Node 型別的物件來包裝使用者傳入的資料,用來儲存目前節點的狀態。關於Node型別的具體包裝過程這裡就不再贅述,需要的話可以看原始碼或者搜尋相關資料結構的介紹。這裡僅對比一下使用者傳入的data和組建內部的Node。
使用者傳入的:

[
      {
        id: 1,
        label: "一級 1",
        children: [
          {
            id: 4,
            label: "二級 1-1",
            children: [
              {
                id: 9,
                label: "三級 1-1-1"
              },
              {
                id: 10,
                label: "三級 1-1-2"
              }
            ]
          }
        ]
      },
      {
        id: 2,
        label: "一級 2",
        children: [
          {
            id: 5,
            label: "二級 2-1"
          },
          {
            id: 6,
            label: "二級 2-2"
          }
        ]
      },
      {
        id: 3,
        label: "一級 3",
        children: [
          {
            id: 7,
            label: "二級 3-1"
          },
          {
            id: 8,
            label: "二級 3-2",
            children: [
              {
                id: 11,
                label: "三級 3-2-1"
              },
              {
                id: 12,
                label: "三級 3-2-2"
              },
              {
                id: 13,
                label: "三級 3-2-3"
              }
            ]
          }
        ]
      }
    ]
複製程式碼

元件內部包裝過的rootNode

高仿頭條-廣告系統中的級聯皮膚元件
Node型別的資料新增了checked(是否勾選),disabled(是否禁用),id(元件內部自增的唯一標識),level(節點深度),selected(是否選中),text(節點的文字)。提前定義好這些屬性才能夠讓我們的元件變成響應式的(這些屬性使用者可以在初始化的時候選擇傳入也可以不傳)。
同時具有parent和childNodes來進行任意方向上的查詢。

有了根節點之後,通過查詢childNodes並且遞迴就能夠構建出級聯皮膚的template。

節點的聯動

 handleCheck(isCheck, id, immediate = true/*是否立即進行資料壓縮 */) {
   const checkedLevel = this.checkedLevel;
   //勾選當前級別及子級
   const selectNode = this.flattenData.find(item => item.id === id);
   if (!selectNode) {
     return;
   }
   //遞迴
   //由父到子
   function setCheck(node) {
     node.checked = isCheck;
     if (!node.childNodes.length && node.level < checkedLevel) {
       node.noChildChecked = isCheck;
     }
     if (!Array.isArray(node.childNodes) || !node.childNodes.length) {
       return;
     }
     node.childNodes.forEach(node => {
       setCheck(node);
     });
   }
   //由子到父
   function setParentCheck(parent) {
     if (!parent || !parent.parent) {
       return;
     }
     parent.checked = parent.childNodes.every(child => {
       return child.checked === true;
     });
     setParentCheck(parent.parent);
   }

   setCheck(selectNode);
   setParentCheck(selectNode.parent);
   if (immediate) {
     this.getCheckedData();
   }
 }
複製程式碼

節點的聯動首先通過扁平化資料查詢到當前節點。再對該節點進行由父到子和由子到父兩個方向的checked設定。

這裡因為涉及到全選或者批量設定節點的勾選狀態,所以有一個引數標誌是否立即呼叫資料壓縮的方法。

列的展開

列的展開通過節點的select來觸發,包括勾選和點選事件。

 handleSelect(id) {
   //單選
   const selectNode = this.flattenData.find(item => item.id === id);
   selectNode.parent.childNodes.forEach(node => (node.selected = false));
   selectNode.selected = true;

   //下一級展示出來,更深的層級不渲染
   if (selectNode.level < this.maxLevel) {
     this.curShowList[selectNode.level] = !!selectNode.childNodes.length;
     for (let i = selectNode.level + 1; i < this.maxLevel; i++) {
       this.curShowList[i] = false;
     }
   }
   //單選模式,邏輯變為類似級聯選擇器,選擇非最深層次的節點直接清空當前節點下所有的checked結果,視為重新選擇
   if (this.isSingle && selectNode.level !== this.maxLevel) {
     this.flattenData.forEach(p => (p.checked = false));
     this.getCheckedData();
   }
 }
複製程式碼

列的展開通過控制curShowList陣列,陣列的每一項的true or false對應每一列的展開或者收起。這裡額外提供一個isSingle的props可以把元件降級為級聯選擇器,滿足只能單選的情況。

節點選擇後的資料壓縮

 getCheckedData() {
      const result = [];
      const toZipData = this.flattenData.filter(p => p.level === this.zipLevel);
      function step(nodes) {
        if (!nodes || !nodes.length) {
          return;
        }
        const curSelectData = nodes.filter(p => p.checked || p.noChildChecked);
        const noSelectData = nodes.filter(
          p => !(p.checked || p.noChildChecked)
        );
        result.push(...curSelectData);
        noSelectData.forEach(p => step(p.childNodes));
      }
      step(toZipData);
      this.checkedData = result;
}
複製程式碼

首先通過扁平化的陣列過濾出目標壓縮級別的資料,直接把其中選中的資料push到結果中,只把沒有勾選的資料當作下一次遞迴過程的目標資料,遞迴出口是節點不存在或者沒有子節點。

方法

  setCheckedNodes(keys) {//設定節點的選中,可用於搜尋
      /* public API */
      keys.forEach(key => {
        this.setCheckedNode(key, false);
      });
      this.getCheckedData();
    },
    getCheckedNodes(isZip = true) {//獲取選中的資料
      /* public API */
      if (isZip) {
        return this.checkedData.map(item => {
          return {
            id: item.id,
            text: item.text,
            data: item.data,
            level: item.level
          };
        });
      } else {
        return this.flattenData.filter(p => p.checked || p.noChildChecked);
      }
    }
複製程式碼

擴充套件方向

  1. 元件需要支援懶載入,滿足資料量大的情況。
  2. 元件沒有提供插槽,例如右側皮膚資料展示的定製化,左側列的標題等。

結語

元件相對還只是提供了基礎的功能,有待完善。如有錯漏,歡迎指正!希望能讓大家有點收穫。下面是專案的github地址 github.com/juenanfeng/…

相關文章