Skip to content

组件库、生命周期与 pinia(三) ​

本页对应 组件库、生命周期与 pinia,共 6 段视频,约 57 分钟。

学习目标 ​

  • 能够使用 Pinia 组合式 API 定义包含 state 和 actions 的 store,并在 Vue 3 <script setup> 页面中导入、调用,验证数据响应式更新。
  • 能够使用 storeToRefs 解构 Pinia store 中的 state,并直接解构 actions,验证页面加减交互正常。
  • 能够安装并注册 pinia-plugin-persistedstate,通过 persist 配置实现指定数据持久化,并刷新页面验证数据不丢失。
  • 能够使用 createPersistedState 自定义 storage 的 setItem/getItem,基于 uni.setStorageSync/getStorageSync 使持久化兼容 H5 与小程序。
  • 能够通过 key 配置持久化键名前缀,并通过 paths 仅持久化指定字段,验证本地存储键名与存储内容符合预期。

知识点 ​

一、先看【我的】模块的整体思路 ​

为什么需要:【我的】页面要展示司机个人信息、本月任务数据,车辆信息则在独立页面展示。三个数据来源不同,必须分别调用接口,不能靠静态写死。

怎么想:按“接口层 → 页面层”拆。先在 apis/user.js 里定义 profile()、task()、truck() 三个方法,再在页面 onLoad 中请求,拿到 data 后赋给 ref 渲染。转写稿里反复强调“先导出,再导入;保存后刷新;控制台打印看数据有没有出来”,这套调试习惯同样适用于本节接口联调。

怎么做:先写接口方法,再写页面请求函数,最后在模板绑定字段。每个请求都要判断 code !== 200 并 uni.utils.toast()。

易错点:导入路径、方法名拼写、接口字段名要对照文档;不要直接把响应整体塞进模板,要取 data。

二、个人信息查询 ​

为什么需要:页面要显示头像、名称、司机编号、手机号码,这些是登录司机的动态资料。

怎么想:接口只负责取数据,页面只负责渲染。请求放在 onLoad 中,保证进入页面就加载。

怎么做:在 apis/user.js 增加 profile(),页面中请求并赋值。

javascript
// apis/user.js
import { uniFetch } from './uni-fetch'

export default {
  profile() {
    return uniFetch.get('/driver/users')
  },
  task(year, month) {
    return uniFetch.get('/driver/users/taskReport', { year, month })
  },
  truck() {
    return uniFetch.get('/driver/users/truck')
  },
}
vue
<!-- pages/my/index.vue -->
<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import userApi from '@/apis/user'

const userProfile = ref({})
const taskInfo = ref({})

onLoad(() => {
  getUserProfile()
  getTaskInfo()
})

async function getUserProfile() {
  try {
    const { code, data } = await userApi.profile()
    if (code !== 200) return uni.utils.toast()
    userProfile.value = data
  } catch (err) {
    console.log(err)
  }
}
</script>

<template>
  <view class="page-container">
    <view class="user-profile">
      <image class="avatar" :src="userProfile.avatar" mode=""></image>
      <text class="username">{{ userProfile.name }}</text>
      <text class="no">司机编号:{{ userProfile.phone }}</text>
      <text class="mobile">手机号码:{{ userProfile.number }}</text>
    </view>
  </view>
</template>

易错点:笔记模板中“司机编号”绑 phone、“手机号码”绑 number,联调时要以接口文档字段为准核对,不要只凭字段名猜;请求失败要 toast,不要静默。

三、任务数据查询 ​

为什么需要:展示本月任务总量、完成任务量、运输里程。笔记注明这些数据初始为 0,后续【任务】模块完成后会更新。

怎么想:任务数据依赖年月参数,所以接口方法要接收 year、month,页面调用时传示例值。

怎么做:在页面增加 getTaskInfo(),在 onLoad 中调用。

