ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Vue3实战:Vue Router与Pinia从入门到m3u8视频播放

Vue3实战:Vue Router与Pinia从入门到m3u8视频播放 人家都说 Vue 上手快但快不等于不用学路由和状态管理。Day5 咱们就干两件事把 Vue Router 路由玩明白再顺手把 Pinia 状态管理学了最后用一个 m3u8 视频播放页把这两块串起来。这套组合拳打完你就能做出“多页面切换 数据跨组件共享”的正经项目了不管你是刚看完组件通信的小白还是学过 Vuex 但没跟上 Pinia 的老手今天这篇都值得花十分钟过一遍。1. 开始前的准备确认环境和依赖1.1 先检查你的项目版本写代码之前我习惯先确认三件事Node 版本、包管理器、Vue/Vite 版本。很多小白在后续安装依赖时报错八成是卡在环境这里。打开终端依次执行node -v npm -vNode 版本建议 16 以上低于这个版本跑 Vite 5 或更新版本时经常报require is not defined这类看不懂的错。如果你用的是 pnpm注意版本 7 以上比较稳妥如果你的项目是用 Vite 创建的默认 npm 就行后面命令我都用 npm 写。确认完环境我们进项目目录。如果你是从 Day1 一路跟下来的项目里应该已经有了src/views、src/components这些目录。如果没有也没关系后面会一并创建。1.2 安装 vue-router 和 piniaDay5 要装两个核心依赖直接在项目根目录跑npm install vue-router4 pinia如果你是用npm create vitelatest命令创建的项目Vue 版本默认是 3.x对应vue-router必须装 4.x 版本这一点特别重要。我见过不少同学装了vue-router3结果代码里用createRouter时直接报错因为 vue-router 3 是配合 Vue 2 的 API。安装完可以用npm list vue-router pinia检查依赖树确认版本没问题再往下走。1.3 规划 Day5 的目录结构依赖装好之后我们规划一下今天的代码组织。我习惯在src下面建router和stores两个目录这是 Vue 项目的通用约定不管是一个人写小项目还是团队协作看到目录就知道路由和状态管理文件放在哪。src/ ├── views/ # 页面级组件 │ ├── Home.vue │ ├── About.vue │ └── VideoPlay.vue ├── router/ │ └── index.js # 路由配置文件 ├── stores/ │ └── video.js # Pinia 状态管理文件 ├── App.vue └── main.js这样规划完后面写路由配置的时候views负责页面router负责跳转规则stores负责数据共享各司其职。接下来进入今天的重头戏。2. 路由让页面真正“跳”起来2.1 为什么需要路由在没学路由之前你可能也做过一个页面里用v-if切换不同区块的效果比如点按钮显示首页、再点一下显示详情。这样做小案例没问题但一旦页面多了代码就变得又臭又长而且浏览器的前进后退按钮完全失灵用户体验很差。路由解决的问题本质上就是“把 URL 和页面组件对应起来”。当你想看/home时浏览器展示 Home 组件想看得/play/123时展示 VideoPlay 组件而且浏览器地址栏可以直接分享、刷新后页面还能恢复。你可以把路由理解成一个地图导航URL 是目的地路由配置是导航路线router-view 就是最终展现目的地的窗口。Vue Router 具体做了三件事监听 URL 变化解析对应的路由记录根据路由记录匹配到组件把匹配到的组件渲染到router-view中2.2 创建路由实例并注册到应用里现在我们动手写第一个路由配置。打开src/router/index.js输入以下代码import { createRouter, createWebHistory } from vue-router import Home from ../views/Home.vue import About from ../views/About.vue import VideoPlay from ../views/VideoPlay.vue const routes [ { path: /, name: home, component: Home }, { path: /about, name: about, component: About }, { path: /play/:id, name: play, component: VideoPlay, props: true } ] const router createRouter({ history: createWebHistory(), routes }) export default router这里有一个关键函数createWebHistory()它生成的是 HTML5 History 模式URL 长这样http://localhost:5173/play/123。还有一种createWebHashHistory()模式URL 会是http://localhost:5173/#/play/123。对于小白来说我建议开发阶段先用createWebHistory但你要记住打包部署到服务器后如果刷新页面出现 404多半就是 History 模式没配置后端资源这个坑在第 5 章我会专门讲。配置好 router 文件之后回到src/main.js把路由注册到应用实例import { createApp } from vue import { createPinia } from pinia import App from ./App.vue import router from ./router const app createApp(App) app.use(createPinia()) app.use(router) app.mount(#app)注意我在这里顺便注册了 Pinia因为下一步它就要派上用场。注册顺序无所谓但必须在mount之前完成。然后在App.vue里把原来的组件换成路由出口template router-view / /templaterouter-view就是路由组件展示的地方你访问/时它会渲染 Home访问/about时渲染 About。2.3 页面跳转与传参的三种姿势路由配好了接下来就是实际跳转。Vue Router 里跳转方式有两种模板中声明式跳转和 JS 编程式跳转。先看模板里最常用的方式用router-link替代原生的a标签template nav router-link to/首页/router-link router-link to/about关于我们/router-link router-link :to{ path: /play/123 }播放视频/router-link /nav /template有一点要注意router-link默认会给当前激活的路由加上router-link-active类名所以你在写导航高亮样式时不用自己判断当前路由直接基于这两个类写 CSS 就行。如果需要在方法里跳转用编程式导航import { useRouter } from vue-router const router useRouter() function goPlay(id) { router.push({ path: /play/${id} }) }再说说传参。路由传参我总结为三种姿势每种都有自己的使用场景第一种query 参数适合传搜索条件、页码、过滤条件URL 上直接可见刷新后依然保留。router.push({ path: /about, query: { from: home } })接收时用route.query.from就能拿到。第二种动态路由参数适合传实体的唯一标识比如视频 id、用户 id。在路由配置里用:id声明跳转时 URL 变成/play/123。// 路由配置里已经写了 path: /play/:id router.push({ name: play, params: { id: 123 } }) // 组件接收 const route useRoute() console.log(route.params.id) // 输出 123这里有个特别容易踩的坑如果使用params传参且目标路由是下面这种静态路径{ path: /play, name: play, component: VideoPlay }你router.push({ name: play, params: { id: 123 } })后刷新页面参数会丢失。所以动态参数一定要用/play/:id这种动态路径格式参数才会体现在 URL 里刷新不丢。第三种props 传递在路由配置里给组件设置props: true那么路由参数会直接变成组件的 props 属性使用起来更符合 Vue 的组件开发习惯。// VideoPlay.vue defineProps({ id: { type: String, required: true } })这样一来你不需要在组件里写route.params.id直接当普通 props 用就好。这个写法在我后面讲的视频播放页里会用到。2.4 嵌套路由页面里还有页面有时候你会遇到这样的布局一个后台管理系统顶部是导航左侧是菜单右侧是内容区。这种场景就需要嵌套路由。父路由对应一个布局组件里面放一个router-view子路由渲染到这个router-view里。在路由配置里用children属性来声明const routes [ { path: /admin, component: () import(../views/AdminLayout.vue), children: [ { path: , redirect: /admin/dashboard }, { path: dashboard, component: () import(../views/Dashboard.vue) }, { path: user, component: () import(../views/AdminUser.vue) } ] } ]注意子路由的 path 不需要写/前缀否则会被当成绝对路径匹配就会出错。在AdminLayout.vue的模板里放router-view /就能接收子路由的组件了。关于组件按需加载上面的配置里我用的是动态导入() import(...)这样做的好处是项目打包时会把每个路由对应的组件单独拆成一个 chunk首屏加载时只下载当前页面需要的文件。这是实际项目中默认优化选项小白可以直接照着写。2.5 路由守卫跳转前的安全检查前面几个热词里反复出现的“vue路由拦截器”就是这里要讲的路由守卫。它的作用是在路由跳转前、跳转后或者跳转过程中插入一段逻辑常见用途是登录状态校验比如“未登录用户点击个人中心强制跳回登录页”。Vue Router 提供三种守卫我平时最常用的是全局前置守卫beforeEachrouter.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next({ path: /login, query: { redirect: to.fullPath } }) } else { next() } })在路由配置里给需要登录的页面加上 meta 标记{ path: /admin, component: () import(../views/AdminLayout.vue), meta: { requiresAuth: true }, children: [ { path: dashboard, component: () import(../views/Dashboard.vue) } ] }beforeEach接收三个参数to即将跳转到的路由对象里面有 path、params、query、meta 等信息from当前离开的路由对象next放行函数只有调用next()才会继续跳转特别注意一定要确保next()被调用且只调用一次否则组件可能卡在加载状态或重复渲染。如果你不想用next也可以直接返回一个路由地址比如return { path: /login }效果是一样的这种写法在新版的 Vue Router 4 里更推荐。守卫是异步函数提示如果你在守卫里做异步请求比如发起一个刷新 token 的请求记得用async/await或者返回 Promise确保跳转等待异步完成。router.beforeEach(async (to, from) { if (to.meta.requiresAuth) { const loginStatus await checkLoginStatus() if (!loginStatus) return { path: /login } } return true })路由守卫除了全局前置守卫还有全局后置守卫afterEach和组件内守卫beforeRouteEnter等但小白阶段掌握beforeEach足够应对绝大多数拦截场景。3. 状态管理Pinia 让数据“共享”变简单3.1 什么时候真正需要状态管理前端项目里经常遇到“组件 A 修改了数据组件 B 也要同步更新”的需求。有人说用组件通信不就行了吗话是没错但组件通信的props和emit只适用于父子组件如果两个毫无嵌套关系的组件需要共享同一个数据通信链路就会变成“传参接力”一旦页面层级深了代码维护成本极高。状态管理的核心思路是把共享数据抽离到一个独立的地方任何组件都可以直接读写数据变化时所有引用了它的组件自动更新。我常在社区里看到有人问“到底什么时候用 Pinia”我的经验是多个页面共享用户登录信息购物车商品数量在多个页面同步全局主题切换、语言包切换视频播放进度跨页面保存只要满足“跨组件 跨页面 需要响应式”这三个条件就适合用状态管理。而如果只有一个组件内部的数据完全没必要造一个 store本地 ref 就够。3.2 定义你的第一个 Pinia storePinia 的 API 设计比 Vuex 简洁多了安装后不用像 Vuex 那样写一堆 namespace 和 mutations直接用defineStore定义即可。打开src/stores/video.jsimport { ref, computed } from vue import { defineStore } from pinia export const useVideoStore defineStore(video, () { // state const currentId ref() const currentTime ref(0) const historyList ref([]) // getter const lastWatchTime computed(() { const last historyList.value[historyList.value.length - 1] return last ? last.time : 0 }) // action function setCurrent(id, time) { currentId.value id currentTime.value time const index historyList.value.findIndex(item item.id id) if (index -1) { historyList.value[index].time time } else { historyList.value.push({ id, time }) } } function clearHistory() { historyList.value [] currentId.value currentTime.value 0 } return { currentId, currentTime, historyList, lastWatchTime, setCurrent, clearHistory } })这里用的是 setup 语法写 store算是 Pinia 里比较现代的风格本质上就是把 Vue 3 的ref、computed、函数组合到一起。对比一下 Vuex 的写法Pinia 少了很多模板代码没有 mutationsactions 直接修改状态没有命名空间嵌套一个文件就是一个模块不需要mapState、mapGetters这类映射辅助函数3.3 在组件里读写 store小心响应式丢失在组件中使用 store直接调用useVideoStore即可script setup import { useVideoStore } from ../stores/video const store useVideoStore() // 读取 console.log(store.currentTime) // 修改 store.setCurrent(123, 30) /script需要注意直接访问store.currentTime是响应式的但如果你解构出来再访问响应式就会丢失。我在实际开发中见过太多次这种 bug 了// 错误示例解构后数据变化页面不会更新 const { currentTime, historyList } store正确做法是用storeToRefs来解构这一步相当于保留 ref 的引用import { storeToRefs } from pinia const { currentTime, historyList } storeToRefs(store)哪些需要解构、哪些不能解构简单记一个口诀state 和 getter 用storeToRefs包裹后再解构actions 方法直接解构因为方法本身绑定在 store 实例上即使解构出来调用内部的 this 依然指向 store所以const { setCurrent } store是没问题的。3.4 Pinia vs Vuex新手怎么选既然热词里很多人搜“vue pinia vs vuex”这里直接给结论新项目推荐 Pinia老项目如果已经上了 Vuex 4也没必要强行重写。下面是我个人的对比表对比维度PiniaVuex 4学习成本低几乎没有新概念高必须理解 state、getters、mutations、actions、modulesTypeScript 支持原生支持类型推导很流畅需要额外的类型体操修改状态直接在 actions 或组件中修改 state修改 state 必须走 mutations模块化多个 store 文件即多个模块modules 嵌套结构层级深了难维护DevTools支持可查看时间旅行支持但配置较弱适用场景Vue 3 新项目、新手学习Vue 2 项目、已用 Vuex 的存量项目一句话总结Vuex 里最让人头疼的 mutations 和 actions 的划分在 Pinia 中被彻底去掉因为你已经可以直接修改 state没有必要再套一层“提交”。如果你是从 Vue 2 转过来的老开发放下 Vuex 的思维包袱用 Pinia 反而更接近 Vue 3 的响应式本质。4. 练手实战用 Vue Router Pinia 做一个 m3u8 视频播放页4.1 需求拆解从列表页点击进入播放页把前面的路由和状态管理串起来我们做一个视频播放页的实战。最终效果是首页有一个视频列表点击某个视频后进入播放页播放页根据 URL 中的视频 id 加载对应的 m3u8 视频流同时记住你上次播放到的位置。很多人在热词里搜“vue播放m3u8”说明实际项目中确实有播放 HLS 直播流或者点播流的需求。m3u8 是一种基于 HLS 协议的媒体播放列表文件里面存着一段一段的.ts分片文件地址浏览器原生的 video 标签并不能直接播放所有平台的 m3u8 流所以我们需要借助 hls.js 这个库。先安装依赖npm install hls.js4.2 接入 hls.js 实现视频播放在src/views/VideoPlay.vue中写播放器代码template div classvideo-play video refvideoRef controls classvideo-player/video button clickresumePlay从上次进度继续播放/button /div /template script setup import { ref, onMounted, onBeforeUnmount } from vue import Hls from hls.js import { useRoute } from vue-router import { useVideoStore } from ../stores/video const props defineProps({ id: { type: String, required: true } }) const route useRoute() const videoRef ref(null) const store useVideoStore() let hls null // 实际开发中这个地址通常由后端根据 id 返回 const videoSrcMap { 123: https://example.com/streams/123.m3u8, 456: https://example.com/streams/456.m3u8 } function initVideo() { const video videoRef.value if (!video) return const src videoSrcMap[props.id] if (Hls.isSupported()) { hls new Hls({ // 出错后自动重试次数 fragLoadMaxRetry: 5, // 低延迟模式 lowLatencyMode: false }) hls.loadSource(src) hls.attachMedia(video) hls.on(Hls.Events.MANIFEST_PARSED, () { const saved store.historyList.find(item item.id props.id) if (saved) { video.currentTime saved.time } }) } else if (video.canPlayType(application/vnd.apple.mpegurl)) { // Safari 原生支持 HLS video.src src video.addEventListener(loadedmetadata, () { const saved store.historyList.find(item item.id props.id) if (saved) { video.currentTime saved.time } }) } } function resumePlay() { const video videoRef.value const saved store.historyList.find(item item.id props.id) if (saved) { video.currentTime saved.time } video.play().catch(() { // 部分浏览器要求先静音才能自动播放 video.muted true video.play() }) } function onTimeUpdate() { const video videoRef.value if (video props.id) { store.setCurrent(props.id, video.currentTime) } } onMounted(() { initVideo() videoRef.value?.addEventListener(timeupdate, onTimeUpdate) }) onBeforeUnmount(() { hls?.destroy() videoRef.value?.removeEventListener(timeupdate, onTimeUpdate) }) /script我解释一下这段代码的几个关键设计hls.destroy()必须在组件卸载时调用否则 hls 实例会一直占用内存页面切换多次后可能卡顿。这是用 hls.js 最容易忽略的点。video.addEventListener(timeupdate, onTimeUpdate)用来持续监听播放时间变化这里专门把函数抽出来是为了在onBeforeUnmount时能准确触发removeEventListener避免内存泄漏。自动播放策略现代浏览器有严格的自动播放限制绝大多数情况下无用户手势且带声音的视频没法直接play()。所以这里做了一层兜底先正常尝试播放如果被拒绝就设置 muted 后再播一次用户点按钮解除静音即可。4.3 用路由参数定位视频 用 Pinia 保存进度这个实战里的两条主线正好呼应前面两个章节路由负责“定位到哪个视频”Pinia 负责“视频看到哪里了”。当我注册路由时写了props: true这样route.params.id会自动映射成组件的idprop组件里就不需要再手动去route.params取值了。这是一个非常实用的组合用法。而 Pinia 在这里扮演的是“跨页面记忆卡”。你从播放页退回首页再进入同一个视频播放器初始化后可以直接从store.historyList里找到上次播放时间跳转到对应位置。如果用组件内 ref 保存这个数据在页面销毁时就会被回收无法跨页面保留。结合上一章的storeToRefs如果首页要显示“上次看到 xx 分钟”的进度文字也可以直接用 store 里的数据这比通过路由参数传参要优雅得多script setup import { storeToRefs } from pinia import { useVideoStore } from ../stores/video const store useVideoStore() const { historyList } storeToRefs(store) /script4.4 实战中的排坑记录这块我单独提一下因为 m3u8 播放页在实际开发中问题特别集中问题一跨域报错浏览器加载 m3u8 及其内部的.ts分片时要求视频服务器在响应头中带上Access-Control-Allow-Origin才能跨域访问。如果后端没法改 CORS 头你可以在 Vite 开发环境配置代理把视频请求转发到同源地址。// vite.config.js export default { server: { proxy: { /api: { target: https://your-video-server.com, changeOrigin: true, rewrite: path path.replace(/^\/api/, ) } } } }这样视频地址写/api/streams/123.m3u8请求就会在同源下转发出去浏览器就不会报跨域错误了。注意生产环境依然需要服务器或其他方式处理 CORS这个不是仅仅开发配置能解决的。问题二hls.js 解码报错Hls.Events.ERROR事件中如果fatal为 true说明遇到了致命错误常见的有networkError、mediaError。网络问题可以调用hls.startLoad()重试但mediaError通常意味着视频编码格式与浏览器不兼容需要后端转码成 hls.js 支持的 H.264 编码格式。hls.on(Hls.Events.ERROR, (event, data) { if (data.fatal) { switch (data.type) { case Hls.ErrorTypes.NETWORK_ERROR: hls.startLoad() break case Hls.ErrorTypes.MEDIA_ERROR: hls.recoverMediaError() break default: hls.destroy() break } } })问题三切换视频地址后播放器不刷新如果复用了同一个播放器组件只是路由参数从123变成456Vue 因为“组件可以复用”的机制默认不会重新创建组件甚至不会执行onMounted。解决办法是在组件里监听 id 的变化import { watch } from vue watch(() props.id, (newId) { hls?.destroy() store.currentId store.currentTime 0 initVideo() })5. Day5 必踩的坑路由与状态管理的自检清单5.1 刷新后 404 和打包后页面白屏这两个问题放在一起说因为根源是同一个路由模式与后端配置不匹配。先看刷新后 404。如果你的路由用了createWebHistory()模式开发环境没问题但部署到 nginx 后输入http://your-server.com/play/123直接刷新nginx 会去磁盘找play/123这个路径文件找不到就返回 404。解决方案是配置 nginx 的 try_fileslocation / { try_files $uri $uri/ /index.html; }如果你暂时没条件改 nginx最简单的办法是把路由模式改成createWebHashHistory()URL 中的#会让请求始终落在根路径的 index.html 上刷新就不会 404 了。缺点是不够美观但对小白来说先把项目跑起来更重要。再看到打包后页面白屏。通常原因是资源路径写死成了绝对路径/assets/xxx.js但部署环境的子路径并不是根域。解决方法是修改vite.config.jsexport default { base: ./ }这样打包出来的资源引用会变成相对路径在任意子路径部署都能正常加载。5.2 解构后响应式丢失这个坑我在前面已经打过预防针了但值得单独再强调一次。Pinia 的 state 如果直接解构就是“一次性快照”之后 store 里的数据不管怎么变解构出来的变量都纹丝不动。排查这类问题有一个技巧在控制台打印解构后的变量和store内部的值如果发现两者不一致那一定是响应式连接断了。解决方式就用storeToRefs包裹再解构或者在模板里始终通过store.xxx访问避免解构。Vue Router 里的route对象也是响应式的但如果你在setup中解构route.params你会拿到当前参数快照路由变化后不会更新。正确做法是用计算属性或者watchconst { params } route // 错误路由变化后 params 不更新 const id computed(() route.params.id) // 正确5.3 调试技巧vue-devtools、debugger、console开发中遇到“页面不跳转”“数据没更新”这类问题别瞎猜按照下面顺序排查先看 vue-devtools 的 Routes 面板确认当前路由是否匹配到了目标组件如果路由记录是空的问题出在路由配置如果路由记录对但页面空白问题出在组件渲染。然后看 Pinia 面板检查 store 里的 state 是否发生了预期变化这一步能快速定位是 store 逻辑问题还是组件渲染问题。如果 vue-devtools 都看不出问题就在代码里加debugger断点。浏览器打开开发者工具触发一次你的操作代码会停在 debugger 那一行配合 Scope 面板查看当前作用域的所有变量比一条条console.log高效得多。还有一个我特别想安利的小技巧给 console.log 加上标签前缀比如console.log(VideoPlay id:, props.id) console.log(store history:, store.historyList)这样混在大量日志中时一眼就能看到是哪条日志排查效率翻倍。别嫌麻烦等你的项目里有一大堆 console 输出时就知道这个习惯有多重要。5.4 给小白的学习路线建议如果你顺利看到这里恭喜你其实已经掌握了 Vue 项目中最重要的三个核心知识块组件、路由、状态管理。Day5 的学习重点不是背 API而是理解这三块是怎么协作的。我见过很多初学者把 API 背得滚瓜烂熟但实际写项目时还是不会组织代码。我的建议是学完今天的内容后马上给自己布置一个小项目比如一个带登录鉴权的视频点播页面把路由守卫、动态路由传参、Pinia 保存进度全部用上。只有自己踩一遍坑这些东西才是你的。我还想提一个偏门但很实用的点在封装组件时如果你发现写在子组件上的 class 或者属性没有生效大概率是因为子组件没有显式地绑定$attrs。子组件的根元素如果只有一个attrs 会自动透传但如果根元素有多个或者你想控制精确位置需要在组件内部手动写v-bind$attrs。这个问题面试和实际开发都可能遇到趁着 Day5 脑子还热可以先加个书签。如果你后面去了解 React会发现 Vue 的模板语法和响应式系统跟 React 的 JSX 和 fiber 协调机制完全是两种设计思路到时候再对比学习会有更深的体会。但 Day5 咱们先不贪多把路由和状态管理玩明白再说。路由解决的是“页面怎么跳”Pinia 解决的是“数据怎么放”。这两个东西一旦上手你写出的项目就会从“单页玩具”变成“多页应用”从“组件孤立”走向“数据共享”。如果你在跟着练习时卡住了重点检查这几处依赖是否安装完整、component 是否成功导入、useRouter是否在 setup 顶层调用、有没有忘了给子路由配置router-view。最后说一句路由守卫里的next()一定只能调一次遇到路由跳转卡住的案例十次里八次是这里出了岔子。
返回列表