---
url: /courses/web-frontend-framework/pinia-engineering/index.md
---
# 第7章：共享状态与工程化交付

商品区点了“加入购物车”，顶部角标要马上增加，购物车区域也要出现同一件商品。如果三个位置各存一份数组，很快就会出现“顶部说 2 件，购物车却只有 1 件”的问题。

本章用 Pinia 建立一份购物车真源，让商品区、顶部角标和购物车区域共同读取、共同修改；随后加入安全的本地持久化，并把项目整理成别人能够重新安装、运行和构建的仓库。

::: tip 学习说明
本章依次学习状态归属、Pinia Store、跨页面共享、持久化和工程化交付，并通过章末任务完成综合应用。
:::

::: tip 配套资源
本章示例工程位于 `resources/ch07/classroom-demo/`，章末任务起始工程位于 `resources/ch07/after-class-starter/`。先在对应项目中执行 `npm install`，再执行 `npm run dev`。
:::

::: tip 🎯 学习梯度指引（分层通关）

* **核心必学（保底通关 · 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 完成安装、启动和构建 |

### 开工顺序

:::: steps

1. 复制 `resources/ch07/classroom-demo/` 作为课堂工程，执行 `npm install` 和 `npm run dev`。
2. 先判断哪些状态真正需要共享，再在 `main.js` 注册 Pinia。
3. 创建 setup 式购物车 Store，先验证加购、改数量、删除和派生总价。
4. 接入商品区、顶部角标和购物车区域，再加入持久化和损坏数据回退。
5. 检查目录、前端环境变量和提交粒度，最后补齐 README 并执行 `npm run build`。
   ::::

::: tip 完成后应该是什么样
商品区点击“加入购物车”后，顶部角标立即增加，购物车区域出现商品；修改数量后总件数和总价自动变化。刷新后购物车按设计恢复，把本地数据改坏后页面仍能打开。把项目复制到新目录后，只看 README 也能重新运行。
:::

## 7.2 知识点一：状态先归属，再进 Store

**学习目标**

掌握状态归属的判断方法，能够为局部状态、导航状态和跨页面状态选择合适位置。

**语法/概念**

Store 可以理解为“多个页面共同使用的业务仓库”。放进去的数据能被很多地方读取，也意味着很多地方会依赖它。因此，Store 不是更高级的变量箱。

先问三个问题：

1. 谁需要读这个状态？
2. 关闭当前页面后还要不要保留？
3. 修改规则是不是需要集中管理？

| 状态 | 推荐位置 | 理由 |
| --- | --- | --- |
| 表单输入草稿 | 表单组件 | 只服务本次编辑，取消即销毁 |
| 弹窗开关 | 页面或弹窗父组件 | 只是局部 UI 状态 |
| 搜索词、页码 | 路由 query 或页面 | 需要刷新、分享时优先放 URL |
| 商品列表 loading | 当前页面 | 只属于这一次请求 |
| 购物车条目 | Pinia Store | 多处读取，并且有加购、删除等规则 |
| 当前登录用户摘要 | Pinia 或会话模块 | 多页面读取，但权限仍由服务端确认 |

最小判断模板：

```js
// 只在当前输入框使用：留在组件
const keyword = ref('')

// 多个页面共同读取：才考虑 Store
const cart = useCartStore()
```

::: warning 全局状态不是“更高级”
Store 增加了共享能力，也增加了耦合和生命周期。能留在局部的状态就留在局部，能由 URL 表达的导航状态优先由 URL 表达。
:::

**问题：** “新增商品弹窗是否打开”只有当前页面使用，它有必要进入购物车 Store 吗？

**课堂演示**

### 步骤 1：建立一张可见的状态归属表

先把 `src/App.vue` 改为：

```vue title="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 写法：

```js
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` 可以正常更新。如果想把数据取出来单独使用，不能直接普通解构：

```js
// 不推荐：totalItems 只是解构时取得的普通值
const { totalItems } = cart

