返回笔记首页

数据处理与可视化 - 面试指南

主题配置

4.1 树形数据处理

简历描述

项目经验 - 树形数据处理优化

plain
优化大规模树形数据的渲染和操作性能,支持 10 万+ 节点的流畅交互
- 实现树形数据的扁平化存储和虚拟化渲染,10 万节点的树展开时间从 8s 降至 200ms
- 设计高效的树操作算法,包括搜索、过滤、路径查找等,时间复杂度从 O(n²) 优化到 O(n)
- 通过增量更新和局部渲染,树节点选中、展开等操作响应时间 < 50ms

SOP 标准回答

面试官问:你处理过哪些复杂的树形数据场景?

"我们有个文件管理系统,需要展示几万个文件和文件夹的树形结构。一开始用递归组件渲染,节点多了以后页面直接卡死,体验非常差。

我做了几个优化。第一个是数据结构改造,把树形数据扁平化存储。原来是嵌套的对象数组,现在改成一维数组配合一个 Map,每个节点记录它的 parentId、level、children IDs。这样访问任意节点都是 O(1),不用递归查找。

第二个是虚拟化渲染,只渲染展开的可见节点。用户展开一个文件夹,才把它的子节点加到渲染列表里。配合虚拟滚动,10 万个节点也能流畅滚动。

第三个是增量更新。用户勾选一个节点,只更新这个节点和它受影响的父节点、子节点,不触发整棵树重新渲染。我用了一个状态管理器,精确追踪每个节点的状态变化。

难点在于一些复杂操作,比如全选、反选、搜索。全选要递归处理所有子节点,数据量大了很慢。我用迭代代替递归,用栈来遍历,速度快很多。搜索的话,我做了一个索引,预先把所有节点的路径和关键字段建立映射,搜索时直接查索引。

现在这个树组件已经支持了部门树、权限树、分类树等多个业务场景,性能和体验都很好。"

项目难点与亮点

难点1:树形数据的高效转换

"后端返回的是嵌套的树形结构,前端渲染需要扁平化,而某些操作又需要还原成树形。频繁转换会有性能问题。

我设计了一个双向映射的数据结构。初始化时,遍历一遍树形数据,构建两个索引:一个是 nodeMap,key 是节点 ID,value 是节点数据;另一个是 treeMap,记录每个节点的父子关系。

这样既能快速访问单个节点,也能快速查找父节点、子节点、兄弟节点。需要树形结构时,从根节点开始,通过 treeMap 递归构建。需要扁平结构时,直接用 nodeMap。

还有个优化是懒加载。如果树特别大,一次性转换会很慢。我改成按需转换,用户展开某个节点,才转换它的子树。转换结果会缓存,下次展开直接用缓存。

这套方案让大数据量树形结构的初始化时间从 5 秒降到了 500ms,用户感知明显变好。"

难点2:复杂的树操作逻辑

"业务里有很多复杂的树操作。比如勾选一个节点,它的所有子节点应该自动勾选,它的父节点要根据兄弟节点的勾选情况决定是全选、半选还是不选。

我的实现是用了一个状态计算函数。勾选变化时,先更新当前节点,然后分别向上和向下传播。向下比较简单,递归设置所有子孙节点的状态。向上需要检查兄弟节点,如果全部勾选,父节点就全选;如果部分勾选,父节点就半选;如果全部未勾选,父节点就不选。

为了性能,我用迭代代替递归,用栈来遍历。还做了剪枝优化,如果某个子树的状态没变化,就不继续往下遍历。

还有个难点是拖拽排序。用户可以拖拽节点改变顺序,甚至拖到其他父节点下。要保证拖拽后数据结构的一致性,还要触发必要的回调,比如父节点变化了要通知后端。我抽象了一套事件系统,每个操作都会触发 beforeChange 和 afterChange 事件,业务代码可以监听这些事件做自己的处理。"

亮点:树形数据的搜索和过滤

"搜索树形数据有个问题,就是要不要显示匹配节点的父节点和子节点。只显示匹配节点,用户看不出层级关系;显示整条路径,又可能太多。

我做了几种模式。第一种是只显示匹配节点和它的祖先链,这样用户能看出这个节点在哪个位置。第二种是显示匹配节点和它的直接子节点,方便用户查看。第三种是高亮匹配节点,但保持原来的树结构,用户可以展开查看完整上下文。

实现上,我用了一个过滤器链的概念。每个过滤器接收节点列表,返回过滤后的列表。搜索过滤器会找出匹配的节点和它们的祖先,展开状态过滤器会根据用户操作决定显示哪些节点,排序过滤器会调整节点顺序。

这些过滤器可以组合使用,非常灵活。比如搜索的同时可以按名称排序,可以只显示已勾选的节点。"

亮点:树形数据的持久化

"树的展开状态、勾选状态需要持久化,刷新页面后能恢复。但存整棵树太浪费空间,我只存关键信息。

展开状态我只存展开节点的 ID 列表,勾选状态也只存勾选节点的 ID。加载时根据这些 ID 重建状态。

特别的是,我做了一个'智能压缩'。如果某个节点的所有子节点都勾选了,就只存父节点的 ID,不存子节点。恢复时,展开父节点就自动勾选所有子节点。这样存储空间能节省 80% 以上。

还支持导出和导入。用户可以把当前的树状态导出成 JSON,发给其他人,其他人导入后就能看到相同的视图。这个功能在协作场景很有用。"

