SSF0SSF0
首页
前端
  • Node
  • Go
  • Python
  • MySql
  • Bash
  • Git
  • Docker
  • VuePress
  • CI/CD
  • 服务器
  • 网站
  • 学习资料
  • 软件
  • 开源项目
Timeline
Github
标签
分类
首页
前端
  • Node
  • Go
  • Python
  • MySql
  • Bash
  • Git
  • Docker
  • VuePress
  • CI/CD
  • 服务器
  • 网站
  • 学习资料
  • 软件
  • 开源项目
Timeline
Github
标签
分类
  • HTML

    • html1
    • html2
  • CSS

    • Flex 布局常见问题与解决方案
  • JavaScript

    • 数据类型及引用问题
    • 处理 Blob 类型文件下载问题总结
    • localStorage 与 sessionStorage 区别
    • JavaScript 中的 script 标签问题详解
    • JavaScript 中的 this 指向问题详解
    • SessionStorage踩坑记录
    • 动态加载 JS 脚本方法对比
    • 浏览器页面关闭场景下的数据上报
    • HTTP流式接口详解与封装实践
    • JavaScript 中 this 绑定与解构:为什么 Pinia Store 可以解构,但 Class 实例方法不行?
  • es6

    • Promise
    • es6 模块导出方式全解析
    • reduce
    • forEach 中使用 async/await 的异步陷阱
  • Vue2

    • created VS mounted 发起异步请求
    • vue2-2
  • Vue3

    • Vite + Vue3 修改 element-plus 源码
    • Vue v-if 与 v-show
    • Vue3 ref 获取组件
    • Vue3 路由传参
    • 父子组件与组件里 Hooks 加载顺序
    • 第三方组件传入参数TS提示
    • Vue3 Props 在 Hooks 中的响应性处理
    • Vue Router 的两种历史模式及部署配置详解
    • 在Vue 3项目中顺利集成Tailwind CSS的完整指南
    • Vue Scoped CSS 深度解析
    • Vue 3 Teleport 组件完全指南:从原理到实战
    • Vant List 组件中双向绑定 loading 的注意事项
    • Vue 3 + Vite 中的路径别名与静态资源处理深度解析
    • 获取图片高度
    • 深入理解 Pinia:storeToRefs vs toRefs - 避开 TypeScript 类型推断的坑
  • webSocket

    • WebSocket 鉴权入门(实战版):握手票据 / 首帧鉴权 + 连接级 cid
  • Electron

    • 快速构建 electron + vue3 项目
  • TS

    • TS 泛型
    • 记录模板使用断言的问题
    • type 与 interface
  • WebPack

    • Webpack 介绍
  • Vite

    • Vite CLI 常见命令
    • vite 与 webpack 比较
    • Vite base 配置与 public 静态资源路径问题
  • 客户端数据库

    • IndexedDB 索引陷阱:字段重命名后索引返回旧数据
    • IndexedDB 事务陷阱与最佳实践:TransactionInactiveError 完全解析
  • UniApp

    • uni-app + Monorepo + 微信小程序热更新失效问题排查与结论总结
    • Monorepo + uni-app 开发中监听 / 热更新机制差异与解决方案
    • uni-app 运行与编译原理梳理
    • 为什么 uni-app 在 Monorepo 中必须做「环境区分」,而 Web 项目却不用?
  • 项目工程

    • 前端代码风格
    • Vue3 项目规范
    • npm 镜像问题
    • 包管理工具
    • 使用 engines 限制开发环境依赖
    • 打包与shell交互指定模式
    • Monorepo示例
    • PC 项目兼容 uni-app:基于 pnpm Monorepo 的工程化重构实践
    • Vue3 + Vite + pnpm Monorepo 迁移问题指南
    • 前端工程化:深入理解 npm 依赖管理机制
    • pnpm 修改依赖源码打包报错
    • 代码片段
  • 移动端

    • 浏览器跳转外部应用时请求状态码问题
  • 记录一下小程序
  • 控制浏览器正确保存网站账号密码的技巧
  • 信息泄露
  • 使用Gulp实现前端项目哈希化压缩打包
  • 前端缓存策略与文件哈希化的关联解析
  • 浏览器刷新方式与 Vue 生命周期、缓存行为解析
  • 浏览器消息铃声实现记录

