做纯 HTML 视频网站这件事,本身听起来像是一个“上古时期”的玩法。现在做视频站,动辄就是 Vue/React 框架、前后端分离、云点播、防盗链签名,一套流程下来没个把月搞不定。但如果你只是想快速搭一个能放视频、能搜索、能筛选、能播放的网页,老老实实用 HTML + CSS + 原生 JavaScript 就能解决全部需求,而且源码逻辑一眼通透,改起来毫不费力。
这个项目很适合三类人:第一类,刚学完前端基础想找个完整小项目练手的同学,你能从这里看到 HTML 结构、CSS 布局、JavaScript 事件处理是怎么在一个真实场景里协同工作的;第二类,做个人作品集、小团队内部工具,想快速弄一个视频展示页面的人,不需要懂后端,不需要配服务器,双击 HTML 文件就能看效果;第三类,单纯想看一份整洁源码、想本地存一个视频网站模板备用的人。
我在这个项目里最看重的一点是:没有构建工具、没有框架依赖、连 node_modules 都不需要存在,整个项目就是几个文件摆在那,源码摊开一眼能看完。这年头能在一分钟之内从零跑起来的“网站”项目真的不多了,这个算一个。
1. 先想清楚:纯 HTML 视频网站到底是个什么东西
1.1 为什么值得用纯 HTML 做视频网站
先说个容易混淆的概念。标题说的“纯 HTML”,在行内一般指的是 纯前端实现,也就是页面只有 .html、.css、.js 文件,不需要 PHP、Node.js、Java 这类后端语言参与动态渲染。视频文件本身肯定没法用 HTML 凭空造出来,但视频的列表展示、分类筛选、播放器控件、搜索过滤这些“网站功能”,全部可以在浏览器里完成。
这个方案最大的优势有三点。
一是部署成本几乎为零。你把 HTML 文件拖到浏览器里就能打开,甚至不需要“部署”这个概念。想上线的话,扔到任意一个静态托管平台,比如 GitHub Pages、Cloudflare Pages 或者对象存储配静态网站托管,整个过程五分钟搞定,不需要买服务器,也不用配置 Nginx。
二是源码决策成本低。很多框架项目你看完一个组件文件,还得再看路由配置、状态管理、打包配置才能搞懂整个项目在做什么。HTML 项目没有中间层,打开一个 HTML 文件,结构、样式、逻辑都在里面,对于学习者和二次开发者都非常友好。
三是运行稳定。不依赖第三方服务,没有后端接口挂掉的问题,不会因为某个 npm 包升级导致整个项目跑不起来。只要浏览器的 HTML 标准不倒,这个页面十年后打开还是这个效果。
1.2 这个项目适合谁、能解决什么问题
我最推荐的用法是把它当成一个视频索引工具。比如你手上有几十个教学视频、产品演示视频,散落在不同文件夹或 CDN 上,每次要看都得翻目录、找文件,很痛苦。用这个网页,把所有视频地址维护在一个清单里,打开页面就是卡片墙,想看哪个点哪个,体验瞬间就体面了。
对前端初学者来说,这个项目是教科书级别的练习样本。它涵盖了:
- HTML5 语义化标签的使用:
header、main、section、dialog这些都有实际场景; - CSS 布局的经典操作:Flexbox 做导航栏,Grid 做视频卡片网格,响应式断点适配不同屏幕;
- JavaScript 的 DOM 操作、事件委托、数组方法
filter、数据与视图的同步更新; video元素的核心属性、事件与浏览器兼容性处理。
这些知识点单独看都零碎,但在一个完整项目里串起来,你才能理解它们各自解决什么问题。
1.3 项目最终的效果预览
动手之前,先描述一下成品长什么样,方便你对答案:
页面顶部是一条导航栏,左边是站点名称(比如“我的视频库”),右边是搜索框,输入关键词即时过滤卡片。导航栏下方是几个分类筛选按钮:全部、前端、后端、工具、生活,点击高亮当前分类。页面主体是响应式卡片网格,每张卡片是一段封面图(poster)、视频标题、时长和分类标签。点击卡片,页面中央弹出一个居中播放层,黑底、带关闭按钮,视频自适应居中播放。手机端访问时,网格从三列变成两列再变成一列,播放层占满全屏。
我实际做的时候,整个代码量大约 220 行左右。听起来不多,但这个体量刚好能让你一眼看穿所有逻辑,又比那种几十行的玩具示例有真实感。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心技术拆解:video 标签和页面骨架
2.1 video 标签那些必须搞懂的属性
做视频网站,最核心的 HTML 元素就是 <video>。这个标签从 HTML5 时代开始就是浏览器原生支持的视频播放方案,不需要装 Flash,不需要引第三方播放器库。
一个最基础的播放器写法长这样:
html复制<video src="movie.mp4" controls></video>
controls 属性会显示浏览器自带的控制条,包含播放/暂停按钮、进度条、音量、全屏等。但在实际项目里,你几乎不可能只用这种默认形式,因为默认控件在不同浏览器里长得不一样,而且可定制性差。更常见的做法是保留原生控件做兜底,同时用自定义按钮控制播放行为。
我在这个项目里用到这几个关键属性:
poster:视频加载前的封面图地址,这张图直接决定视频卡片好不好看,必须有;preload="metadata":只预加载视频的元数据(时长、尺寸、首帧),而不是整个视频文件。这个属性对列表页性能至关重要,否则页面一加载就开始下载几十个视频文件,流量直接爆炸;playsinline和muted:这两个通常配合移动端使用。iOS Safari 在默认情况下打开视频会自动切入全屏播放,加上playsinline才能在页面内嵌播放;muted用于静音自动播放,后面会展开讲;controls:在弹窗播放层里我会保留它,因为用户需要进度拖动、音量调节、全屏切换这些能力,没必要全部自己造轮子。
一个典型的使用场景是:
html复制<video
src="https://example.com/videos/1.mp4"
poster="https://example.com/covers/1.jpg"
preload="metadata"
playsinline
controls
></video>
2.2 视频格式选型:MP4 还是 WebM
如果你是自己准备视频素材,这一步逃不开。浏览器对视频格式的支持并不完全统一,但有一个比较稳妥的组合。
目前事实上的标准答案是:MP4 容器 + H.264 视频编码 + AAC 音频编码。这个组合在 Chrome、Firefox、Safari、Edge 以及所有主流手机上都是通吃的。你从剪辑软件里导出视频时,选 H.264 编码、.mp4 后缀,基本不会出问题。
WebM 则更“现代”,VP9/AV1 编码,压缩率更高、画质更好,尤其适合追求极致体积的 Web 项目。但它有一个致命伤:Safari 很长时间都不原生支持 WebM 播放(现在部分版本通过系统解码器解决了),面向大众用户的站点不能只放 WebM 格式。
所以我的建议是:主视频一律用 H.264 的 MP4。如果你确实想兼容多格式,可以在 video 标签里写多个 source,浏览器会按顺序尝试:
html复制<video controls poster="cover.jpg">
<source src="movie.webm" type="video/webm">
<source src="movie.mp4" type="video/mp4">
</video>
这样 Chrome 优先选 WebM,Safari 自动跳过 WebM 去加载 MP4,各得其所。但要注意,source 是“全部加载完成后才统一尝试”,不是一边加载一边决策,所以首次打开会有一定延迟。
2.3 页面布局:CSS Grid 是这张牌的关键
视频卡片的网格布局,我强烈推荐用 CSS Grid,而不是 Flexbox 或者 Float。原因很简单:Grid 天生就是做二维布局,一行放几列、列宽怎么伸缩、间距怎么控制,都是声明式的,代码量少、可读性好。
我实际用的核心布局代码是这样:
css复制.video-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 24px;
}
就这一行 grid-template-columns,响应式网格就完成了。auto-fill 告诉浏览器尽可能多地生成列,minmax(260px, 1fr) 规定每列最小 260px、最大占满剩余空间。浏览器会自动根据容器宽度决定一行放 2 列、3 列还是 4 列,完全不需要写媒体查询。
同时导航栏和页面底部我用了 Flexbox,因为这是典型的一维排列场景:从左到右排一排,两端对齐,逻辑清楚。实践里你会慢慢形成一种直觉:一维排列用 Flex,二维网格用 Grid,不要混着硬套。
2.4 播放器的交互逻辑:点击、弹窗和关闭
视频播放的交互看似简单,实际有几个容易被忽略的点。
点击卡片打开播放层。我不用 alert 或者新开窗口,而是在页面里预置一个隐藏的遮罩层和播放器容器。点击卡片时,读取卡片上记录的 data-src 和 data-poster,动态设置到 video 标签上,再给遮罩层加一个显示类。逻辑是:
javascript复制function openPlayer(src, poster) {
playerVideo.src = src;
playerVideo.poster = poster;
overlay.classList.add('active');
playerVideo.play();
}
这里有个细节:打开播放器后不要立刻调用 play()。因为视频资源是刚设置上去的,浏览器需要先缓冲一点数据才能开始播。更稳的做法是监听 loadedmetadata 事件后就绪后再播放,或者干脆把 play() 放在一个 setTimeout 里给浏览器一点准备时间。我在代码里用的是 loadedmetadata 一次性监听,实测最可靠。
关闭播放层时要停掉视频。很多人第一次写会忘记:只隐藏遮罩层,不暂停视频。结果就是用户关了弹窗,后台还在播放,甚至只有声音没有画面,非常尴尬。正确做法是在关闭函数里调用 playerVideo.pause() 并且清空 src:
javascript复制function closePlayer() {
playerVideo.pause();
playerVideo.removeAttribute('src');
playerVideo.load();
overlay.classList.remove('active');
}
加上 load() 是为了彻底释放浏览器对视频资源的引用,也避免下次打开时出现奇怪的缓存问题。
3. 从零手写:完整源码与实现步骤
3.1 项目目录结构
这个项目不需要太复杂的目录,我实际用的是:
code复制video-site/
├── index.html
├── style.css
└── script.js
如果你喜欢把所有代码塞在一个文件里,那也没问题。但对于发布源码的场景,我推荐拆成三个文件,各司其职,别人看着也清楚。视频文件和封面图放在外部或者直接用 CDN 链接,不会混进项目目录里。
3.2 页面骨架:HTML 部分
先看 index.html 的完整结构。这里我特意用了语义化标签,方便阅读和维护。
html复制<!DOCTYPE html>
<html lang="zh-cn">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>我的视频库</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header class="site-header">
<div class="logo">▶ 我的视频库</div>
<input type="search" id="searchInput" placeholder="搜索视频标题或分类...">
</header>
<nav class="category-nav">
<button class="cat-btn active" data-category="全部">全部</button>
<button class="cat-btn" data-category="前端">前端</button>
<button class="cat-btn" data-category="后端">后端</button>
<button class="cat-btn" data-category="工具">工具</button>
<button class="cat-btn" data-category="生活">生活</button>
</nav>
<main class="video-grid" id="videoGrid">
<!-- 动态生成视频卡片 -->
</main>
<div class="overlay" id="overlay">
<div class="player-wrapper">
<button class="close-btn" id="closeBtn">×</button>
<video id="playerVideo" controls playsinline></video>
</div>
</div>
<script src="script.js"></script>
</body>
</html>
注意几个细节:
<input type="search"> 带有原生清除按钮,移动端键盘也会显示“搜索”键,比 type="text" 更贴合场景。分类按钮的 data-category 属性是我用来记录分类标识的,JavaScript 通过它来过滤数据。
播放层我用的是普通 div 遮罩加 .active 类控制显隐,没有用 dialog 标签。虽然 dialog 语义上更准确,但它在移动端的样式兼容性不如 div 灵活,而且控制显隐需要额外处理 ::backdrop。在这种小项目里,div 加类控制是最稳的。
3.3 视觉表现:CSS 部分
style.css 里主要的样式分四块:基础重置、导航栏、视频卡片、播放层。核心代码我完整贴出来:
css复制* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif;
background: #0f1115;
color: #f0f0f0;
min-height: 100vh;
}
.site-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 32px;
background: #1a1c23;
border-bottom: 1px solid #2a2d37;
position: sticky;
top: 0;
z-index: 10;
}
.logo {
font-size: 20px;
font-weight: 700;
}
#searchInput {
width: 260px;
padding: 8px 14px;
border-radius: 20px;
border: 1px solid #333;
background: #0f1115;
color: #fff;
outline: none;
}
.category-nav {
display: flex;
gap: 10px;
padding: 16px 32px;
flex-wrap: wrap;
}
.cat-btn {
padding: 6px 18px;
border-radius: 20px;
border: 1px solid #333;
background: transparent;
color: #aaa;
cursor: pointer;
font-size: 14px;
transition: all 0.2s ease;
}
.cat-btn:hover {
color: #fff;
border-color: #666;
}
.cat-btn.active {
background: #3b82f6;
border-color: #3b82f6;
color: #fff;
}
.video-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 24px;
padding: 24px 32px;
}
.video-card {
background: #1a1c23;
border-radius: 12px;
overflow: hidden;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
border: 1px solid #2a2d37;
}
.video-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.video-card img {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
display: block;
}
.video-card-info {
padding: 12px 16px;
}
.video-card-info h3 {
font-size: 15px;
margin-bottom: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.video-card-meta {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #888;
}
.overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.92);
z-index: 999;
align-items: center;
justify-content: center;
}
.overlay.active {
display: flex;
}
.player-wrapper {
position: relative;
width: min(960px, 92vw);
}
.player-wrapper video {
width: 100%;
aspect-ratio: 16 / 9;
background: #000;
border-radius: 8px;
}
.close-btn {
position: absolute;
top: -44px;
right: 0;
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: #333;
color: #fff;
font-size: 18px;
cursor: pointer;
z-index: 1;
}
@media (max-width: 640px) {
.site-header {
flex-direction: column;
gap: 12px;
padding: 16px;
}
#searchInput {
width: 100%;
}
.video-grid {
padding: 16px;
grid-template-columns: 1fr;
}
}
有几个地方值得重点说。
卡片悬停的“上浮 + 阴影”效果是我比较喜欢的一种做法,只改 transform 和 box-shadow,不触发重排,性能开销小。aspect-ratio: 16 / 9 让封面图即使在图片还未加载时也占住位置,防止页面加载过程出现高度跳动。
object-fit: cover 这个属性很容易被忽视。如果封面图的长宽比不是标准的 16:9,没有它图片就会被拉伸变形,加上它之后图片会等比裁剪居中显示,视觉上整齐得多。
播放层我使用 position: fixed; inset: 0,这个写法比传统的 top/left/width/height 四件套简洁得多。.overlay.active 控制 display: flex,让遮罩层里的内容水平和垂直居中。
3.4 交互逻辑:JavaScript 部分
script.js 是整个项目的灵魂。我把视频数据集中管理,然后通过渲染函数生成页面。这种“数据驱动视图”的模式虽然简单,但它是所有前端框架的雏形,值得好好学习。
javascript复制const videos = [
{
title: "HTML 基础入门",
category: "前端",
duration: "12:30",
poster: "https://picsum.photos/seed/html/640/360",
src: "https://example.com/videos/html-basics.mp4"
},
{
title: "CSS Flexbox 实战",
category: "前端",
duration: "18:20",
poster: "https://picsum.photos/seed/css/640/360",
src: "https://example.com/videos/css-flexbox.mp4"
},
{
title: "Node.js 快速上手",
category: "后端",
duration: "25:10",
poster: "https://picsum.photos/seed/node/640/360",
src: "https://example.com/videos/node-start.mp4"
},
{
title: "Git 常用命令速查",
category: "工具",
duration: "08:45",
poster: "https://picsum.photos/seed/git/640/360",
src: "https://example.com/videos/git-quick.mp4"
},
{
title: "Vim 配置美化",
category: "工具",
duration: "22:15",
poster: "https://picsum.photos/seed/vim/640/360",
src: "https://example.com/videos/vim-config.mp4"
},
{
title: "宅家咖啡拉花记录",
category: "生活",
duration: "05:50",
poster: "https://picsum.photos/seed/coffee/640/360",
src: "https://example.com/videos/coffee-latte.mp4"
}
];
const grid = document.getElementById('videoGrid');
const searchInput = document.getElementById('searchInput');
const overlay = document.getElementById('overlay');
const playerVideo = document.getElementById('playerVideo');
const closeBtn = document.getElementById('closeBtn');
let currentCategory = '全部';
let currentKeyword = '';
function render() {
const filtered = videos.filter(video => {
const matchCategory = currentCategory === '全部' || video.category === currentCategory;
const keyword = currentKeyword.trim().toLowerCase();
const matchKeyword = !keyword ||
video.title.toLowerCase().includes(keyword) ||
video.category.toLowerCase().includes(keyword);
return matchCategory && matchKeyword;
});
grid.innerHTML = filtered.map(video => `
<div class="video-card" data-src="${video.src}" data-poster="${video.poster}">
<img src="${video.poster}" alt="${video.title}" loading="lazy">
<div class="video-card-info">
<h3>${video.title}</h3>
<div class="video-card-meta">
<span>${video.category}</span>
<span>${video.duration}</span>
</div>
</div>
</div>
`).join('');
}
grid.addEventListener('click', event => {
const card = event.target.closest('.video-card');
if (!card) return;
const src = card.dataset.src;
const poster = card.dataset.poster;
playerVideo.src = src;
playerVideo.poster = poster;
overlay.classList.add('active');
playerVideo.addEventListener('loadedmetadata', function onLoaded() {
playerVideo.play();
playerVideo.removeEventListener('loadedmetadata', onLoaded);
});
});
function closePlayer() {
playerVideo.pause();
playerVideo.removeAttribute('src');
playerVideo.load();
overlay.classList.remove('active');
}
closeBtn.addEventListener('click', closePlayer);
overlay.addEventListener('click', event => {
if (event.target === overlay) {
closePlayer();
}
});
searchInput.addEventListener('input', event => {
currentKeyword = event.target.value;
render();
});
document.querySelector('.category-nav').addEventListener('click', event => {
const btn = event.target.closest('.cat-btn');
if (!btn) return;
document.querySelectorAll('.cat-btn').forEach(item => item.classList.remove('active'));
btn.classList.add('active');
currentCategory = btn.dataset.category;
render();
});
render();
这段代码里有一个非常值得说的设计:事件委托。我在页面加载时只对 grid 和 category-nav 绑定了点击事件,而不是对每个卡片、每个按钮单独绑定。因为卡片是动态渲染的,如果用 querySelectorAll 加 forEach 绑定事件,那每次筛选后重新渲染 DOM,原先的事件监听器全丢了,必须重新绑定。事件委托把监听器挂在父元素上,通过 event.target.closest('.video-card') 判断点击的是不是卡片,这样不管 DOM 怎么变,监听器永远有效。这个技巧在实际开发里使用率极高。
关于播放器加载的时序问题,我多说一句。当我设置 playerVideo.src 之后立刻调用 play(),在多数浏览器里会得到一个 Uncaught (in promise) 的报错,因为资源的元数据还没有加载完成,浏览器不知道视频的分辨率、时长、解码格式,没法进入可以播放的状态。等 loadedmetadata 事件触发后再播放,就能完美避开这个坑。
3.5 如何把视频换成你自己的
源码拿到手,第一步肯定要替换成自己的视频。这个项目的视频数据全部维护在 script.js 顶部的 videos 数组里,你把每个对象的 src 换成自己视频的地址、poster 换成自己的封面图即可。
一个小建议:尽量用完整的绝对地址(https://...)或者相对地址(./videos/1.mp4),不要用本地磁盘的绝对路径(C:\Users\...)。本地磁盘路径在别人电脑上打不开,而且浏览器出于安全策略,对本地文件的访问限制也很多。
如果视频文件放在项目目录下,比如 videos/ 文件夹里,那么 src: "videos/1.mp4" 这样的相对路径就够用。如果放在对象存储或者 CDN 上,直接用完整 URL 链接。封面图同理。
4. 踩坑实录:本地打开视频网站时最容易翻车的 5 个问题
4.1 本地双击 HTML 文件,视频明明没坏但放不出来
这是最经典的一个问题。你用 VS Code 或者其他编辑器打开项目,双击 index.html,页面里的封面图、布局都正常,但点播放就是转圈或者黑屏,控制台直接一串报错。
查一下浏览器 F12 控制台,多半是这一类:
code复制Access to video at 'file:///.../1.mp4' from origin 'null' has been blocked by CORS policy
这是浏览器的安全限制。当网页通过 file:// 协议打开时,浏览器会把它当成一个源(origin 是 null),而同源的视频文件却被当成跨域资源,直接被拦截。
解决这个问题有两个思路:
最简单的是起一个本地静态服务器。在项目根目录执行:
bash复制# 使用 Python 3 起的服务器
python -m http.server 8080
然后浏览器访问 http://localhost:8080。这样页面和视频文件走的是同一个 http:// 协议,也就没有跨域问题了。
如果你用的是 VS Code,装一个 Live Server 扩展,右键 index.html,选择 “Open with Live Server”,它会自动帮你起好本地服务器,还带热更新,开发体验好很多。
另一种思路是用外部在线视频地址。如果你的视频都放在线上(CDN 或对象存储),那么本地双击页面播放也没问题,因为协议是 http(s),没有本地文件跨域的限制。
4.2 视频文件本身没问题,但在浏览器里显示“不支持格式”
这种情况多发生在你随便找了个 .mkv、.flv 或者 .avi 格式的视频。这些格式虽然很常见,但浏览器原生不支持,只有装了特定解码器或者第三方插件才能播放。
我的建议是先用剪辑软件或者免费工具转码。FFmpeg 是命令行里的神器,一条命令就能把视频转成浏览器兼容的 H.264 MP4:
bash复制ffmpeg -i input.mkv -c:v libx264 -c:a aac -movflags +faststart output.mp4
加上 -movflags +faststart 很重要,它把视频文件的元数据信息从文件尾部挪到头部,浏览器在视频未完全下载时就能开始播放,显著提升加载体验。如果是大批量转换,写个 for 循环批量处理就行。
4.3 视频加载特别慢,即使本地文件也会卡
本地视频卡顿,大概率不是网络问题,而是视频编码等级太高或者码率太大。很多剪辑软件的默认导出参数是给电影级制作准备的,码率超高、分辨率 4K,浏览器软解码扛不住。
我的应对策略是:面向网页播放的视频,统一做一次压缩。分辨率不超过 1080p,视频码率控制在 5Mbps 左右,音频码率 128kbps 到 192kbps。用 FFmpeg 可以这样:
bash复制ffmpeg -i input.mp4 -c:v libx264 -crf 28 -preset slow -vf "scale=1920:-2" -c:a aac -b:a 128k -movflags +faststart output.mp4
-crf 28 是一个画质和体积的平衡点,数值越大画质越低、体积越小。24 到 28 之间比较常用。另外 scale=1920:-2 表示最长边设为 1920,高度按比例自适应,-2 保证高度是偶数(H.264 编码要求宽高必须为偶数)。
4.4 手机上打开页面,视频加载出来但布局乱
布局问题的根源通常是没设置 viewport 元标签。没有下面这一行,手机浏览器会默认用一个较宽的视口渲染页面,然后整体缩放,看起来就是字很小、布局失真:
html复制<meta name="viewport" content="width=device-width, initial-scale=1.0">
另外,移动端点击视频播放时,浏览器有时会自动横屏或者全屏弹出。你如果想要视频保持页面内嵌播放,需要在 video 标签加上 playsinline。iOS 的 Safari 对 playsinline 的支持通常还需要加 webkit-playsinline 版本,不过新版本 Safari 已经默认支持 playsinline 了,加一个 playsinline 就够。
4.5 设置了自动播放却不出声
浏览器为了用户体验,有一套自动播放策略:大多数浏览器允许静音自动播放,但带声音的自动播放会被拦截。如果你的需求是打开页面视频就自动播,请务必同时设置 muted 属性和 playsinline 属性:
html复制<video src="intro.mp4" autoplay muted playsinline></video>
如果用户需要声音,就用一个控制按钮手动打开。不是所有自动播放的拒绝都是错误,这是浏览器的合理保护机制,硬破会很麻烦,不如顺着它设计:默认静音,点击去声音。
4.6 问题排查速查表
| 现象 | 常见原因 | 首选排查方向 |
|---|---|---|
| 页面打不开 | 路径引用错误 / 编码问题 | 检查 html 文件路径,F12 看 Console 报错 |
| 视频黑屏 | 视频格式不支持 / 跨域拦截 | 换 H.264 MP4,用本地服务器访问 |
| 视频加载慢 | 码率过大 / 网络慢 | 压缩视频,开启 CDN |
| 点击播放没反应 | JS 语法错误 / 事件绑定问题 | 看 Console,检查 data-src 是否为空 |
| 手机端布局乱 | 缺少 viewport 标签 | 补全 meta viewport |
| 视频自动播放无声音 | 浏览器自动播放策略 | 加 muted 属性 |
5. 只靠前端还能玩出什么花活
基础版本做完,你已经有了一个能用的视频网站。接下来这些进阶方向不需要引入新技术栈,都是在现有代码上做加法,特别适合继续练习。
5.1 用 localStorage 记住用户的播放进度
这是最容易带来“质变感”的功能。用户看到一半关掉,下次打开还想从原来位置继续看,这个体验在原生播放器中不难实现。
核心思路:监听 video 元素的 timeupdate 事件,把当前播放位置写进 localStorage,关闭播放器时如果进度超过某个阈值,就在卡片的元信息区域显示“上次看到 xx:xx”的标记。下次打开播放器时读出保存的进度,调用 currentTime 属性跳到对应位置。
javascript复制playerVideo.addEventListener('timeupdate', () => {
localStorage.setItem(`video-progress-${currentVideoId}`, playerVideo.currentTime);
});
打开播放器时:
javascript复制const saved = localStorage.getItem(`video-progress-${currentVideoId}`);
if (saved && saved > 10) {
playerVideo.currentTime = saved;
}
5.2 把视频清单抽成 JSON 文件
现在视频数据直接写在 script.js 里,好处是简单直接,坏处是每次加视频都要改 JS 文件。进阶一点的做法是把视频数据抽到 videos.json 里,页面加载时用 fetch 读取:
javascript复制fetch('videos.json')
.then(res => res.json())
.then(data => {
videos = data;
render();
});
注意:用 file:// 协议打开时 fetch 会被跨域拦截,所以这个玩法需要配合本地服务器使用。如果你已经用 Live Server 或者 python -m http.server 跑起来了,就没问题。数据源抽离之后,你甚至可以配合定时任务把 JSON 文件自动生成出来,添加视频就变成了往 JSON 里加一条记录,内容维护变得更轻松。
5.3 部署到线上:免费静态托管的几种选择
纯前端项目上线特别简单,你只需要一个能托管静态文件的地方。
我的推荐顺序是:GitHub Pages 最省事,适合项目源码本来就在 GitHub 上的情况;Cloudflare Pages 提供全球 CDN,国内访问速度相对好一些;对象存储(比如阿里云 OSS、腾讯云 COS)配静态网站托管,自由度最高,适合自己域名、有长期运营计划的情况。
以 GitHub Pages 为例,在仓库的 Settings -> Pages 里选择分支,构建部署完之后,你会得到一个类似 https://username.github.io/reponame/ 的地址,全世界都能访问。
5.4 还能加上的功能清单
顺着这个项目的思路,以下几个功能加成本极低但效果明显:
- 视频分类多选:把分类从单选改成多选,用 Set 记录当前选中的分类集合;
- 按时长排序:加一个下拉框,按视频时长升序或降序排列;
- 全屏播放按钮:直接在播放层上加一个按钮,调用
video.requestFullscreen(); - 播放列表:在当前分类里自动生成一个播放列表,播完一个自动播下一个;
- 键盘事件支持:按空格播放/暂停、按 Esc 关闭播放层、按左右方向键快进快退。
其中键盘事件支持是我强烈建议加的功能,代码量不到 20 行,但用起来体验感立刻提升一个档次:
javascript复制document.addEventListener('keydown', event => {
if (!overlay.classList.contains('active')) return;
if (event.code === 'Escape') {
closePlayer();
} else if (event.code === 'Space') {
event.preventDefault();
if (playerVideo.paused) {
playerVideo.play();
} else {
playerVideo.pause();
}
} else if (event.code === 'ArrowRight') {
playerVideo.currentTime += 10;
} else if (event.code === 'ArrowLeft') {
playerVideo.currentTime -= 10;
}
});
最后再说点实在的
我见过不少人拿到这种小项目源码,看两眼觉得“这也太简单了”,然后关掉。但以我带新人的经验看,能把一个 200 行的纯前端项目从头到尾讲清楚、每个属性都能说得出为什么的人,其实少之又少。大多数人能说出 video 标签加 controls 能出播放器,但说不清楚 preload 三个值的区别、想不明白为什么点击后立刻 play() 会报错、不知道自动播放为什么要 muted。写代码是“从 0 到 1”,把每一行都问一个为什么,才是真正从 1 到 100 的过程。
这个视频网站模板的扩展空间非常大,往东走可以接实时数据、接弹幕、做推荐算法,往西走也是一个完全静态、无依赖、永远不过时的作品集页面。哪天你回头再看,可能会发现当初理解的“静态”只是表面——真正不依赖框架的东西,反而最经得起时间折腾。
