前端工程師完全征服樹結構組件的祕籍

前言

樹形組件的需求,不少人遇到都以爲頭疼、邏輯複雜,除了展現以外,還要有增刪該查的邏輯。通常樹形組件具備多個層級,若是當前層級有下一個層級,會有像children、list等屬性,數據結構通常就是javascript

const tree = [
    {
        name: 'a',
        id: 1,
    },
    {
        name: 'b',
        id: 2,
        children: [
            {
                name: 'c',
                id: 3
            }
        ]
    },
]
複製代碼

界面大概就是這種:前端

這裏先給出下文數據源:java

const data = [{"name":"廣東","id":1,"children":[{"name":"深圳","id":2,"children":[{"name":"南山區","id":3},{"name":"福田區","id":4},{"name":"寶安區","id":5}]},{"name":"廣州","id":6,"children":[{"name":"天河區","id":7},{"name":"番禺區","id":8},{"name":"海珠區","id":9}]}]}]
複製代碼

遞歸渲染與記錄節點信息

遞歸就是最常規的方式了,以antd的tree組件爲例,你們都會這樣作:node

// 放在react的class組件裏面
renderTree = (data = []) => {
  return data.map(item => (
    <TreeNode title={item.name}> {renderTree(item.children)} </TreeNode>
  ))
}

  render() {
    return (
      <React.Fragment> <Tree defaultExpandAll={true} selectable={false}> <TreeNode title="root" > {this.renderTree(this.state.data)} </TreeNode> </Tree> </React.Fragment> ); } 複製代碼

先把名字做爲節點title,而後若是有子節點,就用一樣的方法渲染子節點。react

組件已經好了,若是咱們要點擊,咱們怎麼知道哪一個層級的哪一個節點被點了呢?是否是會寫一個搜索算法,傳入當前節點id,而後回溯去記錄路徑展現出來?這雖然能夠作到,但顯然是不優雅的,咱們只須要犧牲空間換時間的方法就能夠大大優化這個過程,便是在遍歷的過程當中把節點信息帶到下一個遞歸函數裏面去算法

renderTree = (data = [], info = { path: '', id: '' }) => {
    return data.map(item => (
      <TreeNode title={ <Button onClick={() => console.log(`${info.path}/${item.name}`)}>{item.name}</Button> }> {this.renderTree(item.children, { path: `${info.path}/${item.name}`, id: `${info.id}/${item.id}` })} </TreeNode>
    ));
}
複製代碼

如今,咱們點擊哪個,就打印當前節點路徑了數組

增刪改查操做

若是遇到了增刪改查,基於前面的條件,咱們記錄了要用到的信息,因此能夠藉助這些信息進行增刪改查。ruby

點擊查看通常的增刪改查規則
  • 增:須要知道父節點id(父.push)
  • 刪:須要知道父節點id和當前節點id(父.splice(子))
  • 改:須要知道父節點id和當前節點id(父.子 = newVal)
  • 查:須要知道父節點id((父) => 父.全部子)

後臺通常是id,對前端通常是keybash

咱們刪掉剛剛的按鈕,把id去掉(由於咱們如今僅僅用前端測試,只用key便可,若是須要傳到後臺,則須要遵照上面的規則傳id),而後用一樣的方法記錄每一層的keyantd

renderTree = (data = [], info = { path: '', key: '' }) => {
    return data.map((item, index) => (
      <TreeNode title={ <React.Fragment> {item.name} <Button onClick={() => { console.log(`${info.key}.${index}`.slice(1)) }}>新增節點</Button> </React.Fragment> }> {this.renderTree(item.children, { path: `${info.path}/${item.name}`, key: `${info.key}.${index}` })} </TreeNode> )); } 複製代碼

此時,咱們點擊天河區,打印出來的是0.1.0,也就是咱們所點的是data[0].children[1].children[0],要給data[0].children[1].children[0]的children push一個新元素。因此咱們還要寫一個相似lodash.get的方法:

function get(target, keysStr) {
    const keys = keysStr.split('.')
    let res = target[keys.shift()]
    while (res && keys.length) {
        res = res.children[keys.shift()]
    }
    return res
}
複製代碼

Button裏面的onclik方法改一下:

<Button onClick={() => {
    const currentKeyPath = `${info.key}.${index}`.slice(1)
    this.setState(({ data }) => {
      const current = get(data, currentKeyPath) // 拿到當前節點
      // 給children屬性追加一個新節點
      ;(current.children || (current.children = [])).push({ name: '新增的節點' })
      return data
    })
  }}>新增節點</Button>
複製代碼

新增了一個奇奇怪怪的節點,巴不得立刻 更名刪除了,刪除須要知道父節點key和當前節點key,咱們仍是繼續在title那裏加一個按鈕:

<Button onClick={() => {
    const currentKeyPath = `${info.key}`.slice(1) // 父節點key路徑
    this.setState(({ data }) => {
      const current = get(data, currentKeyPath)
      current.children.splice(index, 1) // 刪除當前節點第index個元素
      return data
    })
  }}>刪除節點</Button>
