6000字长文,讲透vue的数据流应该如何设计

517 阅读7分钟

阅读提示: 本文适合1-2年工作经验,熟悉vue框架的开发者阅读。如果你没用vue写过实战项目,阅读本文的内容会有些吃力,建议先补足实战经验再来看。 文章里关于vue的一些api,我会讲的比较底层,这样你才能理解vue是怎么处理数据流的。

数据流是什么?

简单来讲,就是组件之间数据的流向。为什么搞清楚这一点很重要?

如果你搞不清楚数据的流向,开发中就很难找到问题所在,我就在调bug的时候,因为找不到是哪个地方修改了数据,耗费了我大量的时间和精力,猛扣头皮!!

要不然就是动了一个地方的数据以后,其他的数据就突然出现异常,但我却不知道为什么。

所以说,了解和设计出一个好的数据流,有利于后期的开发维护。

数据流类型

由于笔者用vue比较多,所以主要讲一下单向数据流和全局数据流,这是vue开发主流的两种数据流类型。

单向数据流

实际上 vue这个框架,是典型的单向数据流单向数据流,指的是数据只能从父组件向子组件传递,子组件无法改变父组件的props。如果想要修改只能通过emit事件触发的方式,在父组件中修改。

一言以蔽之,所有数据的更改都是从父组件传递到子组件,都是自上而下的。

单向数据绑定

单向数据绑定实现1:props

在父传子的前提下,父组件的数据发生改变,会通过props来通知子组件自动更新。 子组件内部,不能直接修改父组件传递过来的props——props是只读的。

这样做的好处是,防止多个子组件都尝试修改父组件状态时,让这一行为变得难以追溯。

如果子组件改了props的值会怎么样呢?这里分情况讨论:

  1. 如果子组件修改的props数据类型,是引用数据类型,那么绑定的其实是引用地址,子组件修这个引用类型的值,vue不会报警告,但是建议别这样做。
  2. 如果绑定的props是基础类型值,子组件直接修改父组件传递的props,vue会抛出警告。

所以,尽量使用单向数据流,这样做的优点是:

  1. 单向数据流会使所有状态的改变可记录、可跟踪,源头易追溯;
  2. 所有数据只有一份,组件数据只有唯一的入口和出口,使得程序更直观更容易理解,有利于应用的可维护性。

单向数据绑定实现2:watch+props

这个方法,适用于父组件的值和子组件的值不相同的情况。

在子组件中,首先需要在data中定义一个和props对应的变量,然后用watch监听props的变化,props变化时,通过数据格式转换的方法修改data的数据。 这样讲有点抽象,还是看个案例吧。

实战案例 有这么一个场景,封装一个业务数据的树形选择器,支持多选。 我们的props是字符串,值是id,如果多选的话就是ids。

这里就不能直接用v-model把value赋值给树组件,因为value的值不能直接赋给antd组件,它value不是字符串,它是个对象数组,格式是[{key,value,label},{key,value,label}...]

所以这里我们换一种处理方法:

首先我们在data里,定义一个变量treeValue,这个treeValue直接赋值给树组件的value值。

然后在watch里面监听value的变化,然后把values转换成对象数组,再给treeValue赋值。

<template>
  <a-tree-select
    :value="treeValue"
    :treeData="treeData"
    :multiple="multiple"
    @change="onChange"
  >
  </a-tree-select>
