Skip to content

三级路由与优化路由(Lesson25 2/3) ​

本页对应 Lesson25,共 6 段视频,约 1 小时 18 分。

学习目标 ​

  • 能够使用自定义渲染函数优化路由配置,抽离重复的组件渲染逻辑,并通过参数传递正确渲染不同组件。
  • 能够为三级路由配置动态参数,在模板中通过 $route、在 <script> 中通过 useRoute 获取参数,并完成详情数据渲染。
  • 能够使用编程式导航完成页面跳转、前进后退及带参数跳转,并在组件方法中正确调用 router.push、router.go 等方法。
  • 能够使用 getRoutes、hasRoute、addRoute、removeRoute 动态查询、判断、添加和删除路由,并正确处理二级、三级路由的添加时机与参数。
  • 能够说明 Pinia 的定位、与 Vuex 的主要区别,以及选项式与组合式两种 store 写法,并独立创建 store 定义 state 和 actions。
  • 能够完成 Pinia 安装、在 main.ts 中注册、创建 store 目录与入口文件,并在组件中读取状态、调用 action 验证状态更新。

知识点 ​

三级路由要解决什么问题 ​

为什么需要:消息列表 /home/message 展示多条消息,点某一条要进入详情。详情不是一级页面,也不是二级列表本身,而是列表下的“某一条”,所以 URL 设计成 /home/message/:filmId,用 filmId 表示具体消息。这样刷新页面也能直达详情,参数也在地址栏里。

怎么想:先把详情页当成普通组件建出来,再把它注册成 Message 的子路由;父级 Message/index.vue 里要有 <router-view /> 承载三级页面;最后列表项跳转时把 filmId 拼进地址,详情页再接收参数。

怎么做:先建 src/pages/Home/Message/Detail/index.vue:

vue
<template>
    <h3>Detail</h3>
</template>

<script lang="ts" setup>

</script>

<style scoped>

</style>

易错点:只建组件不配路由,详情不会出现;只配 children 但父组件没有 <router-view />,三级页面也不会渲染。转写稿里说的“在这个基础上加上一个内容”,实际就是要在 Message/index.vue 中给子路由留出口。

在二级路由下配置 children ​

为什么需要:三级路由必须挂在二级路由的 children 里,否则 /home/message/:filmId 和 /home/message 没有父子关系,父级布局和子级详情无法配合。

怎么想:/home/message 已经指向 Message/index.vue,它的 children 里再放一条 path: "/home/message/:filmId",组件指向 Detail/index.vue。笔记里用的是绝对路径写法,能匹配;更常见的写法是 path: ":filmId"。

怎么做:在 src/router/index.ts 中配置:

ts
		{
            path:"/home/message",
            component:()=>import("@/pages/Home/Message/index.vue"),
            children:[
                {
                    path:"/home/message/:filmId",
                    component:()=>import("@/pages/Home/Message/Detail/index.vue")
                }
            ]
		}

易错点:children 拼写、组件路径大小写、index.vue 文件名要一致;参数名 filmId 前后必须一致,不能列表写 id、详情取 filmId。如果改成相对路径,不要写成 "/:filmId",否则地址层级会变。

列表页跳转要带上 filmId ​

为什么需要:详情页要知道“看哪一条”,所以跳转地址必须带参数。转写稿里把原来的 a 标签改成 router-link,把 to 改成路径拼接,本质就是让地址变成 /home/message/1、/home/message/2。

怎么想:可以用声明式导航 router-link,也可以编程式导航 router.push。声明式写法适合列表项;编程式适合在点击事件里先做判断再跳。

