外观
第7章:共享状态与工程化交付
约 8328 字大约 28 分钟
Pinia状态管理环境变量Git
2026-07-28
商品区点了“加入购物车”,顶部角标要马上增加,购物车区域也要出现同一件商品。如果三个位置各存一份数组,很快就会出现“顶部说 2 件,购物车却只有 1 件”的问题。
本章用 Pinia 建立一份购物车真源,让商品区、顶部角标和购物车区域共同读取、共同修改;随后加入安全的本地持久化,并把项目整理成别人能够重新安装、运行和构建的仓库。
学习说明
本章依次学习状态归属、Pinia Store、跨页面共享、持久化和工程化交付,并通过章末任务完成综合应用。
配套资源
本章示例工程位于 resources/ch07/classroom-demo/,章末任务起始工程位于 resources/ch07/after-class-starter/。先在对应项目中执行 npm install,再执行 npm run dev。
🎯 学习梯度指引(分层通关)
- 核心必学(保底通关 · Pinia 单主线与多处共享):掌握 7.1、7.2(状态归属判定:局部状态留在组件,跨页面真源才进 Store)、7.3(Pinia 规范注册、setup 式
defineStore、storeToRefs响应式解构)、7.4(商品区加购、顶部角标汇总、购物车列表管理“三处同源”协同)、7.5(基础localStorage恢复与保存、try...catch基础容错防白屏)、7.6(基础规范 README 编写与npm run build构建验证)。能够独立使用 Pinia 完成多组件状态共享并完成工程产物交付。 - 进阶选学(🌟 自主拓展 · 状态订阅与工程化素养):7.5(Pinia 深度订阅
$subscribe机制、多版本存储 Key 升级隔离、复杂脏数据深度清理)、7.6(Git Conventional Commits 规范化提交粒度、npm run preview生产产物本地预览与部署边界)。供学有余力或有进阶工程需求的学生自主探索,不作为基础达标强制考核。
7.1 本章任务清单
本章不是把所有变量都搬进 Store,而是先判断状态归属,再共享真正需要跨页面使用的业务状态。
| 任务 | 具体怎么做 | 完成后应该看到 |
|---|---|---|
| 判断状态归属 | 根据使用范围和寿命,把状态放在组件、页面、URL 或 Pinia | 得到“状态 → 推荐位置 → 理由”表,不把所有变量塞进 Store |
| 建立购物车 Store | 注册 Pinia,使用 setup 式 defineStore 编写 state、派生结果和业务动作 | 重复加入同一商品时只增加 quantity,不产生重复行 |
| 接通三个位置 | 商品区负责加购,顶部显示总件数,购物车区域修改和删除 | 任意一处操作后,三处数据立即同步 |
| 完成安全持久化 | 用 $subscribe 保存,用 onMounted 恢复,用 try/catch 处理损坏 JSON | 刷新后购物车保留;损坏数据不会让页面白屏 |
| 整理工程交付 | 检查环境变量、目录职责、提交粒度和 README | 新目录能按 README 完成安装、启动和构建 |
开工顺序
- 复制
resources/ch07/classroom-demo/作为课堂工程,执行npm install和npm run dev。 - 先判断哪些状态真正需要共享,再在
main.js注册 Pinia。 - 创建 setup 式购物车 Store,先验证加购、改数量、删除和派生总价。
- 接入商品区、顶部角标和购物车区域,再加入持久化和损坏数据回退。
- 检查目录、前端环境变量和提交粒度,最后补齐 README 并执行
npm run build。
完成后应该是什么样
商品区点击“加入购物车”后,顶部角标立即增加,购物车区域出现商品;修改数量后总件数和总价自动变化。刷新后购物车按设计恢复,把本地数据改坏后页面仍能打开。把项目复制到新目录后,只看 README 也能重新运行。
7.2 知识点一:状态先归属,再进 Store
学习目标
掌握状态归属的判断方法,能够为局部状态、导航状态和跨页面状态选择合适位置。
语法/概念
Store 可以理解为“多个页面共同使用的业务仓库”。放进去的数据能被很多地方读取,也意味着很多地方会依赖它。因此,Store 不是更高级的变量箱。
先问三个问题:
- 谁需要读这个状态?
- 关闭当前页面后还要不要保留?
- 修改规则是不是需要集中管理?
交互演示
状态应该留在哪里
按共享范围和存活时间判断局部状态、路由状态与 Pinia 状态。
- 1识别所有者who changes it
- 2判断范围who reads it
- 3判断寿命how long
- 4进入 Storeshared business state
当前发生
先确认谁创建、修改和销毁这份状态。
观察证据
状态职责能用一句话说明。
| 状态 | 推荐位置 | 理由 |
|---|---|---|
| 表单输入草稿 | 表单组件 | 只服务本次编辑,取消即销毁 |
| 弹窗开关 | 页面或弹窗父组件 | 只是局部 UI 状态 |
| 搜索词、页码 | 路由 query 或页面 | 需要刷新、分享时优先放 URL |
| 商品列表 loading | 当前页面 | 只属于这一次请求 |
| 购物车条目 | Pinia Store | 多处读取,并且有加购、删除等规则 |
| 当前登录用户摘要 | Pinia 或会话模块 | 多页面读取,但权限仍由服务端确认 |
最小判断模板:
// 只在当前输入框使用:留在组件
const keyword = ref('')
// 多个页面共同读取:才考虑 Store
const cart = useCartStore()全局状态不是“更高级”
Store 增加了共享能力,也增加了耦合和生命周期。能留在局部的状态就留在局部,能由 URL 表达的导航状态优先由 URL 表达。
问题: “新增商品弹窗是否打开”只有当前页面使用,它有必要进入购物车 Store 吗?
课堂演示
步骤 1:建立一张可见的状态归属表
先把 src/App.vue 改为:
src/App.vue
<script setup>
const states = [
{
name: '商品搜索词',
place: '页面或 URL query',
reason: '刷新或分享时可能需要恢复',
},
{
name: '编辑弹窗开关',
place: '当前页面',
reason: '离开页面后不需要保留',
},
{
name: '购物车条目',
place: 'Pinia Store',
reason: '商品区、顶部和购物车都要读取',
},
{
name: '商品列表 loading',
place: '当前页面',
reason: '只描述这一次列表请求',
},
]
</script>
<template>
<main class="page">
<h1>状态应该放在哪里</h1>
<table>
<thead>
<tr>
<th>状态</th>
<th>推荐位置</th>
<th>理由</th>
</tr>
</thead>
<tbody>
<tr v-for="state in states" :key="state.name">
<td>{{ state.name }}</td>
<td>{{ state.place }}</td>
<td>{{ state.reason }}</td>
</tr>
</tbody>
</table>
</main>
</template>
<style scoped>
.page {
width: min(860px, calc(100% - 32px));
margin: 48px auto;
font: 16px/1.7 system-ui, sans-serif;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 12px;
border: 1px solid #d8deea;
text-align: left;
}
th {
background: #f4f7fb;
}
</style>保存/运行后应该看到:页面显示四条状态及其推荐位置,购物车条目是唯一明确进入 Pinia 的业务状态。
步骤 2:用三个问题逐条核对
依次指着表格回答:
- 只有当前组件使用吗?
- 刷新或分享链接后需要恢复吗?
- 是否有多处共同使用的业务规则?
运行后应该看到:搜索词不一定进入 Store,弹窗和 loading 留在页面,购物车才进入 Store。
当堂练习
- 【必做任务(基础通关)】:分析“购物车条目”与“新增商品弹窗开关”两个典型状态,判断它们应该存放在 Pinia Store 还是组件局部,并用一句话说清理由(对比跨页面多处共享 vs 局部即时销毁)。
- 【选做挑战(🌟 自主拓展)】:完成包含全部 5 项状态的完整归属表(商品搜索词、新增弹窗开关、购物车条目、商品列表 loading、当前用户摘要),并额外为 1 个中后台典型场景(如“后台左侧菜单折叠状态”或“暗黑主题模式开关”)做出归属判定与理由陈述。
7.3 知识点二:Pinia 核心概念、注册与 setup 式 Store
学习目标
掌握 Pinia 的注册、setup 式 Store 和 storeToRefs,能够建立可响应的购物车状态、派生结果和业务动作。
语法/概念
本课程统一使用组合式 setup 写法:
export const useCartStore = defineStore('cart', () => {
const items = ref([])
const totalItems = computed(() => 0)
function addItem(product) {
// 集中处理加购规则
}
return { items, totalItems, addItem }
})三类内容可以这样对应:
| Store 概念 | setup 式写法 | 简单说明 |
|---|---|---|
| state | ref | 真正保存的原始数据 |
| getters | computed | 根据原始数据自动算出的结果 |
| actions | 普通函数 | 集中处理修改规则 |
为什么要使用 storeToRefs
Store 本身是响应式对象,模板直接写 cart.totalItems 可以正常更新。如果想把数据取出来单独使用,不能直接普通解构:
// 不推荐:totalItems 只是解构时取得的普通值
const { totalItems } = cart
// 正确:得到保持连接的 ref
const { items, totalItems, totalPrice } = storeToRefs(cart)storeToRefs 像给解构后的变量接上一根线。Store 变化时,这些变量仍会更新。业务动作可以继续写成 cart.addItem(product),读起来最清楚。
路由守卫中的注册顺序
如果路由守卫在组件外读取 Store,要先创建并注册同一个 Pinia 实例,再让守卫使用它;本章不展开守卫里的 Store 代码。
避坑警示:解构响应式断裂与 Pinia 注册顺序
- 禁止直接对 Store 展开普通解构:从 Store 中解构状态(state)和计算属性(getters)时,必须使用
storeToRefs(cart);直接使用 ES6 普通解构(如const { totalItems } = cart)会切断响应式连接,导致 Store 数据更新时界面不刷新。业务函数(actions,如addItem)直接从 Store 实例调用即可,或直接解构函数。 - Pinia 必须先注册后使用:在
src/main.js中必须先执行app.use(pinia),然后组件才能安全调用useCartStore()。如果在 Pinia 根实例被注入 Vue 应用之前就尝试初始化 Store,控制台将报出getActivePinia was called with no active Pinia致命错误。
问题: totalItems 和 totalPrice 都能根据 items 算出来,它们应该再保存成 state,还是写成派生结果?
课堂演示
步骤 1:安装并注册 Pinia
先检查 package.json。没有 Pinia 时执行:
npm install pinia创建 Pinia 根实例的 API 名称是 createPinia,它必须在组件使用 Store 前交给 Vue 应用。
保存/运行后应该看到:项目仍能正常打开,Console 不出现 getActivePinia() 相关错误。
步骤 2:创建 setup 式购物车 Store
Store 中由已有数据推导结果使用 computed,数量和总价不再手工维护第二份。
保存后应该看到:items 是唯一购物车真源;总件数和总价都由 computed 自动计算;重复加入同一 id 时只增加 quantity。
步骤 3:在页面中使用 Store
把 src/App.vue 改为:
src/App.vue
<script setup>
import { storeToRefs } from 'pinia'
import { useCartStore } from './stores/cart'
const cart = useCartStore()
const { items, totalItems, totalPrice } =
storeToRefs(cart)
const product = {
id: 1,
name: 'Vue 学习卡',
price: 29.9,
}
</script>
<template>
<main class="page">
<h1>setup 式购物车</h1>
<button type="button" @click="cart.addItem(product)">
加入 Vue 学习卡
</button>
<p>购物车总件数:{{ totalItems }}</p>
<p>购物车总价:¥{{ totalPrice.toFixed(2) }}</p>
<ul>
<li v-for="item in items" :key="item.id">
{{ item.name }} × {{ item.quantity }}
</li>
</ul>
</main>
</template>
<style scoped>
.page {
max-width: 680px;
margin: 48px auto;
padding: 24px;
font: 16px/1.7 system-ui, sans-serif;
}
button {
padding: 9px 13px;
}
</style>保存/运行后应该看到:连续点击按钮时,列表仍只有一行,quantity、总件数和总价会同步增加。
当堂练习
- 【必做任务(基础通关)】:
- 为购物车 Store 增加
clearaction,集中把items.value恢复为空数组并在 return 中暴露; - 在页面中添加“清空购物车”按钮(购物车为空时置灰禁用),点击后调用
cart.clear(); - 验证点击清空后,列表被清空,派生的
totalItems和totalPrice自动归零,且组件中严禁直接写cart.items = []。
- 为购物车 Store 增加
- 【选做挑战(🌟 自主拓展)】: 为加购操作增加“库存上限控制”(例如设定每件商品最大购买量为 5 件)。当用户在商品区连续点击加入购物车达到上限时,通过控制台或轻提示反馈“库存不足”,阻止继续递增。
7.4 知识点三:三处页面共享一个 Store
学习目标
掌握多个组件读取同一 Store 的方式,能够让商品区、顶部角标和购物车区域保持同步。
语法/概念
多个页面共享不是互相发送很多通知,而是共同读取同一个 Store:
const cart = useCartStore()
const { totalItems } = storeToRefs(cart)三个位置各自只做自己的事:
| 位置 | 读取什么 | 发出什么动作 |
|---|---|---|
| 商品区 | 商品信息 | addItem(product) |
| 顶部角标 | totalItems | 不直接修改 |
| 购物车区域 | items、totalPrice | setQuantity、removeItem、clear |
组件不要直接拼接 items 数组。页面只说“加入”“改数量”“删除”,具体规则留在 Store。
问题: 如果顶部角标自己保存一个数量,购物车区域又自己保存一个数量,哪一个才是真数据?
课堂演示
本节从上一节已经完成 clear action 的 Store 继续。
步骤 1:创建顶部角标
src/components/AppHeader.vue
<script setup>
import { storeToRefs } from 'pinia'
import { useCartStore } from '../stores/cart'
const cart = useCartStore()
const { totalItems } = storeToRefs(cart)
</script>
<template>
<header class="app-header">
<strong>校园微商城</strong>
<span>购物车({{ totalItems }})</span>
</header>
</template>保存后应该看到:顶部组件只读取总件数,不保存自己的 count,也不修改购物车。
步骤 2:创建商品区
src/views/ProductDetailView.vue
<script setup>
import { useCartStore } from '../stores/cart'
const cart = useCartStore()
const products = [
{ id: 1, name: 'Vue 学习卡', price: 29.9 },
{ id: 2, name: '前端排错记录本', price: 16 },
]
</script>
<template>
<section>
<h2>商品区</h2>
<div class="product-grid">
<article
v-for="product in products"
:key="product.id"
class="card"
>
<strong>{{ product.name }}</strong>
<span>¥{{ product.price }}</span>
<button
type="button"
@click="cart.addItem(product)"
>
加入购物车
</button>
</article>
</div>
</section>
</template>保存后应该看到:点击任意商品的“加入购物车”,Store 中对应商品增加,顶部角标同步变化。
步骤 3:创建购物车区域
src/views/CartView.vue
<script setup>
import { storeToRefs } from 'pinia'
import { useCartStore } from '../stores/cart'
const cart = useCartStore()
const { items, totalPrice } = storeToRefs(cart)
</script>
<template>
<section>
<h2>购物车</h2>
<p v-if="items.length === 0">还没有商品。</p>
<ul v-else class="cart-list">
<li v-for="item in items" :key="item.id">
<span>{{ item.name }}</span>
<input
:value="item.quantity"
type="number"
min="1"
@change="
cart.setQuantity(
item.id,
Number($event.target.value),
)
"
/>
<button
type="button"
@click="cart.removeItem(item.id)"
>
删除
</button>
</li>
</ul>
<p>合计:¥{{ totalPrice.toFixed(2) }}</p>
<button
type="button"
:disabled="items.length === 0"
@click="cart.clear"
>
清空购物车
</button>
</section>
</template>保存后应该看到:购物车可以修改数量、删除和清空;顶部角标和总价自动同步。
步骤 4:把三个区域放进根页面
三处共享同一购物车
src
App.vue
src/App.vue
<script setup>
import AppHeader from './components/AppHeader.vue'
import CartView from './views/CartView.vue'
import ProductDetailView from './views/ProductDetailView.vue'
</script>
<template>
<main class="page">
<AppHeader />
<div class="layout">
<ProductDetailView />
<CartView />
</div>
</main>
</template>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
background: #f7f8fc;
color: #172033;
font: 16px/1.7 system-ui, sans-serif;
}
.page {
width: min(980px, calc(100% - 32px));
margin: 32px auto;
}
.app-header {
display: flex;
justify-content: space-between;
padding: 16px 20px;
border-radius: 14px;
background: #172033;
color: white;
}
.layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
margin-top: 18px;
}
.layout > section {
padding: 20px;
border-radius: 14px;
background: white;
}
.product-grid,
.cart-list {
display: grid;
gap: 10px;
}
.card,
.cart-list li {
display: grid;
gap: 8px;
padding: 14px;
border: 1px solid #dce2ef;
border-radius: 10px;
}
button,
input {
padding: 8px 10px;
font: inherit;
}
@media (max-width: 680px) {
.layout {
grid-template-columns: 1fr;
}
}
</style>保存/运行后应该看到:页面同时出现顶部角标、商品区和购物车区;任何区域操作都围绕同一个 cart Store,数据不会互相打架。
当堂练习
- 【必做任务(基础通关)】:
- 在
ProductDetailView.vue商品卡片上增加“购物车中已有 N 件”辅助提示; - 编写一个辅助查询函数,根据商品
id从cart.items实时查找条目(未找到时显示 0 件); - 验证加购或在购物车中修改数量/删除时,商品卡片上的数量提示与顶部角标、购物车列表保持实时同步,严禁在商品组件内保存独立的数量状态。
- 在
- 【选做挑战(🌟 自主拓展)】: 在
cart.jsStore 中将其封装为一个柯里化 getter 计算属性(例如getItemCountById: (state) => (id) => ...),或者当某件商品的已加购件数 > 0 时,将商品卡片的“加入购物车”按钮切换为带有加减符号的“步进器”控件。
7.5 知识点四:持久化与损坏数据回退
学习目标
掌握 $subscribe、localStorage、版本 key 和异常回退,能够安全保存并恢复非敏感购物车状态。
语法/概念
Pinia 解决的是“运行期间共享”,刷新浏览器后内存会重新开始。持久化就是把必要数据先保存成字符串,页面再次打开时再恢复。
localStorage.setItem('key', JSON.stringify(data))
const data = JSON.parse(localStorage.getItem('key'))本章只保存购物车条目,不保存密码、令牌或隐私信息。
| 工具 | 主要作用 |
|---|---|
$subscribe | Store 每次变化后执行保存 |
localStorage | 浏览器中的字符串存储 |
$patch | 一次恢复一组 Store 字段 |
try/catch | 旧数据损坏时阻止页面崩溃 |
| 版本 key | 数据结构升级时区分旧格式 |
存储 key 统一使用:
const STORAGE_KEY = 'vue-course-cart-v1'版本后缀 v1 表示这是第一版数据格式。以后字段结构大改,可以使用新版本 key,不必误读旧数据。
问题: 如果 Application 面板里的字符串不是合法 JSON,直接执行 JSON.parse 会发生什么?
课堂演示
步骤 1:在页面挂载时恢复购物车
在 src/App.vue 的 <script setup> 中加入:
import { onMounted } from 'vue'
import { useCartStore } from './stores/cart'
const cart = useCartStore()
const STORAGE_KEY = 'vue-course-cart-v1'
onMounted(() => {
try {
const saved = JSON.parse(
localStorage.getItem(STORAGE_KEY) || 'null',
)
if (Array.isArray(saved?.items)) {
cart.$patch({ items: saved.items })
return
}
localStorage.removeItem(STORAGE_KEY)
} catch {
cart.$patch({ items: [] })
localStorage.removeItem(STORAGE_KEY)
}
})保存/运行后应该看到:第一次没有旧数据时页面正常打开;存在合法 items 时,购物车在页面挂载后恢复。
步骤 2:订阅 Store 变化并保存
紧接着加入:
cart.$subscribe((_mutation, state) => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ items: state.items }),
)
})保存/运行后应该看到:加入、改数量、删除或清空后,DevTools 的 Application → Local Storage 中会同步出现 vue-course-cart-v1。
步骤 3:验证正常恢复
- 加入两件商品;
- 打开 Application → Local Storage;
- 确认 key 是
vue-course-cart-v1; - 刷新页面。
运行后应该看到:刷新后商品和数量仍然存在,顶部角标和总价与恢复的数据一致。
步骤 4:验证损坏数据回退
把 vue-course-cart-v1 的 value 手工改为:
{broken-json然后刷新页面。
运行后应该看到:页面不会白屏,购物车回到空状态,损坏的存储项被删除。
不把秘密放进 localStorage
密码、完整令牌、Cookie、身份证号和真实隐私数据不能照搬购物车示例保存。浏览器里的前端数据可以被当前用户查看和修改。
🌟 进阶选学:Pinia 状态订阅机制与数据版本迁移
- 为什么使用
$subscribe:常规watch监听复杂深层对象需要开启deep: true,开销较大;而 Pinia 提供的$subscribe专门针对该 Store 的变更进行监听,在每次 patch 或 action 执行后触发,且能够区分是通过直接修改还是通过$patch带来的变动。 - 版本 Key 的升级设计:当业务从单纯保存
{ id, name, price, quantity }升级为支持多规格属性(如{ id, skuId, specs, count })时,如果继续读取旧缓存会导致取值undefined甚至代码崩溃。此时将版本 key 升级为vue-course-cart-v2,即可自动隔离旧版本残存数据,并在检测到旧 key 时安全清除。在更复杂的工程化项目中,还可以编写数据迁移适配器(Migration Function)实现无缝兼容。
当堂练习
- 【必做任务(基础通关)】:
- 将项目中的购物车存储 key 统一命名为带有版本标识的
vue-course-cart-v1; - 在页面挂载阶段(
onMounted)使用try...catch包裹JSON.parse逻辑,若存在合法items则通过$patch安全恢复; - 验证向购物车加购商品后,刷新页面能够正确恢复已有商品,控制台无报错。
- 将项目中的购物车存储 key 统一命名为带有版本标识的
- 【选做挑战(🌟 自主拓展)】: 在浏览器开发者工具(Application → Local Storage)中,人为将
vue-course-cart-v1的内容修改为不合法的 JSON 字符串(例如{corrupted_data);刷新页面,验证catch异常分支是否能静默捕获该错误、安全重置为空购物车并清理损坏键值,页面不发生白屏崩溃。
7.6 知识点五:工程化交付
学习目标
掌握前端环境变量边界、项目目录职责、Git 提交粒度和 README,能够把课堂工程整理成可复现仓库。
语法/概念
工程化交付不是“把我电脑上的文件压缩一下”,而是让另一个人知道环境、入口、运行方法、构建方法和已知问题。
环境变量不是保险箱
Vite 只把带 VITE_ 前缀的变量暴露给客户端代码。这不是安全功能,而是暴露规则:进入浏览器包的任何值都可被用户查看。
| 可以放入前端环境变量 | 不能放入 |
|---|---|
| API 公共根路径 | 数据库密码 |
| 公开站点标识 | 服务端私钥 |
| 功能开关 | 第三方高权限 secret |
| 构建环境名称 | 永久访问令牌 |
环境变量只讲公开配置边界,本章不演示后端,也不演示任何真实密钥。
目录要表达职责
综合项目建议结构
src
api# Axios 实例与领域接口
…
assets# 经构建处理的图片与样式
…
components# 可复用展示与业务组件
…
layouts# 稳定页面骨架
…
router# 路由记录与访问入口
…
stores# 跨页面共享业务状态
…
views# 页面级组件
…
App.vue# 根组件与一级出口
main.js# 应用、插件与全局样式入口
public# 原样复制的公共静态资源
…
.env.example# 公开变量名与示例值
package.json# 脚本与直接依赖
package-lock.json# npm 依赖解析记录
README.md# 运行、构建、功能与已知问题
结构不是越深越专业。每个目录都要回答“这一类代码为什么放在一起”,并让新加入项目的人能找到修改入口。
Git 提交一次只说清一件事
好的提交说明能让人看懂这次变化:
feat: add cart quantity actionsfix: keep cart data after refreshdocs: add local setup and build instructions
本章只学习提交粒度和 message 例子,不展开 Git 命令。
README 是项目说明入口
最小 README 语法模板:
# 项目名称
## 环境
## 本地运行
## 构建
## 已知问题问题: 一个没有参与开发的人,最少需要哪些信息才能在新目录中把项目运行起来?
课堂演示
步骤 1:检查前端环境变量
检查 .env.example、.env.development 和源码,只记录:
- 公开 API 根路径;
- 公开功能开关;
- 构建环境名称。
保存/检查后应该看到:前端环境变量中没有密码、私钥、高权限 secret 或永久令牌。
步骤 2:按职责检查目录
把 API、Store、页面和组件分别对照上面的 file-tree:
- 请求函数放
api/; - 跨页面购物车放
stores/; - 页面级组件放
views/; - 可复用展示放
components/。
检查后应该看到:同一职责的文件放在一起,没有为了“看起来专业”创建空目录。
步骤 3:把开发过程拆成小提交
播放交付链,说明“我的电脑能跑”和“别人能复现”之间还缺哪些证据。
交互演示
从代码到可交付仓库
工程规范最终要形成别人能运行、能理解、能复现的证据。
- 1整理边界folders
- 2隔离环境.env
- 3形成提交Git
- 4写运行说明README
当前发生
页面、组件、接口、状态和资源各归其位。
观察证据
新同学能从目录找到修改入口。
检查后应该看到:加购物车 action、修复持久化、补 README 是三件可分别描述的任务,不混成“update project”。
步骤 4:编写可复现 README
仓库中承载运行说明与项目入口的文件名是 README,它要让别人不用询问作者也能完成基本操作。
保存后应该看到:README 至少包含环境、运行、构建和已知问题四节,命令与 package.json 中的脚本一致。
步骤 5:执行最终构建
npm run build运行后应该看到:终端提示构建成功,并生成 dist/;如果失败,先按报错定位,不要只删除错误行。
当堂练习
- 【必做任务(基础通关)】:
- 为自己的课堂工程编写规范 README,必须涵盖“环境要求、本地启动、生产构建、购物车演示路径”四节;
- 核查
.env*配置文件与 README,确保绝无任何数据库密码、私钥或高权限 secret 等敏感内容; - 在终端中执行
npm run build,成功输出构建产物目录dist/,确认无控制台打包报错。
- 【选做挑战(🌟 自主拓展)】: 在工程中执行
npm run preview(或npx serve dist),在本地体验生产构建产物的真实访问效果;同时在 README 的“已知问题”小节中,结合第 4 章内容,补充说明单页面应用(SPA)在 History 模式部署到 Nginx 或静态托管平台时所需的服务器重定向回退配置(try_files $uri $uri/ /index.html)。
7.7 本章串联验收
| 验收动作 | 应该看到的结果 |
|---|---|
| 重复加入同一商品 | 只有一行商品,quantity 增加 |
| 修改购物车数量 | 顶部总件数和总价同步变化 |
| 删除或清空 | 三处页面同时更新 |
| 刷新页面 | 合法购物车数据恢复 |
| 写入损坏 JSON | 页面回到空购物车,不白屏 |
| 检查环境变量 | 没有密码、私钥或高权限 secret |
| 按 README 重新运行 | 安装、启动和构建命令有效 |
7.8 复盘检查点
学习自检
停一下:共享与交付边界清楚吗
确认 Store、持久化、环境变量和仓库交付都有合理范围。
7.9 本章小测
章节测评
3 题第7章小测:Pinia 与工程交付
检查状态归属、派生值和前端秘密边界。
7.10 本章知识自检
| 我能做到 | 自检方法 |
|---|---|
| 判断状态归属 | 能说明组件、URL、Pinia 的选择理由 |
| 编写 setup 式 Store | 使用 ref、computed 和函数,不混用另一套写法 |
| 保持响应式解构 | 使用 storeToRefs 读取 state 和派生结果 |
| 多处共享 | 商品区、角标、购物车共同读取一个 Store |
| 安全持久化 | 使用版本 key,合法数据恢复,损坏数据回退 |
| 说明秘密边界 | 知道 VITE_ 变量进入浏览器后并不保密 |
| 完成交付 | README 与构建结果能让别人复现 |
全部做到后,再开始章末课后任务。
7.11 常见误区
| 误区 | 后果 | 修正 |
|---|---|---|
| 所有状态都进 Store | 依赖扩大、调试困难 | 先做归属判断 |
| 直接存派生总价 | 与条目不同步 | 使用 getter |
| 组件任意修改 Store 数组 | 业务规则散落 | 通过 action 表达动作 |
| 持久化所有 state | 隐私、过期与迁移风险 | 只保存必要字段并加版本 |
把 secret 放 VITE_ 变量 | 构建后公开 | 秘密留在服务端 |
| 一次提交整个项目 | 无法审查与复盘 | 按可验证任务提交 |
7.12 课后任务:课程资料收藏夹 Store
任务目标
课程首页、资料列表和收藏页都需要读取同一份收藏数据。请从 resources/ch07/after-class-starter/ 开始,用 Pinia 管理真正跨页面共享的收藏状态,同时把搜索词、弹窗开关等局部状态留在各自页面,并完成持久化与构建验收。
基础通关要求(全员必做)
- 创建收藏夹 Store:使用 setup 式
defineStore编写src/stores/favorite.js,使用ref管理收藏条目数组; - 派生计算属性:使用
computed派生收藏总数、总学习时长等计算结果,不额外保存可以计算出的第二份冗余数据; - 集中业务动作:提供添加收藏、移除收藏、修改学习时长和清空收藏等 actions 方法;
- 多位置状态实时同步:资料列表与顶部角标读取同一个 Store,任一处操作后其他位置实时同步更新;
- 二次确认防误触:清空收藏前弹窗提示确认,点击取消时保持原数据不变;
- 安全本地持久化:使用带有版本标识的 key(如
vue-course-fav-v1)将收藏数据保存至localStorage,并在挂载时使用try...catch安全恢复,数据损坏时不白屏; - 状态归属说明与构建:简述为什么搜索词和弹窗开关应留在组件局部,并执行
npm run build成功输出dist/目录。
进阶拓展任务(自主选做 · 🌟 自主拓展)
- 异常脏数据主动破坏与回退测试:在 DevTools Application 面板中人为将本地存储修改为畸形 JSON 字符串,刷新页面,验证系统是否能够安全清空并平稳回到空收藏状态,记录控制台无报错的表现;
- 版本 Key 隔离与迁移设计:设计版本升级策略(例如从
v1升级到v2,为每个收藏项增加“最后学习时间戳”字段),编写检测并清理旧版本键值的容错逻辑; - 规范化工程交付:整理符合规范的 Git 提交记录,并在 README 中补充单页面应用在生产环境部署时的路由回退说明。
必须使用的知识
- setup 式
defineStore; computed派生结果;- actions 集中业务规则;
- 多页面读取同一 Store 与
storeToRefs; $subscribe持久化与损坏数据回退;- 状态归属判断;
npm run build验收。
完成效果
在资料列表点击收藏后,顶部角标和收藏页立即同步;修改学习时长后总时长自动更新;刷新浏览器后收藏仍然存在;把本地存储改成错误 JSON 后页面仍能打开并回到空收藏夹。
全员需完成基础通关要求;学有余力者可自主完成进阶拓展任务并在实践中深入探索。
验收步骤
| 顺序 | 操作 | 达标结果 |
|---|---|---|
| 1 | 在资料列表收藏两份资料 | 顶部角标和收藏页立即同步 |
| 2 | 重复收藏同一 id | 不出现重复行,按既定规则处理 |
| 3 | 修改学习时长 | 总学习时长自动更新,没有第二份真源 |
| 4 | 点击清空后选择取消 | 收藏数据保持不变 |
| 5 | 再次清空并确认 | 条目、总数和总时长同时归零 |
| 6 | 刷新浏览器 | 合法收藏数据能够恢复 |
| 7 | 阅读恢复代码并用一种无效数据验证 | 页面回到安全初始状态,不白屏 |
| 8 | 执行 npm run build | 构建成功,无 Console 报错 |
进阶验收(🌟 自主拓展):在 Application 面板手工写入损坏 JSON,提交刷新前后和存储清理证据。
提交内容
- 项目源码,不包含
node_modules/和dist/; - 收藏前后和刷新恢复效果图;
- 状态归属说明;
- 构建成功截图。
完成进阶拓展任务时,再补交本地存储损坏后的安全回退效果图和扩展状态归属表。
本章小结
- 先判断状态所有者、共享范围和寿命,再决定是否进入 Pinia。
- setup 式 Store 用
ref保存真源,用computed计算派生结果,用函数表达业务动作。 - 多个页面共同读取同一个 Store,不再各自保存一份购物车。
- 持久化要最小化、版本化,并处理损坏数据。
- 前端环境变量进入浏览器后并不保密。
- 工程规范最终要变成可运行、可理解、可追溯的仓库。
- 下一章只分析微商城前台与管理端的页面功能和项目边界。