</template>
<script>
  import { getTreeNodeByKey } from '@/utils/tree'

  export default {
    name: 'ModelTypeTreeSelect',
    props: {
      value: {
        type: String,
        required: false
      },
      // 是否支持多选
      multiple: {
        type: Boolean,
        default: false
      },
    },
    watch: {
      value(newValue) {
        if (!newValue || newValue === '0') {
          this.treeValue = null
        }
        let values = this.value.split(',')
        this.treeValue = values.map(id => {
          let node = getTreeNodeByKey(this.treeData, id)
          return {
            key: node.id,
            value: node.id,
            label: node.typeName
          }
        })
      }
    },
    data() {
      return {
        treeValue: null,
        treeData: [],
      }
    },
    created() {
      this.loadRoot()
    },
    methods: {
      loadRoot() {
        queryModelTypeAll().then((res) => {
          if (!res.success) {
            this.$notify['error'].call(this, {
              key: 'ErrorNotificationKey',
              message: '操作信息提示',
              description: res.message
            })
            return
          }
          this.treeData = res.treeData
        })
      },
      onChange(value) {
        if (!value) {
          //这里是做双向绑定:
          //emit会直接改父组件value的值,value再通过watch监听,改变treeValue的值
          this.$emit('change', '')
        } else if (value instanceof Array) {
          this.$emit('change', value.map(item => item.value).join(','))
        } else {
          this.$emit('change', value.value)
        }
      },
    },
    //2.2新增 在组件内定义 指定父组件调用时候的传值属性和事件类型
    model: {
      prop: 'value',
      event: 'change'
    }
  }

单向数据绑定实现3:watch+方法

用watch监听,然后调子组件或$bus方法修改目标组件的数据,这样也能实现单向数据绑定,适用于祖孙或兄弟组件通信的场景。

比如下面这种

export default {
    watch: {
      nodeList(newValue) {
      //用户添加数据表node节点
        this.$bus.emit('dragable-sheetlist-node', newValue)
      },
      }
    },
}
单向数据绑定实现4:computed+props

计算属性适用于数据有异步请求的场景,比如下面这种,treeValue需要根据props的value(id)去查询对象数组(treeData)才能拿到。 但我的treeData需要请求接口才能拿到。如果这里用watch监听value,如果value的watch在loadData之前触发,那么我这里就没法拿到treeValue了。

所以这种场景需要放在computed里面计算,因为TreeData计算以后计算还会重新计算一遍。

<template>
  <a-tree-select
    allowClear
    labelInValue
    :replaceFields="replaceFields"
    :getPopupContainer="(node) => node.parentNode"
    style="width: 100%"
    :disabled="disabled"
    :dropdownStyle="{ maxHeight: '400px', overflow: 'auto' }"
    :placeholder="placeholder"
    :value="treeValue"
    :treeData="treeData"
    :multiple="multiple"
    v-bind="$attrs"
    @change="onChange"
  >
  </a-tree-select>
</template>
<script>
  import { queryModelTypeAll } from '@views/auditmodel/model/act/ModelTypeAct'
  import { getTreeNodeByKey } from '@/utils/tree'

  export default {
    name: 'ModelTypeTreeSelect',
    props: {
      value: {
        type: String,
        required: false
      },
      placeholder: {
        type: String,
        default: '请选择',
        required: false
      },
      disabled: {
        type: Boolean,
        default: false,
        required: false
      },
      // 是否支持多选
      multiple: {
        type: Boolean,
        default: false
      },
      loadTriggleChange: {
        type: Boolean,
        default: false,
        required: false
      }
    },
    computed: {
      treeValue() {
        if (!this.value || this.value === '0' || this.treeData.length === 0) {
          return null
        }
        let values = this.value.split(',')
        return values.map(id => {
          let node = getTreeNodeByKey(this.treeData, id)
          return {
            key: node.id,
            value: node.id,
            label: node.typeName
          }
        })
      }
    },
    data() {
      return {
        replaceFields: { title: 'typeName', key: 'id', value: 'id' },
        treeData: []
      }
    },
    created() {
      this.loadRoot()
    },
    methods: {
      loadRoot() {
        queryModelTypeAll().then((res) => {
          if (!res.success) {
            this.$notify['error'].call(this, {
              key: 'ErrorNotificationKey',
              message: '操作信息提示',
              description: res.message
            })
            return
          }
          this.treeData = res.treeData
        })
      },
      onChange(value) {
        if (!value) {
          //这里是做双向绑定:
          //emit会直接改父组件value的值,value再通过watch监听,改变treeValue的值
          this.$emit('change', '')
        } else if (value instanceof Array) {
          this.$emit('change', value.map(item => item.value).join(','))
        } else {
          this.$emit('change', value.value)
        }
      },
    },
    //2.2新增 在组件内定义 指定父组件调用时候的传值属性和事件类型 这个牛逼
    model: {
      prop: 'value',
      event: 'change'
    }
  }
