之前接到一个任务,要求用一个输入框让用户得以输入商品的折扣,但是折扣的形式根据用户所在的国家地区的不同而不同。具体来说:英语国家可以输入 1 ~ 99 的数值,表示商品的优惠(discount);中文地区可以输入 0.1 ~ 9.9 的数值,表示商品的折扣(percentage)。

举例来说,英语国家的用户输入 15 ,表示商品会给用户原价 15% 的优惠;而中文地区的用户输入 8.5 ,表示商品实际价格是原价的 85% 。

二者在前端展示时刚好互补,但是后台保存数据时,统一使用英语国家的形式。

也就是说,在展示数据和处理用户的输入时前端可能要转换一下。

这种处理数据的逻辑,我个人有一个倾向:将逻辑向最底层或者最顶层两端推,而不是放在系统的中间。在这个例子中,要么将处理逻辑放在输入组件中,要么在接收数据、发送数据时做转换;这样系统的大部分代码都不用知道这个特殊的逻辑,而且出问题了也容易定位。

出于复用、以及更容易维护代码考虑,将处理逻辑放在输入组件中会更好。

先声明一点:以下具体实现不是本人想出来的,也不是实际业务中运行的代码。虽然也不是什么高深的技巧,但我觉得写得好,分享出来希望能帮到有需要的人。

假设前端项目用的框架是 element ui ,我们要复用这个框架的 el-input 组件,同时要注入以上特殊的业务逻辑,实现一个基础组件

官方有实现基础组件的指南,其中有一点是 Disabling Attribute Inheritance,这样做可以阻止组件的根元素继承上面传下来的属性,再配合使用 $attrs 可以将上面传下来的属性(除去 style/class 属性以及在 props 中定义的属性绑定)传给真正需要的组件。

具体来说,我们要实现一个封装了 el-input 组件的基础组件,将上面传下来的属性传进 el-input 。

template 的内容:

<label>
  label: 
  <el-input
    v-bind="$attrs"
    placeholder="请输入内容"/>
</label>

script 的内容:

export default {
  name: 'CustomInput',
  inheritAttrs: false
}

接下来我们要在组件内部维护一个变量,显示输入框的内容,并且在用户输入时向上面触发一个事件。将这个变量命名为 internalValue ,作为 v-model 指令的属性值传到 el-input 组件。

template 的内容:

<label>
  label: 
  <el-input
    v-model="internalValue"
    v-bind="$attrs"
    placeholder="请输入内容"/>
</label>

说一下 v-model ,它是一个语法糖,假设你这样写:

<input v-model="data" />

这种写法与下面的写法是等价的:

<input v-bind:value="data" v-on:input="data = $event.target.value" />

使用 v-model 可以解决输入框显示的问题,但怎样在用户输入时向上面触发一个事件呢?

方法有很多,比如 watch 这个 internalValue 值,这个值被改变时触发事件;或者在 el-input 组件上使用 v-on:input 指令(即使用了 v-model 指令,你还可以使用 v-on:input 指令,两者不会相互影响);你甚至可以把 v-model 拆散成 v-bind:valuev-on:input 两个指令。

但这些做法都不够聪明。联想到 vue 实例的 computed 属性可以设置 getter 和 setter 。我们可以把 internalValue 设置为组件的 computed 属性,在 getter/setter 集中处理业务逻辑。

export default {
  name: 'CustomInput',
  inheritAttrs: false,
  computed: {
    internalValue: {
      get() {

      },
      set(rawValue) {

      }
    }
  }
}

接下来结合业务逻辑,定义组件的一些属性。

  • type 属性,有两个可能值, discount 表示处于英语国家, percent 表示处于中文地区
  • input 属性,由上层传下来的值,永远是 discount 形式

根据 type 属性,在 getter/setter 中做数值转换。

export default {
  name: 'CustomInput',
  inheritAttrs: false,
  props: {
    type: {
      type: String,
      default: 'discount'
    },
    input: {
      type: String|Number,
      default: ''
    }
  },
  computed: {
    value: {
      get: function() {
        // input is always discount
        return this.type === 'discount' ? Number(this.input) : this.discountToPercent(this.input)
      },
      set: function(rawValue) {
        // always emit discount
        const value = this.type === 'discount' ? Number(rawValue) : this.percentToDiscount(rawValue)
        this.$emit('input', value)
      }
    }
  },

  methods: {
    // 8.5 -> 15
    percentToDiscount(percent) {
      return (10 - percent) * 10
    },

    // 30 -> 7
    discountToPercent(discount) {
      return (100 - discount) / 10
    }
  }
}

目前为止,我们的组件已经能实现功能,但是作为基础组件,还缺少了些东西。

我们只把 $attrs 传给 el-input 组件,却没有把事件也传递过去,而且出于封装考虑,我们不应该把 input 事件绑定传递给 el-input 。

<label>
  label:
  <el-input
    v-bind="$attrs"
    v-model="value"
    v-on="filteredListener"
    placeholder="请输入内容"/>
</label>
export default {
  // ...省略部分代码
  computed: {
    filteredListener(){
      const filteredListener = Object.assign({}, this.$listeners)
      delete filteredListener.input
      return filteredListener
    }
    //...省略部分代码
  }
  //...省略部分代码
}

最后还得处理用户的输入,比如用户输入小数点/非数字时输入框的回显, javascript 小数精度问题等等。这些都可以在 getter/setter 中处理,不过细节非常多,我也没能一一处理,想了解更多可以看代码,就不再说了。