作为一个在移动端和跨端开发里摸爬滚打了多年的老前端,我太清楚"全端兼容"这四个字的分量了。尤其是视频上传这个功能,在 UniApp 项目里几乎是个绕不开的"硬骨头":H5 端要考虑跨域和 blob 格式,小程序端要处理临时文件路径和 uploadFile 的边界,App 端又要面对沙箱路径和原生插件兼容。很多项目初期写着"支持全端",最后总在某个端上翻车。

我最近刚在一个实际项目里从头到尾梳理了一套 UniApp 全端兼容的 OSS 视频上传方案,从选型到落地踩了不少坑,也沉淀了一些通用经验。这篇文章就把这套方案完整拆开,从环境准备、核心代码、分端处理到异常兜底,把每个关键节点的"为什么这么做"和"实际怎么踩坑"都讲清楚。如果你正被多端视频上传折腾得头疼,这篇应该能让你少走不少弯路。

1. 为什么 OSS 直传是"全端兼容"的最优解:先想清楚再动手

在做任何技术方案前,我习惯先问自己一句:这个项目到底需要什么?视频上传的核心诉求不只是"能把文件发出去",而是"可靠地把大文件传到云端,并且不拖垮业务服务器"。很多人第一反应是走自己的后端接口转发,但视频文件动辄几十 MB 甚至上百 MB,后端转发既占用带宽又拖慢响应,一旦并发上来服务器直接成为瓶颈。

1.1 服务端转发 vs 客户端直传:不是技术选型,是架构取舍

服务端转发的模式其实很直观:客户端把视频 POST 给自己的服务器,服务器再转存到 OSS。这种方案在早期业务量小、文件小的时候能用,但一旦涉及"全端视频上传",问题就暴露得很明显。

首先是服务器压力。一个 100MB 的视频,经过服务器中转,消耗的是服务器出网带宽,而服务器带宽是按 M 计费的,费用远高于 OSS 的流量成本。其次是对齐问题:如果你在 App 端、小程序端、H5 端各自写一套上传逻辑,每端的网络库、超时策略、重试机制都不一样,后期维护成本非常高。

我最终选择的是"客户端直传 OSS"。具体流程是:客户端从业务后端获取临时上传凭证(STS 或签名),然后直接跟 OSS 的 Bucket 交互,把视频分片或整体上传。业务服务器全程不碰文件流,只负责颁发凭证和记录上传结果。这样一来,带宽压力全部落在 OSS 上,上传速度和稳定性反而更好。

注意:直传方案不等于放弃后端管控。上传凭证必须由服务端颁发,并且设置合理的时效(比如 30 分钟有效),避免客户端持有永久密钥。

1.2 UniApp 环境下直传 OSS 的三个前提条件

这里有一个容易被忽略的点:UniApp 只是个跨端框架,它本身不提供文件上传能力,最终还是要靠各端的原生能力或 Web API。所以直传 OSS 在 UniApp 里要成立,必须同时满足三个条件:

第一条,各端都能拿到文件的"可用路径"。H5 端拿到的是 File 对象或 Blob,小程序端拿到的是 wxfile:// 开头的临时路径,App 端拿到的是 _doc 或 _downloads 下的本地绝对路径。这三种路径格式完全不同,不能混用。

第二条,各端都有办法发起 HTTP 请求并携带文件流。小程序端用 uni.uploadFile,H5 端可以用 XMLHttpRequest 或 axios,App 端可以用 uni.uploadFile 走原生上传,也可以用 plus.uploader。不同端的能力边界不一样,方案设计时要选一个"共同子集"。

第三条,OSS 的 Bucket 必须开启跨域设置(CORS),否则 H5 端会被浏览器拦截。这个小程序端没有这个限制,但 H5 端是模拟不掉的,很多人在这里栽过跟头。

搞清这三点之后,整套方案的框架就清楚了:统一的业务后端颁发凭证 + 各端适配文件路径 + 统一的 OSS 客户端库处理上传。下面一个个拆开说。

1.3 我需要先埋一个结论:OSS SDK 不能直接用

如果你直接去 npm 装 ali-oss (阿里云官方 Node.js SDK),在 UniApp 项目里大概率跑不起来。原因在于官方 SDK 的依赖里包含了一些浏览器兼容性不好的模块,比如 buffer 、 crypto 等,且内部逻辑大量使用 Node.js 的全局对象。在小程序端和 App 端,这些全局对象是不存在的。

所以我的做法是: 不直接在客户端引入完整的 OSS SDK,而是用轻量级的签名算法 + uni.request / uni.uploadFile 自己实现上传逻辑 。这样做的好处很明显:不依赖第三方库的兼容性,所有逻辑都掌握在自己手里,后续排查问题也简单。

当然,如果你只是做 H5 单端,直接用官方 Browser SDK 没问题;但如果你要"全端兼容",我建议还是走自研轻量逻辑这条路。后面我会给出完整实现代码,看完你就明白为什么这么做最稳。

