【浏览器 API / 网络请求 / 文件处理】前端文件上传全流程:从基础上传到断点续传

文件上传

本文系统梳理图片上传的所有核心知识点,包括本地处理、格式规范、校验逻辑、上传优化、安全规范等,覆盖文件上传的全场景需求。

1 基础核心:本地文件处理(不上传服务器)

1. 本地预览(两种核心方式)

本地预览是前端处理图片的基础,无需上传服务器,仅在浏览器内存中生成临时 URL。

实现方式 核心 API 优点 缺点 适用场景
URL.createObjectURL URL.createObjectURL(file) 性能高(直接引用内存文件,无编码)、速度快 需手动释放内存(URL.revokeObjectURL) 主流浏览器(IE10+)、大文件预览
FileReader.readAsDataURL new FileReader().readAsDataURL(file) 兼容性更好(IE9+)、生成 base64 字符串 大文件编码耗时、base64 字符串体积更大 兼容老浏览器、小文件预览
html 复制代码
<template>
  <div class="image-upload-preview">
    <!-- 图片选择区域(符合无障碍规范) -->
    <label class="upload-label">
      选择图片:
      <input
        type="file"
        accept="image/*"
        class="file-input"
        @change="handleFileSelect"
      >
    </label>

    <!-- 图片预览区域 -->
    <div class="preview-area" v-if="previewUrl">
      <img :src="previewUrl" alt="图片预览" class="preview-img">
    </div>

    <!-- 未选择图片提示 -->
    <div class="no-file-tip" v-else>
      暂未选择图片
    </div>
  </div>
</template>

<script lang="ts">
import { Vue, Component } from 'vue-property-decorator'; // 需安装 vue-property-decorator

@Component({
  name: 'ImageUploadPreview'
})
export default class ImageUploadPreview extends Vue {
  // 响应式数据(替代 ref)
  previewUrl: string = '';
  // 保存ObjectURL用于释放内存
  private objectUrl: string = '';

  /**
   * 处理文件选择逻辑
   * @param e 文件选择事件
   */
  handleFileSelect(e: Event): void {
    const target = e.target as HTMLInputElement;
    const file = target.files?.[0];
    
    if (!file) {
      this.previewUrl = '';
      return;
    }

    // 校验文件类型(仅图片)
    if (!file.type.startsWith('image/')) {
      alert('请选择有效的图片文件!');
      target.value = ''; // 清空选择的非图片文件
      return;
    }

    // 先释放之前的URL,避免内存泄漏
    if (this.objectUrl) {
      URL.revokeObjectURL(this.objectUrl);
    }

    // 生成预览URL
    this.objectUrl = URL.createObjectURL(file);
    this.previewUrl = this.objectUrl;
  }

  // 组件卸载钩子(释放内存)
  beforeUnmount(): void {
    if (this.objectUrl) {
      URL.revokeObjectURL(this.objectUrl);
    }
  }
}
</script>

2. 本地生成的文件 URL 格式

预览方式 URL 格式 特点
URL.createObjectURL blob:http://localhost:8080/xxx-xxx-xxx 浏览器内存引用、仅当前会话有效、刷新 / 关闭页面失效
FileReader.readAsDataURL data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA... base64 编码字符串、可直接嵌入 HTML、体积比原文件大 30% 左右
服务器返回 URL https://xxx.com/xxx.png 持久化存储、跨设备 / 会话访问、需上传服务器

3. 基础校验(类型 / 大小 / 尺寸)

前端提前校验可减少无效服务器请求,是必做的基础逻辑。

注意

  • 核心结论:前端获取图片真实宽高,必须通过 new Image() 生成 Image 实例并加载图片,这是浏览器的固有机制,没有更简单的方式;
  • 关键逻辑:Image 加载是异步的,需在 onload 回调中获取尺寸;
  • 最佳实践:获取尺寸后及时释放临时 URL,避免内存泄漏;
  • 性能说明:本地加载 blob: URL 速度极快,无需担心性能问题。

图片文件(File 对象)本身只包含文件元信息(如大小、MIME 类型、文件名),但不包含图片的宽高数据:file.size/file.type 是文件的属性(操作系统层面的元数据);图片的宽高是图片内容层面的信息(如 PNG/JPG 编码时写入的尺寸),必须加载图片后才能解析。

