优秀的编程知识分享平台

网站首页 > 技术文章 正文

使用 React 和树数据结构实现嵌套过滤器

nanyue 2024-09-20 21:58:37 技术文章 7 ℃



TypeScript 中的树节点

最初,我们需要为树节点定义一个类型。它将是一个通用类型,可用于任何类型的数据,并且仅包含两个字段:value和children。Value是一个泛型类型T,并且children是一个数组TreeNode<T>。

export interface TreeNode<T> {
  value: T
  children: TreeNode<T>[]
}

为了定位特定节点,我们将使用该getTreeNode函数遍历树到所需的节点。路径是一个数字数组,每个数字都是子节点的索引。

export function getTreeNode<T>(tree: TreeNode<T>, path: number[]): TreeNode<T> {
  return path.reduce((node, i) => node.children[i], tree)
}

为了检索给定节点的所有值,我们将使用该getTreeValues函数。该函数返回特定节点及其所有子节点的值数组。

export function getTreeValues<T>(tree: TreeNode<T>): T[] {
  return [tree.value, ...tree.children.flatMap(getTreeValues)]
}

我们现在可以定义我们的习惯树。节点的数据将由 表示,其中HabitTreeNodeValue包含习惯类别的 id,例如“健康”、“关系”、“工作”等。这还有一个可选的习惯 id 数组和可选颜色。例如,“幸福”类别没有自己的习惯;相反,它是该领域定义的其他习惯的组合children。

import { TreeNode } from "@reactkit/utils/tree"
import { HabitId } from "./habits"

export interface HabitTreeNodeValue {
  id: string
  habits?: HabitId[]
  color?: number
}

export interface HabitTreeNode extends TreeNode<HabitTreeNodeValue> {}

export const habitTree: HabitTreeNode = {
  value: {
    id: "happiness",
    color: 5,
  },
  children: [
    {
      value: {
        id: "health",
        color: 4,
      },
      children: [
        {
          value: {
            id: "sleep",
            habits: [
              "sunlight",
              "limitCoffee",
              "noAlcohol",
              "earlySleep",
              "noLateFood",
              "noWorkAfterDinner",
              "noElectronicsInBedroom",
            ],
          },
          children: [],
        },
        {
          value: {
            id: "nutrition",
            habits: ["morningFast", "noLateFood", "supplements", "content"],
          },
          children: [],
        },
        {
          value: {
            id: "body",
            habits: ["outdoors", "exercise", "walk"],
          },
          children: [],
        },
        {
          value: {
            id: "mind",
            habits: [
              "meditation",
              "learn",
              "max",
              "noWorkAfterDinner",
              "noElectronicsInBedroom",
            ],
          },
          children: [],
        },
      ],
    },
    {
      value: {
        id: "relationships",
        color: 11,
      },
      children: [
        {
          value: {
            id: "marriage",
            habits: [
              "compliment",
              "review",
              "help",
              "noWorkAfterDinner",
              "noElectronicsInBedroom",
            ],
          },
          children: [],
        },
      ],
    },
    {
      value: {
        id: "work",
        color: 2,
      },
      children: [
        {
          value: {
            id: "productivity",
            habits: [
              "noWorkAfterDinner",
              "sunlight",
              "limitCoffee",
              "noAlcohol",
              "earlySleep",
              "morningFast",
              "prepare",
              "noEarlyCoffee",
              "noLateFood",
              "outdoors",
              "exercise",
              "noElectronicsInBedroom",
            ],
          },
          children: [],
        },
      ],
    },
  ],
}

接下来,我们找到了一系列不知道它们正在树中使用的习惯。它们只是唯一习惯 ID 和有关它们的信息的列表,其中包括emoji、name和description。

使用 React 嵌套过滤器

借助 React,我们现在可以实现嵌套过滤器。当前类别以数字数组的形式存储在钩子中useState。“幸福”类别将是一个空数组,“健康”类别将是[0],“睡眠”类别将是[0, 0],依此类推。

import { capitalizeFirstLetter } from "@reactkit/utils/capitalizeFirstLetter"
import { getTreeNode, getTreeValues } from "@reactkit/utils/tree"
import { withoutDuplicates } from "@reactkit/utils/array/withoutDuplicates"
import { HStack, VStack } from "@reactkit/ui/ui/Stack"
import { TreeFilter } from "@reactkit/ui/ui/tree/TreeFilter"
import { useState, useMemo } from "react"
import styled from "styled-components"
import { HabitTreeNode, habitTree } from "./data/habitTree"
import { habitRecord } from "./data/habits"
import { Text } from "@reactkit/ui/ui/Text"
import { HabitItem } from "./HabitItem"

const Container = styled(HStack)`
  width: 100%;
  flex-wrap: wrap;
  gap: 40px;
  align-items: start;
`

const Content = styled(VStack)`
  gap: 20px;
  flex: 1;
`

const FilterWrapper = styled.div`
  position: sticky;
  top: 0;
`

const getCategoriesColors = (
  { value, children }: HabitTreeNode,
  parentColor?: number
): Record<string, number | undefined> => {
  const color = value.color ?? parentColor

  return {
    [value.id]: color,
    ...children.reduce(
      (acc, child) => ({
        ...acc,
        ...getCategoriesColors(child, color),
      }),
      {}
    ),
  }
}

const defaultColor = 3

