news 2026/10/1 11:49:55

HTML5 video事件详解:duration、timeupdate、ended的坑与实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
HTML5 video事件详解:duration、timeupdate、ended的坑与实战

先直接抛个结论:HTML5 的 video 标签,难点从来不是怎么把视频放上去,而是怎么处理和播放相关的各种状态和时间信息。你去看网上一堆教程,翻来覆去就那几个 API 名字:duration、currentTime、ended事件。但真到项目里一做,马上会遇到“duration 怎么是 NaN”、“timeupdate 到底多久触发一次”、“播完了怎么不触发 ended”这类问题。

我早期做视频播放页的时候,就因为这些折腾了不少时间。当时客户要求很简单:显示视频总时长、当前播放时间,播放结束后自动跳转下一个视频。我照着文档写,代码没几行,跑起来却全不对。后来把 video 的事件机制彻底捋了一遍,才算真正搞懂。这篇文章就把我从头到尾的实践、踩坑和最终方案全部分享出来,希望能帮你少走弯路。

1. 先从事件驱动说起:video标签一堆事件到底谁先谁后

很多人第一次接触 video 标签,习惯性地把它当成一个“对象”,直接读属性就行。比如想知道总时长,就写video.duration;想知道当前播放到哪里,就写video.currentTime。想法没错,但忽略了关键的一点:HTML5 video 里的这些属性,不是一开始就有值的,而且值会随着播放动态变化。

打个比方你就明白了。video.duration就像外卖 App 里的预计送达时间,你刚下单的时候它显示不出来,得等商家接单、骑手取餐之后才会慢慢更新。如果你在页面加载完的一瞬间去读duration,拿到的基本都是NaN。同样的道理,currentTime也会随着播放不停地变化。

所以 video 标签的核心使用方式,不是“读取静态属性”,而是“监听状态变化的事件”。视频从开始加载到真正播放结束,会经历元数据加载、数据缓冲、播放、暂停、快进、报错等一系列过程,每一步都有对应的事件。你的业务逻辑,就是围绕这些事件去做响应。

看几张我在项目里常用的核心事件表,先建立整体印象:

事件名触发时机常用场景
loadedmetadata视频元数据加载完成后获取duration、videoWidth等
loadeddata当前帧数据加载完成显示第一帧画面
timeupdate播放位置改变时周期性触发更新进度条、当前时间
playplay()被调用或自动播放启动时按钮状态切换
playing暂停后数据充足恢复播放时隐藏加载提示
pause播放被暂停时按钮状态切换
waiting因缺少数据而暂停播放时显示缓冲 Loading
canplay有足够数据开始播放时允许用户点播放
canplaythrough有足够数据可完整播放时预加载完成提示
seeking用户拖动进度条、开始跳转时显示跳转 Loading
seeked跳转完成后隐藏跳转 Loading
ended播放到结尾时播完后的业务逻辑
error加载或播放发生错误时错误提示
progress浏览器下载视频时周期性触发更新缓冲进度条

看到这些事件,你应该能感觉到,做 video 播放器更像是在处理一个异步流程:加载、缓冲、播放、跳转、结束,每个环节都需要监听对应事件,才能做出正确的界面反馈和业务处理。

实际操作里,我见过不少同行图省事,用setInterval去轮询currentTime来更新进度条。不是说绝对不能这么做,但有几个明显问题:一是轮询频率不好选,设高了浪费性能,设低了进度条明显卡顿;二是你轮询不到“元数据加载完成”这种关键时点,更没法知道视频是不是因为缓冲停住了。用事件驱动,代码逻辑清晰得多,性能也更优。

2. 拿时长和时间戳之前,先避开设计里的几个坑

这一节是全文的核心之一,把“获取视频总时长”和“当前播放时间”这两个需求掰开揉碎地讲清楚。

2.1 duration在loadedmetadata前是NaN

duration返回的是视频总时长,单位秒。它看起来是个普通属性,实际上是一个“延迟就绪”的属性。视频的元数据是从网络上下载的,在loadedmetadata事件触发之前,duration的值是NaN。