2. 环境准备与 OSS Bucket 配置:少一个细节,上传就会卡壳

方案定下来之后,第一步是配置环境和 OSS 侧的服务端逻辑。很多人觉得这一步随便搞搞就行,把代码写完再补配置,结果调试的时候各种神秘报错:跨域报错、签名不匹配、Policy 格式错误。我建议按照下面的顺序一步步来,每一步都验证一下,不要急于写业务代码。

2.1 开通 OSS、创建 Bucket、设置 CORS 的完整流程

去阿里云控制台开通 OSS 服务,然后创建一个 Bucket。这里有几个注意点:

  • 地域(Region)选择:尽量和你的业务服务器同地域,这样内网传输快、流量费用低。比如服务器在华东2(上海),Bucket 就选华东2。
  • 读写权限:不要用公共读,建议用"私有"权限。因为视频内容通常是业务数据,走 STS 临时凭证访问即可,没必要开公共读到公网上。
  • 服务端加密:可以开启,但要注意是否影响后续的视频处理(比如转码服务),一般保持默认即可。

Bucket 创建好之后,第一件事是配置 CORS,这是 H5 端能正常上传和播放的前提。进入 Bucket 的"数据安全"->"跨域设置",添加一条规则:来源设置为 * (或你的域名),允许 Methods 填 GET, POST, PUT, DELETE, HEAD ,允许 Headers 填 * ,暴露 Headers 填 ETag 。缓存时间设个 600 秒就行。

不要图省事跳过 CORS。H5 端通过浏览器直传 OSS 时,如果 CORS 没配好,会出现"Access to XMLHttpRequest at 'https://xxx.oss-cn-hangzhou.aliyuncs.com' from origin 'https://xxx' has been blocked by CORS policy"这种报错,而且排查起来比较隐蔽。

2.2 创建 RAM 子用户和 STS 临时凭证接口

直传 OSS 的通用实践是使用 STS(Security Token Service)临时凭证。原因很简单:你不会希望把长期的 AccessKeyId/AccessKeySecret 暴露在客户端代码里,那等于把 Bucket 的管理权限拱手让人。

在 RAM 控制台创建一个子用户,只赋予 OSS 的 oss:PutObject 和 oss:GetObject 权限,然后写好授权策略(Policy)。基本思路是:限制该子用户只能访问某个特定 Bucket,甚至只能上传和读取特定目录。

我这里贴一个精简版 STS Policy 授权示例,实际操作时记得把 Bucket 名称和目录替换成你自己的:

{
  "Version": "1",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "oss:PutObject",
        "oss:GetObject"
      ],
      "Resource": [
        "acs:oss:*:*:your-bucket-name/videos/*",
        "acs:oss:*:*:your-bucket-name/snapshots/*"
      ]
    }
  ]
}

然后通过阿里云 STS SDK(服务端调用)换取临时凭证,返回给你的客户端。我一般会封装一个接口,比如 /api/oss/sts ,返回如下字段:

{
  "accessKeyId": "STS.xxx",
  "accessKeySecret": "xxx",
  "securityToken": "xxx",
  "region": "oss-cn-hangzhou",
  "bucket": "your-bucket-name",
  "expiration": "2024-01-01T12:00:00Z"
}

后端用官方 SDK 的 AssumeRole 接口即可,代码非常简单,但有两个坑:

第一个是 Policy 参数的格式。如果你的子用户已经有固定 Policy,STS 换取凭证时可以不带 Policy 参数;如果带了,最终权限是"子用户权限 与 STS Policy 权限的交集"。所以我建议 STS 接口里显式传入最小权限 Policy,不要依赖子用户权限,这样更可控。

第二个是子用户需要开启"OpenAPI 调用访问",否则无法通过 API 换取临时凭证。这个开关在 RAM 用户详情页,新用户经常忘记打开。

2.3 UniApp 项目内的环境变量和配置文件准备

环境准备部分最后一步是在 UniApp 项目里把配置集中管理。我不建议把凭证、Bucket 名称、Region 这些硬编码在业务页面里,否则换个环境(开发/测试/生产)就得全局搜索替换。

我的做法是在项目根目录建一个 config/oss.config.js ,内容大致如下:

// config/oss.config.js
// 按环境区分配置
const ENV = 'dev'; // 可选 dev / test / prod

const config = {
  dev: {
    stsUrl: 'https://dev-api.example.com/api/oss/sts',
    region: 'oss-cn-hangzhou',
    bucket: 'dev-bucket',
    // 上传目录前缀,会在客户端拼接生成 objectKey
    uploadDir: 'videos/dev/',
    // 访问域名,用于生成可播放的 URL
    cdnDomain: 'https://dev-cdn.example.com'
  },
  test: { ... },
  prod: { ... }
};

