基于 HTML5 Video API 的高能节点标记播放器实现
2026/9/2 19:49:15 网站建设 项目流程

在综艺 Reaction 视频的制作流程里,最耗时的工作往往不是录制本身,而是回看完整素材并反复拖动进度条。以《换乘恋爱 4》EP-17 这样的关键集数为例,观众往往会把某枚戒指的出现视为“剧情炸弹”,因为戒指一出现,关系走向就发生明显反转;制作团队要快速在几十秒内定位这个镜头,还要在对应位置插入自己的表情、评价或字幕,才能形成完整的 Reaction 内容。如果只靠大脑记时间码,或者用记事本记一句“大概第 8 分钟”,效率很低,也无法复用。下面就从解决这个实际痛点出发,编写一个基于原生 HTML5 Video API 的高能节点标记播放器,把 EP-17 里的“戒指炸弹”“自尊心冲突”这类节点变成可点击、可跳转、可导出的时间轴标记。

这个项目不依赖 Vue、React 或构建工具,只需要一个浏览器和一个本地静态服务器就能跑通。核心思路很简单:视频播放器负责播放,JavaScript 维护一个带时间戳的节点数组,再通过 DOM 渲染成可点击的标记列表。这样无论是个人剪片前整理素材,还是小团队一起拉片对齐剧情爆点,都能比纯手工打点节省大量时间。

1. 为什么 Reaction 剪辑需要高能节点标记器

1.1 高能片段是协作对齐的核心单位

Reaction 视频的观赏性和制作效率,很大程度上取决于“高能片段”是否被准确捕捉。所谓高能片段,不是指视频中每一秒都有戏剧冲突,而是指那些真正会引发观众情绪波动的节点。在 EP-17 这样的单集内容里,高能节点往往有明确的视觉道具或台词信号,比如“戒指炸弹”对应一个反转镜头,“放下一点自尊心”对应一段关系对话。

如果没有工具,剪辑人员通常这么做:先完整看一遍素材,凭记忆记录大概时间点;然后写脚本时再次跳转核对;最后在剪辑软件里逐段剪切。一个 90 分钟的单集,反复找点可能耗时 1 到 2 小时。更麻烦的是,如果两个人同时看,一个人说“在第 8 分钟左右”,另一个人一拖就拖到第 10 分钟,对齐成本非常高。

高能节点标记器的价值,就是把这些模糊的口头描述变成结构化的数据:这个片段发生在哪一秒,是什么类型,用户看到这里应该注意什么。所有信息聚合在一个时间轴上,谁都可以快速跳转到指定节点。

1.2 三种素材整理方式对比

先看普通 Excel 表格、纯文本笔记和播放器内嵌标记三种方案的区别。这里用表格做一个直观对比:

方案记录方式跳转效率协作复用适合场景
Excel 表格手写时间列、备注列低,要手动在播放器输入时间可用网盘共享,但不同步临时记录少量节点
纯文本笔记写下“戒指炸弹 482s”低,需要手动拖进度条可复制,但容易漏看个人简单打点
播放器内标记在播放器中点击添加,自动收集时间高,点击节点直接跳转可导出 JSON 再共享剪辑素材整理
在线协作标记后端存储标记,多人同时编辑高,实时同步高,权限可控团队项目制作

从表格能看出,播放器内嵌标记比纯文本方案多了一层交互能力:点击节点时可以直接修改video.currentTime,这是 Excel 和笔记做不到的。

1.3 这个 Demo 能做什么

下面的示例项目会实现一个最小可用闭环:

  • 输入视频地址并加载。
  • 在任意播放位置添加标记,记录当前秒数。
  • 标记包含类型、标签名、备注,例如“戒指炸弹”“自尊心冲突”。
  • 自动按时间排序,并在列表中跳转。
  • 支持删除标记。
  • 支持导出 JSON 和导入 JSON。
  • 使用localStorage本地持久化,刷新页面后不丢失。

在这个能力基础上,后续可以扩展后端保存、多人协作、视频帧缩略图等生产功能。

2. 先理解 HTML5 Video 的时间控制能力

2.1 video 元素的核心属性与方法