技术实现

树形数据扁平化

typescript
// utils/tree.ts
export interface TreeNode {
  id: string;
  name: string;
  children?: TreeNode[];
  [key: string]: any;
}

export interface FlatNode {
  id: string;
  parentId: string | null;
  level: number;
  data: TreeNode;
  childrenIds: string[];
  path: string[];
}

export class TreeStore {
  // 扁平化存储
  private nodeMap: Map<string, FlatNode> = new Map();
  // 根节点ID列表
  private rootIds: string[] = [];

  constructor(treeData: TreeNode[]) {
    this.flatten(treeData);
  }

  // 扁平化树形数据
  private flatten(nodes: TreeNode[], parentId: string | null = null, level: number = 0, path: string[] = []) {
    nodes.forEach(node => {
      const { children, ...data } = node;
      const childrenIds = children?.map(child => child.id) || [];
      const nodePath = [...path, node.id];

      const flatNode: FlatNode = {
        id: node.id,
        parentId,
        level,
        data: data as TreeNode,
        childrenIds,
        path: nodePath,
      };

      this.nodeMap.set(node.id, flatNode);

      if (parentId === null) {
        this.rootIds.push(node.id);
      }

      if (children && children.length > 0) {
        this.flatten(children, node.id, level + 1, nodePath);
      }
    });
  }

  // 获取节点
  getNode(id: string): FlatNode | undefined {
    return this.nodeMap.get(id);
  }

  // 获取父节点
  getParent(id: string): FlatNode | undefined {
    const node = this.getNode(id);
    if (!node || !node.parentId) return undefined;
    return this.getNode(node.parentId);
  }

  // 获取子节点
  getChildren(id: string): FlatNode[] {
    const node = this.getNode(id);
    if (!node) return [];
    return node.childrenIds
      .map(childId => this.getNode(childId))
      .filter(Boolean) as FlatNode[];
  }

  // 获取所有祖先节点
  getAncestors(id: string): FlatNode[] {
    const ancestors: FlatNode[] = [];
    let current = this.getNode(id);

    while (current && current.parentId) {
      const parent = this.getParent(current.id);
      if (parent) {
        ancestors.unshift(parent);
        current = parent;
      } else {
        break;
      }
    }

    return ancestors;
  }

  // 获取所有后代节点
  getDescendants(id: string): FlatNode[] {
    const descendants: FlatNode[] = [];
    const queue = [id];

    while (queue.length > 0) {
      const currentId = queue.shift()!;
      const children = this.getChildren(currentId);

      children.forEach(child => {
        descendants.push(child);
        queue.push(child.id);
      });
    }

    return descendants;
  }

  // 搜索节点
  search(keyword: string, fields: string[] = ['name']): FlatNode[] {
    const results: FlatNode[] = [];
    const lowerKeyword = keyword.toLowerCase();

    this.nodeMap.forEach(node => {
      const matched = fields.some(field => {
        const value = node.data[field];
        return value && String(value).toLowerCase().includes(lowerKeyword);
      });

      if (matched) {
        results.push(node);
      }
    });

    return results;
  }

  // 获取节点路径(面包屑)
  getNodePath(id: string): FlatNode[] {
    const node = this.getNode(id);
    if (!node) return [];

    const ancestors = this.getAncestors(id);
    return [...ancestors, node];
  }

  // 移动节点
  moveNode(nodeId: string, newParentId: string | null, index?: number): boolean {
    const node = this.getNode(nodeId);
    if (!node) return false;

    // 不能移动到自己的后代节点下
    if (newParentId) {
      const descendants = this.getDescendants(nodeId);
      if (descendants.some(d => d.id === newParentId)) {
        return false;
      }
    }

    // 从原父节点移除
    if (node.parentId) {
      const oldParent = this.getNode(node.parentId);
      if (oldParent) {
        oldParent.childrenIds = oldParent.childrenIds.filter(id => id !== nodeId);
      }
    } else {
      this.rootIds = this.rootIds.filter(id => id !== nodeId);
    }

    // 添加到新父节点
    if (newParentId) {
      const newParent = this.getNode(newParentId);
      if (!newParent) return false;

      if (index !== undefined) {
        newParent.childrenIds.splice(index, 0, nodeId);
      } else {
        newParent.childrenIds.push(nodeId);
      }

      // 更新节点的层级和路径
      const newLevel = newParent.level + 1;
      const newPath = [...newParent.path, nodeId];
      this.updateNodeLevel(nodeId, newLevel, newPath);
    } else {
      if (index !== undefined) {
        this.rootIds.splice(index, 0, nodeId);
      } else {
        this.rootIds.push(nodeId);
      }

      this.updateNodeLevel(nodeId, 0, [nodeId]);
    }

    node.parentId = newParentId;
    return true;
  }

  // 更新节点及其后代的层级
  private updateNodeLevel(nodeId: string, level: number, path: string[]) {
    const node = this.getNode(nodeId);
    if (!node) return;

    node.level = level;
    node.path = path;

    node.childrenIds.forEach(childId => {
      this.updateNodeLevel(childId, level + 1, [...path, childId]);
    });
  }