javascript
async function getTaskInfo() {
  try {
    const { code, data } = await userApi.task('2023', '07')
    if (code !== 200) return uni.utils.toast()
    taskInfo.value = data
  } catch (err) {
    console.log(err)
  }
}
vue
<view class="month-overview">
  <view class="title">本月任务</view>
  <view class="content">
    <view class="item">
      <text class="volumn">{{ taskInfo.taskAmounts }}</text>
      <text class="label">任务总量</text>
    </view>
    <view class="item">
      <text class="volumn">{{ taskInfo.completedAmounts }}</text>
      <text class="label">完成任务量</text>
    </view>
    <view class="item">
      <text class="volumn">{{ taskInfo.transportMileage }}</text>
      <text class="label">运输里程(km)</text>
    </view>
  </view>
</view>

易错点:年月参数格式要与文档一致;初始值显示 0 是正常现象,不要误判接口失败;字段名 taskAmounts、completedAmounts、transportMileage 不要写错。

四、车辆信息查询 ​

为什么需要:车辆照片、车牌号、车型、所属机构、载重等信息在独立页面展示,方便司机查看当前车辆。

怎么想:图片用 swiper 轮播 pictureList,文本信息用 uni-list 展示。请求逻辑与前面一致。

怎么做:

vue
<!-- subpkg_user/truck/index.vue -->
<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import userApi from '@/apis/user'

const truckInfo = ref({})

onLoad(() => {
  getTruckInfo()
})

async function getTruckInfo() {
  try {
    const { code, data } = await userApi.truck()
    if (code !== 200) return uni.utils.toast()
    truckInfo.value = data
  } catch (err) {
    console.log(err)
  }
}
</script>

<template>
  <view class="page-container">
    <swiper class="truck-pictures" indicator-active-color="#fff" circular indicator-dots>
      <swiper-item v-for="picture in truckInfo.pictureList" :key="picture.url">
        <image class="picture" mode="aspectFill" :src="picture.url"></image>
      </swiper-item>
    </swiper>
    <view class="truck-meta">
      <uni-list :border="false">
        <uni-list-item :border="false" title="车辆编号" :right-text="truckInfo.id" />
        <uni-list-item :border="false" title="车辆号牌" :right-text="truckInfo.licensePlate" />
        <uni-list-item :border="false" title="车型" :right-text="truckInfo.truckType" />
        <uni-list-item :border="false" title="所属机构" :right-text="truckInfo.currentOrganName" />
        <uni-list-item :border="false" title="载重" :right-text="truckInfo.allowableLoad" />
      </uni-list>
    </view>
  </view>
</template>

易错点:笔记原代码中 truchInfo 是拼写笔误,建议统一改为 truckInfo,否则模板取值容易出错;v-for 的 :key 用 picture.url;图片模式用 aspectFill 避免变形。转写稿里“拼亚/皮尼”即 Pinia,本节不展开,但“命名要语义化、保存后刷新验证、控制台打印排查”的调试思路可以沿用。

本节作业要做什么 ​

本节笔记只有一句核心要求:独立完成第3章节3个接口的调用。转写稿里没有出现这3个接口的名称、请求地址、请求参数和返回结构,因此这里不能替你编造三个接口,也不应凭空写死 URL。作业的正确目标是:按第3章讲义或接口文档,把3个接口逐个调通,能拿到返回数据,能在页面或Pinia store中正确使用,并做好基本异常处理。

为什么需要先明确这一点?因为“接口调用”不是只写三行 import,而是要完成“请求—返回—渲染/存储—异常”的闭环。怎么想?先把3个接口列成清单:接口名、请求方法、URL、入参、返回字段、使用页面。怎么做?每完成一个就在清单上打勾,避免最后只调了一个却以为完成了三个。易错点:把示例代码当成真实接口;只写请求不处理返回;参数名按记忆写而不对照文档。

先纠正常见同音字和API名 ​

