MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

React 处理嵌套列表的渲染方式

2024-08-255.6k 阅读

1. React 列表渲染基础回顾

在深入探讨嵌套列表渲染之前,我们先来回顾一下 React 中基本列表渲染的方式。React 提供了 map 方法来处理数组的渲染。例如,假设有一个简单的数组 names

import React from 'react';

const names = ['Alice', 'Bob', 'Charlie'];

const App = () => {
  return (
    <ul>
      {names.map((name, index) => (
        <li key={index}>{name}</li>
      ))}
    </ul>
  );
};

export default App;

在上述代码中,我们通过 map 方法遍历 names 数组,并为每个元素创建一个 <li> 标签。这里的 key 属性非常重要,它是 React 用来识别列表中每个元素的唯一标识符,有助于提高渲染效率,特别是在列表项增删改时。

2. 嵌套列表结构分析

嵌套列表是指列表项本身又包含一个列表。常见的场景比如树形结构的数据,像文件目录,其中文件夹可能包含子文件夹和文件。在 JSON 格式中,这种结构可能如下表示:

[
  {
    "title": "Folder 1",
    "type": "folder",
    "children": [
      {
        "title": "File 1",
        "type": "file"
      },
      {
        "title": "Folder 1 - Sub",
        "type": "folder",
        "children": [
          {
            "title": "File 2",
            "type": "file"
          }
        ]
      }
    ]
  },
  {
    "title": "Folder 2",
    "type": "folder",
    "children": [
      {
        "title": "File 3",
        "type": "file"
      }
    ]
  }
]

这种结构的特点是每个对象可能有一个 children 属性,它又是一个包含类似结构对象的数组,形成了嵌套关系。

3. 递归渲染嵌套列表

处理嵌套列表渲染的常用方法是使用递归。递归函数会不断调用自身来处理每一层的嵌套。下面我们来看一个简单的 React 组件示例,用于渲染上述树形结构的嵌套列表:

import React from 'react';

const data = [
  {
    "title": "Folder 1",
    "type": "folder",
    "children": [
      {
        "title": "File 1",
        "type": "file"
      },
      {
        "title": "Folder 1 - Sub",
        "type": "folder",
        "children": [
          {
            "title": "File 2",
            "type": "file"
          }
        ]
      }
    ]
  },
  {
    "title": "Folder 2",
    "type": "folder",
    "children": [
      {
        "title": "File 3",
        "type": "file"
      }
    ]
  }
];

const renderItem = (item) => {
  if (item.type === 'file') {
    return <li key={item.title}>{item.title}</li>;
  } else {
    return (
      <li key={item.title}>
        {item.title}
        <ul>
          {item.children.map(renderItem)}
        </ul>
      </li>
    );
  }
};

const App = () => {
  return (
    <ul>
      {data.map(renderItem)}
    </ul>
  );
};

export default App;

在上述代码中,我们定义了 renderItem 函数,它根据 itemtype 来决定如何渲染。如果是文件,直接渲染文件名;如果是文件夹,先渲染文件夹名,然后递归渲染其 children。最后在 App 组件中,通过 map 方法将整个数据数组进行渲染。

3.1 递归渲染的原理剖析

递归渲染的核心在于函数的自我调用。每次调用 renderItem 函数时,它会根据当前 item 的类型进行不同的处理。对于文件夹类型的 item,它会再次调用 renderItem 来处理其 children 数组中的每一个元素。这样就一层一层地深入到嵌套结构的最底层,从而完整地渲染出整个嵌套列表。

3.2 递归渲染的注意事项

  • 性能问题:在大型嵌套列表中,递归渲染可能会导致性能问题。因为每次递归调用都会创建新的函数调用栈,如果嵌套层次过深,可能会导致栈溢出。为了避免这种情况,可以考虑使用迭代的方式替代递归,或者优化数据结构以减少嵌套深度。
  • Key 的使用:在递归渲染中,确保每个列表项都有唯一的 key 至关重要。在上述代码中,我们使用 item.title 作为 key,前提是 title 在整个数据结构中是唯一的。如果不是,需要确保生成唯一的标识符,否则 React 在更新列表时可能会出现错误。

4. 使用 React.memo 优化嵌套列表渲染

