大文件上传

摘要 :大文件上传的核心思路是「切片 + 断点续传 + 秒传」。前端将大文件按固定大小(如 5MB)切分成多个 chunk,逐个上传;用文件的 hash 值作为唯一标识,后端据此判断哪些分片已存在(秒传)或需要续传;切片进度存入 IndexedDB,支持刷新页面后恢复。一句话概括------把大象装冰箱分三步:切、传、合,中间断了能接着来。

一、为什么需要大文件上传方案

传统上传的三大痛点

直接用 <input type="file"> + FormData 上传大文件(1GB+),会遇到:

痛点 场景 后果
网络中断 WiFi 切换 / 信号波动 / 走隧道 之前传的全白费,得从头再来
浏览器崩溃 / 关机 上传几 GB 占满内存 进度丢失,用户想砸电脑
服务端限制 Nginx 默认 client_max_body_size=1MB 直接返回 413 Request Entity Too Large
无进度反馈 用户盯着转圈圈 体验极差,不知道是卡了还是还在传
并发瓶颈 多人同时传大文件 带宽被打满,所有人都在慢

解决思路:化整为零

css 复制代码
传统方式:  [======== 1GB 文件 ========]  →  一次性 POST  →  失败 = 全部重来 ❌

切片方式:  [chunk1][chunk2][chunk3]...[chunkN]  →  逐个 POST  →  失败只重传那一片 ✅
           ↑ 每个 5MB,独立上传,独立记录进度

二、核心原理:切片上传

2.1 文件切片

使用 File 对象继承自 Blob.slice() 方法,将大文件切成固定大小的分片:

javascript 复制代码
const CHUNK_SIZE = 5 * 1024 * 1024; // 每块 5MB

/**
 * 将文件切分成 chunk 数组
 * @param {File} file - 原始文件
 * @param {number} size - 每块大小(字节)
 * @returns {Array<{index: number, chunk: Blob, hash: string}>}
 */
function createChunks(file, size = CHUNK_SIZE) {
  const chunks = [];
  const totalChunks = Math.ceil(file.size / size);

  for (let i = 0; i < totalChunks; i++) {
    const start = i * size;
    const end = Math.min(start + size, file.size);
    const chunk = file.slice(start, end);

    chunks.push({
      index: i,          // 分片序号,从 0 开始
      chunk: chunk,      // Blob 数据
      start: start,
      end: end,
    });
  }

  return chunks;
}

// 使用示例
// const fileInput = document.getElementById('fileInput').files[0];
// const chunks = createChunks(fileInput); // 1GB 文件 → 200 个 chunk

2.2 文件 Hash 计算(唯一标识)

Hash 是整个方案的「身份证」------同一个文件无论切多少次,hash 都一样;不同文件 hash 不同。常用 SparkMD5 库计算:

javascript 复制代码
import SparkMD5 from 'spark-md5';

/**
 * 计算文件 hash(增量读取,避免内存爆炸)
 * @param {File} file
 * @returns {Promise<string>} hex 格式的 md5
 */
async function calculateFileHash(file) {
  return new Promise((resolve, reject) => {
    const chunkSize = 2 * 1024 * 1024; // 每次 2MB 读入内存
    const chunks = Math.ceil(file.size / chunkSize);
    let currentChunk = 0;
    const spark = new SparkMD5.ArrayBuffer();
    const reader = new FileReader();

    reader.onload = (e) => {
      spark.append(e.target.result);
      currentChunk++;

      if (currentChunk < chunks) {
        loadNext();
      } else {
        resolve(spark.end()); // 最终 hash
      }
    };

    reader.onerror = reject;

    function loadNext() {
      const start = currentChunk * chunkSize;
      const end = Math.min(start + chunkSize, file.size);
      reader.readAsArrayBuffer(file.slice(start, end));
    }

    loadNext();
  });
}

为什么不用整个文件一次性读? 一个 1GB 的文件如果 readAsArrayBuffer(file) 会占用 ~1GB 内存,浏览器直接卡死。增量读取每次只拿 2MB 到内存中,算完就释放。

2.3 逐片上传

javascript 复制代码
/**
 * 上传单个分片
 */
