CodeL
以前端为翼,以 AI 为脑,向全栈而行
2026-03-31

前端媒体资源完全指南

前端媒体资源完全指南 从图片格式选择到视频直播实现,从懒加载优化到西瓜播放器集成,覆盖 Vue3 项目中媒体资源的完整解决方案 目录 1. 核心概念 2. 图片格式与优化 3. 图片懒加载实现 4. Vue3 图片网站优...

前端媒体资源完全指南 #

从图片格式选择到视频直播实现,从懒加载优化到西瓜播放器集成,覆盖 Vue3 项目中媒体资源的完整解决方案


目录 #

  1. 核心概念
  2. 图片格式与优化
  3. 图片懒加载实现
  4. Vue3 图片网站优化
  5. 视频格式与编码
  6. 视频播放器选型
  7. 西瓜播放器详解
  8. 直播流实现
  9. 视频网站性能优化
  10. 实战场景
  11. 常见问题
  12. 总结速记

一、核心概念 #

1.1 媒体资源类型概览 #

前端项目中的媒体资源主要分为两大类:

类型 常见格式 特点 典型场景
图片 JPEG、PNG、WebP、AVIF、GIF、SVG 静态、体积较小、加载快 商品图、头像、背景图
视频 MP4、WebM、HLS、DASH 动态、体积大、需要流式加载 点播、直播、短视频
音频 MP3、AAC、OGG、Opus 只有声音 音乐播放、语音消息
直播流 HLS、FLV、DASH、RTMP 实时传输、低延迟要求 直播、监控、会议

1.2 核心名词解释 #

名词 大白话解释 举例
编码格式 视频/图片的"压缩方式",决定质量和体积 H.264、H.265、VP9
封装格式 视频/图片的"容器格式",文件后缀名 .mp4、.webm、.mkv
码率(Bitrate) 每秒的数据量,越高越清晰但体积越大 1080p 通常 4-8 Mbps
分辨率 视频的宽高像素数 720p、1080p、4K
帧率(FPS) 每秒的画面数,越高越流畅 24fps(电影)、60fps(游戏)
关键帧(I帧) 完整的一帧画面,可独立解码 直播切片的起点
P帧/B帧 差异帧,依赖其他帧解码 压缩率更高
CDN 内容分发网络,就近获取资源 加速图片/视频加载
流媒体 边下边播,不需要全部下载 HLS、DASH
转码 转换视频编码格式 H.264 → H.265

1.3 编码 vs 封装(重要区分) #

很多人搞混编码和封装:

文件:video.mp4

封装格式:MP4(容器,里面可以装不同编码的视频)

视频编码:H.264、H.265、VP9(压缩算法)
音频编码:AAC、MP3、Opus(声音压缩)

举个例子:

文件名 封装格式 视频编码 音频编码
video.mp4 MP4 H.264 AAC
video.webm WebM VP9 Opus
video.mkv MKV H.265 AAC
video.avi AVI MPEG-4 MP3

核心要点:

  • 封装格式决定文件后缀和兼容性
  • 编码格式决定压缩效率和浏览器支持
  • 同一个封装格式可以装不同编码的视频

二、图片格式与优化 #

2.1 图片格式对比 #

格式 优点 缺点 适用场景 浏览器支持
JPEG 压缩率高、兼容性好 不支持透明、有损压缩 照片、商品图 ✅ 全支持
PNG 支持透明、无损压缩 体积较大 图标、Logo、截图 ✅ 全支持
WebP 体积小(比 JPEG 小 30%)、支持透明 老浏览器不支持 现代网站的通用选择 ✅ 97%+
AVIF 压缩率最高(比 WebP 还小) 编码慢、兼容性一般 追求极致压缩 ⚠️ 90%+
GIF 支持动画 体积大、颜色少(256色) 简单动图、表情包 ✅ 全支持
SVG 矢量图、无限缩放 不适合复杂图片 图标、Logo、图表 ✅ 全支持

2.2 格式选择决策树 #

需要图片?
    ├── 照片/复杂图片
    │       ├── 追求兼容 → JPEG
    │       └── 追求体积 → WebP 或 AVIF

    ├── 需要透明
    │       ├── 老浏览器 → PNG
    │       └── 现代浏览器 → WebP

    ├── 图标/Logo
    │       ├── 简单图形 → SVG
    │       └── 复杂图形 → PNG/WebP

    ├── 需要动画
    │       ├── 简单动画 → GIF(或 WebP 动画)
    │       └── 复杂动画 → 视频(MP4/WebM)

    └── 追求极致压缩
            └── AVIF(后备 WebP)

2.3 WebP 与 AVIF 的实战使用 #

<!-- 方式 1:<picture> 标签自动选择最优格式 -->
<picture>
  <!-- 浏览器按顺序选择第一个支持的 -->
  <source srcset="image.avif" type="image/avif">
  <source srcset="image.webp" type="image/webp">
  <img src="image.jpg" alt="后备图片" loading="lazy">
</picture>
 
<!-- 方式 2:服务端根据 Accept 头自动返回 -->
<!-- 请求时带上 Accept: image/webp,image/avif -->
<!-- 服务端检测后返回对应格式 -->
<img src="/api/image/123" alt="自动适配格式">
// 检测浏览器支持
function checkImageSupport() {
  return {
    webp: document.createElement('canvas').toDataURL('image/webp').startsWith('data:image/webp'),
    avif: document.createElement('canvas').toDataURL('image/avif').startsWith('data:image/avif'),
  };
}
 
// 根据支持情况请求图片
async function getOptimalImage(id) {
  const support = checkImageSupport();
  const format = support.avif ? 'avif' : support.webp ? 'webp' : 'jpg';
  return `/api/image/${id}.${format}`;
}

2.4 图片压缩工具 #

工具 用途 特点
TinyPNG 在线压缩 PNG/JPEG 简单易用、压缩率高
Squoosh Google 在线工具 支持多种格式、可调参数
sharp Node.js 图片处理 批量处理、自动化
imagemin 构建工具插件 Webpack/Vite 集成
// Vite 构建时自动压缩图片
import viteImagemin from 'vite-plugin-imagemin';
 
export default {
  plugins: [
    viteImagemin({
      gifsicle: { optimizationLevel: 3 },
      optipng: { optimizationLevel: 7 },
      mozjpeg: { quality: 80 },
      svgo: { plugins: [{ name: 'removeViewBox' }] },
      webp: { quality: 80 },
    }),
  ],
};

三、图片懒加载实现 #

3.1 什么是懒加载? #

懒加载(Lazy Loading) = 图片进入视口时才加载,而不是页面加载时全部加载。

好处:

  • 减少首屏加载时间
  • 节省带宽(用户可能不滚动到底部)
  • 提升用户体验

3.2 原生懒加载(最简单) #

<!-- 浏览器原生支持,只需加 loading="lazy" -->
<img src="image.jpg" loading="lazy" alt="自动懒加载">
 
<!-- 兼容性:Chrome 77+、Firefox 75+、Safari 15.4+ -->

3.3 IntersectionObserver 实现(可控性更强) #

// HTML: <img class="lazy" data-src="real-image.jpg" src="placeholder.jpg">
 