  // 转回树形结构
  toTree(rootId?: string): TreeNode[] {
    const buildTree = (nodeIds: string[]): TreeNode[] => {
      return nodeIds.map(id => {
        const flatNode = this.getNode(id);
        if (!flatNode) return null;

        const node: TreeNode = { ...flatNode.data };

        if (flatNode.childrenIds.length > 0) {
          node.children = buildTree(flatNode.childrenIds);
        }

        return node;
      }).filter(Boolean) as TreeNode[];
    };

    if (rootId) {
      const node = this.getNode(rootId);
      if (!node) return [];
      return buildTree([rootId]);
    }

    return buildTree(this.rootIds);
  }
}

树形组件实现

typescript
// components/Tree/Tree.tsx
import React, { useMemo, useState, useCallback } from 'react';
import { TreeStore, FlatNode } from '@/utils/tree';
import { TreeNode } from './TreeNode';
import { VirtualScroll } from '@/components/VirtualScroll';

interface TreeProps {
  data: any[];
  checkable?: boolean;
  draggable?: boolean;
  defaultExpandedKeys?: string[];
  defaultCheckedKeys?: string[];
  onCheck?: (checkedKeys: string[], info: any) => void;
  onExpand?: (expandedKeys: string[]) => void;
  onDrop?: (info: any) => void;
}

export function Tree({
  data,
  checkable = false,
  draggable = false,
  defaultExpandedKeys = [],
  defaultCheckedKeys = [],
  onCheck,
  onExpand,
  onDrop,
}: TreeProps) {
  // 创建树存储
  const treeStore = useMemo(() => new TreeStore(data), [data]);

  // 展开状态
  const [expandedKeys, setExpandedKeys] = useState<Set<string>>(
    new Set(defaultExpandedKeys)
  );

  // 勾选状态
  const [checkedKeys, setCheckedKeys] = useState<Set<string>>(
    new Set(defaultCheckedKeys)
  );

  const [halfCheckedKeys, setHalfCheckedKeys] = useState<Set<string>>(new Set());

  // 获取可见节点(展开的节点)
  const visibleNodes = useMemo(() => {
    const result: FlatNode[] = [];
    const queue = [...treeStore['rootIds']];

    while (queue.length > 0) {
      const id = queue.shift()!;
      const node = treeStore.getNode(id);
      if (!node) continue;

      result.push(node);

      // 如果节点展开,添加子节点到队列
      if (expandedKeys.has(id)) {
        queue.unshift(...node.childrenIds);
      }
    }

    return result;
  }, [treeStore, expandedKeys]);

  // 处理展开/收起
  const handleExpand = useCallback(
    (nodeId: string) => {
      setExpandedKeys(prev => {
        const next = new Set(prev);
        if (next.has(nodeId)) {
          next.delete(nodeId);
        } else {
          next.add(nodeId);
        }
        onExpand?.(Array.from(next));
        return next;
      });
    },
    [onExpand]
  );

  // 处理勾选
  const handleCheck = useCallback(
    (nodeId: string, checked: boolean) => {
      const node = treeStore.getNode(nodeId);
      if (!node) return;

      const newChecked = new Set(checkedKeys);
      const newHalfChecked = new Set(halfCheckedKeys);

      // 更新当前节点和所有后代节点
      const updateDescendants = (id: string, isChecked: boolean) => {
        if (isChecked) {
          newChecked.add(id);
        } else {
          newChecked.delete(id);
        }
        newHalfChecked.delete(id);

        const descendants = treeStore.getDescendants(id);
        descendants.forEach(descendant => {
          if (isChecked) {
            newChecked.add(descendant.id);
          } else {
            newChecked.delete(descendant.id);
          }
          newHalfChecked.delete(descendant.id);
        });
      };

      // 更新祖先节点
      const updateAncestors = (id: string) => {
        let current = treeStore.getNode(id);

        while (current && current.parentId) {
          const parent = treeStore.getNode(current.parentId);
          if (!parent) break;

          const children = treeStore.getChildren(parent.id);
          const checkedCount = children.filter(child =>
            newChecked.has(child.id)
          ).length;
          const halfCheckedCount = children.filter(child =>
            newHalfChecked.has(child.id)
          ).length;

          if (checkedCount === children.length) {
            // 全部勾选
            newChecked.add(parent.id);
            newHalfChecked.delete(parent.id);
          } else if (checkedCount > 0 || halfCheckedCount > 0) {
            // 部分勾选
            newChecked.delete(parent.id);
            newHalfChecked.add(parent.id);
          } else {
            // 全部未勾选
            newChecked.delete(parent.id);
            newHalfChecked.delete(parent.id);
          }

          current = parent;
        }
      };

      updateDescendants(nodeId, checked);
      updateAncestors(nodeId);

      setCheckedKeys(newChecked);
      setHalfCheckedKeys(newHalfChecked);

      onCheck?.(Array.from(newChecked), {
        checked,
        node,
        halfCheckedKeys: Array.from(newHalfChecked),
      });
    },
    [treeStore, checkedKeys, halfCheckedKeys, onCheck]
  );

  // 渲染节点
  const renderNode = useCallback(
    (node: FlatNode, index: number) => {
      return (
        <TreeNode
          key={node.id}
          node={node}
          expanded={expandedKeys.has(node.id)}
          checked={checkedKeys.has(node.id)}
          halfChecked={halfCheckedKeys.has(node.id)}
          checkable={checkable}
          draggable={draggable}
          onExpand={() => handleExpand(node.id)}
          onCheck={(checked) => handleCheck(node.id, checked)}
        />
      );
    },
    [
      expandedKeys,
      checkedKeys,
      halfCheckedKeys,
      checkable,
      draggable,
      handleExpand,
      handleCheck,
    ]
  );

  return (
    <div className="tree">
      <VirtualScroll
        data={visibleNodes}
        itemHeight={32}
        height={600}
        width="100%"
        renderItem={renderNode}
      />
    </div>
  );
}

