请不要把 Vue 的响应式原理理解为双向绑定哦?很多同学在学习 Vue 时,认为 Vue 的响应式原理是双向绑定。这并不准确,Vue 的响应式是一种单向行为。
这种单向行为只是数据到 DOM 的映射。
Vue 响应式原理是单向行为,为什么能双向绑定? - 图1
而双向绑定不仅有数据到 DOM 的映射,还有 DOM 到数据的映射。
Vue 响应式原理是单向行为,为什么能双向绑定? - 图2
虽然 Vue 的响应式原理是单向行为,但 Vue 为了便于开发者开发,在响应式原理的基础之上实现了一个双向绑定的语法糖。
是的,他就是 v-model,在 Vue 项目的开发中,它再常见不过了。
它可以在一些特定的表单标签如:input、select、textarea自定义组件中使用(v-model 也不是可以作用到任意标签)。那么 v-model 的实现原理到底是怎样的呢?接下来,我们从普通表单元素自定义组件两个方面来分别分析它的实现。

普通表单元素中的 v-model

Vue.version=’2.6.11’;

为了更加直观方便的解析,这里引入一个简单的示例:

  1. new Vue({
  2. el: '#app',
  3. data: {
  4. message: ''
  5. },
  6. template: `
  7. <div>
  8. <input
  9. v-model="message"
  10. placeholder="请输入"
  11. ref="test-ref"
  12. id="testId"
  13. key="test-key"
  14. class="text-clasee"
  15. style="color: red"
  16. data-a="test-a"
  17. data-b="test-b"
  18. />
  19. </div>
  20. `
  21. });

示例很简单,一个输入框,设置了 v-model 和一些其他的属性(辅助说明的作用)。为了演示在编译阶段表单元素中 v-model 的变化,所以示例用了Runtime + Compiler 版本。

Vue.js 提供了 2 个版本,一个是 Runtime + Compiler 版本,一个是 Runtime only 版本。Runtime + Compiler 版本是包含编译代码的,可以把编译过程放在运行时做,Runtime only 版本不包含编译代码的,需要借助 webpack 的 vue-loader 事先把模板编译成 render 函数。

parse 阶段的 v-model

首先我们从编译的 parse阶段来看看 v-model 在 AST 是怎么描述的。先看看 parse template的 AST 长什么样。
image.png
我们在示例 input 元素中设置了很多属性,不同的属性会被放入到不同的集合或者是属性值中。
比如:key、ref、class、style都会被单独赋值。
image.png
而 v-model 被记录在了**directives 数列**中。
image.png
在整个 AST 描述对象中,针对属性,还有一个几个大家可以关注一下。
比如:attrsMap 集合,记录了所有属性的 key - value 的映射。
image.png
rowAttrsMap 集合,记录了所有属性的相信信息。
image.png
attrs 数列,记录了非指令、非单独被赋值的属性(如:key、ref、class、style)的集合。
image.png
attrsList 数列,记录了非单独被赋值的属性的集合。
image.png
到这里我们知道了在编译解析阶段, v-model 被记录在了**directives 数列**中。

generate 阶段的 v-model

进过了parse阶段的洗礼,我们在来看看在generate阶段 v-model 又会被怎么处理了?
我们先看看最后的生成产物render code string长什么样子。

  1. with (this) {
  2. return _c('div', [
  3. _c('input', {
  4. directives: [{
  5. name: "model",
  6. rawName: "v-model",
  7. value: (message),
  8. expression: "message"
  9. }],
  10. key: "test-key",
  11. ref: "test-ref",
  12. staticClass: "text-clasee",
  13. staticStyle: { "color": "red" },
  14. attrs: { "placeholder": "请输入", "id": "testId", "data-a": "test-a", "data-b": "test-b" },
  15. domProps: { "value": (message) },
  16. on: { "input": function ($event) {
  17. if ($event.target.composing) return;
  18. message = $event.target.value
  19. }
  20. }
  21. })])
  22. }

generate阶段会传入已经优化处理好的 AST。然后在函数中根据不同的节点属性执行不同的生成函数。
Vue 响应式原理是单向行为,为什么能双向绑定? - 图10
对于 v-model 这类指令属性来说,就会走到 genData函数来进行code string的生成。
在 Vue 中一个 VNode代表一个虚拟节点,而该节点的虚拟属性信息VNodeData 描述。而 VNodeData 的生成就是用genData函数来实现的。
genData 函数就是根据 AST 元素节点的属性构造出一个 data 对象字符串,这个在后面创建 VNode 的时候的时候会作为参数传入。
Vue 中对处理节点属性其实有三个 genData 函数。分别是genDatagenData$1genData$2。三个函数分别处理不同类型的节点属性。
在 Vue 中可以使用绑定 Class 与 绑定 Style 来生成动态 class 列表和内联样式。在源码中:

  • genData 的作用就是处理静态的 class 和绑定的 class。
  • genData$1 用来处理静态的 style 和绑定的 style。
  • genData$2 用来处理其他属性。