深入理解 Pinia:storeToRefs vs toRefs - 避开 TypeScript 类型推断的坑

前言:一个让人困惑的 Bug

在使用 Pinia 开发 Vue 3 项目时,我遇到了一个非常困惑的问题:

// 我的代码
const { conversationList, activeSessionId, sessionActions } =
  storeToRefs(store);

// 编辑器提示
// sessionActions: Ref<{ setActive: Function, clear: Function }>

// 使用时
sessionActions.value.setActive("3"); // TypeScript 不报错!

但是,运行时却报错了:

Uncaught TypeError: Cannot read properties of undefined (reading 'value')

控制台打印:

console.log(sessionActions); // undefined ❌

这让我非常困惑:

  • ✅ 编辑器有完整的类型提示

  • ✅ TypeScript 编译通过

  • ❌ 运行时却是 undefined

这究竟是怎么回事?storeToRefs 和 toRefs 到底有什么区别?


一、Pinia Store 的基本结构

在深入问题之前,先理解 Pinia Store 的结构:

// store/modules/conversation.ts
export const useConversationStore = defineStore("conversation", () => {
  // State(响应式数据)
  const conversationList = ref<ConversationItem[]>([]);
  const activeSessionId = ref<string | null>(null);

  // Actions(方法/函数)
  const sessionActions = {
    setActive(sessionId: string | null) {
      activeSessionId.value = sessionId;
    },
    clear() {
      activeSessionId.value = null;
    },
  };

  return {
    // State - 使用 readonly 包装
    conversationList: readonly(conversationList),
    activeSessionId: readonly(activeSessionId),

    // Actions - 普通对象
    sessionActions,
  };
});

关键点:

  • conversationList:Readonly<Ref<T>> - 响应式对象

  • activeSessionId:Readonly<Ref<T>> - 响应式对象

  • sessionActions:Object - 普通 JavaScript 对象


二、为什么需要 storeToRefs 或 toRefs?

问题:直接解构会丢失响应式

// ❌ 错误:直接解构会失去与 Store 的响应式连接
const { conversationList } = useConversationStore();
// 不要直接从 store 上用 ES 解构取 state / getters,这样会丢失和响应式代理的连接

// ✅ 正确:使用 storeToRefs 确保持续追踪 Store 状态
const { conversationList } = storeToRefs(useConversationStore());
// storeToRefs 创建一个始终指向最新 Store 状态的响应式引用
// 即使 Store 更新,这里也能获取到最新值

三、核心区别:storeToRefs vs toRefs

3.1 storeToRefs - 智能过滤(Pinia 专用)

源码分析(简化版):

// Pinia 源码
function storeToRefs(store) {
  const refs = {};

  for (const key in store) {
    const value = store[key];

    // 核心:运行时判断属性类型
    if (isRef(value) || isReactive(value)) {
      refs[key] = toRef(store, key); // ✅ 只提取响应式属性
    }
    // ❌ 跳过普通对象和函数(actions 在这里被过滤)
  }

  return refs;
}

测试验证:

const store = useConversationStore();

// 检查原始类型
console.log(isRef(store.conversationList)); // true ✅
console.log(isRef(store.sessionActions)); // false ❌

// storeToRefs 的结果
const refs = storeToRefs(store);
console.log("conversationList" in refs); // true ✅
console.log("sessionActions" in refs); // false ❌
console.log(refs.sessionActions); // undefined ❌

3.2 toRefs - 无差别转换(Vue 通用)

源码分析(简化版):

// Vue 源码
function toRefs(object) {
  const refs = {};

  for (const key in object) {
    // 无任何判断!所有属性都转换
    refs[key] = toRef(object, key);
  }

  return refs;
}

// toRef 的实现
function toRef(object, key) {
  return {
    get value() {
      return object[key]; // 访问原始对象
    },
    set value(newValue) {
      object[key] = newValue;
    },
  };
}

测试验证:

const store = useConversationStore();