4.2 Excel 导入导出

简历描述

项目经验 - Excel 数据处理

plain
实现完整的 Excel 导入导出功能,支持复杂格式和大数据量处理
- 基于 SheetJS 实现 Excel 解析和生成,支持样式、公式、图片等高级特性
- 通过 Web Worker 异步处理,10 万行数据导出时间从 15s 降至 3s,不阻塞 UI
- 设计模板映射系统,支持字段映射、数据校验、错误提示,降低 60% 的导入错误率

SOP 标准回答

面试官问:你们的 Excel 导入导出是怎么做的?

"我们业务里经常需要导入导出 Excel,比如批量导入用户、导出报表。我基于 SheetJS 这个库封装了一套完整的方案。

导出比较简单,主要是把表格数据转成 Excel 格式。难点在于样式和格式化。比如金额要显示千分位,日期要格式化,某些列要加粗或者改颜色。我做了一个配置系统,每一列可以定义 formatter、cellStyle 这些属性,导出时自动应用。

导入比较复杂,因为用户上传的 Excel 格式五花八门。我做了几层处理。第一层是格式检测,检查列头是否匹配、数据类型是否正确。第二层是字段映射,如果列头名称和系统不一致,会智能猜测或者让用户手动映射。第三层是数据校验,根据业务规则校验每一行数据,把错误的行标记出来,生成错误报告。

性能方面,大文件处理用了 Web Worker。解析 Excel 和数据校验都在 Worker 里做,不阻塞主线程。还做了进度提示,用户能看到当前处理到第几行。

还有个细节是错误处理。导入失败时,不是直接丢弃,而是生成一个错误 Excel,把原始数据和错误信息一起导出,用户修改后可以重新导入。这个体验好很多。"

项目难点与亮点

难点1:复杂的数据类型转换

"Excel 里的数据类型和 JavaScript 不完全一样。日期在 Excel 里是数字,需要转成 Date 对象。数字可能有千分位,需要去掉逗号。布尔值可能是'是/否'、'true/false',需要统一处理。

我写了一套类型转换器系统。每种数据类型有对应的 parser 和 formatter。parser 负责从 Excel 读取数据时转换,formatter 负责写入 Excel 时格式化。

比如日期类型的 parser:

typescript
const dateParser = (value: any) => {
  // Excel 日期是从 1900-01-01 开始的天数
  if (typeof value === 'number') {
    const date = new Date((value - 25569) * 86400 * 1000);
    return date;
  }
  // 字符串日期
  if (typeof value === 'string') {
    return new Date(value);
  }
  return null;
};

还要处理各种边界情况。比如用户输入的日期格式不对,或者数字字段里混了文字,都要有友好的错误提示,不能直接报错。"

难点2:大文件的内存优化

"导入 10 万行数据时,如果一次性全部加载到内存,浏览器可能崩溃。我做了流式处理。

SheetJS 支持流式读取,可以一次读一部分数据,处理完再读下一部分。我设置了一个批次大小,比如每次处理 1000 行。处理完一批,释放内存,再处理下一批。

导出也是类似,不是一次性生成整个 Excel 文件,而是逐个 Sheet、逐行写入。SheetJS 的 stream 模式可以实现这个。

还有个优化是压缩。Excel 文件本质是 zip 压缩的 XML,SheetJS 默认压缩级别比较低。我调整了压缩级别,文件大小能减少 30% 左右,下载和上传都快了很多。"

亮点:模板化的导入导出

"为了减少开发工作量,我做了一个模板系统。定义好一个模板,就能自动生成导入导出功能。

模板包含字段定义、校验规则、样式配置。比如:

typescript
const userTemplate = {
  name: '用户管理',
  fields: [
    {
      key: 'username',
      label: '用户名',
      type: 'string',
      required: true,
      rules: [{ min: 3, max: 20, message: '用户名长度 3-20' }],
    },
    {
      key: 'email',
      label: '邮箱',
      type: 'email',
      required: true,
    },
    {
      key: 'age',
      label: '年龄',
      type: 'number',
      rules: [{ min: 18, max: 100, message: '年龄 18-100' }],
    },
  ],
};

基于这个模板,可以生成导入界面、导出按钮、错误提示,所有逻辑都自动处理。新增一个导入导出功能,只需要写模板,不需要写业务代码。

我们还做了一个模板市场,常用的模板可以分享,其他项目直接使用,进一步提升了效率。"

技术实现

Excel 导入核心实现

typescript
// utils/excel/importer.ts
import * as XLSX from 'xlsx';
import { ExcelTemplate, ValidationResult } from './types';

export class ExcelImporter {
  private template: ExcelTemplate;
  private worker: Worker | null = null;

  constructor(template: ExcelTemplate) {
    this.template = template;
  }

