零、資料
elementUI el-tree 源碼,詳情移步官網和 github。
一、引言
手頭需要開發權限結構,首先想起的就是 el-tree,但是最終的表現的樣式和 el-tree 完全不一樣,因此想着先看一看大佬們是怎樣封裝這種復雜類型的組件的,順便復習下樹結構(偽),於是有了本篇的閱讀筆記和代碼片段。
實現功能:節點選擇取消(包括全選、半選)、禁用、異步更新。
二、片段
(一) js 部分
1. Node 節點對象

import { markNodeData, NODE_KEY, objectAssign } from './utils'; // 作為 自定義子節點的 id let nodeIdSeed = 0; // 獲取當前節點中子節點的狀態 export const getChildState = node => { let all = true; let none = true; let allWithoutDisable = true; for (let i = 0, j = node.length; i < j; i++) { const n = node[i]; if (n.checked !== true || n.indeterminate) { all = false; if (!n.disabled) allWithoutDisable = false; } if (n.checked !== false || n.indeterminate) { none = false; } } return { all, none, allWithoutDisable, half: !all && !none }; } // 根據檢索當前節點的狀態並通知父節點 export const reInitChecked = function (node) { if (node.childNodes.length === 0) return; const {all, none, half} = getChildState(node.childNodes); if (all) { node.checked = true; node.indeterminate = false; } else if (half) { node.checked = false; node.indeterminate = true; } else if (none) { node.checked = false; node.indeterminate = false; } const parent = node.parent; if (!parent || parent.level === 0) return; if (!node.store.checkStrictly) { reInitChecked(parent); } } // 根據 store.props 處理傳入的 this.data 與 eltree 中固有 key 的關系 const getPropertyFromData = function(node, prop) { // 初始化 store 時傳入的 props const props = node.store.props; const data = node.data || {}; const config = props[prop]; // 用戶在 data 中自定義的 key if (typeof config === 'function') { return config(data, node); } else if (typeof config === 'string') { return data[config]; } else if (typeof config === 'undefined') { const dataProp = data[prop]; return dataProp === undefined ? '' : dataProp; } } class Node { constructor(options) { // 注意和 this.data 中的 id 區分開來 this.id = nodeIdSeed++; this.text = null; this.checked = false; this.indeterminate = false; // 這個字段 保存 當前節點的數據(不包含父節點的, 父節點的在 this.parent 字段中) this.data = null; // options 也有個, 這個待會會被 options 的覆蓋掉 this.parent = null; this.visible = true; // 估計是為了 root: Node 准備的 this.isCurrent = false; // 把傳入的參數混入到 當前的 Node 對象中去 for (const option in options) { if (options.hasOwnProperty(option)) { this[option] = options[option]; } } // internal 的一些參數 this.level = 0; this.load = false; // 這個估計是為了懶加載准備的 this.loading = false; // 這個估計是為了懶加載准備的 this.childNodes = []; // 標示節點的等級 if (this.parent) this.level = this.parent.level + 1; const store = this.store; if (!store) throw new Error('[Node]store 對象未構建!'); // 在 store.nodesMap 注冊這個節點, 便於后期查找 store.registerNode(this); // const props = store.props; if (store.lazy !== true && this.data) { this.setData(this.data); } if (!Array.isArray(this.data)) { markNodeData(this, this.data); } if (!this.data) return; } /** * @param {*} data 每個相應子節點的 data 數據(用戶傳進來的) * @memberof Node */ setData(data) { if (!Array.isArray(data)) { // 注意不是數組的時候會走這里!!! // 傳遞 this, 主要是取 節點(this) 自定義的 id markNodeData(this, data); } this.data = data; this.childNodes = []; let children; if (this.level === 0 && this.data instanceof Array) { children = this.data } else { children = getPropertyFromData(this, 'children') || []; } // 循環把 this.data 中的 children 數據也變成 Node 節點 for (let i = 0, l = children.length; i < l; i++) { this.insertChild({ data: children[i] }); } } /** * 把當前節點下的 children 轉換成 Node 節點 * @param {*} child * @param {*} index * @param {*} batch ques 存疑,源碼中只有一個地方的調用(doCreateChildren)傳入了true */ insertChild(child, index, batch) { if (!child) throw new Error('[node]子節點插入失敗,必須要傳入所需的數據!'); if (!(child instanceof Node)) { // child 不是我們的 節點類型 if (!batch) { // ques 存疑,源碼中只有一個地方的調用(doCreateChildren)傳入了true const children = this.getChildren(true); if (children.indexOf(child.data) === -1) { // children 數組中找不到 child if (typeof index === 'undefined' || index < 0) { children.push(child.data); } else { children.splice(index, 0, child.data); } } } // 淺合並對象(足夠) objectAssign(child, { parent: this, store: this.store, }); child = new Node(child); } child.level = this.level + 1; if (typeof index === 'undefined' || index < 0) { this.childNodes.push(child); } else { this.childNodes.splice(index, 0, child); } } /** * 獲取 this.data 下面的 children(或開發映射成 children) 字段的 value * 返回值帶扶正處理 * 這里是從 源數據 取的值,而不是 node 節點對象中 - 與 getPropertyFromData 的區別 * @param {boolean} [forceInit=false] * @returns Array * @memberof Node */ getChildren(forceInit = false) { // this is data if (this.level === 0) return this.data; const data = this.data; if (!data) return null; const props = this.store.props; let children = props ? props.children : 'children'; if (data[children] === undefined) data[children] = null; // 強制初始化 && data[children] 為空 if (forceInit && !data[children]) data[children] = []; return data[children]; } /** * 設置 節點的 checked 狀態 * @param {*} value * @param { boolean } deep * @param {*} recursion 遞歸 * @param {*} passValue * @memberof Node */ setChecked(value, deep, recursion, passValue) { this.indeterminate = value === 'half'; this.checked = value === true; if (this.store.checkStrictly) return; // 這個 檢索 子節點 的 checked 狀態 // if (!(this.shouldLoadData() && !this.store.checkDescendants)) { // 這里 shouldLoadData 與 lazy 相關, 結合本例看源碼,shouldLoadData() 一定返回 false if (!(false && !this.store.checkDescendants)) { let { all, allWithoutDisable } = getChildState(this.childNodes); if (!this.isLeaf && (!all && allWithoutDisable)) { this.checked = false; value = false; } const handleDescendants = () => { if (deep) { const childNodes = this.childNodes; for (let i = 0, j = childNodes.length; i < j; i++) { const child = childNodes[i]; passValue = passValue || value !== false; const isCheck = child.disabled ? child.checked : passValue; child.setChecked(isCheck, deep, true, passValue); } const { half, all } = getChildState(childNodes); if (!all) { this.checked = all; this.indeterminate = half; } } }; // if (this.shouldLoadData()) { if (false) { // Only work on lazy load data. so i don't need to write } else { handleDescendants(); } } const parent = this.parent; if (!parent || parent.level === 0) return; // 這里應該會通知父節點自己的狀態 if (!recursion) reInitChecked(parent) } /** * 這個函數的作用是返回 初始化 store 時傳入的 key 字段值 * @readonly * @memberof Node */ get key() { const nodeKey = this.store.key; if (this.data) return this.data[nodeKey]; return null; } /** * 這個函數的作用是返回 當前節點的 label 字段值 * @readonly * @memberof Node */ get label() { return getPropertyFromData(this, 'label'); } /** * 這個函數的作用是返回 當前節點的 disabled 狀態 * @readonly * @memberof Node */ get disabled() { return getPropertyFromData(this, 'disabled'); } } export default Node;
2. Store 狀態樹對象以及整個樹系統的入口(全局只會產生一個該對象)