HTML5 原生<video>元素已经把播放器能力封装得比较完整。这个项目只用到其中最核心的几个:

  • src:视频地址,可以是本地相对路径、服务器绝对地址或可直接播放的媒体 URL。
  • currentTime:当前播放位置,单位是秒,可以读取也可以赋值。
  • duration:视频总时长,单位是秒。
  • play():开始播放,返回 Promise。
  • pause():暂停播放。
  • timeupdate:播放位置变化时触发的事件。

在实现标记跳转时,最重要的代码只有一句:

video.currentTime = marker.time;

currentTime赋一个合法的秒数后,浏览器会立刻把播放进度跳到对应位置,并触发后续的timeupdateseeked等事件。

2.2 timeupdate 事件的工作方式

timeupdate是项目实现“自动播报高能节点”的依赖事件。它不需要手动定时器,浏览器会在媒体播放位置变化时触发。实际触发频率取决于浏览器实现,通常在 250 毫秒到 4 秒之间。对于这个 Demo 来说,每秒钟触发几次足够用来判断是否接近某个标记。

这里要注意,timeupdate的触发并不是绝对均匀的。系统繁忙、视频卡顿、浏览器节能策略都可能让它变得稀疏。所以不要依赖它做精确到帧的业务,只能作为普通 UI 更新与提示的触发信号。