浏览器的 Image 对象(本质是 <img> 标签的 JS 实例)加载图片时,会解析图片的二进制数据,提取出宽高信息并赋值给 img.width/img.height ------ 这个过程是浏览器内置的,无法通过纯 JS 解析 File 对象直接获取。

无需上传服务器,本地即可获取:Image 加载的是本地生成的 blob: 协议 URL(URL.createObjectURL(file)),全程在浏览器内存中完成,不会上传到服务器,性能很高。

js 复制代码
 /**
   * 文件选择处理(包含完整的类型/大小/尺寸校验)
   * @param e 选择文件事件
   */
  handleFileSelect(e: Event): void {
    // TypeScript 类型断言,获取 input 元素
    const target = e.target as HTMLInputElement;
    const file = target.files?.[0];
    if (!file) return;

    // 1. 类型校验(精准校验MIME类型)
    const validTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
    if (!validTypes.includes(file.type)) {
      alert('仅支持JPG/PNG/WEBP/GIF格式!');
      target.value = ''; // 清空错误选择的文件
      return;
    }

    // 2. 大小校验(限制5MB以内)
    const maxSize = 5 * 1024 * 1024; // 5MB
    if (file.size > maxSize) {
      alert('图片大小不能超过5MB!');
      target.value = ''; // 清空错误选择的文件
      return;
    }

    // 3. 尺寸校验(限制宽高不超过2000px)
    const img = new Image();
    // 先释放旧的ObjectURL,避免内存泄漏
    if (this.objectUrl) {
      URL.revokeObjectURL(this.objectUrl);
    }
    this.objectUrl = URL.createObjectURL(file);
    img.src = this.objectUrl;

    img.onload = () => {
      if (img.width > 2000 || img.height > 2000) {
        alert('图片宽高不能超过2000px!');
        target.value = ''; // 清空错误选择的文件
        // 释放当前校验失败的图片URL
        URL.revokeObjectURL(this.objectUrl);
        this.objectUrl = '';
        return;
      }

      // 所有校验通过,生成预览图
      console.log('校验通过,尺寸:', img.width + 'x' + img.height);
      this.previewUrl = this.objectUrl;
    };

    // 图片加载失败处理
    img.onerror = () => {
      alert('图片加载失败,请选择有效的图片文件!');
      target.value = '';
      URL.revokeObjectURL(this.objectUrl);
      this.objectUrl = '';
    };
  }

4. 多文件上传

通过multiple属性实现同时选择多张图片:

html 复制代码
<template>
  <div class="multi-image-upload">
    <!-- 多文件选择按钮(符合无障碍规范) -->
    <label class="upload-btn">
      选择多张图片
      <input
        type="file"
        accept="image/*"
        multiple
        class="file-input"
        @change="handleMultiFile"
      >
    </label>

    <!-- 图片预览区域 -->
    <div class="preview-wrap" v-if="previewList.length > 0">
      <div class="preview-item" v-for="(item, index) in previewList" :key="index">
        <img :src="item.previewUrl" alt="图片预览" class="preview-img">
        <div class="preview-info">
          <p>文件名:{{ item.fileName }}</p>
          <p>大小:{{ item.fileSize }}KB</p>
          <p>尺寸:{{ item.width }}x{{ item.height }}px</p>
        </div>
      </div>
    </div>

    <!-- 空状态提示 -->
    <div class="empty-tip" v-else>
      未选择任何图片
    </div>
  </div>
</template>

<script lang="ts">
import { Component, Vue, Prop, Watch } from 'vue-property-decorator';

// 定义预览项类型接口(TS 类型约束)
interface PreviewItem {
  previewUrl: string;
  fileName: string;
  fileSize: string;
  width: number;
  height: number;
  objectUrl: string;
}

/**
 * 多图片上传组件(Vue CLI + TS 版本)
 * @author 辅助开发
 */
@Component({
  name: 'MultiImageUpload' // 组件名(Vue CLI 规范)
})
export default class MultiImageUpload extends Vue {
  // 响应式数据(Class 风格)
  private previewList: PreviewItem[] = []; // 预览列表
  private objectUrlList: string[] = [];    // 临时URL列表(内存管理)

