乐趣区

Vue面试题精选:Vue原理以及双向数据绑定的实战过程

1 MVVM
双向数据绑定指的是,将对象属性变化与视图的变化相互绑定。换句话说,如果有一个拥有 name 属性的 user 对象,与元素的内容绑定,当给 user.name 赋予一个新值,页面元素节点也会相应的显示新的数据。同样的,如果页面元素(通常是 input)上的数据改变,输入一个新的值会导致 user 对象中的 name 属性发生变化。
MVVM 最早由微软提出来,它借鉴了桌面应用程序的 MVC 思想,在前端页面中,把 Model 用纯 JavaScript 对象表示,View 负责显示,两者做到了最大限度的分离。把 Model 和 View 关联起来的就是 ViewModel。ViewModel 负责把 Model 的数据同步到 View 显示出来,还负责把 View 的修改同步回 Model。
总之一句话, 数据与表现分离,当某一个数据改变时,页面上所有使用这个数据的元素的内容都会改变。下面是一个最简单的数据绑定的例子,来自 Vue2.0 源码阅读笔记–双向绑定实现原理,这个例子十分简单粗暴,就做了三件事:
创建 obj 对象,用来保存数据监听 keyup 事件,当事件触发时,把选定的 input 标签的值赋给 obj 对象的 hello 属性。改变 obj 对象 的 hello 属性的 set 方法,当 hell 被赋值时,将这个值同时赋值给选中的两个元素。<!DOCTYPE html> <head></head> <body> <div id=”app”>
<input type=”text” id=”a”>
<span id=”b”></span>
</div>
<script type=”text/javascript”> var obj = {}; Object.defineProperty(obj, ‘hello’, {
get: function() {
console.log(‘get val:’+ val);
return val;
},
set: function(newVal) {
val = newVal;
console.log(‘set val:’+ val);
document.getElementById(‘a’).value = val;
document.getElementById(‘b’).innerHTML = val;
}
});
document.addEventListener(‘keyup’, function(e) {
obj.hello = e.target.value;
});
</script> </body></html>1.1 实现数据双向绑定的方式
双向数据绑定底层的思想非常的基本,它可以被压缩成为三个步骤:
我们需要一个方法来识别哪个 UI 元素被绑定了相应的属性 (上面的例子里直接选中了元素,而没有提供对外的函数) 我们需要监视属性和 UI 元素的变化我们需要将所有变化传播到绑定的对象和元素常见的实现数据绑定的方法,有大致如下几种:
发布者 - 订阅者模式脏值检查数据劫持其中最简单也是最有效的途径,是使用发布者 - 订阅者模式。上面的例子就使用到了。
发布者 - 订阅者模式的思想很简单:使用自定义的 data 属性在 HTML 代码中指明绑定。所有绑定起来的 JavaScript 对象以及 DOM 元素都将“订阅”一个发布者对象。任何时候,如果某一个被绑定的内容(如 JavaScript 对象或者一个 HTML 输入字段)被侦测到发生了变化,我们将代理事件到发布者 - 订阅者模式,这会反过来将变化广播并传播到所有绑定的对象和元素。下面是一个来自谈谈 JavaScript 中的双向数据绑定的例子,我在注释里添加了一些我的理解。
function DataBinder(object_id){
// 创建一个简单地 PubSub 对象
var pubSub = {// 一个 pubSub 对象,内部有一个 callbacks 对象,保存回调函数
callbacks: {}, // 键名为触发回调函数的自定义事件名称,值为一个数组,每一项都是一个回调函数
on: function(msg,callback){// on 方法 传入参数,一个字符串(就是自定义事件的名称),一个回调函数
this.callbacks[msg] = this.callbacks[msg] || []; // 以 msg 作为键名,创建数组(如果存在,等于原数组)
this.callbacks[msg].push(callback); // 将新的回调函数加入数组
},
publish: function(msg){// publish 方法
this.callbacks[msg] = this.callbacks[msg] || []; // 根据 msg 传入的参数,调用 this.callbacks 对象 的 msg 属性保存的数组,如果没有,等于新建的空数组
for(var i = 0, len = this.callbacks[msg].length; i<len;i++){// 循环调用所有注册在了 msg 里的回调函数
this.callbacks[msg][i].apply(this,arguments); // 调用注册的回调函数时,将 this 指向 publish 的调用者,参数为 publish 函数调用时传入的参数
}
}
},
data_attr = “data-bind-” + object_id, // 产生一个字符串,对传入的参数进行处理,加上“data-bind”前缀加上,后面会用这个字符串作为属性名,获得需要绑定的元素
message = object_id + “:change”, // 产生一个字符串,对传入的参数进行处理,加上“:change”后缀加上,后面会用这个字符串作为事件名,将事件派发给接收的元素
changeHandler = function(evt){// 根据事件的触发者,判断是否是监听的数据
var target = evt.target || evt.srcElemnt, //IE8 兼容 触发事件的元素
prop_name = target.getAttribute(data_attr); // 得到元素的 data_attr 属性

if(prop_name && prop_name !== “”){// 根据元素属性,判断是否是监听的元素
pubSub.publish(message,prop_name,target.value); // 广播 message 事件,调用所以注册了 message 事件的函数,调用注册的回调函数时,将 this 指向 publish 的调用者,参数为 publish 函数调用时传入的参数(publish 函数内部有 apply)
}
};
// 监听变化事件并代理到 PubSub
if(document.addEventListener){// 监听整个文档的变化,并调用 changeHandler 函数
document.addEventListener(“change”,changeHandler,false);
}else{
//IE8 使用 attachEvent 而不是 addEventListener
document.attachEvent(“onchange”,changeHandler);
}
//PubSub 将变化传播到所有绑定元素
pubSub.on(message,function(vet,prop_name,new_val){// 调用 pubSub.on 方法,注册 message 事件的回调函数,本例中,message 事件也只绑定了一个回调函数,就是这个匿名函数,功能是将变化元素的值传入所有被监听的元素
var elements = document.querySelectorAll(“[” + data_attr + “=” + prop_name + “]”), // 根据传入的参数,获得所以需要接受数据的元素
tah_name;
for(var i = 0,len =elements.length; i < len; i++){// 循环对元素进行处理
tag_name = elements[i].tagName.toLowerCase();

if(tag_name === “input” || tag_name === “textarea” || tag_name === “select”){// 根据元素的种类,确定数据额输出方式
elements[i].value = new_val;
}else{
elements[i].innerHTML = new_val;
}
}
});
return pubSub;
}
// 在 model 的设置器中 function User(uid){
var binder = new DataBinder(uid), // 返回一个 pubSub 对象,其上保存了由传入参数 uid 确定的元素所有绑定的回调函数
user = {
attributes: {}, // 保存需要同步的数据
set: function(attr_name,val){// 调用 set 方法,将需要同步的数据通过 publish 方法传给监听的元素
this.attributes[attr_name] = val;
// 使用“publish”方法
binder.publish(uid+ “:change”, attr_name, val,this);
},
get: function(attr_name){
return this.attributes[attr_name];
}
}
return user; // 函数作为一个构造函数时,返回一个对象,作为这个构造函数的实例
}
var user = new User(123); // 返回一个 user 对象,对象有一个 attributes 属性指向一个对象,这个对象保存这需要同步的数据 user.set(“name”,”Wolfgang”); // 所有带有 data-bind-123=”name” 属性的 html 标签都会被监听,它们的值会同步改变,保持相同然后说脏检查,脏检查是一种不关心你如何以及何时改变的数据,只关心在特定的检查阶段数据是否改变的数据监听技术。简单来说,脏检查是直接检测数据是否改变,如果某一个被监听的数据改变,就将这个值传给所有被被监听者。
而数据劫持,就是通过对属性的 set get 方法进行改造,来监测数据的改变,发布消息给订阅者,触发相应的监听回调。
2 vue 数据双向绑定
已经了解到 vue 是通过数据劫持的方式来做数据绑定的,其中最核心的方法便是通过 Object.defineProperty()来实现对属性的劫持,达到监听数据变动的目的。
要实现 mvvm 的双向绑定,主要进行了:
实现一个数据监听器 Observer,能够对数据对象的所有属性进行监听,如有变动可拿到最新值并通知订阅者实现一个指令解析器 Compile,对每个元素节点的指令进行扫描和解析,根据指令模板替换数据,以及绑定相应的更新函数实现一个 Watcher,作为连接 Observer 和 Compile 的桥梁,能够订阅并收到每个属性变动的通知,执行指令绑定的相应回调函数,从而更新视图 mvvm 入口函数,整合以上三者例子大体来自这篇文章的,我根据自己的理解做了些修改,添加了一些注释
为了便于理解,首先,来实现一个消息的储存中转的构造函数:
var uid = 0; // 通过全局的 uid 给 Dep 实例增加唯一 id,以区分不同实例
function Dep() {
this.id = uid++; // 给 Dep 实例添加 id,并将全局的 uid 加 1
this.subs = [];
}Dep.prototype = {
addSub: function(sub) {// 增加 sub
this.subs.push(sub);
},

depend: function() {
Dep.target.addDep(this); // 将全局对象 Dep 的 target 属性指向的对象(这个函数的调用者 this)添加的 subs 里
},

removeSub: function(sub) {// 删处 sub
var index = this.subs.indexOf(sub);
if (index != -1) {
this.subs.splice(index, 1);
}
},

notify: function() { // 通知所有 subs 数据已更新
this.subs.forEach(function(sub) {
sub.update();
});
}
}; 通过修改对象的属性,每一个绑定的属性都会有一个 Dep 实例。每一个 Dep 实例都会有一个 subs 属性,用来存储需要通知的对象,当对象属性改变时,通过 set 方法,调用这个属性的 Dep 实例的原型的 notify 方法,根据 subs 数组保存的内容,通知绑定了这个属性值的数据修改内容。
function Observer(data) {
this.data = data;
this.walk(data); // 调用原型的方法,处理对象
}
Observer.prototype = {
walk: function(data) {
var me = this;
Object.keys(data).forEach(function(key) {// 遍历 data 的属性,修改属性的 get / set
me.convert(key, data[key]);
});
},
convert: function(key, val) {
this.defineReactive(this.data, key, val);
},

defineReactive: function(data, key, val) {// 对属性进行修改
var dep = new Dep();
var childObj = observe(val);

Object.defineProperty(data, key, {
enumerable: true, // 可枚举
configurable: false, // 不能再 define
get: function() {
if (Dep.target) {
dep.depend(); // 将全局的 Dep.target 添加到 dep 实例的 subs 数组里
}
return val;
},
set: function(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);
}; 然后对 html 模板进行编译,根据每个节点及其的属性,判断是否包含‘{{}}’,’v-‘,’on’等特殊字符串,判断是否进行了绑定,将绑定了的属性个 get set 进行处理,
function Compile(el, vm) {
this.$vm = vm;
this.$el = this.isElementNode(el) ? el : document.querySelector(el);

if (this.$el) {
this.$fragment = this.node2Fragment(this.$el);
this.init();
this.$el.appendChild(this.$fragment);
}
}
Compile.prototype = {
node2Fragment: function(el) {
var fragment = document.createDocumentFragment(),
child;

// 将原生节点拷贝到 fragment
while (child = el.firstChild) {// 如果 el 有资源素,就将其赋值给 child,返回 true
fragment.appendChild(child); // 将 child 从 el 转移到 fragment 下,el 会少一个资源素,进行下一轮循环
}

return fragment; // 返回 fragment
},

init: function() {
this.compileElement(this.$fragment); // 对 fragment 进行改造
},

compileElement: function(el) {
var childNodes = el.childNodes,
me = this;

[].slice.call(childNodes).forEach(function(node) {// 循环遍历节点,处理属性
var text = node.textContent;
var reg = /\{\{(.*)\}\}/;

if (me.isElementNode(node)) {
me.compile(node); // 处理元素节点

} else if (me.isTextNode(node) && reg.test(text)) {// 处理文本节点
me.compileText(node, RegExp.$1);
}

if (node.childNodes && node.childNodes.length) {
me.compileElement(node); // 递归调用,处理子元素
}
});
},

compile: function(node) {
var nodeAttrs = node.attributes, // 获得 dom 节点在 html 代码里设置的属性
me = this;

[].slice.call(nodeAttrs).forEach(function(attr) {// 对属性进行遍历,设置
var attrName = attr.name;
if (me.isDirective(attrName)) {// 判断是普通属性还是绑定指令,如果是指令,对指令进行处理
var exp = attr.value;
var dir = attrName.substring(2);
// 绑定了事件指令
if (me.isEventDirective(dir)) {
compileUtil.eventHandler(node, me.$vm, exp, dir);
// 普通指令
} else {
compileUtil[dir] && compileUtil[dir](node, me.$vm, exp);
}
node.removeAttribute(attrName); // 移除原本属性
}
});
},
compileText: function(node, exp) {
compileUtil.text(node, this.$vm, exp);
},

isDirective: function(attr) {
return attr.indexOf(‘v-‘) == 0;
},

isEventDirective: function(dir) {
return dir.indexOf(‘on’) === 0;
},

isElementNode: function(node) {// 判断是不是元素节点
return node.nodeType == 1;
},

isTextNode: function(node) {// 判断是不是文本节点
return node.nodeType == 3;
}
}最后,实现 watch,监视属性的变化。watch 的每个实例,会添加到希望监听的属性的 dep.subs 数组中,当监听的数据发生变化,调用 notify 函数,然后函数内部调用 subs 中所以 watch 实例的 updata 方法,通知监听这个数据的对象。受到通知后,对象判断值是否改变,如果改变,调用回调函数,更改视图
function Watcher(vm, exp, cb) {
this.cb = cb;
this.vm = vm;
this.exp = exp;
// 此处为了触发属性的 getter,从而在 dep 添加自己,结合 Observer 更易理解
this.value = this.get();
}Watcher.prototype = {
update: function() {
this.run(); // 属性值变化收到通知
},
run: function() {
var value = this.get(); // 取到最新值
var oldVal = this.value;
if (value !== oldVal) {// 判断值是否改变
this.value = value;
this.cb.call(this.vm, value, oldVal); // 执行 Compile 中绑定的回调,更新视图
}
},
get: function() {
Dep.target = this; // 将当前订阅者指向自己
var value = this.vm[exp]; // 触发 getter,添加自己到属性订阅器中
Dep.target = null; // 添加完毕,重置
return value;
}
}; 最后,通过 MVVM 构造器,将上面及部分整合起来,实现数据绑定。
function MVVM(options) {
this.$options = options;
var data = this._data = this.$options.data;
observe(data, this);
this.$compile = new Compile(options.el || document.body, this)
}上面的内容只是实现数据绑定的大概思路,其他内容我再慢慢完善。
3 vue 数据双向绑定的缺陷
3.1 vue 实例创建后,再向其上添加属性,不能监听
当创建一个 Vue 实例时,将遍历所有 DOM 对象,并为每个数据属性添加了 get 和 set。get 和 set 允许 Vue 观察数据的更改并触发更新。但是,如果你在 Vue 实例化后添加(或删除)一个属性,这个属性不会被 vue 处理,改变 get 和 set。
如果你不想创建一个新的对象,你可以使用 Vue.set 设置一个新的对象属性。该方法确保将属性创建为一个响应式属性,并触发视图更新:
function addToCart (id) {
var item = this.cart.findById(id);
if (item) {
item.qty++
} else {
// 不要直接添加一个属性,比如 item.qty = 1
// 使用 Vue.set 创建一个响应式属性
Vue.set(item, ‘qty’, 1)
this.cart.push(item)
}
}addToCart(myProduct.id);3.2 数组
Object.defineProperty 的一个缺陷是无法监听数组变化。
当直接使用索引 (index) 设置数组项时,不会被 vue 检测到:
app.myArray[index] = newVal; 然而 Vue 的文档提到了 Vue 是可以检测到数组变化的,但是只有以下八种方法, vm.items[indexOfItem] = newValue 这种是无法检测的。
push();pop();shift();unshift();splice();sort();reverse(); 同样可以使用 Vue.set 来设置数组项:
Vue.set(app.myArray, index, newVal);3.3 proxy 与 defineproperty
Proxy 对象在 ES2015 规范中被正式发布, 用于定义基本操作的自定义行为(如属性查找,赋值,枚举,函数调用等)。
它在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。
我们可以这样认为,Proxy 是 Object.defineProperty 的全方位加强版, 具体的文档可以查看此处;
Proxy 有多达 13 种拦截方法, 不限于 apply、ownKeys、deleteProperty、has 等等,是 Object.defineProperty 不具备的。Proxy 返回的是一个新对象, 我们可以只操作新的对象达到目的, 而 Object.defineProperty 只能遍历对象属性直接修改。Proxy 作为新标准将受到浏览器厂商重点持续的性能优化,也就是传说中的新标准的性能红利。当然,Proxy 的劣势就是兼容性问题, 而且无法用 polyfill 磨平, 因此 Vue 的作者才声明需要等到下个大版本 (3.0) 才能用 Proxy 重写。
喜欢的可以关注小编哈~

退出移动版