這是項(xiàng)目之前遇到的一個(gè)bug,最終發(fā)現(xiàn)是由于 reset Vuex state 不正確,污染了 initState 導(dǎo)致的,隱藏得還挺深的,在這里記錄一下。
(PS:想直接看代碼實(shí)現(xiàn)的同學(xué)可以從第三節(jié),正確地 reset module state 的姿勢(shì) 開始看)
背景
項(xiàng)目是用 Vue + Nuxt 寫的一個(gè)H5網(wǎng)頁。
下圖是分類頁的界面,左邊的導(dǎo)航給出的分類項(xiàng)可以疊加選擇。
在選擇了任意分類項(xiàng)后點(diǎn)擊 重置 可以把所有選中的項(xiàng)或輸入的值恢復(fù)到未設(shè)置狀態(tài)。

其中 價(jià)格范圍 是輸入最小值和最大值。

一個(gè)bug
某天產(chǎn)品經(jīng)理跟我反饋了一個(gè)bug……
簡(jiǎn)單來說,就是如果用戶設(shè)置過價(jià)格范圍,然后點(diǎn)了重置,下次再次設(shè)置價(jià)格然后點(diǎn)擊重置的時(shí)候,會(huì)無法重置價(jià)格……
為了更好地說明問題,我寫了個(gè)簡(jiǎn)單的demo頁面,給大家演示一下。