async function uploadChunk(fileHash, chunkData, onProgress) {
  const formData = new FormData();
  formData.append('fileHash', fileHash);       // 文件唯一标识
  formData.append('chunkHash', `${fileHash}-${chunkData.index}`); // 分片标识
  formData.append('chunkIndex', chunkData.index);
  formData.append('totalChunks', chunkData.total);
  formData.append('file', chunkData.chunk);     // 分片 Blob 数据
  formData.append('fileName', chunkData.fileName);

  const response = await fetch('/api/upload/chunk', {
    method: 'POST',
    body: formData,
  });

  if (!response.ok) throw new Error(`Upload failed: ${response.status}`);

  const result = await response.json();

  // 回报进度
  onProgress?.(chunkData.index + 1, chunkData.total);

  return result;
}

/**
 * 并发控制上传所有分片
 * @param {string} fileHash
 * @param {Array} chunks - createChunks 返回的数组
 * @param {number} concurrency - 并发数,默认 3
 * @param {Function} onProgress - 进度回调 (uploaded, total) => void
 */
async function uploadWithPool(fileHash, chunks, concurrency = 3, onProgress) {
  let uploadedCount = 0;
  const total = chunks.length;

  // 用一个简单的并发池
  const pool = new Set();
  let index = 0;

  return new Promise((resolve, reject) => {
    function next() {
      while (pool.size < concurrency && index < total) {
        const currentIndex = index++;
        const chunkData = {
          ...chunks[currentIndex],
          total: total,
          fileName: chunks[currentIndex].fileName,
        };

        const task = uploadChunk(fileHash, chunkData, () => {
          uploadedCount++;
          onProgress?.(uploadedCount, total);
        })
          .then(() => pool.delete(task))
          .catch(reject);

        pool.add(task);
      }

      if (pool.size === 0 && index >= total) {
        resolve(); // 全部完成
      }

      // 任一任务完成后,尝试填充下一个
      if (pool.size > 0) {
        Promise.race(pool).then(next, reject);
      }
    }

    next();
  });
}

三、断点续传

3.1 原理

断点续传的核心问题:重新打开页面后,怎么知道之前传到哪了?

答案:问服务器

css 复制代码
客户端                              服务端
  │                                   │
  ├── 1. 计算文件 hash ──────────────→  │
  │                                   │
  ├── 2. POST /api/upload/check ───→   │
  │     { fileHash: "abc123" }         │
  │                                   │
  ├── 3. ← 返回已上传的分片列表 ─────│
  │     { uploaded: [0,1,2,4,5] }     │
  │     (说明第 3 号分片丢了/没传完)  │
  │                                   │
  ├── 4. 只传缺失的分片 [3,6,7...] ─→ │
  │                                   │
  ├── 5. 全部到齐 → 通知合并 ───────→  │
  │                                   │
  ├── 6. ← 返回文件 URL ─────────────│

3.2 完整实现

javascript 复制代码
class ResumableUploader {
  constructor(options = {}) {
    this.chunkSize = options.chunkSize || 5 * 1024 * 1024;
    this.concurrency = options.concurrency || 3;
    this.uploadUrl = options.uploadUrl || '/api/upload/chunk';
    this.checkUrl = options.checkUrl || '/api/upload/check';
    this.mergeUrl = options.mergeUrl || '/api/upload/merge';
    this.onProgress = options.onProgress || (() => {});
  }

  /**
   * 主入口:开始上传
   */
  async upload(file) {
    this.file = file;
    this.fileName = file.name;
    this.fileSize = file.size;

    // Step 1: 计算文件 hash
    this.fileHash = await calculateFileHash(file);

    // Step 2: 检查已上传的分片(秒传判断)
    const { uploaded, exists } = await this.checkUploadedChunks();

    // 秒传:文件已完整存在
    if (exists) {
      this.onProgress(100, 100, '秒传成功');
      return { url: exists, message: '文件已存在(秒传)' };
    }

    // Step 3: 创建分片列表,排除已上传的
    const allChunks = createChunks(file, this.chunkSize).map(c => ({
      ...c,
      fileName: this.fileName,
    }));
    const pendingChunks = allChunks.filter(c => !uploaded.includes(c.index));

    console.log(`共 ${allChunks.length} 片,已传 ${uploaded.length} 片,待传 ${pendingChunks.length} 片`);

    if (pendingChunks.length === 0) {
      // 所有分片都已上传,只需通知合并
      return this.notifyMerge(allChunks.length);
    }

    // Step 4: 上传缺失分片
    await uploadWithPool(this.fileHash, pendingChunks, this.concurrency,
      (done, total) => {
        const actualDone = done + uploaded.length;
        this.onProgress(actualDone, allChunks.length, '上传中...');
      }
    );

    // Step 5: 通知后端合并
    return this.notifyMerge(allChunks.length);
  }