// 正确：得到保持连接的 ref
const { items, totalItems, totalPrice } = storeToRefs(cart)
```

`storeToRefs` 像给解构后的变量接上一根线。Store 变化时，这些变量仍会更新。业务动作可以继续写成 `cart.addItem(product)`，读起来最清楚。

::: tip 路由守卫中的注册顺序
如果路由守卫在组件外读取 Store，要先创建并注册同一个 Pinia 实例，再让守卫使用它；本章不展开守卫里的 Store 代码。
:::

::: danger 避坑警示：解构响应式断裂与 Pinia 注册顺序

1. **禁止直接对 Store 展开普通解构**：从 Store 中解构状态（state）和计算属性（getters）时，必须使用 `storeToRefs(cart)`；直接使用 ES6 普通解构（如 `const { totalItems } = cart`）会切断响应式连接，导致 Store 数据更新时界面不刷新。业务函数（actions，如 `addItem`）直接从 Store 实例调用即可，或直接解构函数。
2. **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 时执行：

```bash
npm install pinia
```

创建 Pinia 根实例的 API 名称是 createPinia，它必须在组件使用 Store 前交给 Vue 应用。

保存/运行后应该看到：项目仍能正常打开，Console 不出现 `getActivePinia()` 相关错误。

### 步骤 2：创建 setup 式购物车 Store

Store 中由已有数据推导结果使用 computed，数量和总价不再手工维护第二份。

保存后应该看到：`items` 是唯一购物车真源；总件数和总价都由 `computed` 自动计算；重复加入同一 id 时只增加 quantity。

### 步骤 3：在页面中使用 Store

把 `src/App.vue` 改为：

```vue title="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、总件数和总价会同步增加。

**当堂练习**

* **【必做任务（基础通关）】**：
  1. 为购物车 Store 增加 `clear` action，集中把 `items.value` 恢复为空数组并在 return 中暴露；
  2. 在页面中添加“清空购物车”按钮（购物车为空时置灰禁用），点击后调用 `cart.clear()`；
  3. 验证点击清空后，列表被清空，派生的 `totalItems` 和 `totalPrice` 自动归零，且组件中严禁直接写 `cart.items = []`。
* **【选做挑战（🌟 自主拓展）】**：
  为加购操作增加“库存上限控制”（例如设定每件商品最大购买量为 5 件）。当用户在商品区连续点击加入购物车达到上限时，通过控制台或轻提示反馈“库存不足”，阻止继续递增。

## 7.4 知识点三：三处页面共享一个 Store

**学习目标**

掌握多个组件读取同一 Store 的方式，能够让商品区、顶部角标和购物车区域保持同步。

**语法/概念**

多个页面共享不是互相发送很多通知，而是共同读取同一个 Store：

```js
const cart = useCartStore()
const { totalItems } = storeToRefs(cart)
```

三个位置各自只做自己的事：

| 位置 | 读取什么 | 发出什么动作 |
| --- | --- | --- |
| 商品区 | 商品信息 | `addItem(product)` |
| 顶部角标 | `totalItems` | 不直接修改 |
| 购物车区域 | `items`、`totalPrice` | `setQuantity`、`removeItem`、`clear` |

组件不要直接拼接 `items` 数组。页面只说“加入”“改数量”“删除”，具体规则留在 Store。

**问题：** 如果顶部角标自己保存一个数量，购物车区域又自己保存一个数量，哪一个才是真数据？

**课堂演示**

本节从上一节已经完成 `clear` action 的 Store 继续。

### 步骤 1：创建顶部角标

```vue title="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：创建商品区

```vue title="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：创建购物车区域

```vue title="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：把三个区域放进根页面

::: code-tree title="三处共享同一购物车" entry="src/App.vue" height="740px"

```vue title="src/App.vue" :active
<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，数据不会互相打架。

**当堂练习**

* **【必做任务（基础通关）】**：
  1. 在 `ProductDetailView.vue` 商品卡片上增加“购物车中已有 N 件”辅助提示；
  2. 编写一个辅助查询函数，根据商品 `id` 从 `cart.items` 实时查找条目（未找到时显示 0 件）；
  3. 验证加购或在购物车中修改数量/删除时，商品卡片上的数量提示与顶部角标、购物车列表保持实时同步，严禁在商品组件内保存独立的数量状态。
* **【选做挑战（🌟 自主拓展）】**：
  在 `cart.js` Store 中将其封装为一个柯里化 getter 计算属性（例如 `getItemCountById: (state) => (id) => ...`），或者当某件商品的已加购件数 > 0 时，将商品卡片的“加入购物车”按钮切换为带有加减符号的“步进器”控件。

## 7.5 知识点四：持久化与损坏数据回退

**学习目标**

掌握 `$subscribe`、`localStorage`、版本 key 和异常回退，能够安全保存并恢复非敏感购物车状态。

**语法/概念**

Pinia 解决的是“运行期间共享”，刷新浏览器后内存会重新开始。持久化就是把必要数据先保存成字符串，页面再次打开时再恢复。