  /**
   * 处理多文件选择逻辑
   * @param e 文件选择事件
   */
  private handleMultiFile(e: Event): void {
    const target = e.target as HTMLInputElement;
    const files = target.files;

    // 清空历史数据(避免内存泄漏)
    this.clearObjectUrls();
    this.previewList = [];

    if (!files || files.length === 0) return;

    // 遍历所有选中文件
    Array.from(files).forEach((file: File, index: number) => {
      // 1. 类型校验
      const validMimeTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
      if (!validMimeTypes.includes(file.type)) {
        this.$message?.error(`第${index + 1}张【${file.name}】:仅支持JPG/PNG/WEBP/GIF格式`);
        return;
      }

      // 2. 大小校验(5MB限制)
      const maxSize = 5 * 1024 * 1024;
      if (file.size > maxSize) {
        this.$message?.error(`第${index + 1}张【${file.name}】:大小超过5MB(当前${(file.size / 1024 / 1024).toFixed(2)}MB)`);
        return;
      }

      // 3. 尺寸校验 + 生成预览
      this.checkImageSizeAndPreview(file, index);
    });
  }

  /**
   * 校验图片尺寸并生成预览
   * @param file 单个图片文件
   * @param index 文件索引
   */
  private checkImageSizeAndPreview(file: File, index: number): void {
    const img = new Image();
    const objectUrl = URL.createObjectURL(file);
    this.objectUrlList.push(objectUrl);

    // 图片加载完成(获取尺寸)
    img.onload = (): void => {
      // 尺寸校验(2000px限制)
      if (img.width > 2000 || img.height > 2000) {
        this.$message?.error(`第${index + 1}张【${file.name}】:宽高超过2000px(当前${img.width}x${img.height}px)`);
        this.releaseSingleUrl(objectUrl);
        return;
      }

      // 校验通过,添加到预览列表
      this.previewList.push({
        previewUrl: objectUrl,
        fileName: file.name,
        fileSize: (file.size / 1024).toFixed(2),
        width: img.width,
        height: img.height,
        objectUrl
      });
    };

    // 图片加载失败兜底
    img.onerror = (): void => {
      this.$message?.error(`第${index + 1}张【${file.name}】:图片加载失败,请检查文件有效性`);
      this.releaseSingleUrl(objectUrl);
    };

    img.src = objectUrl;
  }

  /**
   * 释放单个临时URL
   * @param url 要释放的ObjectURL
   */
  private releaseSingleUrl(url: string): void {
    URL.revokeObjectURL(url);
    this.objectUrlList = this.objectUrlList.filter(item => item !== url);
  }

  /**
   * 批量释放所有临时URL
   */
  private clearObjectUrls(): void {
    this.objectUrlList.forEach(url => URL.revokeObjectURL(url));
    this.objectUrlList = [];
  }

  // 组件卸载钩子(Vue 生命周期)
  private beforeUnmount(): void {
    this.clearObjectUrls();
  }
}
</script>

<style scoped lang="scss">
// 支持SCSS(Vue CLI 默认集成)
.multi-image-upload {
  padding: 20px;

  .upload-btn {
    display: inline-block;
    cursor: pointer;
    padding: 10px 20px;
    background: #409eff;
    color: #fff;
    border-radius: 4px;
    transition: background 0.2s;

    &:hover {
      background: #66b1ff;
    }

    .file-input {
      display: none;
    }
  }

  .preview-wrap {
    margin-top: 20px;
    display: flex;
    flex-wrap: wrap;
    gap: 16px;
  }

  .preview-item {
    width: 200px;
    border: 1px solid #e5e7eb;
    border-radius: 6px;
    padding: 10px;

    .preview-img {
      width: 100%;
      height: auto;
      border-radius: 4px;
    }

    .preview-info {
      margin-top: 8px;
      font-size: 12px;
      color: #666;

      p {
        line-height: 1.5;
        margin: 0;
      }
    }
  }

  .empty-tip {
    margin-top: 20px;
    color: #999;
    font-size: 14px;
  }
}
</style>

2 上传核心:向服务器发送文件

1. 基础上传逻辑(FormData 格式)

文件上传必须使用multipart/form-data格式,通过 FormData 封装文件:

html 复制代码
<template>
  <label>
    选择图片:
    <input type="file" accept="image/*" @change="handleUpload">
  </label>
</template>

<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import axios from 'axios';

