Vue 响应式原理是单向行为,为什么能双向绑定?

1,431 阅读5分钟

我正在参加「掘金·启航计划」

请不要把 Vue 的响应式原理理解为双向绑定哦?很多同学在学习 Vue 时,认为 Vue 的响应式原理是双向绑定。这并不准确,Vue 的响应式是一种单向行为。

这种单向行为只是数据到 DOM 的映射。

而双向绑定不仅有数据到 DOM 的映射,还有 DOM 到数据的映射。

虽然 Vue 的响应式原理是单向行为,但 Vue 为了便于开发者开发,在响应式原理的基础之上实现了一个双向绑定的语法糖。

是的,他就是 v-model,在 Vue 项目的开发中,它再常见不过了。

它可以在一些特定的表单标签如:input、select、textarea自定义组件中使用(v-model 也不是可以作用到任意标签)。那么 v-model 的实现原理到底是怎样的呢?接下来,我们从普通表单元素自定义组件两个方面来分别分析它的实现。

普通表单元素中的 v-model

Vue.version='2.6.11';

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

  new Vue({
    el: '#app',
    data: {
      message: ''
    },
    template: `
      <div>
        <input 
          v-model="message" 
          placeholder="请输入"
          ref="test-ref"
          id="testId"
          key="test-key"
          class="text-clasee" 
          style="color: red" 
          data-a="test-a"
          data-b="test-b"
        />
      </div>
    `
  });

示例很简单,一个输入框,设置了 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 长什么样。

我们在示例 input 元素中设置了很多属性,不同的属性会被放入到不同的集合或者是属性值中。

比如:key、ref、class、style 都会被单独赋值。

而 v-model 被记录在了directives 数列中。

在整个 AST 描述对象中,针对属性,还有一个几个大家可以关注一下。

比如:attrsMap 集合,记录了所有属性的 key - value 的映射。

rowAttrsMap 集合,记录了所有属性的相信信息。

attrs 数列,记录了非指令、非单独被赋值的属性(如:key、ref、class、style)的集合。

attrsList 数列,记录了非单独被赋值的属性的集合。

到这里我们知道了在编译解析阶段, v-model 被记录在了directives 数列中。

generate 阶段的 v-model

进过了parse阶段的洗礼,我们在来看看在generate阶段 v-model 又会被怎么处理了?

我们先看看最后的生成产物render code string长什么样子。

with (this) { 
  return _c('div', [
    _c('input', { 
      directives: [{ 
        name: "model", 
        rawName: "v-model", 
        value: (message), 
        expression: "message" 
      }], 
      key: "test-key", 
      ref: "test-ref", 
      staticClass: "text-clasee", 
      staticStyle: { "color": "red" }, 
      attrs: { "placeholder": "请输入", "id": "testId", "data-a": "test-a", "data-b": "test-b" }, 
      domProps: { "value": (message) }, 
      on: { "input": function ($event) { 
        if ($event.target.composing) return; 
        message = $event.target.value 
      }   
    } 
  })]) 
}

generate阶段会传入已经优化处理好的 AST。然后在函数中根据不同的节点属性执行不同的生成函数。

对于 v-model 这类指令属性来说,就会走到 genData函数来进行code string的生成。

在 Vue 中一个 VNode代表一个虚拟节点,而该节点的虚拟属性信息VNodeData 描述。而 VNodeData 的生成就是用genData函数来实现的。

genData 函数就是根据 AST 元素节点的属性构造出一个 data 对象字符串,这个在后面创建 VNode 的时候的时候会作为参数传入。

Vue 中对处理节点属性其实有三个 genData 函数。分别是genData genData$1genData$2 。三个函数分别处理不同类型的节点属性。

在 Vue 中可以使用绑定 Class 与 绑定 Style 来生成动态 class 列表和内联样式。在源码中:

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

对于属性的生成函数genData$2,首先就会调用genDrirectives() 方法对元素的指令集合进行处理,也就是对parse阶段生成directives数列进行处理。

而现在directives数列是这个样子。

循环directives数列,进行指令的处理。指令的处理有两行比较重要的代码(代码1、代码2)。