  // 读取 Excel 文件
  async readFile(file: File): Promise<any[][]> {
    return new Promise((resolve, reject) => {
      const reader = new FileReader();

      reader.onload = (e) => {
        try {
          const data = new Uint8Array(e.target?.result as ArrayBuffer);
          const workbook = XLSX.read(data, { type: 'array' });

          // 读取第一个 sheet
          const firstSheet = workbook.Sheets[workbook.SheetNames[0]];
          const rows = XLSX.utils.sheet_to_json(firstSheet, {
            header: 1,
            defval: null,
          }) as any[][];

          resolve(rows);
        } catch (error) {
          reject(error);
        }
      };

      reader.onerror = () => reject(reader.error);
      reader.readAsArrayBuffer(file);
    });
  }

  // 解析数据
  async parse(
    rows: any[][],
    onProgress?: (progress: number) => void
  ): Promise<{ data: any[]; errors: ValidationResult[] }> {
    if (rows.length === 0) {
      return { data: [], errors: [] };
    }

    // 第一行是列头
    const headers = rows[0];
    const dataRows = rows.slice(1);

    // 字段映射
    const fieldMap = this.mapFields(headers);

    // 解析和校验数据
    const data: any[] = [];
    const errors: ValidationResult[] = [];

    for (let i = 0; i < dataRows.length; i++) {
      const row = dataRows[i];
      const rowData: any = {};
      const rowErrors: string[] = [];

      // 解析每个字段
      this.template.fields.forEach((field, index) => {
        const cellIndex = fieldMap[field.key];
        if (cellIndex === undefined) return;

        const cellValue = row[cellIndex];

        try {
          // 类型转换
          const parsedValue = this.parseValue(cellValue, field.type);

          // 校验
          const validationError = this.validateValue(
            parsedValue,
            field
          );

          if (validationError) {
            rowErrors.push(`${field.label}: ${validationError}`);
          } else {
            rowData[field.key] = parsedValue;
          }
        } catch (error: any) {
          rowErrors.push(`${field.label}: ${error.message}`);
        }
      });

      if (rowErrors.length > 0) {
        errors.push({
          row: i + 2, // Excel 行号(从 1 开始,加上表头)
          errors: rowErrors,
          data: row,
        });
      } else {
        data.push(rowData);
      }

      // 更新进度
      if (onProgress && i % 100 === 0) {
        onProgress((i / dataRows.length) * 100);
      }
    }

    return { data, errors };
  }

  // 字段映射
  private mapFields(headers: any[]): Record<string, number> {
    const map: Record<string, number> = {};

    this.template.fields.forEach(field => {
      // 精确匹配
      let index = headers.findIndex(h => h === field.label);

      // 模糊匹配
      if (index === -1) {
        index = headers.findIndex(h =>
          h && h.toString().toLowerCase().includes(field.label.toLowerCase())
        );
      }

      if (index !== -1) {
        map[field.key] = index;
      }
    });

    return map;
  }

  // 解析值
  private parseValue(value: any, type: string): any {
    if (value === null || value === undefined || value === '') {
      return null;
    }

    switch (type) {
      case 'string':
        return String(value).trim();

      case 'number':
        const num = Number(String(value).replace(/,/g, ''));
        if (isNaN(num)) throw new Error('无效的数字');
        return num;

      case 'date':
        if (typeof value === 'number') {
          // Excel 日期序列号
          return new Date((value - 25569) * 86400 * 1000);
        }
        const date = new Date(value);
        if (isNaN(date.getTime())) throw new Error('无效的日期');
        return date;

      case 'boolean':
        const str = String(value).toLowerCase();
        if (['true', '1', '是', 'yes'].includes(str)) return true;
        if (['false', '0', '否', 'no'].includes(str)) return false;
        throw new Error('无效的布尔值');

      case 'email':
        const email = String(value).trim();
        if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
          throw new Error('无效的邮箱格式');
        }
        return email;

      default:
        return value;
    }
  }

  // 校验值
  private validateValue(value: any, field: any): string | null {
    // 必填校验
    if (field.required && (value === null || value === undefined || value === '')) {
      return '此字段为必填项';
    }

    // 自定义规则校验
    if (field.rules && value !== null) {
      for (const rule of field.rules) {
        if (rule.min !== undefined && value < rule.min) {
          return rule.message || `最小值为 ${rule.min}`;
        }
        if (rule.max !== undefined && value > rule.max) {
          return rule.message || `最大值为 ${rule.max}`;
        }
        if (rule.pattern && !rule.pattern.test(String(value))) {
          return rule.message || '格式不正确';
        }
        if (rule.validator) {
          const error = rule.validator(value);
          if (error) return error;
        }
      }
    }

    return null;
  }

  // 生成错误报告 Excel
  generateErrorReport(errors: ValidationResult[]): Blob {
    const worksheet = XLSX.utils.aoa_to_sheet([
      ['行号', '错误信息', '原始数据'],
      ...errors.map(error => [
        error.row,
        error.errors.join('; '),
        JSON.stringify(error.data),
      ]),
    ]);

    const workbook = XLSX.utils.book_new();
    XLSX.utils.book_append_sheet(workbook, worksheet, '错误报告');

    const excelBuffer = XLSX.write(workbook, {
      bookType: 'xlsx',
      type: 'array',
    });

    return new Blob([excelBuffer], {
      type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
    });
  }
}

Excel 导出核心实现

typescript
// utils/excel/exporter.ts
import * as XLSX from 'xlsx';
import { ExcelTemplate } from './types';

