Vue3 + Element Plus 实现大文件分片上传组件(支持秒传、断点续传)

前言

在实际项目开发中,我们经常会遇到需要上传大文件的场景。传统的文件上传方式在处理大文件时存在诸多问题:

  • 上传时间长:大文件上传耗时,用户体验差
  • 易失败:网络不稳定时容易导致上传失败
  • 无法续传:上传失败后需要重新上传整个文件
  • 服务器压力大:大文件一次性上传占用大量内存

本文将介绍如何使用 Vue3 + Element Plus 实现一个功能完善的大文件分片上传组件,支持以下特性:

分片上传 :将大文件切分成小块上传

秒传功能 :通过 MD5 校验实现文件秒传

断点续传 :上传失败后可从断点处继续

上传进度 :实时显示上传进度

文件校验 :支持文件类型、大小校验

拖拽排序:支持文件列表拖拽排序

技术栈

  • Vue 3
  • Element Plus
  • SparkMD5(计算文件 MD5)
  • Sortable.js(拖拽排序)

核心原理

1. 分片上传流程

复制代码
1. 选择文件
2. 计算文件 MD5 值
3. 检查文件是否已上传(秒传)
4. 将文件切分成多个分片
5. 逐个上传分片
6. 服务端合并分片
7. 返回文件地址

2. MD5 计算

使用 SparkMD5 库计算文件的 MD5 值,作为文件的唯一标识:

javascript 复制代码
function computeMD5(file) {
  return new Promise((resolve, reject) => {
    const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice
    const chunkSize = 2097152 // 2MB
    const chunks = Math.ceil(file.size / chunkSize)
    
    let currentChunk = 0
    const spark = new SparkMD5.ArrayBuffer()
    const fileReader = new FileReader()

    fileReader.onload = function (e) {
      spark.append(e.target.result)
      currentChunk++

      if (currentChunk < chunks) {
        loadNext()
      } else {
        resolve(spark.end())
      }
    }

    fileReader.onerror = () => reject('MD5 computation failed')

    function loadNext() {
      const start = currentChunk * chunkSize
      const end = Math.min(start + chunkSize, file.size)
      fileReader.readAsArrayBuffer(blobSlice.call(file, start, end))
    }

    loadNext()
  })
}

3. 文件分片

将大文件按指定大小切分成多个小块:

javascript 复制代码
function createChunks(file, chunkSize) {
  const chunks = []
  let cur = 0
  while (cur < file.size) {
    chunks.push(file.slice(cur, cur + chunkSize))
    cur += chunkSize
  }
  return chunks
}

完整组件代码

ChunkUpload/index.vue

vue 复制代码
<template>
  <div class="upload-file">
    <el-upload
      multiple
      :action="uploadFileUrl"
      :before-upload="handleBeforeUpload"
      :file-list="fileList"
      :limit="limit"
      :on-error="handleUploadError"
      :on-exceed="handleExceed"
      :show-file-list="false"
      :headers="headers"
      :http-request="customUpload"
      class="upload-file-uploader"
      ref="fileUpload"
      v-if="!disabled"
    >
      <!-- 上传按钮 -->
      <el-button v-if="!isUploadBtn" type="primary">选取文件</el-button>
      <slot v-else name="btn"></slot>
    </el-upload>
    
    <!-- 上传提示 -->
    <div class="el-upload__tip" v-if="showTip && !disabled">
      请上传
      <template v-if="fileSize"> 大小不超过 <b style="color: #f56c6c">{{ fileSize }}MB</b> </template>
      <template v-if="fileType"> 格式为 <b style="color: #f56c6c">{{ fileType.join("/") }}</b> </template>
      的文件
    </div>
    
    <!-- 文件列表 -->
    <transition-group
      v-if="showTip"
      ref="uploadFileList"
      class="upload-file-list el-upload-list el-upload-list--text"
      name="el-fade-in-linear"
      tag="ul"
    >
      <li
        :key="file.uid"
        class="el-upload-list__item ele-upload-list__item-content"
        v-for="(file, index) in fileList"
      >
        <el-link :href="file.url" :underline="false" target="_blank">
          <span class="el-icon-document"> {{ getFileName(file.name) }} </span>
        </el-link>
        <div class="ele-upload-list__item-content-action">
          <el-link :underline="false" @click="handleDelete(index)" type="danger" v-if="!disabled">
            &nbsp;删除
          </el-link>
        </div>
        <div v-if="file.status === 'uploading'" class="upload-progress">
          <el-progress :percentage="file.progress || 0" />
        </div>
      </li>
    </transition-group>
  </div>