class LazyLoader {
  constructor(options = {}) {
    this.selector = options.selector || '.lazy';
    this.rootMargin = options.rootMargin || '50px';
    this.threshold = options.threshold || 0;
    
    this.init();
  }
  
  init() {
    // 检查浏览器是否支持 IntersectionObserver
    if ('IntersectionObserver' in window) {
      this.observer = new IntersectionObserver(
        this.handleIntersection.bind(this),
        {
          rootMargin: this.rootMargin,
          threshold: this.threshold,
        }
      );
      
      this.observe();
    } else {
      // 降级:直接加载所有图片
      this.loadAll();
    }
  }
  
  handleIntersection(entries) {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        this.loadImage(entry.target);
        this.observer.unobserve(entry.target);
      }
    });
  }
  
  loadImage(img) {
    const src = img.dataset.src;
    if (!src) return;
    
    // 可选:添加淡入效果
    img.style.opacity = '0';
    img.style.transition = 'opacity 0.3s';
    
    img.onload = () => {
      img.style.opacity = '1';
    };
    
    img.src = src;
    img.classList.remove('lazy');
  }
  
  observe() {
    const images = document.querySelectorAll(this.selector);
    images.forEach(img => this.observer.observe(img));
  }
  
  loadAll() {
    const images = document.querySelectorAll(this.selector);
    images.forEach(img => this.loadImage(img));
  }
}
 
// 使用
new LazyLoader({
  selector: '.lazy',
  rootMargin: '100px',  // 提前 100px 开始加载
});

3.4 Vue3 懒加载组件 #

<!-- LazyImage.vue -->
<template>
  <img
    ref="imgRef"
    :src="placeholder"
    :data-src="src"
    :alt="alt"
    :class="['lazy-image', { loaded: isLoaded }]"
  />
</template>
 
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
 
const props = defineProps({
  src: { type: String, required: true },
  placeholder: { type: String, default: 'data:image/svg+xml,...' }, // 1x1 透明图
  alt: { type: String, default: '' },
});
 
const imgRef = ref(null);
const isLoaded = ref(false);
let observer = null;
 
onMounted(() => {
  if ('IntersectionObserver' in window) {
    observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            loadImage();
            observer.unobserve(entry.target);
          }
        });
      },
      { rootMargin: '50px' }
    );
    
    observer.observe(imgRef.value);
  } else {
    loadImage();
  }
});
 
onUnmounted(() => {
  observer?.disconnect();
});
 
function loadImage() {
  if (imgRef.value && imgRef.value.dataset.src) {
    imgRef.value.src = imgRef.value.dataset.src;
    imgRef.value.onload = () => {
      isLoaded.value = true;
    };
  }
}
</script>
 
<style scoped>
.lazy-image {
  opacity: 0;
  transition: opacity 0.3s ease;
}
 
.lazy-image.loaded {
  opacity: 1;
}
</style>
<!-- 使用 -->
<LazyImage 
  src="/images/photo.jpg" 
  placeholder="/images/placeholder.jpg"
  alt="商品图"
/>

3.5 懒加载最佳实践 #

场景 建议方案
简单网站 原生 loading="lazy"
需要动画效果 IntersectionObserver
Vue/React 项目 封装懒加载组件
动态内容 MutationObserver 监听新增元素
// 动态内容懒加载(SPA 路由切换后)
function setupDynamicLazyLoad() {
  const observer = new MutationObserver((mutations) => {
    mutations.forEach((mutation) => {
      mutation.addedNodes.forEach((node) => {
        if (node.nodeType === 1 && node.classList?.contains('lazy')) {
          lazyObserver.observe(node);
        }
      });
    });
  });
  
  observer.observe(document.body, { childList: true, subtree: true });
}

四、Vue3 图片网站优化 #

4.1 图片列表优化策略 #

<!-- ImageList.vue - 图片列表优化示例 -->
<template>
  <div class="image-list">
    <!-- 虚拟列表:只渲染可见区域 -->
    <VirtualList
      :items="images"
      :item-height="300"
      :buffer="5"
    >
      <template #default="{ item }">
        <ImageCard :image="item" />
      </template>
    </VirtualList>
    
    <!-- 无限滚动加载 -->
    <div ref="loaderRef" class="loader">
      <span v-if="loading">加载中...</span>
      <span v-else-if="!hasMore">没有更多了</span>
    </div>
  </div>
</template>
 
<script setup>
import { ref, onMounted } from 'vue';
import VirtualList from './VirtualList.vue';
import ImageCard from './ImageCard.vue';
 
const images = ref([]);
const loading = ref(false);
const hasMore = ref(true);
const page = ref(1);
const loaderRef = ref(null);
 
// 无限滚动
onMounted(() => {
  const observer = new IntersectionObserver((entries) => {
    if (entries[0].isIntersecting && !loading.value && hasMore.value) {
      loadMore();
    }
  });
  
  observer.observe(loaderRef.value);
});
 
async function loadMore() {
  loading.value = true;
  
  try {
    const newImages = await fetchImages(page.value++);
    
    if (newImages.length === 0) {
      hasMore.value = false;
    } else {
      images.value.push(...newImages);
    }
  } finally {
    loading.value = false;
  }
}
</script>

4.2 图片预加载 #

// 预加载下一张图片(提升用户体验)
function preloadImage(src) {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.onload = () => resolve(img);
    img.onerror = reject;
    img.src = src;
  });
}
 
// Vue3 Composable
import { ref } from 'vue';
 
export function useImagePreload() {
  const preloaded = ref(new Set());
  
  async function preload(urls) {
    const promises = urls
      .filter(url => !preloaded.value.has(url))
      .map(url => preloadImage(url).then(() => preloaded.value.add(url)));
    
    await Promise.all(promises);
  }
  
  return { preload, preloaded };
}
 
// 使用:预加载下一页图片
const { preload } = useImagePreload();
 
async function loadPage(page) {
  const images = await fetchImages(page);
  
  // 预加载下一页
  const nextPage = await fetchImages(page + 1);
  preload(nextPage.map(img => img.url));
  
  return images;
}

4.3 图片占位符策略 #

<!-- 渐进式图片加载 -->
<template>
  <div class="progressive-image">
    <!-- 1. 占位符(模糊缩略图) -->
    <img
      v-if="!isLoaded"
      :src="thumbnail"
      class="thumbnail"
      :style="{ filter: 'blur(20px)' }"
    />
    
    <!-- 2. 高清图 -->
    <img
      ref="fullImage"
      :src="fullSrc"
      :class="['full-image', { hidden: !isLoaded }]"
      @load="onLoad"
    />
  </div>
</template>
 
<script setup>
import { ref } from 'vue';
 
const props = defineProps({
  thumbnail: String,   // 20x20 的模糊缩略图
  fullSrc: String,     // 原图
});
 
const isLoaded = ref(false);
const fullImage = ref(null);
 
function onLoad() {
  isLoaded.value = true;
}
</script>
 
<style scoped>
.progressive-image {
  position: relative;
  overflow: hidden;
}
 
.thumbnail,
.full-image {
  width: 100%;
  height: 100%;
  object-fit: cover;
}
 