  /**
   * 检查已上传的分片
   */
  async checkUploadedChunks() {
    const res = await fetch(this.checkUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        fileHash: this.fileHash,
        fileName: this.fileName,
        totalChunks: Math.ceil(this.fileSize / this.chunkSize),
      }),
    });

    if (!res.ok) throw new Error('检查上传状态失败');
    return res.json();
    // 返回格式: { uploaded: [0,1,2], exists: null } 或 { uploaded: [], exists: "https://..." }
  }

  /**
   * 通知后端合并分片
   */
  async notifyMerge(totalChunks) {
    this.onProgress(totalChunks, totalChunks, '正在合并...');

    const res = await fetch(this.mergeUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        fileHash: this.fileHash,
        fileName: this.fileName,
        totalChunks: totalChunks,
        fileSize: this.fileSize,
      }),
    });

    if (!res.ok) throw new Error('合并失败');
    return res.json();
    // 返回格式: { url: "https://cdn.example.com/files/xxx" }
  }
}

// 使用示例
// const uploader = new ResumableUploader({
//   onProgress: (done, total, status) => {
//     console.log(`进度: ${done}/${total} (${Math.round(done/total*100)}%) ${status}`);
//   }
// });
//
// uploader.upload(fileInput.files[0])
//   .then(result => console.log('上传完成:', result.url))
//   .catch(err => console.error('上传失败:', err));

四、进阶优化

4.1 Web Worker 加速切片 & Hash 计算

切片和 hash 计算是 CPU 密集型操作,放主线程会阻塞 UI。用 Web Worker 把活儿甩到后台:

javascript 复制代码
// worker/hash-worker.js ------ 在 Web Worker 中计算 hash
self.importScripts('https://cdn.jsdelivr.net/npm/spark-md5@3.0.2/spark-md5.min.js');

self.onmessage = async function(e) {
  const { file, chunkSize } = e.data;
  const spark = new SparkMD5.ArrayBuffer();
  const chunks = Math.ceil(file.size / chunkSize);

  for (let i = 0; i < chunks; i++) {
    const start = i * chunkSize;
    const end = Math.min(start + chunkSize, file.size);
    const blob = file.slice(start, end);
    const buffer = await blob.arrayBuffer();
    spark.append(buffer);

    // 向主线程报告 hash 计算进度
    self.postMessage({ type: 'progress', current: i + 1, total: chunks });
  }

  self.postMessage({ type: 'done', hash: spark.end() });
};

// 主线程调用
function calculateHashInWorker(file) {
  return new Promise((resolve) => {
    const worker = new Worker('./worker/hash-worker.js');

    worker.onmessage = (e) => {
      if (e.data.type === 'done') {
        resolve(e.data.hash);
        worker.terminate();
      }
    };

    worker.postMessage({ file, chunkSize: 2 * 1024 * 1024 });
  });
}

4.2 IndexedDB 持久化上传记录

页面刷新或关闭后再打开,从 IndexedDB 恢复上传状态:

javascript 复制代码
// db.js ------ IndexedDB 操作封装
const DB_NAME = 'FileUploadDB';
const DB_VERSION = 1;
const STORE_NAME = 'uploadRecords';

function openDB() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, DB_VERSION);

    request.onupgradeneeded = (event) => {
      const db = event.target.result;
      if (!db.objectStoreNames.contains(STORE_NAME)) {
        db.createObjectStore(STORE_NAME, { keyPath: 'fileHash' });
      }
    };

    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

/** 保存上传记录 */
async function saveUploadRecord(record) {
  const db = await openDB();
  return new Promise((resolve, reject) => {
    const tx = db.transaction(STORE_NAME, 'readwrite');
    tx.objectStore(STORE_NAME).put(record);
    tx.oncomplete = resolve;
    tx.onerror = () => reject(tx.error);
  });
}