```js
localStorage.setItem('key', JSON.stringify(data))
const data = JSON.parse(localStorage.getItem('key'))
```

本章只保存购物车条目，不保存密码、令牌或隐私信息。

| 工具 | 主要作用 |
| --- | --- |
| `$subscribe` | Store 每次变化后执行保存 |
| `localStorage` | 浏览器中的字符串存储 |
| `$patch` | 一次恢复一组 Store 字段 |
| `try/catch` | 旧数据损坏时阻止页面崩溃 |
| 版本 key | 数据结构升级时区分旧格式 |

存储 key 统一使用：

```js
const STORAGE_KEY = 'vue-course-cart-v1'
```

版本后缀 `v1` 表示这是第一版数据格式。以后字段结构大改，可以使用新版本 key，不必误读旧数据。

**问题：** 如果 Application 面板里的字符串不是合法 JSON，直接执行 `JSON.parse` 会发生什么？

**课堂演示**

### 步骤 1：在页面挂载时恢复购物车

在 `src/App.vue` 的 `<script setup>` 中加入：

```js
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 变化并保存

紧接着加入：

```js
cart.$subscribe((_mutation, state) => {
  localStorage.setItem(
    STORAGE_KEY,
    JSON.stringify({ items: state.items }),
  )
})
```

保存/运行后应该看到：加入、改数量、删除或清空后，DevTools 的 Application → Local Storage 中会同步出现 `vue-course-cart-v1`。

### 步骤 3：验证正常恢复

1. 加入两件商品；
2. 打开 Application → Local Storage；
3. 确认 key 是 `vue-course-cart-v1`；
4. 刷新页面。

运行后应该看到：刷新后商品和数量仍然存在，顶部角标和总价与恢复的数据一致。

### 步骤 4：验证损坏数据回退

把 `vue-course-cart-v1` 的 value 手工改为：

```text
{broken-json
```

然后刷新页面。

运行后应该看到：页面不会白屏，购物车回到空状态，损坏的存储项被删除。

::: danger 不把秘密放进 localStorage
密码、完整令牌、Cookie、身份证号和真实隐私数据不能照搬购物车示例保存。浏览器里的前端数据可以被当前用户查看和修改。
:::

::: tip 🌟 进阶选学：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）实现无缝兼容。
  :::

**当堂练习**

* **【必做任务（基础通关）】**：
  1. 将项目中的购物车存储 key 统一命名为带有版本标识的 `vue-course-cart-v1`；
  2. 在页面挂载阶段（`onMounted`）使用 `try...catch` 包裹 `JSON.parse` 逻辑，若存在合法 `items` 则通过 `$patch` 安全恢复；
  3. 验证向购物车加购商品后，刷新页面能够正确恢复已有商品，控制台无报错。
* **【选做挑战（🌟 自主拓展）】**：
  在浏览器开发者工具（Application → Local Storage）中，人为将 `vue-course-cart-v1` 的内容修改为不合法的 JSON 字符串（例如 `{corrupted_data`）；刷新页面，验证 `catch` 异常分支是否能静默捕获该错误、安全重置为空购物车并清理损坏键值，页面不发生白屏崩溃。

## 7.6 知识点五：工程化交付

**学习目标**

掌握前端环境变量边界、项目目录职责、Git 提交粒度和 README，能够把课堂工程整理成可复现仓库。

**语法/概念**

工程化交付不是“把我电脑上的文件压缩一下”，而是让另一个人知道环境、入口、运行方法、构建方法和已知问题。

### 环境变量不是保险箱

Vite 只把带 `VITE_` 前缀的变量暴露给客户端代码。这不是安全功能，而是暴露规则：进入浏览器包的任何值都可被用户查看。

| 可以放入前端环境变量 | 不能放入 |
| --- | --- |
| API 公共根路径 | 数据库密码 |
| 公开站点标识 | 服务端私钥 |
| 功能开关 | 第三方高权限 secret |
| 构建环境名称 | 永久访问令牌 |

环境变量只讲公开配置边界，本章不演示后端，也不演示任何真实密钥。

### 目录要表达职责

::: file-tree title="综合项目建议结构" icon="colored"

* 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 actions`
* `fix: keep cart data after refresh`
* `docs: add local setup and build instructions`

本章只学习提交粒度和 message 例子，不展开 Git 命令。

### README 是项目说明入口

最小 README 语法模板：

```md
# 项目名称

## 环境

## 本地运行

## 构建

## 已知问题
```

**问题：** 一个没有参与开发的人，最少需要哪些信息才能在新目录中把项目运行起来？

**课堂演示**

### 步骤 1：检查前端环境变量

检查 `.env.example`、`.env.development` 和源码，只记录：

* 公开 API 根路径；
* 公开功能开关；
* 构建环境名称。

保存/检查后应该看到：前端环境变量中没有密码、私钥、高权限 secret 或永久令牌。

### 步骤 2：按职责检查目录

把 API、Store、页面和组件分别对照上面的 file-tree：

* 请求函数放 `api/`；
* 跨页面购物车放 `stores/`；
* 页面级组件放 `views/`；
* 可复用展示放 `components/`。

检查后应该看到：同一职责的文件放在一起，没有为了“看起来专业”创建空目录。

### 步骤 3：把开发过程拆成小提交

播放交付链，说明“我的电脑能跑”和“别人能复现”之间还缺哪些证据。

```mermaid
flowchart LR
  A[完成一个小任务] --> B[运行并自测]
  B --> C[检查 Git diff]
  C --> D[写清提交说明]
  D --> E[更新 README 或问题记录]
  E --> A
```

检查后应该看到：加购物车 action、修复持久化、补 README 是三件可分别描述的任务，不混成“update project”。

### 步骤 4：编写可复现 README

仓库中承载运行说明与项目入口的文件名是 README，它要让别人不用询问作者也能完成基本操作。

保存后应该看到：README 至少包含环境、运行、构建和已知问题四节，命令与 `package.json` 中的脚本一致。

### 步骤 5：执行最终构建

```bash
npm run build
```

运行后应该看到：终端提示构建成功，并生成 `dist/`；如果失败，先按报错定位，不要只删除错误行。

**当堂练习**

* **【必做任务（基础通关）】**：
  1. 为自己的课堂工程编写规范 README，必须涵盖“环境要求、本地启动、生产构建、购物车演示路径”四节；
  2. 核查 `.env*` 配置文件与 README，确保绝无任何数据库密码、私钥或高权限 secret 等敏感内容；
  3. 在终端中执行 `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 复盘检查点

{{reflection-checkpoint:vue-pinia-engineering-checkpoint}}

## 7.9 本章小测

{{assessment:vue-pinia-engineering-check}}

## 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 管理真正跨页面共享的收藏状态，同时把搜索词、弹窗开关等局部状态留在各自页面，并完成持久化与构建验收。

### 基础通关要求（全员必做）

1. **创建收藏夹 Store**：使用 setup 式 `defineStore` 编写 `src/stores/favorite.js`，使用 `ref` 管理收藏条目数组；
2. **派生计算属性**：使用 `computed` 派生收藏总数、总学习时长等计算结果，不额外保存可以计算出的第二份冗余数据；
3. **集中业务动作**：提供添加收藏、移除收藏、修改学习时长和清空收藏等 actions 方法；
4. **多位置状态实时同步**：资料列表与顶部角标读取同一个 Store，任一处操作后其他位置实时同步更新；
5. **二次确认防误触**：清空收藏前弹窗提示确认，点击取消时保持原数据不变；
6. **安全本地持久化**：使用带有版本标识的 key（如 `vue-course-fav-v1`）将收藏数据保存至 `localStorage`，并在挂载时使用 `try...catch` 安全恢复，数据损坏时不白屏；
7. **状态归属说明与构建**：简述为什么搜索词和弹窗开关应留在组件局部，并执行 `npm run build` 成功输出 `dist/` 目录。

### 进阶拓展任务（自主选做 · 🌟 自主拓展）

1. **异常脏数据主动破坏与回退测试**：在 DevTools Application 面板中人为将本地存储修改为畸形 JSON 字符串，刷新页面，验证系统是否能够安全清空并平稳回到空收藏状态，记录控制台无报错的表现；
2. **版本 Key 隔离与迁移设计**：设计版本升级策略（例如从 `v1` 升级到 `v2`，为每个收藏项增加“最后学习时间戳”字段），编写检测并清理旧版本键值的容错逻辑；
3. **规范化工程交付**：整理符合规范的 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，不再各自保存一份购物车。
* 持久化要最小化、版本化，并处理损坏数据。
* 前端环境变量进入浏览器后并不保密。
* 工程规范最终要变成可运行、可理解、可追溯的仓库。
* 下一章只分析微商城前台与管理端的页面功能和项目边界。