.full-image.hidden {
  opacity: 0;
}
 
.full-image {
  position: absolute;
  top: 0;
  left: 0;
  transition: opacity 0.3s;
}
</style>

4.4 响应式图片 #

<!-- 根据设备加载不同尺寸 -->
<template>
  <picture>
    <!-- 移动端:小图 -->
    <source
      media="(max-width: 640px)"
      :srcset="`${src}?w=640 1x, ${src}?w=1280 2x`"
    />
    <!-- 平板:中图 -->
    <source
      media="(max-width: 1024px)"
      :srcset="`${src}?w=1024 1x, ${src}?w=2048 2x`"
    />
    <!-- 桌面:大图 -->
    <img
      :src="`${src}?w=1920`"
      :srcset="`${src}?w=1920 1x, ${src}?w=3840 2x`"
      :alt="alt"
      loading="lazy"
    />
  </picture>
</template>

4.5 图片 CDN 优化 #

// CDN 图片处理函数
function optimizeImage(url, options = {}) {
  const {
    width,
    height,
    quality = 80,
    format = 'webp',
  } = options;
  
  const params = new URLSearchParams();
  
  if (width) params.set('w', width);
  if (height) params.set('h', height);
  params.set('q', quality);
  params.set('f', format);
  
  // 阿里云 OSS 示例
  // https://oss.example.com/image.jpg?x-oss-process=image/resize,w_640/quality,q_80/format,webp
  return `${url}?x-oss-process=image/resize,w_${width}/quality,q_${quality}/format,${format}`;
  
  // 七牛云示例
  // return `${url}?imageView2/1/w/${width}/h/${height}/q/${quality}/format/${format}`;
  
  // 腾讯云 COS 示例
  // return `${url}?imageMogr2/thumbnail/${width}x/quality/${quality}/format/${format}`;
}
 
// 使用
const optimizedUrl = optimizeImage('https://cdn.example.com/photo.jpg', {
  width: 640,
  quality: 80,
  format: 'webp',
});

五、视频格式与编码 #

5.1 视频格式对比 #

格式 封装 常用编码 优点 缺点 浏览器支持
MP4 MP4 H.264 + AAC 兼容性最好 体积较大 ✅ 全支持
WebM WebM VP9/AV1 + Opus 体积小、开源 老 Safari 不支持 ✅ 95%+
MOV QuickTime H.264/H.265 Apple 生态 Windows 兼容差 ⚠️ 部分
MKV Matroska 任意编码 灵活、支持多轨道 浏览器不直接支持 ❌ 需转码

5.2 视频编码对比 #

编码 发布年份 压缩效率 兼容性 适用场景
H.264 (AVC) 2003 基准 ✅ 全支持 通用视频、直播
H.265 (HEVC) 2013 比 H.264 小 50% ⚠️ Safari/Edge 4K 视频、高质量
VP9 2013 接近 H.265 ✅ Chrome/Firefox YouTube、WebM
AV1 2018 比 VP9 小 30% ⚠️ 较新浏览器 追求极致压缩

5.3 编码选择建议 #

选择视频编码?
    ├── 追求最大兼容 → H.264(MP4)

    ├── 追求体积 + 兼容 → H.264 + WebM 双格式

    ├── 追求极致体积 → AV1(后备 VP9)

    └── 4K/高清视频
            ├── Safari 用户多 → H.265
            └── Chrome 用户多 → VP9 或 AV1

5.4 浏览器编码支持检测 #

// 检测视频编码支持
function checkVideoSupport() {
  const video = document.createElement('video');
  
  const codecs = {
    'H.264': 'video/mp4; codecs="avc1.42E01E"',
    'H.265': 'video/mp4; codecs="hvc1.1.6.L93.90"',
    'VP9': 'video/webm; codecs="vp9"',
    'AV1': 'video/mp4; codecs="av01.0.01M.08"',
    'WebM VP8': 'video/webm; codecs="vp8"',
  };
  
  const support = {};
  
  for (const [name, mime] of Object.entries(codecs)) {
    support[name] = video.canPlayType(mime) !== '';
  }
  
  return support;
}
 
// 结果示例
// {
//   'H.264': true,
//   'H.265': true,  // Safari/Edge
//   'VP9': true,    // Chrome/Firefox
//   'AV1': false,   // 需要较新浏览器
// }
 
// 根据支持情况选择视频源
function getOptimalVideo(sources) {
  const support = checkVideoSupport();
  
  // 优先级:AV1 > VP9 > H.265 > H.264
  if (support.AV1 && sources.av1) return sources.av1;
  if (support.VP9 && sources.vp9) return sources.vp9;
  if (support['H.265'] && sources.h265) return sources.h265;
  return sources.h264;  // 后备
}

5.5 视频转码工具 #

工具 用途 特点
FFmpeg 命令行转码 功能强大、自动化
HandBrake GUI 转码 简单易用
Cloudflare Stream 云转码 自动适配
阿里云/腾讯云 媒体处理 云转码 批量处理
# FFmpeg 常用命令
 
# 转换为 H.264 MP4(最大兼容)
ffmpeg -i input.mov -c:v libx264 -crf 23 -c:a aac output.mp4
 
# 转换为 WebM(VP9)
ffmpeg -i input.mp4 -c:v libvpx-vp9 -crf 30 -c:a libopus output.webm
 
# 压缩视频(降低分辨率)
ffmpeg -i input.mp4 -vf scale=1280:-1 -c:v libx264 -crf 28 output.mp4
 
# 提取视频缩略图
ffmpeg -i input.mp4 -ss 00:00:05 -vframes 1 thumbnail.jpg
 
# 生成 HLS 切片
ffmpeg -i input.mp4 -c:v libx264 -c:a aac -hls_time 10 -hls_list_size 0 output.m3u8

六、视频播放器选型 #

6.1 播放器对比 #

播放器 类型 优点 缺点 适用场景
原生 <video> 原生 简单、无依赖 功能少、样式不统一 简单视频播放
Video.js 开源 功能全、插件多 体积较大(~200KB) 通用视频网站
西瓜播放器 开源 国产、文档中文、功能强 社区较小 国内项目、直播
DPlayer 开源 轻量、弹幕支持 功能较少 简单视频、弹幕
Plyr 开源 轻量、现代UI 高级功能少 简洁播放器
ijkplayer 开源 B站出品、功能强 配置复杂 直播、点播

6.2 原生 video 标签 #

<!-- 最简单的视频播放 -->
<video src="video.mp4" controls></video>
 
<!-- 完整属性 -->
<video
  id="myVideo"
  src="video.mp4"
  controls           <!-- 显示控制条 -->
  autoplay           <!-- 自动播放(需配合 muted) -->
  muted              <!-- 静音( autoplay 必需) -->
  loop               <!-- 循环播放 -->
  poster="cover.jpg" <!-- 封面图 -->
  preload="metadata" <!-- 预加载:none、metadata、auto -->
  playsinline        <!-- iOS 内联播放 -->
  webkit-playsinline <!-- iOS 兼容 -->
  width="640"
  height="360"
>
  <!-- 多格式后备 -->
  <source src="video.webm" type="video/webm">
  <source src="video.mp4" type="video/mp4">
  <p>您的浏览器不支持视频播放</p>