如何确定value的类型

这里主要说一下表单的情况吧,尽可能保证value和后台的接口参数一样,比如后台的参数是id,那么你的value也应该是id。如果后台的参数是ids,那么你也应该是ids,并且要处理ids转子组件数据的逻辑。 这样可以保证后台接口的值传过来的时候,你能马上展示。

单向数据绑定实现5:provide / inject

provide/inject,是vue2.2.0 新增特性,适用于祖孙组件通信。官网介绍如下:

这对选项需要一起使用,以允许一个祖先组件向其所有子孙后代注入一个依赖,不论组件层次有多深,并在起上下游关系成立的时间里始终生效。如果你熟悉 React,这与 React 的上下文特性很相似。

官网的解释很让人疑惑,那我翻译下这几句话: provide 可以在祖先组件中指定我们想要提供给后代组件的数据或方法,而在任何后代组件中,我们都可以使用 inject 来接收 provide 提供的数据或方法。 举个官网的🌰:

// 父级组件提供 'foo'
var Provider = {
  provide: {
    foo: 'bar'
  },
  // ...
}

// 子组件注入 'foo'
var Child = {
  inject: ['foo'],
  created () {
    console.log(this.foo) // => "bar"
  }
  // ...
}

可以看到,父组件提供的 foo 变量被子组件成功接收并使用。 了解了 provide/inject 是什么后,我们再来使用使用 provide/inject。

关于 provide/inject 的响应式原理

很多人也许会想到一种骚操作:在根组件中,传入变量,然后在后代组件直接更改根组件的状态。

首先,这严重违背单向数据流,子组件绝对不可以直接更改父组件的状态。再强调一遍,子组件绝对不可以直接更改父组件的状态!

其次,非响应式变量作为状态时,直接赋值也不能成功更改,不信看代码:

// 根组件提供一个非响应式变量给后代组件
export default {
  provide () {
    return {
      text: 'bar'
    }
  }
}

// 后代组件注入 'app'
<template>
	<div>{{this.text}}</div>
</template>
<script>
  export default {
    inject: ['text'],
    created() {
      this.text = 'baz' // 在模板中,依然显示 'bar'
    }
  }
</script>

这里之所以会失败,原因在于 provide 的特殊性。 在官网文档中关于 provide/inject 有这么一个提示:

提示:provide 和 inject 绑定并不是可响应的。这是刻意为之的。然而,如果你传入了一个可监听的对象,那么其对象的属性还是可响应的。

也就是说,Vue 不会对 provide 中的变量进行响应式处理。所以,要想 inject 接受的变量是响应式的,provide 提供的变量本身就需要是响应式的。

由于组件内部的各种状态就是可响应的,所以我们直接在根组件中将组件本身注入 provide,就可以实现数据的响应式。 第一,注意,这里用的是引用类型,就要用箭头函数的形式: () => Object(工厂函数),否则会报错 第二,如果provide拿的是data的数据,是不具备响应式的,必须要传this才可以 代码如下:

// 根组件提供将自身提供给后代组件
export default {
  provide () {
    return {
      app: this
    }
  },
  data () {
    return {
      text: 'bar'
    }
  }
}

// 后代组件注入 'app'
<template>
	<div>{{this.app.text}}</div>
</template>
<script>
  export default {
    inject: ['app'],
    created() {
      // 可以成功修改provide中app的值
      this.app.text = 'baz' // 在模板中,显示 'baz'
      // 但尽量不要这样做,因为这样会破坏单向数据流。
      // 这样会让父组件很难找到:是哪个子组件修改了自己的值,带来后期维护的困难。
    }
  }
</script>

这里我只是讲原理,从开发的角度讲,永远不要这么做!

下面我会具体讲讲:不要用 provide/inject 修改数据的原因。

不要用 provide/inject 修改数据

