React 및 GraphQL에서 트리 뷰를 재귀적으로 렌더링

12189 단어 reacttypescript
React에서 다단계 Tree View 구성 요소를 동적으로 렌더링하려면 얼마나 많은 수준이 있는지 신경 쓰지 않으므로 재귀를 사용해야 합니다.

재귀가 무엇인지 알아야 하는 경우 this link을 확인해야 합니다.

이 문서에서는 다음 패키지를 사용합니다.
  • Material UI => 트리 보기 구성 요소 UI
  • GraphQLApollo Client => back4app 데이터베이스에서 데이터 가져오기

  • 패키지 설치


    npm install @mui/lab @mui/material @mui/icons-material @apollo/client graphql

    Apollo 클라이언트 설정



    모든 앱에서 사용할 수 있도록 index.js에서 구성 요소를 ApolloProvider로 래핑해야 합니다.

    import React from "react";
    import ReactDOM from "react-dom";
    import App from "./App";
    import {
      ApolloClient,
      InMemoryCache,
      ApolloProvider,
      createHttpLink,
    } from "@apollo/client";
    import { setContext } from "@apollo/client/link/context";
    
    // URI for graphql API on back4app
    const httpLink = createHttpLink({
      uri: "https://parseapi.back4app.com/graphql",
    });
    
    const headersLink = setContext((_, { headers }) => {
      // return the headers to the context so httpLink can read them
      return {
        headers: {
          ...headers,
          // These keys are found when you create app on back4app
          "X-Parse-Application-Id": "<YOUR_APPLICATION_ID>",
          "X-Parse-Master-Key": "<YOUR_MASTER_KEY>",
          "X-Parse-REST-API-Key": "<YOUR_REST_API_KEY>",
        },
      };
    });
    
    const client = new ApolloClient({
      link: headersLink.concat(httpLink),
      cache: new InMemoryCache(),
    });
    
    ReactDOM.render(
      <React.StrictMode>
        <ApolloProvider client={client}>
            <App />
        </ApolloProvider>
      </React.StrictMode>,
      document.getElementById("root")
    );
    
    


    GraphQL 쿼리 준비



    이제 사용 중인 API에 대한 쿼리를 준비해야 합니다. 나는 이 튜토리얼에 적절한 중첩을 제공할 back4app에서 ContinentsCountriesCities 데이터베이스를 사용할 것입니다.

    따라서 대륙, 국가 및 도시에 대한 쿼리는 다음과 같습니다(쿼리 세부 정보에 대한 문서로 앱의 Graphql API 플레이그라운드를 확인할 수 있음).

    import { gql } from "@apollo/client";
    
    export const GET_CONTINENTS = gql`
      query allContinents {
        data: continentscountriescities_Continents {
          count
          results: edges {
            node {
              objectId
              name
              children: countries {
                count
              }
            }
          }
        }
      }
    `;
    
    export const GET_COUNTRIES = gql`
      query allCountries($continentId: ID) {
        data: continentscountriescities_Countries(
          where: { continent: { have: { objectId: { equalTo: $continentId } } } }
        ) {
          count
          results: edges {
            node {
              objectId
              name
              children: cities {
                count
              }
            }
          }
        }
      }
    `;
    
    export const GET_CITIES = gql`
      query allCities($countryId: ID) {
        data: continentscountriescities_Cities(
          where: { country: { have: { objectId: { equalTo: $countryId } } } }
        ) {
          count
          results: edges {
            node {
              objectId
              name
            }
          }
        }
      }
    `;
    
    


    apollo 클라이언트에서 제공하는 gql 문자열 리터럴은 기본 스키마에 대한 쿼리 유효성 검사에 도움이 됩니다.

    트리 보기 UI



    Material UI에서 기본 트리 보기를 사용할 수 있지만 TreeItem 클릭 시 데이터 가져오기를 처리하기 위해 사용자 지정 콘텐츠를 제공해야 합니다.

    따라서 우리의 CustomTreeItem는 다음과 같이 보일 것입니다.

    import React, { useEffect } from "react";
    import clsx from "clsx";
    import { CircularProgress, Typography } from "@mui/material";
    import TreeItem, { useTreeItem } from "@mui/lab/TreeItem";
    import { useLazyQuery } from "@apollo/client";
    import { GET_COUNTRIES, GET_CITIES } from "../../utils/Queries";
    
    const CustomContent = React.forwardRef(function CustomContent(
      props,
      ref
    ) {
      // TreeItemContentProps + typename + appendNewData props
      const {
        classes,
        className,
        label,
        nodeId,
        icon: iconProp,
        expansionIcon,
        displayIcon,
        typename,
        appendNewData,
      } = props;
    
       // Extract last part from Typename key of node from graphql
      // Ex: Continentscountriescities_Country => Country
      const type: string = typename?.split("_")[1] || "";
    
      let lazyQueryParams = {};
    
      // Add lazyQueryParams according to type of node
      switch (type) {
        case "Continent":
          lazyQueryParams = {
            query: GET_COUNTRIES,
            variableName: "continentId",
          };
          break;
        case "Country":
          lazyQueryParams = {
            query: GET_CITIES,
            variableName: "countryId",
          };
          break;
        default:
          lazyQueryParams = {
            query: GET_COUNTRIES,
            variableName: "continentId",
          };
          break;
      }
    
      // Lazy query for getting children of this node
      const [getChildren, { loading, data }] = useLazyQuery(
        lazyQueryParams?.query,
        {
          variables: { [lazyQueryParams?.variableName]: nodeId },
        }
      );
    
      const { disabled, expanded, selected, focused, handleExpansion } =
        useTreeItem(nodeId);
    
      const icon = iconProp || expansionIcon || displayIcon;
    
      // Append new children to node
      useEffect(() => {
        if (data?.data?.results && appendNewData) {
          appendNewData(nodeId, data.data?.results || []);
        }
      }, [data]);
    
      const handleExpansionClick = (event) => {
        // Fetch data only once
        if (!data) {
          getChildren();
        }
    
        handleExpansion(event);
      };
    
      return (
        <div
          className={clsx(className, classes.root, {
            [classes.expanded]: expanded,
            [classes.selected]: selected,
            [classes.focused]: focused,
            [classes.disabled]: disabled,
          })}
          onClick={handleExpansionClick}
          ref={ref}
        >
          <div className={classes.iconContainer}>{icon}</div>
          <Typography component="div" className={classes.label}>
            {label}
          </Typography>
    
        </div>
      );
    });
    
    const CustomTreeItem = (props) => {
      return (
        <TreeItem
          ContentComponent={CustomContent}
          // These props will be sent from the parent
          ContentProps={
            { typename: props.typename, appendNewData: props.appendNewData } as any
          }
          {...props}
        />
      );
    };
    
    export default CustomTreeItem;
    
    


    위에서 만든 쿼리와 apollo 클라이언트의 useLazyQuery 후크를 사용하여 구성 요소에서 필요할 때마다 호출할 메서드getChildren()(또는 다른 이름)가 있습니다. 따라서 우리는 handleExpansionClick 메서드에서 이 메서드를 호출하고 데이터가 아직 가져오지 않았는지 확인합니다.

    그리고 계층에서 호출할 쿼리를 결정하기 위해 렌더링하는 노드의 유형을 전환하고 있습니다.

    이제 트리를 렌더링하는 상위 구성 요소의 경우 첫 번째 렌더링에서 기본적으로 대륙 데이터를 렌더링하고 기본 배열에 가져온 새 하위 데이터를 추가하는 재귀 기능이 있습니다. 이를 위해서는 모든 쿼리가 위와 같이 고정된 구조를 가져야 합니다.

    상위 구성 요소는 다음과 같습니다.

    import React, { useEffect, useState } from "react";
    import { useQuery } from "@apollo/client";
    import TreeView from "@mui/lab/TreeView";
    import { CircularProgress } from "@mui/material";
    import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
    import ChevronRightIcon from "@mui/icons-material/ChevronRight";
    import { GET_CONTINENTS } from "../../utils/Queries";
    import CustomTreeItem from "../CustomTreeItem";
    import { getModifiedData } from "../../utils/Shared";
    
    const Tree = () => {
      // Get all continents on first render
      const { loading, data: allContinents } = useQuery(GET_CONTINENTS);
      // Data to render all tree items from
      const [treeItemsData, setTreeItemsData] = useState([]);
    
      // Set treeItemsData with continents recieved
      useEffect(() => {
        if (allContinents?.data?.results) {
          setTreeItemsData(allContinents?.data?.results);
        }
      }, [allContinents]);
    
      // Add new data in its correct place in treeItemsData array
      const appendNewData = (nodeId, data) => {
        const treeItemsDataClone = JSON.parse(JSON.stringify(treeItemsData)); // Deep Copy
    
        // getModifiedData is the recursive function (will be shown below alone)
        const newData = getModifiedData(treeItemsDataClone, nodeId, data);
    
        setTreeItemsData(newData); // set the rendered array with the modified array
      };
    
      // Render children items recursively
      const renderChild = (node) => {
        return (
          <CustomTreeItem
            key={node.objectId}
            classes={{ content: styles.treeItemContent }}
            typename={node.__typename}
            appendNewData={appendNewData}
            nodeId={node.objectId}
            label={node.name}
          >
            {/* If children is an object with a count key > 0, render a dummy treeItem to show expand icon on parent node */}
            {node.children &&
              (node.children.count > 0 ? (
                <CustomTreeItem nodeId="1" />
              ) : (
                node.children.length &&
                node.children.map((child: any) => renderChild(child.node)) // Recursively rendering children if array is found
              ))}
          </CustomTreeItem>
        );
      };
    
      // Show a loader until query resolve
      if (loading) return <CircularProgress />;
      else if (allContinents)
        return (
          <TreeView
            defaultCollapseIcon={<ExpandMoreIcon />}
            defaultExpandIcon={<ChevronRightIcon />}
            sx={{ height: 240, flexGrow: 1, maxWidth: 400, overflowY: "auto" }}
          >
            {treeItemsData.map((continent: any) => {
              return renderChild(continent.node);
            })}
          </TreeView>
        );
      else return <></>;
    };
    
    export default Tree;
    
    


    이제 재귀 함수의 경우 원래 배열, 새 데이터를 찾아서 삽입할 노드 ID 및 삽입할 새 데이터와 같은 매개 변수를 사용합니다.

    이 기능은 발견되었지만here 특정 요구 사항에 맞게 사용자 정의되었습니다.

    /*
        Original Answer: https://stackoverflow.com/a/15524326
        @Description: Searches for a specific object in nested objects or arrays according to "objectId" key
        @Params: originalData => The original array or object to search in
                 nodeId => the id to compare to objectId field
                 dataToBeAdded => new data to be added ad children to found node
        @Returns: Modified original data
      */
    export const getModifiedData = (
      originalData: any,
      nodeId: string,
      dataToBeAdded: any
    ) => {
      let result = null;
      const originalDataCopy = JSON.parse(JSON.stringify(originalData)); // Deep copy
    
      if (originalData instanceof Array) {
        for (let i = 0; i < originalDataCopy.length; i++) {
          result = getModifiedData(originalDataCopy[i], nodeId, dataToBeAdded);
    
          if (result) {
            originalDataCopy[i] = result;
          }
        }
      } else {
        for (let prop in originalDataCopy) {
          if (prop === "objectId") {
            if (originalDataCopy[prop] === nodeId) {
              originalDataCopy.children = dataToBeAdded;
              return originalDataCopy;
            }
          }
    
          if (
            originalDataCopy[prop] instanceof Object ||
            originalDataCopy[prop] instanceof Array
          ) {
            result = getModifiedData(originalDataCopy[prop], nodeId, dataToBeAdded);
            if (result) {
              originalDataCopy[prop] = result;
              break;
            }
          }
        }
      }
    
      return originalDataCopy;
    };
    
    


    상태에서 쉽게 설정할 수 있도록 수정된 배열을 반환합니다.

    긴 코드 스니펫에 대해 죄송하지만 다소 복잡하고 모든 코드를 노출하고 싶었습니다. 반응에서 back4app 데이터베이스 및 graphql로 작업하는 것은 문서에서 명확하지 않았기 때문에 이러한 단계도 제공하고 싶었습니다.

    이 기사가 유사한 기능을 구현하는 사람에게 도움이 되기를 바랍니다.

    좋은 웹페이지 즐겨찾기