建站教程

建站教程

Products

当前位置:首页 > 建站教程 >

揭密 Vue 的双向绑定(Vue数据双向绑定原理实例解析)

GG网络技术分享 2025-03-18 16:14 0


揭密 Vue 的双向绑定

Vue 中需要输入什么内容的时候,自然会想到使用 <input v-model=\"xxx\" /> 的方式来实现双向绑定。下面是一个最简单的示例

<div id=\"app\">

<h2>What\'s your name:</h2>

<input v-model=\"name\" />

<div>Hello {{ name }}</div>

</div>

new Vue({

el: \"#app\",

data: {

name: \"\"

}

});

在这个示例的输入框中输入的内容,会随后呈现出来。这是 Vue 原生对 <input> 的良好支持,也是一个父组件和子组件之间进行双向数据传递的典型示例。不过 v-model 是 Vue 2.2.0 才加入的一个新功能,在此之前,Vue 只支持单向数据流。

Vue 的单向数据流

Vue 的单向数据流和 React 相似,父组件可以通过设置子组件的属性(Props)来向子组件传递数据,而父组件想获得子组件的数据,得向子组件注册事件,在子组件高兴的时候触发这个事件把数据传递出来。一句话总结起来就是,Props 向下传递数据,事件向上传递数据。

上面那个例子,如果不使用 v-model,它应该是这样的

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

由于事件处理写成了内联模式,所以脚本部分不需要修改。但是多数情况下,事件一般都会定义成一个方法,代码就会复杂得多

<input :value=\"name\" @input=\"updateName\" />

new Vue({

// ....

methods: {

updateName(e) {

this.name = e.target.value;

}

}

})