複製代碼

咱們新增的了節點後,首先就是把系統默認名字改掉,改和刪都是差很少的,可是改須要維護一個輸入框來填寫新節點名字。常規的方法是另外控制一個Modal組件,這個Modal裏面有一個Input。點擊肯定便可修改。爲了更好的體驗,我一般是直接行內修改。先寫一個Edit組件,這個組件正常狀況下是一個按鈕,點擊了變成一個Input,失去焦點的時候修改完成

function Edit(props) {
  const [value, setValue] = React.useState(props.value)
  const [isEdit, setIsEdit] = React.useState(false)
  const handleChange = React.useCallback((e) => {
    setValue(e.target.value)
  }, [setValue])
  const handleBlur = React.useCallback((e) => {
    const current = get(props.target, props.currentKeyPath)
    current.name = value // 給當前節點的name賦值
    props.setState(current) // 上層的setstate方法
    setIsEdit(false)
  }, [setValue, value])
  return (
    isEdit ?
    <Input autoFocus={true} value={value} onChange={handleChange} onBlur={handleBlur} /> : <Button onClick={() => setIsEdit(true)}>修改節點</Button> ) } 複製代碼

有了Edit組件,咱們在title的元素裏面加上Edit組件:

<Edit
    target={this.state.data}
    value={item.value}
    currentKeyPath={`${info.key}.${index}`.slice(1)}
    setState={(state) => this.setState(state)}
  />
複製代碼
點擊查看以上所有代碼
import { Input, Tree, Button } from 'antd';
import * as React from 'react';

const { TreeNode } = Tree;

function get(target, keysStr) {
  const keys = keysStr.split('.')
  let res = target[keys.shift()]
  while (res && keys.length) {
    res = res.children[keys.shift()]
  }
  return res
}

function Edit(props) {
  const [value, setValue] = React.useState(props.value)
  const [isEdit, setIsEdit] = React.useState(false)
  const handleChange = React.useCallback((e) => {
    setValue(e.target.value)
  }, [setValue])
  const handleBlur = React.useCallback((e) => {
    const currnet = get(props.target, props.currentKeyPath)
    console.log(props.target,  currnet, props.currentKeyPath)
    currnet.name = value
    props.setState(currnet)
    setIsEdit(false)
  }, [setValue, value])
  return (
    isEdit ?
    <Input
      autoFocus={true}
      value={value}
      onChange={handleChange}
      onBlur={handleBlur}
    /> :
    <Button onClick={() => setIsEdit(true)}>修改節點</Button>
  )
}

const data = [
  { name: '廣東', id: 1, children: [
    { name: '深圳', id: 2, children: [
      { name: '南山區', id: 3 },
      { name: '福田區', id: 4 },
      { name: '寶安區', id: 5 },
    ] },
    {
      name: '廣州',
      id: 6,
      children: [
        { name: '天河區', id: 7 },
        { name: '番禺區', id: 8 },
        { name: '海珠區', id: 9 },
      ]
    }
  ] }
];

export default class Test extends React.Component {
  state = {
    data,
  };
  render() {
    return (
      <React.Fragment>
        <Tree defaultExpandAll={true} selectable={false}>
          <TreeNode
            title="root"
          >
            {this.renderTree(this.state.data)}
          </TreeNode>
        </Tree>
      </React.Fragment>
    );
  }

 renderTree = (data = [], info = { path: '', key: '' }) => {
    return data.map((item, index) => (
      <TreeNode title={
        <React.Fragment>
          {item.name}
          <Button onClick={() => {
            const currentKeyPath = `${info.key}.${index}`.slice(1)
            this.setState(({ data }) => {
              const current = get(data, currentKeyPath)
              ;(current.children || (current.children = [])).push({ name: '新增的節點' })
              return data
            })
          }}>新增節點</Button>
          <Button onClick={() => {
            const currentKeyPath = `${info.key}`.slice(1)
            this.setState(({ data }) => {
              const current = get(data, currentKeyPath)
              current.children.splice(index, 1)
              return data
            })
          }}>刪除節點</Button>
          <Edit
            target={this.state.data}
            value={item.value}
            currentKeyPath={`${info.key}.${index}`.slice(1)}
            setState={(state) => this.setState(state)}
          />
        </React.Fragment>
      }>
        {this.renderTree(item.children, { path: `${info.path}/${item.name}`, key: `${info.key}.${index}` })}
      </TreeNode>
    ));
  }
}
複製代碼

搜索

不必定全部的場景都是空間換時間,只要不是頻繁操做樹結構的,只須要少許的搜索便可。樹搜索就兩種,廣度優先搜索(bfs)、深度優先搜索(dfs)

棧和隊列

棧的規律是,先進後出;隊列的規律是,先進先出,在數組上的表現就是:

  • 棧:arr.push(item);arr.pop()
  • 隊列:arr.push(item);arr.shift()

bfs是基於隊列實現,dfs是基於棧(遞歸也算是棧的一種體現)實現

對於文章最前面那個結構