怎么做:列表项写成类似 :to="&#96;/home/message/${filmId}&#96;" 的形式。若用编程式导航,则先 import { useRouter } from "vue-router",const router = useRouter(),再 router.push(&#96;/home/message/${filmId}&#96;)。

易错点:to 前不要漏冒号,否则会当成普通字符串;路径拼接不要多斜杠;编程式导航用 useRouter,取当前路由参数用 useRoute,两者不要混。转写稿里口误的“费用ID/目ID”统一指 filmId。

详情页接收参数:模板与 setup 的差别 ​

为什么需要:地址里有 filmId,详情页要取出来发请求或渲染。Vue3 的 <script setup> 中不能像模板那样直接写 $route,这是转写稿里重点调试的地方。

怎么想:模板里可以直接用 $route.params.filmId;<script setup> 里推荐用 useRoute()。转写稿里说的“do root”就是 $route,“you root”就是 useRoute。

怎么做:模板中可写 {{ $route.params.filmId }}。脚本中写:

ts
import { useRoute } from "vue-router"
const route = useRoute()
console.log(route.params.filmId)

易错点:在 setup 里直接写 $route 会拿不到;不要用 route.query.filmId,这里参数在 params;useRoute 拼写和导入来源要正确。转写稿中还试过 router.currentRoute.value.params.filmId,能取到但类型和写法更绕,日常优先 useRoute()。

调试:组件复用导致详情不更新 ​

为什么需要:从“孤注一掷”切到“学霸”,URL 的 filmId 变了,但详情组件可能被复用,onMounted 只执行一次,所以页面不刷新。转写稿里点击不同条目发现请求不再发,就是这个问题。

怎么想:监听 route.params.filmId 的变化,变化时重新取详情;并且首次进入也要执行,所以加 immediate: true。

怎么做:用 watch 监听:

ts
import { watch } from "vue"
import { useRoute } from "vue-router"
const route = useRoute()

watch(
  () => route.params.filmId,
  (filmId) => {
    // 根据 filmId 重新请求详情
  },
  { immediate: true }
)

易错点:不要写成 watch(route.params.filmId, ...),要写成函数形式;不加 immediate 首次进入不会执行;如果只在 onMounted 里请求,切换同组件不同参数时不会再次触发。转写稿的调试顺序是:先看地址是否变成 /home/message/1,再在模板打印参数,再在 setup 中改用 useRoute(),最后加 watch 解决切换不更新。

为什么要优化路由配置 ​

为什么需要:原来的路由表里,每个页面都手写一次 () => import("@/pages/.../index.vue"),页面一多就重复、难维护,路径写错也不容易发现。
怎么想:这些动态导入的格式完全一样,只是页面名不同,可以把“页面名 → 动态导入函数”抽成一个工厂函数。
怎么做:定义 renderCom(name),内部拼接 @/pages/ + name + /index.vue,返回动态导入函数。
易错点:name 的大小写、层级路径要和使用目录一致;renderCom 返回的是函数,不能写成已经执行的结果。

抽出 renderCom 统一懒加载 ​

转写稿里讲师先删掉原来零散的 import 写法,改成自定义 renderCom:传什么名字,就渲染哪个组件。这样 Home、About、NotFount 以及子路由都能统一写法。
为什么需要:减少重复代码,后续新增页面只传名字。
怎么想:把“拼接路径 + 动态 import”封装起来,路由表只关心页面名。
怎么做:在 src/router/index.ts 中写:

ts
import NotFount from "@/pages/NotFount/index.vue";
// 1- 引入createRouter
import {createRouter, createWebHashHistory, createWebHistory} from "vue-router"
// 2- 配置每一个路由器的信息
const renderCom = function(name:string){
    return ()=>import("@/pages/"+name+"/index.vue");
}
const routes = [
    {
        path: "/",
        redirect: "/home"
    }, {
        path: "/home",
        component: renderCom("Home"),
        children:[
            {
                path:"/home",
                component:renderCom("Home/NewsList")
            },
            {
                path:"/home/message",
                component:renderCom("Home/Message"),
                children:[
                    {
                        path:"/home/message/:filmId",
                        component:renderCom("Home/Message/Detail")
                    }
                ]
            }
        ]
    },
    {
        path: "/about",
        component:renderCom("About")
    },
    {
        // vue-router@4不支持以下代码实现404
        // path:"*",
        // component:NotFount

        // 需要借助params形式实结合正则实现404
        path: "/:pathMath(.*)",
        component:renderCom("NotFount")
    }
]
// 3- 创建router
const router = createRouter({
    // 相当于Vue2当中的mode:"history"
    history: createWebHistory(),
    // 相当于Vue2当中的mode:"hash"
    // history:createWebHashHistory(),
    routes,
    // linkExactActiveClass:"active",
    // linkActiveClass:"active",
    scrollBehavior() {
        return {
            left: 0,// 相当于之前的x
            top: 0// 相当于之前的y
        }
    }
});

// console.log("router",router);
export default router;

易错点:renderCom("Home/NewsList") 这种层级名要和真实目录对应;不要漏掉 return;component 接收的是函数,不是字符串。

改造各级路由并验证 ​

为什么需要:只改顶层不够,Home 下的 NewsList、Message、Message/Detail 也要统一。
怎么想:按目录层级把名字传进去:Home、Home/NewsList、Home/Message、Home/Message/Detail。
怎么做:按上面代码替换各级 component。改完后在浏览器依次点击 home、about,再刷新三级路由页面验证。转写稿中三级路由出问题时,讲师检查发现路径里多写了斜杠,去掉后恢复。
易错点:三级路由 path 不要多写 /;子路由路径要能与父级拼接;刷新后异常先看控制台和路径。

404、history 与滚动行为 ​

为什么需要:vue-router@4 不再用 path:"*" 做 404,需要 params 加正则兜底。
怎么想:用 /:pathMath(.*) 匹配未命中的地址,再渲染 NotFount。
怎么做:保留笔记中的写法;history 用 createWebHistory() 对应 history 模式,用 createWebHashHistory() 对应 hash 模式;scrollBehavior 返回 { left:0, top:0 } 实现切换后回到顶部。
易错点:NotFount 是笔记中的拼写,保持一致;404 路由通常放最后;scrollBehavior 返回的是坐标对象,不是直接写 window.scrollTo。

三级路由要解决什么问题 ​

这一节的目标是:在 Home -> Message 列表页点击某部电影,进入 Message 下面的详情子路由,并把电影 filmId 通过地址栏参数传给详情页。也就是说,三级路由不仅要把页面渲染出来,还要把“点的是哪部电影”带过去。

为什么需要这样做?因为详情页不能写死数据,它必须知道当前 URL 里的 filmId,再根据这个 ID 请求接口。怎么想?列表页负责生成带参数的链接,详情页负责接收参数并请求数据。怎么做?先在 Message/index.vue 中用 router-link 拼出 /home/message/电影ID,并保留 <router-view> 作为子路由出口。

vue
<template>
    <ul>
        <li v-for="item in state.films" :key="item.filmId">
            <router-link :to="{
                path:'/home/message/'+item.filmId
            }">{{item.name}}</router-link> &nbsp;&nbsp;
            <button>push</button> &nbsp;
            <button>replace</button>
        </li>
    </ul>
    <button>前进</button>
    <button>后退</button>
    <hr/>
    <router-view></router-view>
</template>

易错点:router-link 的 to 如果写成普通字符串,就不能动态拼接;这里用对象形式 path:'/home/message/'+item.filmId。另外,Message 页面里的 <router-view> 不能漏,否则详情组件没有渲染位置。

