前言
在之前面试中,有被问到这个问题,虽然了解过是劫持Object.defineProperty
方法,但是其细节并不太清楚,于是遭到了面试官的鄙视👎,只能回头认真在网上看一下。
刚开始看了很多文章,还是没看懂。
最后我是看这篇文章看懂的,其他的要么略过太多细节,看着有种断层感,根本不知道怎么突然到这一步了。有些要么跟着代码讲思路,有点乱。
这篇文章已经讲解得很好了,但是作为一个小白,我还是看了老半天才懂,原因就是看的源码少,水平不够。
所以我决定重新捋一捋里面的思想,把细节尽可能说清楚,让跟我一样没学过任何源码的人也能搞清楚。
补充一下个人想法,对于这些精妙的思维接触不多,而这些往往是决定我们的高度的,是一个使用者还是研究者?有时候眼光的高低,决定着我们未来道路的长短。
大致原理
vue的响应原理可以从下面官网的分析图大致了解。
官网的解释是这样的:
每个组件实例都有相应的 watcher 实例对象,它会在组件渲染的过程中把属性记录为依赖,之后当依赖项的 setter 被调用时,会通知 watcher 重新计算,从而致使它关联的组件得以更新。
看不懂?没关系,有个大概印象就可以了。
defineProperty是什么鬼?
为什么要先从这里说起?因为这是众所周知vue双向绑定的原理。
MDN解释在这里
简单地说,就是对于我们的对象的属性,我们可以通过defineProperty
来设置它的get
和set
方法,一旦获取值,就会触发get方法,一旦修改值,就会触发set方法。
比如下面简单的例子
var obj = {name:'zeller'};
Object.defineProperty(obj,'name',{
get:function(){
console.log(`你正在获取obj的name值.`)
},
set:function(newVal){
console.log(`name值修改中,新的name值是${newVal}`)
},
})
obj.name//"你正在获取obj的name值."
obj.name = 'atoms'//"name值修改中,新的name值是atoms"
用defineProperty实现一个极简的双向绑定例子
既然这个方法这么有用,我们设置一个容器obj,直接在set里面渲染我们的html,然后监听input的keyup
事件,当事件触发时,修改obj对应的值,从而再触发html的改变。
既然大概思路有了,我们可以尝试一下.
<!--html-->
<input type="text" id="content">请输入内容
<br><br>
他输入的内容是:<p id="reflect" style="color:red;"></p>
var obj={};
//假设我们监听'hello'这个属性
Object.defineProperty(obj,'hello',{
set:function(newVal){
var p = document.getElementById('reflect');
p.innerHTML = newVal;
}
})
var input = document.getElementById('content');
input.addEventListener('keyup',function(e){
obj.hello = e.target.value;
})
分解实际任务
虽然上面的简单演示我们貌似做出来了,但是与实际的样子却不一样。我们看看。
实际是上面这样子调用的,所以我们需要分析一下,该如何实现。
首先,我们要在初次渲染html能拿到data的数据
其次,输入框输入内容变化时,data中的相应属性也能变化
最后,data中的数据变化时,html能实时跟着变化
所以我们大概可以分为3个任务
1、输入框以及文本节点与data中的数据绑定(初始渲染)
2、输入框内容变化时,data中的数据同步变化。即view => model的变化。
3、data中的数据变化时,文本节点的内容同步变化。即model => view的变化。
任务1:初始加载渲染data里的属性
既然要加载data里的属性值,我们就要考虑两种情况,app里的子节点的类型,
- 当childNode是文本节点,而我们匹配到
{{attr}}
时,我们需要去找vue里面绑定的data的attr属性,把它的值替换给文本节点. - 当childNode是元素节点时,比如
<input v-model="attr">
,我们就要去找vue.data.attr的值,并赋给childNode
因此可以看出,我们需要先把所有子节点遍历出来,看看有没有符合以下两个规则的内容:
- 文本节点,含有
{{attr}}
- 元素节点,含有
v-model
这样把值替换完我们就可以返回去了,但是考虑到多次操作dom的开销,我们用createDocumentFragment()
它相当与创建一个仓库,每次把子节点修改完,我们不直接插入父节点(#app),而是放入仓库,最后直接把仓库里的东西替换掉就可以了。
创建fragment仓库
function nodeToFragment (node, vm) {
var flag = document.createDocumentFragment();
var child;
// 许多同学反应看不懂这一段,这里有必要解释一下
// 首先,所有表达式必然会返回一个值,赋值表达式亦不例外
//child = node.firstChild返回的是赋值的node.firstChild
//即只要firstChild存在,就把firstChild赋给child
// 理解了上面这一点,就能理解 while (child = node.firstChild) 这种用法
// 其次,appendChild 方法有个隐蔽的地方,就是调用以后 child 会从原来 DOM 中移除
// 所以,第二次循环时,node.firstChild 已经不再是之前的第一个子元素了
while (child = node.firstChild) {
compile(child,vm)//讲data转化为html
flag.appendChild(child); // 将子节点劫持到文档片段中
}
return flag
}
compile方法在下面解释
替换html
这里主要用的是正则表达式的检测方法,其中对RegExp.$1
的用法不了解的同学可以Google一下,这是正则一个非常巧妙而且强大的地方。
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 == 'v-model') {
var name = attr[i].nodeValue; // 获取 v-model 绑定的属性名
node.value = vm.data[name];
node.removeAttribute('v-model')
}
};
}
// 节点类型为 text
if (node.nodeType === 3) {
if (reg.test(node.nodeValue)) {
var name = RegExp.$1; // 获取匹配到的字符串
name = name.trim();
node.nodeValue = vm.data[name]
}
}
}
我们看看上面的代码,主要就是判断子节点的类型,一旦是元素节点,我们就给它的input事件绑定方法,把input的value传给vm.data[name],如果是文本节点,就直接替换.
这里要注意,element节点我们用的是node.value
,text节点我们用的是node.nodeValue
,这两个写法的区别可以自行Google一下.
最后再创建一个Vue实例
下面是codepen的实例
任务2:响应式的数据绑定
再来看任务二的实现思路:当我们在输入框输入数据的时候,首先触发input事件(或者keyup、change事件),在相应的事件处理程序中,我们获取输入框的value并赋值给vm实例的text属性。我们会利用defineProperty将data中的text劫持为vm的访问器属性,因此给vm.data.text赋值,就会触发set方法。在set方法中主要做两件事,第一是更新属性的值,第二留到任务三再说。
具体怎么做呢?
监听input事件
input节点
当我们触发input时,要在dom节点上绑定事件?
怎么绑定呢?记得我们前面的nodeToFragment函数吗?就是用于遍历所有的子节点,进行节点修改的。
而里面具体干活的是compile函数,nodeToFragment只是一个包工头。
这样,我们就可以在v-model的标签里监听input事件
if (attr[i].nodeName == 'v-model') {
var name = attr[i].nodeValue; // 获取 v-model 绑定的属性名
node.addEventListener('input', function (e) {
// 给相应的 data 属性赋值,进而触发该属性的 set 方法
vm.data[name] = e.target.value;
});
node.value = vm.data[name]; // 将 data 的值赋给该 node
node.removeAttribute('v-model');
我们看看逻辑,一开始就是从vm.data[name]
获取value,一旦自己的内容改变了(e.target.value
),就把这个值告诉(赋值)给vm.data[name]
文本节点
而对于文本节点,是不需要的,我们只需要从vm.data获取数据就可以了。因为它不是可以通过input改变内容的。
node.nodeValue = vm.data[name];
劫持get和set方法
想想我们的思路,我们input触发时,是这样修改data值的
vm.data[name] = e.target.value;
我们希望触发点东西,但那是下一章的内容,无论如何,我们先劫持这些vm.data的所有属性的get和set方法。
以后究竟要怎么搞事我们再决定。
怎么劫持呢?
我们只有在Vue中写入一个observe,用于遍历所有属性,进行get和set的劫持。
function Vue (options) {
this.data = options.data;
var data = this.data;
observe(data, this);
var id = options.el;
var dom = nodeToFragment(document.getElementById(id), this);
// 编译完成后,将 dom 返回到 app 中
document.getElementById(id).appendChild(dom);
}
接下来就是怎么写这个observe。
首先必须遍历所有节点。
然后用defineProperty设置get和set方法,这是我们暂且在set时打印新值,看看data是否真的改变了
function observe (obj, vm) {
Object.keys(obj).forEach(function (key) {
defineReactive(vm.data, key, obj[key]);
})
}
function defineReactive (obj, key, val) {
Object.defineProperty(obj, key, {
get: function () {
return val
},
set: function (newVal) {
if (newVal === val) return
val = newVal;
console.log(obj[key])
}
});
}
以上就是我们的第二部分,主要实现两部分:
1、设置观察函数observe,改写get和set
2、监听元素节点的input,当符合条件(匹配正则)时,首先从vm.data.key获取相应属性的值,触发get。
当input的内容发生改变时,把该值赋给vm.data.key,触发set。
codepen完整代码在这里
可以看到当input的值发生改变时,vm.data.key也发生改变,这里我们先用console来判断这个值是否改变了。
至此,第二部分已经完成。
任务3:把data的值渲染到dom里面
上面已经实现了值的双向传递,我们主要用了属性劫持和方法监听(input)。
接下来想想我们该如何把data渲染进dom。
记得我们刚开始的极简版demo吗?
Object.defineProperty(obj,'hello',{
set:function(newVal){
var p = document.getElementById('reflect');
p.innerHTML = newVal;
}
})
我们是通过找到p元素,当data改变时,直接把新值传给p元素。
但是有一个问题,我们这里假设已经知道p元素与data双向绑定了。
如果我们不知道呢?
仔细看看这句代码p.innerHTML = newVal;
到底哪一个元素的innerHTML才是newVal?
所以我们的关键是找到哪一个节点的对应哪一个属性(vm.data)
这是vue最核心的部分之一
假设我们有一个容器,当我们get内容时,那这个节点肯定与data绑定了,此时我们把这个节点push进这个容器,这样只要每次data改变,我们遍历所有的节点不就可以了吗?
vue管这个容器叫"依赖"(dep),或许表示所有dep里的节点都会依赖这个容器dep。
这么说有点绕口,比如这样,我们在每个属性上绑定一个容器dep,容器上有个数组subs,当有节点要get这个属性的值时,我们就记录下这个节点,push进subs。
而当我们的data改变时,就可以遍历所有的节点,让他们更新dom了。
意思就是连接节点和data的基本思路。具体怎么实现呢?
首先我们每个属性各自都需要一个依赖dep,我们可以写一个构造函数Dep,实例对象维护一个数组,用于存放节点。
function Dep () {
this.subs = []
}
这个依赖还必须有两个功能,添加和更新。
有节点绑定了,就把它添加到数组。
有内容(data)更新了,就”告诉“所有节点去更新dom
所以原型还需要添加这两个方法:
Dep.prototype = {
addSub: function(sub) {
this.subs.push(sub);
},
notify: function() {
this.subs.forEach(function(sub) {
sub.update();
});
}
}
这个dep是跟着属性走的,所以我们需要在遍历属性时创建。
function defineReactive (obj, key, val) {
var dep = new Dep();
Object.defineProperty(obj, key, {
get: function () {
// 添加订阅者 watcher 到主题对象 Dep
if (添加一个条件) dep.addSub();
return val
},
set: function (newVal) {
if (newVal === val) return
val = newVal;
// 作为发布者发出通知
dep.notify();
}
});
这里的get我们应该把节点push进容器数组,但是想一想,是不是连接建立后我们才要把这个节点push进去呢?怎么判断是不是建立连接了呢?
记得我们的compile函数吗?
if (attr[i].nodeName == 'v-model') {
var name = attr[i].nodeValue; // 获取 v-model 绑定的属性名
node.addEventListener('input', function (e) {
// 给相应的 data 属性赋值,进而触发该属性的 set 方法
vm.data[name] = e.target.value;
});
node.value = vm.data[name]; // 将 data 的值赋给该 node
node.removeAttribute('v-model');
}
此时是不是通过判断节点是否有”v-model“,但有时,从data里获取v-model绑定的属性值?
这是连接建立的关键,所以再这之后,我们可以判断可以把节点push进去了。
但是想想,光是节点够吗?我们是否还需要更新的函数?能否写在一起?
所以我们可以建立一个Watcher函数,用于更新dom,这样当有data改变时,只要dep告诉我们去更新所有的Watcher就可以了。
这个Watcher就相当于一个容易,包裹了dom元素的内容还有更新方法。
所以我们push进dep的是一个个的Watcher,有更新就调用Watcher的update方法就可以了。
Watcher应该像下面这么写
function Watcher (vm, node, name, nodeType) {
Dep.target = this;
this.name = name;
this.node = node;
this.vm = vm;
this.nodeType = nodeType;
this.update();
Dep.target = null;
}
Watcher.prototype = {
update: function () {
this.get();
if (this.nodeType == 'text') {
this.node.nodeValue = this.value;
}
if (this.nodeType == 'input') {
this.node.value = this.value;
}
},
// 获取 data 中的属性值
get: function () {
this.value = this.vm.data[this.name]; // 触发相应属性的 get
}
}
这里的Dep.target是作为节点与data绑定的标志,一旦这个存在了,说明我们要去get方法那里push Watcher了。
之后我们要清除这个Dep.target,有其他Watcher实例对象创建时再赋值,传给dep.
因此相当于一个临时的标志容器,且是全局的。
现在看看上面劫持get时的if条件,应该知道怎么写了吧。
就是Dep.target存在的时候
get: function () {
// 添加订阅者 watcher 到主题对象 Dep
if (Dep.target) dep.addSub();
return val
}
至此,我们的程序就完成了。
测试是没有问题的。
下面是我画的流程图,可以帮助理解。
完整示例在这里
回顾
我们创建了一个类似vue的双向绑定机制,怎么实现的呢?
我想从data获取数值,于是我们改变dom,通过匹配正则,符合条件的把data的值赋给dom的value或nodeValue
我们想把内容变更传递给data,于是我们改造所有的data.
各自给它们一个容器dep的数组subs,当连接建立(标志是)同样是正则匹配上了。
此时新建一个watcher,用于标识dom和存放更新dom的方法。
当input的内容改变时,触发obj的set方法,set方法命令subs更新dom,subs遍历所有watcher,让所有watcher中的方法去更新自己的dom。
初次写这么长的文章,刚开始理解这个机制对我来说也有点吃力,但总算搞懂了。
以上,我的解释还有许多不足,欢迎指教,感谢阅读。