在嵌套列表渲染中,如果列表项的结构较为复杂,频繁的重新渲染可能会导致性能问题。React.memo 可以帮助我们优化这一情况。React.memo 是一个高阶组件,它会对组件的 props 进行浅比较,如果 props 没有变化,组件将不会重新渲染。

假设我们有一个用于渲染单个列表项的组件 ListItem

import React from'react';

const ListItem = React.memo(({ item }) => {
  if (item.type === 'file') {
    return <li key={item.title}>{item.title}</li>;
  } else {
    return (
      <li key={item.title}>
        {item.title}
        <ul>
          {item.children.map((child) => <ListItem item={child} />)}
        </ul>
      </li>
    );
  }
});

const App = () => {
  const data = [
    {
      "title": "Folder 1",
      "type": "folder",
      "children": [
        {
          "title": "File 1",
          "type": "file"
        },
        {
          "title": "Folder 1 - Sub",
          "type": "folder",
          "children": [
            {
              "title": "File 2",
              "type": "file"
            }
          ]
        }
      ]
    },
    {
      "title": "Folder 2",
      "type": "folder",
      "children": [
        {
          "title": "File 3",
          "type": "file"
        }
      ]
    }
  ];

  return (
    <ul>
      {data.map((item) => <ListItem item={item} />)}
    </ul>
  );
};

export default App;

在上述代码中,ListItem 组件被 React.memo 包裹。这样,只有当 item prop 发生变化时,ListItem 组件才会重新渲染。这在一定程度上优化了嵌套列表的渲染性能,特别是当列表项数据变化较小时。

4.1 React.memo 的浅比较原理

React.memo 对 props 进行浅比较。浅比较意味着它只会比较对象或数组的第一层属性。例如,如果一个对象包含一个内部对象,而内部对象发生了变化,但外层对象的引用没有改变,React.memo 不会认为 props 发生了变化,组件也就不会重新渲染。这种机制在大多数情况下是有效的,但在处理复杂数据结构时需要注意。

4.2 处理复杂数据结构时的 React.memo 优化

当列表项的数据结构非常复杂,内部属性变化频繁时,单纯的浅比较可能无法满足需求。此时,可以使用 useMemouseCallback 等钩子来确保传递给 ListItem 的 props 是稳定的。例如,如果 ListItem 需要一个函数作为 prop,并且这个函数依赖于某些外部状态,可以使用 useCallback 来确保函数的引用在依赖项不变时保持不变。

import React, { useCallback } from'react';

const ListItem = React.memo(({ item, onItemClick }) => {
  if (item.type === 'file') {
    return <li key={item.title} onClick={() => onItemClick(item)}>{item.title}</li>;
  } else {
    return (
      <li key={item.title}>
        {item.title}
        <ul>
          {item.children.map((child) => <ListItem item={child} onItemClick={onItemClick} />)}
        </ul>
      </li>
    );
  }
});

const App = () => {
  const data = [
    {
      "title": "Folder 1",
      "type": "folder",
      "children": [
        {
          "title": "File 1",
          "type": "file"
        },
        {
          "title": "Folder 1 - Sub",
          "type": "folder",
          "children": [
            {
              "title": "File 2",
              "type": "file"
            }
          ]
        }
      ]
    },
    {
      "title": "Folder 2",
      "type": "folder",
      "children": [
        {
          "title": "File 3",
          "type": "file"
        }
      ]
    }
  ];

  const handleItemClick = useCallback((item) => {
    console.log(`Clicked on ${item.title}`);
  }, []);

  return (
    <ul>
      {data.map((item) => <ListItem item={item} onItemClick={handleItemClick} />)}
    </ul>
  );
};

export default App;

在上述代码中,handleItemClick 函数使用 useCallback 进行了包裹,依赖项为空数组,这意味着只要组件渲染一次,handleItemClick 的引用就不会改变。这样,当传递给 ListItem 时,ListItemonItemClick prop 就不会因为父组件的重新渲染而改变,从而避免了不必要的重新渲染。

5. 基于状态管理的嵌套列表操作

在实际应用中,我们往往需要对嵌套列表进行操作,如展开/折叠文件夹、添加/删除列表项等。这就需要结合 React 的状态管理来实现。