/** 获取上传记录 */
async function getUploadRecord(fileHash) {
  const db = await openDB();
  return new Promise((resolve, reject) => {
    const tx = db.transaction(STORE_NAME, 'readonly');
    const req = tx.objectStore(STORE_NAME).get(fileHash);
    req.onsuccess = () => resolve(req.result || null);
    req.onerror = () => reject(req.error);
  });
}

/** 删除已完成的上传记录 */
async function removeUploadRecord(fileHash) {
  const db = await openDB();
  return new Promise((resolve, reject) => {
    const tx = db.transaction(STORENAME, 'readwrite');
    tx.objectStore(STORE_NAME).delete(fileHash);
    tx.oncomplete = resolve;
    tx.onerror = () => reject(tx.error);
  });
}

// 集成到 Uploader 中:
// 上传前 → saveUploadRecord({ fileHash, fileName, fileSize, uploadedChunks: [], status: 'uploading' })
// 每传完一片 → 更新 uploadedChunks
// 页面加载时 → 扫描未完成的记录,提示用户"是否继续上传?"

4.3 WebSocket 实时进度推送

HTTP 轮询太浪费,用 WebSocket 让服务端主动推进度给前端:

javascript 复制代码
class UploadWithWebSocket {
  constructor(uploadUrl, wsUrl) {
    this.uploadUrl = uploadUrl;
    this.wsUrl = wsUrl;
    this.ws = null;
  }

  connect(fileHash) {
    this.ws = new WebSocket(`${this.wsUrl}?fileHash=${fileHash}`);

    this.ws.onmessage = (event) => {
      const data = JSON.parse(event.data);
      switch (data.type) {
        case 'progress':
          console.log(`服务端确认收到分片 ${data.chunkIndex}`);
          break;
        case 'merge_complete':
          console.log('合并完成,文件 URL:', data.url);
          break;
        case 'error':
          console.error('服务端错误:', data.message);
          break;
      }
    };
  }

  disconnect() {
    this.ws?.close();
  }
}

4.4 错误处理与重试策略

javascript 复制代码
/**
 * 带指数退避的重试上传
 */
async function uploadChunkWithRetry(uploadFn, maxRetries = 3) {
  let lastError;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await uploadFn();
    } catch (err) {
      lastError = err;

      if (attempt < maxRetries) {
        // 指数退避:1s → 2s → 4s
        const delay = Math.pow(2, attempt) * 1000;
        console.warn(`分片上传失败,${delay}ms 后重试 (${attempt + 1}/${maxRetries})`);
        await new Promise(r => setTimeout(r, delay));
      }
    }
  }

  throw lastError;
}

五、完整组件设计

5.1 React 组件示例

jsx 复制代码
import React, { useState, useRef, useCallback } from 'react';
import { ResumableUploader } from './uploader';