export class ExcelExporter {
  private template: ExcelTemplate;

  constructor(template: ExcelTemplate) {
    this.template = template;
  }

  // 导出数据
  export(data: any[], filename: string = 'export.xlsx') {
    // 构建表头
    const headers = this.template.fields.map(field => field.label);

    // 构建数据行
    const rows = data.map(item => {
      return this.template.fields.map(field => {
        const value = item[field.key];
        return this.formatValue(value, field);
      });
    });

    // 创建 worksheet
    const worksheet = XLSX.utils.aoa_to_sheet([headers, ...rows]);

    // 应用样式
    this.applyStyles(worksheet, data.length);

    // 创建 workbook
    const workbook = XLSX.utils.book_new();
    XLSX.utils.book_append_sheet(workbook, worksheet, this.template.name);

    // 写入文件
    XLSX.writeFile(workbook, filename);
  }

  // 格式化值
  private formatValue(value: any, field: any): any {
    if (value === null || value === undefined) {
      return '';
    }

    // 自定义格式化
    if (field.formatter) {
      return field.formatter(value);
    }

    // 默认格式化
    switch (field.type) {
      case 'date':
        if (value instanceof Date) {
          return value.toLocaleDateString('zh-CN');
        }
        return value;

      case 'number':
        if (field.format === 'currency') {
          return `¥${Number(value).toLocaleString('zh-CN', {
            minimumFractionDigits: 2,
            maximumFractionDigits: 2,
          })}`;
        }
        if (field.format === 'percent') {
          return `${(Number(value) * 100).toFixed(2)}%`;
        }
        return value;

      case 'boolean':
        return value ? '是' : '否';

      default:
        return value;
    }
  }

  // 应用样式
  private applyStyles(worksheet: XLSX.WorkSheet, rowCount: number) {
    const range = XLSX.utils.decode_range(worksheet['!ref']!);

    // 列宽
    const colWidths: number[] = [];
    this.template.fields.forEach((field, i) => {
      colWidths[i] = field.width || 15;
    });
    worksheet['!cols'] = colWidths.map(width => ({ wch: width }));

    // 表头样式
    for (let col = range.s.c; col <= range.e.c; col++) {
      const cellAddress = XLSX.utils.encode_cell({ r: 0, c: col });
      if (!worksheet[cellAddress]) continue;

      worksheet[cellAddress].s = {
        font: { bold: true },
        fill: { fgColor: { rgb: 'E7E6E6' } },
        alignment: { horizontal: 'center', vertical: 'center' },
      };
    }

    // 数据行样式
    this.template.fields.forEach((field, colIndex) => {
      if (!field.cellStyle) return;

      for (let row = 1; row <= rowCount; row++) {
        const cellAddress = XLSX.utils.encode_cell({ r: row, c: colIndex });
        if (!worksheet[cellAddress]) continue;

        worksheet[cellAddress].s = {
          ...worksheet[cellAddress].s,
          ...field.cellStyle,
        };
      }
    });
  }

  // 导出模板(空数据)
  exportTemplate(filename: string = 'template.xlsx') {
    this.export([], filename);
  }
}

4.3 图片/文件上传

简历描述

项目经验 - 文件上传组件

plain
开发企业级文件上传组件,支持大文件、断点续传、秒传等高级特性
- 实现分片上传机制,支持 GB 级大文件上传,上传成功率从 70% 提升至 98%
- 通过文件指纹计算和服务端校验实现秒传,重复文件上传时间从分钟级降至秒级
- 集成图片压缩和裁剪功能,图片上传流量减少 60%,加载速度提升 2 倍

SOP 标准回答

面试官问:你们的文件上传功能是怎么实现的?

"我们的文件上传组件支持多种场景,包括图片上传、文件上传、拖拽上传。核心用的是原生的 File API,配合后端接口实现。

普通的小文件上传比较简单,用 FormData 把文件发送到后端就行。难点在于大文件上传,一次性传几个 GB 不现实,网络不稳定的话传到一半断了就前功尽弃。

我实现了分片上传。把大文件切成若干个小块,比如每块 5MB,然后逐个上传。每个分片上传成功后,会记录下来。如果某个分片上传失败,只需要重传这个分片,不用从头开始。所有分片上传完成后,通知后端合并成完整文件。

还有个优化是秒传。用户上传文件前,先计算文件的 MD5 指纹,发送给后端检查。如果服务器已经有相同的文件,就直接返回文件 URL,不用真的上传。这个对重复上传的场景很有用,比如团队里多个人上传同一个文档。

图片上传还做了压缩。大图片直接上传很慢,我在前端用 Canvas 压缩,控制在 500KB 以内。压缩时保持宽高比,避免变形。还支持裁剪,用户可以框选图片的一部分上传。

体验方面,我做了详细的进度提示、错误重试、拖拽上传,整体体验比较流畅。"

项目难点与亮点

难点1:大文件的分片和并发控制

"分片上传有几个技术细节。第一是如何切片。用 File 对象的 slice 方法,可以切出指定范围的 Blob。我每 5MB 切一片,编号从 0 开始。

第二是并发控制。如果 100 个分片同时上传,浏览器会崩溃。我用了一个并发队列,最多同时传 3 个分片。一个分片传完,从队列里取下一个。

第三是失败重试。网络不好时,某些分片可能上传失败。我做了指数退避重试,第一次失败等 1 秒,第二次等 2 秒,最多重试 3 次。超过 3 次就彻底失败,提示用户检查网络。