</video>
// JavaScript 控制
const video = document.getElementById('myVideo');
 
// 播放控制
video.play();          // 播放
video.pause();         // 暂停
video.paused;          // 是否暂停
 
// 进度控制
video.currentTime;     // 当前时间(秒)
video.currentTime = 30;  // 跳转到 30 秒
video.duration;        // 总时长(秒)
 
// 音量控制
video.volume;          // 音量(0-1)
video.muted;           // 是否静音
video.muted = true;    // 静音
 
// 播放速率
video.playbackRate;    // 播放速度(1 = 正常)
video.playbackRate = 2;  // 2 倍速
 
// 全屏
video.requestFullscreen();  // 进入全屏
document.exitFullscreen();  // 退出全屏
 
// 常用事件
video.addEventListener('play', () => console.log('开始播放'));
video.addEventListener('pause', () => console.log('暂停'));
video.addEventListener('ended', () => console.log('播放结束'));
video.addEventListener('timeupdate', () => {
  console.log('当前时间:', video.currentTime);
});
video.addEventListener('loadedmetadata', () => {
  console.log('视频信息加载完成,时长:', video.duration);
});

6.3 Video.js 集成 #

<!-- Vue3 Video.js 组件 -->
<template>
  <div ref="videoContainer" class="video-container">
    <video ref="videoRef" class="video-js vjs-default-skin">
      <source :src="src" :type="type" />
    </video>
  </div>
</template>
 
<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue';
import videojs from 'video.js';
import 'video.js/dist/video-js.css';
 
const props = defineProps({
  src: { type: String, required: true },
  type: { type: String, default: 'video/mp4' },
  poster: String,
  autoplay: { type: Boolean, default: false },
  controls: { type: Boolean, default: true },
});
 
const videoRef = ref(null);
const videoContainer = ref(null);
let player = null;
 
onMounted(() => {
  player = videojs(videoRef.value, {
    controls: props.controls,
    autoplay: props.autoplay,
    poster: props.poster,
    fluid: true,  // 响应式
    playbackRates: [0.5, 1, 1.5, 2],  // 倍速选项
    controlBar: {
      children: [
        'playToggle',
        'volumePanel',
        'currentTimeDisplay',
        'timeDivider',
        'durationDisplay',
        'progressControl',
        'playbackRateMenuButton',
        'fullscreenToggle',
      ],
    },
  });
  
  player.on('play', () => emit('play'));
  player.on('pause', () => emit('pause'));
  player.on('ended', () => emit('ended'));
});
 
onUnmounted(() => {
  if (player) {
    player.dispose();
  }
});
 
watch(() => props.src, (newSrc) => {
  if (player) {
    player.src({ src: newSrc, type: props.type });
  }
});
 
const emit = defineEmits(['play', 'pause', 'ended']);
</script>
 
<style scoped>
.video-container {
  width: 100%;
  max-width: 800px;
}
 
.video-js {
  width: 100%;
  height: 100%;
}
</style>

七、西瓜播放器详解 #

7.1 西瓜播放器简介 #

西瓜播放器(xgplayer) 是字节跳动开源的 HTML5 视频播放器,特点:

  • 国产开源,中文文档完善
  • 支持 MP4、HLS、FLV 等多种格式
  • 支持直播流播放
  • 插件化架构,按需加载
  • 移动端适配好

7.2 基础使用 #

# 安装
npm install xgplayer
// 基础播放器
import Player from 'xgplayer';
import 'xgplayer/dist/index.min.css';
 
const player = new Player({
  id: 'player-container',
  url: 'https://cdn.example.com/video.mp4',
  poster: 'https://cdn.example.com/cover.jpg',
  autoplay: false,
  volume: 0.6,
  videoInit: true,  // 显示封面
  playbackRate: [0.5, 1, 1.5, 2, 3],  // 倍速
  defaultPlaybackRate: 1,
  rotatable: true,   // 旋转
  screenShot: true,  // 截图
  pip: true,         // 画中画
  keyboard: {        // 键盘控制
    seekStep: 10,    // 方向键快进/退 10 秒
    keyCode: {
      fullscreen: 70,  // F 全屏
    },
  },
});
 
// 常用方法
player.play();              // 播放
player.pause();             // 暂停
player.start('new.mp4');    // 切换视频源
player.destroy();           // 销毁播放器
 
// 常用属性
player.currentTime;         // 当前时间
player.duration;            // 总时长
player.volume;              // 音量
player.paused;              // 是否暂停
 
// 常用事件
player.on('play', () => {});
player.on('pause', () => {});
player.on('ended', () => {});
player.on('error', (err) => {});
player.on('timeupdate', () => {});

7.3 Vue3 集成 #

<!-- XgPlayer.vue -->
<template>
  <div ref="playerRef" class="xgplayer-container"></div>
</template>
 
<script setup>
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue';
import Player from 'xgplayer';
import 'xgplayer/dist/index.min.css';
 
const props = defineProps({
  url: { type: String, required: true },
  poster: String,
  autoplay: { type: Boolean, default: false },
  controls: { type: Boolean, default: true },
  width: { type: String, default: '100%' },
  height: { type: String, default: '100%' },
});
 
const emit = defineEmits(['play', 'pause', 'ended', 'error', 'ready']);
 
const playerRef = ref(null);
let player = null;
 
onMounted(() => {
  initPlayer();
});
 
onUnmounted(() => {
  destroyPlayer();
});
 
async function initPlayer() {
  await nextTick();
  
  player = new Player({
    el: playerRef.value,
    url: props.url,
    poster: props.poster,
    autoplay: props.autoplay,
    controls: props.controls,
    width: props.width,
    height: props.height,
    fluid: true,
    videoInit: true,
    playbackRate: [0.5, 1, 1.5, 2],
  });
  
  // 绑定事件
  player.on('play', () => emit('play'));
  player.on('pause', () => emit('pause'));
  player.on('ended', () => emit('ended'));
  player.on('error', (err) => emit('error', err));
  player.on('ready', () => emit('ready', player));
}
 
function destroyPlayer() {
  if (player) {
    player.destroy();
    player = null;
  }
}
 
// 监听 URL 变化
watch(() => props.url, (newUrl) => {
  if (player && newUrl) {
    player.src = newUrl;
    player.reload();
  }
});
 
// 暴露方法
defineExpose({
  play: () => player?.play(),
  pause: () => player?.pause(),
  seek: (time) => { if (player) player.currentTime = time; },
  destroy: destroyPlayer,
});
</script>
 
<style scoped>
.xgplayer-container {
  width: 100%;
  aspect-ratio: 16 / 9;
}
</style>

7.4 HLS 直播支持 #

// 安装 HLS 插件
import Player from 'xgplayer';
import HlsPlugin from 'xgplayer-hls';
import 'xgplayer/dist/index.min.css';
 
const player = new Player({
  id: 'player-container',
  url: 'https://cdn.example.com/live.m3u8',
  isLive: true,          // 直播模式
  plugins: [HlsPlugin],  // 启用 HLS 插件
  hls: {
    retryTimes: 3,       // 重试次数
    retryDelay: 1000,    // 重试间隔
  },
  // 直播特有配置
  lang: 'zh-cn',
  preloadTime: 30,       // 预加载时间
  minBufferLen: 3,       // 最小缓冲时长
  latency: 2,            // 延迟时间(秒)
});

