最近在做的一个业务场景需要劫持掉 RadioGroup 的内部的数据自动更新行为,然后根据外部的条件动态判断能否更新数据。

问题简述

界面如下图。最上方的Tabs标签页是一级分类(Tabs);中间的按钮组(即RadioGroup)是二级分类;下方是数据展示面板,用于展示当前分类下的数据。

如果数据展示面板被手动操作导致数据发生更改后,此时去切换上方的一级分类或者二级分类都需要弹出二次确认弹窗如下:

解决方案

我们的使用的组件库是element-ui 2

下面将针对不同的组件提出不同的数据劫持的方案。

一级分类:Tabs

Tabs组件提供了 before-leave 钩子,可以使用这个钩子完成对数据变更的劫持。

使用示例如下:

二级分类:RadioGroup

相较于Tabs,RadioGroup的处理方案要复杂一些,因为这个组件并没有提供类似于 before-leave 的钩子,所以需要自行实现这一过程。

最初的想法是利用官方提供的 value 属性以及 change 回调来完成这一过程。因为如果用 v-model 的话,Vue内部就会代理所有的数据处理,就不能完成自定义的数据劫持了。

关键代码如下:

<template>
    <el-tabs v-model="activeLevelGroup" :before-leave="beforeLeave" :class="$style['tabs']">
        <el-tab-pane
            v-for="key in bigCatLevelManage"
            :key="key"
            :label="BigCatLevelGroupName[key]"
            :name="`${key}`"
        >
            <!-- FIXME: value + change -->
            <el-radio-group :value="activeLevel" size="small" @change="onRadioChange">
                <el-radio-button
                    v-for="level in BigCatLevelManage[key]"
                    :key="level"
                    :label="level"
                    :data-level="level"
                >
                    {{BigCatBookLevelName[level]}}
                </el-radio-button>
            </el-radio-group>
        </el-tab-pane>
    </el-tabs>
</template>

<script lang="ts">
import {Vue, Prop, Component, Watch} from 'vue-property-decorator';

@Component
export default class LevelTab extends Vue {
    activeLevel = 0;

    onRadioChange(value) {
        // 这里拦截数据变更
        if (this.allowChange) {
            this.activeLevel = value;
        }
        else {
            // ……
        }
    }
}
</script>

当我兴高采烈地去运行代码的时候发现实际效果与预想的效果差的十万八千里。

无论我点选哪个选项,该选项都会被点亮,并且再次点击也无法取消选中……😰

到这里,继续靠推测去研究方案效率就很低了。不如干脆去看看element-ui 源码,看看RadioGroupRadioButton的数据是怎么传递和更新的.

RadioGroup 与 RadioButton

先看了 RadioGroup 的源码,只在 created 钩子里看见了数据处理相关的代码。它监听了 handleChange 事件,并在监听到该事件后向上层发送 change 回调。

created() {
    this.$on('handleChange', value => {
        this.$emit('change', value);
    });
},

RadioGroup 没有更多关于数据处理的代码了,那么玄机就应该在 RadioButton 的源码里了,这里先贴一下 RadioButton 代码里有关数据处理的部分。

<template>
  <label
    class="el-radio-button"
    :class="[
      size ? 'el-radio-button--' + size : '',
      { 'is-active': value === label },
      { 'is-disabled': isDisabled },
      { 'is-focus': focus }
    ]"
    role="radio"
    :aria-checked="value === label"
    :aria-disabled="isDisabled"
    :tabindex="tabIndex"
    @keydown.space.stop.prevent="value = isDisabled ? value : label"
  >
    <input
      class="el-radio-button__orig-radio"
      :value="label"
      type="radio"
      v-model="value"
      :name="name"
      @change="handleChange"
      :disabled="isDisabled"
      tabindex="-1"
      @focus="focus = true"
      @blur="focus = false"
    >
    <span
      class="el-radio-button__inner"
      :style="value === label ? activeStyle : null"
      @keydown.stop>
      <slot></slot>
      <template v-if="!$slots.default">{{label}}</template>
    </span>
  </label>
</template>
<script>
  import Emitter from 'element-ui/src/mixins/emitter';

  export default {
    name: 'ElRadioButton',

    mixins: [Emitter],
    
    computed: {
      // **注意这里**
      value: {
        get() {
          return this._radioGroup.value;
        },
        set(value) {
          this._radioGroup.$emit('input', value);
        }
      },
      _radioGroup() {
        let parent = this.$parent;
        while (parent) {
          if (parent.$options.componentName !== 'ElRadioGroup') {
            parent = parent.$parent;
          } else {
            return parent;
          }
        }
        return false;
      },
    },

    methods: {
      handleChange() {
        this.$nextTick(() => {
          this.dispatch('ElRadioGroup', 'handleChange', this.value);
        });
      }
    }
  };
</script>

从源码里可以看到,内部是使用 <input type="radio" /> 实现的单选按钮。

当我们的使用了 value 来传递数据时,可以监听 input 回调来劫持数据的更改。

然后当我使用 value + input 的组合时,展示效果依然跟上面一样,所有的选项都会被点亮。我用Vue devtools查看数据的时候发现,数据传递确实中断了。而我所看到的点亮的效果,居然是 input:checked 样式造成的,而不是数据。

那么想要用 value 就必须要消除 inputchecked 状态对样式的影响。所以当前最佳的办法就是自定义 click 方法,然后使用 .prevent 修饰符来屏蔽掉内部的点击事件。

“劫持”RadioGroup终极方案

<template>
    <el-tabs v-model="activeLevelGroup" :before-leave="beforeLeave" :class="$style['tabs']">
        <el-tab-pane
            v-for="key in bigCatLevelManage"
            :key="key"
            :label="BigCatLevelGroupName[key]"
            :name="`${key}`"
        >
            <el-radio-group :value="activeLevel" size="small">
                <!-- NOTE:这里拦截了radio-button的向上冒泡。主要为了实现数据切换前进行一系列的判断 -->
                <el-radio-button
                    v-for="level in BigCatLevelManage[key]"
                    :key="level"
                    :label="level"
                    :data-level="level"
                    @click.native.prevent="handeClickRadio(level)"
                >
                    {{BigCatBookLevelName[level]}}
                </el-radio-button>
            </el-radio-group>
        </el-tab-pane>
    </el-tabs>
</template>

<script lang="ts">
import {Vue, Prop, Component, Watch} from 'vue-property-decorator';

@Component
export default class LevelTab extends Vue {

    activeLevel =0;

    // 拦截radio-button向上冒泡,用于判断当前是否允许切换选项
    handeClickRadio(level: BigCatBookLevel) {
        // 如果当前点击的radio已经是选中状态,则不作处理
        if (level === this.activeLevel) {
            return;
        }
        this.beforeLeave().then(() => {
            this.activeLevel = level;
        });
    }
}
</script>

我是尾巴

至此,问题总算解决了,这也感谢其他小伙伴的帮助。

遇到这种问题的时候,还是要多看源码是怎么设计的,从里面寻找突破口。