这是项目之前遇到的一个bug,最终发现是由于 reset Vuex state 不正确,污染了 initState 导致的,隐藏得还挺深的,在这里记录一下。

(PS:想直接看代码实现的同学可以从第三节 正确地 reset module state 的姿势 开始看)

背景

项目是用 Vue + Nuxt 写的一个H5网页。

下图是分类页的界面,左边的导航给出的分类项可以叠加选择。

在选择了任意分类项后点击 重置 可以把所有选中的项或输入的值恢复到未设置状态。

其中 价格范围 是输入最小值和最大值。

一个bug

某天产品经理跟我反馈了一个bug……
简单来说,就是如果用户设置过价格范围,然后点了重置,下次再次设置价格然后点击重置的时候,会无法重置价格……

为了更好地说明问题,我写了个简单的demo页面,给大家演示一下。

错误的示范

下面我们来看看这个错误实现的代码是怎样的。

基于上面的界面,而且 vuex 也分了模块,所以这个分类页的 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 方法。这是网上找到的比较多人建议的 reset state 的方法。

其实这种实现方式在大部分情况下还是work的,但是!!因为我们这个分类页的 state 是个层级比较深的对象,而里面 Object.assign(state[key], initState[key]) 这一句,就是关键!
因为 Object.assign 方法,其实是浅拷贝,所以当重置 priceRange 的时候,由于 priceRange 是个对象,那生成的 targetObject.assign(target, ...sources)】其实只是把引用指向了 initState.priceRange 的引用,也就是说,经过第一次重置之后,initStatepriceRange 和当前的 category statepriceRange 是指向了同一块内存的。
所以,当后面再次设置性别和价格然后点重置的时候,性别可以正常重置,但是价格已经无法重置了,因为 initState 已经被污染了!!

正确地 reset module state 的姿势

方法一

既然经过上面的解释,我们明白了是浅拷贝的锅,那很自然地就会想到用深拷贝的方式来解决这个问题。

下面直接上代码。

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:这里就只放跟上文 错误示范 里对比有修改的部分啦

方法二

在整理这篇文章的时候我又google了一下 vuex reset store,找到了个更优雅的实现方式。

如果我们把 initState 写成一个函数,比如 getDefaultState,这个函数就只是返回 initState 的,然后每次重置的时候先调用这个 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:这里只放跟上文 错误示范 里对比有修改的部分

总结

上面写了两种 reset state 的实现方式,我个人觉得第二种更优雅。

当然,其实还有一个问题,就是这个 category state 设计得过于复杂了,我们一般做项目的时候其实不建议嵌套太深,容易出问题。所以在一开始设计数据 model 的时候,还是要多加考虑呀。

参考

附录

最后附上 demo 页面的代码,方便有需要的同学自取演示。

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">价格范围:</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"></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>