模板里能用 $route,script setup 里不能直接用 ​

详情页模板中可以直接写 $route.params.filmId,但转写稿里调试时发现:在 script setup 中直接写 $route 拿不到,因为组合式 API 没有 this,不能像选项式那样通过实例访问。这里要区分两个东西:useRouter() 得到的是路由器实例,useRoute() 得到的是当前路由信息对象。

方案一:直接引入 router 实例,通过 currentRoute.value.params 取参数。方案二:useRouter() 得到的实例和引入的 router 是同一个,再访问 currentRoute.value.params。方案三最常用:useRoute() 直接拿当前路由对象,再取 params.filmId。

ts
// 方案一
// import $router from "@/router";
// console.log($router.currentRoute.value.params.filmId);

// 方案二
// import {useRouter} from "vue-router";
// import router from "@/router"
// const $router = useRouter();
// console.log($router===router);// true
// console.log($router.currentRoute.value.params.filmId)

// 方案三
// import {useRoute} from "vue-router";
// const $route = useRoute();
// console.log($route.params.filmId)

易错点:useRouter 不是 useRoute;currentRoute 是 ref,要取 .value;参数名必须和路由配置里的 :filmId 一致。

详情页请求数据与监听参数变化 ​

详情页拿到 filmId 后要请求电影详情。转写稿里先尝试在 onMounted 里请求,但发现:从一部电影切到另一部电影时,组件被复用,onMounted 不会再次执行,所以页面不会重新请求。解决方式有两种:一是 onBeforeRouteUpdate 里拿到 to.params.filmId 再请求,并调用 next();二是用 watch 监听 $route.params.filmId,并加 immediate:true,首次进入也执行。

vue
<template>
    <h3>{{state.film.name}}</h3>
    <p>{{state.film.synopsis}}</p>
</template>

<script lang="ts" setup>
// 1- 在模板中可以直接使用$route
// 2- 在script标签中无法使用$route,如何解决?
// 方案一
// import $router from "@/router";
// console.log($router.currentRoute.value.params.filmId);

// 方案二
// import {useRouter} from "vue-router";
// import router from "@/router"
// const $router = useRouter();
// console.log($router===router);// true
// console.log($router.currentRoute.value.params.filmId)

// 方案三
// import {useRoute} from "vue-router";
// const $route = useRoute();
// console.log($route.params.filmId)

import {onMounted, reactive, watch} from "vue";
import {onBeforeRouteUpdate, useRoute} from "vue-router";
import axios from "axios";
const $route = useRoute();
const state = reactive({
    film:{}
})
// 获取数据方案一
// const getway = async function(filmId:string){
//     const {data:{data:{film}}} =await axios.get("https://m.maizuo.com/gateway?filmId="+filmId+"&k=7345366",{
//         headers:{
//             "X-Client-Info":'{"a":"3000","ch":"1002","v":"5.2.1","e":"16923264631851757969801217","bc":"310100"}',
//             "X-Host":"mall.film-ticket.film.info"
//         }
//     })
//     state.film = film;
// }
// onBeforeRouteUpdate(async function(to,from,next){
//     await getway(to.params.filmId as string)
//     next();
// })
// onMounted(function(){
//     getway($route.params.filmId as string)
// })

// 获取数据方案二- watch
watch(()=>$route.params.filmId,async (filmId)=>{
    const {data:{data:{film}}} =await axios.get("https://m.maizuo.com/gateway?filmId="+filmId+"&k=7345366",{
        headers:{
            "X-Client-Info":'{"a":"3000","ch":"1002","v":"5.2.1","e":"16923264631851757969801217","bc":"310100"}',
            "X-Host":"mall.film-ticket.film.info"
        }
    })
    state.film = film;
},{
    immediate:true
})
</script>

<style scoped>

</style>

易错点:watch 要监听 ()=>$route.params.filmId,不是直接监听 $route;immediate:true 保证首次进入就请求;接口解构层级是 data.data.film;filmId 从路由里来通常是字符串,必要时用 as string。验证方式:从列表依次点击不同电影,详情标题和简介都应更新;直接刷新详情页,也应能通过 immediate 首次请求到数据。

从声明式导航到编程式导航 ​

为什么需要:router-link 适合“用户点链接就跳转”的场景;但实际开发中经常要在按钮点击、表单提交、条件判断后跳转,这时不能只靠模板里的链接,需要“用代码触发路由跳转”,也就是编程式导航。
怎么想:声明式导航是“写一个链接让用户点”;编程式导航是“在事件函数里调用路由器的方法”。二者最终都在改地址,只是触发方式不同。
怎么做:先拿到路由器实例,再调用 push、replace、back、forward 等方法。
易错点:不要把“路由器实例”和“当前路由信息”混为一谈;模板里能直接写 $router,但 <script setup> 里不能想当然直接用。

模板中的 $router 与脚本中的 useRouter ​

为什么需要:笔记代码里模板按钮写了 $router.replace(...),而脚本里又写了 const $router = useRouter()。要理解两者来源不同。
怎么想:Vue Router 会把路由器实例挂到模板可访问的全局属性上,所以模板中可以直接用 $router;但在 <script setup> 中,需要显式导入 useRouter() 获取实例。转写稿里调试时也提到:模板中可以直接使用 $router,在 setup 标签中无法直接使用 $router,需要借助 useRouter。
怎么做:

ts
import { useRouter } from "vue-router";
const $router = useRouter();
const goDetail = (filmId: string) => {
  $router.push('/home/message/' + filmId);
};