既然 provide/inject 如此好用,那么,为什么 Vue 官方还要推荐我们使用 Vuex,而不是用原生的 API 呢?

我在前面提到过,Vuex 和 provide/inject最大的区别在于,Vuex 中的全局状态的每次修改是可以追踪回溯的,而 provide/inject 中变量的修改是无法控制的,换句话说,你不知道是哪个组件修改了这个全局状态。

Vue 的设计理念借鉴了 React 中的单向数据流原则(虽然有 sync 这种破坏单向数据流的家伙),而 provide/inject 明显破坏了单向数据流原则。

试想,如果有多个后代组件同时依赖于一个祖先组件提供的状态,那么只要有一个组件修改了该状态,那么所有组件都会受到影响。

这一方面增加了耦合度,另一方面,使得数据变化不可控。如果在多人协作开发中,这将成为一个噩梦。

所以,在此我总结了使用provide/inject的原则。

使用 provide/inject 做全局状态管理的原则:
  1. 多人协作时,做好作用域隔离,避免后期变量冲突等问题。
  2. provide/inject适用于单向数据绑定,即父组件传递数据给子组件。子组件如果需要改变父组件的值,应该用自定义事件通知父组件改值,如emitemit、bus事件,或者直接使用vuex。 这样才能保证数据更改有明显的来源,再强调一遍,这对后期维护很重要。
provide/inject的优势:
  1. 适合于祖孙组件通信,如果用props做就需要穿好几层,这个时候使用provide/inject更加方便和优雅。

  2. 用provied做全局状态管理,能够解决全局作用域的问题。 也许有的同学会问:使用 $root 依然能够取到根节点,那么我们何必使用 provide/inject 呢? 在实际开发中,一个项目常常有多人开发,每个人有可能需要不同的全局变量,如果所有人的全局变量都统一定义在根组件,势必会引起变量冲突等问题。 使用 provide/inject 不同模块的入口组件传给各自的后代组件可以完美的解决该问题。

双向数据绑定

父组件可以通过props改变子组件的数据,子组件也可以用emit事件通知父组件的改变数据。双方数据可以互相更新,这就是双向数据绑定。

实现方式:props加emit

以下代码示例,父组件代码:

<template>
  <div>
    <h1>我是父组件</h1>
    <Son :info="info" @change="fn"></Son>
  </div>
</template>

<script>
import Son from "./Son.vue";
export default {
  data() {
    return {
      info: "我是父组件中的数据",
    };
  },
  components: {
    Son,
  },
  methods: {
    fn(info) {
      this.info = info +  "我是父组件中点击修改后的数据";
    },
  },
};
</script>

子组件代码:

<template>
  <div>
    <h2>我是子组件</h2>
    <p>{{ info }}</p>
    <button @click="fn">修改数据(子)</button>
  </div>
</template>

<script>
export default {
  props: ["info"],
  methods: {
    fn() {
      //这种直接赋值prop是不可取的,vue会直接报错
      //this.info=this.info+"子组件直接赋值prop"
      // 修改数据
      this.$emit('change',this.info + ",我现在被子组件emit了")
    },
  },
};
</script>

分析数据流向分析

(1)我们在父组件中定义了数据:一个叫info的字符串; (2)调用组件的时候,通过:info="info"的方式,将父组件的数据以 prop 的方式传递到子组件中; (3)子组件读取到info,并将其展示在模板中; (4)用户点击按钮,info被修改; (5)子组件监听到这个事件,但它并不直接修改info,而是通过 this.$emit('change'); 以自定义事件的形式,将需要增加的这一个事件报告给父组件; (6)父组件中,由于通过执行过@click="fn" ,能够监听到子组件报告过来的事件,并在自己的 方法中,实现info数据的更新; (7)父组件里的数据更新了,通过props的机制,子组件里的数据也将自动更新,同时也将更新界面内容,这一过程由框架自动完成。 整体过程如下图所示:

双向数据绑定语法糖:v-model

在vue中,提供了v-model这个语法糖,相信大家都不陌生下面这段代码。:

<input type="text" v-model="name">