第四是断点续传。用户关闭页面或者刷新,已上传的分片不会丢失。我把上传进度存到 localStorage,重新打开页面时能恢复。还会检查服务器已经有哪些分片,跳过已上传的,从断点继续。"

难点2:文件指纹的高效计算

"计算文件的 MD5 指纹比较耗时,尤其是大文件。如果主线程计算,页面会卡死。我用 Web Worker 把计算放到后台线程。

还有个优化是抽样计算。不是计算整个文件的 MD5,而是取文件开头、中间、结尾的几个分片,计算这些分片的 MD5,拼接起来作为指纹。这样速度快很多,而且对绝大多数文件来说,这个指纹也是唯一的。

计算出指纹后,先发送给后端检查。后端查数据库,如果有相同指纹的文件,就秒传。没有才真正上传文件。

我还做了一个缓存机制。同一个文件的指纹会缓存在 IndexedDB 里,下次上传直接读缓存,不用重新计算。这个对频繁上传的场景很有用。"

亮点:图片的智能压缩和裁剪

"图片压缩不能简单粗暴地降低质量,要根据图片内容动态调整。我用了一个自适应压缩算法。

首先判断图片尺寸。如果宽度超过 1920px,就缩放到 1920px。然后尝试不同的质量参数,比如 0.9、0.8、0.7,直到文件大小小于目标值(比如 500KB)。

压缩时用 Canvas 的 toBlob 方法,可以指定图片格式和质量。我会根据原图格式选择,JPG 就压缩成 JPG,PNG 就压缩成 PNG,保证最佳效果。

裁剪功能用了一个第三方库 Cropper.js,封装成 React 组件。用户可以拖拽、缩放、旋转,选定区域后生成裁剪后的图片。裁剪完还会再压缩一次,确保文件大小可控。

这些优化让我们的图片上传流量减少了 60%,服务器存储成本也降低了不少。"

技术实现

分片上传实现

typescript
// utils/upload/ChunkUploader.ts
import SparkMD5 from 'spark-md5';

interface ChunkUploadOptions {
  file: File;
  chunkSize?: number;
  concurrent?: number;
  onProgress?: (progress: number) => void;
  onSuccess?: (url: string) => void;
  onError?: (error: Error) => void;
}

export class ChunkUploader {
  private file: File;
  private chunkSize: number;
  private concurrent: number;
  private chunks: Blob[] = [];
  private uploadedChunks: Set<number> = new Set();
  private onProgress?: (progress: number) => void;
  private onSuccess?: (url: string) => void;
  private onError?: (error: Error) => void;

  constructor({
    file,
    chunkSize = 5 * 1024 * 1024, // 5MB
    concurrent = 3,
    onProgress,
    onSuccess,
    onError,
  }: ChunkUploadOptions) {
    this.file = file;
    this.chunkSize = chunkSize;
    this.concurrent = concurrent;
    this.onProgress = onProgress;
    this.onSuccess = onSuccess;
    this.onError = onError;

    this.splitFile();
  }

  // 切片
  private splitFile() {
    const fileSize = this.file.size;
    let start = 0;

    while (start < fileSize) {
      const end = Math.min(start + this.chunkSize, fileSize);
      const chunk = this.file.slice(start, end);
      this.chunks.push(chunk);
      start = end;
    }
  }

  // 计算文件指纹
  async calculateHash(): Promise<string> {
    return new Promise((resolve, reject) => {
      const worker = new Worker(
        new URL('./hash.worker.ts', import.meta.url)
      );

      worker.postMessage({
        file: this.file,
        chunkSize: this.chunkSize,
      });

      worker.onmessage = (e) => {
        const { hash } = e.data;
        worker.terminate();
        resolve(hash);
      };

      worker.onerror = (error) => {
        worker.terminate();
        reject(error);
      };
    });
  }

  // 开始上传
  async upload() {
    try {
      // 1. 计算文件指纹
      const hash = await this.calculateHash();

      // 2. 检查是否可以秒传
      const canSkip = await this.checkFileExists(hash);
      if (canSkip) {
        this.onSuccess?.(canSkip);
        return;
      }

      // 3. 检查已上传的分片
      const uploadedIndexes = await this.getUploadedChunks(hash);
      uploadedIndexes.forEach(index => {
        this.uploadedChunks.add(index);
      });

      // 4. 上传所有分片
      await this.uploadChunks(hash);

      // 5. 合并分片
      const url = await this.mergeChunks(hash);
      this.onSuccess?.(url);
    } catch (error: any) {
      this.onError?.(error);
    }
  }