转写稿里出现了不少口头同音字,写代码时要纠正:Pinia不是“拼命/P尼亚/P命”;store不是“死道/此道”;use不是“柚子”;create不是“可瑞特”;paths不是“pass”;getItem/setItem不是“盖子”。为什么需要纠正?因为这些名字一旦写错,编辑器会直接报错,调试时间都耗在拼写上。怎么想?以官方文档和项目现有代码为准。怎么做?导入具名导出时加大括号;store 导出的是函数,页面里要调用后才能拿到实例。易错点:转写稿明确提醒过,import 时不要漏大括号;useXxxStore() 不要只写 useXxxStore。

用Pinia组织3个接口的调用 ​

转写稿演示了在 store 中定义数据和方法,并全部导出,页面再导入使用。这个思路可以迁移到作业:每个接口对应一个 action,页面只负责触发和展示。为什么需要?因为接口结果如果散落在页面里,三个接口容易重复、难维护。怎么想?先定义 store,再导出 state 和 action,最后在页面调用。怎么做?可参考下面模板,但接口名、参数、URL必须按第3章文档替换,不能照抄:

js
// store/xxx.js:示意模板,不是第3章真实接口
import { defineStore } from 'pinia'
import { ref } from 'vue'

export const useXxxStore = defineStore('xxx', () => {
  const list = ref([])

  async function fetchOne(params) {
    // 按第3章文档填写请求方法、地址、参数
    // const res = await request(...)
    // list.value = res.data
  }

  return { list, fetchOne }
})

页面中:

js
import { useXxxStore } from '@/store/xxx'

const xxxStore = useXxxStore()
// 按文档调用对应 action

易错点:只定义 action 不导出;页面导入路径写错;三个接口共用一个 action 却传错参数;把 storeToRefs 用在方法上。转写稿说得很清楚:storeToRefs 主要用于 state 数据,方法直接解构即可。

调试顺序:先打印,再渲染,再交互 ​

转写稿的调试过程很有参考价值:先 console.log(store) 看 store 里有没有数据和方法,再点按钮验证响应式,最后刷新页面看持久化。作业也按这个顺序:先单独调通一个接口,打印返回结果;确认字段后再绑定页面;三个接口逐个过,不要一次全接。为什么需要?因为三个接口同时接,报错时很难判断是请求、字段还是渲染问题。怎么做?每调一个接口就看一次 Network 和控制台;返回字段对不上时先打印完整响应,不要猜。易错点:只看页面没变化就认定接口失败;刷新后数据丢失误判为接口失败;控制台有报错却继续接下一个接口。

持久化与解构的作业级注意 ​

转写稿补充了持久化:刷新页面后内存数据会丢失,token 这类数据需要长期保存;插件默认可能用 localStorage,小程序环境不适用,所以要自定义 storage;key 加前缀便于识别;paths 只存必要数据,避免 persist: true 把所有数据都存下来造成性能损耗。作业中如果某个接口结果需要跨刷新保留,只持久化必要字段。易错点:把整个 store 全量持久化;key 与项目其他 store 冲突;解构 state 后丢失响应式,却误以为接口没返回。

验收清单 ​

交作业前逐项检查:3个接口是否都真实调用;请求方法、URL、参数是否来自第3章文档;返回数据是否被使用;页面是否有成功/失败反馈;控制台是否无报错;是否清理了多余 console.log;刷新后必要数据是否仍在。转写稿最后也说明,这部分内容是留给学生独立完成的作业,因此本文只给完成路径和调试方法,不代写三个接口的具体实现。

代码示例 ​

示例一:apis/user.js —— 三个接口的统一封装 ​

js
// apis/user.js
// 引入网络请求模块
import { uniFetch } from './uni-fetch'

export default {
  /**
   * 个人资料
   */
  profile() {
    return uniFetch.get('/driver/users')
  },

  /**
   * 任务数据
   * @param {string} year - 任务数据的年份
   * @param {string} month - 任务数据的月份
   */
  task(year, month) {
    return uniFetch.get('/driver/users/taskReport', { year, month })
  },

  /**
   * 车辆信息
   */
  truck() {
    return uniFetch.get('/driver/users/truck')
  },
}