function genDirectives(el, state) {
	...
  for (i = 0, l = dirs.length; i < l; i++) {
  	...
    // 代码 1
    var gen = state.directives[dir.name];
    if (gen) {
      // 代码 2
      needRuntime = !!gen(el, dir, state.warn);
    }
    ...
  }
  ...
}

代码1 var gen = state.directives[dir.name];获取指令对应的方法,不同的指令有不同的directive对应函数,这些对应的方法是提前定义好的。

那么对于 v-model 而言,对应的 directive函数就是 model 函数。

代码2 needRuntime = !!gen(el, dir, state.warn);执行指令对应的函数,也即是 model函数。它会根据 AST 元素节点的不同情况去执行不同的逻辑,对于我们示例中的代码而言,它会命中 genDefaultModel(el, value, modifiers)的逻辑,

genDefaultModel函数就是表单元素实现 v-model 双向绑定的重点了?

function genDefaultModel(
    el,
    value,
    modifiers
  ) {
    var type = el.attrsMap.type;

    // warn if v-bind:value conflicts with v-model
    // except for inputs with v-bind:type
    {
      var value$1 = el.attrsMap['v-bind:value'] || el.attrsMap[':value'];
      var typeBinding = el.attrsMap['v-bind:type'] || el.attrsMap[':type'];
      if (value$1 && !typeBinding) {
        var binding = el.attrsMap['v-bind:value'] ? 'v-bind:value' : ':value';
        warn$1(
          binding + "="" + value$1 + "" conflicts with v-model on the same element " +
          'because the latter already expands to a value binding internally',
          el.rawAttrsMap[binding]
        );
      }
    }

    var ref = modifiers || {};
    var lazy = ref.lazy;
    var number = ref.number;
    var trim = ref.trim;
    var needCompositionGuard = !lazy && type !== 'range';
    var event = lazy
      ? 'change'
      : type === 'range'
        ? RANGE_TOKEN
        : 'input';

    var valueExpression = '$event.target.value';
    if (trim) {
      valueExpression = "$event.target.value.trim()";
    }
    if (number) {
      valueExpression = "_n(" + valueExpression + ")";
    }

    var code = genAssignmentCode(value, valueExpression);
    if (needCompositionGuard) {
      code = "if($event.target.composing)return;" + code;
    }

    addProp(el, 'value', ("(" + value + ")"));
    addHandler(el, event, code, null, true);
    if (trim || number) {
      addHandler(el, 'blur', '$forceUpdate()');
    }
  }
  1. genDefaultModel 函数先处理了 modifiers,我们的示例中没有修饰符,所以我们跳过修饰符处理。
  2. 接着 event 为 inputvalueExpression赋值为$event.target.value(事件值)。
  3. 它然后去执行 genAssignmentCode 去生成代码。message=$event.target.value

  1. code 生成完后,又执行了 2 句非常关键的代码,重点来了,重点来了,重点来了(重要的事情说三遍)。
addProp(el, 'value', ("(" + value + ")"));
addHandler(el, event, code, null, true);

addProp修改 AST 元素,给 el 添加一个 prop,相当于在 input 上动态绑定了 value

addHandler修改 AST 元素,给 el 添加了事件处理,相当于在 input 上绑定了 input 事件

这相当于将 v-model 进行了转换。

<input
  :value="message"
  @input="message=$event.target.value" 
/>

动态绑定inputvalue,并将value指向message,然后当触发输入事件的时候,将输入的目标值设置到message上。实现数据的双向绑定。

我们在回头来看看生成的render code。发现我们即使没有设置指令事件,但是还是生成了 input事件。

with (this) { 
  return _c('div', [
    _c('input', { 
      directives: [{ 
        name: "model", 
        rawName: "v-model", 
        value: (message), 
        expression: "message" 
      }], 
      key: "test-key", 
      ref: "test-ref", 
      staticClass: "text-clasee", 
      staticStyle: { "color": "red" }, 
      attrs: { "placeholder": "请输入", "id": "testId", "data-a": "test-a", "data-b": "test-b" }, 
      domProps: { "value": (message) }, 
      on: { "input": function ($event) { 
        if ($event.target.composing) return; 
        message = $event.target.value 
      }   
    } 
  })]) 
}