function LargeFileUploader({
  accept = '*',              // 接受的文件类型
  maxSize = Infinity,        // 最大文件大小(字节)
  chunkSize = 5 * 1024 * 1024,
  concurrency = 3,
  onUploadSuccess,           // (url) => void
  onUploadError,             // (error) => void
}) {
  const [file, setFile] = useState(null);
  const [progress, setProgress] = useState(0);
  const [status, setStatus] = useState('idle'); // idle | hashing | uploading | merging | success | error
  const [dragOver, setDragOver] = useState(false);
  const inputRef = useRef(null);
  const abortRef = useRef(null);

  const handleFile = useCallback((selectedFile) => {
    if (selectedFile.size > maxSize) {
      alert(`文件过大,最大支持 ${formatSize(maxSize)}`);
      return;
    }
    setFile(selectedFile);
    setProgress(0);
    setStatus('idle');
  }, [maxSize]);

  const handleUpload = useCallback(async () => {
    if (!file) return;

    const uploader = new ResumableUploader({
      chunkSize,
      concurrency,
      onProgress: (done, total, msg) => {
        setProgress(Math.round((done / total) * 100));
        setStatus(msg.includes('hash') ? 'hashing'
          : msg.includes('合并') ? 'merging'
            : 'uploading');
      },
    });

    // 支持取消
    abortRef.current = () => {
      // 实际项目中可用 AbortController 取消 fetch
      setStatus('idle');
      setProgress(0);
    };

    try {
      setStatus('hashing');
      const result = await uploader.upload(file);
      setStatus('success');
      onUploadSuccess?.(result.url);
    } catch (err) {
      setStatus('error');
      onUploadError?.(err);
    }
  }, [file, chunkSize, concurrency, onUploadSuccess, onUploadError]);

  // 拖拽处理
  const handleDragOver = (e) => { e.preventDefault(); setDragOver(true); };
  const handleDragLeave = (e) => { e.preventDefault(); setDragOver(false); };
  const handleDrop = (e) => {
    e.preventDefault();
    setDragOver(false);
    const droppedFile = e.dataTransfer.files[0];
    if (droppedFile) handleFile(droppedFile);
  };

  return (
    <div
      className={`uploader ${dragOver ? 'drag-over' : ''}`}
      onDragOver={handleDragOver}
      onDragLeave={handleDragLeave}
      onDrop={handleDrop}
    >
      <input
        ref={inputRef}
        type="file"
        accept={accept}
        onChange={(e) => handleFile(e.target.files[0])}
        style={{ display: 'none' }}
        multiple={false}
      />

      {!file ? (
        <div className="uploader-prompt" onClick={() => inputRef.current?.click()}>
          <p>点击或拖拽文件到此区域</p>
          <p className="hint">支持大文件上传,断网可续传</p>
        </div>
      ) : (
        <div className="uploader-active">
          <div className="file-info">
            <span className="file-name">{file.name}</span>
            <span className="file-size">{formatSize(file.size)}</span>
          </div>

          <div className="progress-bar">
            <div className="progress-fill" style={{ width: `${progress}%` }} />
            <span className="progress-text">{progress}%</span>
          </div>

          <div className="status-text">{statusTextMap[status]}</div>

          <div className="actions">
            {status !== 'uploading' && status !== 'success' && (
              <button onClick={handleUpload}>开始上传</button>
            )}
            {(status === 'uploading' || status === 'hashing') && (
              <button onClick={abortRef.current}>取消</button>
            )}
            <button onClick={() => { setFile(null); setStatus('idle'); }}>
              重新选择
            </button>
          </div>
        </div>
      )}
    </div>
  );
}

const statusTextMap = {
  idle: '准备就绪',
  hashing: '正在计算文件指纹...',
  uploading: '正在上传...',
  merging: '正在合并文件...',
  success: '上传完成!',
  error: '上传失败,请重试',
};

function formatSize(bytes) {
  if (bytes < 1024) return bytes + ' B';
  if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
  if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
  return (bytes / 1073741824).toFixed(2) + ' GB';
}

5.2 组件 Props / 事件 / 状态一览

类别 名称 类型 说明
Props accept string 文件类型过滤,如 '.pdf,.docx'
maxSize number 最大文件字节数
chunkSize number 分片大小,默认 5MB
concurrency number 并发上传数,默认 3
Events onUploadSuccess (url: string) => void 上传成功回调,返回文件 URL
onUploadError (error: Error) => void 上传失败回调
State status enum idle → hashing → uploading → merging → success/error
progress number 0~100 百分比

六、前后端协议设计

6.1 统一接口规范

接口 方法 参数 返回值 说明
/api/upload/check POST { fileHash, fileName, totalChunks } { uploaded: number[], exists?: string } 查询已上传分片;若文件完整则返回 url(秒传)
/api/upload/chunk POST FormData: fileHash, chunkIndex, chunkHash, file, totalChunks, fileName { success: true, index: number } 上传单个分片
/api/upload/merge POST { fileHash, fileName, totalChunks, fileSize } { url: string } 通知后端合并所有分片

6.2 分片命名规则

makefile 复制代码
文件 hash:  a1b2c3d4e5f6...
分片命名:  {fileHash}-{index}

示例:
  a1b2c3d4-0    ← 第 1 片
  a1b2c3d4-1    ← 第 2 片
  a1b2c3d4-2    ← 第 3 片
  ...
  a1b2c3d4-199  ← 第 200 片(假设 1GB 文件,每片 5MB)

后端存储时可以用这个名作为临时文件名,合并时按 index 顺序拼接。


