Skip to content

Vue开发技巧合集(一)

一、合理使用计算属性

合理地使用计算属性可以让代码更加优雅、严谨且易于维护。那么,何时应该使用计算属性呢?使用计算属性时,你应当遵循这个准则:

如果属性依赖了props data中定义的属性,那么它应当是个计算属性。

vue
data: {
    list: []
},
computed: {
    isEmpty() {
        return !this.list || !this.list.length
    }
}

以上例子中,isEmpty属性依赖了list属性,因此它必须是一个计算属性,当list改变时,isEmpty会自动重新计算获得新值,得益于vue的响应式系统,这个过程是自发产生的,不需要我们手动维护。这里的技巧可以提取出一个开发的思路:

在多个数据间同步状态是复杂且难于管理的,应当使用JavaScript的语言特性来管理依赖而不是手动管理,因为JavaScript可以捕捉到对象属性get set的时机,在这个时机里管理依赖才是最严谨且易于维护的方案。

以上例子,如果不使用计算属性,也可以使用data + watch的组合来实现:

data: {
    list: [],
    isEmpty: false
},
watch: {
    list(newVal) {
        this.isEmpty = !newVal || !newVal.length
    }
}

虽然这种方法也可以达到同样的目的,但仍然是借助了vue的响应式系统,因为vue拦截了对象的set方法,在list改变时,会分发属性改变的事件到watch中,因此其本质是与computed一样的。 尽管如此,使用computed仍然是最好的方案,因为此处的isEmpty只是一个读属性,且其与list是相辅相成的,没有list自然也就没有isEmpty,他们是依赖关系,而不是同级关系,理清这个思路很重要。

最忌讳的方案

以上叙述的都是借助于vue的响应式系统来管理依赖的方案,这些都是比较好的实践。有的初学者会这样来同步两者关系:

data: {
    list: [],
    isEmpty: false
},
created() {
    this.isEmpty = !this.list || !this.list.length
}

这是最忌讳的方案。简言之,这种处理方法的缺陷在:错误地选择了同步状态的时机。应当在属性改变时来同步依赖的状态,而不是在组件创建时,想一想,如果在组件的生命周期中的其他某个时间点,list的值被改了,isEmpty会同步修改状态吗?不会。 前面叙述的data + watch的实现方法虽说不是最佳实践,但是其同步状态的时机是正确的,这种做法只是手动实现了computed而已,除了代码写的比较麻烦,其实与computed的作用是一致的。 但是在这个方案里,开发者没有考虑到list可能在其他时间被改变的情况(例如list是父组件传入的,父组件可能在某个时间点改变list的值),因此,一旦发生这种情况,就会出现list与isEmpty状态不一致的情况。

其他巧用

计算属性除了作为读属性外,还可以作为写属性。上面的例子中,如果开发者想要通过:

this.isEmpty = true

的方式将list的内容清空,我们应该如何做这件事呢?

vue
data: {
    list: []
},
computed: {
    isEmpty: {
        get() {
            return !this.list || !this.list.length
        },
        set(newVal) {
            if (!newVal) {
                this.list.splice(0)
            }
        }
    }
}

除了上面的场景,我们还有比较经典的使用计算属性的读写属性的场景,例如制作一个双向绑定特性的编辑器:

vue
props: {
    value: {
        type: String,
        required: true
    }
},
computed: {
    text: {
        get() {
            return this.value
        },
        set(newVal) {
            this.$emit('input', newVal)
        }
    }
}
// 使用
<editor v-model="content" />

请注意,因为单向数据流的设计原理,子组件不能直接改变父组件的值,因此上述例子中,如果你想通过this.value = 'xxx'的方式来改变父组件的值这是不可行的,因此只能抛出事件的方式通知父组件改变自己的data。 因为不能直接对value做写操作,因此这里使用计算属性是最快捷、最优雅的处理方法,当value变化时,text会自动计算并引起编辑器内容重绘。

二、watch使用技巧

关于watch,主要掌握一个技巧即可:立即监听机制。有的时候,我们希望被监听的属性在第一次初始化时就能执行一次处理函数,这个时候我们应当怎么做呢?

vue
data: {
    list: []
},
watch: {
   list: {
        immediate: true,
        handler(newVal) {
            // ...
        }
    }
}