@Component({
  name: 'ImageUpload'
})
export default class ImageUpload extends Vue {
  /**
   * 处理图片上传核心逻辑
   * @param e 文件选择事件
   */
  private async handleUpload(e: Event): Promise<void> {
    const target = e.target as HTMLInputElement;
    const file = target.files?.[0];
    if (!file) return;

    // 1. 构建FormData(文件上传标准格式)
    const formData = new FormData();
    formData.append('image', file); // 'image'是后端约定的字段名
    // 可选:附加其他参数
    formData.append('userId', '123456');

    try {
      // 2. 发送上传请求
      const res = await axios.post('/api/upload/image', formData, {
        headers: {
          'Content-Type': 'multipart/form-data' // 必须指定该请求头
        }
      });
      // 3. 保存服务器返回的URL(持久化状态)
      const remoteUrl = res.data.url;
      console.log('上传成功,远程URL:', remoteUrl);
    } catch (err) {
      console.error('上传失败:', err);
    }
  }
}
</script>

2. 上传进度展示(onUploadProgress)

通过axios的onUploadProgress监控网络传输进度(仅前端→服务器的传输进度,非服务器处理进度):

html 复制代码
<template>
  <input type="file" accept="image/*" @change="uploadWithProgress" />
  <div
    v-if="progress > 0"
    style="width: 300px; height: 10px; border: 1px solid #ccc; margin-top: 10px"
  >
    <div
      style="height: 100%; background: #409eff"
      :style="{ width: `${progress}%` }"
    ></div>
  </div>
  <p>{{ progress > 0 ? `${progress}%` : '' }}</p>
</template>

<script lang="ts">
import { Vue, Component } from 'vue-property-decorator';
import axios from 'axios';

@Component
export default class UploadProgress extends Vue {
  // 上传进度
  progress: number = 0;

  async uploadWithProgress(e: Event): Promise<void> {
    const target = e.target as HTMLInputElement;
    const file = target.files?.[0];
    if (!file) return;

    const formData = new FormData();
    formData.append('image', file);

    try {
      this.progress = 0;
      await axios.post('/api/upload/image', formData, {
        onUploadProgress: (progressEvent) => {
          if (progressEvent.total) {
            this.progress = Math.round(
              (progressEvent.loaded / progressEvent.total) * 100
            );
          }
        },
      });
      alert('上传完成!');
    } catch (err) {
      console.error('上传失败:', err);
    } finally {
      this.progress = 0;
      target.value = '';
    }
  }
}
</script>

3. 自定义上传按钮(隐藏原生 input)

原生 input 样式丑陋,通过 label 或 JS 手动触发实现自定义按钮:

方式 1:label 包裹(推荐)
html 复制代码
<template>
  <label style="cursor: pointer; padding: 8px 16px; background: #409eff; color: white; border-radius: 4px;">
    选择图片
    <input type="file" accept="image/*" style="display: none;" @change="handleFileSelect">
  </label>
</template>
方式 2:JS 手动触发
html 复制代码
<template>
  <button
    @click="openFileSelector"
    style="padding: 8px 16px; background: #409eff; color: white; border: none; border-radius: 4px; cursor: pointer;"
  >
    选择图片
  </button>
  <input
    ref="fileInput"
    type="file"
    accept="image/*"
    style="display: none"
    @change="handleFileSelect"
  />
</template>

<script lang="ts">
import { Vue, Component } from 'vue-property-decorator';

@Component
export default class UploadButton extends Vue {
  $refs!: {
    fileInput: HTMLInputElement;
  };

  // 打开文件选择框
  openFileSelector(): void {
    this.$refs.fileInput.click();
  }

  // 文件选择后
  handleFileSelect(e: Event): void {
    const target = e.target as HTMLInputElement;
    const file = target.files?.[0];

    if (file) {
      console.log('选择的图片:', file);
    }

    // 重置,支持重复选择
    target.value = '';
  }
}
</script>

4. 拖拽上传(提升体验)

通过监听dragover/drop事件实现拖拽上传:

html 复制代码
<template>
  <div
    class="upload-area"
    @dragover.prevent
    @drop.prevent="handleDrop"
    style="
      width: 300px;
      height: 200px;
      border: 2px dashed #ccc;
      display: flex;
      align-items: center;
      justify-content: center;
      cursor: pointer;
    "
  >
    拖拽图片到这里上传
  </div>
</template>

<script lang="ts">
import { Vue, Component } from 'vue-property-decorator';

@Component
export default class UploadDrag extends Vue {
  /**
   * 处理拖拽上传
   * @param e 拖拽事件
   */
  handleDrop(e: DragEvent): void {
    const files = e.dataTransfer?.files;
    if (!files || files.length === 0) return;

    const file = files[0];

    // 校验是否为图片
    if (file && file.type.startsWith('image/')) {
      console.log('拖拽上传的图片:', file);
      // 在这里写上传逻辑
    }
  }
}
</script>

