不试着自己封装一个element-UI吗?vue3+ts+tsx+vite封装一个树ui组件(element-ui,ant等相同效果)

树形结构相信大家在日常生活中都见过,它的特点是一层一层嵌套,比如文件系统。

以下是vite的源码目录结构(部分):

/vite
├── docs
├── packages
|  └── vite
|     ├── CHANGELOG.md
|     ├── LICENSE.md
|     ├── README.md
|     ├── api-extractor.json
|     ├── bin
|     |  ├── openChrome.applescript
|     |  └── vite.js
|     ├── client.d.ts
|     ├── package.json
|     ├── rollup.config.js
|     ├── scripts
|     |  └── patchTypes.ts
|     ├── src
|     |  └── client
|     |  |  ├── client.ts
|     |  |  ├── env.ts
|     |  |  ├── overlay.ts
|     |  |  └── tsconfig.json
|     ├── tsconfig.base.json
|     └── types
├── scripts
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── README.md
├── jest.config.ts
├── package.json
└── pnpm-workspace.yaml

1 组件需求

要实现的基础树组件效果如下:
在这里插入图片描述

主要包含以下功能:

  1. 渲染嵌套树形结构
  2. 节点连接线
  3. 节点展开 / 收起
  4. 节点勾选
  5. 点击选择
  6. 自定义图标
  7. 默认状态
  8. 节点禁用
  9. 增删改操作
  10. 虚拟滚动(1s内渲染10万树节点)

2 树形结构的表示

由于Tree组件比较复杂,为了实现它的功能,首先要做的就是设计好它的数据结构。

interface ITreeNode {
  label: string;
  id?: string;
  children?: ITreeNode[];

  selected?: boolean; // 点击选中
  checked?: boolean; // 勾选
  expanded?: boolean; // 展开

  disableSelect?: boolean;
  disableCheck?: boolean;
  disableToggle?: boolean;
}

比如vite的源码目录结构,用ITreeNode结构表示就是:

[
  {
    label: 'docs',
    children: [...]
  },
  {
    label: 'packages',
    expanded: true,
    children: [
      ...,
      {
        label: 'vite',
        expanded: true,
        children: [
          ...,
          {
            label: 'README.md'
          },
        ]
      },
    ]
  },
  {
    label: 'scripts',
    children: [...]
  },
  {
    label: 'pnpm-workspace.yaml',
  },
  ...
]

这是一个嵌套结构,需要通过递归的方式来操作,很不方便,而且也很难使用虚拟滚动做性能优化。

所以需要设计一个扁平的内部数据结构,不妨就叫IInnerTreeNode

interface IInnerTreeNode extends ITreeNode {
  parentId?: string; // 父节点ID
  level: number;     // 节点层级
  isLeaf?: boolean;  // 是否叶子结点
}

内部数据结构在ITreeNode的基础上增加了以下字段:

  • parentId:由于这是一个扁平的数据结构,没有嵌套,只有一层,因此为了表达父子关系,需要给节点增加一个parentId,指向父节点
  • level:为了方便地知道当前节点所在层级,并在UI上通过缩进方式体现,需要增加level层级信息
  • isLeaf:叶子结点比较特殊,它没有孩子节点,也需要标识出来

vite的源码目录结构,用扁平结构表示如下(部分):

[
  {
    label: 'docs',
    id: 'node-1',
    level: 1,
  },
  {
    label: 'vite',
    id: 'node-2-1',
    parentId: 'node-2',
    expanded: true,
    level: 2,
  },
  {
    label: 'pnpm-workspace.yaml',
    id: 'node-4',
    level: 1,
    isLeaf: true,
  },
  ...
]

我们编写一个最简单的数据测试一下,docs/tree/index.md

# 树🌲

:::demo Tree组件基本用法,传入
  ```vue
    <template>
      <STree :data="data"></STree>
    </template>
    <script setup>
      const data = [
      {
        label: 'docs',
        id: 'node-1',
        level: 1,
      },
      {
        label: 'packages',
        id: 'node-2',
        expanded: true,
        level: 1,
      },
      {
        label: 'vite',
        id: 'node-2-1',
        parentId: 'node-2',
        expanded: true,
        level: 2,
      },
      {
        label: 'README.md',
        id: 'node-2-1-1',
        parentId: 'node-2-1',
        isLeaf: true,
        level: 3,
      },
      {
        label: 'scripts',
        id: 'node-3',
        level: 1,
      },
      {
        label: 'pnpm-workspace.yaml',
        id: 'node-4',
        level: 1,
        isLeaf: true,
      },
    ]
    </script>
    ```
  :::

文档中注册菜单,docs/.vitepress/config.ts

{
  text: '数据展示',
  items: [{ text: 'Tree 树', link: '/components/tree/' }]
},

注册Tree组件,scripts/entry.ts

import TreePlugin, { Tree } from '../src/tree'

export { Tree }
const installs = [ TreePlugin ]

下面先简单渲染这个数据,tree.tsx

export default defineComponent({
  name: 'STree',
  props: treeProps,
  setup(props: TreeProps) {
    // 获取data
    const { data: innerData } = toRefs(props)
    return () => {
      return <div class="s-tree">{
        // 循环输出节点
        innerData.value.map(treeNode => treeNode.label)
      }</div>
    }
  }
})

此处会提示我们没有data属性,我们给TreeProps添加一个data类型声明,tree-type.ts

export const treeProps = {
  data: {
    type: Object as PropType<Array<IInnerTreeNode>>,
    required: true
  }
} as const

