Products
GG网络技术分享 2025-03-18 16:14 0
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-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 待会儿再说,先用最常见的方法,事件:
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 的几个要素
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 的双向绑定还需要通过触发事件来实现数据回传。这和很多所的期望的赋值回传还是有一定的差距。造成这一差距的主要原因有两个
在现在的 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数据双向绑定原理是通过数据劫持结合发布者-订阅者模式的方式来实现的,首先是对数据进行监听,然后当监听的属性发生变化时则告诉订阅者是否要更新,若更新就会执行对应的更新函数从而更新视图
MVC模式
以往的MVC模式是单向绑定,即Model绑定到View,当我们用JavaScript代码更新Model时,View就会自动更新
MVVM模式
MVVM模式就是Model–View–ViewModel模式。它实现了View的变动,自动反映在 ViewModel,反之亦然。对于双向绑定的理解,就是用户更新了View,Model的数据也自动被更新了,这种情况就是双向绑定。再说细点,就是在单向绑定的基础上给可输入元素input、textare等添加了change(input)事件,(change事件触发,View的状态就被更新了)来动态修改model。
双向绑定原理
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)。
实现一个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收到通知后就会执行绑定的更新函数。
最后的最后就是效果图啦:
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持。
Demand feedback