3进阶优化:提升体验与性能

1. 前端图片压缩(减少上传体积)

大图片直接上传耗带宽、易超时,前端压缩后再上传是核心优化手段:

javascript 复制代码
<script lang="ts">
import { Vue, Component } from 'vue-property-decor';

@Component
export default class ImageUpload extends Vue {
  // 上传相关数据
  progress: number = 0;

  /**
   * 图片压缩函数 (Vue2 + TS 版)
   * @param file 原始图片文件
   * @param quality 压缩质量 0-1
   */
  compressImage(file: File, quality: number = 0.8): Promise<Blob> {
    return new Promise((resolve) => {
      const img = new Image();
      
      img.onload = () => {
        const canvas = document.createElement('canvas');
        const ctx = canvas.getContext('2d')!;

        // 限制最大宽度,等比缩放
        const maxWidth = 1000;
        let width = img.width;
        let height = img.height;

        if (width > maxWidth) {
          height = height * (maxWidth / width);
          width = maxWidth;
        }

        canvas.width = width;
        canvas.height = height;
        ctx.drawImage(img, 0, 0, width, height);

        // 输出压缩后的 Blob
        canvas.toBlob(
          (blob) => {
            resolve(blob!);
          },
          file.type,
          quality
        );
      };

      img.src = URL.createObjectURL(file);
    });
  }

  /**
   * 上传处理方法
   */
  async handleUpload(e: Event): Promise<void> {
    const target = e.target as HTMLInputElement;
    const file = target.files?.[0];

    if (!file) return;

    // 压缩图片
    const compressedBlob = await this.compressImage(file, 0.7);

    // 组装上传数据
    const formData = new FormData();
    formData.append('image', compressedBlob, file.name);

    // 这里继续写你的 axios 上传逻辑......
    console.log('压缩完成,准备上传', compressedBlob);
  }
}
</script>

2. 断点续传 / 分片上传(超大文件)

适用于几百 MB 的图片 / 视频,核心逻辑:

将文件按固定大小拆分(Blob.slice),比如 5MB / 片;

给每个分片标记唯一 ID,按顺序上传;

服务器接收所有分片后合并;

若上传中断,仅重新上传未完成的分片。

html 复制代码
<template>
  <div>
    <button @click="openFile">选择大文件(视频/原图)</button>
    <input
      ref="fileInput"
      type="file"
      style="display: none"
      @change="handleFileChange"
    />

    <!-- 进度条 -->
    <div
      v-if="totalProgress > 0"
      style="width: 300px; height: 10px; background: #eee; margin: 10px 0"
    >
      <div
        style="height: 100%; background: #409eff"
        :style="{ width: totalProgress + '%' }"
      ></div>
    </div>
    <p>总进度:{{ totalProgress }}%</p>
  </div>
</template>

<script lang="ts">
import { Vue, Component } from 'vue-property-decorator';
import axios from 'axios';

// 切片大小 5MB
const CHUNK_SIZE = 5 * 1024 * 1024;

@Component
export default class ChunkUpload extends Vue {
  $refs!: {
    fileInput: HTMLInputElement;
  };

  file: File | null = null;
  totalProgress = 0;
  uploadedChunks: string[] = []; // 已上传成功的切片
  fileHash = ''; // 文件唯一标识(用于断点续传)

  // 打开选择框
  openFile() {
    this.$refs.fileInput.click();
  }

  // 选择文件
  async handleFileChange(e: Event) {
    const target = e.target as HTMLInputElement;
    const file = target.files?.[0];
    if (!file) return;

    this.file = file;
    await this.generateFileHash(file); // 生成文件唯一ID
    await this.checkUploadedChunks(); // 查询已上传切片(断点续传核心)
    this.uploadChunks(); // 开始上传
  }

  // 生成文件 hash(唯一ID)
  async generateFileHash(file: File) {
    const buffer = await file.arrayBuffer();
    const hash = await crypto.subtle.digest('SHA-256', buffer);
    this.fileHash = Array.from(new Uint8Array(hash))
      .map((b) => b.toString(16).padStart(2, '0'))
      .join('');
  }

  // 查询后端:哪些切片已经上传过
  async checkUploadedChunks() {
    const { data } = await axios.get('/api/upload/check', {
      params: { fileHash: this.fileHash },
    });
    this.uploadedChunks = data.uploadedChunks || [];
  }