看看效果,很简陋,有待优化

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fTY79wlh-1665648888961)(file://C:\Users\m9996\AppData\Roaming\marktext\images\2022-10-13-15-46-19-image.png?msec=1665648879980)]

3 数据拍平

我们获取到的Tree组件data数据是由一堆有嵌套结构的TreeNode组成,而不是InnerTreeNode,这个转换需要我们自己来实现,怎么转换呢?

可以通过递归的方式,创建一个utils.ts的文件,里面编写generateInnerTree函数。

// tree/src/utils.ts
export function generateInnerTree(tree: ITreeNode[]): IInnerTreeNode[] {
  return tree.reduce((prev, cur) => {
    if (cur.children) {
      return prev.concat(cur, generateInnerTree(cur.children));
    } else {
      return prev.concat(cur);
    }
    }, []);
}

可以使用下这个极简版本的generateInnerTree,看下效果如何?

const tree = [
  {
    label: 'docs',
    id: 'docs',
  },
  {
    label: 'packages',
    id: 'packages',
    expanded: true,
    children: [
      {
        label: 'plugin-vue',
        id: 'plugin-vue',
      },
      {
        label: 'vite',
        id: 'vite',
        expanded: true,
        children: [
          {
            label: 'src',
            id: 'src',
          },
          {
            label: 'README.md',
            id: 'README.md',
          },
        ]
      },
    ]
  },
  {
    label: 'scripts',
    id: 'scripts',
    children: [
      {
        label: 'release.ts',
        id: 'release.ts',
      },
      {
        label: 'verifyCommit.ts',
        id: 'verifyCommit.ts',
      },
    ]
  },
  {
    label: 'pnpm-workspace.yaml',
    id: 'pnpm-workspace.yaml',
  },
];

转换出来的数据如下:

[
  {label: "docs", id: "docs"},
  {label: "packages", id: "packages", expanded: true, children: [...]},
  {label: "plugin-vue", id: "plugin-vue"},
  {label: "vite", id: "vite", expanded: true, children: [...]},
  {label: "src", id: "src"},
  {label: "README.md", id: "README.md"},
  {label: "scripts", id: "scripts", children: [...]},
  {label: "release.ts", id: "release.ts"},
  {label: "verifyCommit.ts", id: "verifyCommit.ts"},
  {label: "pnpm-workspace.yaml", id: "pnpm-workspace.yaml"}
]

和我们预期的格式非常接近,不过仔细对比发现:

  • 多了children属性
  • 少了parentId / level / isLeaf 字段

levelparentId怎么加呢?level可以通过每进入一次generateInnerTree函数自增的方式获取,parentId可以通过记录走过的节点路径path来获取。

export function generateInnerTree(
  tree: ITreeNode[],
  level = 0,
  path = [] as IInnerTreeNode[]
): IInnerTreeNode[] {
  level++
  return tree.reduce((prev: IInnerTreeNode[], cur) => {
    const o = Object.assign({}, cur) as IInnerTreeNode
    // 增加 level 属性
    o.level = level

    if (path.length > 0 && path[path.length - 1].level >= level) {
      while (path[path.length - 1]?.level >= level) {
        // 子 -> 父时,应该将栈顶元素弹出去
        path.pop()
      }
    }
    // 记录 父->子 路径 path
    path.push(o)

    const parentNode = path[path.length - 2]
    if (parentNode) {
      // 增加 parentId
      o.parentId = parentNode.id
    }

    if (o.children) {
      // 移除 children 属性
      return prev.concat(o, generateInnerTree(o.children, level, path))
    } else {
      // 增加 isLeaf 属性
      o.isLeaf = true
      return prev.concat(o)
    }
  }, [])
}

再来看看generateInnerTree的执行效果:

[
  { label: 'docs', id: 'docs', level: 1, isLeaf: true },
  {
    label: 'packages',
    id: 'packages',
    expanded: true,
    children: [ [Object], [Object] ],
    level: 1
  },
  {
    label: 'plugin-vue',
    id: 'plugin-vue',
    level: 2,
    parentId: 'packages',
    isLeaf: true
  },
  {
    label: 'vite',
    id: 'vite',
    expanded: true,
    children: [ [Object], [Object] ],
    level: 2,
    parentId: 'packages'
  },
  { label: 'src', id: 'src', level: 3, parentId: 'vite', isLeaf: true },
  {
    label: 'README.md',
    id: 'README.md',
    level: 3,
    parentId: 'vite',
    isLeaf: true
  },
  {
    label: 'scripts',
    id: 'scripts',
    children: [ [Object], [Object] ],
    level: 1
  },
  {
    label: 'release.ts',
    id: 'release.ts',
    level: 2,
    parentId: 'scripts',
    isLeaf: true
  },
  {
    label: 'verifyCommit.ts',
    id: 'verifyCommit.ts',
    level: 2,
    parentId: 'scripts',
    isLeaf: true
  },
  {
    label: 'pnpm-workspace.yaml',
    id: 'pnpm-workspace.yaml',
    level: 1,
    isLeaf: true
  }
]

效果和我们预期的是一致的,现在只剩下多余的children

if (o.children) {
  // 先处理子节点
  const children = generateInnerTree(o.children, level, path)
  // 移除 children 属性
  delete o.children
  return prev.concat(o, children)
}

方案:只要传入parentNode,就需要添加parentId


import { IInnerTreeNode, ITreeNode } from './tree-type'

export function generateInnerTree(
  tree: ITreeNode[],
  level = 0, // 节点层级
  parentNode = {} as IInnerTreeNode
): IInnerTreeNode[] {
  level++
  return tree.reduce((prev, cur) => {
    // 创建一个新节点
    const o = { ...cur } as IInnerTreeNode
    // 设置层级
    o.level = level
    // 如果层级比父节点层级高则是子级,设置父级parentId
    if (level > 1 && parentNode.level && level > parentNode.level) {
      o.parentId = parentNode.id
    }
    if (o.children) {
      // 如果存在children,则递归处理这些子节点
      const children = generateInnerTree(o.children, level, o)
      // 处理完删除多余children属性
      delete o.children
      // 将新构造的节点o和已拍平数据拼接起来
      return prev.concat(o, children)
    } else {
      // 叶子节点的情况
      o.isLeaf = true
      // 将新构造的节点o和已拍平数据拼接起来
      return prev.concat(o)
    }
  }, [] as IInnerTreeNode[])
}

下面修改类型声明,

// src/tree/src/tree-type.ts
export const treeProps = {
  data: {
    type: Object as PropType<Array<ITreeNode>>,
    required: true
  }
} as const

使用generateTreeNode,

// src/tree/src/tree.tsx
export default defineComponent({
  name: 'Tree',
  props: treeProps,
  setup(props: TreeProps) {
    const { data } = toRefs(props)
    const innerData = ref(generateInnerTree(data.value))
    return () => {
      return (
        <div class="s-tree">
          {innerData.value.map(treeNode => treeNode.label)}
        </div>
      )
    }
  }
})

更新文档,tree/index.md:

<script lang="ts" setup>
import { ref } from 'vue'
const data = ref([ /* 嵌套数据 */ ]);
</script>

看一下效果:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-T6XEwm8V-1665648888962)(file://C:\Users\m9996\AppData\Roaming\marktext\images\2022-10-13-15-47-01-image.png?msec=1665648879979)]

4节点缩进、折叠功能

节点缩进

现在虽然树节点都渲染出来了,但是看着不像一棵树,我们已经有了节点的层级信息,试着给它加个缩进效果吧。

只需要给TreeNode加一个paddingLeft就行了,第一层没有缩进,从第二层开始,每往里一层缩进24px。

<div class="s-tree">
  {
    innerData.map(treeNode => (
      <div
        class="s-tree-node"
        style={{
          paddingLeft: `${24 * (treeNode.level - 1)}px`
        }}
       >
         { treeNode.label }
       </div>
    ))
  }
</div>

看着是不是有模有样了!效果如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-BjzlBsI2-1665648888962)(file://C:\Users\m9996\AppData\Roaming\marktext\images\2022-10-13-15-47-22-image.png?msec=1665648879980)]

增加展开 / 收起按钮

现在是默认全部节点都展开了,假如我们把scripts那个节点的expanded: true去掉,希望是以下效果:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yDGEyZCB-1665648888963)(file://C:\Users\m9996\AppData\Roaming\marktext\images\2022-10-13-15-47-41-image.png?msec=1665648879980)]

同时需要明确地知道哪个节点是展开的,哪个节点是收起的,接着在节点前面加一个展开/收起的图标按钮给用户反馈。如果是展开的,则显示一个向下的三角图标:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UwWex1QM-1665648888963)(file://C:\Users\m9996\AppData\Roaming\marktext\images\2022-10-13-15-48-16-image.png?msec=1665648879980)]

如果是收起的,则显示一个向右的三角图标,表示该节点下面有子节点,并且是收起的:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-K10ZeFmp-1665648888963)(file://C:\Users\m9996\AppData\Roaming\marktext\images\2022-10-13-15-48-39-image.png?msec=1665648879980)]

实现起来非常简单,在label前面加一个svg图标,默认是向右的三角箭头,如果expanded为true,则顺时针旋转90度,变成向下的三角箭头。注意处理下叶子节点,叶子节点前面不应该有展开/收起图标,而应该是一个占位符,让节点能够左对齐,美观一点。

<div class="s-tree">
  {
    innerData.map(treeNode => (
      <div
        class="s-tree-node"
        style={{
          paddingLeft: `${24 * (treeNode.level - 1)}px`
        }}
      >
        {
          treeNode.isLeaf
          ? <span style={{
              display: 'inline-block',
              width: '25px',
            }} />
          : <svg style={{
                width: '25px',
                height: '16px',
                display: 'inline-block',
                transform: treeNode.expanded ? 'rotate(90deg)': ''
              }} viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"
            >
              <path fill="currentColor" d="M384 192v640l384-320.064z"></path>
            </svg>
        }
        { treeNode.label }
      </div>
    ))
  }
</div>

效果如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5D3tRAG3-1665648888964)(file://C:\Users\m9996\AppData\Roaming\marktext\images\2022-10-13-15-48-02-image.png?msec=1665648879980)]

增加展开 / 收起事件处理

前面都只是UI展示,现在我们想要点击节点前面的图标能够实现展开/收起,这涉及到逻辑,我们增加一个toggleNode的方法。

const innerData  = ref(generateTreeNode(data.value))
// 增加toggleNode方法
const toggleNode = (node: IInnerTreeNode) => {}
<div class="s-tree">
  {
    innerData.map(treeNode => (
      <div
        class="s-tree-node"
        style={{
          paddingLeft: `${24 * (treeNode.level - 1)}px`
        }}
      >
        {
          treeNode.isLeaf
          ? <span style={{
              display: 'inline-block',
              width: '25px',
            }} />
          : <svg              
              // 增加展开/收起的点击事件
              onClick={() => toggleNode(treeNode)}
            >
              <path fill="currentColor" d="M384 192v640l384-320.064z"></path>
            </svg>
        }
        { treeNode.label }
      </div>
    ))
  }
</div>

但是实现功能却没有那么简单,点击展开、收起之后,点击节点的子节点要么全部显示,要么全部隐藏,它们不应该在出现在显示列表中。但是我们还要保持原始数据,因此这里实际上要从innerTree中计算一个新的列表用于展示:

// 获取那些展开的节点列表
const getExpendedTree = computed(() => {
  let excludeNodes: IInnerTreeNode[] = []
  const result = []

  for (const item of innerData.value) {
    // 如果遍历的节点在排除列表中,跳过本次循环
    if (excludeNodes.map(node => node.id).includes(item.id)) {
      continue
    }
    // 当前节点收起,它的子节点应该被排除掉
    if (item.expanded !== true) {
      excludeNodes = getChildren(item)
    }
    result.push(item)
  }

  return result
})

return () => {
  return (
    <div class="s-tree">
      {/* innerData.value.map(treeNode => ()) }  */}
      { getExpendedTree.value.map(treeNode => (/**/)) }
...

这里需要获取指定节点子节点,getChildren实现如下:

// 获取指定节点的子节点
const getChildren = (node: IInnerTreeNode): IInnerTreeNode[] => {
  const result = []
  // 找到传入节点在列表中的索引
  const startIndex = innerData.value.findIndex(item => item.id === node.id)
  // 找到它后面所有的子节点(level比指定节点大)
  for (
    let i = startIndex + 1;
    i < innerData.value.length && node.level < innerData.value[i].level;
    i++
  ) {
    result.push(innerData.value[i])
  }
  return result
}

最后是实现toggleNode方法,只需要在原始列表中找到它并修改expanded状态:

const toggleNode = (node: IInnerTreeNode) => {
  const cur = innerData.value.find(item => item.id === node.id)
  if (cur) cur.expanded = !cur.expanded
}

现在可以正常使用了!效果如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UtkrQQ0O-1665648888964)(file://C:\Users\m9996\AppData\Roaming\marktext\images\2022-10-13-15-48-59-image.png?msec=1665648879980)]

到此为止,我们就实现了一颗能展开/收起的极简版本的Tree组件。

5 useTree:提取UI无关的可复用逻辑

到目前为止Tree有了基本功能,不过它的体积也在不断膨胀,这不利于维护。提取UI无关的逻辑到composables中是Composition API的精髓,Tree组件的UI无关逻辑是什么呢?

分析Tree组件UI无关的部分是什么

还是从需求开始分析:

  • 我们要实现节点的展开 / 收起,只需要改变innerData中的expanded字段
  • 我们要实现勾选和点击选择,也是一样的,改变节点的checkedselected属性即可。
  • 节点禁用也是类似的,只需改变节点disableToggle / disableSelect / disableCheck 属性即可。
  • 如果要实现增删改节点呢?往innerData加节点、删节点、修改节点的label属性等。

实现Tree组件的功能就变成了:“操作innerData这个扁平的数据结构”,这就是Tree组件中与UI无关的逻辑部分啦,我们可以叫:useTree

实现基础版useTree

创建一个use-tree.ts的文件,写入以下内容:

// composables/use-tree.ts
// composables/use-tree.ts
import { ref, computed, Ref, unref } from 'vue'
import { IInnerTreeNode, ITreeNode } from '../tree-type'
import { generateInnerTree } from '../utils'

export default function useTree(tree: ITreeNode[] | Ref<ITreeNode[]>) {
  const data = unref(tree)
  const innerData = ref(generateInnerTree(data))

  const toggleNode = (node: IInnerTreeNode) => {
    const cur = innerData.value.find(item => item.id === node.id)
    if (cur) cur.expanded = !cur.expanded
  }
  // 获取那些展开的节点列表
  const expendedTree = computed(() => {
    let excludeNodes: IInnerTreeNode[] = []
    const result = []

    for (const item of innerData.value) {
      // 如果遍历的节点在排除列表中,跳过本次循环
      if (excludeNodes.map(node => node.id).includes(item.id)) {
        continue
      }
      // 当前节点收起,它的子节点应该被排除掉
      if (item.expanded !== true) {
        excludeNodes = getChildren(item)
      }
      result.push(item)
    }

    return result
  })

  // 获取指定节点的子节点
  const getChildren = (node: IInnerTreeNode): IInnerTreeNode[] => {
    const result = []
    // 找到传入节点在列表中的索引
    const startIndex = innerData.value.findIndex(item => item.id === node.id)
    // 找到它后面所有的子节点(level比指定节点大)
    for (
      let i = startIndex + 1;
      i < innerData.value.length && node.level < innerData.value[i].level;
      i++
    ) {
      result.push(innerData.value[i])
    }
    return result
  }

  return {
    expendedTree,
    toggleNode
  }
}

使用useTree

接下来就可以在Tree中使用useTree。

export default defineComponent({
  setup(props) {
    const { data } = toRefs(props);
    // 使用useTree
    const { toggleNode, expendedTree } = useTree(data.value)

    return () => (
      <div class="s-tree">
        {
          expendedTree.map(treeNode => <div class="s-tree-node">{ treeNode.label }</div>)
        }
      </div>
    )
  }
})

接下来就是不断地完善useTree,给Tree组件增加功能啦。

6 加个hover效果吧

当鼠标移到节点上时,希望节点出现一个浅色的背景色hover:bg-slate-300,tree.tsx:

<div class="s-tree-node hover:bg-slate-300">

效果如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ACjZaNHl-1665648888964)(file://C:\Users\m9996\AppData\Roaming\marktext\images\2022-10-13-15-49-15-image.png?msec=1665648879980)]

7 加个连接线吧

一般为了让父子节点的关系更加一目了然,会给Tree增加连接线,比如VSCode的目录结构树:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bTNBOvSK-1665648888964)(file://C:\Users\m9996\AppData\Roaming\marktext\images\2022-10-13-15-49-27-image.png?msec=1665648879981)]

需要在展开/收起按钮前面增加连接线的元素,然后设置好它的样式就行。

连接线要显示有两个条件:

  • 必须不是叶子节点
  • 必须是展开状态

注意下面代码实现中我们关于连接线定位的计算公式:

  • top:和节点实际高度相同,即NODE_HEIGHT
  • left:level-1个NODE_INDENT再加上12像素偏移,即NODE_INDENT * (treeNode.level- 1) + 12px
  • height:高度是所有处于展开状态下的子节点数量乘NODE_HEIGHT,即NODE_HEIGHT * childrenExpanded.length
// 节点高度
const NODE_HEIGHT = 32

// 节点缩进大小
const NODE_INDENT = 24

export default defineComponent({
  setup(props) {
    const { toggleNode, expendedTree, getChildrenExpanded } = useTree(data.value)

    return () => (
      <div class="s-tree">
        {
          expendedTree.valu.map(treeNode => (
            <div
              {/* 添加样式 */}
              class="s-tree-node relative leading-8"
              style={{
                paddingLeft: `${NODE_HEIGHT * (treeNode.level - 1)}px`
              }}
            >
              {/* 连接线 */}
              {!treeNode.isLeaf && treeNode.expanded && lineable.value && (
                  <span
                    class="s-tree-node__vline absolute w-px bg-slate-300"
                    style={{
                      height: `${
                        NODE_HEIGHT * getChildrenExpanded(treeNode).length
                      }px`,
                      left: `${NODE_INDENT * (treeNode.level - 1) + 12}px`,
                      top: `${NODE_HEIGHT}px`
                    }}
                  ></span>
                )}

              {/* ... */}

            </div>
          ))
        }
      </div>
    )
  }
})

下面是获取指定节点展开子节点工具方法:

//src/tree/src/composables/use-tree.ts
// 获取指定节点的子节点
  const getChildren = (node: IInnerTreeNode, recursive = true) => {
    const result = []
    // 找到node 在列表中的索引
    const startIndex = innerData.value.findIndex(item => item.id === node.id)
    // 找到它后面所有子节点(level 比当前节点大)
    for (
      let i = startIndex + 1;
      i < innerData.value.length && node.level < innerData.value[i].level;
      i++
    ) {
      if (recursive) {
        result.push(innerData.value[i])
      } else if (node.level === innerData.value[i].level - 1) {
        // 直接子节点
        result.push(innerData.value[i])
      }
    }
    return result
  }

  // 计算参考线高度
  const getChildrenExpanded = (
    node: IInnerTreeNode,
    result: IInnerTreeNode[] = []
  ) => {
    // 获取当前节点的直接子节点
    const childrenNodes = getChildren(node, false)
    result.push(...childrenNodes)
    childrenNodes.forEach(item => {
      if (item.expanded) {
        getChildrenExpanded(item, result)
      }
    })
    return result
  }

效果如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qlz9tSNL-1665648888965)(file://C:\Users\m9996\AppData\Roaming\marktext\images\2022-10-13-15-49-47-image.png?msec=1665648879981)]

8 勾选功能

本节我们给Tree组件增加一个可勾选的功能,这为以后批量编辑节点做好准备。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qlN4ViAB-1665648888965)(file://C:\Users\m9996\AppData\Roaming\marktext\images\2022-10-13-15-49-57-image.png?msec=1665648879981)]

新增checkable属性

先给Tree组件增加一个checkable的props,用来控制是否启用勾选功能。

export const treeProps = {
  data: {
    type: Object as PropType<Array<ITreeNode>>,
    required: true
  },
  // 新增
  checkable: {
    type: Boolean,
    default: false
  }
} as const

勾选时通过TreeNode的checked属性来控制,先在Tree中增加勾选框元素,让它根据节点的checked属性动态变化,tree.tsx:

const { data, checkable } = toRefs(props)

// ...

<div class="s-tree-node">
  {/* 复选框 */}
  {checkable.value && (
    <input
      type="checkbox"
      v-model={treeNode.checked}
      class="relative top-[2px] mr-1"
    />
  )}

  {/* 节点文本 */}
  {treeNode.label}
</div>

测试,docs/tree/index.md:把docs节点checked属性设置成true,勾选框也能正常被勾选上。

:::demo ☑️勾选功能,传入checkable
  ```vue
  <template>
    <STree :data="data" checkable></STree>
  </template>
  <script setup>
    import {ref} from 'vue'
    const data = ref([
      {
        label: 'docs',
        id: 'docs',
        // 添加checked
        checked: true
      },
      {
        label: 'packages',
        id: 'packages',
        expanded: true,
        // 增加checked
        checked: true,
        // ...
      }
    ])
  </script>

:::

效果如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-E5l94A1A-1665648888965)(file://C:\Users\m9996\AppData\Roaming\marktext\images\2022-10-13-15-51-12-image.png?msec=1665648879981)]

增加点击事件处理

下面处理用户点击行为

{
  checkable.value &&
  <input type="checkbox"
    v-model={treeNode.checked}
    onClick={() => {
      toggleCheckNode(treeNode)
    }}
  />
}

实现toggleCheckNode

const toggleCheckNode = (treeNode: IInnerTreeNode) => {
  // 父节点可能一开始没有设置checked
  // 这里手动设置一下
  treeNode.checked = !treeNode.checked

  // 获取所有子节点,设置它们checked跟父节点一致
  getChildren(treeNode).forEach(child => {
    child.checked = treeNode.checked
  });
}

效果如下:当勾选packages节点时,它的所有子节点都被勾选上了。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UQftHJTI-1665648888966)(file://C:\Users\m9996\Desktop\动画1.gif?msec=1665648880005)]

子到父的联动

子到父的联动会稍微复杂一点,我们先来梳理下逻辑:

  • 首先要知道当前勾选节点的父节点是那个,这通过parentId就可以获取
  • 其次需要知道当前节点的兄弟节点有多少个被勾选上了
    • 如果没一个勾选上,那么父节点应该取消勾选
    • 如果全部勾选上了,则父节点也应该勾选上
  • 最后还需要考虑递归,父节点的父节点也应该联动起来,以此类推

继续完善toggleCheckNode方法。

const toggleCheckNode = (treeNode: IInnerTreeNode) => {
    // ...

    // 子-父联动
    // 获取父节点
    const parentNode = innerData.value.find(item => item.id === treeNode.parentId);;
    // 如果没有父节点,则没必要处理子到父的联动
    if (!parentNode) return;
    // 获取兄弟节点:只是一个特殊的getChildren,仅获取父节点直接子节点,需要改造getChildren
    const siblingNodes = getChildren(parentNode, false)
    const checkedSiblingNodes = siblingNodes.filter(item => item.checked);

    if (checkedSiblingNodes.length === siblingNodes.length) {
      // 如果所有兄弟节点都被勾选,则设置父节点的checked属性为true
      parentNode.checked = true
    } else if (checkedSiblingNodes.length === 0) {
      // 否则设置父节点的checked属性为false
      parentNode.checked = false
    }
  }

获取兄弟节点其实就是getChildren方法的特殊版本,getChildren方法会获取一个节点的所有嵌套子节点,这里只需要获取直接子节点,所以只需要改造下getChildren

const getChildren = (node: IInnerTreeNode, recursive = true): IInnerTreeNode[] => {
  // ...

  for (
    let i = startIndex + 1;
    i < innerData.value.length && node.level < innerData.value[i].level;
    i++
  ) {
    // recursive时只添加level小1的后代
    if (recursive) {
      result.push(innerData.value[i])
    } else if (
      // 只要当前节点的层级比父节点小1,就是直接子节点
      node.level === innerData.value[i].level - 1
    ) {
      result.push(innerData.value[i]);
    }
  }
  return result
}

测试下功能正常!

在这里插入图片描述

思考题:递归联动

最后还需要考虑递归,父节点的父节点也应该联动起来,以此类推

思考题:半选

大家可以思考下半选如何实现,即当子节点勾选数量大于1个小于总子节点数量时,它的父节点其实应该半选,目前的实现是没有勾选上。

9 自定义图标

为了让我们的Tree组件更灵活,应该允许使用者自定义树节点的样式,比如在节点前后增加图标、自定义展开/收起图标等。这个功能是纯UI的,不涉及逻辑,因此不需要修改useTree,而只需要修改Tree组件即可。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-R9ZMJt0h-1665648888969)(file://C:\Users\m9996\AppData\Roaming\marktext\images\2022-10-13-15-54-45-image.png?msec=1665648879981)]

自定义展开/收起图标

先增加自定义展开/收起图标的插槽,只需要加一个三目运算符的判断,如果有icon插槽,就使用插槽内容,没有icon插槽就用默认的实心三角箭头。

注意这里使用了Scoped Slots,用于往插槽传入treeNode参数。

export default defineComponent({
  name: 'Tree',
  props: treeProps,
  setup(props: TreeProps, { slots }) {
    return () => {
      return (
      {
        treeNode.isLeaf ?
          <span style={{ display: 'inline-block', width: '25px' }} /> :
            {/* 新增icon插槽判断 */}
            slots.icon ?
              slots.icon({nodeData: treeNode, toggleNode}) :
              <svg>...</svg> {/* 原先的图标 */}
      }
   }
 }  
})

在Tree组件中使用下试试看,icon插槽可以接收到容器传过来的treeNode数据,判断节点是否展开,展开就顺时针旋转90度,让箭头朝下,docs/tree/index.md:

:::demo 自定义展开图标,设置icon插槽

<template>
  <STree :data="data">
    <template #icon="{nodeData, toggleNode}">
      <span v-if="nodeData.isLeaf" class="devui-tree-node__indent"></span>
      <span v-else @click="(event) => {
          event.stopPropagation();
          toggleNode(nodeData);
        }"
      >
        <svg :style="{
            transform: nodeData.expanded ? 'rotate(90deg)': '',
            display: 'inline-block',
            margin: '0 5px',
            cursor: 'pointer'
          }" viewBox="0 0 1024 1024" width="12" height="12"
        >
          <path d="M857.70558 495.009024 397.943314 27.513634c-7.132444-7.251148-18.794042-7.350408-26.048259-0.216941-7.253194 7.132444-7.350408 18.795065-0.216941 26.048259l446.952518 454.470749L365.856525 960.591855c-7.192819 7.192819-7.192819 18.85544 0 26.048259 3.596921 3.596921 8.311293 5.39487 13.024641 5.39487s9.42772-1.798972 13.024641-5.39487L857.596086 520.949836C864.747973 513.797949 864.796068 502.219239 857.70558 495.009024z"></path>
        </svg>

      </span>
    </template>
  </STree>
<template>
:::

效果如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-K5E2267A-1665648888972)(file://C:\Users\m9996\AppData\Roaming\marktext\images\2022-10-13-15-54-58-image.png?msec=1665648879981)]

自定义节点内容

有时我们想在节点前后增加一些内容,比如图标,就需要增加content插槽。

{
  slots.content
  ? slots.content(treeNode)
  : treeNode.label
}

icon插槽的套路类似,不再赘述。

有了iconcontent插槽,就可以做一个Github 代码树效果啦!

要实现的效果如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zbCCT6rC-1665648888972)(file://C:\Users\m9996\AppData\Roaming\marktext\images\2022-10-13-15-55-12-image.png?msec=1665648879982)]

试着用我们的Tree组件来实现,主要有以下功能:

  • 节点前面需要增加文件夹或文件的图标,如果是父节点则加文件夹图标,如果是叶子结点则加文件图标
  • 叶子节点后面需要增加一个代表是否修改过的标记图标
<template #content="treeNode">
  <svg v-if="treeNode.isLeaf" id="octicon_file_16" viewBox="0 0 16 16" width="16" height="16" fill="#57606a" style="display:inline-block"><path fill-rule="evenodd" d="M3.75 1.5a.25.25 0 00-.25.25v11.5c0 .138.112.25.25.25h8.5a.25.25 0 00.25-.25V6H9.75A1.75 1.75 0 018 4.25V1.5H3.75zm5.75.56v2.19c0 .138.112.25.25.25h2.19L9.5 2.06zM2 1.75C2 .784 2.784 0 3.75 0h5.086c.464 0 .909.184 1.237.513l3.414 3.414c.329.328.513.773.513 1.237v8.086A1.75 1.75 0 0112.25 15h-8.5A1.75 1.75 0 012 13.25V1.75z"></path></svg>
  <svg v-else id="octicon_file-directory-fill_16" viewBox="0 0 16 16" width="16" height="16" fill="#54aeff" style="display:inline-block"><path d="M1.75 1A1.75 1.75 0 000 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0016 13.25v-8.5A1.75 1.75 0 0014.25 3H7.5a.25.25 0 01-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75z"></path></svg>
  {{treeNode.label}}
  <svg v-if="treeNode.isLeaf" title="modified" viewBox="0 0 16 16" width="16" height="16" fill="#9a6700" style="position: absolute; right: 0; top: 8px;">
    <path fill-rule="evenodd" d="M2.75 2.5h10.5a.25.25 0 01.25.25v10.5a.25.25 0 01-.25.25H2.75a.25.25 0 01-.25-.25V2.75a.25.25 0 01.25-.25zM13.25 1H2.75A1.75 1.75 0 001 2.75v10.5c0 .966.784 1.75 1.75 1.75h10.5A1.75 1.75 0 0015 13.25V2.75A1.75 1.75 0 0013.25 1zM8 10a2 2 0 100-4 2 2 0 000 4z"></path>
  </svg>
</template>

实现的效果如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OuSW0K5G-1665648888972)(file://C:\Users\m9996\AppData\Roaming\marktext\images\2022-10-13-16-08-58-image.png?msec=1665648879981)]

是不是已经和Github的几乎是一样的,这说明我们开发的Tree组件是一个真正能在业务中用起来的实实在在的组件。后面要做的就是持续地完善它!

自定义节点内容

有时我们想在节点前后增加一些内容,比如图标,就需要增加content插槽。

{
  slots.content
  ? slots.content(treeNode)
  : treeNode.label
}

和icon插槽的套路类似,不再赘述。

有了icon和content插槽,就可以做一个Github PR代码检视的效果啦!

要实现的效果如下:

试着用我们的Tree组件来实现,主要有以下功能:

  • 节点前面需要增加文件夹或文件的图标,如果是父节点则加文件夹图标,如果是叶子结点则加文件图标
  • 叶子节点后面需要增加一个代表是否修改过的标记图标
<template #content="treeNode">
  <svg v-if="treeNode.isLeaf" id="octicon_file_16" viewBox="0 0 16 16" width="16" height="16" fill="#57606a" style="display:inline-block"><path fill-rule="evenodd" d="M3.75 1.5a.25.25 0 00-.25.25v11.5c0 .138.112.25.25.25h8.5a.25.25 0 00.25-.25V6H9.75A1.75 1.75 0 018 4.25V1.5H3.75zm5.75.56v2.19c0 .138.112.25.25.25h2.19L9.5 2.06zM2 1.75C2 .784 2.784 0 3.75 0h5.086c.464 0 .909.184 1.237.513l3.414 3.414c.329.328.513.773.513 1.237v8.086A1.75 1.75 0 0112.25 15h-8.5A1.75 1.75 0 012 13.25V1.75z"></path></svg>
  <svg v-else id="octicon_file-directory-fill_16" viewBox="0 0 16 16" width="16" height="16" fill="#54aeff" style="display:inline-block"><path d="M1.75 1A1.75 1.75 0 000 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0016 13.25v-8.5A1.75 1.75 0 0014.25 3H7.5a.25.25 0 01-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75z"></path></svg>
  {{treeNode.label}}
  <svg v-if="treeNode.isLeaf" title="modified" viewBox="0 0 16 16" width="16" height="16" fill="#9a6700" style="position: absolute; right: 0;">
    <path fill-rule="evenodd" d="M2.75 2.5h10.5a.25.25 0 01.25.25v10.5a.25.25 0 01-.25.25H2.75a.25.25 0 01-.25-.25V2.75a.25.25 0 01.25-.25zM13.25 1H2.75A1.75 1.75 0 001 2.75v10.5c0 .966.784 1.75 1.75 1.75h10.5A1.75 1.75 0 0015 13.25V2.75A1.75 1.75 0 0013.25 1zM8 10a2 2 0 100-4 2 2 0 000 4z"></path>
  </svg>
</template>

实现的效果如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OJe4ytnx-1665648888973)(file://C:\Users\m9996\AppData\Roaming\marktext\images\2022-10-13-16-09-12-image.png?msec=1665648879982)]

是不是已经和Github的几乎是一样的,这说明我们开发的Tree组件是一个真正能在业务中用起来的实实在在的组件。后面要做的就是持续地完善它!

10 代码重构

目前UI全部都写在tree.tsx中,导致代码可读性变差,需要进行重构。

TreeNode

我们首先可以将树节点部分抽离出来成为STreeNode子组件。

重构的方法分成三步:

  • 创建tree-node.tsx子组件文件,将tree.tsx中相应的模板部分剪切到子组件中
  • 补齐tree-node中的变量和方法
  • tree中使用tree-node子组件

创建tree-node.tsx,components/tree-node/tree-node.tsx

import { defineComponent, inject, toRefs } from 'vue'

// 节点高度
const NODE_HEIGHT = 32

// 节点缩进大小
const NODE_INDENT = 24

export default defineComponent({
  name: 'STreeNode',
  setup(props, { slots }) {
    const { lineable, checkable, treeNode } = toRefs(props)
    const { toggleNode, getChildrenExpanded, toggleCheckNode } =
      inject('TREE_UTILS')
    return () => (
      <div
        class="relative leading-8 hover:bg-slate-300"
        style={{
          paddingLeft: `${NODE_INDENT * (treeNode.level - 1)}px`
        }}
      >
        {/* 连接线 */}
        {!treeNode.isLeaf && treeNode.expanded && lineable.value && (
          <span
            class="s-tree-node__vline absolute w-px bg-slate-300"
            style={{
              height: `${NODE_HEIGHT * getChildrenExpanded(treeNode).length}px`,
              left: `${NODE_INDENT * (treeNode.level - 1) + 12}px`,
              top: `${NODE_HEIGHT}px`
            }}
          ></span>
        )}
        {/* 如果是叶子节点则放一个空白占位元素,否则放一个三角形反馈图标 */}
        {treeNode.isLeaf ? (
          <span
            style={{
              display: 'inline-block',
              width: '25px'
            }}
          />
        ) : slots.icon ? (
          slots.icon({ nodeData: treeNode, toggleNode })
        ) : (
          <svg
            style={{
              width: '25px',
              height: '16px',
              display: 'inline-block',
              transform: treeNode.expanded ? 'rotate(90deg)' : ''
            }}
            viewBox="0 0 1024 1024"
            xmlns="http://www.w3.org/2000/svg"
            onClick={() => toggleNode(treeNode)}
          >
            <path fill="currentColor" d="M384 192v640l384-320.064z"></path>
          </svg>
        )}
        {/* 复选框 */}
        {checkable.value && (
          <input
            type="checkbox"
            v-model={treeNode.checked}
            class="relative top-[2px] mr-1"
            onClick={() => {
              toggleCheckNode(treeNode)
            }}
          />
        )}

        {/* 节点文本 */}
        {slots.content ? slots.content(treeNode) : treeNode.label}
      </div>
    )
  }
})

需要解决一下问题:

  • 定义TreeNodeProps
  • 定义TreeUtils

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dTqtEnTr-1665648888973)(file://C:\Users\m9996\AppData\Roaming\marktext\images\2022-10-13-16-09-35-image.png?msec=1665648879993)]

创建TreeNodeProps,components/tree-node/tree-node-type.ts

import { ExtractPropTypes, PropType } from 'vue'
import { IInnerTreeNode, treeProps } from '../tree-type'

export const treeNodeProps = {
  ...treeProps,
  treeNode: {
    type: Object as PropType<IInnerTreeNode>,
    required: true
  }
}

export type TreeNodeProps = ExtractPropTypes<typeof treeNodeProps>
import { treeNodeProps, TreeNodeProps } from './tree-node-type'

export default defineComponent({
  props: treeNodeProps,
  setup(props: TreeNodeProps, { slots }) {}
})

引入类型之后,会提示treeNode.xxx错误,这是因为treeNode是Ref,对应修改一下。

定义TreeUtils

type TreeUtils = {
  toggleNode: (treeNode: IInnerTreeNode) => void
  getChildrenExpanded: (treeNode: IInnerTreeNode) => IInnerTreeNode[]
  toggleCheckNode: (treeNode: IInnerTreeNode) => void
}
const { toggleNode, getChildrenExpanded, toggleCheckNode } = inject(
  'TREE_UTILS'
) as TreeUtils

最后在tree.tsx中使用tree-node

import { defineComponent, provide, toRefs } from 'vue'
import useTree from './composables/use-tree'
import { IInnerTreeNode, TreeProps, treeProps } from './tree-type'
import STreeNode from './components/tree-node'

export default defineComponent({
  name: 'STree',
  props: treeProps,
  setup(props: TreeProps, { slots }) {
    // 获取data
    const treeData = useTree(props.data)
    provide('TREE_UTILS', treeData)
    return () => {
      return (
        <div class="s-tree">
          {
            treeData.expendedTree.value.map((treeNode: IInnerTreeNode) => (
              <STreeNode {...props} treeNode={treeNode}>
                {{
                  content: slots.content,
                  icon: slots.icon
                }}
              </STreeNode>
            ))
          }
        </div>
      )
    }
  }
})

测试可行。但是这里还有改进空间,tree.tsx太薄,成了纯粹的转发。我们完全可以将插槽判断逻辑在此处完成,让tree-node变成更纯粹的内容展示。我们作以下更改:

  • tree中作插槽是否传递的判断
  • tree-node中移除判断逻辑
  • 把默认展开折叠图标封装为单独组件

tree中作插槽是否传递的判断

import STreeNodeToggle from './components/tree-node-toggle'

export default defineComponent({
  setup(props: TreeProps, { slots }) {
    return () => {
      return (
        <div class="s-tree">
          {
            treeData.expendedTree.value.map((treeNode: IInnerTreeNode) => (
              <STreeNode {...props} treeNode={treeNode}>
                {{
                  content: () =>
                    slots.content ? slots.content(treeNode) : treeNode.label,
                  icon: () =>
                    slots.icon ? (
                      slots.icon({
                        nodeData: treeNode,
                        toggleNode: treeData.toggleNode
                      })
                    ) : (
                      <STreeNodeToggle
                        expanded={!!treeNode.expanded}
                        onClick={() => treeData.toggleNode(treeNode)}
                      ></STreeNodeToggle>
                    )
                }}
              </STreeNode>
            ))
          }
        </div>
      )
    }
  }
})

提取一个TreeNodeToggle组件

提取默认折叠图标为TreeNodeToggle组件:

import { SetupContext } from 'vue'

// 函数式组件更简洁
export default (props: { expanded: boolean }, { emit }: SetupContext) => (
  <svg
    style={{
      width: '25px',
      height: '16px',
      display: 'inline-block',
      transform: props.expanded ? 'rotate(90deg)' : ''
    }}
    viewBox="0 0 1024 1024"
    xmlns="http://www.w3.org/2000/svg"
    // 点击事件在外面处理
    onClick={() => emit('onClick')}
  >
    <path fill="currentColor" d="M384 192v640l384-320.064z"></path>
  </svg>
)

这样tree.tsx里面的代码就变得非常清爽,测试功能没问题之后,就可以继续增加别的特性。

11节点增删操作

接下来我们来完成节点的增删操作,最终效果如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-I5XIi5B9-1665648888973)(file://C:\Users\m9996\Desktop\动画3.gif?msec=1665648880009)]

打开use-tree.ts文件,增加appendremove两个方法:

import { Ref } from 'vue'
import { IInnerTreeNode } from '../tree-type'

export default function useTree(tree: Ref<IInnerTreeNode[]>) {
  const append = (parent: IInnerTreeNode, node: IInnerTreeNode) => {
    // 增加节点的逻辑
    console.log('useOperate append', parent, node)
  }

  const remove = (node: IInnerTreeNode) => {
    // 删除节点的逻辑
    console.log('useOperate remove', node)
  }

  return {
    append,
    remove
  }
}

增加增删操作的UI

然后给tree组件增加一个props叫operable

export const treeProps = {  
  // 增加节点增删操作的功能
  operable: {
    type: Boolean,
    default: false
  },
};

tree-node.tsx中增加相应的操作按钮

export default defineComponent({
  name: 'STreeNode',
  props: treeNodeProps,
  setup(props: TreeNodeProps, { emit, slots }) {
    // 增加operable
    const { lineable, checkable, operable } = toRefs(props)
    // 增加append,remove
    const { getChildrenExpanded, toggleCheckNode, append, remove } = inject(
      'TREE_UTILS'
    ) as TreeUtils
    // 增加isShow控制操作按钮显示
    const isShow = ref(false)
    // 操作按钮触发
    const toggleOperate = () => {
      if (isShow.value) {
        isShow.value = false
      } else {
        isShow.value = true
      }
    }

    return () => (
      <div class="s-tree-node" 
        // 控制操作按钮显示
        onMouseenter={toggleOperate}
        onMouseleave={toggleOperate}
      >
        {/* 连接线 */}
        <div class="s-tree-node__content">
          {/* 展开/收起按钮 */}
          {/* 勾选按钮 */}
          {/* 节点内容 */}
          {/* 增删改操作 */}
          {operable.value && isShow.value && (
            <span class="inline-flex ml-1">
              <svg
                onClick={() => {
                  append(treeNode, {
                    label: '新节点'
                  })
                }}
                viewBox="0 0 1024 1024"
                width="14"
                height="14"
                class="cursor-pointer"
              >
                <path d="M590.769231 571.076923h324.923077c15.753846 0 29.538462-13.784615 29.538461-29.538461v-59.076924c0-15.753846-13.784615-29.538462-29.538461-29.538461H590.769231c-11.815385 0-19.692308-7.876923-19.692308-19.692308V108.307692c0-15.753846-13.784615-29.538462-29.538461-29.538461h-59.076924c-15.753846 0-29.538462 13.784615-29.538461 29.538461V433.230769c0 11.815385-7.876923 19.692308-19.692308 19.692308H108.307692c-15.753846 0-29.538462 13.784615-29.538461 29.538461v59.076924c0 15.753846 13.784615 29.538462 29.538461 29.538461H433.230769c11.815385 0 19.692308 7.876923 19.692308 19.692308v324.923077c0 15.753846 13.784615 29.538462 29.538461 29.538461h59.076924c15.753846 0 29.538462-13.784615 29.538461-29.538461V590.769231c0-11.815385 7.876923-19.692308 19.692308-19.692308z"></path>
              </svg>
              <svg
                onClick={() => {
                  remove(treeNode)
                }}
                viewBox="0 0 1024 1024"
                width="14"
                height="14"
                class="cursor-pointer ml-1"
              >
                <path d="M610.461538 500.184615l256-257.96923c11.815385-11.815385 11.815385-29.538462 0-41.353847l-39.384615-41.353846c-11.815385-11.815385-29.538462-11.815385-41.353846 0L527.753846 417.476923c-7.876923 7.876923-19.692308 7.876923-27.569231 0L242.215385 157.538462c-11.815385-11.815385-29.538462-11.815385-41.353847 0l-41.353846 41.353846c-11.815385 11.815385-11.815385 29.538462 0 41.353846l257.969231 257.969231c7.876923 7.876923 7.876923 19.692308 0 27.56923L157.538462 785.723077c-11.815385 11.815385-11.815385 29.538462 0 41.353846l41.353846 41.353846c11.815385 11.815385 29.538462 11.815385 41.353846 0L498.215385 610.461538c7.876923-7.876923 19.692308-7.876923 27.56923 0l257.969231 257.969231c11.815385 11.815385 29.538462 11.815385 41.353846 0L866.461538 827.076923c11.815385-11.815385 11.815385-29.538462 0-41.353846L610.461538 527.753846c-7.876923-7.876923-7.876923-19.692308 0-27.569231z"></path>
              </svg>
            </span>
          )}
        </div>
      </div>
    )
  }
});

增加一个增删操作的demo,看下UI和数据传递是否正确

## 操作节点
:::demo
```vue
<template>
  <STree :data="data" operable></STree>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue'

export default defineComponent({
  setup() {
    const data = ref([
      {
        label: 'node 1',
        id: 'node-1',
        children: [
          {
            label: 'node 1-1',
            id: 'node-1-1'
          },
        ]
      },
      {
        label: 'node 2',
        id: 'node-2'
      },
    ])

    return {
      data,
    }
  },
})
</script>

:::

效果如下:
效果如下:

点击“+”和“X”图标按钮,传递的数据也是正确的。下一步就是实现useOperate里面的具体逻辑。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vAF0ZTMi-1665648888973)(file://C:\Users\m9996\AppData\Roaming\marktext\images\2022-10-13-16-11-28-image.png?msec=1665648879982)]

实现节点操作

接下来我们想要实现节点的增删操作的视线逻辑,use-tree.ts

import { randomId } from "./utils";

export default function useTree(data: Ref<IInnerTreeNode[]>) {
  // ...

  const getIndex = (node: IInnerTreeNode): number => {
    if (!node) return -1
    return innerData.value.findIndex(item => item.id === node.id)
  }

  const append = (parent: IInnerTreeNode, node: IInnerTreeNode) => {
    // 获取parent最后一个子节点
    const children = getChildren(parent, false)
    const lastChild = children[children.length - 1]

    // 确定node插入位置
    // 默认在parent后面
    let insertedIndex = getIndex(parent) + 1

    // 如果存在lastChild则在其后面
    if (lastChild) {
      insertedIndex = getIndex(lastChild) + 1
    }

    // 保证parent是展开、非叶子状态
    // 这样可以看到新增节点
    parent.expanded = true
    parent.isLeaf = false

    // 新增节点初始化
    const currentNode = ref({
      ...node,
      level: parent.level + 1,
      parentId: parent.id,
      isLeaf: true
    })

    // 设置新增节点ID
    if (currentNode.value.id === undefined) {
      currentNode.value.id = randomId()
    }

    // 插入新节点
    innerData.value.splice(insertedIndex, 0, currentNode.value)
  }

  const remove = (node: IInnerTreeNode) => {
    // 获取node子节点ids
    const childrenIds = getChildren(node).map(nodeItem => nodeItem.id)

    // 过滤掉node和其子节点之外的节点
    innerData.value = innerData.value.filter(
      // item既不是node也不是node子节点
      item => item.id !== node.id && !childrenIds.includes(item.id)
    )
  }

  return {
    append,
    remove,
  }
}

生成随机id,实现randomId,src/shared/utils.ts

export function randomId(n = 8): string {
  // 生成n位长度的字符串
  const str = 'abcdefghijklmnopqrstuvwxyz0123456789' // 可以作为常量放到random外面
  let result = ''
  for (let i = 0; i < n; i++) {
    result += str[parseInt((Math.random() * str.length).toString())]
  }
  return result
}

效果如下:
在这里插入图片描述

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
Vue2中,对于element-ui组件的二次封装,可以按照以下步骤进行: 1. 需求分析:明确需要封装element-ui组件,以及需要添加的功能和配置项。 2. 创建父组件:编写父组件的template和script代码,其中template中调用封装组件,script中定义需要传递给封装组件的props属性。 3. 创建封装组件:编写封装组件的template和script代码。在template中使用element-ui组件,并根据需要进行样式和布局的调整。在script中定义props属性,接收父组件传递的值,并监听element-ui组件的事件,触发update事件给父组件。 4. 通过临时变量传递值:由于父组件传递给封装组件的props不能直接作为封装组件的v-model属性传递给element-ui组件,所以需要在封装组件中定义一个临时变量来存储值,并将该变量与element-ui组件进行绑定。 5. 完成打通:在封装组件中监听中间件,接收到element-ui组件的update事件后,再将该事件传递给父组件。 总结来说,Vue2中对于element-ui组件的二次封装,需要创建父组件封装组件,通过props属性传递值,并在封装组件中监听element-ui组件的事件并触发update事件给父组件。同时,需要使用临时变量来传递值给element-ui组件。<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* *2* *3* [Vue3+ts+element-plus 组件的二次封装-- 页脚分页el-pagination的二次封装](https://blog.csdn.net/cs492934056/article/details/128096257)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v92^chatsearchT3_1"}}] [.reference_item style="max-width: 100%"] [ .reference_list ]

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

林多多@

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值