【浏览器 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监控传输进度,自定义按钮提升交互体验;
  • 优化核心:前端压缩减少体积,全局状态解决状态丢失,分片上传处理超大文件;
  • 安全核心:前端校验仅做体验优化,后端必须严格校验,云存储上传需用临时凭证。
相关推荐
weixin_3823952334 分钟前
为小工厂量身打造:本地部署的物料管理系统带缺料计算
前端·制造
__zRainy__40 分钟前
解决pnpm v10+不自动构建
前端·pnpm·工程化
猫猫不是喵喵.1 小时前
Vue3 Props 属性
前端·javascript·vue.js
醉城夜风~2 小时前
CSS元素显示模式(display)
前端·css
AI大模型-小华3 小时前
Codex 三方充值快速入门指南
java·前端·数据库·chatgpt·ai编程·codex·chatgpt pro
做前端的娜娜子5 小时前
同一链接实现 PC Web 与移动 H5 自适应
前端·掘金·金石计划
小帅不太帅5 小时前
架构没变、规模没变,DeepSeek V4 Flash 正式版凭什么暴涨 47 分?
前端·aigc·deepseek
jarvisuni6 小时前
DeepSeekFlash前端依旧拉垮,而且变慢了很多!
前端·javascript·算法
卷福同学7 小时前
AI编程出海第二步:验证关键词能否做站
前端·人工智能·后端