关键行与易错点

  • 三个方法都返回 uniFetch.get(...) 的返回值(Promise),必须 return,否则页面里 await userApi.profile() 拿到的是 undefined。
  • task(year, month) 的第二个参数是查询参数对象 { year, month },不是拼在 URL 上,交给 uni-fetch 统一处理。
  • 统一用 export default { ... } 导出对象,页面里才能 import userApi from '@/apis/user' 后以 userApi.profile() 调用。
  • 注意 import 路径写相对路径 ./uni-fetch,不要写成 @/apis/uni-fetch,两者指向不同。

示例二:pages/my/index.vue —— 个人信息 + 任务数据 ​

vue
<!-- pages/my/index.vue -->
<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import userApi from '@/apis/user'

// 用户信息
const userProfile = ref({})
// 任务数据
const taskInfo = ref({})

onLoad(() => {
  getUserProfile()
  getTaskInfo()
})

// 用户信息接口
async function getUserProfile() {
  try {
    const { code, data } = await userApi.profile()
    // 检测接口是否调用成功
    if (code !== 200) return uni.utils.toast()
    // 渲染请求来的数据
    userProfile.value = data
  } catch (err) {
    console.log(err)
  }
}

// 任务数据
async function getTaskInfo() {
  try {
    const { code, data } = await userApi.task('2023', '07')
    // 检测接口是否调用成功
    if (code !== 200) return uni.utils.toast()
    // 渲染请求来的数据
    taskInfo.value = data
  } catch (err) {
    console.log(err)
  }
}
</script>

<template>
  <view class="page-container">
    <!-- 个人信息 -->
    <view class="user-profile">
      <image class="avatar" :src="userProfile.avatar" mode=""></image>
      <text class="username">{{ userProfile.name }}</text>
      <text class="no">司机编号:{{ userProfile.phone }}</text>
      <text class="mobile">手机号码:{{ userProfile.number }}</text>
    </view>

    <!-- 本月任务 -->
    <view class="month-overview">
      <view class="title">本月任务</view>
      <view class="content">
        <view class="item">
          <text class="volumn">{{ taskInfo.taskAmounts }}</text>
          <text class="label">任务总量</text>
        </view>
        <view class="item">
          <text class="volumn">{{ taskInfo.completedAmounts }}</text>
          <text class="label">完成任务量</text>
        </view>
        <view class="item">
          <text class="volumn">{{ taskInfo.transportMileage }}</text>
          <text class="label">运输里程(km)</text>
        </view>
      </view>
    </view>
  </view>
</template>

<style lang="scss" scoped>
.page-container {
  padding: 30rpx;
}
.user-profile {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 40rpx 0;

  .avatar {
    width: 160rpx;
    height: 160rpx;
    border-radius: 50%;
  }
  .username {
    margin-top: 20rpx;
    font-size: 36rpx;
    font-weight: bold;
  }
  .no,
  .mobile {
    margin-top: 10rpx;
    color: #818181;
    font-size: 26rpx;
  }
}
.month-overview {
  margin-top: 30rpx;
  background-color: #fff;
  border-radius: 12rpx;
  padding: 24rpx;

  .title {
    font-weight: bold;
    margin-bottom: 20rpx;
  }
  .content {
    display: flex;

    .item {
      flex: 1;
      display: flex;
      flex-direction: column;
      align-items: center;

      .volumn {
        font-size: 40rpx;
        font-weight: bold;
      }
      .label {
        margin-top: 8rpx;
        color: #818181;
        font-size: 24rpx;
      }
    }
  }
}
</style>

关键行与易错点

  • onLoad 必须从 @dcloudio/uni-app 引入,不是从 vue;两个接口在 onLoad 里并列调用,互不阻塞。
  • await userApi.profile() 的结果用解构 const { code, data } = ...,先判 code !== 200 再赋值,return uni.utils.toast() 表示失败即中断,不渲染脏数据。
  • 一定用 userProfile.value = data,不能写 userProfile = data,否则 ref 变量被整体覆盖,响应式丢失。
  • 模板里字段名(name / phone / number / taskAmounts / completedAmounts / transportMileage)必须与接口返回一一对应,建议先在控制台打印 data 核对,再落到模板。
  • taskInfo 初始是空对象 {},接口未返回前页面显示为空白;若希望初始显示 0,可把初始值写成 ref({ taskAmounts: 0, completedAmounts: 0, transportMileage: 0 })。

