4.1 树形数据处理
简历描述
项目经验 - 树形数据处理优化
优化大规模树形数据的渲染和操作性能,支持 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,发给其他人,其他人导入后就能看到相同的视图。这个功能在协作场景很有用。"
技术实现
树形数据扁平化
// 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);
}
}
树形组件实现
// 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 数据处理
实现完整的 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:
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% 左右,下载和上传都快了很多。"
亮点:模板化的导入导出
"为了减少开发工作量,我做了一个模板系统。定义好一个模板,就能自动生成导入导出功能。
模板包含字段定义、校验规则、样式配置。比如:
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 导入核心实现
// 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 导出核心实现
// 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 图片/文件上传
简历描述
项目经验 - 文件上传组件
开发企业级文件上传组件,支持大文件、断点续传、秒传等高级特性
- 实现分片上传机制,支持 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%,服务器存储成本也降低了不少。"
技术实现
分片上传实现
// 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);
}
}
图片压缩实现
// 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
);
});
}
上传组件使用示例
// 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>
);
}