从上面的示例来看 v-model 节约了不少代码,最重要的是可以少定义一个事件处理函数。所以 v-model 实际干的事件包括

  • 使用 v-bind(即 :)单向绑定一个属性(示例::value=\"name\")
  • 绑定 input 事件(即 @input)到一个默认实现的事件处理函数(示例:@input=updateName
  • 这个默认的事件处理函数会根据事件对象带入的值来修改被绑定的数据(示例:this.name = e.target.value)

自定义组件的 v-model

Vue 对原生组件进行了封装,所以 <input> 在输入的时候会触发 input 事件。但是自定义组件应该怎么呢?这里不妨借助 JsFiddle Vue 样板的 Todo List 示例。

JsFiddle 的 Vue 样板

样板代码包含 HTML 和 Vue(js) 两个部分,代码如下:

<div id=\"app\">

<h2>Todos:</h2>

<ol>

<li v-for=\"todo in todos\">

<label>

<input type=\"checkbox\"

v-on:change=\"toggle(todo)\"

v-bind:checked=\"todo.done\">

<del v-if=\"todo.done\">

{{ todo.text }}

</del>

<span v-else>

{{ todo.text }}

</span>

</label>

</li>

</ol>

</div>

new Vue({

el: \"#app\",

data: {

todos: [

{ text: \"Learn JavaScript\", done: false },

{ text: \"Learn Vue\", done: false },

{ text: \"Play around in JSFiddle\", done: true },

{ text: \"Build something awesome\", done: true }

]

},

methods: {

toggle: function(todo){

todo.done = !todo.done

}

}

})

定义 Todo 组件

JsFiddle 的 Vue 模板默认实现一个 Todo 列表的展示,数据是固定的,所有内容在一个模板中完成。我们首先要做事情是把单个 Todo 改成一个子组件。因为在 JsFiddle 中不能写成多文件的形式,所以组件使用 Vue.component() 在脚本中定义,主要是把 <li> 内容中的那部分拎出来:

Vue.component(\"todo\", {

template: `

<label>

<input type=\"checkbox\" @change=\"toggle\" :checked=\"isDone\">

<del v-if=\"isDone\">

{{ text }}

</del>

<span v-else>

{{ text }}

</span>

</label>

`,

props: [\"text\", \"done\"],

data() {

return {

isDone: this.done

};

},

methods: {

toggle() {

this.isDone = !this.isDone;

}

}

});

原来定义在 App 中的 toggle() 方法也稍作改动,定义在组件内了。toggle() 调用的时候会修改表示是否完成的 done 的值。但由于 done 是定义在 props 中的属性,不能直接赋值,所以采用了官方推荐的第一种方法,定义一个数据 isDone,初始化为 this.done,并在组件内使用 isDone 来控制是否完成这一状态。

相应的 App 部分的模板和代码精减了不少:

<div id=\"app\">

<h2>Todos:</h2>

<ol>

<li v-for=\"todo in todos\">

<todo :text=\"todo.text\" :done=\"todo.done\"></todo>

</li>

</ol>

</div>

new Vue({

el: \"#app\",

data: {

todos: [

{ text: \"Learn JavaScript\", done: false },

{ text: \"Learn Vue\", done: false },

{ text: \"Play around in JSFiddle\", done: true },

{ text: \"Build something awesome\", done: true }

]

}

});

不过到此为止,数据仍然是单向的。从效果上来看,点击复选框可以反馈出删除线线效果,但这些动态变化都是在 todo 组件内部完成的,不存在数据绑定的问题。

为 Todo List 添加计数

为了让 todo 组件内部的状态变化能在 Todo List 中呈现出来,我们在 Todo List 中添加计数,展示已经完成的 Todo 数量。因为这个数量受 todo 组件内部状态(数据)的影响,这就需要将 todo 内部数据变化反应到其父组件中,这才有 v-model 的用武之地。

这个数量我们在标题中以 n/m 的形式呈现,比如 2/4 表示一共 4 条 Todo,已经完成 2 条。这需要对 Todo List 的模板和代码部分进行修改,添加 countDone 和 count 两个计算属性:

<div id=\"app\">

<h2>Todos ({{ countDone }}/{{ count }}):</h2>

<!-- ... -->

</div>

new Vue({

// ...

computed: {

count() {

return this.todos.length;

},

countDone() {

return this.todos.filter(todo => todo.done).length;

}

}

});

现在计数呈现出来了,但是现在改变任务状态并不会对这个计数产生影响。我们要让子组件的变动对父组件的数据产生影响。v-model 待会儿再说,先用最常见的方法,事件:

  • 子组件 todo 在 toggle() 中触发 toggle 事件并将 isDone 作为事件参数
  • 父组件为子组件的 toggle 事件定义事件处理函数

Vue.component(\"todo\", {

//...

methods: {

toggle(e) {

this.isDone = !this.isDone;

this.$emit(\"toggle\", this.isDone);

}

}

});

<!-- #app 中其它代码略 -->

<todo :text=\"todo.text\" :done=\"todo.done\" @toggle=\"todo.done = $event\"></todo>

这里为 @toggle 绑定的是一个表达式。因为这里的 todo 是一个临时变量,如果在 methods 中定义专门的事件处理函数很难将这个临时变量绑定过去(当然定义普通方法通过调用的形式是可以实现的)。

现在通过事件方式已经达到了预期效果

改造成 v-model

之前我们说了要用 v-model 实现的,现在来改造一下。注意实现 v-model 的几个要素

  • 子组件通过 value 属性(Prop)接受输入
  • 子组件通过触发 input 事件输出,带数组参数
  • 父组件中用 v-model 绑定

Vue.component(\"todo\", {

// ...

props: [\"text\", \"value\"], // <-- 注意 done 改成了 value

data() {

return {

isDone: this.value // <-- 注意 this.done 改成了 this.value

};

},

methods: {

toggle(e) {

this.isDone = !this.isDone;

this.$emit(\"input\", this.isDone); // <-- 注意事件名称变了

}

}

});

<!-- #app 中其它代码略 -->

<todo :text=\"todo.text\" v-model=\"todo.done\"></todo>

.sync 实现其它数据绑定

前面讲到了 Vue 2.2.0 引入 v-model 特性。由于某些原因,它的输入属性是 value,但输出事件叫 input。v-model、value、input 这三个名称从字面上看不到半点关系。虽然这看起来有点奇葩,但这不是重点,重点是一个控件只能双向绑定一个属性吗?

Vue 2.3.0 引入了 .sync 修饰语用于修饰 v-bind(即 :),使之成为双向绑定。这同样是语法糖,添加了 .sync 修饰的数据绑定会像 v-model 一样自动注册事件处理函数来对被绑定的数据进行赋值。这种方式同样要求子组件触发特定的事件。不过这个事件的名称好歹和绑定属性名有点关系,是在绑定属性名前添加 update: 前缀。

比如 <sub :some.sync=\"any\" /> 将子组件的 some 属性与父组件的 any 数据绑定起来,子组件中需要通过 $emit(\"update:some\", value) 来触发变更。

上面的示例中,使用 v-model 绑定始终感觉有点别扭,因为 v-model 的字面意义是双向绑定一个数值,而表示是否未完成的 done 其实是一个状态,而不是一个数值。所以我们再次对其进行修改,仍然使用 done 这个属性名称(而不是 value),通过 .sync 来实现双向绑定。

Vue.component(\"todo\", {

// ...

props: [\"text\", \"done\"], // <-- 恢复成 done

data() {

return {

isDone: this.done // <-- 恢复成 done

};

},

methods: {

toggle(e) {

this.isDone = !this.isDone;

this.$emit(\"update:done\", this.isDone); // <-- 事件名称:update:done

}

}

});

<!-- #app 中其它代码略 -->

<!-- 注意 v-model 变成了 :done.sync,别忘了冒号哟 -->

<todo :text=\"todo.text\" :done.sync=\"todo.done\"></todo>

揭密 Vue 双向绑定

通过上面的讲述,我想大家应该已经明白了 Vue 的双向绑定其实就是普通单向绑定和事件组合来完成的,只不过通过 v-model 和 .sync 注册了默认的处理函数来更新数据。Vue 源码中有这么一段

// @file: src/compiler/parser/index.js

if (modifiers.sync) {

addHandler(

el,

`update:${camelize(name)}`,

genAssignmentCode(value, `$event`)

)

}

从这段代码可以看出来,.sync 双向绑定的时候,编译器会添加一个 update:${camelize(name)} 的事件处理函数来对数据进行赋值(genAssignmentCode 的字面意思是生成赋值的代码)。

展望

目前 Vue 的双向绑定还需要通过触发事件来实现数据回传。这和很多所的期望的赋值回传还是有一定的差距。造成这一差距的主要原因有两个

  1. 需要通过事件回传数据
  2. 属性(prop)不可赋值

在现在的 Vue 版本中,可以通过定义计算属性来实现简化,比如

computed: {

isDone: {

get() {

return this.done;

},

set(value) {

this.$emit(\"update:done\", value);

}

}

}

说实在的,要多定义一个意义相同名称不同的变量名也是挺费脑筋的。希望 Vue 在将来的版本中可以通过一定的技术手段减化这一过程,比如为属性(Prop)声明添加 sync 选项,只要声明 sync: true 的都可以直接赋值并自动触发 update:xxx 事件。

当然作为一个框架,在解决一个问题的时候,还要考虑对其它特性的影响,以及框架的扩展性等问题,所以最终双向绑定会演进成什么样子,我们对 Vue 3.0 拭目以待。

Vue数据双向绑定原理实例解析

Vue数据双向绑定原理是通过数据劫持结合发布者-订阅者模式的方式来实现的,首先是对数据进行监听,然后当监听的属性发生变化时则告诉订阅者是否要更新,若更新就会执行对应的更新函数从而更新视图

Vue数据双向绑定原理实例解析 (https://www.wpmee.com/) javascript教程 第1张

MVC模式

以往的MVC模式是单向绑定,即Model绑定到View,当我们用JavaScript代码更新Model时,View就会自动更新

Vue数据双向绑定原理实例解析 (https://www.wpmee.com/) javascript教程 第2张

MVVM模式

MVVM模式就是Model–View–ViewModel模式。它实现了View的变动,自动反映在 ViewModel,反之亦然。对于双向绑定的理解,就是用户更新了View,Model的数据也自动被更新了,这种情况就是双向绑定。再说细点,就是在单向绑定的基础上给可输入元素input、textare等添加了change(input)事件,(change事件触发,View的状态就被更新了)来动态修改model。

Vue数据双向绑定原理实例解析 (https://www.wpmee.com/) javascript教程 第3张

双向绑定原理

vue数据双向绑定是通过数据劫持结合发布者-订阅者模式的方式来实现的

我们已经知道实现数据的双向绑定,首先要对数据进行劫持监听,所以我们需要设置一个监听器Observer,用来监听所有属性。如果属性发上变化了,就需要告诉订阅者Watcher看是否需要更新。因为订阅者是有很多个,所以我们需要有一个消息订阅器Dep来专门收集这些订阅者,然后在监听器Observer和订阅者Watcher之间进行统一管理的。接着,我们还需要有一个指令解析器Compile,对每个节点元素进行扫描和解析,将相关指令(如v-model,v-on)对应初始化成一个订阅者Watcher,并替换模板数据或者绑定相应的函数,此时当订阅者Watcher接收到相应属性的变化,就会执行对应的更新函数,从而更新视图。

因此接下去我们执行以下3个步骤,实现数据的双向绑定:

(1)实现一个监听器Observer,用来劫持并监听所有属性,如果有变动的,就通知订阅者。

(2)实现一个订阅者Watcher,每一个Watcher都绑定一个更新函数,watcher可以收到属性的变化通知并执行相应的函数,从而更新视图。

(3)实现一个解析器Compile,可以扫描和解析每个节点的相关指令(v-model,v-on等指令),如果节点存在v-model,v-on等指令,则解析器Compile初始化这类节点的模板数据,使之可以显示在视图上,然后初始化相应的订阅者(Watcher)。

Vue数据双向绑定原理实例解析 (https://www.wpmee.com/) javascript教程 第4张

实现一个Observer

Observer是一个数据监听器,其实现核心方法就是Object.defineProperty( )。如果要对所有属性都进行监听的话,那么可以通过递归方法遍历所有属性值,并对其进行Object.defineProperty( )处理

如下代码实现了一个Observer。

function Observer(data) {  this.data = data;  this.walk(data);

}

Observer.prototype = {  walk: function(data) {

var self = this;    //这里是通过对一个对象进行遍历,对这个对象的所有属性都进行监听

Object.keys(data).forEach(function(key) {

self.defineReactive(data, key, data[key]);

});

},  defineReactive: function(data, key, val) {

var dep = new Dep();   // 递归遍历所有子属性

var childObj = observe(val);

Object.defineProperty(data, key, {

enumerable: true,

configurable: true,

get: function getter () {

if (Dep.target) {

// 在这里添加一个订阅者

console.log(Dep.target)

dep.addSub(Dep.target);

}        return val;

},

// setter,如果对一个对象属性值改变,就会触发setter中的dep.notify(),

通知watcher(订阅者)数据变更,执行对应订阅者的更新函数,来更新视图。

set: function setter (newVal) {

if (newVal === val) {

return;

}

val = newVal;

// 新的值是object的话,进行监听

childObj = observe(newVal);

dep.notify();

}

});

}

};function observe(value, vm) {  if (!value || typeof value !== \'object\') {

return;

}  return new Observer(value);

};// 消息订阅器Dep,订阅器Dep主要负责收集订阅者,然后在属性变化的时候执行对应订阅者的更新函数

function Dep () {

this.subs = [];

}

Dep.prototype = { /**

* [订阅器添加订阅者]

* @param {[Watcher]} sub [订阅者]

*/

addSub: function(sub) {

this.subs.push(sub);

}, // 通知订阅者数据变更

notify: function() {

this.subs.forEach(function(sub) {

sub.update();

});

}

};

Dep.target = null;

在Observer中,当初我看别人的源码时,我有一点不理解的地方就是Dep.target是从哪里来的,相信有些人和我会有同样的疑问。这里不着急,当写到Watcher的时候,你就会发现,这个Dep.target是来源于Watcher。

实现一个Watcher

Watcher就是一个订阅者。用于将Observer发来的update消息处理,执行Watcher绑定的更新函数。

如下代码实现了一个Watcher

function Watcher(vm, exp, cb) {

this.cb = cb;

this.vm = vm;

this.exp = exp;

this.value = this.get(); // 将自己添加到订阅器的操作}

Watcher.prototype = {  update: function() {

this.run();

},  run: function() {

var value = this.vm.data[this.exp];

var oldVal = this.value;

if (value !== oldVal) {

this.value = value;

this.cb.call(this.vm, value, oldVal);

}

},  get: function() {

Dep.target = this; // 缓存自己

var value = this.vm.data[this.exp] // 强制执行监听器里的get函数

Dep.target = null; // 释放自己

return value;

}

};

在我研究代码的过程中,我觉得最复杂的就是理解这些函数的参数,后来在我输出了这些参数之后,函数的这些功能也容易理解了。vm,就是之后要写的SelfValue对象,相当于Vue中的new Vue的一个对象。exp是node节点的v-model或v-on:click等指令的属性值。

上面的代码中就可以看出来,在Watcher的getter函数中,Dep.target指向了自己,也就是Watcher对象。在getter函数中,

var value = this.vm.data[this.exp] // 强制执行监听器里的get函数。

这里获取vm.data[this.exp] 时,会调用Observer中Object.defineProperty中的get函数

get: function getter () {

if (Dep.target) {

// 在这里添加一个订阅者

console.log(Dep.target)

dep.addSub(Dep.target);

}

return val;

},

从而把watcher添加到了订阅器中,也就解决了上面Dep.target是哪里来的这个问题。

实现一个Compile

Compile主要的作用是把new SelfVue 绑定的dom节点,(也就是el标签绑定的id)遍历该节点的所有子节点,找出其中所有的v-指令和\" {{}} \".

(1)如果子节点含有v-指令,即是元素节点,则对这个元素添加监听事件。(如果是v-on,则node.addEventListener(\'click\'),如果是v-model,则node.addEventListener(\'input\'))。接着初始化模板元素,创建一个Watcher绑定这个元素节点。

(2)如果子节点是文本节点,即\" {{ data }} \",则用正则表达式取出\" {{ data }} \"中的data,然后var initText = this.vm[exp],用initText去替代其中的data。实现一个MVVM

可以说MVVM是Observer,Compile以及Watcher的“boss”了,他需要安排给Observer,Compile以及Watche做的事情如下

(1)Observer实现对MVVM自身model数据劫持,监听数据的属性变更,并在变动时进行notify

(2)Compile实现指令解析,初始化视图,并订阅数据变化,绑定好更新函数

(3)Watcher一方面接收Observer通过dep传递过来的数据变化,一方面通知Compile进行view update。

最后,把这个MVVM抽象出来,就是vue中Vue的构造函数了,可以构造出一个vue实例。最后写一个html测试一下我们的功能

<!DOCTYPE html><html lang=\"en\"><head>

<meta charset=\"UTF-8\">

<title>self-vue</title></head><style>

#app {

text-align: center;

}</style><body>

<div id=\"app\">

<h3>{{title}}</h3>

<input v-model=\"name\">

<h1>{{name}}</h1>

<button v-on:click=\"clickMe\">click me!</button>

</div></body><script src=\"js/observer.js\"></script>

<script src=\"js/watcher.js\"></script>

<script src=\"js/compile.js\"></script>

<script src=\"js/mvvm.js\"></script>

<script type=\"text/javascript\">

var app = new SelfVue({

el: \'#app\',

data: {

title: \'hello world\',

name: \'canfoo\'

},

methods: {

clickMe: function () {

this.title = \'hello world\';

}

},

mounted: function () {

window.setTimeout(() => {

this.title = \'你好\';

}, 1000);

}

});</script></html>

先执行mvvm中的new SelfVue(...),在mvvm.js中,

observe(this.data);

new Compile(options.el, this);

先初始化一个监听器Observer,用于监听该对象data属性的值。

然后初始化一个解析器Compile,绑定这个节点,并解析其中的v-,\" {{}} \"指令,(每一个指令对应一个Watcher)并初始化模板数

据以及初始化相应的订阅者,并把订阅者添加到订阅器中(Dep)。这样就实现双向绑定了。

如果v-model绑定的元素,

<input v-model=\"name\">

即输入框的值发生变化,就会触发Compile中的

node.addEventListener(\'input\', function(e) {

var newValue = e.target.value;

if (val === newValue) {

return;

}

self.vm[exp] = newValue;

val = newValue;

});

self.vm[exp] = newValue;这个语句会触发mvvm中SelfValue的setter,以及触发Observer对该对象name属性的监听,即Observer中的Object.defineProperty()中的setter。

setter中有通知订阅者的函数dep.notify,Watcher收到通知后就会执行绑定的更新函数。

最后的最后就是效果图啦:

Vue数据双向绑定原理实例解析 (https://www.wpmee.com/) javascript教程 第5张

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持。

Vue数据双向绑定原理实例解析 (https://www.wpmee.com/) javascript教程 第6张

标签:

提交需求或反馈

Demand feedback