  // 开始分片上传
  async uploadChunks() {
    if (!this.file) return;
    const file = this.file;
    const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
    let successCount = this.uploadedChunks.length;

    // 循环切片
    for (let i = 0; i < totalChunks; i++) {
      // 跳过已上传的切片(断点续传)
      if (this.uploadedChunks.includes(i + '')) {
        this.updateProgress(successCount, totalChunks);
        successCount++;
        continue;
      }

      // 切割文件
      const start = i * CHUNK_SIZE;
      const end = Math.min(start + CHUNK_SIZE, file.size);
      const chunk = file.slice(start, end);

      // 构建表单
      const formData = new FormData();
      formData.append('chunk', chunk);
      formData.append('fileHash', this.fileHash);
      formData.append('chunkIndex', i + '');
      formData.append('totalChunks', totalChunks + '');
      formData.append('filename', file.name);

      // 上传当前切片
      await axios.post('/api/upload/chunk', formData);

      // 成功
      successCount++;
      this.updateProgress(successCount, totalChunks);
    }

    // 全部上传完成 → 通知后端合并
    await axios.post('/api/upload/merge', {
      fileHash: this.fileHash,
      filename: file.name,
    });

    alert('上传成功!');
    this.totalProgress = 0;
  }

  // 更新总进度
  updateProgress(success: number, total: number) {
    this.totalProgress = Math.round((success / total) * 100);
  }
}
</script>

4 安全与规范

1. 前端校验≠后端校验

前端校验仅提升体验,恶意用户可绕过前端直接上传恶意文件;

后端必须重新校验:

校验文件真实类型(通过文件头而非后缀名);

限制文件大小、存储路径;

设置文件访问权限,避免上传脚本文件执行。

2. 云存储上传规范(OSS/COS)

禁止在前端直接配置 AccessKey(易被扒取);

正确方式:前端先调用后端接口获取「临时上传凭证」,再用凭证直传云存储;

后端控制凭证有效期和权限(仅允许上传图片、仅写入指定目录)。

3. 无障碍与兼容性

兼容性

URL.createObjectURL:IE10 + 支持;

FileReader:IE9 + 支持;

如需兼容更低版本,可降级为仅上传、无预览。

无障碍(a11y)

上传控件必须关联 label(满足vuejs-accessibility/form-control-has-label规则);

上传状态需有文字提示(而非仅靠进度条);

错误提示需清晰,且能被屏幕阅读器识别。

4. 常用生态工具(避免重复造轮子)

Vue 组件库:Element Plus ElUpload、Ant Design Vue Upload(内置预览、压缩、进度、拖拽);

压缩库:compressorjs(更易用的图片压缩工具);

分片上传:web-uploader(百度开源,支持分片、断点续传)。

5 总结

  • 本地处理核心:掌握URL.createObjectURL/FileReader预览、类型 / 大小 / 尺寸校验,无需上传服务器即可实现基础图片处理;
  • 上传核心:通过 FormData 封装文件,用onUploadProgress监控传输进度,自定义按钮提升交互体验;
  • 优化核心:前端压缩减少体积,全局状态解决状态丢失,分片上传处理超大文件;
  • 安全核心:前端校验仅做体验优化,后端必须严格校验,云存储上传需用临时凭证。
相关推荐
Setsuna_F_Seiei2 小时前
AI 对话应用之页面滚动交互的实现
前端·javascript·ai编程
新缸中之脑2 小时前
追踪来自Agent的Web 流量
前端
wefly20172 小时前
从使用到原理,深度解析m3u8live.cn—— 基于 HLS.js 的 M3U8 在线播放器实现
java·开发语言·前端·javascript·ecmascript·php·m3u8
英俊潇洒美少年3 小时前
vue如何实现react useDeferredvalue和useTransition的效果
前端·vue.js·react.js
kyriewen114 小时前
给浏览器画个圈:CSS contain 如何让页面从“卡成PPT”变“丝滑如德芙”
开发语言·前端·javascript·css·chrome·typescript·ecmascript
英俊潇洒美少年4 小时前
react19和vue3的优缺点 对比
前端·javascript·vue.js·react.js
~无忧花开~6 小时前
React生命周期全解析
开发语言·前端·javascript·react.js·前端框架·react
cj81406 小时前
Prompt,Agent,Skill,Mcp分别于langchain有什么关系
前端