JS + Vue 学习笔记(三):从路由切换到 Pinia 共享状态与构建预览
前言
前两天,我补了 JavaScript 基础、Vue 模板与响应式,练习了组件通信、异步请求和本地持久化。第三天的重点,是把页面组织起来:用路由切换内容,用 Pinia 管理共享状态,再做表单校验、自测和构建预览。
今天让我印象最深的,不是多记住了几个 API,而是开始分清三件事:内容属于哪个组件,数据由谁保存,页面运行的是源码还是构建产物。
这篇文章按实际学习过程整理。示例以 Vue 3 的 <script setup lang="ts"> 为背景,不同小节的代码是独立片段,不需要全部重复粘贴。现有项目已经准备了部分路由页和 store,我主要通过阅读、修改和运行验证理解它们,并不是今天从零搭建了所有文件。
一、路由:地址、组件和显示位置要连起来看
项目中已经有以下对应关系:
ts
{ path: '/users', component: UserListView },
{ path: '/users/:id', component: UserDetailView },
{ path: '/todos', component: TodoView },
访问 /users 显示用户列表,访问 /users/2 显示用户详情,访问 /todos 显示待办页。
但只有路由配置还不够。入口组件需要提供导航和路由内容的显示位置:
vue
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router'
</script>
<template>
<header>
<h1>第三天:路由练习</h1>
<nav>
<RouterLink to="/users">用户列表</RouterLink>
<RouterLink to="/todos">我的待办</RouterLink>
</nav>
</header>
<p>下面是当前地址对应的页面:</p>
<RouterView />
</template>
我把它理解为三个分工:
text
RouterLink → 跳到哪个地址
路由配置 → 地址对应哪个组件
RouterView → 匹配到的组件显示在哪里
如果删掉 RouterView,导航仍可以改变地址,但对应的路由页没有地方显示。
我还加了一句固定文字,再切换列表页和待办页。结果是文字一直保留,下方的内容发生变化。这样就直观看到了:页面外壳和路由内容不是同一层。
二、按钮为什么只在用户详情页出现
练习时,我把"查看 2 号用户"的按钮写在用户详情组件中,却疑惑为什么访问 /users 时看不到它。
原因不是"没有 id,所以按钮不显示",而是:
/users匹配的是用户列表组件。/users/2匹配的是用户详情组件。- 按钮写在详情组件中,自然随详情组件出现。
text
App.vue
├── 固定标题、导航、说明文字
└── RouterView
├── /users → 用户列表组件
├── /users/2 → 用户详情组件
└── /todos → 待办组件
<header> 也只是 HTML 标签,不会因为叫 header 就自动成为全站公共区域。它是否一直显示,取决于放在哪个组件、什么位置。
我的需求最终是让按钮出现在 /users,因此把按钮和跳转函数一起放到了用户列表组件中,而不是放到 App.vue。
这次还留下过一个错误:函数移走了,详情页模板里的旧按钮却没有删除,导致类型检查提示找不到 goToUser。清理残留绑定后,检查才通过。
移动功能时,不能只移动函数,还要检查模板中是否留下了对它的调用。
三、useRoute 读取信息,useRouter 执行导航
这两个名字很像,但职责不同:
ts
const route = useRoute()
const router = useRouter()
1. 读取参数
对于当前这条 /users/:id 路由,访问 /users/2 时:
ts
route.params.id // '2'
typeof route.params.id // 'string'
我最初以为它是数字,后来才明确:地址里看起来像数字的这一段,读取到的仍然是字符串。
ts
'8' + 1 // '81'
Number('8') + 1 // 9
需要数字运算时,要先转换;只是拼接请求地址时,则可以直接使用字符串。
项目中还用计算属性读取当前参数:
ts
const userId = computed(() => String(route.params.id))
这样当前参数变化后,读取 userId 时可以得到对应的新值。
2. 执行跳转
ts
function goToUser() {
router.push('/users/2')
}
vue
<button @click="goToUser">查看 2 号用户</button>
我曾把路径写成 /user/2,少了一个 s。项目配置的是 /users/:id,两者不匹配,会进入兜底的找不到页面路由。
记忆方式是:route 负责读,router 负责跳;路径要和配置对应。
四、共享计数:为什么引入 Pinia
如果在两个页面中分别写:
ts
const count = ref(0)
它们是两份独立数据。变量名字相同,不代表保存的是同一份状态。
但这并不意味着只有 Pinia 才能共享数据。我当时提出了一个问题:把计数放在 App.vue 中,不也可以吗?
答案是可以,不过要分清需求:
- 只是所有页面上方都能看到同一个计数器,放在
App.vue的路由内容区外就可以。 - 如果页面组件内部也要读取、修改它,可以通过 props 和事件连接父子组件。
- 当多个组件都需要使用同一份状态时,也可以用 Pinia 把它集中管理。
Pinia 不是共享状态的唯一办法,而是另一种组织共享数据的方式。
1. state、action 和 getter
现有计数 store 的核心结构如下,我把加 1 修改成了加 2:
ts
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
}),
getters: {
doubleCount: state => state.count * 2,
},
actions: {
increment() {
this.count += 2
},
reset() {
this.count = 0
},
},
})
state保存共享数据。action定义操作数据的方法。getter根据状态计算派生结果,例如双倍计数。
组件中取得 store 后,可以直接使用:
ts
const counter = useCounterStore()
vue
<p>{{ counter.count }}</p>
<button @click="counter.increment()">+2</button>
这里读取的是 counter.count,不写成 counter.count.value。
2. 我实际验证了什么
我修改了 action 和按钮文字,观察 A、B 两个组件读取同一份计数。接着从只看 A 切换到只看 B,B 显示了更新后的 4,控制台没有报错。
需要说明:/counter/a 和 /counter/b 在当前配置中使用的是同一个路由组件,只是在内部按参数切换不同面板;这不是两个独立路由页的完整实现。
后续"在列表页修改、在待办页显示同一 store"的方案,我确认理解后选择不再重复动手,因此这里只记录设计思路,不把它写成已完成的跨页实操。
另外,切换路由时保留状态,不等于刷新后也能保留。 当前计数 store 没有接入本地持久化;待办刷新后保留,是因为它另外使用了 localStorage。
五、表单校验:computed 算错误,v-if 控制显示
表单使用 reactive 保存用户名和邮箱,使用计算属性生成错误信息:
ts
const form = reactive({ username: '', email: '' })
const showValidation = ref(false)
const usernameError = computed(() => {
return form.username.trim() === '' ? '用户名不能为空' : ''
})
输入为空或全是空格,返回错误文字;合法时返回空字符串。
模板负责决定是否展示:
vue
<small v-if="showValidation && usernameError">
{{ usernameError }}
</small>
提交时才开启 showValidation,所以刚打开页面不会立即出现一堆错误。提交失败后再输入合法用户名,提示会自动消失:
text
用户名变化
→ computed 重新计算错误信息
→ 合法时结果为 ''
→ v-if 条件不成立
→ 错误提示消失
这里具体依赖的是 form.username,不是表单的任何字段变化都要重新计算用户名错误。
邮箱则使用一个基础格式规则:
ts
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
这只是练习中的基础格式检查,不能证明邮箱真实存在或可以收信。
实际操作中,我验证了空提交提示必填、错误邮箱提示格式问题、输入合法后提示消失,以及合法提交显示成功文字。这里的"提交成功"只是本地页面提示,没有向服务器保存数据。
六、十道自测暴露出的两个卡点
自测覆盖了引用、回调、数组方法、解构、异步,以及响应式、计算属性、组件通信、侦听器和路由参数。
结果是:8 题完全正确,1 题部分正确,1 题错误。 两个需要回看的地方是下面这些。
1. ref 和 reactive 不能套用同一种访问方式
ts
const count = ref(0)
const user = reactive({ age: 20 })
count.value++ // 正确
user.age++ // 正确
我在选择题中误选了 user.value.age++。当前这个 user 是响应式对象,应直接访问 age,它没有用于包装整个对象的 value 属性。
复习时,我正确补出了 name.value 和 person.age。
2. immediate 首次执行时没有旧值
ts
const count = ref(10)
watch(count, (newValue, oldValue) => {
console.log(newValue, oldValue)
}, { immediate: true })
立即执行的第一次打印:
text
10 undefined
我最初忘记 immediate: true,补题时又把初始旧值理解成了和新值相同。实际上,创建时还没有发生一次数据变化,因此这次没有旧值。
如果之后把 count.value 改成 12,对应的变化回调才会收到 12 和 10。最后我通过了补题,但仍然需要后续练习巩固,不能把一次答对当成已经熟练掌握。
七、dev、build、preview:源码和成品不是一回事
这是今天收尾时又确认的一组区别:
| 命令 | 用途 |
|---|---|
npm run dev |
开发时使用,修改源码后通常通过热更新看到变化 |
npm run build |
执行当前项目的类型检查与构建,生成 dist |
npm run preview |
在本地预览已有的 dist 构建产物 |
我问过:运行 preview 后再改代码,页面会不会同步?
不会自动把源码更新重新打包进去。 preview 读取的是上一次构建的文件。改了源码后,需要重新执行 npm run build,构建成功再刷新预览页面;如果预览服务已停止,再启动它。
powershell
# 平时边写边看
npm run dev
# 验收打包后的版本
npm run build
npm run preview
这就像源文件和导出成品:修改源文件,不代表之前导出的成品会自动变化。
本次构建与预览结果
最后由助手直接执行构建,并使用独立浏览器会话检查:
- 类型检查和构建通过。
- 用户列表成功加载接口数据。
- 导航切换到待办页正常。
- 添加临时待办后刷新,数据仍然保留。
- 直接请求
/todos返回 HTTP 200,浏览器刷新没有出现 404。 - 刷新后的控制台没有错误和警告。
临时待办已删除,测试浏览器和临时预览服务已关闭,没有修改我原来浏览器中的待办数据。
这只说明本地构建预览通过,不代表网站已上线。 真实部署使用 history 路由时,还要检查托管服务器的 SPA fallback:访问 /todos 等前端路由时,应能返回应用入口,再交给前端路由处理,而不是直接报告文件不存在。
总结
第三天,我开始把"组件写在哪里""状态由谁保存"和"地址如何对应页面"联系起来理解。按钮不显示时,先检查当前路由究竟渲染哪个组件;数据不同步时,先判断两个组件是否使用同一份状态,而不是只看变量名。
三天计划完成的是一轮主题覆盖和主要功能练习,不是三天精通 Vue。部分现有代码通过阅读和修改理解,部分重复实操与总结复述选择跳过,这些都应该和真正运行验证过的内容分开记录。
接下来,我想减少对现成代码的依赖,自己重新做一个包含列表、详情、待办和共享计数的小项目,把这些概念真正写熟。