示例三:subpkg_user/truck/index.vue —— 车辆信息查询 ​

vue
<!-- subpkg_user/truck/index.vue -->
<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import userApi from '@/apis/user'

// 车辆信息
const truchInfo = ref({})

onLoad(() => {
  getTruckInfo()
})

// 获取车辆信息
async function getTruckInfo() {
  try {
    const { code, data } = await userApi.truck()
    // 检测接口是否调用成功
    if (code !== 200) return uni.utils.toast()
    // 渲染请求来的数据
    truchInfo.value = data
  } catch (err) {
    console.log(err)
  }
}
</script>

<template>
  <view class="page-container">
    <!-- 车辆照片轮播 -->
    <swiper
      class="truck-pictures"
      indicator-active-color="#fff"
      circular
      indicator-dots
    >
      <swiper-item v-for="picture in truchInfo.pictureList" :key="picture.url">
        <image class="picture" mode="aspectFill" :src="picture.url"></image>
      </swiper-item>
    </swiper>

    <!-- 车辆元信息 -->
    <view class="truck-meta">
      <uni-list :border="false">
        <uni-list-item
          :border="false"
          title="车辆编号"
          :right-text="truchInfo.id"
        />
        <uni-list-item
          :border="false"
          title="车辆号牌"
          :right-text="truchInfo.licensePlate"
        />
        <uni-list-item
          :border="false"
          title="车型"
          :right-text="truchInfo.truckType"
        />
        <uni-list-item
          :border="false"
          title="所属机构"
          :right-text="truchInfo.currentOrganName"
        />
        <uni-list-item
          :border="false"
          title="载重"
          :right-text="truchInfo.allowableLoad"
        />
      </uni-list>
    </view>
  </view>
</template>

<style lang="scss" scoped>
.truck-pictures {
  width: 750rpx;
  height: 380rpx;

  .picture {
    width: 100%;
    height: 100%;
  }
}
.truck-meta {
  margin-top: 20rpx;
  background-color: #fff;
}
</style>

关键行与易错点

  • swiper 循环的是 truchInfo.pictureList,ref({}) 初始为空对象,pictureList 为 undefined 时 v-for 不会报错但轮播为空,接口回来后才渲染,属正常现象。
  • :key="picture.url" 用图片地址做 key,避免用 index。
  • <image> 用 mode="aspectFill" 保证图片铺满不变形;swiper 上的 indicator-dots / circular / indicator-active-color 都是布尔/字符串属性,直接写即可。
  • uni-list-item 用 :right-text 绑定右侧文字,title 是左侧标题,别把两者写反;right-text 前有冒号表示绑定变量,不加冒号就变成纯字符串字面量。
  • 页面在 subpkg_user 分包下,import userApi from '@/apis/user' 依然用 @ 别名,不要写成 ../../apis/user,避免层级算错。
  • 变量名 truchInfo(笔记中即如此拼写)在 script 与 template 中必须保持一致,改名时记得全局替换,否则模板渲染为空白。