5.1 使用 useState 实现展开/折叠功能

我们以树形结构的嵌套列表为例,为每个文件夹添加展开/折叠功能。

import React, { useState } from'react';

const data = [
  {
    "title": "Folder 1",
    "type": "folder",
    "children": [
      {
        "title": "File 1",
        "type": "file"
      },
      {
        "title": "Folder 1 - Sub",
        "type": "folder",
        "children": [
          {
            "title": "File 2",
            "type": "file"
          }
        ]
      }
    ]
  },
  {
    "title": "Folder 2",
    "type": "folder",
    "children": [
      {
        "title": "File 3",
        "type": "file"
      }
    ]
  }
];

const App = () => {
  const [expandedFolders, setExpandedFolders] = useState([]);

  const toggleFolder = (folderTitle) => {
    if (expandedFolders.includes(folderTitle)) {
      setExpandedFolders(expandedFolders.filter(title => title!== folderTitle));
    } else {
      setExpandedFolders([...expandedFolders, folderTitle]);
    }
  };

  const renderItem = (item) => {
    if (item.type === 'file') {
      return <li key={item.title}>{item.title}</li>;
    } else {
      const isExpanded = expandedFolders.includes(item.title);
      return (
        <li key={item.title}>
          <span onClick={() => toggleFolder(item.title)}>{isExpanded? '−' : '+'} {item.title}</span>
          {isExpanded && (
            <ul>
              {item.children.map(renderItem)}
            </ul>
          )}
        </li>
      );
    }
  };

  return (
    <ul>
      {data.map(renderItem)}
    </ul>
  );
};

export default App;

在上述代码中,我们使用 useState 来管理展开的文件夹标题数组 expandedFolderstoggleFolder 函数用于切换文件夹的展开状态。在 renderItem 函数中,根据文件夹是否在 expandedFolders 中来决定是否渲染其 children

5.2 使用 Redux 进行复杂状态管理

对于更复杂的嵌套列表操作,如批量删除、移动列表项等,使用 Redux 这样的状态管理库会更加合适。以下是一个简单的示例,展示如何使用 Redux 来管理嵌套列表的删除操作。

首先,安装 Redux 和 React - Redux:

npm install redux react-redux

创建 Redux 的 storereduceraction

// actions.js
const DELETE_ITEM = 'DELETE_ITEM';

const deleteItem = (title) => ({
  type: DELETE_ITEM,
  payload: title
});

export { deleteItem };

// reducer.js
const initialState = [
  {
    "title": "Folder 1",
    "type": "folder",
    "children": [
      {
        "title": "File 1",
        "type": "file"
      },
      {
        "title": "Folder 1 - Sub",
        "type": "folder",
        "children": [
          {
            "title": "File 2",
            "type": "file"
          }
        ]
      }
    ]
  },
  {
    "title": "Folder 2",
    "type": "folder",
    "children": [
      {
        "title": "File 3",
        "type": "file"
      }
    ]
  }
];

const listReducer = (state = initialState, action) => {
  switch (action.type) {
    case DELETE_ITEM:
      return state.filter(item => {
        if (item.type === 'file' && item.title === action.payload) {
          return false;
        }
        if (item.type === 'folder') {
          item.children = item.children.filter(child => {
            if (child.type === 'file' && child.title === action.payload) {
              return false;
            }
            if (child.type === 'folder' && child.title === action.payload) {
              return false;
            }
            return true;
          });
          return item.children.length > 0;
        }
        return true;
      });
    default:
      return state;
  }
};

export default listReducer;

// store.js
import { createStore } from'redux';
import listReducer from './reducer';

const store = createStore(listReducer);

export default store;

在 React 组件中使用 Redux:

import React from'react';
import { useSelector, useDispatch } from'react-redux';
import { deleteItem } from './actions';

const App = () => {
  const data = useSelector(state => state);
  const dispatch = useDispatch();

  const renderItem = (item) => {
    if (item.type === 'file') {
      return (
        <li key={item.title}>
          {item.title}
          <button onClick={() => dispatch(deleteItem(item.title))}>Delete</button>
        </li>
      );
    } else {
      return (
        <li key={item.title}>
          {item.title}
          <ul>
            {item.children.map(renderItem)}
          </ul>
        </li>
      );
    }
  };

  return (
    <ul>
      {data.map(renderItem)}
    </ul>
  );
};