易错点:在脚本里直接写 $router.push(...) 会报未定义;变量名写成 $router 只是习惯,本质是 useRouter() 的返回值。还要区分 useRoute():它拿的是当前路由信息,不是导航方法。

push 与 replace 的区别 ​

为什么需要:同样能跳到详情页,但历史记录行为不同。
怎么想:push 是“压入一条新历史记录”,用户点浏览器后退能回到上一页;replace 是“替换当前历史记录”,不会新增后退点。转写稿中演示时页面效果看起来一样,但历史栈不一样。
怎么做:笔记里 goDetail 用 push;模板按钮用 $router.replace('/home/message/' + item.filmId)。
易错点:不要以为 replace 是“不能跳转”;它只是不新增历史。路径拼接时注意 /home/message/ 后面的斜杠,少写会拼成错误地址。

back、forward 与 go ​

为什么需要:有时页面要提供“前进/后退”按钮,模拟浏览器历史操作。
怎么想:back() 后退一步,forward() 前进一步;也可以用 go(-1)、go(1)。转写稿中提到可以写 go(-1) 或 back,前进写 go(1)。
怎么做:

vue
<button @click="$router.forward()">前进</button>
<button @click="$router.back()">后退</button>

易错点:没有可后退的历史时,back() 不会产生预期效果;不要把它和“跳转到某个固定路径”混淆。

完整代码:Home/Message/index.vue ​

vue
<template>
    <ul>
        <li v-for="item in state.films" :key="item.filmId">
            <router-link :to="{
                path:'/home/message/'+item.filmId
            }">{{item.name}}</router-link> &nbsp;&nbsp;
            <button @click="goDetail(item.filmId)">push</button> &nbsp;
            <button @click="$router.replace('/home/message/'+item.filmId)">replace</button>
        </li>
    </ul>
    <button @click="$router.forward()">前进</button>
    <button @click="$router.back()">后退</button>
    <hr/>
    <router-view></router-view>
</template>

<script lang="ts" setup>
import {onMounted, reactive} from "vue";
import {useRouter} from "vue-router";
import axios from "axios";
const $router = useRouter();
const goDetail=(filmId:string)=>{
    $router.push('/home/message/'+filmId)
}
let state = reactive({
    films:[]
})

onMounted(function(){
    // https://m.maizuo.com/gateway?cityId=310100&pageNum=1&pageSize=10&type=1&k=6086743
    axios.get('https://m.maizuo.com/gateway?cityId=310100&pageNum=1&pageSize=3&type=1&k=6086743',{
        headers:{
            "X-Client-Info":'{"a":"3000","ch":"1002","v":"5.2.1","e":"16923264631851757969801217","bc":"310100"}',
            "X-Host":"mall.film-ticket.film.list"
        }
    }).then(value=>{
        // console.log(value.data.data.films);
        state.films = value.data.data.films;
    })

})
</script>

<style scoped>

</style>

调试过程:先点 router-link 看声明式导航是否正常;再点 push 按钮,观察地址变化;点 replace 后连续后退,比较历史记录差异;最后点“前进/后退”验证 forward/back。若脚本中 $router 报错,就改成 useRouter() 获取实例。
易错点汇总:useRouter 拿路由器,useRoute 拿当前路由;push 增历史,replace 换历史;模板可直接用 $router,脚本中要导入;路径拼接注意斜杠;router-view 不要漏,否则子路由详情不显示。

为什么需要动态操作路由 ​

为什么需要:后台管理系统里,不同用户登录后能看到的菜单不同。普通员工不能看到“支付流水”等页面,如果路由写死,用户仍可能通过地址访问。动态路由就是登录后根据权限决定“加不加这条路由”。

怎么想:路由表本质是数组,Vue Router 实例提供了增、删、查的方法。先能查,再判断,再添加或删除;接口返回路由数据时,也可以遍历添加。

怎么做:在 src/main.ts 中通过 router.getRoutes()、router.hasRoute()、router.addRoute()、router.removeRoute() 操作。注意添加路由要发生在 app.mount("#app") 之前,否则首次刷新会 404。

易错点:hasRoute 接收的是路由 name,不是 path;removeRoute 也接收 name。如果 addRoute 时没写 name,就不能用 removeRoute(name) 删除,要用它返回的函数删除。

四个基础方法与本节代码 ​

ts
import {createApp} from "vue";
import App from "@/App.vue";
import router from "@/router";

// 1- getRoutes:获取所有的路由信息列表,类型是一个数组。
// const routes = router.getRoutes();
// console.log(routes);

// 2- hasRoute:判断路由信息是否存在,接收的是路由的名字,返回的是一个布尔值
// console.log(router.hasRoute("home"));// true
// console.log(router.hasRoute("home2"));// false

// 3- addRoute:添加路由信息
// const renderCom = function(name:string){
//     return ()=>import("@/pages/"+name+"/index.vue");
// }
// const aboutRoute = router.addRoute({
//     path: "/about",
//     // name:"about",
//     component:renderCom("About")
// })

// 4- removeRoute:删除路由
// 4-1- 方案一
// 查看路由是否存在:
// console.log(router.hasRoute("about"));
// if(router.hasRoute("about")){
//     router.removeRoute("about");
// }

// 4-2- 方案二:添加完路由之后会有一个返回值,该返回值是一个函数,调用该函数即可删除
// aboutRoute();
// console.log(router.getRoutes());