这样,当组件创建时,会马上执行一次list的watch事件,其处理函数中的参数就是list定义时的初始值。 请注意,在data中定义的属性,不会马上被监听到改变,因为在data执行时,vue组件并没有创建完成,更别说监听到改变了。因此,私以为immediate只是一个标记,带有该标记的watch字段会在组件创建完成后马上执行一次处理函数。

三、父子组件通信

首先,单向数据流的设计原理是优秀的,子组件不具备改变父组件的任何能力,现实生活其实也遵循这个规律,举例说:

你买了一台电脑,你是父组件,你持有了子组件,你可以通过接口操作子组件,你可以为电脑换一个新的显卡(改变属性),可以按下电源键开机(调用函数)。但是,子组件操控不了父组件,对于电脑而言,它不会知道将会被谁持有,因此,子组件只能通过抛出事件的方式与父组件进行交流。例如电脑由于故障发生了蓝屏,这个蓝屏其实就是一个事件,或者说是一个受激信号。在发出蓝屏的信号后,使用者只能根据该信号作出反应,这样便完成了子组件对父组件的交流。但是请注意,这里子组件并没有操控父组件,如果电脑可以操控使用者的话,那么在蓝屏后,它应当能自动调用使用者的修复问题的函数来解决问题,但是刚才我们说了,子组件不知道其被谁所持有,有可能是一个资深的电脑工程师,也可能是一个90岁的老人,你能期望他们拥有一样的电脑修复能力吗?

关于以上例子可以继续引申一下,有的电脑是专门为专业人士准备的,当你购买该电脑时,需要先确保你拥有专业的电脑修复能力方可购买,当电脑蓝屏时,这个子组件此时可以假设你具备一切修理能力,因此它会直接调用使用者的修复函数,这是因为在买电脑前,使用者向电脑这个子组件注入了修复电脑这个能力。这种设计思路在vue中也有提现,请参考vue的provide/inject:vue provide/inject

那么,在vue中,父子组件是如何进行通信的呢?首先,父组件通过接口将属性注入到子组件,子组件接收到父组件传递的信息后,做一些内部操作(黑盒子原理),在适当的时机,子组件通过抛出事件的方式告知子组件当前的状态,这样,便完成了整个通信流程。 用代码描述,这个过程是这样的:

vue
// 父组件
const content = 'xxx'
function handleChange(text) {
    // ...
}
<editor :value="content" @change="handleChange" />
// 子组件
props: {
    value: String
},
created() {
    // 适当时机
    doSomething(() => {
        this.$emit('change', 'yyy')
    })
}

四、v-model本质

**双向绑定只是一种语法糖。**使用者不要将单向数据流与双向绑定混淆,让我们来看这么一个例子:

// 父组件
let content = "xxx"
function handleChange(text) {
    content = text
}
<editor :value="content" @change="handleChange"  />
// 子组件
<input :value="value" @keydown="handleKeyDown">
props: {
    value: String
},
methods: {
   handleKeyDown(e) {
        this.$emit('change', e.target.value)
    }
}

上述例子中,父组件传递内容到子组件,子组件通过输入框进行展示,当用户在输入框中输入内容时,子组件抛出一个change事件,父组件捕捉到该事件,改变内容的值,该值继续流向子组件引起子组件重绘,于是输入框显示了新的值。 这,就是双向绑定。父组件的内容通过props绑定到子组件,子组件的内部数据改变时,通过事件告知父组件,将新的值绑定到父组件的某个属性上。这样,父组件的值可以影响UI展示,UI交互也可以引起数据变化,这就是双向绑定的核心。 看到这里,我们可以放心的说,双向绑定没有违背单向数据流的设计原理。只是组合了父子组件通信,将数据与UI绑定在了一起,某一方的变化可以引起另一方的变化而已。

那么,为什么说,双向绑定是一种语法糖呢? 请注意看上面的例子,如果每一个双向绑定都要写这么多模式化的代码,这对于开发者太不友好了。一个好的框架、工具、组件一定能让开发者少些代码,尽可能得通过抽象来达到该目的。那么,vue是怎么做这件事的呢?

// 父组件
let content = "xxx"
<editor :value.sync="content"  />
// 子组件
<input :value="value" @keydown="handleKeyDown">
props: {
    value: String
},
methods: {
   handleKeyDown(e) {
        this.$emit('update:value', e.target.value)
    }
}