// toRefs 的结果
const allRefs = toRefs(store);
console.log("sessionActions" in allRefs); // true ✅
console.log(allRefs.sessionActions); // Ref<Object> ✅

// 使用
allRefs.sessionActions.value.setActive("3"); // ✅ 可以工作

3.3 对比总结表

特性storeToRefstoRefs
来源Pinia 专用工具Vue 通用工具
转换机制运行时过滤(只转换 Ref/Reactive)无差别转换(所有属性)
State 处理✅ 提取为 Ref✅ 提取为 Ref
Actions 处理❌ 跳过(结果中不存在)✅ 包装为 Ref<Object>
Actions 访问方式N/A(不存在会报错)actions.value.method()
TypeScript 类型⚠️ 类型不准确(包含 actions)✅ 类型准确
性能最优(无额外包装)稍差(额外 Ref 包装)
语义清晰度✅ 清晰(区分 state 和 actions)⚠️ 混乱(actions 也要 .value)

四、TypeScript 类型陷阱详解

4.1 问题复现

// 我的 Store
const store = useConversationStore();
// 返回类型:{
//   conversationList: Readonly<Ref>,
//   sessionActions: { setActive: Function }
// }

// 使用 storeToRefs
const { conversationList, sessionActions } = storeToRefs(store);

// TypeScript 推断
// conversationList: Ref<ConversationItem[]>
// sessionActions: Ref<{ setActive: Function, clear: Function }>  ← 错误!

鼠标悬停显示:

const sessionActions: Ref<{
  setActive(sessionId: string | null): void;
  clear(): void;
}>;

实际运行时:

console.log(sessionActions); // undefined ❌
sessionActions.value.setActive("3"); // TypeError ❌

4.2 为什么 TypeScript 类型不准确?

Pinia 的类型定义(简化):

// Pinia 源码类型定义
export function storeToRefs<SS extends StoreGeneric>(
  store: SS
): ToRefs<StoreState<SS> & StoreGetters<SS>>;

// 问题:类型定义假设提取 State + Getters
// 但实际运行时,还会过滤掉非 Ref/Reactive 对象

TypeScript 的局限性:

  1. 编译时:TypeScript 读取 Store 的返回类型,认为所有属性都会被提取
  2. 运行时:storeToRefs 执行过滤逻辑,跳过普通对象
  3. 结果:类型系统和运行时行为不一致

为什么不报错?

因为 TypeScript 无法在编译时判断:

  • 哪些属性是 Ref/Reactive(会被提取)
  • 哪些属性是普通对象(会被跳过)

所以它保守地假设所有属性都会被提取,导致类型推断错误。


4.3 如何识别这个坑?

警告信号:

  1. ✅ 编辑器有类型提示
  2. ✅ TypeScript 不报错
  3. ❌ 运行时 console.log 是 undefined
  4. ❌ 访问 .value 时报 Cannot read properties of undefined

验证方法:

const refs = storeToRefs(store);
console.log(Object.keys(refs));
// 只包含 ["conversationList", "activeSessionId"]
// 不包含 "sessionActions" ← 确认被过滤了

五、正确使用规范

✅ 方式 1:推荐 - 分开解构

const store = useConversationStore();

// State 用 storeToRefs
const { conversationList, activeSessionId } = storeToRefs(store);

// Actions 直接从 store 取
const { sessionActions, messageActions } = store;

// 使用
conversationList.value; // ✅ 响应式 state
sessionActions.setActive("3"); // ✅ 直接调用 actions

优点:

  • ✅ 语义清晰:state 用 .value,actions 直接调用
  • ✅ 性能最优:无额外 Ref 包装
  • ✅ 符合 Pinia 最佳实践
  • ✅ TypeScript 类型安全

⚠️ 方式 2:可用但不推荐 - toRefs 全解构

const { conversationList, activeSessionId, sessionActions } = toRefs(store);

// 使用
conversationList.value; // ✅ state
sessionActions.value.setActive("3"); // ⚠️ actions 也要 .value

缺点:

  • ⚠️ 语义混乱:方法也需要 .value
  • ⚠️ 轻微性能损耗:额外创建 Ref 对象
  • ⚠️ 代码可读性差:不符合直觉

❌ 方式 3:错误 - storeToRefs 解构 actions