// 扩展:将所有的路由通过方法去添加
// 以下数据是通过接口获取过来的。
const renderCom = function(name:string){
    return ()=>import("@/pages/"+name+"/index.vue");
}
const routes = [
    {
        path: "/",
        redirect: "/home"
    },
    {
        path: "/home",
        name:"home",
        component: renderCom("Home"),
        children:[
            {
                path:"/home",
                name:"home",
                component:renderCom("Home/NewsList")
            },
            {
                path:"/home/message",
                name:"message",
                component:renderCom("Home/Message"),
                children:[
                    {
                        path:"/home/message/:filmId",
                        component:renderCom("Home/Message/Detail")
                    }
                ]
            }
        ]
    },
    {
        path: "/about",
        name:"about",
        component:renderCom("About")
    },
    {
        // vue-router@4不支持以下代码实现404
        // path:"*",
        // component:NotFount

        // 需要借助params形式实结合正则实现404
        path: "/:pathMath(.*)",
        component:renderCom("NotFount")
    }
]
// 方案一:
// router.addRoute(routes[0]);
// router.addRoute(routes[1]);
// router.addRoute(routes[2]);
// router.addRoute(routes[3]);

// 方案二:
// routes.forEach(item=>{
//     router.addRoute(item);
// })

// 方案三:
// router.addRoute({
//     path: "/",
//     redirect: "/home"
// });
// router.addRoute({
//     path: "/home",
//     name:"home",
//     component: renderCom("Home"),
//     children:[
//         {
//             path:"/home",
//             name:"home",
//             component:renderCom("Home/NewsList")
//         },
//         {
//             path:"/home/message",
//             name:"message",
//             component:renderCom("Home/Message"),
//             children:[
//                 {
//                     path:"/home/message/:filmId",
//                     component:renderCom("Home/Message/Detail")
//                 }
//             ]
//         }
//     ]
// });
// router.addRoute( {
//     path: "/about",
//     name:"about",
//     component:renderCom("About")
// });
// router.addRoute({
//     // vue-router@4不支持以下代码实现404
//     // path:"*",
//     // component:NotFount
//
//     // 需要借助params形式实结合正则实现404
//     path: "/:pathMath(.*)",
//     component:renderCom("NotFount")
// })

// 方案四:
router.addRoute({
    path: "/",
    redirect: "/home"
});
router.addRoute({
    path: "/home",
    name:"home",
    component: renderCom("Home")
});
// 添加二级路由:第一个参数是所在路由的名字,第二个参数是路由信息
// 将添加的路由放置在名字为home的路由下。
router.addRoute("home",{
    path:"/home",
    name:"newList",
    component:renderCom("Home/NewsList")
})
router.addRoute("home", {
    path:"/home/message",
    name:"message",
    component:renderCom("Home/Message")
})
router.addRoute("message",{
    path:"/home/message/:filmId",
    component:renderCom("Home/Message/Detail")
})
router.addRoute( {
    path: "/about",
    name:"about",
    component:renderCom("About")
});
router.addRoute({
    path: "/:pathMath(.*)",
    component:renderCom("NotFount")
})

createApp(App).use(router).mount("#app");

用接口数据批量添加路由 ​

为什么需要:真实项目里路由权限常由后端接口返回,前端拿到数组后再注册,而不是写死在 router/index.ts。

怎么想:既然 routes 是数组,就可以逐项 addRoute(routes[i]),也可以 routes.forEach(item => router.addRoute(item))。

怎么做:转写稿中演示了三种:逐个下标添加、forEach 遍历添加、拆开单条添加。拆开添加适合需要控制顺序或单独处理二级路由的场景。

易错点:renderCom 是“渲染组件”的辅助函数,注意拼写;NewsList、Message、Detail、NotFound 这些目录名要和真实文件一致,否则动态导入失败。404 在 Vue Router 4 中用 path: "/:pathMatch(.*)" 这类 params 加正则的写法,path:"*" 不再适用。

添加二级、三级路由 ​

为什么需要:Home 下面有 NewsList、Message,Message 下面还有 Detail。动态添加时不能把子路由直接当一级路由塞进去。

怎么想:addRoute 第一个参数传父路由的 name,第二个参数传子路由对象。先加 home,再 addRoute("home", 二级);先加 message,再 addRoute("message", 三级)。

怎么做:方案四就是先加一级 home,再往 home 下加 newList、message,再往 message 下加 :filmId 详情路由。

易错点:转写稿调试时出现过“一级路由消失”,原因是 name 重复。name 要唯一;addRoute("home", {...}) 里的父级名字必须已经存在。删除时 removeRoute("about") 只认 name;如果添加时没写 name,就用返回值函数删除。

删除路由与挂载时机 ​

为什么需要:权限变化或退出登录时,要移除动态添加的路由。

怎么想:先 hasRoute("about") 判断,再 removeRoute("about");或者保存 const aboutRoute = router.addRoute(...),调用 aboutRoute() 删除。

怎么做:转写稿中把 about 注释后访问变 404,再 addRoute 恢复;但刷新后仍 404,因为添加代码放在 mount 之后。把添加逻辑提到 createApp(App).use(router).mount("#app") 之前即可。

易错点:动态路由添加时机要早于挂载;getRoutes() 返回数组,可用于检查;hasRoute/removeRoute 都传 name;addRoute 返回值是删除函数,不是路由对象。

先定位:Pinia 解决什么问题 ​

本节笔记只给出一句定位:“Pinia 符合直觉的 Vue.js 状态管理库”。转写稿前半段大量内容是路由优化、三级数据渲染、编程式导航和动态路由调试,属于上一节路由部分的延伸;本节真正的主线从“Pinia 介绍”开始,不要把这些路由代码混进 Pinia 的知识点里。