对于属性的生成函数genData$2,首先就会调用genDrirectives() 方法对元素的指令集合进行处理,也就是对parse阶段生成directives数列进行处理。
image.png
而现在directives数列是这个样子。
image.png
循环directives数列,进行指令的处理。指令的处理有两行比较重要的代码(代码1、代码2)。

  1. function genDirectives(el, state) {
  2. ...
  3. for (i = 0, l = dirs.length; i < l; i++) {
  4. ...
  5. // 代码 1
  6. var gen = state.directives[dir.name];
  7. if (gen) {
  8. // 代码 2
  9. needRuntime = !!gen(el, dir, state.warn);
  10. }
  11. ...
  12. }
  13. ...
  14. }

代码1 var gen = state.directives[dir.name];获取指令对应的方法,不同的指令有不同的directive对应函数,这些对应的方法是提前定义好的。
image.png
那么对于 v-model 而言,对应的 directive函数就是 model 函数。
代码2 needRuntime = !!gen(el, dir, state.warn);执行指令对应的函数,也即是 model函数。它会根据 AST 元素节点的不同情况去执行不同的逻辑,对于我们示例中的代码而言,它会命中 genDefaultModel(el, value, modifiers)的逻辑,
image.png
**genDefaultModel**函数就是表单元素实现 v-model 双向绑定的重点了?

  1. function genDefaultModel(
  2. el,
  3. value,
  4. modifiers
  5. ) {
  6. var type = el.attrsMap.type;
  7. // warn if v-bind:value conflicts with v-model
  8. // except for inputs with v-bind:type
  9. {
  10. var value$1 = el.attrsMap['v-bind:value'] || el.attrsMap[':value'];
  11. var typeBinding = el.attrsMap['v-bind:type'] || el.attrsMap[':type'];
  12. if (value$1 && !typeBinding) {
  13. var binding = el.attrsMap['v-bind:value'] ? 'v-bind:value' : ':value';
  14. warn$1(
  15. binding + "=\"" + value$1 + "\" conflicts with v-model on the same element " +
  16. 'because the latter already expands to a value binding internally',
  17. el.rawAttrsMap[binding]
  18. );
  19. }
  20. }
  21. var ref = modifiers || {};
  22. var lazy = ref.lazy;
  23. var number = ref.number;
  24. var trim = ref.trim;
  25. var needCompositionGuard = !lazy && type !== 'range';
  26. var event = lazy
  27. ? 'change'
  28. : type === 'range'
  29. ? RANGE_TOKEN
  30. : 'input';
  31. var valueExpression = '$event.target.value';
  32. if (trim) {
  33. valueExpression = "$event.target.value.trim()";
  34. }
  35. if (number) {
  36. valueExpression = "_n(" + valueExpression + ")";
  37. }
  38. var code = genAssignmentCode(value, valueExpression);
  39. if (needCompositionGuard) {
  40. code = "if($event.target.composing)return;" + code;
  41. }
  42. addProp(el, 'value', ("(" + value + ")"));
  43. addHandler(el, event, code, null, true);
  44. if (trim || number) {
  45. addHandler(el, 'blur', '$forceUpdate()');
  46. }
  47. }
  1. genDefaultModel 函数先处理了 modifiers,我们的示例中没有修饰符,所以我们跳过修饰符处理。
  2. 接着 event 为 inputvalueExpression赋值为$event.target.value(事件值)。
  3. 它然后去执行 genAssignmentCode 去生成代码。message=$event.target.value