// ❌ 运行时报错
const { conversationList, sessionActions } = storeToRefs(store);
sessionActions.value.setActive("3"); // TypeError!

问题:

  • ❌ sessionActions 是 undefined
  • ❌ 编辑器有类型提示但运行时报错
  • ❌ 这是本文要避免的坑!

六、性能与语义深度分析

6.1 内存开销对比

// 方式 1:storeToRefs + 直接取
const { conversationList } = storeToRefs(store);
const { sessionActions } = store;
// 内存:只为 state 创建 Ref 包装
// sessionActions 直接引用原对象

// 方式 2:toRefs
const { conversationList, sessionActions } = toRefs(store);
// 内存:为所有属性创建 Ref 包装
// sessionActions 也被包装为 Ref<Object>

6.2 访问路径对比

// 直接从 store 取
store.sessionActions.setActive("3");
// 路径:store → sessionActions → setActive
// 访问次数:2 次对象属性查找

// storeToRefs + 解构
const { sessionActions } = store;
sessionActions.setActive("3");
// 路径:变量 sessionActions → setActive
// 访问次数:1 次(已解构到变量)

// toRefs
const { sessionActions } = toRefs(store);
sessionActions.value.setActive("3");
// 路径:Ref → .value(触发 getter)→ store.sessionActions → setActive
// 访问次数:3 次(额外的 Ref 解包)

6.3 语义清晰度对比

在组件中使用:

<template>
  <!-- 方式 1:语义清晰 -->
  <div>{{ conversationList }}</div>
  <button @click="sessionActions.setActive('3')">切换</button>

  <!-- 方式 2:语义混乱 -->
  <div>{{ conversationList }}</div>
  <button @click="sessionActions.value.setActive('3')">切换</button>
  <!-- ⚠️ 为什么 conversationList 不需要 .value,sessionActions 需要? -->
</template>

传递给其他函数:

// 方式 1:清晰
const { sessionActions } = store;
someFunction(sessionActions); // 传递对象 ✅

// 方式 2:混乱
const { sessionActions } = toRefs(store);
someFunction(sessionActions.value); // 传递对象
someFunction(sessionActions); // 传递 Ref<Object>?
// ⚠️ 到底应该传什么?

七、实战场景分析

7.1 在组件中使用

<script setup lang="ts">
import { useConversationStore } from "@/store/modules/conversation";
import { storeToRefs } from "pinia";

// ✅ 推荐写法
const store = useConversationStore();
const { conversationList, activeSessionId } = storeToRefs(store);
const { sessionActions } = store;

// 使用
const handleClick = () => {
  sessionActions.setActive("3"); // 清晰直观
};
</script>

<template>
  <div v-for="item in conversationList" :key="item.id">
    {{ item.name }}
  </div>
  <button @click="handleClick">切换会话</button>
</template>

7.2 在 Composables 中使用

// hooks/useConversation.ts
export function useConversation() {
  const store = useConversationStore();
  const { activeSessionId } = storeToRefs(store);
  const { sessionActions, messageActions } = store;

  const sendMessage = (content: string) => {
    if (!activeSessionId.value) {
      console.warn("未选择会话");
      return;
    }
    // 发送消息逻辑
  };

  return {
    activeSessionId, // ✅ 响应式
    sessionActions, // ✅ 方法集合
    sendMessage,
  };
}

7.3 在 Service 类中使用

// services/ConnectionService.ts
export class ConnectionService {
  private conversationStore = useConversationStore();

  sendMessage(message: Message): boolean {
    // 直接访问,每次都是最新值
    if (!this.conversationStore.activeSessionId) {
      return false;
    }

    // 调用 actions
    this.conversationStore.sessionActions.setActive(message.sessionId);
    return true;
  }
}

在 Service 中不需要解构:

  • Service 本身保持 store 引用
  • 访问 this.conversationStore.xxx 始终是最新值
  • 不需要 storeToRefs 或 toRefs

八、避坑指南

8.1 识别坑的信号

⚠️ 危险信号:

const { sessionActions } = storeToRefs(store);

// 信号 1:编辑器显示类型
// sessionActions: Ref<Object>  ← 有类型提示