video.addEventListener('timeupdate', () => { const current = video.currentTime; // 在这里判断是否逼近某个高能节点 const next = markers.find((marker) => { return marker.time > current && marker.time - current <= 5; }); if (next) { // 弹出提示 } });

上面代码中的marker.time - current <= 5表示判断“当前时间距离标记时间 5 秒以内”,用于提前提示。这个阈值可以根据素材类型调整,综艺类可以设大一点,纯影视解说可以设小一点。

2.3 currentTime 跳转的边界情况

currentTime虽然可以随意赋值,但有几个边界情况需要提前处理:

  • 赋值小于 0,浏览器会按 0 处理,但更稳妥的做法是Math.max(0, time)
  • 赋值大于duration,浏览器会跳到视频结尾并触发ended
  • 视频没有加载完成时,duration可能是NaN,此时跳转可能不生效。
  • 用户在标记列表里点击跳转时,最好先判断video.src是否已设置。

这些边界情况会在后文的核心代码中通过校验和兜底处理来解决。

3. 项目结构与页面骨架

3.1 项目目录与运行方式

这是一个零依赖的前端小项目,目录结构可以保持简单:

reaction-highlight-player/ ├── index.html ├── style.css ├── app.js └── highlights.example.json

直接用浏览器打开index.html也可以运行。不过如果使用远程视频地址,有些浏览器会限制自动播放或全屏,建议放到本地静态服务器里访问。这里提供一个最简单的启动方式:

python3 -m http.server 8080

然后在浏览器中访问:

http://localhost:8080

如果电脑上安装的是 Node.js,也可以用:

npx serve .

两种方式任选一种即可。关键点是保证页面和视频文件在同一来源下,避免后续做视频截图或复用媒体资源时出现跨域问题。

3.2 index.html 页面骨架

index.html负责页面结构和引入脚本。不需要复杂框架,只需要两个区域:视频播放区域和标记管理区域。

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Reaction 高能节点标记播放器 Demo</title> <link rel="stylesheet" href="style.css"> </head> <body> <main class="player-shell"> <section class="video-area"> <video id="videoPlayer" controls preload="metadata"></video> <div class="toolbar"> <label> 视频地址: <input id="videoUrl" type="url" placeholder="输入本地或远程视频地址"> </label> <button id="loadVideoBtn" type="button">加载</button> </div> </section> <section class="marker-area"> <h2>高能节点标记</h2> <form id="markerForm"> <input id="markerTime" type="number" min="0" step="1" placeholder="时间点(秒)"> <select id="markerType"> <option value="bomb">反转/炸弹</option> <option value="emotion">情感冲突</option> <option value="dialogue">关键台词</option> <option value="custom">自定义</option> </select> <input id="markerLabel" type="text" placeholder="标签名,例如:戒指炸弹"> <textarea id="markerNote" placeholder="备注说明"></textarea> <button type="submit">添加标记</button> <button id="exportBtn" type="button">导出 JSON</button> <button id="importBtn" type="button">导入 JSON</button> <input id="importFile" type="file" accept=".json" hidden> </form> <ul id="markerList"></ul> </section> </main> <script src="app.js"></script> </body> </html>

关键点有两个:

  1. <video controls preload="metadata">controls让浏览器显示原生控制条,preload="metadata"表示只预加载元数据,避免进入页面就缓冲整段视频。
  2. 导入按钮是一个隐藏的文件输入框,由“导入 JSON”按钮触发点击,这样 UI 上不会出现浏览器默认的文件选择样式。

3.3 标记的数据结构

标记的数据结构直接决定后续扩展是否方便。一个合理的 JSON 节点可以这样定义:

{ "id": 1710000000001, "time": 482, "type": "bomb", "label": "戒指炸弹出现", "note": "镜头特写,标记为反转节点", "createdAt": "2025-01-01T10:00:00.000Z" }

字段含义如下:

字段类型说明
idnumber标记唯一 ID,用时间戳生成
timenumber事件发生的秒数
typestring标记类型,如 bomb、emotion 等
labelstring短标签名
notestring备注,可以写更详细的剪辑思路
createdAtstringISO 时间,记录创建时间

之所以保留idcreatedAt,是为了将来接入后端后可以做增量同步和冲突处理。导出文件时可以统一包含一个版本号:

{ "version": 1, "markers": [] }

这个设计在后续扩展中会比只存一个数组更稳妥。

4. 核心 JS 实现:标记的增删改查与跳转

4.1 全局状态与 localStorage

app.js里先定义全局状态。标记数组是唯一核心数据源,所有 UI 都从它渲染。

const video = document.getElementById('videoPlayer'); const videoUrlInput = document.getElementById('videoUrl'); const loadVideoBtn = document.getElementById('loadVideoBtn'); const markerForm = document.getElementById('markerForm'); const markerTime = document.getElementById('markerTime'); const markerType = document.getElementById('markerType'); const markerLabel = document.getElementById('markerLabel'); const markerNote = document.getElementById('markerNote'); const markerList = document.getElementById('markerList'); const exportBtn = document.getElementById('exportBtn'); const importBtn = document.getElementById('importBtn'); const importFile = document.getElementById('importFile'); const STORAGE_KEY = 'reaction-highlight-markers'; let markers = []; let promptedMarkerId = null;

STORAGE_KEYlocalStorage的键名。markers是页面内存中的标记数组。页面刷新后通过loadMarkers()从本地恢复。

function loadMarkers() { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return []; const data = JSON.parse(raw); return Array.isArray(data) ? data : []; } catch (error) { console.warn('本地存储解析失败,已重置', error); localStorage.removeItem(STORAGE_KEY); return []; } } function saveMarkers() { localStorage.setItem(STORAGE_KEY, JSON.stringify(markers)); }

很多人在写本地持久化时忘记处理JSON.parse异常。如果用户曾经在控制台手动修改过数据,或者存储内容被截断,JSON.parse会直接抛错,导致整个脚本停摆。这里用try/catch包住,并且在异常时清空本地存储,是更稳的做法。

4.2 添加标记

添加标记时,不直接把表单值塞进数组,而是先做类型校验和时间排序。

function addMarkerFromForm(event) { event.preventDefault(); const time = Number.parseFloat(markerTime.value); if (!Number.isFinite(time) || time < 0) { alert('请输入合法的秒数'); return; } markers.push({ id: Date.now(), time, type: markerType.value, label: markerLabel.value.trim(), note: markerNote.value.trim(), createdAt: new Date().toISOString() }); markers.sort((a, b) => a.time - b.time); saveMarkers(); renderMarkers(); markerForm.reset(); }

这里有两个容易踩的坑:

  1. markerTime.valueinput[type=number]拿到的是字符串,必须用Number.parseFloat转数字,再用Number.isFinite判断。
  2. 标记添加后要排序,否则列表展示顺序会乱。后面做“下一个高能节点提示”时也依赖有序数组。

4.3 渲染标记列表

渲染列表时,如果直接用innerHTML拼接用户输入内容,会存在 XSS 风险。比如某个标记的label写成<img src=x onerror=alert(1)>,插入 DOM 时就会执行脚本。最简单的方式是写一个escapeHtml方法。

function escapeHtml(str) { return String(str) .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&#39;'); }

然后渲染列表:

function formatTime(seconds) { const s = Math.max(0, Math.floor(seconds)); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const sec = s % 60; const pad = (n) => n.toString().padStart(2, '0'); return h > 0 ? `${pad(h)}:${pad(m)}:${pad(sec)}` : `${pad(m)}:${pad(sec)}`; } function getTypeName(type) { const map = { bomb: '反转/炸弹', emotion: '情感冲突', dialogue: '关键台词', custom: '自定义' }; return map[type] || type; } function renderMarkers() { markerList.innerHTML = ''; markers.forEach((marker, index) => { const li = document.createElement('li'); li.dataset.index = index; li.innerHTML = ` <div class="marker-main"> <strong>${formatTime(marker.time)}</strong> <span class="type-tag">${getTypeName(marker.type)}</span> <span>${escapeHtml(marker.label || '未命名')}</span> </div> <p class="marker-note">${escapeHtml(marker.note || '')}</p> <div class="marker-actions"> <button class="jump-btn" type="button">跳转</button> <button class="delete-btn" type="button">删除</button> </div> `; li.querySelector('.jump-btn').addEventListener('click', () => jumpToMarker(marker)); li.querySelector('.delete-btn').addEventListener('click', () => removeMarker(index)); markerList.appendChild(li); }); }

这里没有使用insertAdjacentHTML,而是先createElement,再在里面使用innerHTML,最后绑定事件。这种写法在原生 JS 里比较直观,且可以精准给每个按钮绑定当前标记。

4.4 跳转与删除

跳转的关键逻辑是设置video.currentTime。注意播放器没有加载视频时,src为空,跳转不会生效,需要给出提示。

function jumpToMarker(marker) { if (!video.src) { alert('请先加载视频'); return; } const targetTime = Math.max(0, marker.time); if (Number.isFinite(video.duration) && targetTime > video.duration) { alert('标记时间超出视频总时长'); return; } video.currentTime = targetTime; video.play().catch(() => { console.warn('自动播放被浏览器拦截,请手动点击播放'); }); }

video.play()返回 Promise。在某些浏览器中,即使用户点击了按钮,自动播放策略也可能阻止播放。这里用catch捕获,避免产生未处理的 Promise 异常。

删除标记只需要从数组中移除一项,然后重新渲染:

function removeMarker(index) { markers.splice(index, 1); saveMarkers(); renderMarkers(); }

删除后必须重新保存并渲染,否则列表和本地存储之间会出现不一致。

4.5 导出与导入 JSON

导出功能让标记可以在不同设备之间复用。核心是使用Blob创建一个 JSON 文件,然后模拟点击下载链接。

function exportJSON() { const data = { version: 1, markers }; const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `reaction-highlights-${Date.now()}.json`; a.click(); URL.revokeObjectURL(url); }

URL.revokeObjectURL用于释放内存。下载完成后如果不释放,重复导出会积累大量临时 URL。

导入时读取文件内容并解析 JSON:

importBtn.addEventListener('click', () => { importFile.click(); }); importFile.addEventListener('change', async (event) => { const file = event.target.files[0]; if (!file) return; try { const text = await file.text(); const data = JSON.parse(text); if (!data || !Array.isArray(data.markers)) { throw new Error('文件格式不正确,缺少 markers 数组'); } markers = data.markers; saveMarkers(); renderMarkers(); } catch (error) { console.error('导入失败', error); alert('导入失败:' + error.message); } importFile.value = ''; });

导入成功后,页面中已有的标记会被导入数据整体替换。这个行为在真实项目中需要二次确认,避免误覆盖。

5. 加入高能节点自动提示

5.1 触发逻辑

高能节点自动提示是把这个工具从“手动标记器”升级成“剪辑辅助工具”的关键功能。当视频播放到某个标记前 5 秒时,页面顶部弹出一条提示,告诉用户即将到达哪个节点。

这里需要一个showToast方法。不引入第三方库,直接用 DOM 创建提示元素:

function showToast(message) { let toast = document.querySelector('.toast'); if (!toast) { toast = document.createElement('div'); toast.className = 'toast'; document.body.appendChild(toast); } toast.textContent = message; toast.style.opacity = '1'; clearTimeout(toast._timer); toast._timer = setTimeout(() => { toast.style.opacity = '0'; }, 3000); }

然后在timeupdate事件中判断下一个节点:

function findNextMarker(currentTime) { return markers.find((marker) => { return marker.time > currentTime && marker.time - currentTime <= 5; }); } video.addEventListener('timeupdate', () => { const nextMarker = findNextMarker(video.currentTime); if (nextMarker && nextMarker.id !== promptedMarkerId) { showToast(`即将到达:${nextMarker.label || '高能节点'} ${formatTime(nextMarker.time)}`); promptedMarkerId = nextMarker.id; } });

这里把“接近 5 秒”的标记找出来,并且只在第一次进入范围时提示。用户如果停留在同一个标记附近反复拖动进度条,不会重复打扰。

5.2 用 promptedMarkerId 去重

promptedMarkerId是一个状态标记,用来记录“上次提示过的节点 ID”。当视频继续播放并越过当前节点后,findNextMarker会返回空或返回下一个节点,promptedMarkerId会被更新,因此同一个节点不会连续触发。

如果没有这个去重逻辑,会出现什么情况?由于timeupdate每秒触发多次,条件满足的每一帧都会调用showToast,页面上会连续弹出多个提示,体验非常差。所以去重不是可选项,而是必须的逻辑。

更严格的做法是在标记列表里维护一个hasPrompted字段,但会影响导出数据。用内存变量promptedMarkerId更轻量。

5.3 自动播放策略的影响

自动提示本身不是播放操作,不会触发自动播放限制。但用户在点击标记跳转后,如果调用video.play(),会被部分浏览器的自动播放策略拦截。最明显的表现是:视频已经跳到对应位置,但处于暂停状态。

处理方式是在jumpToMarker中明确调用video.play()并捕获 Promise:

video.play().catch(() => { // 保持暂停状态,等待用户手动播放 });

如果项目要求点击后必须播放,可以考虑在提示弹层中增加“手动点击后播放”的交互,而不是绕过浏览器策略。由于这是前端技术实现,不能通过强制设置来规避浏览器的安全策略,只能从产品逻辑上做降级。

6. 运行验证与常见问题排查

6.1 最小验证路径

写完代码后,按下面的路径验证才是完整的闭环:

  1. 启动本地静态服务器。
  2. 在文本框输入一个可播放的视频地址,点击“加载”。
  3. 视频播放到某个关键位置,暂停,输入时间点或直接使用当前时间,填写标签与备注。
  4. 点击“添加标记”,确认列表中出现对应节点。
  5. 点击“跳转”,确认播放进度跳到指定时间。
  6. 点击“导出 JSON”,确认得到一个合法 JSON 文件。
  7. 刷新页面,确认标记仍然存在。
  8. 点击“导入 JSON”,选择刚才导出的文件,确认列表恢复。

其中第 6 到 8 步最容易被人跳过。很多人在本地调试时只验证“能添加”,没有验证持久化和导出,结果换一台电脑后标记全部丢失,才意识到问题。

6.2 常见问题排查表

实际运行中,比较常见的问题和排查路径可以整理成表格:

问题现象常见原因检查方式处理建议
添加标记后列表不刷新JS 报错,或localStorage不可用打开控制台看报错修复语法错误;隐私模式下改用内存方案
点击跳转没有效果视频未加载,或currentTime超出时长检查video.srcduration先加载视频;跳转前做数值校验
导入文件后标记为空JSON 结构不对,缺少markers数组手动打开 JSON 检查导入时校验Array.isArray(data.markers)
自动提示重复弹出缺少promptedMarkerId去重看控制台日志增加去重状态
视频地址加载失败地址非媒体直链,或服务器禁止访问用浏览器直接打开该地址换成可直链的媒体地址
刷新后标记丢失localStorage被清空,或在不同浏览器打开检查 Application 面板使用导出 JSON 做备份

6.3 至少三个容易踩的坑

第一个坑是直接读markerTime.value当作数字用。表单输入框的值永远是字符串,直接参与计算可能会出现“0 + '1'”这样的字符串拼接问题。必须用Number.parseFloat转换并校验。

第二个坑是渲染列表时没有做 HTML 转义。标记的标签和备注来自用户输入,如果包含 HTML 片段,轻则样式被破坏,重则形成 XSS 漏洞。即使这是一个本地工具,也建议写escapeHtml,形成习惯。

第三个坑是使用timeupdate做精确判断。这个事件不是稳定帧级触发,不要在事件里做昂贵操作。提示、更新currentTime这类轻量 UI 操作可以,不要做重计算或复杂 DOM 操作,否则会造成视频卡顿。

第四个容易忽略的问题是跨源视频。<video>标签播放远程视频时,如果远程服务器不允许跨域,播放通常可以正常,但使用 Canvas 截图或读取视频帧数据时会报错。如果后续要自动生成标记缩略图,需要确保视频服务器返回Access-Control-Allow-Origin响应头。

7. 生产化扩展:从单机标注到团队协作

7.1 标记数据搬到后端

当前 Demo 把标记保存在localStorage,这只适合单机调试。真实团队协作时,需要把标记字段提交到后端,常见接口设计如下:

POST /api/episodes/17/highlights { "time": 482, "type": "bomb", "label": "戒指炸弹出现", "note": "镜头特写,标记为反转节点" }

后端需要做的几件事:

  • 按剧集 ID 隔离数据,避免不同内容串数据。
  • 记录创建人、更新时间,方便追溯。
  • 支持列表查询和增量更新,避免前端一次加载全部数据。
  • time字段建立索引,便于按时间区间查询。

前端可以从“本地数组 + 渲染”升级为“服务端状态 + 缓存”。使用 React 或 Vue 后,标记列表可以抽成独立组件,配合 WebSocket 实现多人同时拉片时的实时同步。

7.2 自动识别高能片段的方向

手动标记虽然准确,但效率有上限。如果想进一步降低人工成本,可以考虑自动识别高能片段,常见方案有:

  • 音频分析:检测音量异常升高、人声情绪激动,自动生成候选节点。
  • 字幕关键词:当字幕中出现“戒指”“炸弹”“分手”“误会”等关键词时,自动插入候选标记。
  • 画面变化:检测镜头切换频率、场景亮度突变等,辅助定位反转镜头。

这些方向都适合作为后期扩展,不建议在一开始就做。先把手动标记流程跑通,再根据真实素材积累节点样本,训练或调优规则才有意义。

7.3 对接剪辑软件与导出规范

当标记列表积累到一定规模后,需要把数据导出到剪辑软件。不同软件的工程格式差异很大,但很多剪辑软件支持 CSV 时间码导入。可以从 JSON 生成一个简单 CSV:

time,label,note,type 00:08:02,戒指炸弹出现,镜头特写反转节点,bomb 00:12:00,自尊心冲突,双方对话张力较大,emotion

另外也可以将标记点转换成 XML 或项目文件里的标记轨道。导出前需要和剪辑团队确认时间基准,是使用视频媒体时间,还是使用时间轴轨道时间。两者如果差一个前导偏移,整批标记都会错位。

7.4 发布前检查清单

如果要把这个功能做成正式工具,发布前建议按以下清单逐项检查:

  • 视频地址是否支持上传,还是只用远程地址。
  • 标记数据是否包含用户身份,是否允许删除和编辑。
  • 是否有批量导入和导出能力,导出格式是否满足下游工具要求。
  • 是否有异常处理,例如视频加载失败、标记时间非法、JSON 格式错误。
  • 是否对用户输入做转义,防止 XSS。
  • 是否考虑移动端触摸操作。
  • 是否限制单集标记数量,避免渲染性能问题。
  • 是否记录操作日志,方便回溯误删或误改。

对刚接触这个场景的开发者来说,先跑通原生 HTML5 Video 版本,再把播放器逻辑迁移到组件化框架里,是性价比最高的路线。不要一开始就引入复杂工程,因为高能节点标记的核心难点从来不是框架,而是“时间轴数据如何组织”和“播放器事件如何驱动交互”这两个基础问题。把这两个问题想清楚,用原生 JS 写一遍,再换任何框架都会顺手很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询