export const CuratedHabits = () => {
  const [path, setPath] = useState<number[]>([])

  const values = useMemo(() => getTreeValues(habitTree), [])
  const categoryColorRecord = useMemo(() => getCategoriesColors(habitTree), [])

  const node = getTreeNode(habitTree, path)

  const habits = withoutDuplicates(
    getTreeValues(node).flatMap((value) => value.habits || [])
  )
    .map((id) => ({
      id,
      ...habitRecord[id],
    }))
    .map((habit) => ({
      ...habit,
      tags: values
        .filter((value) => value.habits?.includes(habit.id))
        .map((value) => ({
          name: value.id,
          color: categoryColorRecord[value.id] ?? defaultColor,
        })),
    }))

  return (
    <Container>
      <FilterWrapper>
        <TreeFilter
          tree={habitTree}
          renderName={(value) => capitalizeFirstLetter(value.id)}
          value={path}
          onChange={setPath}
        />
      </FilterWrapper>
      <Content>
        <Text weight="bold" size={24}>
          {capitalizeFirstLetter(node.value.id)} habits{" "}
          <Text as="span" color="supporting">
            ({habits.length})
          </Text>
        </Text>
        {habits.map((habit) => (
          <HabitItem {...habit} key={habit.id} />
        ))}
      </Content>
    </Container>
  )
}

使用该getTreeValues函数,我们将获得树中的所有习惯。每个习惯都有一个彩色标签,但并非所有习惯都定义了颜色字段。它仅存在于类别级别。因此,我们将使用getCategoriesColors来获取类别 ID 及其颜色的记录。这是一个递归函数,如果子类别没有定义自己的颜色,则将父类别的颜色分配给子类别。

为了获取当前节点,我们使用该getTreeNode函数。有些习惯可能存在于多个类别中。例如,“起床后看阳光”的习惯既属于“睡眠”又属于“生产力”类别。我们不想将其显示两次,因此我们使用该withoutDuplicates函数删除重复项。然后我们为每个独特的习惯添加标签列表。标签代表习惯所属的类别。我们使用categoryColorRecord来获取类别的颜色。

export function withoutDuplicates<T>(
  items: T[],
  areEqual: (a: T, b: T) => boolean = (a, b) => a === b
): T[] {
  const result: T[] = []

  items.forEach((item) => {
    if (!result.find((i) => areEqual(i, item))) {
      result.push(item)
    }
  })

  return result
}

为了显示习惯,我们从habits数组中提取并利用该HabitItem组件。TreeFilter我们将依赖通用组件来过滤习惯。

import { useState, Fragment } from "react"
import styled, { useTheme } from "styled-components"
import { Circle } from "../Circle"
import { NonEmptyOnly } from "../NonEmptyOnly"
import { VStack, HStack } from "../Stack"
import { defaultTransitionCSS } from "../animations/transitions"
import { getVerticalPaddingCSS } from "../utils/getVerticalPaddingCSS"
import { Text } from "../Text"
import { handleWithStopPropagation } from "../../shared/events"
import { InputProps } from "../../props"
import { TreeNode } from "@reactkit/utils/tree"

interface TreeFilterProps<T> extends InputProps<number[]> {
  tree: TreeNode<T>
  renderName: (value: T) => string
}

const Content = styled(VStack)`
  margin-left: 20px;
`

const Container = styled(VStack)`
  cursor: pointer;
`

const Item = styled(HStack)`
  ${getVerticalPaddingCSS(4)}
  align-items: center;
  gap: 8px;
  ${defaultTransitionCSS}
`

export function TreeFilter<T>({
  tree,
  renderName,
  value,
  onChange,
}: TreeFilterProps<T>) {
  const [hovered, setHovered] = useState<number[] | undefined>()

  const { colors } = useTheme()

  const recursiveRender = (node: TreeNode<T>, path: number[]) => {
    const isSelected = value.every((v, i) => v === path[i])

    let color = isSelected ? colors.text : colors.textShy
    if (hovered) {
      const isHovered = hovered.every((v, i) => v === path[i])
      color = isHovered ? colors.text : colors.textShy
    }

    return (
      <Container
        onClick={handleWithStopPropagation(() => onChange(path))}
        onMouseEnter={() => setHovered(path)}
        onMouseLeave={() => {
          setHovered(
            path.length === 0 ? undefined : path.slice(0, path.length - 1)
          )
        }}
      >
        <Item
          style={{
            color: color.toCssValue(),
          }}
        >
          <Circle
            size={8}
            background={isSelected ? colors.primary : colors.transparent}
          />
          <Text weight="bold">{renderName(node.value)}</Text>
        </Item>
        <NonEmptyOnly
          array={node.children}
          render={(items) => (
            <Content>
              {items.map((child, index) => (
                <Fragment key={index}>
                  {recursiveRender(child, [...path, index])}
                </Fragment>
              ))}
            </Content>
          )}
        />
      </Container>
    )
  }

  return <>{recursiveRender(tree, [])}</>
}

它的 props 扩展了 generic InputProps,由value和onChangeprops 组成。在这种情况下,该值将是节点的路径。我们还必须将整个树传递给组件和一个将呈现节点名称的函数。

当我们渲染树结构时,我们无法避免递归。在函数中,我们通过比较prop 和参数recursiveRender来检查当前节点是否被选择。然后,我们根据筛选项的选择状态对它们应用不同的样式。我们还通过更改状态来更新项目悬停时的颜色。该函数用于防止点击事件向上冒泡到父元素。valuepathhoveredhandleWithStopPropagation

我们在组件内渲染节点的子节点Content,这将它们与父节点缩进,左侧有 20 像素的边距。由于我们不想Content在没有子组件时渲染组件,因此我们使用一个名为 的小辅助组件,NonEmptyOnly仅在子组件存在时才渲染它们。

import { ReactNode } from "react"

interface NonEmptyOnlyProps<T> {
  array?: T[]
  render: (array: T[]) => ReactNode
}

export function NonEmptyOnly<T>({ array, render }: NonEmptyOnlyProps<T>) {
  if (array && array.length > 0) {
    return <>{render(array)}</>
  }

  return null
}
最近发表
标签列表