export default config[ENV];

然后在变量拼接时注意一点:objectKey 的前缀和后缀必须规范。我统一用 日期 + 随机字符串 + 扩展名 作为文件名,避免中文名引起的编码问题,也避免文件名冲突。这个后面在核心实现里细说。

3. 核心实现:一个可在全端运行的上传函数是怎么设计出来的

这一节是整个方案的重点。我会从"文件选择"开始,一路写到"上传完成拿到 URL",每个环节都区分 H5、小程序、App 的处理差异。你能看到的不只是代码,还有我为什么这么写。

3.1 使用 uni.chooseVideo 选择视频,并处理各端的路径差异

全端兼容的第一步是选择视频文件。UniApp 提供了 uni.chooseVideo API,但它在不同端的返回结构有微妙差异。我封装了一个统一的 chooseVideo 方法,返回标准化后的 fileInfo 对象。

// utils/video.js
export function chooseVideo(options = {}) {
  return new Promise((resolve, reject) => {
    uni.chooseVideo({
      sourceType: options.sourceType || ['album', 'camera'],
      compressed: options.compressed !== undefined ? options.compressed : true,
      maxDuration: options.maxDuration || 60,
      camera: options.camera || 'back',
      success: (res) => {
        // res.tempFilePath 在各端都存在
        // 但 H5 端还会返回 res.tempFile 或 res.file 之类的 File 对象
        const file = res.tempFile || res.file || null;
        const fileInfo = {
          // 统一用 tempFilePath 作为主路径
          path: res.tempFilePath,
          // 文件大小,H5 端可能没有,需要额外获取
          size: res.size || 0,
          duration: res.duration || 0,
          height: res.height || 0,
          width: res.width || 0,
          name: res.name || getFileNameFromPath(res.tempFilePath),
          // 额外存储 File 对象,H5 端上传时用
          file: file
        };
        resolve(fileInfo);
      },
      fail: (err) => {
        // 用户取消选择也会走 fail,需要区分错误类型
        if (err.errMsg && err.errMsg.includes('cancel')) {
          reject({ code: 'CANCEL', message: '用户取消选择' });
        } else {
          reject({ code: 'CHOOSE_FAIL', message: err.errMsg || '选择视频失败' });
        }
      }
    });
  });
}

这里有几个细节需要特意说明:

  • res.tempFilePath 是 UniApp 官方推荐的跨端路径,基本各方都支持。但 H5 端它并不指向一个真实的文件,实际上是一个 blob: 开头或 data 开头的 URL,不能直接当文件路径传给 uni.uploadFile ,否则会报错。所以我加了 res.tempFile || res.file 来捕获 H5 端的文件对象。
  • H5 端的 uni.chooseVideo 返回的 File 类对象,默认是没有名称的,显示为 blob 。所以我写了 getFileNameFromPath 来从路径中提取文件名,如果提取不到,就根据当前时间生成一个。
  • 在 App 端(尤其是 iOS), res.tempFilePath 可能是一个 file:// 开头的路径,上传前需要转成 _doc 或 _downloads 形式的本地绝对路径,否则原生上传组件可能不认识。这个在不同版本和不同 App 基座上有差异,建议真机调试时打日志看一眼格式。

3.2 从后端换取 STS 凭证,并封装 OSS 签名工具

选好视频后,接下来要去后端换取 STS 临时凭证。这个接口是我在后端用 Node.js 写的,核心逻辑是用官方 STS SDK 调用 AssumeRole 。

// 服务端代码示例(Node.js)
const OSS = require('ali-oss').STS;
const sts = new OSS({
  accessKeyId: process.env.OSS_ACCESS_KEY_ID,
  accessKeySecret: process.env.OSS_ACCESS_KEY_SECRET
});

async function getStsToken() {
  const { credentials } = await sts.assumeRole(
    'acs:ram::your-account-id:role/your-role-name',
    'your-session-name',
    900, // 有效期 15 分钟
    '{"Statement":[{"Effect":"Allow","Action":["oss:PutObject","oss:GetObject"],"Resource":["acs:oss:*:*:your-bucket/videos/*"]}],"Version":"1"}'
  );
  return {
    accessKeyId: credentials.accessKeyId,
    accessKeySecret: credentials.accessKeySecret,
    securityToken: credentials.securityToken,
    expiration: credentials.expiration
  };
}

在客户端,我封装了一个 getOssConfig 方法:

// utils/oss.js
async function getOssConfig() {
  const res = await uni.request({
    url: config.stsUrl,
    method: 'GET',
    timeout: 10000
  });
  if (res.statusCode !== 200) {
    throw new Error('获取 STS 凭证失败');
  }
  return res.data;
}

uni.request 返回的数据结构在不同端有细微差异,但 statusCode 和 data 字段是一致的,可以直接用。