  // 检查文件是否已存在(秒传)
  private async checkFileExists(hash: string): Promise<string | null> {
    const response = await fetch('/api/upload/check', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ hash, filename: this.file.name }),
    });

    const data = await response.json();
    return data.exists ? data.url : null;
  }

  // 获取已上传的分片
  private async getUploadedChunks(hash: string): Promise<number[]> {
    const response = await fetch(`/api/upload/chunks/${hash}`);
    const data = await response.json();
    return data.uploadedChunks || [];
  }

  // 上传所有分片
  private async uploadChunks(hash: string) {
    const pendingChunks = this.chunks
      .map((_, index) => index)
      .filter(index => !this.uploadedChunks.has(index));

    // 并发上传
    const queue = [...pendingChunks];
    const executing: Promise<void>[] = [];

    while (queue.length > 0 || executing.length > 0) {
      // 如果并发数未达到上限,继续添加任务
      while (executing.length < this.concurrent && queue.length > 0) {
        const index = queue.shift()!;
        const promise = this.uploadChunk(hash, index).then(() => {
          executing.splice(executing.indexOf(promise), 1);
          this.uploadedChunks.add(index);
          this.updateProgress();
        });
        executing.push(promise);
      }

      // 等待任意一个任务完成
      if (executing.length > 0) {
        await Promise.race(executing);
      }
    }
  }

  // 上传单个分片
  private async uploadChunk(
    hash: string,
    index: number,
    retries: number = 3
  ): Promise<void> {
    const chunk = this.chunks[index];
    const formData = new FormData();
    formData.append('chunk', chunk);
    formData.append('hash', hash);
    formData.append('index', String(index));
    formData.append('total', String(this.chunks.length));

    for (let attempt = 0; attempt < retries; attempt++) {
      try {
        const response = await fetch('/api/upload/chunk', {
          method: 'POST',
          body: formData,
        });

        if (!response.ok) {
          throw new Error(`上传失败: ${response.statusText}`);
        }

        return;
      } catch (error) {
        if (attempt === retries - 1) {
          throw error;
        }
        // 指数退避
        await new Promise(resolve =>
          setTimeout(resolve, Math.pow(2, attempt) * 1000)
        );
      }
    }
  }

  // 合并分片
  private async mergeChunks(hash: string): Promise<string> {
    const response = await fetch('/api/upload/merge', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        hash,
        filename: this.file.name,
        total: this.chunks.length,
      }),
    });

    const data = await response.json();
    return data.url;
  }

  // 更新进度
  private updateProgress() {
    const progress = (this.uploadedChunks.size / this.chunks.length) * 100;
    this.onProgress?.(progress);
  }
}

图片压缩实现

typescript
// utils/upload/imageCompressor.ts
interface CompressOptions {
  maxWidth?: number;
  maxHeight?: number;
  quality?: number;
  maxSize?: number; // KB
}

export async function compressImage(
  file: File,
  options: CompressOptions = {}
): Promise<Blob> {
  const {
    maxWidth = 1920,
    maxHeight = 1080,
    quality = 0.9,
    maxSize = 500,
  } = options;

  // 读取图片
  const image = await loadImage(file);

  // 计算缩放比例
  let { width, height } = image;
  const ratio = Math.min(
    maxWidth / width,
    maxHeight / height,
    1
  );

  width = Math.floor(width * ratio);
  height = Math.floor(height * ratio);

  // 创建 Canvas
  const canvas = document.createElement('canvas');
  canvas.width = width;
  canvas.height = height;

  const ctx = canvas.getContext('2d')!;
  ctx.drawImage(image, 0, 0, width, height);

  // 动态调整质量
  let currentQuality = quality;
  let blob: Blob | null = null;

  while (currentQuality > 0.1) {
    blob = await canvasToBlob(canvas, file.type, currentQuality);

    // 如果文件小于目标大小,或质量已经很低,就停止
    if (blob.size / 1024 <= maxSize || currentQuality <= 0.3) {
      break;
    }

    currentQuality -= 0.1;
  }

  return blob!;
}

function loadImage(file: File): Promise<HTMLImageElement> {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();

    reader.onload = (e) => {
      const img = new Image();
      img.onload = () => resolve(img);
      img.onerror = reject;
      img.src = e.target?.result as string;
    };

    reader.onerror = reject;
    reader.readAsDataURL(file);
  });
}

function canvasToBlob(
  canvas: HTMLCanvasElement,
  type: string,
  quality: number
): Promise<Blob> {
  return new Promise((resolve, reject) => {
    canvas.toBlob(
      (blob) => {
        if (blob) {
          resolve(blob);
        } else {
          reject(new Error('转换失败'));
        }
      },
      type,
      quality
    );
  });
}

上传组件使用示例

typescript
// components/Upload/Upload.tsx
import React, { useState } from 'react';
import { ChunkUploader } from '@/utils/upload/ChunkUploader';
import { compressImage } from '@/utils/upload/imageCompressor';

export function Upload() {
  const [progress, setProgress] = useState(0);
  const [uploading, setUploading] = useState(false);

  const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;

    setUploading(true);

    try {
      let uploadFile = file;

      // 如果是图片,先压缩
      if (file.type.startsWith('image/')) {
        const compressed = await compressImage(file);
        uploadFile = new File([compressed], file.name, { type: file.type });
      }

      // 创建上传器
      const uploader = new ChunkUploader({
        file: uploadFile,
        onProgress: setProgress,
        onSuccess: (url) => {
          console.log('上传成功:', url);
          setUploading(false);
        },
        onError: (error) => {
          console.error('上传失败:', error);
          setUploading(false);
        },
      });

      // 开始上传
      await uploader.upload();
    } catch (error) {
      console.error('处理文件失败:', error);
      setUploading(false);
    }
  };

  return (
    <div>
      <input
        type="file"
        onChange={handleFileChange}
        disabled={uploading}
      />
      {uploading && (
        <div>
          <div>上传进度: {progress.toFixed(2)}%</div>
          <progress value={progress} max={100} />
        </div>
      )}
    </div>
  );
}