为什么需要:组件之间要共享状态时,靠父子传参会越来越绕;Vuex 又要先定义模块、再注册到 modules,还要理解 mutations、命名空间,容易“模块定义了却忘了挂进去”。Pinia 是 Vue 官方生态里的状态管理库,转写稿明确说它可以替代 Vuex,支持 Vue2/Vue3,不强制使用组合式 API。

怎么想:把 Pinia 想成“大仓库”,里面可以放若干“小仓库”。组件需要共享数据时,不去直接改别人的组件,而是调用仓库里的 action,action 改 state,state 变了组件自动更新。转写稿反复强调这个流程:组件调用 → action 调接口或改状态 → state 更新 → 组件更新。

怎么做:先按“安装 → 创建 Pinia 实例 → 在 main.ts 挂载 → 定义 store → 组件使用”的顺序走。

易错点:Pinia 不是路由,不负责页面跳转;转写稿里 useRoute、router.addRoute 那些是 Vue Router 的内容,不要和 store 混在一起。

安装与挂载 ​

为什么需要:Pinia 要作为 Vue 插件挂到应用上,组件里才能拿到 store。

怎么想:转写稿的调试顺序很实用:先安装依赖,再在 src/store/index.ts 创建 Pinia 实例,最后在 main.ts 里 app.use(pinia)。先只验证“能不能挂上”,再写业务。

怎么做:

bash
npm install pinia
# 或 cnpm / yarn
ts
// src/store/index.ts
import { createPinia } from 'pinia'

const pinia = createPinia()
export default pinia
ts
// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import pinia from './store'

createApp(App).use(pinia).mount('#app')

易错点:只安装不 use,组件里拿不到 store;import 路径和后缀别漏,转写稿里提醒“引入别忘掉”;挂载前先把无关代码清一清,刷新页面看标题是否正常,确认基础环境没被影响。

定义仓库:选项式与组合式 ​

为什么需要:共享状态要集中定义,不能散落在组件里。

怎么想:defineStore 第一个参数是仓库的唯一 id,第二个参数是配置;转写稿建议命名以 use 开头。Pinia 有两种写法:选项式和组合式。

怎么做:选项式:

ts
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  actions: {
    increment() {
      this.count++
    }
  }
})

组合式:

ts
import { defineStore } from 'pinia'
import { ref } from 'vue'

export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  function increment() {
    count.value++
  }
  return { count, increment }
})

易错点:id 不要重复;选项式的 state 要用函数返回对象;组合式里定义的 count、increment 必须 return;Pinia 没有 mutations(转写稿口误成“毛盾”),也没有命名空间,不要照搬 Vuex 的写法。

在组件中使用与修改状态 ​

为什么需要:定义仓库只是第一步,组件要能读、能改。

怎么想:先 useCounterStore() 得到 store 实例,模板里读 counter.count,事件里调 counter.increment() 或 $patch。转写稿演示了 $patch({ count: ... }) 的对象写法,它适合一次改多个状态。

怎么做:

vue
<script setup>
import { useCounterStore } from '@/store/counter'
const counter = useCounterStore()
</script>

<template>
  <p>{{ counter.count }}</p>
  <button @click="counter.increment()">+1</button>
  <button @click="counter.$patch({ count: 10 })">改成 10</button>
</template>

易错点:useXxxStore 是函数,要加括号调用;选项式 action 里用 this.count++,组合式里用 count.value++;$patch 传对象,不要写成赋值;改状态优先走 action,调试时再临时用 $patch。

调试流程与最后提醒 ​

转写稿的调试过程可以总结为:安装后先刷新页面看基础标题是否正常;在 src/store/index.ts 建仓库入口;在 main.ts 注册;组件里先 import 再 use;改状态后看页面是否更新。若页面没反应,先查三件事:Pinia 有没有 use、store 有没有正确导出、组件有没有调用 useXxxStore()。

易错点:不要把“动态添加路由”的代码当成 Pinia 示例;Pinia 的核心就是 state、actions、组件使用这几步,先把这条链路跑通,再扩展业务。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

课堂画面 6

代码示例 ​

示例 1:优化路由配置(src/router/index.ts) ​

ts
// src/router/index.ts
import { createRouter, createWebHistory, createWebHashHistory } from "vue-router"

// 1- 抽出一个“渲染组件”的方法,统一返回动态 import
const renderCom = function (name: string) {
    return () => import("@/pages/" + name + "/index.vue");
}

// 2- 配置每一个路由器的信息
const routes = [
    {
        path: "/",
        redirect: "/home"
    }, {
        path: "/home",
        component: renderCom("Home"),
        children: [
            {
                path: "/home",
                component: renderCom("Home/NewsList")
            },
            {
                path: "/home/message",
                component: renderCom("Home/Message"),
                children: [
                    {
                        path: "/home/message/:filmId",
                        component: renderCom("Home/Message/Detail")
                    }
                ]
            }
        ]
    },
    {
        path: "/about",
        component: renderCom("About")
    },
    {
        // vue-router@4 不支持 path:"*" 实现 404
        // 需要借助 params 形式结合正则实现 404
        path: "/:pathMath(.*)",
        component: renderCom("NotFount")
    }
]

// 3- 创建 router
const router = createRouter({
    // 相当于 Vue2 当中的 mode:"history"
    history: createWebHistory(),
    // 相当于 Vue2 当中的 mode:"hash"
    // history: createWebHashHistory(),
    routes,
    scrollBehavior() {
        return {
            left: 0, // 相当于之前的 x
            top: 0   // 相当于之前的 y
        }
    }
});