image.png

  1. code 生成完后,又执行了 2 句非常关键的代码,重点来了,重点来了,重点来了(重要的事情说三遍)。
    1. addProp(el, 'value', ("(" + value + ")"));
    2. addHandler(el, event, code, null, true);
    **addProp**修改 AST 元素,给 el 添加一个 prop,相当于在 **input 上动态绑定了 value**
    **addHandler**修改 AST 元素,给 el 添加了事件处理,相当于在 **input 上绑定了 input 事件**
    这相当于将 v-model 进行了转换。
    1. <input
    2. :value="message"
    3. @input="message=$event.target.value"
    4. />
    动态绑定inputvalue,并将value指向message,然后当触发输入事件的时候,将输入的目标值设置到message上。实现数据的双向绑定。
    Vue 响应式原理是单向行为,为什么能双向绑定? - 图16
    我们在回头来看看生成的render code。发现我们即使没有设置指令事件,但是还是生成了 input事件。
    1. with (this) {
    2. return _c('div', [
    3. _c('input', {
    4. directives: [{
    5. name: "model",
    6. rawName: "v-model",
    7. value: (message),
    8. expression: "message"
    9. }],
    10. key: "test-key",
    11. ref: "test-ref",
    12. staticClass: "text-clasee",
    13. staticStyle: { "color": "red" },
    14. attrs: { "placeholder": "请输入", "id": "testId", "data-a": "test-a", "data-b": "test-b" },
    15. domProps: { "value": (message) },
    16. on: { "input": function ($event) {
    17. if ($event.target.composing) return;
    18. message = $event.target.value
    19. }
    20. }
    21. })])
    22. }

    小结

    表单元素上的 v-model 原理的精髓,在于通过修改 AST 元素,添加 prop,相当于我们在 input 上动态绑定了 value,又添加事件处理,相当于在 input 上绑定了 input 事件。动态绑定inputvalue,并将value指向message,然后当触发输入事件的时候,将输入的目标值设置到message上。实现数据的双向绑定。间接说明了 v-model 就是一个语法糖。

    组件元素中的 v-model

    为了更加直观方便的解析,这里我们也引入一个简单的示例:
    1. let inputComponent = {
    2. template: `
    3. <div>
    4. <input
    5. :value="value"
    6. placeholder="请输入"
    7. ref="test-ref"
    8. id="testId"
    9. key="test-key"
    10. class="text-clasee"
    11. style="color: red"
    12. data-a="test-a"
    13. data-b="test-b"
    14. />
    15. {{ value }}
    16. </div>
    17. `,
    18. props: ['value'],
    19. }
    20. new Vue({
    21. el: '#app',
    22. data: {
    23. message: ''
    24. },
    25. components: {
    26. inputComponent
    27. },
    28. template: `
    29. <inputComponent v-model="message"></inputComponent>
    30. `
    31. });
    同样,我们使用Runtime + Compiler 版本。

    parse 阶段的 v-model

    parse阶段 v-model 生成的 AST 描述是和表单 v-model 一样。v-model 被记录在了**directives 数列**中。
    image.png

    但是有一点差别的是,由于这里我们用到了组件模式。所以会生成两个 AST。而 v-model 是作用在 inputComponent 组件标签(相当于自定义标签)上的 AST 中。

generate 阶段的 v-model

然后进入generate阶段。在generate阶段会传入已经优化处理好的 AST。然后在函数中根据不同的节点属性执行不同的生成函数。对于组件就会就会走 genComponent 函数调用,对于 genComponent 函数内部其实就是对 genDatagenChildren的封装处理。

  1. function genComponent(
  2. componentName,
  3. el,
  4. state
  5. ) {
  6. var children = el.inlineTemplate ? null : genChildren(el, state, true);
  7. return ("_c(" + componentName + "," + (genData$2(el, state)) + (children ? ("," + children) : '') + ")")
  8. }

一个组件本身其实也就是很多元素的集合,只是这些元素套在了一个名为某某某的组件内部。所以用genComponentgenDatagenChildren封装也就想得通了。
genData负责处理组件的属性,也就包括 v-model。
genChildren负责处理组件内部的元素,用于生成子级虚拟节点信息字符串。
genData处理函数属性,也就走到了表单 v-model 处理的逻辑。先就会调用genDrirectives() 方法对元素的指令集合进行处理,也就是对parse阶段生成directives数列进行处理。
然后执行 model 函数。让后触发genComponentModel()函数。
image.png

config.isReservedTag(tag) 用于判断是否是保留标签。

genComponentModel()函数是组件处理 v-model 的重点,但是这个函数的逻辑很简单。重点就是 model 描述对象的生成。

  1. function genComponentModel(
  2. el,
  3. value,
  4. modifiers
  5. ) {
  6. ...
  7. el.model = {
  8. value: ("(" + value + ")"),
  9. expression: JSON.stringify(value),
  10. callback: ("function (" + baseValueExpression + ") {" + assignment + "}")
  11. };
  12. }