// 信号 2:控制台打印
console.log(sessionActions); // undefined ← 运行时不存在

// 信号 3:运行时报错
sessionActions.value.setActive("3");
// TypeError: Cannot read properties of undefined

8.2 快速检测方法

const refs = storeToRefs(store);

// 方法 1:打印所有 key
console.log(Object.keys(refs));
// 预期:只包含 state/getters,不包含 actions

// 方法 2:检查是否存在
console.log("sessionActions" in refs); // false ← 确认被过滤

// 方法 3:检查值
console.log(refs.sessionActions); // undefined ← 确认不存在

8.3 常见错误模式

// ❌ 错误 1:误用 storeToRefs 解构 actions
const { conversationActions } = storeToRefs(store); // undefined

// ❌ 错误 2:在 hooks 中直接解构(丢失响应式)
export function useConversationList() {
  const { conversationList } = useConversationStore(); // 丢失响应式
  return { conversationList };
}

// ❌ 错误 3:混用 storeToRefs 和直接解构
const { conversationList } = storeToRefs(store);
const { activeSessionId } = store; // ⚠️ 丢失响应式

8.4 正确的心智模型

记住这个规则:

// State/Getters(响应式数据)
const { state1, state2, getter1 } = storeToRefs(store);

// Actions(方法/函数)
const { action1, action2 } = store;

// 或者完全不解构
store.state1; // 响应式
store.action1(); // 直接调用

九、总结与最佳实践

9.1 核心要点

  1. storeToRefs 运行时过滤:只提取 Ref 和 Reactive 对象,跳过普通对象
  2. toRefs 无差别转换:将所有属性包装为 Ref,包括 actions
  3. TypeScript 类型陷阱:storeToRefs 的类型定义不准确,会显示 actions 但运行时是 undefined
  4. 性能差异:storeToRefs + 直接取 > 不解构 > toRefs
  5. 语义清晰度:state 用 .value,actions 直接调用

9.2 推荐最佳实践

// ✅ 标准写法
const store = useConversationStore();
const { conversationList, activeSessionId } = storeToRefs(store);
const { sessionActions, messageActions } = store;

// 使用
conversationList.value; // state
sessionActions.setActive(); // actions

优点:

  • ✅ 语义清晰:一眼看出哪些是数据,哪些是方法
  • ✅ 性能最优:无额外包装
  • ✅ 类型安全:避免运行时错误
  • ✅ 符合 Pinia 官方推荐

9.3 何时使用 toRefs?

仅在以下场景考虑:

  • 个人项目且代码风格统一
  • 不介意所有属性都需要 .value
  • 需要将整个 store 转为 refs 传递

但仍然不推荐:

  • 语义混乱(actions 也要 .value)
  • 不符合 Pinia 设计理念

9.4 避坑清单

  • 永远不要用 storeToRefs 解构 actions
  • State/Getters 用 storeToRefs,Actions 直接从 store 取
  • 在 hooks 中返回 state 时,使用 storeToRefs 保持响应式
  • 遇到 undefined 错误时,检查是否误用了 storeToRefs
  • 不要混用 storeToRefs 和直接解构(会丢失响应式)
  • Service 类中直接保持 store 引用,不需要解构

十、实际案例修复

问题代码

// ❌ 我的错误代码
const { conversationList, activeSessionId, sessionActions } =
  storeToRefs(store);
sessionActions.value.setActive("3"); // TypeError!

修复方案

// ✅ 修复后
const store = useConversationStore();
const { conversationList, activeSessionId } = storeToRefs(store);
const { sessionActions } = store;

sessionActions.setActive("3"); // ✅ 正常工作

经验教训

  1. 不要盲目相信 TypeScript 类型提示:类型系统有局限性
  2. 运行时测试很重要:console.log 验证实际值
  3. 理解工具的设计原理:知道为什么,而不仅仅是怎么用
  4. 遵循官方最佳实践:Pinia 文档推荐的写法有其道理

参考资料

  • Pinia 官方文档
  • Vue 3 响应式 API
  • Pinia GitHub
最后更新时间:
贡献者: 何风顺
上一页
获取图片高度