整体思路
-
数据结构设计
- 使用递归的数据结构(
TreeNode
)表示树形数据 - 每个节点包含
id
、name
、可选的children
数组和selected
状态
- 使用递归的数据结构(
-
状态管理
- 使用
useState
在组件内部维护树状态的副本 - 通过
deepCopyTreeData
函数进行深拷贝,避免直接修改原始数据
- 使用
-
核心功能实现
checkChildrenStatus
:检查子节点状态,用于确定父节点的选中状态updateNodeStatus
:递归更新节点状态,实现状态联动效果findUpdatedNode
:在更新后的树中查找特定节点
-
状态同步逻辑
- 父节点选中/取消时,所有子节点跟随变化
- 子节点全部选中时,父节点自动选中
- 子节点全部未选中时,父节点自动取消选中
-
组件渲染
- 使用递归的
renderTreeNodes
函数渲染任意深度的树结构 - 每个节点包含复选框和名称,子节点通过缩进展示层级关系
- 使用递归的
-
外部交互
- 通过
onChange
回调将状态变化通知给父组件 - 使用
useEffect
监听外部数据变化,同步更新内部状态
- 通过
组件目录结构
代码实现
example\App.tsx
import React from "react";
import { Tree, type TreeNode } from "../packages";
export default function App() {
const data: TreeNode[] = [
{
id: 1,
name: "Node 1",
children: [
{ id: 2, name: "Node 1.1", selected: true },
{
id: 3,
name: "Node 1.2",
selected: false,
children: [
{ id: 4, name: "Node 1.2.1", selected: false },
{ id: 5, name: "Node 1.2.2", selected: false },
],
},
],
selected: true,
},
{ id: 6, name: "Node 2", selected: false },
];
function onChange(node: TreeNode) {
console.log(node);
}
return (
<div>
<Tree data={data} onChange={onChange} />
</div>
);
};
example\index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
example\main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<App />
);
packages\Tree\src\tree.tsx
import React, { useEffect, useState } from "react";
import { TreeNode, TreeProps } from "./types";
// 检查所有子节点状态并返回父节点应该的状态
const checkChildrenStatus = (node: TreeNode): boolean => {
if (!node.children || node.children.length === 0) {
return !!node.selected;
}
// 检查是否所有子节点都被选中
const allSelected = node.children.every((child) =>
child.children && child.children.length > 0
? checkChildrenStatus(child)
: !!child.selected
);
return allSelected;
};
// 深度复制树数据
const deepCopyTreeData = (data: TreeNode[]): TreeNode[] => {
return data.map((node) => ({
...node,
children: node.children ? deepCopyTreeData(node.children) : undefined,
}));
};
// 更新节点状态
const updateNodeStatus = (
nodes: TreeNode[],
nodeId: string | number,
status: boolean
): TreeNode[] => {
return nodes.map((node) => {
if (node.id === nodeId) {
// 更新当前节点
const updatedNode = { ...node, selected: status };
// 如果有子节点,递归更新所有子节点
if (node.children && node.children.length > 0) {
updatedNode.children = updateNodeStatus(node.children, nodeId, status);
// 再对所有子节点应用相同的状态
updatedNode.children = updatedNode.children.map((child) => ({
...child,
selected: status,
children: child.children
? child.children.map((grandChild) => ({
...grandChild,
selected: status,
}))
: undefined,
}));
}
return updatedNode;
} else if (node.children && node.children.length > 0) {
// 递归检查子节点
const updatedChildren = updateNodeStatus(node.children, nodeId, status);
// 检查更新后的子节点状态以决定当前节点状态
const allChildrenSelected = updatedChildren.every(
(child) => !!child.selected
);
const noChildrenSelected = updatedChildren.every(
(child) => !child.selected
);
let nodeStatus = node.selected;
if (allChildrenSelected) {
nodeStatus = true;
} else if (noChildrenSelected) {
nodeStatus = false;
}
return {
...node,
selected: nodeStatus,
children: updatedChildren,
};
}
return node;
});
};
const Tree: React.FC<TreeProps> = ({ data, onChange }) => {
const [treeData, setTreeData] = useState<TreeNode[]>(deepCopyTreeData(data));
// 处理节点选择状态变化
const handleNodeChange = (node: TreeNode, checked: boolean) => {
// 创建更新后的树数据
const updatedTreeData = updateNodeStatus(
deepCopyTreeData(treeData),
node.id,
checked
);
// 更新状态
setTreeData(updatedTreeData);
// 找到更新后的节点并调用onChange
const findUpdatedNode = (
nodes: TreeNode[],
id: string | number
): TreeNode | undefined => {
for (const n of nodes) {
if (n.id === id) return n;
if (n.children) {
const found = findUpdatedNode(n.children, id);
if (found) return found;
}
}
return undefined;
};
const updatedNode = findUpdatedNode(updatedTreeData, node.id);
if (updatedNode && onChange) {
onChange(updatedNode);
}
};
// 渲染树节点
const renderTreeNodes = (nodes: TreeNode[]) => {
return nodes.map((node) => (
<div key={node.id} className="tree-node">
<input
type="checkbox"
checked={!!node.selected}
onChange={(e) => handleNodeChange(node, e.target.checked)}
/>
<span>{node.name}</span>
{node.children && node.children.length > 0 && (
<div className="children-container" style={{ marginLeft: "20px" }}>
{renderTreeNodes(node.children)}
</div>
)}
</div>
));
};
// 当外部data props变化时更新内部状态
useEffect(() => {
setTreeData(deepCopyTreeData(data));
}, [data]);
return <div className="tree">{renderTreeNodes(treeData)}</div>;
};
export default Tree;
packages\Tree\src\types.tsx
export interface TreeNode {
id: string | number;
name: string;
children?: TreeNode[];
selected: boolean;
}
export interface TreeProps {
data: TreeNode[];
onChange: (node: TreeNode) => void;
}
packages\Tree\style\index.tsx (暂时无样式编写)
packages\Tree\tree.tsx
import Tree from "./src/tree";
export * from "./src/types";
export { Tree };
packages\index.tsx
import { Tree } from "./Tree";
export * from "./Tree";
export { Tree };
packages\vite.d.ts
/// <reference types="vite/client" />
package.json (通过 npm init -y 生成)
这样直接启动会有一个警告:The CJS build of Vite's Node API is deprecated. See https://vite.dev/guide/troubleshooting.html#vite-cjs-node-api-deprecated for more details.
原因是 node 项目模块化未指定类型 type: module
{
"name": "react-tree",
"version": "1.0.0",
"main": "index.js",
"type": "module",
"directories": {
"example": "example"
},
"scripts": {
"dev": "vite",
"build": "vite build"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"devDependencies": {
"@types/node": "^22.15.29",
"@types/react": "^19.1.6",
"@types/react-dom": "^19.1.5",
"@vitejs/plugin-react-swc": "^3.10.0", // 编译 react
"vite": "^6.3.5", // 构建工具
"vite-plugin-dts": "^4.5.4" // 生成打包后的声明文件(便于代码提示)
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0"
}
}
tsconfig.json(通过 tsc --init 生成)
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
"jsx": "react-jsx", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "commonjs", /* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
// "outDir": "./", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}
这样直接生成的会有一个问题:无法使用 JSX,除非提供了 "--jsx" 标志。
所以我们需要让 ts 配置(tsconfig.json)为支持 jsx 语法。"jsx": "react-jsx",
vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import path from "node:path";
export default defineConfig({
plugins: [react()],
root: path.resolve(__dirname, "example"), // 如果不配置,默认会从根目录找 index.html
server: {
port: 3000,
open: true,
},
});