</template>

<script setup>
import { getToken } from "@/utils/auth"
import { uploadFileChunk, checkFileChunk } from '@/api/file/uploadFile'
import Sortable from 'sortablejs'
import SparkMD5 from 'spark-md5'

const props = defineProps({
  modelValue: [String, Object, Array],
  // 分片上传地址
  action: {
    type: String,
    default: "/file/uploadBig"
  },
  // 检查文件地址
  checkAction: {
    type: String,
    default: "/file/checkFile"
  },
  // 分片大小 (默认 5MB)
  chunkSize: {
    type: Number,
    default: 5 * 1024 * 1024
  },
  // 上传携带的参数
  data: {
    type: Object
  },
  // 数量限制
  limit: {
    type: Number,
    default: 5
  },
  // 大小限制(MB)
  fileSize: {
    type: Number,
    default: 50000
  },
  // 文件类型
  fileType: {
    type: Array,
    default: () => ["doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "pdf"]
  },
  // 是否显示提示
  isShowTip: {
    type: Boolean,
    default: true
  },
  // 禁用组件
  disabled: {
    type: Boolean,
    default: false
  },
  // 拖动排序
  drag: {
    type: Boolean,
    default: true
  },
  // 上传按钮使用插槽
  isUploadBtn: {
    type: Boolean,
    default: false
  },
  // 自定义返回参数
  customParams: {
    type: Boolean,
    default: false
  },
  // 是否支持重复上传
  isRepeat: {
    type: Boolean,
    default: false
  }
})

const { proxy } = getCurrentInstance()
const emit = defineEmits()
const number = ref(0)
const uploadList = ref([])
const uploadFileUrl = ref(import.meta.env.VITE_APP_BASE_API + props.action)
const baseCdnUrl = import.meta.env.VITE_APP_BASE_CDN
const headers = ref({ Authorization: "Bearer " + getToken() })
const fileList = ref([])
const showTip = computed(
  () => props.isShowTip && (props.fileType || props.fileSize)
)

watch(() => props.modelValue, val => {
  if (val && !props.isRepeat) {
    let temp = 1
    const list = Array.isArray(val) ? val : props.modelValue.split(',')
    fileList.value = list.map(item => {
      if (typeof item === "string") {
        item = { name: baseCdnUrl + item, url: baseCdnUrl + item }
      }
      item.uid = item.uid || new Date().getTime() + temp++
      return item
    })
  } else {
    fileList.value = []
    number.value = 0
    return []
  }
}, { deep: true, immediate: true })

// 上传前校检
function handleBeforeUpload(file) {
  // 校检文件类型
  if (props.fileType.length) {
    const fileName = file.name.split('.')
    const fileExt = fileName[fileName.length - 1]
    const isTypeOk = props.fileType.indexOf(fileExt) >= 0
    if (!isTypeOk) {
      proxy.$modal.msgError(`文件格式不正确,请上传${props.fileType.join("/")}格式文件!`)
      return false
    }
  }
  // 校检文件名
  if (file.name.includes(',')) {
    proxy.$modal.msgError('文件名不正确,不能包含英文逗号!')
    return false
  }
  // 校检文件大小
  if (props.fileSize) {
    const isLt = file.size / 1024 / 1024 < props.fileSize
    if (!isLt) {
      proxy.$modal.msgError(`上传文件大小不能超过 ${props.fileSize} MB!`)
      return false
    }
  }
  proxy.$modal.loading("正在处理上传文件,请稍候...")
  number.value++
  return true
}

// 计算MD5
function computeMD5(file) {
  return new Promise((resolve, reject) => {
    const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice
    const chunkSize = 2097152 // 2MB
    const chunks = Math.ceil(file.size / chunkSize)
    
    let currentChunk = 0
    const spark = new SparkMD5.ArrayBuffer()
    const fileReader = new FileReader()

    fileReader.onload = function (e) {
      spark.append(e.target.result)
      currentChunk++

      if (currentChunk < chunks) {
        loadNext()
      } else {
        const md5 = spark.end()
        resolve(md5)
      }
    }

    fileReader.onerror = function () {
      reject('MD5 computation failed')
    }

    function loadNext() {
      const start = currentChunk * chunkSize
      const end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize
      fileReader.readAsArrayBuffer(blobSlice.call(file, start, end))
    }

    loadNext()
  })
}

// 自定义上传
async function customUpload(options) {
  const { file } = options
  
  // 初始化进度
  const fileIndex = fileList.value.findIndex(f => f.uid === file.uid)
  if (fileIndex === -1) {
    fileList.value.push({
      name: file.name,
      uid: file.uid,
      status: 'uploading',
      progress: 0
    })
  }

  try {
    // 1. 计算文件MD5
    const md5 = await computeMD5(file)
    
    // 2. 检查文件状态(秒传/断点续传)
    const checkRes = await checkFileChunk({ md5 })

    if (checkRes.code === 200 && checkRes.data && checkRes.data.relativePath) {
      // 秒传成功
      const successRes = {
        code: 200,
        msg: "操作成功",
        data: {
          relativePath: checkRes.data.relativePath,
          url: checkRes.data.relativePath
        }
      }
      updateFileProgress(file.uid, 100)
      handleUploadSuccess(successRes, file)
      return
    }

    // 获取已上传的分片状态
    const uploadedStatus = checkRes.data?.chucks || ""
    const chunks = createChunks(file, props.chunkSize)
    const totalChunks = chunks.length
    
    // 3. 逐个上传分片
    for (let i = 0; i < totalChunks; i++) {
      // 检查当前分片是否已上传
      if (uploadedStatus.length > i && uploadedStatus[i] === '1') {
        const progress = Math.ceil(((i + 1) / totalChunks) * 100)
        updateFileProgress(file.uid, progress)
        proxy.$modal.loadingText("正在上传文件,已完成 " + progress + "%")
        continue
      }
      
      let fileName = file.name
      const formData = new FormData()
      formData.append('file', new File([chunks[i]], fileName))
      formData.append('chunkNumber', i)
      formData.append('chunkSize', props.chunkSize)
      formData.append('totalNumber', totalChunks)
      formData.append('md5', md5)
      
      // 添加额外参数
      if (props.data) {
        Object.keys(props.data).forEach(key => {
          formData.append(key, props.data[key])
        })
      }

      const res = await uploadFileChunk(formData)
      
      if (res.code === 200) {
        if (i === totalChunks - 1) {
          updateFileProgress(file.uid, 100)
          handleUploadSuccess(res, file)
          proxy.$modal.msgSuccess('文件上传成功')
          return
        }
      } else {
        throw new Error(res.msg || '上传失败')
      }

      // 更新进度
      const progress = Math.ceil(((i + 1) / totalChunks) * 100)
      updateFileProgress(file.uid, progress)
      proxy.$modal.loadingText("正在上传文件,已完成 " + progress + "%")
    }
  } catch (err) {
    handleUploadError(err)
  }
}

// 创建分片
function createChunks(file, chunkSize) {
  const chunks = []
  let cur = 0
  while (cur < file.size) {
    chunks.push(file.slice(cur, cur + chunkSize))
    cur += chunkSize
  }
  return chunks
}

// 更新文件进度
function updateFileProgress(uid, progress) {
  const item = fileList.value.find(f => f.uid === uid)
  if (item) {
    item.progress = progress
  }
}

// 文件个数超出
function handleExceed() {
  proxy.$modal.msgError(`上传文件数量不能超过 ${props.limit} 个!`)
}

// 上传失败
function handleUploadError(err) {
  console.error(err)
  proxy.$modal.msgError("上传文件失败")
  proxy.$modal.closeLoading()
  fileList.value = fileList.value.filter(f => f.status !== 'uploading')
  number.value--
}

// 上传成功回调
function handleUploadSuccess(res, file) {
  if (!res) return
  if (res.code === 200) {
    if (props.customParams) {
      emit('update:files', { url: res.data?.relativePath })
      proxy.$modal.closeLoading()
      return
    }
    const url = res.data.relativePath || res.data.url || res.data.path
    uploadList.value.push({ name: url, url: url })
    uploadedSuccessfully()
  } else {
    number.value--
    proxy.$modal.closeLoading()
    proxy.$modal.msgError(res.msg)
    uploadedSuccessfully()
  }
}

// 删除文件
function handleDelete(index) {
  fileList.value.splice(index, 1)
  emit("update:modelValue", listToString(fileList.value))
}

// 上传结束处理
function uploadedSuccessfully() {
  if (number.value > 0 && uploadList.value.length === number.value) {
    fileList.value = fileList.value.filter(f => f.status !== 'uploading').concat(uploadList.value)
    uploadList.value = []
    number.value = 0
    emit("update:modelValue", listToString(fileList.value))
    proxy.$modal.closeLoading()
  }
}

// 获取文件名称
function getFileName(name) {
  if (name.lastIndexOf("/") > -1) {
    return name.slice(name.lastIndexOf("/") + 1)
  } else {
    return name
  }
}

// 对象转成指定字符串分隔
function listToString(list, separator) {
  let strs = ""
  separator = separator || ","
  for (let i in list) {
    if (list[i].url) {
      strs += list[i].url + separator
    }
  }
  return strs != '' ? strs.substr(0, strs.length - 1) : ''
}

// 初始化拖拽排序
onMounted(() => {
  if (props.drag && !props.disabled) {
    nextTick(() => {
      const element = proxy.$refs.uploadFileList?.$el || proxy.$refs.uploadFileList
      if (element) {
        Sortable.create(element, {
          ghostClass: 'file-upload-darg',
          onEnd: (evt) => {
            const movedItem = fileList.value.splice(evt.oldIndex, 1)[0]
            fileList.value.splice(evt.newIndex, 0, movedItem)
            emit('update:modelValue', listToString(fileList.value))
          }
        })
      }
    })
  }
})
</script>

<style scoped lang="scss">
.file-upload-darg {
  opacity: 0.5;
  background: #c8ebfb;
}

.upload-file-uploader {
  margin-bottom: 5px;
}

.upload-file-list .el-upload-list__item {
  border: 1px solid #e4e7ed;
  line-height: 2;
  margin-bottom: 10px;
  position: relative;
  transition: none !important;
}

.upload-file-list .ele-upload-list__item-content {
  display: flex;
  justify-content: space-between;
  align-items: center;
  color: inherit;
  flex-wrap: wrap;
}

.ele-upload-list__item-content-action .el-link {
  margin-right: 10px;
}

.upload-progress {
  width: 100%;
  padding: 0 10px;
  margin-top: 5px;
}
</style>

API 接口定义

uploadFile.js

javascript 复制代码
import request from '@/utils/request'

// 分片上传
export function uploadFileChunk(data) {
  return request({
    url: '/file/uploadBig',
    method: 'post',
    data: data,
    headers: {
      'Content-Type': 'multipart/form-data'
    }
  })
}

// 检查文件状态
export function checkFileChunk(params) {
  return request({
    url: '/file/checkFile',
    method: 'get',
    params: params
  })
}

使用示例

基础用法

vue 复制代码
<template>
  <div>
    <ChunkUpload 
      v-model="fileList"
      :chunk-size="5 * 1024 * 1024"
      :file-size="500"
      :file-type="['pdf', 'doc', 'docx']"
    />
  </div>
</template>

<script setup>
import { ref } from 'vue'
import ChunkUpload from '@/components/ChunkUpload'

const fileList = ref('')
</script>

自定义上传按钮

vue 复制代码
<template>
  <ChunkUpload 
    v-model="fileList"
    :is-upload-btn="true"
  >
    <template #btn>
      <el-button type="success" icon="Upload">
        点击上传大文件
      </el-button>
    </template>
  </ChunkUpload>
</template>

表单中使用

vue 复制代码
<template>
  <el-form :model="form" ref="formRef">
    <el-form-item label="附件上传" prop="attachments">
      <ChunkUpload 
        v-model="form.attachments"
        :limit="3"
        :file-size="1000"
        :chunk-size="10 * 1024 * 1024"
      />
    </el-form-item>
    
    <el-form-item>
      <el-button type="primary" @click="submitForm">提交</el-button>
    </el-form-item>
  </el-form>
</template>

<script setup>
import { ref } from 'vue'
import ChunkUpload from '@/components/ChunkUpload'

const formRef = ref()
const form = ref({
  attachments: ''
})

const submitForm = () => {
  formRef.value.validate((valid) => {
    if (valid) {
      console.log('提交的附件:', form.value.attachments)
      // 提交表单逻辑
    }
  })
}
</script>

Props 参数说明

参数 说明 类型 默认值
modelValue 文件列表(v-model) String/Array/Object -
action 分片上传接口地址 String '/file/uploadBig'
checkAction 检查文件接口地址 String '/file/checkFile'
chunkSize 分片大小(字节) Number 5242880 (5MB)
data 上传时附带的额外参数 Object -
limit 最大上传文件数 Number 5
fileSize 文件大小限制(MB) Number 50000
fileType 允许的文件类型 Array 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'pdf'
isShowTip 是否显示提示信息 Boolean true
disabled 是否禁用(只读模式) Boolean false
drag 是否支持拖拽排序 Boolean true
isUploadBtn 是否使用自定义上传按钮 Boolean false
customParams 是否自定义返回参数 Boolean false
isRepeat 是否支持重复上传 Boolean false

Events 事件

事件名 说明 回调参数
update:modelValue 文件列表变化时触发 (fileList: string)
update:files 自定义参数模式下触发 ({ url: string })

服务端接口要求

1. 检查文件接口 (GET /file/checkFile)

请求参数:

javascript 复制代码
{
  md5: string  // 文件的 MD5 值
}

返回格式:

javascript 复制代码
{
  code: 200,
  msg: "操作成功",
  data: {
    relativePath: string,  // 如果文件已存在,返回文件路径(秒传)
    chucks: string         // 已上传的分片状态,如 "111000" 表示前3个分片已上传
  }
}

2. 分片上传接口 (POST /file/uploadBig)

请求参数(FormData):

javascript 复制代码
{
  file: File,           // 分片文件
  chunkNumber: number,  // 当前分片索引(从0开始)
  chunkSize: number,    // 分片大小
  totalNumber: number,  // 总分片数
  md5: string          // 文件 MD5 值
}

返回格式:

javascript 复制代码
{
  code: 200,
  msg: "操作成功",
  data: {
    relativePath: string,  // 文件完整路径(最后一个分片上传成功时返回)
    url: string           // 文件访问地址
  }
}

服务端实现示例(Java Spring Boot)

java 复制代码
@RestController
@RequestMapping("/file")
public class FileUploadController {
    
    @Autowired
    private FileService fileService;
    
    /**
     * 检查文件状态
     */
    @GetMapping("/checkFile")
    public AjaxResult checkFile(@RequestParam String md5) {
        // 1. 检查文件是否已存在(秒传)
        String filePath = fileService.getFileByMd5(md5);
        if (StringUtils.isNotEmpty(filePath)) {
            return AjaxResult.success(Map.of("relativePath", filePath));
        }
        
        // 2. 检查已上传的分片
        String chucks = fileService.getUploadedChunks(md5);
        return AjaxResult.success(Map.of("chucks", chucks));
    }
    
    /**
     * 分片上传
     */
    @PostMapping("/uploadBig")
    public AjaxResult uploadBig(
        @RequestParam("file") MultipartFile file,
        @RequestParam("chunkNumber") Integer chunkNumber,
        @RequestParam("chunkSize") Long chunkSize,
        @RequestParam("totalNumber") Integer totalNumber,
        @RequestParam("md5") String md5
    ) throws IOException {
        // 1. 保存分片
        String chunkPath = fileService.saveChunk(file, md5, chunkNumber);
        
        // 2. 检查是否所有分片都已上传
        if (fileService.isAllChunksUploaded(md5, totalNumber)) {
            // 3. 合并分片
            String filePath = fileService.mergeChunks(md5, totalNumber);
            
            // 4. 保存文件记录
            fileService.saveFileRecord(md5, filePath);
            
            return AjaxResult.success(Map.of(
                "relativePath", filePath,
                "url", filePath
            ));
        }
        
        return AjaxResult.success();
    }
}

核心功能详解

1. 秒传功能

通过计算文件的 MD5 值,在上传前先检查服务器是否已存在相同的文件。如果存在,直接返回文件地址,无需重新上传。

优势:

  • 节省带宽和时间
  • 减轻服务器压力
  • 提升用户体验

2. 断点续传

上传过程中如果网络中断或页面刷新,再次上传时会检查已上传的分片,只上传未完成的部分。

实现原理:

  1. 服务端记录每个文件(通过 MD5 标识)已上传的分片
  2. 客户端上传前先查询已上传的分片状态
  3. 跳过已上传的分片,只上传剩余部分

3. 进度显示

实时显示上传进度,让用户了解上传状态。

javascript 复制代码
// 计算进度百分比
const progress = Math.ceil(((currentChunk + 1) / totalChunks) * 100)
updateFileProgress(file.uid, progress)

4. 文件校验

上传前进行文件类型、大小、文件名等校验,避免无效上传。

javascript 复制代码
// 文件类型校验
const fileExt = file.name.split('.').pop()
const isTypeOk = props.fileType.indexOf(fileExt) >= 0

// 文件大小校验
const isLt = file.size / 1024 / 1024 < props.fileSize

性能优化建议

1. 合理设置分片大小

  • 小文件(< 10MB):不建议使用分片上传
  • 中等文件(10MB - 100MB):建议 5MB 分片
  • 大文件(> 100MB):建议 10MB 分片

2. 并发上传

可以修改代码支持多个分片并发上传,提升上传速度:

javascript 复制代码
// 并发上传示例
const concurrency = 3 // 并发数
const uploadPromises = []

for (let i = 0; i < totalChunks; i += concurrency) {
  const batch = []
  for (let j = 0; j < concurrency && i + j < totalChunks; j++) {
    batch.push(uploadChunk(i + j))
  }
  await Promise.all(batch)
}

3. 服务端优化

  • 使用临时目录存储分片
  • 合并完成后删除分片文件
  • 使用消息队列异步处理合并操作
  • 定期清理过期的分片文件

常见问题

1. 上传大文件时浏览器卡顿

原因: MD5 计算占用大量 CPU
解决: 使用 Web Worker 在后台线程计算 MD5

2. 分片上传失败

原因: 网络不稳定或服务器超时
解决: 添加重试机制,失败的分片自动重试

3. 文件合并失败

原因: 分片顺序错误或分片丢失
解决: 服务端严格校验分片完整性

总结

本文介绍了一个功能完善的 Vue3 大文件分片上传组件,支持秒传、断点续传、进度显示等特性。通过合理的分片策略和 MD5 校验,可以大大提升大文件上传的成功率和用户体验。

核心要点:

  1. 使用 SparkMD5 计算文件唯一标识
  2. 通过 File.slice() 实现文件分片
  3. 服务端记录分片状态,支持断点续传
  4. 实时显示上传进度,提升用户体验

希望这篇文章能帮助你在项目中实现高效的大文件上传功能!

依赖安装

bash 复制代码
# 安装 SparkMD5
npm install spark-md5

# 安装 Sortable.js(如需拖拽排序)
npm install sortablejs

如果觉得这个组件对你有帮助,欢迎 Star ⭐


相关推荐
小糯米6019 分钟前
JavaScript表达式与运算符
开发语言·javascript·ecmascript
体验家32 分钟前
体验家 XMPlus 网页端问卷 SDK 技术解析:用几行 JavaScript 实现精准场景触发与防打扰机制
开发语言·前端·javascript
VidDown37 分钟前
VidDown 工具站:视频分辨率技术
javascript·网络·编辑器·音视频·视频编解码·视频
小鹿软件办公1 小时前
倒计时开启:Chromium 宣布几周内将全面切断 MV2 扩展支持
开发语言·javascript·ublock origin
Csvn2 小时前
TypeScript:你以为安全的 `JSON.parse` 其实是颗雷 — 运行时类型安全实战
前端·javascript
触底反弹2 小时前
从 JS 引擎执行原理理解数据类型:栈内存、堆内存与作用域
javascript·数据结构·面试
橘子星2 小时前
深入理解线性数据结构:栈、队列与链表
前端·javascript
Larcher2 小时前
JS 数据类型的八重人格与内存真相
前端·javascript
Maimai108082 小时前
Web3 前端实时通信如何落地:从 SSE 订阅到行情、订单与账户状态更新
前端·javascript·react.js·前端框架·web3·状态模式
阿黎梨梨2 小时前
二分查找进阶:在排序数组中寻找元素的边界
javascript