數據源
const data = [
  { name: '廣東', id: 1, children: [
    { name: '深圳', id: 2, children: [
      { name: '南山區', id: 3 },
      { name: '福田區', id: 4 },
      { name: '寶安區', id: 5 },
    ] },
    {
      name: '廣州',
      id: 6,
      children: [
        { name: '天河區', id: 7 },
        { name: '番禺區', id: 8 },
        { name: '海珠區', id: 9 },
      ]
    }
  ] }
];
複製代碼

使用bfs遍歷的順序(下文假設全是從左到右遍歷順序)是:廣東、深圳、廣州、南山區、福田區、寶安區、天河區、番禺區、海珠區;使用dfs的順序是:廣東、深圳、南山區、福田區、寶安區、廣州、天河區、番禺區、海珠區

bfs

以搜索"福田區"爲例

function bfs(target, name) {
  const quene = [...target]
  do {
    const current = quene.shift() // 取出隊列第一個元素
    current.isTravel = true // 標記爲遍歷過
    if (current.children) {
      quene.push(...current.children) // 子元追加到隊列後面
    }
    if (current.name === name) {
      return current
    }
  } while(quene.length)
  return undefined
}
複製代碼

再把renderTree方法裏面的操做取掉,加上遍歷過標紅邏輯,再加上bfs的邏輯:

componentDidMount() {
  bfs(this.state.data, '福田區')
  this.forceUpdate()
}

renderTree = (data = [], info = { path: '', key: '' }) => {
    return data.map((item, index) => (
      <TreeNode title={
        <React.Fragment>
          <span style={{ color: item.isTravel ? '#f00' : '#000' }}>{item.name}</span>
        </React.Fragment>
      }>
        {this.renderTree(item.children, { path: `${info.path}/${item.name}`, key: `${info.key}.${index}` })}
      </TreeNode>
    ));
  }
複製代碼

遍歷過程是:

這種狀況能夠知足的場景:父節點所有disabled,只能對和當前等級的節點進行操做

dfs

以搜索"福田區"爲例。基於前面的bfs,能夠很容易過渡到基於循環實現的dfs

function dfs(target, name) {
  const quene = [...target]
  do {
    const current = quene.pop() // 改爲pop,取最後一個,後入先出
    current.isTravel = true
    if (current.children) {
      quene.push(...[...current.children].reverse()) // 保證從左到右遍歷
    }
    if (current.name === name) {
      return current
    }
  } while(quene.length)
  return undefined
}

// 基於遞歸實現
function dfs(target = [], name) {
  return target.find(x => {
    x.isTravel = true
    const isFind = x.name === name
    return isFind ? x : dfs(x.children, name)
  })
}
複製代碼

遍歷過程是:

這種方案知足的場景是:只能操做該節點的歸屬路徑,好比只能操做廣東和深圳兩個節點其餘節點disabled

自上而下dfs和自下而上dfs

先提一下,二叉樹前中後序遍歷,在代碼上的差異就在於處理語句放在哪一個位置:

function tree(node) {
    if (node) {
        console.log('前序遍歷')
        tree(node.left)
        console.log('中序遍歷')
        tree(node.right)
        console.log('後序遍歷')
    }
}
複製代碼

對於dfs,也是有一樣的道理,咱們先把上面的改一下。以搜索"福田區"爲例

function dfs(target = [], name) {
  return target.find(x => {
    x.isTravel = true
    const isFind = x.name === name
    console.log('自上而下', x)
    const ret = isFind ? x : dfs(x.children, name)
    return ret
  })
}
// => 廣東、深圳、南山區、福田區

// 自下而上
function dfs(target = [], name) {
  return target.find(x => {
    x.isTravel = true
    const isFind = x.name === name
    const ret = isFind ? x : dfs(x.children, name)
    console.log('自下而上', x)
    return ret
  })
}
// => 南山區、福田區、深圳、廣東
複製代碼

大部分場景不須要講究哪一種dfs遍歷方式。若是這個數據結構有不少省,咱們想快速找到廣東省的時候,使用自上而下更容易;若是這個數據結構市下面有不少區,想快速找到屬於哪一個市則使用自下而上更容易

總結

  • 遇到樹結構組件,咱們先使用遞歸渲染
  • 遞歸遍歷的同時,記錄下當前節點信息到節點裏面,把當前節點信息帶到下一個遞歸函數的參數裏面去,供後續的curd操做使用
  • 若是遞歸渲染的時候,不提早記錄節點信息到節點裏面,某些後續的特殊操做就須要使用bfs或者dfs
  • 最後在遍歷同時記錄信息不記錄信息後面使用dfs、bfs之間權衡哪一個方案更優
  • 若是使用dfs,還能夠考慮一下自上而下dfs仍是自下而上dfs哪一個更優

只要咱們按照這樣的套路,若是再來樹結構相關需求,那麼,來一個秒一個,毫無壓力

關注公衆號《不同的前端》,以不同的視角學習前端,快速成長,一塊兒把玩最新的技術、探索各類黑科技

相關文章
相關標籤/搜索