export default router;

关键行与易错点

  • renderCom(name) 把“路径字符串 + 动态 import”收敛成一个函数,新增页面时只改传参,避免到处重复写 ()=>import(...)。
  • 三级路由的关键:Home/Message/Detail 必须作为 Home/Message 的 children 写,父组件模板里要有 <router-view> 才能渲染出来。
  • path:"*" 在 vue-router@4 已失效,404 必须用 /:pathMath(.*) 这种带正则的 params 写法。
  • createWebHistory() 与 createWebHashHistory() 二选一,不要同时写;scrollBehavior 返回 {left, top}(不是 x/y)。

示例 2:三级路由数据渲染与传参(Message/index.vue + Detail/index.vue) ​

vue
<!-- src/pages/Home/Message/index.vue -->
<template>
    <ul>
        <li v-for="item in state.films" :key="item.filmId">
            <router-link :to="{
                path:'/home/message/'+item.filmId
            }">{{item.name}}</router-link> &nbsp;&nbsp;
            <button @click="goDetail(item.filmId)">push</button> &nbsp;
            <button @click="$router.replace('/home/message/'+item.filmId)">replace</button>
        </li>
    </ul>
    <button @click="$router.forward()">前进</button>
    <button @click="$router.back()">后退</button>
    <hr/>
    <router-view></router-view>
</template>

<script lang="ts" setup>
import { onMounted, reactive } from "vue";
import { useRouter } from "vue-router";
import axios from "axios";

const $router = useRouter();

const goDetail = (filmId: string) => {
    $router.push('/home/message/' + filmId)
}

let state = reactive({
    films: [] as any[]
})

onMounted(function () {
    axios.get('https://m.maizuo.com/gateway?cityId=310100&pageNum=1&pageSize=3&type=1&k=6086743', {
        headers: {
            "X-Client-Info": '{"a":"3000","ch":"1002","v":"5.2.1","e":"16923264631851757969801217","bc":"310100"}',
            "X-Host": "mall.film-ticket.film.list"
        }
    }).then(value => {
        state.films = value.data.data.films;
    })
})
</script>
vue
<!-- src/pages/Home/Message/Detail/index.vue -->
<template>
    <h3>{{state.film.name}}</h3>
    <p>{{state.film.synopsis}}</p>
</template>

<script lang="ts" setup>
import { reactive, watch } from "vue";
import { useRoute } from "vue-router";
import axios from "axios";

const $route = useRoute();
const state = reactive({
    film: {} as any
})

// 方案二 - watch:监听参数变化并立即执行一次
watch(() => $route.params.filmId, async (filmId) => {
    const { data: { data: { film } } } = await axios.get("https://m.maizuo.com/gateway?filmId=" + filmId + "&k=7345366", {
        headers: {
            "X-Client-Info": '{"a":"3000","ch":"1002","v":"5.2.1","e":"16923264631851757969801217","bc":"310100"}',
            "X-Host": "mall.film-ticket.film.info"
        }
    })
    state.film = film;
}, {
    immediate: true
})
</script>

关键行与易错点

  • 模板里可以直接用 $route、$router,但在 <script setup> 中拿不到,必须 useRoute() / useRouter();useRouter() 得到的实例和 @/router 默认导出是同一个对象。
  • router-link 的 :to 用对象形式 {path:'/home/message/'+item.filmId} 做拼接,参数名 filmId 必须与路由 :filmId 完全一致。
  • 同一个 Detail 组件被复用(只换参数)时,onMounted 不会再次执行,所以用 watch(()=>$route.params.filmId, ..., {immediate:true}) 才能切换影片时刷新数据。
  • 笔记中的另一方案 onBeforeRouteUpdate 里同样要写 next(),漏写会导致导航卡住。
  • $router.forward()/back() 等价于 go(1)/go(-1),replace 不会产生历史记录,后退时不会回到被替换的那一条。

示例 3:动态操作路由(src/main.ts) ​

ts
// src/main.ts
import { createApp } from "vue";
import App from "@/App.vue";
import router from "@/router";

// 1- getRoutes:获取所有路由信息列表,类型是数组
// const routes = router.getRoutes();
// console.log(routes);

// 2- hasRoute:判断路由是否存在,接收“路由名字”,返回布尔值
// console.log(router.hasRoute("home"));  // true
// console.log(router.hasRoute("home2")); // false

const renderCom = function (name: string) {
    return () => import("@/pages/" + name + "/index.vue");
}

// 3- addRoute:添加路由,返回值是一个函数
const aboutRoute = router.addRoute({
    path: "/about",
    name: "about",
    component: renderCom("About")
})

// 4- removeRoute 方案一:按名字删除
// if (router.hasRoute("about")) {
//     router.removeRoute("about");
// }

// 4- removeRoute 方案二:调用 addRoute 的返回值
// aboutRoute();

// 扩展:把二级/三级路由挂到指定父路由下(第一个参数是父路由的 name)
router.addRoute("home", {
    path: "/home/message",
    name: "message",
    component: renderCom("Home/Message")
})
router.addRoute("message", {
    path: "/home/message/:filmId",
    component: renderCom("Home/Message/Detail")
})

// 必须在 mount 之前 addRoute,否则首屏匹配不到
createApp(App).use(router).mount("#app");