7.5 FLV 直播支持 #

// 安装 FLV 插件
import Player from 'xgplayer';
import FlvPlugin from 'xgplayer-flv';
 
const player = new Player({
  id: 'player-container',
  url: 'https://cdn.example.com/live.flv',
  isLive: true,
  plugins: [FlvPlugin],
  flv: {
    isLive: true,
    hasVideo: true,
    hasAudio: true,
    cors: true,
  },
});

7.6 自定义控件 #

// 自定义控件插件
import Player from 'xgplayer';
 
// 自定义按钮
class CustomButton {
  constructor(player) {
    this.player = player;
    this.root = document.createElement('div');
    this.root.className = 'custom-button';
    this.root.innerHTML = '自定义';
    this.root.onclick = () => this.onClick();
  }
  
  onClick() {
    console.log('自定义按钮点击');
  }
  
  render() {
    return this.root;
  }
}
 
const player = new Player({
  id: 'player-container',
  url: 'video.mp4',
  controls: {
    mode: 'bottom',
    autoHide: true,
  },
  // 添加自定义控件
  // ... 在 CSS 中定义样式
});

八、直播流实现 #

8.1 直播协议对比 #

协议 延迟 兼容性 特点 适用场景
HLS 5-30s ✅ 全支持 HTTP 协议、CDN 友好 点播、低要求直播
FLV 1-3s ⚠️ 需要 Flash/JS 低延迟、B站首选 直播
DASH 5-30s ✅ 现代浏览器 自适应码率 点播
RTMP 0.5-2s ❌ 浏览器不支持 推流协议 推流端
WebRTC 0.2-1s ✅ 现代浏览器 超低延迟 视频会议、连麦
HTTP-FLV 1-3s ⚠️ 需要 JS 解析 FLV over HTTP 直播

8.2 HLS 直播原理 #

HLS (HTTP Live Streaming) 工作原理:
 
1. 服务器把视频切成小片段(.ts 文件)
2. 生成播放列表(.m3u8 文件)
3. 播放器下载 m3u8,获取 ts 列表
4. 按顺序下载 ts 片段播放
5. 定期刷新 m3u8,获取新片段
 
文件结构:
live.m3u8(播放列表)
  ├── segment0.ts(视频片段 1)
  ├── segment1.ts(视频片段 2)
  ├── segment2.ts(视频片段 3)
  └── ...
 
m3u8 内容:
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:10
#EXTINF:10.0,
segment0.ts
#EXTINF:10.0,
segment1.ts
#EXTINF:10.0,
segment2.ts

8.3 HLS 播放实现 #

// 方式 1:原生 HLS(Safari 原生支持)
<video src="live.m3u8" controls></video>
 
// 方式 2:hls.js(跨浏览器支持)
import Hls from 'hls.js';
 
const video = document.querySelector('video');
const hlsUrl = 'https://cdn.example.com/live.m3u8';
 
if (Hls.isSupported()) {
  const hls = new Hls({
    debug: false,
    enableWorker: true,
    lowLatencyMode: true,
    liveDurationInfinity: true,  // 直播模式
    liveBackBufferLength: 0,     // 不缓存
  });
  
  hls.loadSource(hlsUrl);
  hls.attachMedia(video);
  
  hls.on(Hls.Events.MANIFEST_PARSED, () => {
    video.play();
  });
  
  hls.on(Hls.Events.ERROR, (event, data) => {
    if (data.fatal) {
      switch (data.type) {
        case Hls.ErrorTypes.NETWORK_ERROR:
          console.log('网络错误,尝试恢复');
          hls.startLoad();
          break;
        case Hls.ErrorTypes.MEDIA_ERROR:
          console.log('媒体错误,尝试恢复');
          hls.recoverMediaError();
          break;
        default:
          console.log('无法恢复,销毁播放器');
          hls.destroy();
          break;
      }
    }
  });
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
  // Safari 原生支持
  video.src = hlsUrl;
  video.addEventListener('loadedmetadata', () => {
    video.play();
  });
}

8.4 FLV 直播实现 #

// flv.js 播放 FLV 直播流
import flvjs from 'flv.js';
 
const video = document.querySelector('video');
const flvUrl = 'https://cdn.example.com/live.flv';
 
if (flvjs.isSupported()) {
  const flvPlayer = flvjs.createPlayer({
    type: 'flv',
    url: flvUrl,
    isLive: true,        // 直播模式
    hasAudio: true,
    hasVideo: true,
    cors: true,
  }, {
    enableWorker: true,
    enableStashBuffer: false,  // 直播建议关闭
    stashInitialSize: 128,
    lazyLoad: false,           // 直播关闭懒加载
  });
  
  flvPlayer.attachMediaElement(video);
  flvPlayer.load();
  flvPlayer.play();
  
  // 错误处理
  flvPlayer.on(flvjs.Events.ERROR, (errType, errDetail) => {
    console.error('FLV 错误:', errType, errDetail);
    
    if (errType === flvjs.Events.NETWORK_ERROR) {
      // 网络错误,重新加载
      flvPlayer.unload();
      flvPlayer.load();
      flvPlayer.play();
    }
  });
  
  // 销毁
  // flvPlayer.destroy();
}

8.5 WebRTC 超低延迟直播 #

// WebRTC 客户端实现
class WebRTCPlayer {
  constructor(videoElement) {
    this.video = videoElement;
    this.pc = null;
  }
  
  async connect(signalUrl) {
    // 创建 RTCPeerConnection
    this.pc = new RTCPeerConnection({
      iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
    });
    
    // 接收远程流
    this.pc.ontrack = (event) => {
      this.video.srcObject = event.streams[0];
    };
    
    // 创建并发送 offer
    const offer = await this.pc.createOffer({
      offerToReceiveAudio: true,
      offerToReceiveVideo: true,
    });
    
    await this.pc.setLocalDescription(offer);
    
    // 发送 offer 到信令服务器
    const response = await fetch(signalUrl, {
      method: 'POST',
      body: JSON.stringify({ sdp: offer.sdp }),
      headers: { 'Content-Type': 'application/json' },
    });
    
    const answer = await response.json();
    
    // 设置远程 answer
    await this.pc.setRemoteDescription(
      new RTCSessionDescription({ type: 'answer', sdp: answer.sdp })
    );
  }
  
  disconnect() {
    if (this.pc) {
      this.pc.close();
      this.pc = null;
    }
  }
}
 
// 使用
const player = new WebRTCPlayer(document.querySelector('video'));
await player.connect('https://signal.example.com/offer');

8.6 直播延迟优化 #

延迟级别 协议 延迟时间 优化手段
高延迟 HLS 10-30s CDN 优化、减少分片时长
中延迟 HLS LL 2-5s 低延迟 HLS
低延迟 FLV 1-3s 减小缓冲
超低延迟 WebRTC 0.2-1s UDP 传输
// HLS 低延迟配置
const hls = new Hls({
  lowLatencyMode: true,
  liveBackBufferLength: 0,
  liveDurationInfinity: true,
  maxBufferLength: 5,        // 最大缓冲 5 秒
  maxMaxBufferLength: 10,
  maxBufferSize: 10 * 1000 * 1000,  // 10MB
  maxBufferHole: 0.5,
});
 