拿到 STS 之后,我把它缓存起来,而不是每次上传都重新获取。因为 STS 接口有频率限制,每次 ChooseVideo 都调一次会把后端打爆。缓存策略很简单:记录拿到凭证的时间,如果还差 5 分钟过期就重新获取,否则复用旧的。

// utils/oss.js
let cachedOssConfig = null;
let cachedTime = 0;

async function getOssConfig() {
  if (cachedOssConfig && Date.now() - cachedTime < 10 * 60 * 1000) {
    return cachedOssConfig;
  }
  const res = await uni.request({ url: config.stsUrl, method: 'GET' });
  cachedOssConfig = res.data;
  cachedTime = Date.now();
  return cachedOssConfig;
}

3.3 各端上传文件的核心差异:uni.uploadFile vs 自建 XMLHttpRequest

这是整个方案里分叉最多的地方,也是很多人最容易栽的地方。

H5 端的实现方式

H5 端可以用 uni.uploadFile ,但它的 filePath 参数在 H5 端传 tempFilePath (blob URL)时,会遭遇兼容性问题,某些版本的浏览器不能正确上传。更稳的做法是用 XMLHttpRequest + FormData 实现,把 File 对象直接 append 进去。

function uploadToAliyunByXHR({ file, filename, ossConfig, objectKey, onProgress }) {
  return new Promise((resolve, reject) => {
    const formData = new FormData();
    const fileObj = new File([file], filename, { type: file.type || 'video/mp4' });
    
    formData.append('key', objectKey);
    formData.append('policy', ossConfig.policy);  // 后端需要返回 policy 和 signature
    formData.append('OSSAccessKeyId', ossConfig.accessKeyId);
    formData.append('success_action_status', '200');
    formData.append('signature', ossConfig.signature);
    formData.append('file', fileObj);

    const xhr = new XMLHttpRequest();
    xhr.open('POST', `https://${ossConfig.bucket}.${ossConfig.region}.aliyuncs.com`);
    xhr.onload = () => {
      if (xhr.status === 200) {
        resolve({ url: `https://${ossConfig.bucket}.${ossConfig.region}.aliyuncs.com/${objectKey}` });
      } else {
        reject(new Error(xhr.responseText));
      }
    };
    xhr.onerror = (e) => reject(e);
    xhr.upload.onprogress = (e) => {
      if (e.lengthComputable) {
        const percent = Math.round((e.loaded / e.total) * 100);
        onProgress && onProgress(percent);
      }
    };
    xhr.send(formData);
  });
}

这种形式实际上是"服务端签名后直传",需要后端在 STS 之外再返回 policy 和 signature 。如果不想这么麻烦,也可以用 uni.uploadFile 配合 filePath (此时传 File 对象地址),但兼容性不如 XHR。

我的建议是: H5 端和 App 端优先走 XHR,小程序端走 uni.uploadFile 。后面会解释为什么这样分工。

小程序端的实现方式

小程序端不能用 XHR 自定义 FormData 上传,因为小程序的运行环境没有完整的 XMLHttpRequest 实现(虽然开发者工具模拟了,但真机上不一致)。正确姿势是用 uni.uploadFile 。

function uploadToAliyunByUniUploadFile({ path, name, ossConfig, objectKey, onProgress }) {
  return new Promise((resolve, reject) => {
    const uploadTask = uni.uploadFile({
      url: `https://${ossConfig.bucket}.${ossConfig.region}.aliyuncs.com`,
      filePath: path,
      name: 'file',  // 服务端表单字段名
      formData: {
        key: objectKey,
        policy: ossConfig.policy,
        OSSAccessKeyId: ossConfig.accessKeyId,
        signature: ossConfig.signature,
        success_action_status: '200'
      },
      success: (res) => {
        if (res.statusCode === 200) {
          resolve({ url: `https://${ossConfig.bucket}.${ossConfig.region}.aliyuncs.com/${objectKey}` });
        } else {
          reject(new Error(res.errMsg || `上传失败: ${res.statusCode}`));
        }
      },
      fail: (err) => reject(err)
    });

    uploadTask.onProgressUpdate((res) => {
      onProgress && onProgress(res.progress);
    });
  });
}

小程序端最需要注意的是 formData 字段名必须和 OSS 要求的 key 、 policy 、 OSSAccessKeyId 、 signature 对应,一个都不能少。少一个 OSS 都会返回 403。

App 端的实现方式

App 端最稳妥的还是 uni.uploadFile ,它底层会调用 plus.uploader 原生组件,不会受到 WebView 的限制,也不会像 H5 端那样有 CORS 问题。

不过 App 端有一个隐藏的坑:如果你用 uni.chooseVideo 拿到的 tempFilePath 是 file:// 形式,直接传给 uni.uploadFile 有概率失败。解决方法是做个转换:

function convertFileToLocalPath(path) {
  // 如果是 file:// 开头,转成 _doc 等本地路径
  if (path.startsWith('file://')) {
    const fileName = path.split('file://').pop();
    const localPath = plus.io.convertLocalFileSystemURL(fileName);
    return localPath;
  }
  return path;
}

这个转换需要确保执行环境里有 plus 对象,也就是必须在 App 端运行,否则会报错。所以调用前要做条件编译或运行环境判断。

3.4 全端统一入口:一个 Uploader 类搞定所有端差异

为了不让业务页面里写一堆 ifdef 条件编译,我封装了一个统一的 uploadVideo 函数,内部根据环境自动选择实现。这样业务方只需要:

import { uploadVideo } from '@/utils/uploader.js';

const videoFileInfo = await chooseVideo();
const result = await uploadVideo({
  fileInfo: videoFileInfo,
  onProgress: (percent) => { console.log('上传进度:', percent); }
});

console.log('上传成功', result.url);

整个 uploadVideo 的简化版长这样(完整版会在后面章节接着展开异常处理,这里先看主流程):

// utils/uploader.js
import { chooseVideo } from './video.js';
import { getOssConfig } from './oss.js';

export async function uploadVideo({ fileInfo, onProgress }) {
  // 1. 获取 STS 凭证(有缓存)
  const ossConfig = await getOssConfig();
  // 2. 生成 objectKey
  const objectKey = generateObjectKey(fileInfo.name);
  // 3. 根据编译环境和运行环境分发
  // #ifdef H5
  return uploadToAliyunByXHR({
    file: fileInfo.file,
    filename: fileInfo.name,
    ossConfig,
    objectKey,
    onProgress
  });
  // #endif
  // #ifdef MP-WEIXIN || MP-ALIPAY || MP-BAIDU
  return uploadToAliyunByUniUploadFile({
    path: fileInfo.path,
    name: fileInfo.name,
    ossConfig,
    objectKey,
    onProgress
  });
  // #endif
  // #ifdef APP-PLUS
  const nativePath = convertFileToLocalPath(fileInfo.path);
  return uploadToAliyunByUniUploadFile({
    path: nativePath,
    name: fileInfo.name,
    ossConfig,
    objectKey,
    onProgress
  });
  // #endif
}

这样封装完之后,业务层完全感知不到各端差异。后续如果新增一个端(比如字节小程序),只需要在分发逻辑里加一个编译分支,不用改业务代码。

4. 视频上传的常见踩坑现场:从拿不到文件名到被判定为 octet-stream

代码写完了,不代表事情结束了。真正折磨人的是调试阶段遇到的各种诡异问题。我把自己遇到的典型问题按出现频率排了个序,每个都配上原因分析和解决办法。

4.1 H5 端文件名丢失与后缀缺失:OSS 把文件判定为 octet-stream

这是一个非常隐蔽的坑。H5 端使用 uni.chooseVideo 拿到的是 File 对象,它的 name 往往是 blob 或者一段 UUid 字符串,没有任何文件扩展名。如果你直接把 objectKey 拼成 videos/20240101/123456 ,没有 .mp4 后缀,OSS 会把 Content-Type 设置为 application/octet-stream ,导致上传之后的视频链接无法直接预览,而且下载到本地也没有正确扩展名。

解决办法有两个:

  • 一是选择视频前通过文件名的 MIME type 推断,把实际的扩展名补上。比如 file.type === 'video/mp4' 就拼 .mp4 。
  • 二是在 uni.chooseVideo 成功后,手动给文件加一个可靠的名称。

我最常用的是第二种,在 chooseVideo 的 success 回调里做一次兜底:

// utils/video.js
function getFileExt(file, path) {
  const mimeMap = {
    'video/mp4': 'mp4',
    'video/quicktime': 'mov',
    'video/x-msvideo': 'avi',
    'video/webm': 'webm'
  };
  if (file && file.type) {
    return mimeMap[file.type] || 'mp4';
  }
  // 从路径中提取扩展名
  const matches = /\.(\w+)$/.exec(path || '');
  if (matches) {
    return matches[1];
  }
  return 'mp4';
}

然后生成 objectKey 时强制拼接扩展名,这样 OSS 就能正确识别视频类型, Content-Type 也会自动设置成 video/mp4 。

4.2 小程序上传报 403 SignatureDoesNotMatch:Policy 与签名不匹配

这个问题的根源通常是前端使用了 uni.uploadFile 的 formData ,但 objectKey 里的字符和后端生成 policy/signature 时的资源路径不一致。