在这里,我们在输入框内输入值,父组件的值也会直接改变。

看上去好像视图的操作直接修改了父组件的值,所以很多人以为这是一种双向数据流,即子组件可以直接修改父组件的值。

但其实不是这样,因为v-model的本质相当于:

<input type="text" :value="name" @input="name = $event.target.value">

我们仔细观察这段代码,可以得出一个结论: 在给<input />元素添加 v-model 属性时,默认会把 value作为元素的属性,然后把input事件作为实时传递 value 的触发事件。

你发现没有,其实这个和上面那个propsemit的案例,是一个套模式,都是子组件通过自定义事件通信父组件改值。

这里vue自己处理了input原生的input 事件,这是 HTML5 新增加的,类似 onchange ,每当输入框内容发生变化,就会触发 input 事件,把最新的value值传给传递给val,完成双向数据绑定的效果 。

相当于vue把input这个组件重写为下面的代码:

<template>
    <input type="text" :value="value" @input="handleInput" :placeholder="placehodler" />
</template>
<script>
  export default {
    name: 'kInput',
    props: {
        value: ['String', 'Number'],
        placeholder: String
    },
    methods: {
        handleInput ($event) {
            // 通过input标签的原生事件input将值emit出去,以达到值得改变实现双向绑定
            this.$emit('input', $event.target.value)
        }
    }
  }
</script>
<style scoped type="less">
</style>

注意: 在HTML5中,不是所有能进行双向绑定的元素都是 value property 和input事件。 1. texttextarea 元素使用 value 作为 propinput 事件; 2. checkboxradio 使用 checked 作为 propchange 事件; 3. select 字段将 value 作为 prop 并将 change 作为事件。

它们都有一个共通点:依赖一个value作为prop,以及emit事件传递新值。

所以,如果我们要在自定义组件中使用 v-model 进行双向绑定,必须满足以下两个条件:

  1. 将其 value attribute 绑定到一个名叫 valueprop
  2. 在数据发生变化时 一个带新值的 input 事件,将新的值通过$emit把 input 事件抛出

如果我想改值的名字怎么办?我不想用value和input这些名字怎么办?

vue也提供了model这个api,让我们可以更改value和input的名字,我们把第一段案例的代码用自定义model重写一下:

<template>
  <div>
    <h1>我是父组件</h1>
    <!--不用v-model的写法-->
    <Son :info="info" @change="fn"></Son>
    <!--用v-model的写法,可以省略 @change="fn"-->
    <Son v-model="info"></Son>
  </div>
</template>

<script>
import Son from "./Son.vue";
export default {
  data() {
    return {
      info: "我是父组件中的数据",
    };
  },
  components: {Son},
  methods: {
    fn(info) {
      this.info = info +  "我是父组件中点击修改后的数据";
    },
  },
};
</script>

子组件代码:

<template>
  <div>
    <h2>我是子组件</h2>
    <p>{{ info }}</p>
    <button @click="fn">修改数据(子)</button>
  </div>
</template>

<script>
export default {
  model:{
    // 默认值是value,这里指定info这个prop为v-model
    prop: 'info',
    // 修改值的自定义事件,默认值为input,我们这里改为change
    event: 'change'
  }
  props: ["info"],
  methods: {
    fn() {
      // 修改数据
      this.$emit('change',this.info + ",我现在被子组件emit了")
    },
  },
};
</script>

可以看到,和第一个案例不同,使用v-model后,父组件不必处理子组件的@change事件,也能实现数据的更新。 之所以用v-model后能简写父组件的@change事件,是因为vue默认帮我们处理了事件的赋值。 相当于vue自动加上去了这句代码:@change="val=>info = val"

看到这里你是不是就明白了,v-model虽然是叫双向数据绑定,但它不是双向数据流(这是很多人的误区,以为子组件的操作不经过任何处理,就直接能修改父组件的值)。

其实v-model本质上还是单向数据流,属于第一种propsemit的处理流程的简写。