錯(cuò)誤的示范
下面我們來看看這個(gè)錯(cuò)誤實(shí)現(xiàn)的代碼是怎樣的。
基于上面的界面,而且 vuex 也分了模塊,所以這個(gè)分類頁的 store 是這樣的:
category.js
const initState = {
selectedIds: {
gender: null,
category: [],
discount: [],
priceRange: {
start: null,
end: null
},
source: [],
}
}
export const state = () => {
return Object.assign({}, initState)
}
export const mutations = {
SET_FILTER_IDS_STATE(state, data) {
Object.keys(data).forEach(key => {
console.log('key', key)
if (key === 'priceRange') {
state.selectedIds.priceRange.start = data[key].start
state.selectedIds.priceRange.end = data[key].end
} else {
state.selectedIds[key] = data[key]
}
})
},
RESET_ALL_FILTERS(state) {
Object.keys(initState).forEach(key => {
Object.assign(state[key], initState[key])
})
},
}
export const actions = {
async setFilters({ commit }, { selectedIds }) {
commit('SET_FILTER_IDS_STATE', selectedIds)
},
async resetAllFilters({ commit }) {
commit('RESET_ALL_FILTERS')
},
}
export const getters = {
selectedIds(state) {
return state.selectedIds
},
}
問題就出在上面的 RESET_ALL_FILTERS 方法。這是網(wǎng)上找到的比較多人建議的 reset state 的方法。
其實(shí)這種實(shí)現(xiàn)方式在大部分情況下還是work的,但是!!因?yàn)槲覀冞@個(gè)分類頁的 state 是個(gè)層級(jí)比較深的對(duì)象,而里面 Object.assign(state[key], initState[key]) 這一句,就是關(guān)鍵!
因?yàn)?Object.assign 方法,其實(shí)是淺拷貝,所以當(dāng)重置 priceRange 的時(shí)候,由于 priceRange 是個(gè)對(duì)象,那生成的 target【Object.assign(target, ...sources)】其實(shí)只是把引用指向了 initState.priceRange 的引用,也就是說,經(jīng)過第一次重置之后,initState 的 priceRange 和當(dāng)前的 category state 的 priceRange 是指向了同一塊內(nèi)存的。
所以,當(dāng)后面再次設(shè)置性別和價(jià)格然后點(diǎn)重置的時(shí)候,性別可以正常重置,但是價(jià)格已經(jīng)無法重置了,因?yàn)?initState 已經(jīng)被污染了??!
正確地 reset module state 的姿勢(shì)
方法一
既然經(jīng)過上面的解釋,我們明白了是淺拷貝的鍋,那很自然地就會(huì)想到用深拷貝的方式來解決這個(gè)問題。
下面直接上代碼。
category.js
import cloneDeep from 'lodash.clonedeep'
export const state = () => {
return cloneDeep(initState)
}
export const mutations = {
RESET_ALL_FILTERS(state) {
Object.assign(state, cloneDeep(initState))
},
}
PS:這里就只放跟上文 錯(cuò)誤示范 里對(duì)比有修改的部分啦
方法二
在整理這篇文章的時(shí)候我又google了一下 vuex reset store,找到了個(gè)更優(yōu)雅的實(shí)現(xiàn)方式。
如果我們把 initState 寫成一個(gè)函數(shù),比如 getDefaultState,這個(gè)函數(shù)就只是返回 initState 的,然后每次重置的時(shí)候先調(diào)用這個(gè) getDefaultState 再賦值,那就能保證 initState 一定是初始值啦,也就同樣可以避免 initState 被污染的問題了。
還是上代碼。
category.js
const getDefaultState = () => {
return {
selectedIds: {
gender: null,
category: [],
discount: [],
priceRange: {
start: null,
end: null
},
source: [],
}
}
}
export const state = getDefaultState
export const mutations = {
RESET_ALL_FILTERS(state) {
const initState = getDefaultState()
Object.keys(initState).forEach(key => {
state[key] = initState[key]
})
},
}
PS:這里只放跟上文 錯(cuò)誤示范 里對(duì)比有修改的部分
總結(jié)
上面寫了兩種 reset state 的實(shí)現(xiàn)方式,我個(gè)人覺得第二種更優(yōu)雅。
當(dāng)然,其實(shí)還有一個(gè)問題,就是這個(gè) category state 設(shè)計(jì)得過于復(fù)雜了,我們一般做項(xiàng)目的時(shí)候其實(shí)不建議嵌套太深,容易出問題。所以在一開始設(shè)計(jì)數(shù)據(jù) model 的時(shí)候,還是要多加考慮呀。
參考
附錄
最后附上 demo 頁面的代碼,方便有需要的同學(xué)自取演示。
demo.vue
<template>
<div class="page">
<section>
<form>
<div class="input-group">
<label class="input-label">性別:</label>
<div class="radio-group">
<input id="man" type="radio" value="man" name="gender" v-model="gender" />
<label for="man">男士</label>
</div>
<div class="radio-group">
<input id="woman" type="radio" value="woman" name="gender" v-model="gender" />
<label for="woman">女士</label>
</div>
</div>
<div class="input-group">
<label class="input-label">價(jià)格范圍:</label>
¥<input class="input" type="number" v-model="minPrice" />至
¥<input class="input" type="number" v-model="maxPrice" />
</div>
<div class="btn-group">
<button class="btn btn-reset" @click.prevent="onReset">重置</button>
<button class="btn btn-submit" @click.prevent="onSubmit">提交</button>
</div>
</form>
</section>
<hr />
<section class="vuex-display">
<h3 class="vuex-display-title">Vuex state</h3>
<div class="state-item" v-for="key in Object.keys(selectedIds)" :key="key">
<span class="state-key">{{key}}:</span>
<p
v-if="key === 'priceRange'"
>{{selectedIds[key].start && selectedIds[key].end ? selectedIds[key].start + '-' + selectedIds[key].end : '未選擇'}}</p>
<p v-else-if="key === 'gender'">{{selectedIds[key] || "未選擇"}}</p>
<p v-else>{{selectedIds[key].join(',') || '未選擇'}}</p>
</div>
</section>
</div>
</template>
<script>
import { mapGetters, mapActions } from "vuex";
export default {
name: "test",
layout: "single-page",
data() {
return {
minPrice: NaN,
maxPrice: NaN,
gender: null,
category: [],
discount: [],
source: []
};
},
computed: {
...mapGetters({
selectedIds: "categoryFilter/selectedIds"
})
},
async mounted() {
this.initPriceRange();
},
methods: {
...mapActions({
setFilters: "categoryFilter/setFilters",
resetFilters: "categoryFilter/resetAllFilters"
}),
async initFilters() {
try {
const res = await this.$axios.$get(
"api" + this.$api.filter.categoryList
);
if (res.status === 0) {
const { data } = res;
this.$store.commit("category/FETCH_FILTERS", {
data
});
}
} catch (e) {
console.error(e);
}
},
initPriceRange() {
this.minPrice = this.selectedIds.priceRange.start || NaN;
this.maxPrice = this.selectedIds.priceRange.end || NaN;
},
onSubmit() {
let selectedIds = {
gender: this.gender,
category: this.category,
discount: this.discount,
source: this.source,
priceRange: {
start: Number(this.minPrice),
end: Number(this.maxPrice)
}
};
this.setFilters({
selectedIds
});
},
onReset() {
this.minPrice = NaN;
this.maxPrice = NaN;
this.gender = null;
this.category = [];
this.discount = [];
this.source = [];
this.resetFilters();
}
}
};
</script>
<style lang="scss" scoped>
.page {
padding: 20px;
}
.input-group {
color: #333;
display: flex;
justify-content: flex-start;
padding: 10px 0;
align-items: center;
}
.input {
width: 80px;
border: solid 0.5px #aaa;
border-radius: 5px;
padding: 0 10px;
line-height: 30px;
margin-right: 10px;
}
.input-label {
margin-right: 5px;
font-weight: bold;
}
input[type="radio"] {
margin-right: 5px;
}
.radio-group {
display: flex;
align-items: center;
margin-right: 20px;
}
.btn-group {
margin-top: 20px;
text-align: right;
display: flex;
justify-content: space-between;
}
.btn {
width: 60px;
height: 35px;
border-radius: 5px;
width: 46%;
}
.btn-submit {
background-color: #333;
border: none;
color: #fff;
}
.btn-reset {
border: #333 solid 0.5px;
background: #fff;
color: #333;
}
hr {
border: solid 0.5px #aaa;
margin: 10px 0;
}
section {
padding-bottom: 20px;
}
.vuex-display-title {
font-size: 20px;
padding: 10px 0;
}
.vuex-display {
color: #333;
}
.state-item {
line-height: 1.5;
padding-bottom: 10px;
}
.state-key {
font-weight: bold;
}
</style>