小结

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

组件元素中的 v-model

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

let inputComponent = {
  template: `
    <div>
      <input 
        :value="value"
        placeholder="请输入"
        ref="test-ref"
        id="testId"
        key="test-key"
        class="text-clasee" 
        style="color: red" 
        data-a="test-a"
        data-b="test-b"
      />
      {{ value }}
    </div>
  `,
  props: ['value'],
}
new Vue({
  el: '#app',
  data: {
    message: ''
  },
  components: {
    inputComponent
  },
  template: `
    <inputComponent v-model="message"></inputComponent>
  `
});

同样,我们使用Runtime + Compiler 版本。

parse 阶段的 v-model

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

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

generate 阶段的 v-model

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

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

一个组件本身其实也就是很多元素的集合,只是这些元素套在了一个名为某某某的组件内部。所以用genComponentgenDatagenChildren封装也就想得通了。

genData负责处理组件的属性,也就包括 v-model。

genChildren负责处理组件内部的元素,用于生成子级虚拟节点信息字符串。

genData处理函数属性,也就走到了表单 v-model 处理的逻辑。先就会调用genDrirectives() 方法对元素的指令集合进行处理,也就是对parse阶段生成directives数列进行处理。

然后执行 model 函数。让后触发genComponentModel()函数。

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

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

  function genComponentModel(
    el,
    value,
    modifiers
  ) {
    ...
    el.model = {
      value: ("(" + value + ")"),
      expression: JSON.stringify(value),
      callback: ("function (" + baseValueExpression + ") {" + assignment + "}")
    };
  }

针对我们的示例,生成了如下的描述对象。

回到genDrirectives() 方法,会对 model 描述对象处理,将描述对象生成字符串。挂载到data上。

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

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

with(this) {
  return _c('inputComponent',{
    model: {
      value:(message),
      callback: function ($$v) {	message=$$v	},
      expression:"message"
    }
  }
)}

还一个目的是在创建子组件的阶段,将组件的 v-model转换到propsevent

整个组件的创建是一个递归过程,当处理完父组件的创建(父组件包含了组件标签),也就进入了子组件的创建,这里我们看看如果将组件的 v-model转换到propsevent

关键就是一个函数,transformModel()函数。

function createComponent(
  Ctor,
  data,
  context,
  children,
  tag
) {
  ...
  // transform component v-model data into props & events
  if (isDef(data.model)) {
    transformModel(Ctor.options, data);
  }
  ...
}

function transformModel(options, data) {
  var prop = (options.model && options.model.prop) || 'value';
  var event = (options.model && options.model.event) || 'input'
    ; (data.attrs || (data.attrs = {}))[prop] = data.model.value;
  var on = data.on || (data.on = {});
  var existing = on[event];
  var callback = data.model.callback;
  if (isDef(existing)) {
    if (
      Array.isArray(existing)
      ? existing.indexOf(callback) === -1
      : existing !== callback
    ) {
      on[event] = [callback].concat(existing);
    }
  } else {
    on[event] = callback;
  }
}

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

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

这样一来,就将父组件的属性绑定到子组件的 value上,同时监听自定义input事件。当子组件进行派发input事件时,回调修改父组件的值。

并且transformModel()函数还可以将子组件的 value prop 以及派发的 input 事件名进行配置处理(默认是valueinput)。

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

比如:

let inputComponent = {
  template: `
    <div>
      <input 
        :value="newMessage"
        ...
        @input="updateValue"
      />
      {{ value }}
    </div>
  `,
  props: ['newMessage'],
  model: {
    prop: 'newMessage',
    event: 'change'
  },
  methods: {
    updateValue(e) {
      this.$emit('change', e.target.value)
    }
  }
}
new Vue({
  el: '#app',
  data: {
    message: ''
  },
  components: {
    inputComponent
  },
  template: `
    <inputComponent v-model="message"></inputComponent>
  `
});

子组件修改了接收的 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 的响应式原理认为是双向绑定。