举个例子:后端给 STS Policy 的资源是 videos/* ,但前端实际生成的 objectKey 是 videos/2024/07/xxx.mp4 ,这个没问题。但如果前端在 objectKey 里带了 / 开头的路径,比如 /videos/2024/07/xxx.mp4 ,OSS 在计算签名时会把首部的 / 忽略,然后和后端 Policy 里的 videos/* 对比,发现资源不在允许范围内,直接拒绝。

解决方案是统一约定 objectKey 的前缀规则,前端生成后可以打日志确认一下,不要相信直觉。我习惯在生成后做一次 encodeURIComponent 脱敏校验,确保没有空格、中文和特殊符号。签名计算和后端 Policy 必须基于同一个 objectKey,不能前端拼一个、后端算一个。

注意:Policy 签名里的 resource 是一个带通配符的字符串,比如 acs:oss:*:*:bucket-name/videos/* 。如果前端上传的子路径在 videos 之外,即使签名正确,OSS 也会因为权限不足返回 AccessDenied。

4.3 App 端沙箱权限与临时目录清理

App 端还有一个坑是文件路径的权限。 uni.chooseVideo 返回的临时文件路径,在小程序端会自动清理,但在 App 端并不会。如果你不主动清理,这些临时文件会一直堆积在沙箱目录里,导致 App 占用的存储空间越来越大。

我的做法是在上传完成后,主动调用 plus.io 删除临时文件:

function removeTempFile(path) {
  // #ifdef APP-PLUS
  return new Promise((resolve) => {
    plus.io.resolveLocalFileSystemURL(
      path,
      (entry) => {
        entry.remove(
          () => resolve(true),
          () => resolve(false)
        );
      },
      () => resolve(false)
    );
  });
  // #endif
  // #ifndef APP-PLUS
  return Promise.resolve(true);
  // #endif
}

删除时机需要注意:一定要在视频确认上传成功之后再删,不要选择完视频就立刻删,否则上传时会发现文件已经被删了,直接失败。

4.4 微信小程序临时文件大小限制与真机差异

微信小程序的 chooseVideo 默认会把视频压缩到 compressed 参数指定的标准,但对于较大的源视频(比如 200MB 以上),即使压缩,临时文件也可能超过小程序的单次上传限制。

我实测过,微信开发者工具里上传 100MB 文件通常没问题,但真机(尤其是 iOS)上偶尔会出现上传失败,控制台报错 uploadFile:fail 。这不一定是你代码的问题,而是微信本身对该接口的限制和 iOS 系统的内存约束。

针对这个问题,我有两个应对思路:

  • 一是把 compressed 参数设为 true ,并设置 maxDuration ,减少生成文件的大小。
  • 二是如果业务上允许,支持用户选择压缩后上传或原图上传。给用户一个开关,降低单次上传体积。

当然,如果视频体积实在很大,可以考虑分片上传。但这里有个现实问题:小程序端的 Web Worker 能力受限,手动实现分片上传比较麻烦,对大部分业务来说整体上传 + 服务端限流就够了。分片上传适合对文件大小有强诉求的场景,后面我可以单独开篇写。

5. 优化加载与播放体验:上传只是开始,播放链路也要全端打通

视频上传完成的最终目的,是让用户能在各端看到视频。很多人上传处理好文件就以为大功告成,结果 H5 端播放黑屏、App 端播放卡顿、小程序端无法播放。播放体验是"全端兼容"的另一半,需要特别注意。

5.1 不同端的视频 URL 策略:直链 vs 签名 URL vs CDN 加速

上传完成后,你会得到一个 OSS 直链,形如 https://your-bucket.oss-cn-hangzhou.aliyuncs.com/your-object-key 。这个链接在 H5 端可以直接放在 <video> 标签里播放;小程序端用 <video> 组件也支持;App 端用 video 组件同样能识别。

但这里有问题:如果你的 Bucket 是私有的,这个直链在未签名时是无法访问的。全端兼容的方案是,让后端在上传完成后生成一个 STS 签名 URL,有效期为比如 15 分钟,然后把签名 URL 返回给客户端播放。

以 Node.js 后端为例,生成签名 URL 的方式:

const OSS = require('ali-oss');
const client = new OSS({
  region: 'oss-cn-hangzhou',
  accessKeyId: 'your-access-key-id',
  accessKeySecret: 'your-access-key-secret',
  bucket: 'your-bucket'
});

const url = client.signatureUrl('your-object-key', {
  expires: 900,  // 15 分钟
  method: 'GET'
});

不过 15 分钟过期对用户来说体验不好,视频没看完链接就失效了。我的实践是:

  • 如果业务上允许视频较短,直接用签名 URL,过期时间设置成 1 小时。
  • 如果视频较长,建议将 Bucket 开放 CDN 加速,并将 CDN 的 URL 返回给用户。CDN 有回源鉴权,可以自行配置。但要注意 CDN 节点可能缓存长时间不失效,对于隐私性要求高的视频,需要单独做防盗链。

5.2 iOS Safari 与微信里的播放兼容问题:Ranges 请求和 WebM

前端播放视频最常踩的坑是 iOS Safari(包括微信内置浏览器)对 HTTP Range 请求的依赖。当视频文件较大时,Safari 会向服务器发送 Range: bytes=0-1 的探测请求,如果 OSS 返回了 206 Partial Content ,Safari 才能正常拖动进度条。如果 OSS 返回 200(即不支持 Range),视频虽然能开始播放,但进度条无法拖动,甚至播放一会儿就卡住。

OSS 本身是支持 Range 请求的,只要你没有在 CDN 上强制关闭缓存。如果你用了 CDN,需要检查是否配置了 Range回源 和相关缓存规则,不然会破坏 Range 支持。

另外一个坑是 WebM 格式。Android 端原生浏览器经常能播放 WebM,但 iOS Safari 不支持。如果你压缩视频时没有指定输出格式,某些 App 端的压缩逻辑可能生成 WebM 文件,从而在 iOS 上播放失败。稳妥的做法是统一转成 MP4,或者在上传前检测文件格式,如果不是 MP4,提示用户重新选择或用服务端转码。

5.3 上传进度展示:避免"假进度"和"卡在99%"

进度展示是体验的一部分。小程序端的 uploadTask.onProgressUpdate 返回的进度是真实的上传字节数,但 H5 端 XHR 的 upload.onprogress 对某些浏览器来说,会在最后阶段卡住不动。尤其是文件较大时,进度条走到 99% 但迟迟不触发 100% 回调,用户以为卡了,直接退出页面。

应对办法是:不依赖单个回调判断完成,而是以上传请求返回成功(res.statusCode === 200)作为最终完成信号,在请求成功后将进度强制置为 100%。同时,在 UI 侧做一个兜底:如果连续 15 秒没有新的进度更新,且上传未完成,就提示用户网络异常,并给出重试按钮。

6. 分片上传与复杂场景:什么时候需要,怎么做到全端兼容

如果你的视频文件经常超过 500MB,或者用户手机网络不稳定,整体上传往往撑不住。分片上传能显著提高成功率,但实现难度和维护成本也更高。这一节聊聊我自己的取舍。

6.1 分片上传 vs 整体上传的边界条件

很多人一上来就想做分片上传,但我认为先要搞清楚一个前提:你的业务场景是否真的需要分片?

分片上传的技术本质是:先把文件切割成多个块,逐个上传,最后调用 complete 合并。好处是单块失败只需要重传该块,不用从头再来;整体上传如果中途断了,重传成本就高很多。

但分片上传有两个额外成本:

  • 服务端需要维护上传会话,记录哪些块已上传、哪些块未上传,需要额外开发接口。
  • 前端在 H5 端切割文件需要 Blob.slice,在小程序端切割文件需要使用 FileSystemManager 的 readFile,App 端则要看原生封装,整体复杂度显著上升。

所以我的边界建议是:

  • 文件小于 200MB:不要分片,整体上传就够了,省心省力。
  • 文件在 200MB~500MB:可以考虑给 H5 端加上"断点续传"能力,前端把分片信息发给后端,后端记录 offset。
  • 文件超过 500MB 或网络环境恶劣:强烈建议分片,分片大小建议 5MB~10MB,既能减少失败重试成本,又能保证并发度。

6.2 基于 OSS multipart 的简化分片方案

OSS 提供了现成的 Multipart Upload API,理论上可以在客户端直接调用。但正如前面所说,小程序端对分片支持不足,需要我们自己封装一层。

我实现过一套简化版的分片逻辑,思路如下:

  1. 前端把视频按 5MB 一份切成数组,每份读取成 ArrayBuffer。
  2. 先用 STS 凭证发起 InitiateMultipartUpload,拿到 uploadId。
  3. 逐个上传分片,每片返回 ETag。
  4. 最后调用 CompleteMultipartUpload。

核心代码大概是这样(这里只展示关键逻辑,为了可读性做了简化):

// utils/multipart.js
import { getOssConfig } from './oss.js';
import { buildAuthHeader } from './signature.js';

export async function uploadMultipart({ path, objectKey, partSize = 5 * 1024 * 1024, onProgress }) {
  const ossConfig = await getOssConfig();
  // #ifdef H5
  return uploadMultipartH5({ file: path, objectKey, partSize, onProgress, ossConfig });
  // #endif
  // #ifdef MP-WEIXIN
  return uploadMultipartMiniProgram({ filePath: path, objectKey, partSize, onProgress, ossConfig });
  // #endif
  // #ifdef APP-PLUS
  return uploadMultipartApp({ path, objectKey, partSize, onProgress, ossConfig });
  // #endif
}

每一端的实现都有各自麻烦的地方:

  • H5 端可以用 File.slice() 截取 ArrayBuffer,然后用 PutObject 带上请求头 x-oss-upload-id 上传。
  • 小程序端需要 FileSystemManager.readFile 配合 ArrayBuffer ,但小程序对内存有硬限制,300MB 的视频若全部读入内存会直接把节点搞崩,所以必须一读一传。
  • App 端则可以用原生 FileReader 分片,也可以用 plus 的文件操作 API。

分片上传代码量比较大,而且每端的网络库行为差异多,非常吃测试资源。如果团队人力有限,我更推荐用生态成熟的第三方库,比如 ali-oss 的微信小程序版本,或者 uni-file-picker 这样带有分片上传能力的插件。用现成方案虽然少了一些定制的自由,但能大幅降低维护成本。

分片上传的另一个好处是方便做并发控制。我用并发数为 3 的队列,既能保证速度,又不至于把请求全部堆上去导致 OSS 限流。

6.3 断点续传的本地记录策略

分片上传如果中途断了,如何恢复?我的做法是在本地缓存一个上传状态对象:

// 存到 Storage 或文件系统
{
  objectKey: 'videos/2024/07/xxx.mp4',
  uploadId: 'xxx',
  uploadedParts: [1, 2, 3, 5],  // 成功上传的块序号
  partSize: 5 * 1024 * 1024,
  totalParts: 20,
  updatedAt: 1700000000000
}

下次进入上传页时,先检查本地是否有未完成的分片记录,如果有,继续从未完成的块开始。这里的 key 是 objectKey,同一个文件再次上传时会生成新的 objectKey,新旧 session 不会混淆。

当然,这种本地存储也有坑:如果用户清除了 App 缓存,缓存记录就会丢失,已上传的分片也会变成孤儿分片。我一般会在服务端定期清理过期未 Complete 的分片(OSS 有自己的生命周期规则,你可以给 Bucket 配一条规则,将 Multipart Upload 的过期时间设为 7 天后自动清理)。这样既不会占用太多存储,也不需要复杂的手动清理逻辑。

7. 错误处理与异常兜底:上传失败不能只靠 alert

上传是个高失败率操作。网络抖动、STS 过期、文件损坏、后端接口超时,任何一个环节出问题都可能导致上传中断。一套健壮的上传方案,必须包含完善的错误分类和兜底重试机制。

7.1 错误分类与用户提示策略

我习惯把错误分成以下几类,并分别处理:

错误类型 典型场景 处理策略
本地错误 文件路径不存在、文件读取失败 提示用户重新选择文件
网络错误 请求超时、断网、TLS 握手失败 自动重试 2 次,重试时重置进度条,提示用户检查网络
权限错误 STS 过期、Policy 不允许、签名不匹配 重新换取 STS,若仍失败提示联系客服
OSS 服务端错误 Bucket 不存在、Quota 超限 提示系统错误,记录日志供后台排查

这里有一点要特别注意:用户取消上传 vs 上传失败,必须分开提示。取消操作不应该触发重试,失败操作才应该。我前面在 chooseVideo 里用 CANCEL 区分用户取消,就是为了后面统一处理。

7.2 失败后重试与断点续传的联动

整体上传的重试逻辑比较简单:失败后,如果网络恢复,重新携带同一个 objectKey 直接传即可。OSS 会覆盖同名文件,所以即使上传了一部分,重传也是安全的。

分片上传的重试则复杂一些,这个在上面已经说过了。重试时最好给用户一个按钮,比如"重新上传"和"继续上传",让用户自己选择,而不是盲目自动重试。自动重试只适合网络瞬时抖动,不适合用户主动断网半天的情况。

7.3 统一上报与监控:上传失败率才是你该关心的指标

最后,我强烈建议在上传开始时打点,上传成功或失败后再打一个结束点。把统计信息上报给后端,比如每秒上传字节数、失败阶段(请求 STS、上传文件、回调业务)、OSS 返回的错误码。这样线上出了大面积上传失败,你能第一时间从数据里定位是哪个环节出了问题。

我经历过一次线上事故,用户反馈上传视频一直失败,后来靠监控数据发现是 STS 接口的 RAM 角色权限被误改了,导致所有客户端拿到的凭证都是无效的。如果没有统一上报,靠用户一个个反馈,排查时间会拉长很多。

做一个简单的统计上报也很容易,就是在 uploadVideo 里加一个异步的 sendEvent 调用,不影响主流程。关键是这个习惯要坚持下来,线上稳定性和开发效率都靠这些基础数据兜底。


视频上传从表面看只是"选文件、传上去、拿链接"三步,实际上每一端都有各自的脾气和暗坑。我在处理 UniApp 全端兼容的过程中最大的体会就是:不要试图用一个万能的纯前端 SDK 解决所有端的问题,而是先想清楚每个端的运行环境差异,再用"统一入口 + 分端适配"的思路去组织代码。这套方案在我这边的项目里已经跑了好几轮迭代,H5、微信小程序、App 三端的视频上传成功率都在 99% 以上。如果你正在做类似的功能,照着这个思路走,至少能帮你省下几个通宵排查 bug 的时间。

Logo

火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。

更多推荐