前端工程师完全征服树结构组件的秘籍

前言

树形组件的需求,不少人遇到都以为头疼、逻辑复杂,除了展现以外,还要有增删该查的逻辑。通常树形组件具备多个层级,若是当前层级有下一个层级,会有像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哪一个更优

只要咱们按照这样的套路,若是再来树结构相关需求,那么,来一个秒一个,毫无压力

关注公众号《不同的前端》,以不同的视角学习前端,快速成长,一块儿把玩最新的技术、探索各类黑科技

相关文章
相关标签/搜索