通过.syncupdate:xxx的语法组合,将数据改变的事件抽象到了vue的内部,其实内部发生的事与上面是一样的,vue会捕捉update:xxx这个事件,然后重新改变value的值。

一步到位

上面的方法已经很快捷的实现了双向绑定,但是仍然有两个问题:

  1. :value.syncupdate:value这种写法太复杂,每次都要这么写,要是忘记了怎么办?
  2. 对于输入框而言,实际只有一个需要双向绑定的数据即value,甚至对于大多数组件而言都是这样的,应该为这种情况提供一个快速解决办法。

于是,v-model出现了,v-model语法默认绑定名为value的属性,子组件改变该数据时,默认需要抛出input事件,因此: v-model = :value.sync + emit('input')

五、如何组织data?理解响应式系统

很多人在组织data的数据时,常犯这些毛病:

  • 将本不应该托管到响应式系统的数据组织在data中
  • 将本应该是计算数据的数据组织在data中
  • 平铺组织data,层级混乱,可读性差

区分响应式数据与非响应式数据

首先,vue的响应式系统其实就是一种拦截机制代理模式,借助于Object.defineProperty()API,可以拦截到对象每个属性的读写时机,当数据发生读写操作时,通知依赖数据进行更新(在此之前要先收集依赖)。

因此,你应当知道的是,响应式系统是复杂的,如果一个数据,它不会有任何依赖(没有UI依赖,也没有其他依赖),它最好不要存在于响应式系统中,占用不必要的资源。在vue中,我们可以这么做,将一个数据定义在响应式系统之外,但又保证可以读取得到该值:

vue
data() {
    // 非响应式数据
    this.type = 1
    return {
        // 响应式数据
        list: []
    }
}

在组织data前,你应当先思考什么是响应式数据,什么是非响应式数据,然后为他们准备不同的容器,而不是一股脑的将所有数据全部置于响应式容器中。

这个习惯可以逼迫开发者去思考,思考响应式系统的原理,思考依赖收集与依赖管理。

合理使用计算属性

第一点中已经详细论述该问题,此处不再赘述。

设计数据层级

data的层级是非常重要的,很多人容易忽视这个问题。例如对于一个表单页面,你应当这样组织data:

data: {
    form: {
        username: '',
        password: ''
    }
}

切勿将所有属性都平铺展示,这样会严重降低代码的可读性和增大后期维护的难度。

如果页面有多个loading,你可以这么组织loading:

data: {
    listLoading: false,
    submitLoading: false
}

这样的写法虽能满足需求,但是如果loading的状态很多的时候,就会使得平铺的属性过多且分散,同样降低了代码的可读性,此时你可以借助命名空间的概念,这样设计data层级:

data: {
    loading: {
        list: false,
        submit: false
    }
}

这样,当未来需要扩展loading状态时,会非常方便。同样,为一个表格页面组织查询条件的data,你可以这么做:

data: {
    query: {
        id: '',
        keyword: '',
        status: 1,
        // ...
    }
}

如此这般,只要将命名空间的思想渗透到思考方式中,写出的代码就会变得工整、易读、易于维护和扩展。

最小化原则

最后,需要注意的是,我们在设计data时,应当让data尽可能地少。可以通过两个手段来完成:

  1. 减少不必要的属性,将依赖属性提取到计算属性中,利用命名空间的概念减少一级属性的个数;
  2. 如果组件的属性过多,则说明组件的逻辑过于复杂,此时可以通过提取组件的方式将复杂的逻辑分散至各个子组件中。

需要注意的是,组件的逻辑复杂程度决定了data的复杂度,一个复杂的组件,不管开发者如何去缩减属性的个数,最终属性集合也是庞大的。此时,推荐大家使用提取组件的方式,将大的组件拆分为小的组件,这也是计算机领域常见的一种思维:分而治之

总结

开发技巧是思维方式的一种表现,开发者应当多思考,而不是将精力过度集中于寻找奇技淫巧,这是一中本末倒置的做法。

最后,总结以上五点技巧所汇集的一些思考方式与设计原理:

  • 借助框架/工具/组件的特性来解决问题,而不是手动解决;
  • 代理模式:拦截操作,捕捉时机;
  • 黑箱方法与接口原理:组件化的基本思想;
  • 依赖收集与依赖管理;
  • 事件:事物的受激反应;
  • 抽象思维:解决重复问题;
  • 命名空间:合理放置;
  • 分治原理:解决庞大问题。