接触Vue有一段时间了,但是对于其双向绑定的实现一直是似懂非懂,今天看到一篇写的比较好的文章 传送门1 根据原作者的指导自己也去实现了一遍简单的 demo (本文的demo均基于Object.defineProperty 实现数据劫持,利用了对Vue.js实现双向绑定的思想)
[注]本文所有图片均来自于:传送门2
前言
几种主流的双向绑定
1.发布-订阅模式
2.脏值检测
通过对比数据是否有变更,来决定是否更新视图。最简单的可以通过定时轮询去检测数据的变动。当然Google不会这么low, Angular 只有在指定事件触发时进入脏值检测:
- DOM事件,比如用户输入文本点击按钮等(ng-click)
- XHR响应事件
- 浏览器 Location 变更
- Timer事件
- 执行 $digidt() 或 $apply()
3.数据劫持
Vue.js 采用的是 数据劫持+发布/订阅模式
的方式,通过 Object.defineProperty()
来劫持各个属性的 setter/getter, 在数据变动时发布消息给订阅者(Wacther), 触发相应的监听回调。下图展示了Vue实现双向绑定的流程
实现一个简单的双向绑定
<!DOCTYPE html>
<html lang="en">
<head>
<title>双向绑定最最最初级demo</title>
<meta charset="UTF-8">
</head>
<body>
<div id="app">
<input type="text" id="txt">
<p id="show-txt"></p>
</div>
</body>
<script>
var obj={}
Object.defineProperty(obj,'txt',{
get:function(){
return obj
},
set:function(newValue){
document.getElementById('txt').value = newValue
document.getElementById('show-txt').innerHTML = newValue
}
})
document.addEventListener('keyup',function(e){
obj.txt = e.target.value
})
</script>
</html>
进阶版demo
DOM操作是非常耗时和好性能,所以在优化过程中先从DOM操作入手。因为遍历解析过程中有多次DOM操作,为了提高性能和效率,需要一种方法来避免对DOM元素的直接封装操作。在Vue使用 DocumentFragment
作为替代容器。DocumentFragment
接口表示一个没有父级文件的最小文档对象。它被当做一个轻量版本的Document 使用。所以使用 DocumentFragment 代替DOM直接处理,可以提高性能和速度。
封装DOM节点为 DocumentFragment
//将传入 node 的子节点进行劫持,经过处理后重新挂载回目标节点
function convertNode(node,vm){
var fragment = document.createDocumentFragment(),
child
while(child = node.firstChild){
//将原生节点拷贝到 fragment,并删除之前的child节点
fragment.appendChild(child)
}
return fragment
}
var dom = convertNode(document.getElementById('app'))
document.getElmentById('app').appendChild(dom)
实现Complie解析模板指令
Complie 主要做的事情就是解析模板指令,将模板中的变量替换为数据。所以要遍历整个DOM树,进行扫描解析编译,调用对应的指令渲染函数进行渲染,并调用对应的指令更新函数进行绑定
<div id="app">
<input type="text" id="txt" h-model="text">
{{text}}
</div>
function convertNode(node,vm){
//...
while(child = node.firstChild){
Compile(child,vm)
fragment.appendChild(child)
}
return fragment
}
function Compile(node,vm){
var reg = /\{\{(.*)\}\}/
if(node.nodeType===1){
var attr = node.attributes //对所有属性进行解析
for(var i=0;i<attr.length;i++){
if(attr[i].nodeName=='h-model'){
//将元素与数据绑定
var bindName = attr[i].nodeValue
//为输入框添加事件监听触发
node.addEventListener('input',function(e){
vm.data[bindName] = e.target.value
node.value = vm.data[bindName]
})
node.removeAttribute('h-model');
}
if(node.nodeType===3){
if(reg.test(node.nodeValue)){
var bindName = RegExp.$1.trim()
console.log(RegExp.$1)
node.nodeValue = vm.data[bindName]
}
}
}
}
ViewModel 层向 View 层的数据绑定
接下来实现一个 Xin
构造器,通过 Compile 来解析模板指令,通过 Observer 监听属性数据的变化实现 Model
层向 View
层的数据绑定
function Xin(options){
this.data = options.data
Observer(this.data,this)
var id = options.el
var dom = convertNode(document.getElementById(id),this)
document.getElementById(id).appendChild(dom)
}
新建一个 vm 实例来测试一下 Model --> View
的绑定情况
var vm = new Xin({
el:'app',
data:{
text:'Hello MVVM'
}
})
View 层向 viewModel 层的数据绑定
实际上,在 Observer 中我们已经通过 数据劫持
实现了监听每个数据的变化,在控制台打印 console.log(val)
就可以实时看到数据的变化。所以接下来实现的关键就是怎么用监听到的数据去更新视图。
在这里,我们去实现一个 Wacther
可以将它理解为观察者 ,他的作用是能够接收从 Observer 发过来的属性变动通知, 然后根据属性的变动更新视图 update
。
在监听过程中,为所有的 data 属性生成一个主题对象 Dep,Dep中包含需要维护的观察者列表。每当主题对象状态发生变化时,其相关依赖都会得到通知,并且被自动更新(数据变动会触发notify,再调用订阅者的update() 方法)
function Dep(){
this.subs=[] //订阅者队列
}
Dep.prototype={
addSub:function(sub){
this.subs.push(sub)
},
notify:function(){
this.subs.forEach(function(sub){
sub.update()
})
}
}
funcion Watcher(vm,node,bindName){
//将全局Dep.target设置为当前页面元素node
Dep.target = this
//完成watcher的初始化
this.name = bindName
this.node = node
this.vm = vm
this.update() //初次绑定时进行更新
Dep.target = null //保证Dep.target唯一
}
Watcher.prototype = {
get:function(){
this.value = this.vm.data[this.name]
},
update:function(){
this.get()
this.node.nodeValue = this.value
}
}
function Observer(obj,vm){
//...
Object.defineProperty(obj,prop,{
get:function(){...},
set:function(newVal){
if(val == newVal) return
val = newVal
//data属性被修改,由dep触发view层更新
dep.notify()
}
})
}
考虑这样一个问题,什么时候会有双向绑定? viewModel --> view
可能会发生在所有类型的DOM节点上,而 view --> viewModel
只能发生在 input, select, textarea 等交互控件上。所以将文本节点包装成 Watcher
, 添加相关元素的观察者列表中,Watcher 负责更新页面元素
function Compile(node,vm){
//...
if(node.nodeType ===3){ //文本节点类型
if(reg.test(node.nodeValue)){
var bindName = RegExp.$1.trim()
new Watcher = (vm,node,bindName) //为该页面元素node生产watcher
}
}
}
更新
本文中实现模板渲染的方法借鉴了 Vue 1.x
中实现模板渲染的方法。Vue 2.x
模板渲染 方法借鉴React 中的 VirtualDOM,基于 VirtualDOM。 Vue 2.x 还支持服务端渲染SSR
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。