• 首页 首页 icon
  • 工具库 工具库 icon
    • IP查询 IP查询 icon
  • 内容库 内容库 icon
    • 快讯库 快讯库 icon
    • 精品库 精品库 icon
    • 问答库 问答库 icon
  • 更多 更多 icon
    • 服务条款 服务条款 icon

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

武飞扬头像
林多多@
帮助1

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

以下是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

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

学新通

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>

看一下效果:

学新通

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>

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

学新通

增加展开 / 收起按钮

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

学新通

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

学新通

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

学新通

实现起来非常简单,在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>
学新通

效果如下:

学新通

增加展开 / 收起事件处理

前面都只是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
}

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

学新通

到此为止,我们就实现了一颗能展开/收起的极简版本的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">

效果如下:

学新通

7 加个连接线吧

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

学新通

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

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

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

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

  • 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
  }
学新通

效果如下:

学新通

8 勾选功能

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

学新通

新增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>
学新通

:::

效果如下:

学新通

增加点击事件处理

下面处理用户点击行为

{
  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节点时,它的所有子节点都被勾选上了。

学新通

子到父的联动

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

  • 首先要知道当前勾选节点的父节点是那个,这通过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组件即可。

学新通

自定义展开/收起图标

先增加自定义展开/收起图标的插槽,只需要加一个三目运算符的判断,如果有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>
:::
学新通

效果如下:

学新通

自定义节点内容

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

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

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

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

要实现的效果如下:

学新通

试着用我们的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>

实现的效果如下:

学新通

是不是已经和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>

实现的效果如下:

学新通

是不是已经和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

学新通

创建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节点增删操作

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

学新通

打开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里面的具体逻辑。

学新通

实现节点操作

接下来我们想要实现节点的增删操作的视线逻辑,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
}

效果如下:
学新通

这篇好文章是转载于:学新通技术网

  • 版权申明: 本站部分内容来自互联网,仅供学习及演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,请提供相关证据及您的身份证明,我们将在收到邮件后48小时内删除。
  • 本站站名: 学新通技术网
  • 本文地址: /boutique/detail/tanhgghgga
系列文章
更多 icon
同类精品
更多 icon
继续加载