import Node from './Node'; class Store { constructor(options) { this.currentNode = null; this.currentNodeKey = null; // 把傳入的參數混入到 store 對象中去 for (let option in options) { if (options.hasOwnProperty(option)) { this[option] = options[option]; } } // 方便查詢所有的子節點 this.nodesMap = {} this.root = new Node({ data: this.data, store: this, }); if (this.lazy && this.load) { // 本例中沒有,所以不寫了 } else { this._initDefaultCheckedNodes(); } } /** * 如其名,在 this.nodesMap 注冊這個節點, 便於后期查找 * @param { Node } node */ registerNode(node) { // this.key, 初始化 store 對象時傳入的 參數,string const key = this.key; if (!key || !node || !node.data) return; // node.key, 會調用 Node 中的 get key 方法 const nodeKey = node.key; if (nodeKey !== undefined) this.nodesMap[node.key] = node; } // 初始化默認選中的節點們 _initDefaultCheckedNodes() { const defaultCheckedKeys = this.defaultCheckedKeys || []; const nodesMap = this.nodesMap; defaultCheckedKeys.forEach(checkedKey => { const node = nodesMap[checkedKey]; if (node) { node.setChecked(true, !this.checkStrictly); } }); } /** * 獲取選中的節點的 keys (不包括半選狀態下的) * @param {boolean} [leafOnly=false] 跟懶加載有關,本例用不到 * @returns {Array} * @memberof Store */ getCheckedKeys(leafOnly = false) { return this.getCheckedNodes(leafOnly).map(data => (data || {})[this.key]); } /** * 獲取選中的節點 * @param {boolean} [leafOnly=false] 跟懶加載有關,本例用不到 * @param {boolean} [includeHalfChecked=false] 需要包含 半選 的節點 * @returns {Array[Node]} * @memberof Store */ getCheckedNodes(leafOnly = false, includeHalfChecked = false) { const checkedNodes = []; const traverse = function (node) { const childNodes = node.root ? node.root.childNodes : node.childNodes; childNodes.forEach(child => { if ((child.checked || (includeHalfChecked && child.indeterminate)) && (!leafOnly || (leafOnly && child.isLeaf))) { checkedNodes.push(child.data); } traverse(child); }); }; traverse(this); return checkedNodes; } /** * 獲取 半選擇 狀態下的節點的 keys * @param {boolean} [leafOnly=false] 跟懶加載有關,本例用不到 * @returns {Array[]} * @memberof Store */ getHalfCheckedKeys(leafOnly = false) { return this.getHalfCheckedNodes(leafOnly).map((data) => (data || {})[this.key]); } /** * 獲取 半選擇 狀態下的節點 * @returns {Array[Node]} * @memberof Store */ getHalfCheckedNodes() { const nodes = []; const traverse = function (node) { const childNodes = node.root ? node.root.childNodes : node.childNodes; childNodes.forEach(child => { if (child.indeterminate) { nodes.push(child.data); } traverse(child); }); }; traverse(this); return nodes; } /** * 設置默認選中的節點 * @param {Array} newValue * @memberof Store */ setDefaultCheckedKey(newValue) { if (newValue !== this.defaultCheckedKeys) { this.defaultCheckedKeys = newValue; this._initDefaultCheckedNodes(); } } /** * 異步數據的更新 * @memberof Store */ setData(newVal) { const instanceChanged = newVal !== this.root.data; if (instanceChanged) { this.root.setData(newVal); this._initDefaultCheckedNodes(); } } } export default Store;
3. utils.js

export const NODE_KEY = '$treeNodeId'; // 給對象新增個屬性 $treeNodeId export const markNodeData = function(node, data) { if (!data || data[NODE_KEY]) return; Object.defineProperty(data, NODE_KEY, { value: node.id, enumerable: false, configurable: false, writable: false, }); } // merge object export const objectAssign = function(target) { for (let i = 1, j = arguments.length; i < j; i++) { let source = arguments[i] || {}; for (let prop in source) { if (source.hasOwnProperty(prop)) { let value = source[prop]; if (value !== undefined) { target[prop] = value; } } } } return target; }; export const getNodeKey = function(key, data) { if (!key) return data[NODE_KEY]; return data[key]; }
(二) 組件部分
1. 自定 CheckBox.vue

<template> <div class="checkbox-container"> <el-checkbox v-model="node.checked" :indeterminate="node.indeterminate" :disabled="!!node.disabled" @click.native.stop @change="handleCheckChange" >{{node.label}}</el-checkbox> </div> </template> <script> export default { name: 'yourCheckBoxName', props: { node: { props: Object, default() { return {} } }, }, data() { return { tree: null, // vue component } }, created() { const parent = this.$parent; if (parent.isTreeTable) { this.tree = parent; } else { this.tree = parent.tree; } }, methods: { handleCheckChange(value, ev) { this.node.setChecked(ev.target.checked, !this.tree.checkStrictly); }, } } </script>
2. 外殼組件核心內容

<script> import TableCheckbox from './ckeckbox'; import Store from './utils/store'; import { getNodeKey } from './utils/utils' export default { name: 'TreeTable', components: {TableCheckbox}, props: { data: { type: Array, }, nodeKey: String, props: { default() { return { children: 'children', label: 'label', disabled: 'disabled' }; } }, showCheckbox: { type: Boolean, default: true }, defaultCheckedKeys: Array, }, data() { return { store: null, root: null, // store 上的一個屬性, 這個對象就是我們的 Node 樹系統 } }, watch: { defaultCheckedKeys(newValue) { this.store.setDefaultCheckedKey(newValue); }, data(newVal) { this.store.setData(newVal); }, }, created() { this.isTreeTable = true; this.store = new Store({ key: this.nodeKey, data: this.data, lazy: false, props: this.props, checkStrictly: false, checkDescendants: false, defaultCheckedKeys: this.defaultCheckedKeys, }); this.root = this.store.root; }, methods: { getNodeKey(node) { return getNodeKey(this.nodeKey, node.data); }, getCheckedKeys(leafOnly) { return this.store.getCheckedKeys(leafOnly); }, getHalfCheckedKeys() { return this.store.getHalfCheckedKeys(); }, }, } </script>
三、思路和感悟
體會了數據與視圖分離的思想。
代碼大致的執行先后順序: 外殼組件 created => 初始化並生成 Store (狀態)樹(唯一) => 初始化並遞歸生成 Node 樹(按照數據結構形成多個 Node 對象) => 自定義的 Checkbox 組件與節點樹一一對應(渲染) => ...
核心方法是 Node 中的自定義的 setChecked, 半核心方法 Checkbox.vue 中的 handleCheckChange,需要注意的是,由於在 Checkbox 中 el-checkbox 組件與對應的 Node 節點中的checked 的值是存在映射關系的,所以如果我們在 setChecked 方法首行打印該 Node 對象會發現其狀態值已經改變,而我們自定的 setChecked 方法會根據其他條件進行判斷和第二次修正,同理,handleCheckChange 也是對 Node 狀態的第二次修正。
比較精彩的是子節點的狀態經過 setChecked 修正后與父組件的狀態變更,這里並沒有直接調用父節點的 setChecked 方法(否則會形成死循環),而是通過 reInitChecked(parent) 方法,通知父節點,讓父節點循環檢測下其下子節點的狀態(並不需要去檢測孫節點),並直接修改自己的 checked 字段值,接着,由父節點再遞歸往上通知, 從而完成整個狀態值改變邏輯。
目前的片段已基本滿足需求,因此后續的高級功能抽空(並不)再研究。