// FLV 低延迟配置
const flvPlayer = flvjs.createPlayer({
  type: 'flv',
  isLive: true,
}, {
  enableStashBuffer: false,
  stashInitialSize: 128,
  lazyLoad: false,
  seekType: 'range',
});

九、视频网站性能优化 #

9.1 视频预加载策略 #

// 预加载视频元数据
function preloadVideoMetadata(url) {
  return new Promise((resolve, reject) => {
    const video = document.createElement('video');
    video.preload = 'metadata';
    
    video.onloadedmetadata = () => {
      const info = {
        duration: video.duration,
        width: video.videoWidth,
        height: video.videoHeight,
      };
      video.src = '';  // 清除,释放资源
      resolve(info);
    };
    
    video.onerror = reject;
    video.src = url;
  });
}
 
// 预加载视频首帧
function preloadFirstFrame(url) {
  return new Promise((resolve) => {
    const video = document.createElement('video');
    video.preload = 'auto';
    video.muted = true;
    
    video.onloadeddata = () => {
      video.pause();
      resolve(video);
    };
    
    video.src = url;
    video.play();
  });
}

9.2 自适应码率(ABR) #

// 根据网速自动切换清晰度
class AdaptiveBitrate {
  constructor(player, sources) {
    this.player = player;
    this.sources = sources;  // { '1080p': url, '720p': url, '480p': url }
    this.currentQuality = '720p';
    this.lastSpeedCheck = Date.now();
    this.bytesLoaded = 0;
  }
  
  // 监听下载速度
  onProgress(loaded) {
    const now = Date.now();
    const elapsed = (now - this.lastSpeedCheck) / 1000;
    
    if (elapsed >= 5) {  // 每 5 秒检测一次
      const speed = (loaded - this.bytesLoaded) / elapsed;  // bytes/s
      this.bytesLoaded = loaded;
      this.lastSpeedCheck = now;
      
      this.adjustQuality(speed);
    }
  }
  
  // 调整清晰度
  adjustQuality(speed) {
    // 根据速度选择清晰度
    // 1080p 需要 > 5MB/s
    // 720p 需要 > 2MB/s
    // 480p 需要 > 1MB/s
    
    const mbps = speed / 1024 / 1024;
    let newQuality = '480p';
    
    if (mbps > 5) newQuality = '1080p';
    else if (mbps > 2) newQuality = '720p';
    
    if (newQuality !== this.currentQuality) {
      this.switchQuality(newQuality);
    }
  }
  
  // 切换清晰度(无缝切换)
  switchQuality(quality) {
    const currentTime = this.player.currentTime;
    const paused = this.player.paused;
    
    this.player.src = this.sources[quality];
    this.player.currentTime = currentTime;
    
    if (!paused) {
      this.player.play();
    }
    
    this.currentQuality = quality;
    console.log(`切换到 ${quality}`);
  }
}

9.3 视频缩略图预览 #

// 生成视频缩略图精灵图
async function generateThumbnails(videoUrl, count = 10) {
  const video = document.createElement('video');
  video.src = videoUrl;
  video.muted = true;
  
  await new Promise(resolve => {
    video.onloadedmetadata = resolve;
  });
  
  const duration = video.duration;
  const interval = duration / count;
  const thumbnails = [];
  
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');
  canvas.width = 160;
  canvas.height = 90;
  
  for (let i = 0; i < count; i++) {
    video.currentTime = i * interval;
    
    await new Promise(resolve => {
      video.onseeked = resolve;
    });
    
    ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
    thumbnails.push({
      time: video.currentTime,
      url: canvas.toDataURL('image/jpeg', 0.7),
    });
  }
  
  return thumbnails;
}
 
// 生成精灵图(所有缩略图合成一张图)
async function generateSprite(videoUrl, options = {}) {
  const {
    cols = 5,
    rows = 5,
    width = 160,
    height = 90,
  } = options;
  
  const video = document.createElement('video');
  video.src = videoUrl;
  video.muted = true;
  
  await new Promise(resolve => {
    video.onloadedmetadata = resolve;
  });
  
  const duration = video.duration;
  const total = cols * rows;
  const interval = duration / total;
  
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');
  canvas.width = width * cols;
  canvas.height = height * rows;
  
  for (let i = 0; i < total; i++) {
    video.currentTime = i * interval;
    
    await new Promise(resolve => {
      video.onseeked = resolve;
    });
    
    const x = (i % cols) * width;
    const y = Math.floor(i / cols) * height;
    
    ctx.drawImage(video, x, y, width, height);
  }
  
  return canvas.toDataURL('image/jpeg', 0.8);
}

9.4 视频缓存策略 #

// 使用 Service Worker 缓存视频
// sw.js
self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);
  
  // 视频请求使用 Range 缓存
  if (url.pathname.endsWith('.mp4') || url.pathname.endsWith('.webm')) {
    event.respondWith(
      caches.open('video-cache').then((cache) => {
        return cache.match(event.request).then((response) => {
          if (response) {
            return response;
          }
          
          return fetch(event.request).then((response) => {
            // 只缓存完整响应(非 Range 请求)
            if (!event.request.headers.has('Range')) {
              cache.put(event.request, response.clone());
            }
            return response;
          });
        });
      })
    );
  }
});
 
// 使用 IndexedDB 缓存视频片段
async function cacheVideoSegment(url, blob) {
  const db = await openDB('video-cache', 1, {
    upgrade(db) {
      db.createObjectStore('segments');
    },
  });
  
  await db.put('segments', blob, url);
}
 
async function getCachedSegment(url) {
  const db = await openDB('video-cache', 1);
  return await db.get('segments', url);
}

9.5 内存优化 #

// 视频播放器内存管理
class VideoPlayerManager {
  constructor() {
    this.players = new Map();
    this.maxPlayers = 3;  // 最大同时播放数
  }
  
  createPlayer(id, container, options) {
    // 如果超过限制,销毁最旧的
    if (this.players.size >= this.maxPlayers) {
      const [oldestId] = this.players.keys();
      this.destroyPlayer(oldestId);
    }
    
    const player = new Player({
      el: container,
      ...options,
    });
    
    this.players.set(id, player);
    return player;
  }
  
  destroyPlayer(id) {
    const player = this.players.get(id);
    if (player) {
      player.destroy();
      this.players.delete(id);
    }
  }
  
  // 页面隐藏时暂停所有播放器
  pauseAll() {
    this.players.forEach(player => {
      if (!player.paused) {
        player.pause();
      }
    });
  }
}
 
// 页面可见性监听
document.addEventListener('visibilitychange', () => {
  if (document.hidden) {
    playerManager.pauseAll();
  }
});

十、实战场景 #

场景 1:图片网站完整实现 #