这也是网上最常见的一段错误代码:

const video = document.getElementById('video'); console.log(video.duration); // 很可能输出 NaN

正确做法是等loadedmetadata触发后再读取:

const video = document.getElementById('video'); video.addEventListener('loadedmetadata', () => { console.log('视频总时长:', video.duration); // 这里才能安全地拿到时长 });

顺带提一个坑中坑:如果视频是直播流或未正确返回 Content-Length 的流媒体,duration会一直是Infinity,永远不会变成一个有限数字。所以拿到duration后要做一次判断,不能直接拿来算进度条百分比。我一般这样处理:

video.addEventListener('loadedmetadata', () => { const duration = video.duration; if (Number.isFinite(duration)) { // 普通点播视频,走正常逻辑 initProgressBar(duration); } else { // 直播流或者未知时长,禁用进度条,显示 LIVE enterLiveMode(); } });

2.2 timeupdate的触发频率没有你想象的那么快

currentTime表示当前播放时间,单位也是秒。这个属性是可读写的,给它赋值就能实现拖动跳转。但要注意,浏览器不会每时每刻都通知你“当前时间变了”,而是通过timeupdate事件周期性地告诉你。这个周期在不同浏览器里略有差异,实测下来大约每 250ms 触发一次,也就是一秒钟触发 3~4 次左右。

这个频率对更新进度条、显示时间文本来说完全够用。但如果你想做逐帧动画、或者做精确到帧的互动效果,光靠timeupdate是不行的,需要结合requestAnimationFrame或者视频帧绘制相关的 API。

我见过一个真实案例:有人想做一个“在视频播放的同时,同步跳动一个动画角色”的效果,他把角色位置的更新写在了timeupdate里,结果动画一卡一卡的。这其实不是timeupdate卡,而是它本身的粒度就决定了达不到 60fps 的流畅度。

2.3 浮点精度和格式化显示的问题

另一个容易忽略的细节是浮点精度问题。视频播放到最后,currentTime并不会严格等于duration,可能会差 0.001 秒甚至更多。比如视频总时长 12 秒,播放结束时currentTime可能是 11.999999。

如果你用currentTime === duration去判断是否播完,几乎永远等不到 true。正确的判断方式是设一个容差范围:

const isAtEnd = video.duration - video.currentTime < 0.5;

显示时间文本的时候,也要注意格式化。常见的格式是分钟:秒,比如03:25。我写了一个通用的格式化函数,可以直接抄:

function formatTime(seconds) { if (!Number.isFinite(seconds) || Number.isNaN(seconds)) { return '00:00'; } const min = Math.floor(seconds / 60); const sec = Math.floor(seconds % 60); return `${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}`; }

注意Number.isFinite的判断,这能同时过滤掉NaN和Infinity,避免直播流把时间文本搞成00:00 / Infinity。

2.4 设置currentTime跳转不是随时都可以

想实现点击进度条跳转,直接给currentTime赋新值就行。但如果时机不对,代码会报错。

常见错误是:视频还没加载好,就尝试设置currentTime。在loadedmetadata之前,浏览器根本不知道视频的可跳转范围,此时赋值会抛异常。所以比较稳妥的做法是:在loadedmetadata之后启用进度条交互,并且在设置currentTime时用try/catch兜一下。

progressBar.addEventListener('input', () => { const duration = video.duration; if (!Number.isFinite(duration) || duration <= 0) return; const targetTime = (progressBar.value / 1000) * duration; video.currentTime = targetTime; // 必要时套 try/catch });

3. 播放结束:一个ended不够,边界情况比想象多

标题明确提到了“播放结束触发的事件”,对应的事件就是ended。这一节我要讲清楚这个事件的触发条件,以及几类不容易想到的边界情况。

3.1 ended事件的触发链路

ended事件在视频播放到结尾时触发。这里说的“播放到结尾”,是指currentTime到达duration的终点位置,而且视频是按正常顺序播放完的。

一个很自然的场景就是“视频课程学习”:用户把一节课看完了,你要记录学习进度、弹出“完成”按钮、或者自动跳到下一集。在ended事件里写这些业务逻辑非常合适:

video.addEventListener('ended', () => { // 1. 标记课程完成 markCourseComplete(courseId, lessonId); // 2. 显示重播按钮 replayButton.classList.remove('hidden'); // 3. 自动播放下一个视频 if (currentIndex < playlist.length - 1) { video.src = playlist[++currentIndex]; video.play(); } });

3.2 设置了loop不会触发ended

loop属性可以让视频循环播放,这在做背景视频、广告展示墙时很常用。但注意,设置了loop之后,视频播放到结尾会无缝从头开始,浏览器不会触发ended事件。

也就是说,如果你既想让视频循环播放,又想在“每一遍播完”时做一些统计(比如记录循环了几次),光靠ended是不行的,得自己用timeupdate去监听播放位置接近终点的情况:

video.addEventListener('timeupdate', () => { if (video.duration - video.currentTime < 0.05) { loopCount++; } });

3.3 手动把currentTime设成duration并不会触发ended

这个坑我印象特别深。有一次做自定义播放器,我想实现“拖动进度条到最右边 = 播放结束”的效果,于是在timeupdate里判断,如果拖动到末尾就把currentTime赋值为duration,以为这样能触发ended。结果发现:什么都不触发。

原因在于,浏览器对currentTime的赋值有一套内部限制,它会把你的值限制在一个可视作的范围内,通常不允许你精确地设置到duration,而是停在duration之前的某个极小偏差位置。赋值成功之后,视频并不会自动推进到最后,所以ended不触发。

正确的做法是:当你检测到用户拖到末尾时,直接调用业务逻辑,或者把视频暂停并手动触发你想要的“结束”动作,而不是指望系统ended事件。

3.4 直播流和MSE流不会自然触发ended

前面提到过,直播流的duration是Infinity,它永远不会播完,所以ended永远不会触发。这个好理解。

容易踩坑的是基于 Media Source Extensions(MSE)实现的点播播放器。通过 MSE 喂数据的话,视频播完并不会自动触发ended,你需要主动调用mediaSource.endOfStream(),告诉播放器“数据流结束了”,之后浏览器才会触发ended。如果你在用第三方播放器内核,遇到ended不触发的问题,优先检查是不是 MSE 模式下漏掉了这一步。

3.5 视频损坏、网络中断不会触发ended而是触发error

ended只在正常播放完毕时触发。如果视频文件损坏、解码失败、网络中断,触发的是error事件,而不是ended。所以业务逻辑里不能只监听ended,还要监听error,给用户一个友好的提示,同时做好异常上报。

video.addEventListener('error', () => { const error = video.error; const errorMap = { 1: '用户主动中断了下载', 2: '网络错误,加载中断', 3: '视频解码失败,可能文件损坏', 4: '浏览器不支持该视频格式' }; console.error('VideoError:', errorMap[error.code] || '未知错误'); });

4. 常用事件速查与真实玩法:不止play和pause

除了timeupdate和ended,实际项目里高频使用的还有一批事件。它们不一定要全部用到,但每个都可能在特定场景下帮你解决问题。

4.1 play、playing、pause三兄弟别混用

这三个事件新手最容易搞混。

  • play:当play()方法被调用,或浏览器收到自动播放指令时触发。注意,这仅仅表示“开始播放的请求已发起”,不代表视频真的在播放了。在移动端或浏览器自动播放策略下,play()即使被调用,也可能因为策略限制而失败。
  • playing:视频真正开始播放以后触发。如果视频因为缓冲数据不足暂停了,等数据够了恢复播放,也会触发playing。
  • pause:视频暂停时触发。

一个很实际的应用场景是播放按钮的文案切换。点击播放按钮后,你希望在视频真正播放时显示“暂停”,在缓冲卡住时显示“加载中”。如果只用play和pause,缓冲等待那段时间按钮文案会停留在“暂停”,体验不好。正确做法是结合playing、waiting、pause三个事件来更新按钮状态。

playButton.addEventListener('click', () => { if (video.paused) { video.play().catch(() => { button.textContent = '播放'; }); } else { video.pause(); } }); video.addEventListener('play', () => { button.textContent = '暂停'; }); video.addEventListener('playing', () => { button.textContent = '暂停'; }); video.addEventListener('waiting', () => { button.textContent = '加载中…'; }); video.addEventListener('pause', () => { button.textContent = '播放'; });

另外强调一个验证过很多次的事实:现代浏览器都不允许带声音的视频自动播放,只有静音状态下才能通过autoplay属性自动播放。所以前端做自动播放之前,一定要设置muted属性,或者用video.muted = true再调用play()。play()返回的是一个 Promise,如果被拦截会 reject,建议加.catch()兜底。

4.2 waiting与canplay:理解缓冲这把双刃剑

waiting在“视频播放到一半,后面的数据还没下载好,不得不暂停等待缓冲”时触发。这个过程用户体验很不好,但前端能做的就是展示一个 Loading 动画,再配合playing事件把它关掉。

canplay和canplaythrough是另外两个容易被忽略的事件。canplay表示有足够的数据可以开始播放,但不保证能一口气播完;canplaythrough表示预计能持续播放到结束。在做“预加载完成后自动播放”这类需求时,canplaythrough是一个很有用的信号。

4.3 progress与缓冲进度条的实现

progress事件在浏览器下载媒体数据时周期性触发。配合video.buffered属性,可以拿到已经缓冲的范围,从而画出类似 YouTube 那种灰色缓冲进度条。

function updateBuffered() { const duration = video.duration; if (!Number.isFinite(duration) || duration <= 0) return; if (video.buffered.length > 0) { const bufferedEnd = video.buffered.end(video.buffered.length - 1); bufferedBar.style.width = `${(bufferedEnd / duration) * 100}%`; } } video.addEventListener('progress', updateBuffered);

buffered是一个TimeRanges对象,可能有多段缓冲区间。一般我们取最后一段的end值,表示目前下载到哪个位置了,这样画的缓冲条更贴近用户感知。

4.4 seeking与seeked:拖动进度条的正确响应方式

拖动进度条设置currentTime后,会先触发seeking,跳转完成后触发seeked。如果你的视频文件很大、网络又不好,跳转可能需要等待。在这期间,可以在界面上显示转圈 Loading,等seeked再隐藏。

另外,拖动input[type="range"]时,input事件会连续触发很多次。如果每次input都去设置currentTime,会产生大量跳转请求,播放器会卡顿甚至崩溃。我的实践是:拖动过程中只更新 UI 的预览值,等用户松手(change事件)再一次性设置currentTime。

4.5 loadedmetadata与loadeddata:拿完时长别忘了宽高

loadedmetadata除了能获取duration,还能拿到视频的原始宽高:

video.addEventListener('loadedmetadata', () => { const width = video.videoWidth; const height = video.videoHeight; // 根据宽高比调整播放器尺寸,避免画面变形 });

在做视频列表页时,这个信息很有用。你可以提前用宽高比占位,防止页面布局抖动。videoWidth是原始分辨率宽度,不是元素显示宽度,记得区分。

loadeddata表示当前帧的数据已经加载完成,也就是画面已经可以显示了。如果你想在视频封面图上做“加载完自动隐藏封面”,监听这个事件很合适。

4.6 由热搜词带出来的几个实用操作

写这篇的时候正好看到一堆跟 video 相关的高频搜索词,其中有几个在项目里也确实很常用,顺便提一下:

  • 倍速播放:video.playbackRate可以控制播放速度,支持小数,比如 0.5、1.5、2。在做上网课、刷视频这类产品时是刚需。切换时注意先把当前速度读出来,再设置新值。
  • 视频旋转:有些视频是竖屏拍摄的,在电脑上播放默认是横的。单纯用 CSS 旋转视频元素时,注意把视频容器尺寸也做适配。搜索词里那行v.style.rotate = '-90deg'确实能转,但要配合容器宽高调整才好看。
  • 画中画:video.requestPictureInPicture()可以让视频悬浮在小窗里,用户切到其他标签页还能继续看。这个 API 的兼容性已经足够好,值得用起来。
  • 全屏:video.requestFullscreen()加上document.fullscreenElement判断,基本就是浏览器原生全屏的标准玩法。

这些操作本质上都绕不开事件监听:倍速设置以后,进度条更新逻辑不用变,因为timeupdate会按照playbackRate的比例来汇报时间进度;旋转只是视觉变化,不影响事件流;画中画有独立的enterpictureinpicture和leavepictureinpicture事件,要在事件里处理界面上“进入画中画”和“退出画中画”的状态。

5. 把知识点串起来:自定义播放器实战代码

前面讲了这么多事件和属性,最终还是要落在可运行的项目里。这一节我提供一个完整的自定义播放器实现,集成了上面讲到的绝大部分知识点:总时长显示、当前播放时间、可拖动进度条、缓冲进度条、播放/暂停切换、倍速切换、全屏、播放结束重播、错误处理。代码可以直接粘贴到 HTML 里跑起来。

5.1 HTML结构与基础样式

<div class="player"> <video id="video" src="https://www.w3schools.com/html/mov_bbb.mp4" playsinline preload="metadata" ></video> <div class="controls"> <button id="playBtn">播放</button> <div class="progress-wrap"> <div id="bufferedBar" class="buffered-bar"></div> <input id="progressBar" type="range" min="0" max="1000" value="0" /> </div> <span id="timeText">00:00 / 00:00</span> <button id="speedBtn">1.0x</button> <button id="fullBtn">全屏</button> </div> </div>
.player { width: 100%; max-width: 720px; background: #000; } .player video { display: block; width: 100%; } .controls { display: flex; align-items: center; gap: 8px; padding: 10px; background: #222; color: #fff; } .progress-wrap { position: relative; flex: 1; height: 20px; } .progress-wrap input[type="range"] { position: relative; z-index: 2; width: 100%; margin: 0; background: transparent; } .buffered-bar { position: absolute; top: 8px; left: 0; width: 0; height: 4px; background: #555; z-index: 1; }

5.2 JavaScript完整逻辑

const video = document.getElementById('video'); const playBtn = document.getElementById('playBtn'); const progressBar = document.getElementById('progressBar'); const bufferedBar = document.getElementById('bufferedBar'); const timeText = document.getElementById('timeText'); const speedBtn = document.getElementById('speedBtn'); const fullBtn = document.getElementById('fullBtn'); const speeds = [0.5, 1, 1.25, 1.5, 2]; function formatTime(seconds) { if (!Number.isFinite(seconds) || Number.isNaN(seconds)) return '00:00'; const m = Math.floor(seconds / 60); const s = Math.floor(seconds % 60); return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; } function updateTimeText() { const current = video.currentTime || 0; const total = video.duration; if (Number.isFinite(total)) { timeText.textContent = `${formatTime(current)} / ${formatTime(total)}`; } else { timeText.textContent = `${formatTime(current)} / LIVE`; } } function updateBuffered() { const total = video.duration; if (!Number.isFinite(total) || total <= 0) return; if (video.buffered.length > 0) { const bufferedEnd = video.buffered.end(video.buffered.length - 1); bufferedBar.style.width = `${(bufferedEnd / total) * 100}%`; } } // 1. 播放/暂停 function togglePlay() { if (video.paused) { video.play().catch(() => { playBtn.textContent = '播放'; }); } else { video.pause(); } } playBtn.addEventListener('click', togglePlay); video.addEventListener('click', togglePlay); video.addEventListener('play', () => { playBtn.textContent = '暂停'; }); video.addEventListener('playing', () => { playBtn.textContent = '暂停'; }); video.addEventListener('waiting', () => { playBtn.textContent = '加载中…'; }); video.addEventListener('pause', () => { playBtn.textContent = '播放'; }); // 2. 元数据加载完成后初始化 video.addEventListener('loadedmetadata', () => { updateTimeText(); if (!Number.isFinite(video.duration)) { progressBar.disabled = true; } }); // 3. 更新进度条和时间文本 video.addEventListener('timeupdate', () => { const total = video.duration; if (Number.isFinite(total) && total > 0) { progressBar.value = (video.currentTime / total) * 1000; } updateTimeText(); updateBuffered(); }); // 4. 缓冲进度更新 video.addEventListener('progress', updateBuffered); // 5. 进度条拖动:input只更新UI,change才真正跳转 progressBar.addEventListener('input', () => { const total = video.duration; if (!Number.isFinite(total) || total <= 0) return; const previewTime = (progressBar.value / 1000) * total; timeText.textContent = `${formatTime(previewTime)} / ${formatTime(total)}`; }); progressBar.addEventListener('change', () => { const total = video.duration; if (!Number.isFinite(total) || total <= 0) return; video.currentTime = (progressBar.value / 1000) * total; }); // 6. 跳转时的seeking/seeked处理 video.addEventListener('seeking', () => { playBtn.textContent = '加载中…'; }); video.addEventListener('seeked', () => { playBtn.textContent = video.paused ? '播放' : '暂停'; }); // 7. 倍速切换 speedBtn.addEventListener('click', () => { const current = video.playbackRate; let idx = speeds.indexOf(current); if (idx === -1) idx = 1; const nextSpeed = speeds[(idx + 1) % speeds.length]; video.playbackRate = nextSpeed; speedBtn.textContent = `${nextSpeed.toFixed(2)}x`; }); // 8. 全屏切换 fullBtn.addEventListener('click', () => { if (document.fullscreenElement) { document.exitFullscreen(); } else { video.requestFullscreen(); } }); // 9. 播放结束:显示重播并支持一键重播 video.addEventListener('ended', () => { playBtn.textContent = '重播'; }); playBtn.addEventListener('click', () => { if (playBtn.textContent === '重播') { video.currentTime = 0; video.play(); } }); // 10. 错误处理 video.addEventListener('error', () => { const mediaError = video.error; if (!mediaError) return; const errorMap = { 1: '用户中断了下载', 2: '网络错误', 3: '解码失败,文件可能已损坏', 4: '浏览器不支持此视频格式' }; alert(errorMap[mediaError.code] || '未知播放错误'); });

5.3 代码走读:几个值得注意的设计

这个播放器里藏了几个不容易直接看出来的细节,展开说一下:

  • 进度条的 input 和 change 分离:input事件在拖动过程中高频触发,我只用它来更新显示层的时间预览。change事件在松手后才触发,这时候才真正写入currentTime。这样避免高频跳转拖垮播放器。
  • ended 之后的“重播”处理:播放结束我把按钮文字改成“重播”,然后单独拦截按钮点击。这个思路比在ended里强制play()更稳,因为用户可能并不想立即重播,他可能想先点暂停。
  • seeked 里用 video.paused 判断:跳转完成后,播放状态可能是暂停的也可能是播放的,不能写死。所以通过video.paused反推按钮文案,比硬编码更准确。

在 React 或 Vue 里,这套逻辑不用大改,只是事件绑定方式略有不同。以 React 为例:

<video onLoadedMetadata={handleLoadedMetadata} onTimeUpdate={handleTimeUpdate} onEnded={handleEnded} ref={videoRef} />

核心的事件处理函数是一样的,框架只是帮我们把addEventListener的生命周期管理掉了。

5.4 写完之后的测试清单

代码写完后,建议手动过一遍测试清单,能帮你发现很多潜在问题:

  • 首次打开页面,时间显示是否为00:00 / 00:00,而不是NaN / NaN。
  • 视频加载中,播放按钮点击后是否显示“加载中…”。
  • 播放过程中直接切换倍速,按钮文本和时间进度是否正确。
  • 拖动进度条到末尾,松手后是否触发了ended。
  • 把视频源src改成一个不存在的地址,错误提示是否正常弹出。
  • 播放结束后点“重播”,能否正常回到开头重新播放。
  • 窗口进入全屏再退出,播放状态是否保持正常。

这七项如果全部通过,这个播放器的基础交互就算稳了。

最后再说点实际操作里的体会

做了几年视频相关的功能,我最大的感受是:video 标签的事件其实就那么多,难的不是记住事件名,而是搞清楚每个事件在什么时机触发、什么时机不触发。比如duration要等loadedmetadata,ended对直播流无效,currentTime不能直接赋成duration来模拟结束。这些细节光看文档很容易忽略,但在项目里碰到了又特别耽误时间。

建议你把这个播放器代码跑起来,在控制台把loadedmetadata、timeupdate、playing、waiting、ended这些事件全部console.log出来,切身感受一下它们的触发顺序和频率。搞懂一遍之后,后面再遇到任何视频类的需求,比如加字幕、多音轨、清晰度切换、视频列表自动连播,你都能很快找到对应的实现路径。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 11:49:54

YOLO目标检测实战指南:从v1到v8原理贯通与工业落地

1. 这不是“速成课”&#xff0c;而是一份目标检测工程师的实战成长地图 YOLO这个词&#xff0c;现在几乎成了目标检测领域的代名词。但很多人点开“YOLOv13”这个标题时&#xff0c;第一反应是&#xff1a;等等&#xff0c;YOLO官方最新版本明明是YOLOv8&#xff08;Ultralyti…

作者头像 李华
网站建设 2026/10/1 11:48:07

Python异步编程核心:asyncio、协程与任务调度实战

如果你写过几段带网络请求或文件读写的 Python 代码&#xff0c;大概率体会过这种场景&#xff1a;一个爬虫循环请求 50 个页面&#xff0c;90% 的时间都耗在那句 requests.get() 上。你以为自己在写代码&#xff0c;实际却是在等网络。 Python 异步编程这套东西&#xff0c…

作者头像 李华
网站建设 2026/10/1 11:47:52

Docker Compose安装与实战:unknown command排查指南

“docker: unknown command: docker compose”——这大概是过去一年我在各种技术群里看到频率最高的报错。很多人拿着新写的compose.yaml文件&#xff0c;复制粘贴docker compose up -d&#xff0c;终端啪地甩出这么一行&#xff0c;整个人就懵了&#xff1a;明明Docker装得好好…

作者头像 李华
网站建设 2026/10/1 11:47:51

Flutter for OpenHarmony跨端健康仪表盘实战:从数据桥接到性能优化

做OpenHarmony应用开发这几年&#xff0c;我大部分时间都在用ArkTS写页面&#xff0c;直到上个月接了一个生活助手App的项目&#xff0c;需求里明确要求健康仪表盘要同时覆盖OpenHarmony和Android两端&#xff0c;工期还被压得特别紧。我第一反应就是把Flutter搬过来。不是ArkT…

作者头像 李华
网站建设 2026/10/1 11:47:48

openrig DIY铝合金型材模拟赛车驾驶舱:材料选型与组装全攻略

玩模拟赛车三年多&#xff0c;从最开始拿桌子椅子凑合&#xff0c;到后来咬牙买成品驾驶舱&#xff0c;再到最后自己动手做了一套开放式铝合金型材 rig&#xff0c;我算是把这条路上的坑基本都踩遍了。今天聊的这个 openrig 方案&#xff0c;不是什么商业产品&#xff0c;而是社…

作者头像 李华
网站建设 2026/10/1 11:47:45

MySQL入门实战:环境搭建、建库建表与SQL基础详解

直接点说吧&#xff1a;看到标题我就乐了—— MySOL 。第一次见到的时候我也以为是什么新出的数据库&#xff0c;搜了一圈官网才发现&#xff0c;这大概率是把 MySQL 打成 MySOL 了。不过这种手滑倒也给第一章笔记留了个好开头&#xff1a;如果把 SOL 当成 SQL 去理解…

作者头像 李华