export default App;

在上述代码中,我们定义了 DELETE_ITEM 动作类型和 deleteItem 动作创建函数。listReducer 处理删除操作,通过过滤掉要删除的列表项来更新状态。在 App 组件中,使用 useSelector 获取 Redux 状态,useDispatch 来分发 deleteItem 动作。

6. 虚拟列表技术在嵌套列表中的应用

对于大型嵌套列表,一次性渲染所有列表项可能会导致性能问题,特别是在移动设备上。虚拟列表技术可以有效地解决这个问题。虚拟列表只渲染当前可见区域的列表项,当用户滚动时,动态地加载新的列表项。

6.1 使用 react - virtualized 库实现虚拟列表

react - virtualized 是一个常用的 React 虚拟列表库。下面是一个如何在嵌套列表中使用它的示例。

首先,安装 react - virtualized

npm install react - virtualized

假设我们有一个嵌套结构的数据,并且要在虚拟列表中渲染它:

import React from'react';
import { List } from'react - virtualized';
import 'react - virtualized/styles.css';

const data = [
  {
    "title": "Folder 1",
    "type": "folder",
    "children": [
      {
        "title": "File 1",
        "type": "file"
      },
      {
        "title": "Folder 1 - Sub",
        "type": "folder",
        "children": [
          {
            "title": "File 2",
            "type": "file"
          }
        ]
      }
    ]
  },
  {
    "title": "Folder 2",
    "type": "folder",
    "children": [
      {
        "title": "File 3",
        "type": "file"
      }
    ]
  }
];

const rowRenderer = ({ index, key, style }) => {
  const item = data[index];
  if (item.type === 'file') {
    return (
      <div key={key} style={style}>
        {item.title}
      </div>
    );
  } else {
    return (
      <div key={key} style={style}>
        {item.title}
        {/* 这里可以进一步递归渲染子列表,但 react - virtualized 本身不直接支持嵌套虚拟列表,需要更复杂的处理 */}
      </div>
    );
  }
};

const App = () => {
  return (
    <List
      height={400}
      rowCount={data.length}
      rowHeight={30}
      rowRenderer={rowRenderer}
      width={300}
    />
  );
};

export default App;

在上述代码中,我们使用 react - virtualizedList 组件。rowRenderer 函数定义了每个列表项的渲染方式。heightwidthrowCountrowHeight 等属性用于配置虚拟列表的基本参数。

6.2 处理嵌套结构的虚拟列表

直接在 react - virtualized 中处理嵌套结构的虚拟列表比较复杂,因为它本身不直接支持嵌套虚拟列表。一种解决方案是将嵌套结构扁平化,然后在渲染时通过一些逻辑来还原嵌套视觉效果。例如,可以为每个列表项添加一个 depth 属性来表示其在嵌套结构中的层级。

import React from'react';
import { List } from'react - virtualized';
import 'react - virtualized/styles.css';

const originalData = [
  {
    "title": "Folder 1",
    "type": "folder",
    "children": [
      {
        "title": "File 1",
        "type": "file"
      },
      {
        "title": "Folder 1 - Sub",
        "type": "folder",
        "children": [
          {
            "title": "File 2",
            "type": "file"
          }
        ]
      }
    ]
  },
  {
    "title": "Folder 2",
    "type": "folder",
    "children": [
      {
        "title": "File 3",
        "type": "file"
      }
    ]
  }
];

const flattenData = (data, depth = 0) => {
  return data.reduce((acc, item) => {
    if (item.type === 'file') {
      acc.push({...item, depth });
    } else {
      acc.push({...item, depth });
      acc = acc.concat(flattenData(item.children, depth + 1));
    }
    return acc;
  }, []);
};

const flatData = flattenData(originalData);

const rowRenderer = ({ index, key, style }) => {
  const item = flatData[index];
  const indent =' '.repeat(item.depth * 2);
  if (item.type === 'file') {
    return (
      <div key={key} style={style}>
        {indent}{item.title}
      </div>
    );
  } else {
    return (
      <div key={key} style={style}>
        {indent}{item.title}
      </div>
    );
  }
};

