数据双向绑定原理简单概括的话就是:
View层影响Model层是通过对 ‘keyup’ 等事件的监听。
Model层影响View层是通过 Object.defineProperty( ) 方法劫持数据并结合发布订阅者模式的方式来实现数据的双向绑定。
(vue 3.0版本里用 Proxy 替代 Object.defineProperty)
当然不能只掌握到这个层面,下面介绍如何进行数据劫持以及发布订阅者模式:
1、Object.defineProperty( ) 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象
Object.defineProperty(obj, prop, descriptor)
这里我们主要是通过重定义属性描述符里的 get 和 set 方法进行数据劫持
get:属性的 getter 函数,如果没有 getter,则为 undefined。当访问该属性时,会调用此函数。该函数的返回值会被用作属性的值。
set:属性的 setter 函数,如果没有 setter,则为 undefined。当属性值被修改时,会调用此函数。
总结:当我们修改数据层(Model)某个属性值(数据)时,就会触发重定义过的 set 方法去同步修改视图层(View)的数据
2、发布订阅者模式的应用
订阅者(Watcher)把自己想订阅的数据或事件添加到订阅者收集器(Dep),当某事件(set或get等)触发时,发布者(Observer)发布该事件到订阅者收集器,由订阅者收集器统一通知有订阅该事件的订阅者(Watcher)去执行相应的更新函数从而更新视图。(参考下图流程)
(图片转自参考文章一)
到此应该有个大概的思路了,下面根据原理图来介绍整个流程:
1、首先使用Object.defineProperty()中的 getter/setter 作为一个Observer(劫持器)去劫持data对象中的所有属性,在属性 set 的时候通知Dep(订阅者收集器)去通知相关订阅者。
2、实现一个 Watcher(订阅者),Watcher 就是收到 Dep 数据变化的通知后,会去执行相对应的更新函数来更新视图,同一个数据可能在多处被使用,所以订阅者不止一个;这也是 Dep 存在的意义,对 Watcher 集中起来统一管理。
3、Dep(订阅者收集器),里面存放每个数据对应的所有 Watcher,当Observer 的 set 方法被触发时,就调用 Dep 里面的的notify(通知)方法,逐条去通知所有的 Watcher 。
4、Complier是一个编译器,作用是解析模板指令,扫描和解析 vue 代码中每一个节点,先将节点转换为碎片化文档 DocumentFragment(性能优化,减少重排),再一次性 append 所有节点至目标 element 内,完成视图的初始化;同时编译器还担当着初始化 Watcher 的任务,即给 Watcher 绑定相关的更新函数 ,最终使 Watcher 添加到 Dep 中去。
简单实现(不包括Dep、Complier的实现):
<body>
<div id="app">
<input type="text" id="txt">
<span id="show-txt">span>
div>
<script>
var obj = {}
Object.defineProperty(obj, 'val', {
get: function () {
return val
},
set: function (newValue) {
document.getElementById('txt').value = newValue
document.getElementById('show-txt').innerHTML = newValue
}
})
document.addEventListener('keyup', function (e) {
obj.val = e.target.value
})
script>
body>
掌握以上内容已经算是上道了(面试时解释完上面内容也差不多了),下面是较为完整的实现(加深理解):
1.首先实现一个劫持器,对数据进行劫持
function defineReactive(obj, key, val) {
var dep = new Dep(); //创建dep订阅者收集器
Object.defineProperty(obj, key, {
get: function() {
if(Dep.target) {
dep.addSub(Dep.target)
}
return val
},
set: function(newVal) {
if(newVal === val) {
return
}
val = newVal;
dep.notify(); //执行dep的通知函数去通知相关订阅者
}
})
}
2、实现一个观察者,对于一个实例的每一个属性值都进行观察
function Observer(obj, vm) {
for(let key of Object.keys(obj)) {
defineReactive(vm, key, obj[key]);
}
}
3、实现dep的构造函数
function Dep() {
this.subs = [] //用来收集订阅者
}
Dep.prototype = {
addSub(sub) { //添加订阅者的方法
this.subs.push(sub)
},
notify() { //通知相关的所有订阅者执行更新函数
this.subs.forEach(function(sub) {
sub.update();
})
}
}
4、实现Watcher订阅者
function Watcher(vm, node, name) {
Dep.target = this; //辨识订阅者者要添加到哪个dep收集器里
this.vm = vm;
this.node = node;
this.name = name;
this.update();
Dep.target = null;
}
Watcher.prototype = {
update() {
this.get();
this.node.nodeValue = this.value //更新函数
},
get() {
this.value = this.vm[this.name] //触发相应的get
}
}
5、实现编译器Complier
function compile(node, vm) {
var reg = /\{\{(.*)\}\}/; // 用正则来匹配{{messeage}}
if(node.nodeType === 1) { //如果是元素节点
var attr = node.attributes;
//解析元素节点的所有属性
for(let i = 0; i < attr.length; i++) {
if(attr[i].nodeName == 'v-model') {
var name = attr[i].nodeValue //看看是与哪一个数据相关
node.addEventListener('input', function(e) { //将与其相关的数据改为最新值
vm[name] = e.target.value
})
node.value = vm.data[name]; //将data中的值赋予给该node
node.removeAttribute('v-model')
}
}
}
//如果是文本节点,即{{messeage}}情况
if(node.nodeType === 3) {
if(reg.test(node.nodeValue)) {
var name = RegExp.$1; //获取到匹配的字符串
name = name.trim();
node.nodeValue = vm[name]; //将data中的值赋予给该node
new Watcher(vm, node, name) //绑定一个订阅者
}
}
}
//在向碎片化文档中添加节点时,每个节点都处理一下
function nodeToFragment(node, vm) {
var fragment = document.createDocumentFragment();
var child;
while(child = node.firstChild) {
compile(child, vm);
fragment.appendChild(child);
}
return fragment
}
6、 Vue构造函数
function Vue(options) {
this.data = options.data;
observe(this.data, this) //给data中的所有属性值增添了observe
var id = options.el;
var dom = nodeToFragment(document.getElementById(id), this)
//处理完所有节点后,重新把内容添加回去,减少重排
document.getElementById(id).appendChild(dom)
}
(有兴趣更进一步研究的话,去看源码吧!)
参考文章:
VUE双向数据绑定原理及简单实现
Vue数据双向绑定原理及简单实现
理解VUE双向数据绑定原理和实现—赵佳乐