问题描述
在使用Vue 3和Pinia开发过程中,我遇到了一个有趣的问题:同一个响应式对象在不同位置引用时,显示的值却不一致。具体表现为:
- 当
handleSearch
函数定义在组件(index.vue
)中时,打印出的store.searchFormData
值是最新的(用户输入的值) - 当
handleSearch
函数定义在store(store.ts
)中时,打印出的searchFormData
值总是初始化的空字符串
这种行为看起来违反了我们对响应式系统的理解 - 同一个响应式对象,不管在哪里引用,应该都能反映最新值才对。
代码分析
让我们先看看问题中涉及的代码:
组件中(index.vue):
import { useStore } from './store';
const store = useStore();
function handleSearch(data) {
console.log(store.searchFormData, 'searchFormData', data);
}
// ZCrud组件使用
<ZCrud
v-model:formData="store.searchFormData"
@search="store.handleSearch"
/>
Store中(store.ts):
export const useStore = defineStore('gamePkg', () => {
const searchFormData = reactive<SearchFormDataType>({
gameProductId: '',
areaCode: '',
storeId: '',
gameName: ''
});
function handleSearch(data) {
console.log(searchFormData, 'searchFormData', data);
}
return {
searchFormData,
handleSearch
};
});
组件源码分析
通过查看ZCrud
组件的源码,我们找到了关键线索:
<ZForm
modelValue={dialogFormData.value}
onUpdate:modelValue={(val: any) => { dialogFormData.value = val }}
/>
这段代码揭示了问题的本质:当表单数据更新时,组件是通过替换整个对象而不是修改属性的方式来更新数据的。这导致了响应式对象引用的变化。
为了验证这一点,我们修改了store中的handleSearch
函数:
function handleSearch(data) {
console.log(searchFormData === this.searchFormData, 'are they the same object?');
console.log(searchFormData, 'local searchFormData');
console.log(this.searchFormData, 'store.searchFormData');
}
测试结果显示它们不是同一个对象,这证实了我们的猜想:组件通过替换整个reactive对象的方式更新数据,导致了引用的不一致。
进一步思考:为什么Options写法从来没有这个问题?
在发现问题的根本原因后,我们开始思考:为什么以前使用Options API写法的Pinia store从来没有遇到过这个问题?
让我们对比一下Options写法:
// Options Store 写法
export const useStore = defineStore('gamePkg', {
state: () => ({
searchFormData: {
gameProductId: '',
areaCode: '',
storeId: '',
gameName: ''
}
}),
actions: {
handleSearch(data) {
// 这里使用 this.searchFormData 从来没有问题
console.log(this.searchFormData, 'searchFormData', data);
}
}
});
这个对比让我们意识到,问题可能不仅仅是组件替换对象这么简单,而是Setup Store和Options Store在内部处理机制上存在根本性的差异。
Pinia源码分析
为了深入理解这个问题,我们需要查看Pinia的内部实现机制。
Options Store的处理机制
在Options式的store中,Pinia会将state、getters、actions分别处理:
// Pinia 内部处理 Options Store 的伪代码
function createOptionsStore(id, options, pinia) {
const store = reactive({ $id: id });
// 处理 state
const state = options.state();
pinia.state.value[id] = reactive(state);
// 处理 actions - 关键在这里!
Object.keys(options.actions).forEach(actionName => {
const originalAction = options.actions[actionName];
// wrapAction 会绑定 this 到 store 实例
store[actionName] = function(...args) {
// this 被绑定到 store 实例,而不是闭包变量
return originalAction.apply(store, args);
};
});
// 建立 state 的代理连接
Object.keys(state).forEach(key => {
Object.defineProperty(store, key, {
get() {
return pinia.state.value[id][key];
},
set(value) {
pinia.state.value[id][key] = value;
}
});
});
return store;
}
关键点:在Options Store中,actions会被wrapAction
包装,this
总是指向store实例
Setup Store的处理机制
在Setup式的store中,所有内容都在一个函数中定义:
// Pinia 内部处理 Setup Store 的伪代码
function createSetupStore(id, setupFn, pinia) {
const store = reactive({ $id: id });
// 执行 setup 函数
const setupResult = setupFn();
// 将返回的内容分类处理
Object.keys(setupResult).forEach(key => {
const value = setupResult[key];
if (isRef(value) || isReactive(value)) {
// 这是 state,需要转移到 pinia.state 中
pinia.state.value[id] = pinia.state.value[id] || {};
pinia.state.value[id][key] = value;
// 在 store 上建立代理
Object.defineProperty(store, key, {
get() {
return pinia.state.value[id][key];
},
set(newValue) {
pinia.state.value[id][key] = newValue;
}
});
} else if (typeof value === 'function') {
// 这是 action,直接赋值(没有重新绑定 this)
store[key] = value; // 函数内部的闭包引用保持不变!
}
});
return store;
}
关键点:Setup Store中的函数直接通过闭包引用局部变量,没有重新绑定
问题的根本原因
通过源码分析,我们发现了问题的根本原因:
Options Store为什么不会出现问题:
- Actions通过
wrapAction
重新绑定:每个action都被包装,this
指向store实例 - 通过
this
访问状态:this.searchFormData
总是通过store实例的代理属性访问 - 代理属性指向最新值:store实例的属性通过getter/setter代理到
pinia.state.value[id]
// Options Store 中的 action 实际执行
function handleSearch(data) {
// this 指向 store 实例
// this.searchFormData 通过代理访问 pinia.state.value.gamePkg.searchFormData
console.log(this.searchFormData); // 总是最新值
}
Setup Store为什么会出现问题:
- 函数保持原始闭包引用:
handleSearch
函数直接引用setup函数中的局部变量 - 局部变量不会更新:当组件替换整个对象时,闭包中的引用仍然指向旧对象
- 没有通过代理访问:直接访问闭包变量,绕过了Pinia的代理机制
// Setup Store 中的问题
export const useStore = defineStore('gamePkg', () => {
const searchFormData = reactive({ gameProductId: '' }); // 局部变量A
function handleSearch(data) {
console.log(searchFormData); // 闭包引用局部变量A,永远不变
}
return { searchFormData, handleSearch };
});
// 当组件更新时
// store.searchFormData = newObject; // 这会更新 pinia.state,但不会影响闭包中的引用
解决方案
针对这个问题,有几种解决方法:
-
使用Pinia的配置式API:
export const useStore = defineStore('gamePkg', {
state: () => ({
searchFormData: {
gameProductId: '',
areaCode: '',
storeId: '',
gameName: ''
}
}),
actions: {
handleSearch(data) {
console.log(this.searchFormData, 'searchFormData', data);
}
}
});
配置式API中的action会通过wrapAction
包装,绑定到store实例上,使用this
访问状态,确保总是获取最新值。
-
在Store中通过store实例访问:
function handleSearch(data) {
// 不使用闭包变量,而是通过store引用访问
const store = useStore();
console.log(store.searchFormData, 'searchFormData', data);
}
使用watch监听变化:
const searchFormData = reactive({...});
let currentSearchFormData = searchFormData;
watch(() => store.searchFormData, (newVal) => {
currentSearchFormData = newVal;
});
function handleSearch(data) {
console.log(currentSearchFormData, 'searchFormData', data);
}
解决方案的原理
为什么配置式API可以解决问题:
// 配置式 API 中,action 被 wrapAction 处理
actions: {
handleSearch(data) {
console.log(this.searchFormData); // this 指向 store,通过代理访问最新值
}
}
为什么通过store实例访问可以解决问题:
// Setup Store 中的解决方案
function handleSearch(data) {
const store = useStore(); // 获取 store 实例
console.log(store.searchFormData); // 通过代理访问最新值
}
总结
这个问题本质上是由于Vue的响应式系统和JavaScript闭包特性之间的交互所导致的。当组件通过替换整个对象的方式更新数据时,闭包引用的对象和store实例属性的对象变成了不同的实例。
问题的本质是闭包引用 vs 代理访问的区别:
- Options Store:actions通过
wrapAction
绑定到store实例,使用this
访问状态,走的是代理路径 - Setup Store:functions保持原始闭包引用,直接访问局部变量,绕过了代理机制
在使用Composition API风格的Pinia store时,我们应当注意:
- 如果需要在store内部函数中访问最新状态,优先使用配置式API
- 或者通过store实例(而非闭包变量)访问状态
- 理解响应式对象的引用方式,特别是在组件可能替换整个对象的情况下
这个案例也提醒我们,在使用Vue的响应式系统时,不仅要关注值的变化,还要注意对象引用的变化。这就是为什么同样是响应式对象,在不同的访问方式下会表现出不同的行为。Options式的设计天然地避免了这个问题,而Setup式需要开发者注意访问路径的选择。