针对我们的示例,生成了如下的描述对象。
image.png
回到genDrirectives() 方法,会对 model 描述对象处理,将描述对象生成字符串。挂载到data上。

  1. // component v-model
  2. if (el.model) {
  3. data += "model:{value:" + (el.model.value) + ",callback:" + (el.model.callback) + ",expression:" + (el.model.expression) + "},";
  4. }

image.png
这一步的处理不仅仅是为了为组件标签生成代码字符串render code

  1. with(this) {
  2. return _c('inputComponent',{
  3. model: {
  4. value:(message),
  5. callback: function ($$v) { message=$$v },
  6. expression:"message"
  7. }
  8. }
  9. )}

还一个目的是在创建子组件的阶段,将组件的 **v-model**转换到**props****event**
整个组件的创建是一个递归过程,当处理完父组件的创建(父组件包含了组件标签),也就进入了子组件的创建,这里我们看看如果将组件的 v-model转换到propsevent
关键就是一个函数,transformModel()函数。

  1. function createComponent(
  2. Ctor,
  3. data,
  4. context,
  5. children,
  6. tag
  7. ) {
  8. ...
  9. // transform component v-model data into props & events
  10. if (isDef(data.model)) {
  11. transformModel(Ctor.options, data);
  12. }
  13. ...
  14. }
  15. function transformModel(options, data) {
  16. var prop = (options.model && options.model.prop) || 'value';
  17. var event = (options.model && options.model.event) || 'input'
  18. ; (data.attrs || (data.attrs = {}))[prop] = data.model.value;
  19. var on = data.on || (data.on = {});
  20. var existing = on[event];
  21. var callback = data.model.callback;
  22. if (isDef(existing)) {
  23. if (
  24. Array.isArray(existing)
  25. ? existing.indexOf(callback) === -1
  26. : existing !== callback
  27. ) {
  28. on[event] = [callback].concat(existing);
  29. }
  30. } else {
  31. on[event] = callback;
  32. }
  33. }

transformModel()函数目的就是将组件设置的 v-model 转换。

  • model.value -> 子组件 props.value
  • model.callback -> 子组件 on event

Vue 响应式原理是单向行为,为什么能双向绑定? - 图21
这样一来,就将父组件的属性绑定到子组件的 **value**上,同时监听自定义**input**事件。当子组件进行派发**input**事件时,回调修改父组件的值。
Vue 响应式原理是单向行为,为什么能双向绑定? - 图22
并且transformModel()函数还可以将子组件的 value prop 以及派发的 input 事件名进行配置处理(默认是valueinput)。

  1. var prop = (options.model && options.model.prop) || 'value';
  2. var event = (options.model && options.model.event) || 'input'

比如:

  1. let inputComponent = {
  2. template: `
  3. <div>
  4. <input
  5. :value="newMessage"
  6. ...
  7. @input="updateValue"
  8. />
  9. {{ value }}
  10. </div>
  11. `,
  12. props: ['newMessage'],
  13. model: {
  14. prop: 'newMessage',
  15. event: 'change'
  16. },
  17. methods: {
  18. updateValue(e) {
  19. this.$emit('change', e.target.value)
  20. }
  21. }
  22. }
  23. new Vue({
  24. el: '#app',
  25. data: {
  26. message: ''
  27. },
  28. components: {
  29. inputComponent
  30. },
  31. template: `
  32. <inputComponent v-model="message"></inputComponent>
  33. `
  34. });

子组件修改了接收的 prop 名以及派发的事件名,然而这一切父组件作为调用方是不用关心的,这样做的好处是我们可以把 value 这个 prop 作为其它的用途。

小结

父组件 v-model 默认被绑定在子组件的 value 上,并且同时监听自定义input事件。当子组件触发自定义input事件时,父组件中的v-model绑定值也随之更新,同时子组件的value也被改变,实现数据的双向绑定。这是 Vue 的父子组件通讯模式,父组件通过 prop 把数据传递到子组件,子组件修改数据后通过 $emit 事件的方式通知父组件,所以说组件上的 v-model 也是一种语法糖。

总结

OK,到这里 v-model 的原理已经全部分析完了,我们再来回忆一下:

  • 表单元素上的 v-model 原理的精髓,在于通过修改 AST 元素,添加 prop,添加事件处理,实现数据的双向绑定。
  • 组件元素上的 v-model 原理的精髓,在于父组件默认将v-model 被绑定在子组件的 value 上,并添加子组件的派发事件处理,实现数据的双向绑定。

但是不管是表单元素还是组件元素,Vue 的双向绑定本质上是 v-model语法糖。所以请不要将 Vue 的响应式原理认为是双向绑定。