常见错误 ​

  • 现象:页面加载后头像、姓名、编号全空白,控制台无报错。 原因:ref({}) 写成普通对象 const userProfile = {},异步赋值后视图不更新。 修法:状态一律用 ref/reactive 包裹;渲染前用 console.log(data) 确认字段真实存在再绑模板。

  • 现象:司机编号和手机号显示反了(编号位置出现 11 位手机号)。 原因:凭字段名猜语义,number/phone 未与接口文档逐个比对。 修法:以接口文档字段说明为准绑定,别按单词字面意思猜;联调时把返回值贴到模板旁对照一遍。

  • 现象:任务总量、完成量、里程恒为 0,或换月后数据仍是上月。 原因:userApi.task('2023', '07') 年月写死。 修法:用 dayjs().year() / dayjs().format('MM') 动态取值,并注意接口要求月份是两位数还是数字。

  • 现象:接口失败时页面一片空白,用户不知道发生了什么。 原因:if (code !== 200) return uni.utils.toast() 未传文案,catch 里只 console.log。 修法:toast(msg) 必须带提示语,catch 中同样给出兜底提示;关键页面再加空状态/骨架屏。

  • 现象:车辆照片轮播区域高度为 0,图片不显示或严重变形。 原因:swiper/image 未设宽高,或 mode 用了默认 scaleToFill 拉伸。 修法:给 .truck-pictures 和 .picture 显式设置高度与 width:100%,image 用 aspectFill。

  • 现象:Pinia 中解构出的 count 改了不生效,或对方法误用 storeToRefs 报错。 原因:直接解构 state 丢失响应性;storeToRefs 只处理 state/getters。 修法:state 用 storeToRefs(store),actions 直接从 store 上普通解构。

  • 现象:H5 端持久化正常,小程序端刷新后数据丢失。 原因:插件默认走 localStorage,小程序无此 API。 修法:用 createPersistedState({ storage: { setItem: uni.setStorageSync, getItem: uni.getStorageSync } }) 自定义存储。

  • 现象:持久化后本地缓存体积异常大,页面变卡。 原因:第三个参数直接传 true,store 内所有字段全量落盘。 修法:改用 { paths: ['userProfile'] } 白名单,只存必要字段(如 token、用户信息)。

练习 ​

  1. 三接口串联:独立完成 profile / task / truck 三个方法的封装与页面渲染,要求每个页面先打印完整响应再绑模板,提交时截图对比"接口返回字段"与"模板绑定字段"一一对应。

  2. 动态年月:把任务数据的年月改为动态获取,并在页面上支持"上一月/下一月"切换,切换后重新拉取数据(注意 onLoad 只执行一次,切换需自己写方法调用)。

  3. 统一请求处理:把"判断 code → toast → 赋值 → catch"这套逻辑抽成一个可复用的封装(如 useRequest 或 uni.utils.request),三个页面改用它,要求失败时有文案、成功时返回 data。

  4. 异常兜底:给车辆信息页加空状态与图片加载失败兜底(image 的 @error),并给三个接口加 uni.showLoading / hideLoading。

  5. Store + 持久化:把用户信息存入 Pinia,用 storeToRefs 解构渲染,配置持久化 paths: ['userProfile'] 与自定义 uni.setStorageSync 存储,分别在 H5 和小程序端刷新验证数据不丢失。

课堂画面 ​

课堂画面 01

课堂画面 02

课堂画面 03

课堂画面 04

课堂画面 05

课堂画面 06

原视频对照 ​

#视频时长
1神领物流-Pinia定义Store-212:40
2神领物流-pinia数据解构04:27
3神领物流-pinia数据持久化-108:48
4神领物流-pinia数据持久化-208:33
5神领物流-pinia数据持久化-314:52
6神领物流-pinia数据持久化-408:08

小结 ​

  1. 三个接口的开发套路是固定的:定义方法 → 页面 onLoad 调用 → 校验 code → 赋值渲染,没有例外。
  2. 字段以接口文档为准,不能凭单词字面猜语义,number/phone 这类易混字段必须逐一核对。
  3. 状态必须用 ref/reactive 包裹,否则异步赋值后视图不会更新,且这种错误往往不报错、最难查。
  4. 请求失败必须有用户可见的提示,只写 console.log 等于把问题留给用户。
  5. 写死参数(年月、ID)在本地能跑通,但换月/换人即失效,属于必须提前消除的隐患。
  6. storeToRefs 只解决 state/getters 的响应性丢失,actions 直接解构即可。
  7. 持久化在小程序端必须自定义 storage,否则默认 localStorage 方案静默失效。
  8. 持久化用 paths 白名单而非 true,存得越少越稳、越快。