const App = () => {
  return (
    <List
      height={400}
      rowCount={flatData.length}
      rowHeight={30}
      rowRenderer={rowRenderer}
      width={300}
    />
  );
};

export default App;

在上述代码中,flattenData 函数将嵌套结构的数据扁平化,并为每个项添加 depth 属性。rowRenderer 函数根据 depth 属性来缩进列表项,从而呈现出嵌套的视觉效果。虽然这种方式不能完全模拟真实的嵌套虚拟列表,但在一定程度上可以提高大型嵌套列表的渲染性能。

7. 无障碍性与嵌套列表渲染

在前端开发中,无障碍性是非常重要的。对于嵌套列表的渲染,我们需要确保屏幕阅读器等辅助技术能够正确理解和导航列表结构。

7.1 使用语义化标签

在 React 中渲染嵌套列表时,应尽量使用语义化标签。例如,使用 <ul><li> 标签来构建列表结构,而不是使用 <div> 等非语义化标签来模拟列表。这样屏幕阅读器可以识别列表的层次结构,为视障用户提供更好的导航体验。

import React from'react';

const data = [
  {
    "title": "Folder 1",
    "type": "folder",
    "children": [
      {
        "title": "File 1",
        "type": "file"
      },
      {
        "title": "Folder 1 - Sub",
        "type": "folder",
        "children": [
          {
            "title": "File 2",
            "type": "file"
          }
        ]
      }
    ]
  },
  {
    "title": "Folder 2",
    "type": "folder",
    "children": [
      {
        "title": "File 3",
        "type": "file"
      }
    ]
  }
];

const renderItem = (item) => {
  if (item.type === 'file') {
    return <li key={item.title}>{item.title}</li>;
  } else {
    return (
      <li key={item.title}>
        {item.title}
        <ul>
          {item.children.map(renderItem)}
        </ul>
      </li>
    );
  }
};

const App = () => {
  return (
    <ul>
      {data.map(renderItem)}
    </ul>
  );
};

export default App;

7.2 提供 ARIA 角色和属性

在某些情况下,语义化标签可能不足以提供足够的无障碍信息。这时可以使用 ARIA(Accessible Rich Internet Applications)角色和属性。例如,如果列表项有展开/折叠功能,可以添加 aria - expanded 属性来表示当前状态。

import React, { useState } from'react';

const data = [
  {
    "title": "Folder 1",
    "type": "folder",
    "children": [
      {
        "title": "File 1",
        "type": "file"
      },
      {
        "title": "Folder 1 - Sub",
        "type": "folder",
        "children": [
          {
            "title": "File 2",
            "type": "file"
          }
        ]
      }
    ]
  },
  {
    "title": "Folder 2",
    "type": "folder",
    "children": [
      {
        "title": "File 3",
        "type": "file"
      }
    ]
  }
];

const App = () => {
  const [expandedFolders, setExpandedFolders] = useState([]);

  const toggleFolder = (folderTitle) => {
    if (expandedFolders.includes(folderTitle)) {
      setExpandedFolders(expandedFolders.filter(title => title!== folderTitle));
    } else {
      setExpandedFolders([...expandedFolders, folderTitle]);
    }
  };

  const renderItem = (item) => {
    if (item.type === 'file') {
      return <li key={item.title}>{item.title}</li>;
    } else {
      const isExpanded = expandedFolders.includes(item.title);
      return (
        <li key={item.title}>
          <span
            onClick={() => toggleFolder(item.title)}
            aria - expanded={isExpanded}
            role="button"
          >
            {isExpanded? '−' : '+'} {item.title}
          </span>
          {isExpanded && (
            <ul>
              {item.children.map(renderItem)}
            </ul>
          )}
        </li>
      );
    }
  };

  return (
    <ul>
      {data.map(renderItem)}
    </ul>
  );
};

export default App;

在上述代码中,aria - expanded 属性表示文件夹的展开状态,role="button" 使屏幕阅读器将该元素识别为按钮,从而提供更好的交互提示。通过这些方式,可以使嵌套列表在不同的辅助技术下都能被正确地访问和使用。