首页 / 资讯中心 / 文章详情

Vue 3自定义视频播放器控制器:从进度条到全屏的完整实现

Vue 3自定义视频播放器控制器:从进度条到全屏的完整实现 ★ FEATURED ARTICLE
没做前端播放器之前我以为视频播放器的“自定义controller”就是把原生controls藏掉自己写个播放/暂停按钮盖在video上面再把进度条换根好看点的横条就完事了。直到某次接手一个某公司的视频详情页项目需求清单里有一条扎眼的描述“控制条不能用原生样式进度条改成胶囊形要带实时预览和拖拽跳转还要倍速、音量、全屏。”我才意识到一个可复用的“vue自定义video controller”要处理的不只是按钮摆放而是一整套围绕video元素的事件链路、状态同步和边界情况。这篇文章我把这个项目的完整实现思路拆出来讲。重点覆盖为什么原生controls在真实业务里撑不住、播放器组件怎么拆分、进度条和缓冲条的数据链路怎么打通、音量倍速全屏这些辅助控件各自的实现细节以及我在实测里踩过的兼容性坑。适合正在用Vue 3做视频站、直播间、后台媒体库或者想把播放器皮肤彻底改造成自定义风格的同学参考。代码基于Vue 3 script setup编写读起来只需要你有基础的Vue组件和HTML5 video事件认识。1. 为什么放着现成的controls不用原生播放器控制条的四个硬伤先说个结论如果项目只是临时放个视频video controls完全够用。但一旦产品经理开始跟你谈“控制条风格要融入整体UI”、“播放器右下角要加个按钮”、“进度条点击手感要优化”原生controls基本就是死路一条。我整理了一下硬伤主要集中在四个地方。1.1 不同浏览器渲染出来的控制条长得完全不一样原生controls的UI不是网页开发者能控制的它取决于浏览器本身。同一段视频在桌面端浏览器A、浏览器B、移动端C里展示出来播放按钮、进度条、音量滑块的位置和样式各有各的形态。如果你的页面设计稿里有统一配色、统一圆角、统一毛玻璃效果原生控件一个都满足不了。更麻烦的是不同浏览器对原生控件的CSS覆盖能力也不一样有的伪元素能改有的改不动最后只能在“凑合用原生”和“彻底重写”之间二选一。项目UI一旦对播放器有强风格要求重写就不可避免。1.2 原生控件的功能面太窄原生控件只能做播放/暂停、时间显示、进度拖动、音量调节和全屏。但真实业务里需要的东西远不止这些多倍速切换0.75x、1.5x、清晰度切换、自动播放策略、海报图点击播放、播放结束后的Replay按钮、加载中的转圈loading、直播流的低延迟模式这些原生控件一个都不提供。你只能在controls之外额外叠加一层浮层按钮去补但浮层按钮和原生控件的样式又很难统一交互上还容易互相遮挡。与其缝缝补补不如从一开始就把它做成完全可控的自定义controller。1.3 关键状态拿不到联动无从谈起用原生controls时你很难拿到一些关键状态来驱动页面其他区域。比如缓冲进度是多少视频是不是已经播放结束当前倍速是多少用户是不是进全屏了这些数据虽然可以通过监听video原生事件拿到但原生控件本身不会把这些状态暴露成方便你绑定的数据字段。这就导致播放器旁边的“猜你喜欢”列表没法跟播放进度联动页面的分享按钮没法感知到播完时机数据埋点也做不了。也就是说你想要的可视化控制背后必须有一套自己能完全掌控的状态层而自定义controller的本质就是在Vue里把video的状态“接管”过来。1.4 样式细节没法做到像素级最后再补一刀即使你只想微调原生控件比如把进度条改细一点把时间字体改小不同浏览器的实现差异就会劝退你。浏览器A的进度条是::-webkit-media-controls-progress-bar浏览器B的进度条又是另一套结构。调不了多久你就会发现时间成本全花在逆向浏览器的私有样式上。而自定义controller配合Flex布局和CSS变量进度条要多粗多圆、控制条背景要渐变还是毛玻璃全部由你决定跟视频元素本身彻底解耦。下面开始说正路怎么在Vue里把这个自研controller搭起来。2. 组件骨架把播放器状态从一堆DOM事件里抽出来自定义controller最容易翻车的方式是所有控制逻辑都堆在播放器外层组件里一个.vue文件里同时塞video、按钮、进度条、音量滑块的DOM事件结果几百行之后自己都找不到状态在哪。2.1 组件树怎么拆一层壳、一条控制条、一组独立小块我在项目里的拆法是一个PlayerContainer壳组件加一个PlayerControls控制条组件控制条内部再拆成ProgressBar、TimeDisplay、VolumeControl、PlayButton、RateMenu、FullscreenButton这些独立小块。壳组件负责持有video元素、加载视频、监听原生事件、维护播放器状态控制条组件负责显隐逻辑和布局每个小块只做一件事。这样拆的好处是进度条需要频繁更新如果它和其他按钮放在一个组件里整个控制条都会跟着频繁重渲染。拆开之后进度条自己更新自己音量滑块自己维护自己的UI状态互不干扰。实际项目中这个收益非常明显尤其是拖动进度条时整个控制条不卡顿。2.2 状态层一个useVideoPlayer组合式函数我建议把video元素相关的所有状态和操作方法收敛到一个组合式函数里通过provide/inject向下分发。这样任何子组件都不用直接去document.querySelector(video)而是统一从播放器上下文拿状态和方法。核心状态我一般维护这些字段// useVideoPlayer.js import { ref, reactive, provide } from vue; export function useVideoPlayer(videoRef) { const playerState reactive({ currentTime: 0, // 当前播放秒数 duration: 0, // 总时长 buffered: 0, // 缓冲百分比 0-100 playing: false, // 是否播放中 ended: false, // 是否播完 volume: 1, // 音量 0-1 muted: false, // 是否静音 rate: 1, // 播放速率 waiting: false // 是否在缓冲等待 }); const setCurrentTime (time) { if (videoRef.value) { videoRef.value.currentTime time; } }; const togglePlay () { if (!videoRef.value) return; if (videoRef.value.paused) { videoRef.value.play(); } else { videoRef.value.pause(); } }; provide(player, { playerState, videoRef, togglePlay, setCurrentTime }); }你看播放按钮、进度条、键盘快捷键用的都是同一套playerState这样就不会出现“进度条组件里自己维护了一份currentTime跟video实际播放位置对不上”的问题。整个Controller的核心原则就一句话所有UI状态都由这一份playerState派生video DOM永远是唯一数据源但不直接跟子组件对话。2.3 video原生事件的分发模板绑定还是手动监听video元素上有一堆原生事件比如loadedmetadata、timeupdate、progress、waiting、playing、ended、volumechange。在Vue模板里你可以直接这样写video refvideoRef :srcsrc loadedmetadataonLoadedMetadata timeupdateonTimeUpdate progressonProgress playingonPlaying waitingonWaiting endedonEnded volumechangeonVolumeChange /然后在script里集中处理这些事件把它们映射到playerState上const onLoadedMetadata () { playerState.duration videoRef.value.duration || 0; }; const onTimeUpdate () { playerState.currentTime videoRef.value.currentTime; playerState.ended false; }; const onProgress () { const video videoRef.value; if (video.buffered.length 0) { const bufferedEnd video.buffered.end(video.buffered.length - 1); playerState.buffered (bufferedEnd / video.duration) * 100; } }; const onEnded () { playerState.ended true; playerState.playing false; };这种写法最直白所有事件集中在一个文件里管理排查问题时顺着事件名找就行。需要注意的一点是不要在模板事件回调里写复杂的业务逻辑事件回调只负责更新state组件通过state的响应式变化去驱动UI。如果直接把DOM读写逻辑堆在模板里后期一要加埋点、减交互改起来会非常痛苦。3. 进度条与缓冲条从timeupdate到像素级拖拽的完整实现进度条是自定义controller里最核心、也最容易出细节问题的地方。它看起来只是一条横线实则需要处理好数据更新频率、坐标换算、拖拽与播放冲突三层问题。3.1 数据链路播放进度和缓冲进度怎么进状态播放进度的来源是timeupdate事件它本身足够准确但触发频率并不高大概每250毫秒触发一次。250毫秒在数据上是准的放在UI里却会有肉眼可见的“一顿一顿”感尤其是进度条宽度百分比变化的时候。你直接width: currentTime / duration * 100%就会发现进度条像秒针一样一格格跳而不是视频播放器该有的平滑推进感。处理办法是把时间差值过渡交给CSS。进度条内部实际进度条用transition: width 0.2s linear;这0.2秒刚好覆盖掉timeupdate的间隔视觉上就平滑了。不用起定时器也不用requestAnimationFrame成本最低。如果你需要更高精度的UI进度可以在timeupdate之外再用requestAnimationFrame循环读取video.currentTime但实测下来普通业务场景没必要CSS过渡足够。缓冲进度使用progress事件它在视频加载数据时周期性触发。注意video.buffered是一个TimeRanges对象可能有多个分段。简单场景下直接取最后一段的末尾作为整体缓冲进度就已经足够const onProgress () { const video videoRef.value; if (!video.duration) return; if (video.buffered.length 0) { const bufferedEnd video.buffered.end(video.buffered.length - 1); playerState.buffered Math.min(100, (bufferedEnd / video.duration) * 100); } };把buffered和played做成两层横条底层是半透明灰表示缓冲上层是主题色表示已播放再在上层右边放一个圆形手柄视觉上就是标准播放器了。3.2 把鼠标坐标换算成播放时间一套可以直接抄的公式用户点击或拖动进度条时我们需要把鼠标在页面上的clientX换算成0到1的百分比再乘以时长得到目标秒数。换算公式看起来简单但有个重要细节不能用event.target的坐标而要用进度条容器的getBoundingClientRect()来计算否则你点到进度条内部某个子元素上时坐标基准就错了。const progressRef ref(null); function computePercentFromEvent(e) { const rect progressRef.value.getBoundingClientRect(); const clickX e.clientX - rect.left; let percent clickX / rect.width; percent Math.min(1, Math.max(0, percent)); return percent; }这里Math.min和Math.max是必须的因为鼠标在进度条容器外面按下去、拖回来再松开时百分比可能超过0到1的范围不钳制就会出现currentTime越界、进度条溢出的小bug。百分比转成时间就一行const targetTime percent * playerState.duration然后通过video.currentTime targetTime让视频跳转。不过要注意duration在loadedmetadata之前可能是NaN所以点击处理函数里要加一层防御duration不可用时就忽略这次点击。3.3 拖动与播放的冲突不做好这一步进度条会“打架”进度条最容易出现的体验问题是用户按住手柄拖动时视频仍在播放timeupdate事件也在实时更新playerState.currentTimeUI刚被你拽到60%的位置下一帧又因为播放进度回到55%手柄就像在跟你的手指打架。解决办法是引入一个dragging状态拖动过程中进度条UI只跟随手指坐标不响应播放进度更新手指松开时一次性写入video.currentTime。完整的Pointer事件处理逻辑如下const isDragging ref(false); let dragPreviewTime 0; function onPointerDown(e) { isDragging.value true; progressRef.value.setPointerCapture(e.pointerId); updateDragProgress(e); } function onPointerMove(e) { if (!isDragging.value) return; updateDragProgress(e); } function onPointerUp(e) { if (!isDragging.value) return; isDragging.value false; setCurrentTime(dragPreviewTime); } function updateDragProgress(e) { const percent computePercentFromEvent(e); dragPreviewTime percent * playerState.duration; }然后模板里进度条外层容器绑定pointerdown、pointermove、pointerup内层已播放条宽度这样算div refprogressRef classprogress pointerdownonPointerDown pointermoveonPointerMove pointeruponPointerUp div classprogress__buffer :style{ width: playerState.buffered % }/div div classprogress__played :style{ width: (isDragging ? (dragPreviewTime / playerState.duration) * 100 : (playerState.currentTime / playerState.duration) * 100) % } /div div classprogress__thumb :style{ left: (isDragging ? (dragPreviewTime / playerState.duration) * 100 : (playerState.currentTime / playerState.duration) * 100) % } /div /div这个isDragging分支是整套拖拽逻辑的命门。没有它播放和拖拽就会互相抢控件有了它拖动过程中整个进度条只认手指松开后才把控制权交还给video。如果你需要拖拽时实时预览画面拖动过程中让视频画面跟随预览帧可以在onPointerMove里用video.currentTime同步跳转但会频繁触发解码一般本地小视频没问题大体积视频或流媒体不建议这么做用预览帧图片代替更好。3.4 点击和拖拽同时存在时的判断如果同一容器既处理点击跳转又处理拖拽跳转很容易出现“点一下视频跳了松开时又跳了一次”的问题。Pointer事件的处理逻辑里我习惯只用pointerdown/pointerup来做完整的一次交互按下记录起始坐标移动时如果位移超过阈值就进入拖拽模式松开时无论点击还是拖拽都统一apply时间。实际项目里更省事的方案是容器只处理pointerdown然后setPointerCapture把后续pointermove和pointerup都捕获到容器自身。这样不用监听document也避免其他地方触发事件时干扰。阈值判断可以这样加在onPointerDown记录startXonPointerMove里如果Math.abs(e.clientX - startX)大于3像素才进入拖拽状态。这样点击和拖拽就能共存用户轻点一下立即跳转按住拖动则精细控制。4. 控制条辅助控件时间、音量、倍速、全屏的分工进度条搞定之后控制条还缺几个标准件播放/暂停按钮、时间显示、音量控件、倍速菜单、全屏按钮。这些控件相对独立但每个都有值得注意的细节。4.1 播放/暂停别忘了play()返回一个Promise播放按钮的逻辑不复杂但有一个坑很典型video.play()在浏览器自动播放策略限制下可能返回一个被rejected的Promise如果调用play()时不加catch控制台会给你刷一条未捕获的错误。我已经在不止一个项目里见过这个报错原因是用户快速点击播放按钮视频还没加载出来就开始播放播放请求被浏览器拒绝。稳妥的写法是const togglePlay () { const video videoRef.value; if (!video) return; if (video.paused) { const playPromise video.play(); if (playPromise ! undefined) { playPromise.catch(() { // 播放被浏览器拒绝时静默处理UI仍保持待播放状态 playerState.playing false; }); } } else { video.pause(); playerState.playing false; } };配合playing事件更新playerState.playing true播放按钮的图标来源于playing字段而不是直接读写video.paused这样保证UI和数据流始终一致。4.2 时间显示自己写个格式化函数原生控件自带时间显示自定义之后就得自己格式化。时长小于1小时显示mm:ss大于1小时显示h:mm:ss这个规则很好理解function formatTime(seconds) { if (Number.isNaN(seconds) || seconds Infinity) return 00:00; const total Math.floor(seconds); const h Math.floor(total / 3600); const m Math.floor((total % 3600) / 60); const s total % 60; const ss String(s).padStart(2, 0); if (h 0) { const mm String(m).padStart(2, 0); return ${h}:${mm}:${ss}; } return ${m}:${ss}; }注意duration在部分视频元数据还没加载完时可能是NaN或InfinityformatTime里要兜底返回00:00否则页面会出现一个奇怪的NaN:NaN。时间显示组件直接用formatTime(playerState.currentTime)和formatTime(playerState.duration)更新频率跟着timeupdate走就好因为进度条那边已经做了CSS过渡时间显示本身也可以靠transition淡动或者不做效果每秒跳一次其实也符合播放器惯例。4.3 音量控件垂直滑块与静音切换音量控件的核心是video.volume取值0到1。原生controls的音量调节是水平的自定义时做成垂直方向更节省横向空间。实现方式是外层容器hover时展开滑条滑条本身用input[typerange]加writing-mode: vertical之类的CSS旋转或者直接用div做自定义滑块。我这里推荐用div做因为input[typerange]在垂直方向上的细节样式在不同浏览器里依然不太统一。音量状态放到playerState里const onVolumeChange () { const video videoRef.value; playerState.volume video.volume; playerState.muted video.muted; }; const setVolume (v) { const video videoRef.value; video.volume v; video.muted v 0; }; const toggleMute () { const video videoRef.value; video.muted !video.muted; };这里有个细节容易被忽视如果直接把volume赋成一个小于0.05的数用户感觉上已经是静音了但video.muted还是false图标停留在音量小档。所以赋值时要主动判断v 0来设置muted状态确保UI和实际听感一致。音量滑块的交互和进度条拖拽类似也是pointerdown记录、pointermove更新、pointerup结束。完全可以把进度条的拖拽逻辑抽成一个小组合式函数复用给音量和进度条两个组件。不要因为代码量不大就选择复制粘贴拖拽逻辑里setPointerCapture、边界钳制这些细节一旦在两处出现分歧排查起来很折磨。4.4 倍速菜单与全屏切换的实现细节倍速本质上只是设置video.playbackRate。UI上给一组常用档位0.5、0.75、1、1.25、1.5、2。点击某个档位后const setRate (rate) { const video videoRef.value; video.playbackRate rate; playerState.rate rate; };核心里有一个小坑视频播完后有些浏览器会把playbackRate重置回默认值或者用户切换视频源后倍速不生效。所以在loadedmetadata和ended事件里重新应用一下playerState.rate比较稳妥。这个处理很细节但产品使用时很敏感毕竟用户选了1.5倍速结果播下一集又变回1倍速体验会打折扣。全屏切换逻辑虽然只有几行但注意要全屏的是播放器容器而不是video元素本身否则控制条会被全屏的video挡在后面。判断当前是否全屏标准字段是document.fullscreenElement老浏览器可能用document.webkitFullscreenElement。切换方法同理容器上可能同时存在requestFullscreen和webkitRequestFullscreen。兼容写法const toggleFullscreen () { const container containerRef.value; if (!document.fullscreenElement) { if (container.requestFullscreen) { container.requestFullscreen(); } else if (container.webkitRequestFullscreen) { container.webkitRequestFullscreen(); } } else { if (document.exitFullscreen) { document.exitFullscreen(); } else if (document.webkitExitFullscreen) { document.webkitExitFullscreen(); } } };全屏状态还需要监听fullscreenchange事件用来切换全屏/退出全屏图标以及在全屏时隐藏页面其他元素。容器上监听完记得在onBeforeUnmount里移除。5. 实测中容易翻车的兼容与边界问题代码写出来只是第一步真正让自定义controller可用的是把视频领域的各种边界情况处理掉。这一节我从真实项目里挑出几个最常遇到的翻车点。5.1 移动端自动全屏与内联播放如果不处理移动端自定义controller会在手机上直接失灵。移动浏览器对video有一个默认行为点击播放时自动进入系统全屏播放器你的自定义控制条就完全被系统UI覆盖了。要让视频在页面内联播放必须在video标签上加playsinline属性某些移动浏览器还需要兼容webkit-playsinline和x5-playsinlinevideo playsinline webkit-playsinline x5-playsinline :srcsrc /这个属性在桌面端没有副作用建议默认加上。另一个移动端需要注意的点是iOS上视频只有muted状态下才能自动播放所以做自动播放场景时要muted autoplay playsinline一起设置然后默认静音播放用户点击音量按钮才取消静音。5.2 timeupdate频率太低导致的UI卡顿感前面提过timeupdate大约每250ms一次。在快速拖拽进度条时尤其明显手指移动的轨迹是流畅的但进度条已播放宽度因为要等timeupdate更新会出现闪烁、拉扯。这个问题我在3.3里用dragging状态解决了“打架”但UI更新频率上还需要再补一层播放中的平滑推进依赖CSS transition而拖拽跟手速度不依赖timeupdate因为onPointerMove自身的触发频率是远高于250ms的。两手抓下来实测已经很流畅。如果你的项目对进度平滑有更高要求比如体育比赛回放类页面再考虑rAF实时读取currentTime的方案。5.3 拖拽过程中进度条回跳问题还有一个隐蔽的问题拖拽松手时视频跳转需要时间seek但video的timeupdate事件在seek完成前可能先触发一次把currentTime更新成旧的播放位置导致进度条“回跳”一下然后才跳转到新位置。表现就是用户明显看到进度条先回弹再前进观感很差。解决方式是在seek期间忽略timeupdate的UI更新或者在seek后短暂锁定UI进度直到seeking和seeked事件序列完成再解封。我项目里用的方案是监听seeking和seekedconst onSeeking () { seekLocked.value true; }; const onSeeked () { seekLocked.value false; playerState.currentTime videoRef.value.currentTime; };进度条显示宽度时如果seekLocked为true就沿用dragPreviewTime为false时再走正常的currentTime。这个锁只需要加在seek跳转那一下成本很低但对拖拽体验的改善很明显。5.4 播放结束与重播的状态复位视频播放结束时ended事件触发。如果不做处理控制条会停在“播放中”的UI状态播放按钮还是暂停图标进度条虽然到头了但状态不彻底。我在ended事件里把playing置为false、ended置为trueUI层就可以根据ended状态显示成“重播按钮”进度条显示完全走完。点击重播时把currentTime归零再调playconst replay () { const video videoRef.value; video.currentTime 0; video.play(); };playerState里的ended要记得在timeupdate里重置为false不然重播后ended状态仍然残留UI会继续展示重播图标。5.5 元数据未加载完成时的NaN与Infinity防御在loadedmetadata触发之前video.duration可能是NaN更长尾的情况下比如未缓冲完成的流媒体可能是Infinity。这类值一旦参与计算进度条百分比会算出NaN时间显示会变成NaN:NaN然后整个控制条数字区域就“坏了”。我的习惯是所有涉及duration的计算入口都做一层防御duration不是有限数时进度条点击不生效进度条宽度为0时间显示回退00:00。别指望每个视频源都会及时触发loadedmetadata有些慢网速场景下用户打开页面时video标签可能先拿到一个不完整header这个防御必须做。6. 打磨细节控制条显隐、键盘操作与后续扩展功能型闭环跑通之后剩下的工作是把控制条从“能用”做到“好用”。我最后加一层细节打磨这些内容也是区分自研播放器专业度的关键。6.1 控制条自动隐藏与显隐过渡视频播放时控制条如果一直亮着会很干扰视线。主流播放器的做法是鼠标在播放器区域内移动时显示控制条静止3秒左右自动隐藏暂停视频时始终显示控制条。实现上需要一个计时器let hideTimer null; function showControls() { controlsVisible.value true; clearTimeout(hideTimer); hideTimer setTimeout(() { if (playerState.playing !playerState.waiting) { controlsVisible.value false; } }, 3000); }鼠标在视频容器上mousemove时调用showControls。控制条显隐用Vue的transition做透明度淡出淡入配合一个底部渐变遮罩层视觉上很自然。有一个细节控制条本身如果也在鼠标hover范围内建议鼠标停在控制条上时不要自动隐藏否则用户想点倍速菜单时控制条突然消失体验很糟。做法是控制条容器监听mouseenter清除计时器、mouseleave重新计时。6.2 键盘快捷键空格、方向键、音量快捷键播放器除了鼠标操作键盘快捷键也是专业度的一部分。在播放器容器获得焦点时绑定keydown事件空格播放/暂停左方向键后退10秒右方向键前进10秒上/下方向键音量加减M静音实现时注意一个问题空格键在页面上通常会触发滚动所以要e.preventDefault()。如果同时按了M键或者方向键浏览器还可能对页面做其他默认操作统一拦截掉。播放器容器上加上tabindex0才能让它获得键盘焦点否则快捷键只在用户点击过控制条之后才可用。6.3 可插拔的扩展位字幕、画中画、清晰度自研controller最爽的地方是功能可以无限叠加。我在组件设计时会给控制条预留一个slot扩展区放在时间显示和全屏按钮之间这样后续加字幕开关、画中画、清晰度切换菜单都不用改已有控件代码div classcontrols PlayButton / ProgressBar / TimeDisplay / div classcontrols__extra slot nameextra/slot /div VolumeControl / FullscreenButton / /div画中画功能浏览器原生API就已经支持调用video.requestPictureInPicture()即可但注意它只在支持该特性的浏览器里可用需要先判断document.pictureInPictureEnabled。清晰度切换本质就是切换video的src并维持当前播放位置切完等loadedmetadata后再重新seek。6.4 一次实测后的体会这个项目到后期我发现真正消耗时间的不是每个控件怎么写而是控件的状态一致性。比如倍速和拖拽同时发生时进度条锁是非要不可全屏切换后控制条层级在容器的最上层容器本身要套一个position: relative播放结束立即切换视频源时旧视频的异步事件可能被新视频的事件覆盖所以切换源前应该主动解绑或清空状态。如果你也是从原生controls往自定义controller迁移我的建议是先别急着把组件树拆得很细。先用最朴素的方式在一张页面上把video事件、进度条、播放按钮这条路走通确认你能完整控制视频状态之后再动手把控制条拆成组件。这样可以避免在组件通信还没理清的时候就陷入“进度条组件改不了视频时间”“音量组件拿不到video引用”这类泥潭。等第一版自研controller在你项目里稳定跑起来你会发现后续加任何新功能都只是在已有状态层上多接一个组件而已。
阅读完成 · 觉得有帮助?
咨询建站