关键行与易错点

  • 四个方法要分清:getRoutes() 取列表、hasRoute(name) 传的是 name 不是 path、addRoute() 增、removeRoute(name) 删。
  • addRoute("home", {...}) 的两个参数是「父路由的 name」+「子路由配置对象」;父路由必须有 name,否则挂不上。
  • name 必须全局唯一,二级路由都叫 home 会互相覆盖,导致父级路由“消失”(转写稿里就踩了这个坑)。
  • addRoute 的调用时机很关键:要写在 createApp(...).mount() 之前,否则刷新后 404。
  • removeRoute 的第二种写法依赖 addRoute 的返回值(一个函数),若路由没有 name,只能靠这种方式删除。

常见错误 ​

  1. 详情页首次进入有数据,切换同列表里的其他影片时内容不刷新 现象:地址栏 filmId 变了,页面还是上一部电影。 原因:/home/message/:filmId 前后是同一个组件实例,Vue 复用了它,onMounted 只执行一次。 修法:把请求逻辑抽成 getData(id),用 watch(() => $route.params.filmId, getData, { immediate: true }),或在 onBeforeRouteUpdate 里重新取数并 next();也可给 <router-view :key="$route.fullPath"> 强制重建。

  2. <script setup> 里写 $route.params.filmId 拿到 undefined 或直接报错 现象:模板里 {{ $route.params.filmId }} 正常,脚本里同样写法不行。 原因:$route/$router 是挂在组件模板上下文上的全局属性,setup 作用域里没有。 修法:const route = useRoute()、const router = useRouter();只有确实需要"全局单例 router 对象"时才 import router from "@/router",读当前路由参数一律用 useRoute()。

  3. 404 兜底路由不生效,乱输地址后页面一片空白 现象:path: "*" 写了但匹配不到。 原因:vue-router@4 移除了 * 通配写法。 修法:改成 path: "/:pathMatch(.*)*"(或笔记里的 /:pathMath(.*),参数名自定但要与 route.params 读取一致),并把它放在 routes 数组最后。

  4. router.addRoute() 加的路由刷新一下就 404 现象:不刷新能点进去,F5 之后找不到。 原因:addRoute 写在 app.mount() 之后,或写在某个组件生命周期里,刷新时该代码还没跑到,路由已经完成首次匹配。 修法:把动态注册放在 createApp(App).use(router) 之前(或至少 mount 之前);权限类场景则放在路由守卫里 await 完再 next(),必要时配合 router.isReady()。

  5. 用 addRoute("home", {...}) 挂二级路由,父级下不出现子路由 现象:hasRoute("home") 为 true,但子路由访问不到 / 被覆盖。 原因:第一个参数是父路由 name,父级必须已注册且 name 存在;同时所有 name 必须全局唯一,重名会顶掉原路由。 修法:先确认父级 name 拼写,再检查新路由的 name 是否与已有路由重复(同名就换名);删路由时优先用 addRoute 返回的函数,而不是 removeRoute(name),避免名字记错。

  6. router.push 拼接 path 跳转后落到 404 现象:点击按钮跳转地址少了层级或多了斜杠。 原因:path 是绝对路径写法,必须写全 /home/message/xxx,不能只写 message/xxx;带参数时用 params 又没给 name。 修法:要么 router.push('/home/message/' + id) 写全路径,要么 router.push({ name: 'detail', params: { filmId: id } }) —— 后者必须配 name,用 path 时 params 会被忽略。

练习 ​

  1. 给 /home/message/:filmId 的详情页加一个"上一部 / 下一部"按钮,要求切换时不重建组件也能刷新内容,并说明你为什么选 watch 而不是 onMounted。
  2. 用 renderCom(name) 的写法,把现有全部路由(含三级路由与 404)改造成"从一份数组数据循环生成",并保证 404 仍在最后。
  3. 写一个模拟权限场景:初始路由表里没有 /about,用户点击"登录"按钮后才通过 router.addRoute 注册并跳转过去;再提供一个"退出"按钮用两种方式(removeRoute(name) 和接收返回值函数)各删一次。
  4. 在 Message/index.vue 里用 useRouter() 实现 goDetail(id),分别用 push 和 replace 各做一次跳转,然后点浏览器后退键,观察两者历史栈差异并写下结论。
  5. 用 Pinia 建一个 store/index.ts 和一个 useCounterStore,在组件里既用解构 store.count 又用 store.increment() 修改,验证视图是否响应,并解释为什么直接解构 state 会丢响应性。

原视频对照 ​

#视频时长
1优化路由05:26
2三级数据渲染-传递参数24:26
3编程式导航04:11
4动态操作路由的方法24:56
5pinia介绍13:54
6安装06:00

小结 ​

  • 三级路由的本质是"父组件里再放一个 <router-view>",路由表嵌套只是声明,视图嵌套要靠组件模板配合。
  • 动态段 :filmId 改变时组件默认复用,"取数"要绑定在参数变化上,而不是绑定在挂载上,这是本模块最高频的坑。
  • useRoute() 拿"当前路由对象",useRouter() 拿"路由器实例",前者读参、后者跳转,二者不要混用;$route 只在模板里天然可用。
  • 路由表用工厂函数 + 循环生成,是把"配置"变成"数据"的第一步,为后面接口下发路由做铺垫。
  • addRoute 的时机比写法更重要:注册必须发生在首次匹配之前,否则刷新即失效;子路由注册依赖父级 name。
  • 编程式导航的 push/replace 差异体现在历史栈,forward/back/go 操作的是浏览器历史而不是路由表。
  • Pinia 把 Vuex 的 mutations 和 modules 砍掉了,只剩 state + actions,流程仍是"组件调 action → action 改 state → 视图更新"。
  • 判断是否该用 Pinia:跨组件共享且需要跨页面存活的数据才进 store,纯父子通信不要往仓库里塞。