<!-- ImageGallery.vue - 图片画廊 -->
<template>
  <div class="image-gallery">
    <!-- 瀑布流布局 -->
    <div class="masonry" ref="masonryRef">
      <div
        v-for="(image, index) in images"
        :key="image.id"
        class="masonry-item"
        :style="{ height: `${image.displayHeight}px` }"
      >
        <LazyImage
          :src="image.url"
          :thumbnail="image.thumbnail"
          :alt="image.title"
          @click="openLightbox(index)"
        />
      </div>
    </div>
    
    <!-- 加载更多 -->
    <div ref="loaderRef" class="loader">
      <span v-if="loading">加载中...</span>
    </div>
    
    <!-- 图片预览弹窗 -->
    <ImageLightbox
      v-if="lightboxIndex !== null"
      :images="images"
      :index="lightboxIndex"
      @close="lightboxIndex = null"
      @prev="lightboxIndex--"
      @next="lightboxIndex++"
    />
  </div>
</template>
 
<script setup>
import { ref, onMounted, computed } from 'vue';
import LazyImage from './LazyImage.vue';
import ImageLightbox from './ImageLightbox.vue';
 
const images = ref([]);
const loading = ref(false);
const page = ref(1);
const loaderRef = ref(null);
const masonryRef = ref(null);
const lightboxIndex = ref(null);
 
// 瀑布流布局
const columnWidth = 300;
const gap = 16;
 
function layoutMasonry() {
  const container = masonryRef.value;
  if (!container) return;
  
  const containerWidth = container.offsetWidth;
  const columns = Math.floor(containerWidth / (columnWidth + gap));
  const heights = new Array(columns).fill(0);
  
  const items = container.querySelectorAll('.masonry-item');
  
  items.forEach((item, index) => {
    const col = heights.indexOf(Math.min(...heights));
    const x = col * (columnWidth + gap);
    const y = heights[col];
    
    item.style.transform = `translate(${x}px, ${y}px)`;
    item.style.width = `${columnWidth}px`;
    
    const image = images.value[index];
    if (image) {
      const aspectRatio = image.height / image.width;
      image.displayHeight = columnWidth * aspectRatio;
      heights[col] += image.displayHeight + gap;
    }
  });
  
  container.style.height = `${Math.max(...heights)}px`;
}
 
// 无限滚动
onMounted(() => {
  loadImages();
  
  const observer = new IntersectionObserver((entries) => {
    if (entries[0].isIntersecting && !loading.value) {
      loadImages();
    }
  });
  
  observer.observe(loaderRef.value);
  
  window.addEventListener('resize', layoutMasonry);
});
 
async function loadImages() {
  loading.value = true;
  
  try {
    const newImages = await fetch(`/api/images?page=${page.value++}`);
    images.value.push(...newImages);
    
    // 等待 DOM 更新后布局
    setTimeout(layoutMasonry, 0);
  } finally {
    loading.value = false;
  }
}
 
function openLightbox(index) {
  lightboxIndex.value = index;
}
</script>
 
<style scoped>
.masonry {
  position: relative;
}
 
.masonry-item {
  position: absolute;
  transition: transform 0.3s;
}
 
.loader {
  text-align: center;
  padding: 20px;
}
</style>

场景 2:视频点播网站 #

<!-- VideoPlayer.vue -->
<template>
  <div class="video-page">
    <!-- 播放器 -->
    <div class="player-container">
      <XgPlayer
        ref="playerRef"
        :url="currentSource.url"
        :poster="video.poster"
        @ready="onPlayerReady"
        @ended="onVideoEnded"
      />
      
      <!-- 清晰度选择 -->
      <div class="quality-selector">
        <button
          v-for="(source, quality) in sources"
          :key="quality"
          :class="{ active: currentQuality === quality }"
          @click="changeQuality(quality)"
        >
          {{ quality }}
        </button>
      </div>
    </div>
    
    <!-- 视频信息 -->
    <div class="video-info">
      <h1>{{ video.title }}</h1>
      <p>{{ video.description }}</p>
    </div>
    
    <!-- 推荐列表 -->
    <div class="recommend-list">
      <VideoCard
        v-for="item in recommendations"
        :key="item.id"
        :video="item"
      />
    </div>
  </div>
</template>
 
<script setup>
import { ref, computed, onMounted } from 'vue';
import XgPlayer from './XgPlayer.vue';
import VideoCard from './VideoCard.vue';
 
const props = defineProps({
  videoId: String,
});
 
const video = ref({});
const sources = ref({});
const currentQuality = ref('720p');
const playerRef = ref(null);
 
const currentSource = computed(() => {
  return sources.value[currentQuality.value] || {};
});
 
onMounted(async () => {
  // 加载视频信息
  const data = await fetch(`/api/videos/${props.videoId}`);
  video.value = data.video;
  sources.value = data.sources;
  
  // 加载推荐
  loadRecommendations();
});
 
function changeQuality(quality) {
  const currentTime = playerRef.value?.currentTime || 0;
  currentQuality.value = quality;
  
  // 恢复播放进度
  setTimeout(() => {
    playerRef.value?.seek(currentTime);
  }, 100);
}
 
function onPlayerReady(player) {
  // 预加载下一集
  preloadNextEpisode();
}
 
function onVideoEnded() {
  // 自动播放下一集
  playNextEpisode();
}
 
async function preloadNextEpisode() {
  const nextId = video.value.nextEpisodeId;
  if (nextId) {
    // 预加载视频元数据
    const video = document.createElement('video');
    video.preload = 'metadata';
    video.src = `/api/videos/${nextId}/preview`;
  }
}
</script>

场景 3:直播平台 #

<!-- LiveStream.vue -->
<template>
  <div class="live-page">
    <!-- 直播播放器 -->
    <div class="live-player">
      <XgPlayer
        ref="playerRef"
        :url="streamUrl"
        :is-live="true"
        @error="onStreamError"
      />
      
      <!-- 直播状态 -->
      <div class="live-status">
        <span class="live-badge">LIVE</span>
        <span class="viewer-count">{{ viewerCount }} 观看</span>
      </div>
      
      <!-- 延迟显示 -->
      <div class="latency-info">
        延迟: {{ latency }}s
      </div>
    </div>
    
    <!-- 聊天室 -->
    <div class="chat-room">
      <div class="messages" ref="messagesRef">
        <div
          v-for="msg in messages"
          :key="msg.id"
          class="message"
        >
          <span class="username">{{ msg.username }}:</span>
          <span class="content">{{ msg.content }}</span>
        </div>
      </div>
      
      <input
        v-model="newMessage"
        @keyup.enter="sendMessage"
        placeholder="发送弹幕..."
      />
    </div>
  </div>
</template>
 
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import XgPlayer from './XgPlayer.vue';
 
const props = defineProps({
  roomId: String,
});
 
const playerRef = ref(null);
const streamUrl = ref('');
const viewerCount = ref(0);
const latency = ref(0);
const messages = ref([]);
const newMessage = ref('');
const messagesRef = ref(null);
 
let ws = null;
 
onMounted(async () => {
  // 获取直播流地址
  const { url, viewers } = await fetch(`/api/live/${props.roomId}`);
  streamUrl.value = url;
  viewerCount.value = viewers;
  
  // 连接聊天 WebSocket
  connectChat();
  
  // 监控延迟
  monitorLatency();
});
 