七、方案对比

维度 普通 FormData 上传 切片上传(基础版) 切片+断点续传(完整版)
文件大小限制 受浏览器/服务端限制(通常 < 100MB) 理论无上限 理论无上限
断网恢复 ❌ 从头再来 ❌ 从头再来 ✅ 只传剩余分片
进度显示 ❌ 只有整体完成/失败 ✅ 分片级进度 ✅ 分片级 + 可恢复
秒传 ✅ hash 匹配直接返回 URL
实现复杂度 低(3 行代码) 中(~200 行) 高(~600 行+)
适用场景 头像、小附件 内网稳定环境 公网、大文件、弱网

八、面试高频问题

Q1:为什么切片大小一般选 5MB?

  • 太小(如 10KB):请求数爆炸,HTTP 头部开销占比高,反而更慢
  • 太大(如 100MB):单次传输时间长,一旦失败重传代价大
  • 5MB 是经验平衡点:单次传输约 2~5 秒(普通宽带),失败重传代价可控,总请求数也合理(1GB ≈ 200 个请求)

Q2:hash 计算太慢怎么办?

  • Web Worker:不阻塞 UI(如上 4.1 所示)
  • 增量计算:不要一次读整个文件,分块读入(如上 2.2 所示)
  • 抽样 hash:只取文件头部 + 尾部 + 中间各 2MB 来算(牺牲精度换速度,适合非严格场景)
  • Web Crypto API :浏览器原生 crypto.subtle.digest('SHA-256', buffer) 比 SparkMD5 更快且不需要引入库

Q3:如何保证分片的顺序性?

  • 每个分片带 chunkIndex 序号
  • 后端收到后存为 {fileHash}-{index} 临时文件
  • 合并时按 index 从小到大拼接:cat 0 1 2 3 ... > final_file
  • 或者用 preallocated file + seek write,每个分片写到对应 offset 位置

Q4:如何处理用户重复选择同一个文件?

  • file.name + file.size + file.lastModified 做 quick check
  • 如果一致且 hash 已算过,跳过 hash 步骤直接走 check 接口
  • 这就是「同一文件秒传」的另一种形式

Q5:如果后端合并时部分分片丢失怎么办?

  • merge 接口在合并前先检查所有分片是否存在
  • 缺失则返回 409 + 缺失的 index 列表
  • 前端补传缺失分片后再次请求 merge

九、记忆口诀

bash 复制代码
大文件上传三步走:切、传、合,断了能续不用愁。

切片五兆刚刚好,hash 当作身份证。
先问后端传了啥,缺谁补谁不白忙。
Worker 算 hash 不卡页,IndexedDB 存进度。
WebSocket 推消息,指数退避防重试。

一句话总结:
切片 + hash 标识 + 断点查询 + 补传缺失 + 后端合并 = 完整的大文件上传方案
相关推荐
做前端的娜娜子1 天前
文本切片与召回(Chunk、Overlap 到混合检索)学习笔记
langchain·openai·掘金·金石计划
做前端的娜娜子1 天前
#浏览器存储方案:localStorage、sessionStorage 与 Cookie
前端·面试·掘金·金石计划
光影少年2 天前
react navite性能优化 & 常见坑
前端·react native·掘金·金石计划
做前端的娜娜子5 天前
文档加载工程:多格式数据接入与 Document 标准化
langchain·ai编程·掘金·金石计划
做前端的娜娜子6 天前
Embedding 向量模型:从语义表示到相似度计算
langchain·openai·掘金·金石计划
做前端的娜娜子6 天前
第二个 AI 调用——invoke 阻塞式与 stream 流式生成
人工智能·llm·掘金·金石计划
做前端的娜娜子7 天前
第一个AI调用——TypeScript 工程初始化与配置管理(实战跟练)
langchain·openai·掘金·金石计划
做前端的娜娜子9 天前
什么是浅拷贝(Shallow Copy)和深拷贝(Deep Copy)?如何手动实现一个深拷贝函数
javascript·面试·掘金·金石计划
做前端的娜娜子10 天前
JavaScript 闭包
前端·javascript·掘金·金石计划
做前端的娜娜子11 天前
移动端上拉加载与下拉刷新实现方案
前端·面试·掘金·金石计划