v-model只是帮我们减少了父组件控制数据流向的代码,让用户的操作可以通过单向的数据流向,更新到绑定值中,绑定值再通过props的渲染更新到页面上。

让我们用户用起来的感觉,是我们操作视图的变化而改变了数据。 但其实它的数据流依然和第一个案例一样,子组件无法直接改值,数据依然是从父传给子。

流程如下图所示:

v-model优点

优点:双向数据绑定的优点是无需进行和单向数据绑定的那些CRUD(Create,Retrieve,Update,Delete)操作。

双向数据绑定最经常的应用场景就是表单了,这样当用户在前端页面完成输入后,不用任何操作,我们就已经拿到了用户的数据存放到数据模型中了,在表单交互较多的场景下,会简化大量业务无关的代码。

v-model缺点

虽然v-model很方便,但它也是有缺点的:

  1. v-model有数量的限制,一个组件只能有一个v-model。
  2. 双向绑定会带来维护上的问题,因为子组件可以变更父组件,且在父组件和子组件两侧都没有明显的变更来源(这点在使用别人组件库时尤为明显),无法追踪局部状态的变化,潜在的行为增加了debug的难度。 由于组件数据变化来源入口变得可能不止一个,数据流转方向易紊乱。为了解决这个问题,我推荐你使用sync指令修饰符。
  3. 做组件库的时候,如果封装了v-model,就一定要在文档里讲清楚是哪个自定义事件修改的值。 否则父组件改值不可控,子组件说改就改,因为不知道覆盖哪一个自定义事件。

vant2弹出框案例

其实做了v-model,也支持父组件覆盖默认的@change事件

<input type="text" v-model="name" @input="name ='我覆盖了:' +$event.target.value">

但是你不告诉别人是哪个自定义事件改的值,非要人家看你的源码才知道答案,这就很不友好。

双向数据绑定的修饰符:.sync

.sync修饰符可以实现和v-model同样的功能,而且它比v-model更加灵活

  1. 因为.sync修饰符是可选的,父组件想加就加,不加就没有双向绑定。
  2. 不像v-model那样有数量上的限制,想用几个就用几个。 .sync修饰符的本质
// 正常父传子: 
<son :info="str" :title="str2"></son>

// 加上sync之后父传子(.sync没有数量限制): 
<son :info.sync="str" .title.sync="str2"></son> 

// 它等价于
<son
  :info="str" @update:info="val=>str=val"
  :title="str2" @update:title="val=>str2=val"></son> 

// 相当于多了一个事件监听,事件名是update:info,回调函数中,会把接收到的值赋值给属性绑定的数据项中。

这里面的传值与接收与正常的父向子传值没有区别,唯一的区别在于往回传值的时候$emit所调用的事件名必须是update:属性名,如下所示:

<template>
  <div>
    <p>{{ info }}</p>
    <button @click="fn">修改数据(子)</button>
  </div>
</template>
<script>
export default {
  props: ["info","title"],
  name:'son',
  methods: {
    fn() {
      // 修改数据:`$emit`所调用的事件名必须是`update:属性名`
      this.$emit('update:info',this.info + ",我现在被子组件emit了")
    },
  },
};
</script>

事件名写错不会报错,但是也不会有任何的改变,这点需要多注意。

注意事项: 1.注意带有 .sync 修饰符的 v-bind 不能和表达式一起使用,例如:

v-bind:title.sync=”doc.title + ‘!’”

这是无效的。取而代之的是,你只能提供你想要绑定的 property 名,类似 v-model。 2.不要将 v-bind.sync 用在一个字面量的对象上,例如:

v-bind.sync=”{ title: doc.title }”

这是无法正常工作的,官网给出的理由是:因为在解析一个像这样的复杂表达式的时候,有很多边缘情况需要考虑。

总之,如果你封装组件,除非这个值是input或者checkbox这种,不需要校验就能直接改值的组件,可以直接封装成v-model,否则我都建议你用sync来处理双向绑定,这样更加灵活。

下期我们会讲讲在多个组件中如何维护同一个数据。

参考文章: juejin.cn/post/684490… juejin.cn/post/710646…