onUnmounted(() => {
  ws?.close();
});
 
function connectChat() {
  ws = new WebSocket(`wss://chat.example.com/room/${props.roomId}`);
  
  ws.onmessage = (event) => {
    const data = JSON.parse(event.data);
    
    if (data.type === 'message') {
      messages.value.push(data.message);
      
      // 滚动到底部
      setTimeout(() => {
        messagesRef.value.scrollTop = messagesRef.value.scrollHeight;
      }, 0);
    } else if (data.type === 'viewers') {
      viewerCount.value = data.count;
    }
  };
}
 
function sendMessage() {
  if (!newMessage.value.trim()) return;
  
  ws.send(JSON.stringify({
    type: 'message',
    content: newMessage.value,
  }));
  
  newMessage.value = '';
}
 
function monitorLatency() {
  setInterval(() => {
    if (playerRef.value) {
      // 计算延迟(直播时间 - 当前播放时间)
      const liveTime = Date.now() / 1000;
      const playTime = playerRef.value.currentTime;
      latency.value = Math.round(liveTime - playTime);
    }
  }, 1000);
}
 
function onStreamError(err) {
  console.error('直播流错误:', err);
  
  // 自动重连
  setTimeout(() => {
    playerRef.value?.reload();
  }, 3000);
}
</script>

十一、常见问题 #

Q1:为什么视频自动播放失败? #

原因: 浏览器策略限制,自动播放必须静音。

<!-- ❌ 不会自动播放 -->
<video src="video.mp4" autoplay></video>
 
<!-- ✅ 静音后可以自动播放 -->
<video src="video.mp4" autoplay muted></video>

解决方案:

// 尝试播放,失败后提示用户
async function tryAutoplay(video) {
  try {
    await video.play();
  } catch (err) {
    // 自动播放失败,显示播放按钮
    showPlayButton();
  }
}

Q2:视频在 iOS 上全屏播放怎么办? #

解决方案: 添加 playsinline 属性。

<video
  src="video.mp4"
  playsinline
  webkit-playsinline
></video>

Q3:如何实现视频无缝循环? #

// 方式 1:loop 属性(有短暂停顿)
<video src="video.mp4" loop></video>
 
// 方式 2:双视频无缝循环
const video1 = document.querySelector('#video1');
const video2 = document.querySelector('#video2');
 
video1.onended = () => {
  video2.play();
  video1.style.display = 'none';
  video2.style.display = 'block';
};
 
video2.onended = () => {
  video1.play();
  video2.style.display = 'none';
  video1.style.display = 'block';
};

Q4:如何获取视频的第一帧作为封面? #

async function getVideoPoster(videoUrl) {
  const video = document.createElement('video');
  video.src = videoUrl;
  video.muted = true;
  
  await new Promise(resolve => {
    video.onloadeddata = resolve;
  });
  
  const canvas = document.createElement('canvas');
  canvas.width = video.videoWidth;
  canvas.height = video.videoHeight;
  
  const ctx = canvas.getContext('2d');
  ctx.drawImage(video, 0, 0);
  
  return canvas.toDataURL('image/jpeg');
}
 
// 使用
const poster = await getVideoPoster('video.mp4');

Q5:HLS 直播卡顿怎么办? #

常见原因和解决方案:

原因 解决方案
网络问题 降低清晰度、增加缓冲
CDN 问题 切换 CDN 节点
分片过大 减小分片时长(2-5 秒)
解码性能差 使用硬件加速
// 优化 HLS 配置
const hls = new Hls({
  maxBufferLength: 30,          // 增加缓冲
  maxMaxBufferLength: 60,
  maxBufferSize: 60 * 1000 * 1000,
  startLevel: -1,               // 自动选择清晰度
  capLevelToPlayerSize: true,   // 根据播放器大小选择
});

Q6:如何在移动端优化视频体验? #

<!-- 移动端优化配置 -->
<video
  src="video.mp4"
  playsinline           <!-- iOS 内联播放 -->
  webkit-playsinline    <!-- iOS 兼容 -->
  x5-video-player-type="h5"        <!-- 微信 X5 内核 -->
  x5-video-player-fullscreen="true"
  preload="metadata"    <!-- 只预加载元数据 -->
  poster="cover.jpg"    <!-- 封面图 -->
></video>
/* 移动端视频样式 */
video {
  width: 100%;
  height: auto;
  object-fit: contain;
  background: #000;
}
 
/* 避免视频被挤压 */
.video-container {
  position: relative;
  width: 100%;
  padding-top: 56.25%;  /* 16:9 */
}
 
.video-container video {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
}

Q7:图片加载失败如何处理? #

<!-- Vue3 图片错误处理 -->
<template>
  <img
    :src="imageSrc"
    @error="onImageError"
    :alt="alt"
  />
</template>
 
<script setup>
import { ref } from 'vue';
 
const props = defineProps({
  src: String,
  alt: String,
  fallback: { type: String, default: '/images/placeholder.jpg' },
});
 
const imageSrc = ref(props.src);
 
function onImageError() {
  imageSrc.value = props.fallback;
}
</script>

十二、总结速记 #

图片格式选择 #

需求 推荐格式
照片 WebP > JPEG
透明图 WebP > PNG
图标/Logo SVG
动画 WebP 动画 > GIF(短视频用 MP4)
极致压缩 AVIF

视频编码选择 #

场景 推荐编码
最大兼容 H.264 (MP4)
追求体积 H.265 或 VP9
极致压缩 AV1
直播 H.264

直播协议选择 #

延迟要求 推荐协议
10-30s 可接受 HLS
1-5s FLV / HTTP-FLV
< 1s WebRTC

懒加载方式 #

方式 兼容性 推荐场景
loading="lazy" 现代浏览器 简单网站
IntersectionObserver 全浏览器 Vue/React 组件

播放器选型 #

播放器 适用场景
原生 <video> 简单播放
Video.js 通用视频网站
西瓜播放器 国内项目、直播
DPlayer 弹幕视频

CDN 图片处理 #

// 通用格式
`${url}?w=${width}&q=${quality}&f=${format}`;
 
// 阿里云 OSS
`${url}?x-oss-process=image/resize,w_${width}/quality,q_${quality}/format,${format}`;

视频性能优化 #

策略 方法
预加载 preload="metadata"
懒加载 进入视口再初始化播放器
自适应码率 根据网速切换清晰度
内存管理 页面隐藏时暂停、限制播放器数量
缓存 Service Worker / IndexedDB

附录:工具推荐 #

图片工具 #

工具 用途 链接
TinyPNG 在线压缩 https://tinypng.com
Squoosh Google 压缩工具 https://squoosh.app
sharp Node.js 图片处理 https://sharp.pixelplumbing.com

视频工具 #

工具 用途 链接
FFmpeg 命令行转码 https://ffmpeg.org
HandBrake GUI 转码 https://handbrake.fr
MediaInfo 视频信息查看 https://mediaarea.net

测试工具 #

工具 用途
caniuse.com 浏览器兼容性查询
Cloudflare Stream 视频托管 + 自动转码
Mux 视频分析 + 优化建议

最后更新:2026-03-29