前言
自己之前看过一些Vue2
源码解析的文章,视频,博客等,之前面试也经常会被问到双向数据绑定甚至被要求手写代码(身为一个学习前端一年半的垃圾练习生,被问原理我可以跟你将博客上一些高大上的解释跟你吹上半天,但是你要我写,哼,那我只好理直气壮的告诉你:我不会!)。因此这次想要从一个简单的角度逐渐切入,写一篇Vue
双向数据绑定相关的内容。
Vue双向数据绑定
在Vue
中,双向数据绑定总是第一个被提起的话题,在本文尝试编写一个简单版本的双向数据绑定,然后看一下源码的实现,并对其进行分析。
1. vue的效果预览
在最开头,先写一个Vue
基本的功能代码,查看Vue
的功能:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<body>
<div id="app">
<div>i am {{name}}</div>
<div v-text="name"></div>
<div v-html="age"></div>
<div>computed: {{doubleAge}}</div>
<input type="text" v-model="input">
<button @click="handleClick">clickMe</button>
</div>
<script>
const vm = new Vue({
el: '#app',
data: {
name: 'Evan',
age: 18,
input: 'test',
},
methods:{
handleClick(){
console.log("i am be cliked, i am ", this.age)
},
},
computed: {
doubleAge(){
return this.age * 2;
}
}
})
</script>
</body>
</html>
我们直接打开这个html,展示的内容如下图
然后F12打开开发者工具 对控制台进行一顿操作:
这里我依次进行了修改
Vue
实例vm
上的age
,name
,input
,页面的内容对应也发生了改变,然后点击按钮,控制台输出相应内容。最后去输入框里面修改输入框的内容,再去控制台打印出来vm.input
的值,发现其对应的值也改变了。哇,真的好神奇呢,居然在控制台改变了数据的值,(真是浮夸的表演)。html
上的内容也发生了变化
2. 手写Vue
双向数据绑定
在查看了上面引入Vue
文件实现的效果后,我们也来实现一下类似的功能。在上面的html中,script
引入了vue.js
后,创建了一个script
,在其中做了如下的操作:
- 创建一个
Vue
的实例。并传入了一个对象参数,里面包含data
,el
,computed
等(后续将传入构造函数的对象称之为options
)。 - 将
options.el
对应的元素节点(上面例子中即为<div id='#app'>
,后续将options.el
对应的元素节点称之为根节点)以及该节点对应的孩子节点里面编写的Vue
相关的内容,解析成真正的Dom节点,展示在页面上。 - 创建
Vue
的实例后,根节点以及其子节点中使用option.data
相关属性的地方,会因为options.data
中数据的变化,视图随之更新。
那么如何实现类似于Vue这样数据改变相应视图的功能呢?
在提及Vue
时,总能听到MVVM设计模式,简单介绍一下MVVM:
MVVM由三部分组成: Model(数据层), View(视图层), ViewModel。其中当Model层发生变化时,ViewModel收到Model的相关变化,进而去操作View层,更新相关的视图。而当View层有用户的输入或者交互时,我们也可以通过viewModel去改变Model层的状态(即修改数据内容)。
在张图片中,可以将View当作我们在创建
Vue
实例时选择的根节点,viewModel当作Vue
实例,而Model就是Vue
构造函数传入对象参数中的data
属性,当Model中的数据改变时,我们通过Vue
实例来修改DOM
节点的相关内容,进而当我们在修改的Model时,我们无需操作Dom便可以更新视图。由此,可以猜测实现Vue
功能需要做如下的内容:
- 当数据发生改变时,通知到
Vue
实例,并且Vue
实例可以找到根节点中我们使用相关的数据的地方,对其进行DOM操作,更新视图。 Vue
能够解析在<div id="#app">
该节点下我们编写的Vue
指令,例如{{name}}
,v-text
等操作,并且将其转化为我们浏览器能够识别的,正确的DOM内容将其展示在视图层,并且对有指令存在的DOM节点做额外的处理:当数据改变的时候,可以对其进行视图的更新。
接下来,将script
标签的src
属性改为引入本地的mvvm.js
开始我们的编写:
1. Vue类的编写
根据上面的分析,mvvm.js
中应该要有一个Vue
这么一个构造函数,我们尝试编写如下的代码:
class Vue{
constructor(options){
this.$data = options.data;
this.$el = options.el;
this.$option = options;
if(this.$el){
// 将数据变为响应式
new Observer(this.$data);
// 解析模板
new Compile(this.$el, this);
}
}
}
在上面代码中,利用Observer
类来实现数据的相应式处理,利用Compile
去解析我们编写在<div id='app'>
中的相关内容。
下面将从Compile
开始,从解析options.el
开始慢慢过渡到双向数据绑定。
2. Compile 类
在上面的内容中,我们知道Compile
类是用来解析options.el
中的Dom
节点的,具体要做的操作如下:
- 解析
Vue
根节点中的节点内容,检查vue
相关的指令。 - 将
Vue
指令转化为Dom节点的相关内容,在视图上显示我们预期的内容。 - 对某些使用
Vue
数据和指令的节点做一些额外的处理,使Vue
中$data
内部某个属性改变时,视图也能自动改变。
因此我们尝试编写如下的代码:
class Compile{
constructor(el, vm){
this.$el = this.isElementNode(el) ? el: document.querySelector(el);
this.$vm = vm;
// 在内存中创建一个和$el相同的元素节点,
// 并且将$el的孩子加入到内存中
let fragment = this.node2fragment(this.$el);
// 解析模板($el节点)
this.compile(fragment);
// 将解析后的节点重新挂载到DOM树上
this.$el.appendChild(fragment);
}
// 判断node是否为元素节点
isElementNode(node) {
return node.nodeType === 1;
}
// 判断是否为v-开头的Vue指令
isDirective(attr) {
return attr.startsWith('v-');
}
compile(fragment){
// 遍历根节点中的子节点
let childNodes = fragment.childNodes;
[...childNodes].forEach(child =>{
if(this.isElementNode(child)){
// 解析元素节点的属性,查看是否存在Vue指令
this.compileElement(child);
// 如果子节点也是元素节点,则递归执行该函数
this.compile(child);
}else{
// 解析文本节点,查看是否存在"{{}}"
this.compileText(child);
}
})
}
// 解析元素
compileElement(node){
// 获取元素节点的所有属性
let attrs = node.attributes;
// 遍历所有属性,查找是否存在Vue指令
[...attrs].forEach(attr =>{
// name: 属性名, expr: 属性值
let {name, value:expr} = attr;
// 判断是不是指令
if(this.isDirective(name)){
let [,directive] = name.split('-');
// 如果为指令则去设置该节点的响应式函数
compileUtil[directive](node, expr, this.$vm);
}
})
}
// 解析文本
compileText(node){
let content = node.textContent;
// 匹配 {{xxx}}
if(/\{\{(.+?)\}\}/.test(content)){
compileUtil['contentText'](node, content, this.$vm);
}
}
// 把节点移动到内存中
node2fragment(node){
// 创建文档碎片
let fragment = document.createDocumentFragment();
let firstChild;
while(firstChild = node.firstChild){
fragment.appendChild(firstChild);
}
return fragment;
}
}
简单介绍一下Compile
这个类构造函数进行的操作:
- 创建内存中的
Dom
对象(创建虚拟Dom
)。首先在构造函数中,将vue
的根节点和vue
实例保存到Compile
实例的属性中,然后创建了一个存在于内存中的Dom
对象,内容和Vue
根节点中的DOM
节点相同。当我们直接去操作Dom
节点的时候,对节点的一些操作会导致浏览器的重新渲染,因此这里将关于Dom
节点的所有内容保存一份到内存中,当对内存中的Dom
对象完成编译(Vue
相关指令,语法的解析)后,再将其直接挂载到Dom
上,这样可以减少浏览器的渲染次数,提高性能。(在本文中,后续将对内存中的Dom
对象称之为虚拟Dom
)。 - 执行
compile
函数。Compile
构造函数将虚拟Dom
的根节点传入这个函数,然后对其子节点进行遍历。
- 当子节点是元素节点的时候,我们首先调用
compileElement
查看该节点的属性是否存在例如v-text
,v-html
这样的Vue
指令,如果存在则对该节点的属性进行检查,查看元素节点的属性是否存在v-
开头的指令,然后将该元素节点传入compile
函数进行递归操作。 - 如果子节点不是元素节点则将其当为文本节点,直接调用
compileText
方法,对文本节点的内容进行{{}}
的匹配。如果文本内容存在{{}}
,则对文本内容进行相应的替换,将{{xxx}}
替换为真正的值。
3. compileUtil 对象
在上面内容中,Compile
类已经有了解析了Vue
指令的功能。解析到的元素节点如果存在Vue
指令,或者解析的文本节点存在形如{{}}
的内容时,在Compile
实例中会调用compileUtil
对象的函数处理解析出来的Vue
指令。由于在compile
我们还没将节点转换为真实视图的Dom
节点,也没有完成当Vue
数据改变,视图随之更新的功能。由此我们可以推断出compileUtils
是一个工具函数的集合,用于帮我们处理Vue
相关的指令。
那么可以尝试编写compileUtil
工具对象:
const compileUtil = {
getValue(expr, vm){
return expr.split('.').reduce((totalValue, key) =>{
if(!totalValue[key]) return '';
return totalValue[key];
}, vm.$data)
},
setValue(expr, vm, value){
return expr.split('.').reduce((totalValue, key, index, arr) =>{
if(index === arr.length - 1) totalValue[key] = value;
return totalValue[key];
}, vm.$data)
},
getContentValue(content, vm){
return content.replace(/\{\{(.+?)\}\}/g, (...args) =>{
return this.getValue(args[1], vm);
})
},
contentText(node, content, vm){
let fn = () =>{
this.textUpdater(node, this.getContentValue(content, vm));
}
let resText = content.replace(/\{\{(.+?)\}\}/g, (...args) =>{
// args[1] 为{{xxx}}中的xxx
new Watcher(vm, args[1], fn);
return this.getValue(args[1], vm);
});
// 首次解析直接替换文本内容
this.textUpdater(node, resText);
},
text(node, expr, vm){
let value = this.getValue(expr, vm);
// 调用函数更新dom内容
this.textUpdater(node, value);
let fn = () =>this.textUpdater(node, this.getValue(expr, vm));
new Watcher(vm, expr, fn);
},
textUpdater(node, value){
node.textContent = value;
},
html(node, expr, vm){
let value = this.getValue(expr, vm);
// 调用函数更新dom内容
this.htmlUpdater(node, value);
let fn = () =>this.htmlUpdater(node, this.getValue(expr, vm));
new Watcher(vm, expr, fn);
},
htmlUpdater(node, value){
node.innerHTML = value;
},
model(node, expr, vm){
let value = this.getValue(expr, vm);
// 初始化表单中的值
this.modelUpdater(node, value);
let fn = () => this.modelUpdater(node, this.getValue(expr, vm));
node.addEventListener('input', ()=>{
this.setValue(expr, vm, node.value);
})
new Watcher(vm, expr, fn);
},
modelUpdater(node, value){
node.value = value;
}
}
在此工具类中,实现了v-text
,v-html
,v-model
以及文本内容中{{}}
的处理。下面简单介绍各个函数的作用:
getValue(expr, vm)
:从Vue
实例的$data
属性中获取obj.name
这样的字符串表达式在$data
中对应的属性值。(形如obj.name
这样的字符串表示Vue
实例中$data
的某个属性值,下文称之为字符串表达式,函数中一般用参数expr
表示)。setValue(expr, vm, value)
: 将value
赋值给字符串表达式对应的Vue
实例$data
上的属性。text(node, expr, vm)
对元素节点的v-text
指令进行处理。textUpdater(node, value)
对元素的文本内容进行更新。html(node, expr, vm)
与text
逻辑类似, 该函数对元素节点的v-html
指令进行处理htmlUpdater(node, value)
与textUpdater
类似,只不过该函数是对元素里面的html
内容进行更新。model(node, expr, vm)
的逻辑与text
逻辑也是类似的,不同的是在表单元素使用v-model
指令后,用户在视图层修改表单元素内容时,用户输入内容需要同步更新到vm.$data
中的相关属性中。因此我们在编写与text
类似的逻辑时,还需要在表单元素上添加一个input
事件监听,当表单元素发生变化时,直接更新vm.$data
上面的相关属性的值。contentText(node, content, vm)
对存在{{}}
的文本节点进行处理。getContentValue(node, vm)
解析形如i am {{name}}
字符串,返回为解析后的文本。
在上面的代码中,我们发现在处理v-text
, v-html
, v-model
等指令的函数时,已经将节点中相应的内容替换成需要在视图中展示的内容。但在每个指令对应的处理函数中都创建了一个Watcher
对象,这个对象是做什么的呢?
在compile
中Vue
的指令解析已经完成,Dom
节点内容的转化我们在CompileUtile
也已经实现,但是还差一个核心功能没有实现:当Vue
实例$data
数据改变时,我们如何在Dom
中更新视图?
我们在compileUtil
编写了很多形如xxxUpdater
的函数,每次调用该方法时,就可以改变真实Dom
的视图内容。这时候我们不禁想到如果每次Vue
中$data
一更新,就有一个工具人帮我们调用这些更新函数改变视图就好了。想到这里我不禁要大喊一句:~~就决定是你了,皮卡丘。~~就决定是你了,Watcher
。因此,我们需要new
出来一个工具人watcher
,帮助我们更新视图。
tips: 后续使用watcher
表示Watcher
实例对象,dep
表示Dep
实例对象,vm
表示Vue
实例。
4. Watcher类
class Watcher{
constructor(vm, expr, cb){
this.$vm = vm;
this.expr = expr;
this.cb = cb;
this.getter();
}
update(){
let newVal = compileUtil.getValue(this.expr, this.$vm);
if(this.value === newVal) return;
this.value = newVal;
this.cb();
}
getter(){
window.target = this;
this.value = compileUtil.getValue(this.expr, this.$vm);
window.target = null;
}
}
上面就是一个简单的Watcher
类,可以看到构造函数中我们传入了Vue
实例,字符串表达式,以及更新节点的函数。在构造函数中,我们将传入的参数保存到实例对象的属性上,然后调用getter
方法。从代码上看,getter
方法将Watcher
实例赋值给了window
这个全局对象的target
上,然后获取了一下expr
表达式在Vue
实例中对应$data
属性中对应的值。然后将window.target
属性置为空。
这是什么操作??我是谁,我在哪,我在干嘛??目前先不解释getter
函数的意义,在讲完下面的Observer
以及Dep
后在回头看这个神奇的黑魔法-v-。
Watcher
中还有一个update
函数,当数据更新时,调用该方法更新视图内容。
到目前为止, new Compile()
做的工作就结束了。总结一下干了哪些事情:
- 找到
Vue
的根节点,将其所有子节点加入到虚拟Dom
中。 - 解析
Vue
根节点中的内容,将包含Vue
指令的节点进行处理,得到真实Dom中的内容。 - 创建工具人
Watcher
实例,可以调用update
方法更新视图。 - 将虚拟
Dom
重新挂载到真实Dom节点上,在视图上显示内容。
我们知道,我们在编写Vue
中的template
模板时(即<div id="app">
以及其子节点的相关内容)时,可能会多次使用某个属性。在最开始我们写的例子中,多次使用了name
这个属性。节点每次使用同一个属性时,我们都会创建一个工具人Watcher
,当工具人们关注的女神name
属性变化时,我们需要一个经纪人通知所有的工具人,女神有事情需要你们帮忙,快去干活。这个经纪人就是Dep
实例。
5. Dep类
class Dep{
constructor(){
this.subs = [];
}
addSubs(){
this.subs.push(window.target);
}
notify(){
this.subs.forEach(watcher => watcher.update());
}
}
Dep类的构造函数:创建一个数组,用于保存Watcher
实例。Dep
中存在两个方法:
addSubs
用于添加watcher
到subs
数组中。notify
遍历自己实例中存储的subs
数组的每一个watcher
,执行update
函数更新相关的视图。
根据上面编写的代码,我们已经将问题转化成这样: 当数据发生变化的时候,如何去通知所有使用该数据的watcher
调用响应的update
方法呢?在这里其实是两个问题需要解决:
- 如何收集所有使用某个相同数据的
watcher
,对其进行统一的更新处理。即我们如何实现数据的依赖收集 - 如何在数据发生改变时,将收集到
watcher
依赖全部更新。
第二个问题,经纪人Dep
实例已经解决了。我们使用Dep
类收集相关的watcher
,然后对其进行统一的更新操作。还剩问题一待解决。
6. Observer类
在上一节中,我们现在还差最后一步,就可以完成数据的响应式功能:在何处,何时创建Dep
实例去收集watcher
?
在此之前,我们首先要了解一下Object.defineProperty()
这个核心方法,MDN文档对该方法的介绍是这样的:
Object.defineProperty(obj, prop, descriptor)
方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。
参数:
obj
: 要定义属性的对象prop
: 要定义或修改的属性的名称或Symbol
descriptor
: 要定义或者修改的属性描述符
前两个属性很好理解,就是填入某个对象和它的属性。重点就是descriptor,它是一个对象,包含:configurable
, enumerable
, value
, writable
, get
, set
六个描述符。我们今天主要使用其中的get
和set
,其余的属性如果有兴趣可以去MDN上面查看文档学习 :)
写一个简单Demo了解get
和set
描述符的作用:
let obj = { name: 'Evan', age: 22};
Object.defineProperty(obj, 'age', {
get(){
return "don't ask my age";
}
})
console.log(obj, obj.name, obj.age);
// { name: 'Evan', age: [Getter] } 'Evan' 'don\'t ask my age'
obj.age = 18;
console.log(obj, obj.name, obj.age);
// { name: 'Evan', age: [Getter] } 'Evan' 'don\'t ask my age'
let obj2 = { name: 'lucy', age: 30};
let frozenAge = 18;
Object.defineProperty(obj2, 'age', {
get(){
return frozenAge
},
set(newVal){
if(newVal > 18) return
frozenAge = newVal;
}
})
console.log(obj2, obj2.name, obj2.age);
// { name: 'lucy', age: [Getter/Setter] } 'lucy' 18
obj2.age = 30;
console.log(obj2, obj2.name, obj2.age)
// { name: 'lucy', age: [Getter/Setter] } 'lucy' 18
obj2.age = 10;
console.log(obj2, obj2.name, obj2.age)
// { name: 'lucy', age: [Getter/Setter] } 'lucy' 10
在上面的代码中,我们能够了解到get
和set
的作用:
get
: 获取obj.key时,调用get
函数,函数返回值即为该属性的值,这个值会取代原有对象的值。set
: 当设置obj.key时,调用set
函数,set
函数的参数则为想要设置的值。
看到get
和set
时,有没有眼前一亮:
- 当
set
触发时,我们可以调用dep
的notify
方法,更新视图。 - 当
get
触发时,我们可以让dep
去收集watcher
这不就是我们想要的,收集依赖的地方和触发更新的地方吗? 由此可以尝试编写如下函数:
function defineReactive(obj, key){
let dep = new Dep();
let value = obj[key];
Object.defineProperty(obj, key, {
get(){
if(window.target){
dep.addSubs();
}
return value;
},
set: (newVal) =>{
// 当新值和老值相同时
if(value === newVal) return;
value = newVal;
dep.notify();
}
})
}
看到这里我们终于再次看到了window.target
这个对象,大家还记得在哪里使用了它吗,没错!就是Watcher
类里面的getter
方法,当时说后面在讲它的作用,就是在这里QAQ。简单来说,window.target
就是触发依赖收集的条件,如果在某个未知的地方获取到了Object.defineProperty
中设置过get
的某个object
的属性的值,那我们就有了Dep
收集依赖的第一个条件:触发get
,其次我们还需要第二条件进行筛选,就是Window.target
的值不为空,不为空那它是什么呢,当然是我们的watcher
:-D。为什么我们需要第二个条件呢?在之前的分析过程中,我们知道dep
中的subs
需要存储的是watcher
,用于更新视图,但是在实际过程中,有多个地方需要获取vm.$data
中的某个属性(例如methods
中的方法会获取data
多个属性的值),因此会触发set
函数但是我们没有必要去收集watcher
。
由于js是单线程的,不存在当创建一个watcher
的时候,其他的地方刚好触发了相关的set
从而依赖错误收集,所以我们在new一个watcher
的时候 调用getter
方法, 先将window.target
指向wathcer
, 然后去获取一下expr
对应的vm.$data
中的相关值,触发Object.defineProperty
中的get
方法,让dep
把watcher
收集到subs
属性中,最后将window.target
置空,完成数据依赖的收集。这样在对应的数据发生变化时,dep.subs
中就存在所有的watcher
,可以调用dep
的notify
方法,更新所有依赖该数据的watcher
,完成视图的更新。
总结:和女神打交道的人不是全都是女神的舔狗,如果window.target
有值,才标志着你是舔狗,那好兄弟dep
就会记住这个工具人。
这样,我们还差一步,就是递归遍历vm
中的$data
将其中的所有的属性,利用Object.defineProperty
方法将其变为响应式。因此我们回到最开始Vue
的构造函数,其中执行了了new Observer
:
class Observer{
constructor(data){
this.$data = data;
this.observer(this.$data);
}
observer(obj){
if(typeof obj !== 'object') return;
Object.keys(obj).forEach(key =>{
this.defineReactive(obj, key, obj[key]);
})
}
defineReactive(obj, key, value){
if(typeof value === 'object') this.observer(value);
let dep = new Dep();
Object.defineProperty(obj, key, {
get(){
if(window.target) {
dep.addSubs();
}
return value;
},
set: (newVal) =>{
if(value === newVal) return;
// 防止 newVal为对象的情况,需要重新将对象中的属性变为响应式
this.observer(newVal);
value = newVal;
dep.notify();
}
})
}
}
到此为止,我们的双向数据绑定的简单实现已经完成了,还剩下computed
,v-on
(或者说@xxx
),以及一些细节还未实现。
3. 对简化版vue进行完善
在上述的章节中,我们实现了一个简单版本的Vue双向数据绑定,接下来我们对其功能进行一些简单的扩展。
1. 对于Vue
中$data
属性值的获取
在Vue
中,我们写JS代码调用Vue中$data
都是直接使用形如this.name
的方式,而不是类型this.$data.name
的形式,因此,我们可以在Vue
的构造函数中做一个简单的代理,this.name
代理到this.$data.name
上,在构造函数中添加proxyVm
方法
// 把数据 全部用Object.defineProperty
new Observe(this.$data);
// 把数据获取操作,vm上的取值操作 都代理到vm.$data;
this.proxyVm(this.$data);
new Compiler(this.$el, this);
proxyVm
的具体实现如下:
proxyVm(data){
for(let key in data){
Object.defineProperty(this, key, {
get(){
return data[key];
},
set(newVal){
data[key] = newVal;
}
})
}
}
2. Vue
中computed
属性的实现
我们知道,在computed
中,我们可以编写相关方法,方法中所有使用到的数据任意一个发生变化时,我们都会重新计算相关的值,并且更新视图。
这个时候,我们想一下,我们所实现的Watcher
的getter
方法正是收集依赖的地方,如果我们在getter
方法中,将window.target
指向为自己后,执行一个函数,那么是不是会将自己加入到getter
方法中该函数所使用的所有数据的相关Dep
的subs
中,当其中某个数据更新了,我们调用Dep
实例的notify
方法都可以调用到Watcher
实例自己的update
方法更新视图。想到这里,我们貌似知道了computed
的实现方法,我们需要将Watcher
稍微改造一下
class Watcher{
constructor(vm, expr, cb){
this.$vm = vm;
this.expr = expr;
this.cb = cb;
this.get = typeof expr === 'function' ? expr : () => compileUtil.getValue(this.expr, this.$vm);
this.getter();
}
update(){
let newVal = this.get();
if(this.value === newVal) return;
this.value = newVal;
this.cb();
}
getter(){
window.target = this;
this.value = this.get();
window.target = null;
}
}
然后需要将vm.$options.computed
中的所有属性通过代理,让我们和data
一样 可以直接在vue
实例上访问。因此在Vue构造函数中调用proxyVm(this.$options.computed)
将其代理,然后我们通过代理让data
和computed
的属性通过实例可以直接访问,那么模板中遇到{{this.count}}
时可能为computed
也可能为data
上的属性,因此我们将compileUtil.getValue
中的reduce
方法中的参数从vm.$data
换成vm
.
3. 简单实现@
指令绑定方法
在本次内容中,我们实现一个@
方法,看到@
,我们知道这时v-on
的缩写,在解析时,我们要去解析特殊字符
首先在Compile
类中修改compileElement
方法,然后在类中添加compileSpecificDirective
方法。
compileElement(node){
// 获取元素节点的所有属性
let attrs = node.attributes;
// 遍历所有属性,查找是否存在Vue指令
[...attrs].forEach(attr =>{
// name: 属性名, expr: 属性值
let {name, value:expr} = attr;
// 判断是不是指令
if(this.isDirective(name)){
let [,directive] = name.split('-');
// 如果为指令则去设置该节点的响应式函数
compileUtil[directive](node, expr, this.$vm);
}
// 解析特殊指令,比如"@", ":"
this.compileSpecificDirective(node, name, expr);
})
}
compileSpecificDirective(node, name, expr){
if(name.trim().startsWith('@')){
compileUtil['on'](node, name.trim().substr(1), this.$vm, expr);
}
}
然后再compileUtil
添加一个on
方法
on(node, eventName, vm, expr){
let fn = vm.$options.methods[expr].bind(vm);
node.addEventListener(eventName, fn);
}
这样一个简单的@指令就被我们实现了(没有实现带参数的情况的,实在是想偷个懒… ^_^ )
在这里我们可以理一理实现这个简单版vue.js
都干了什么
到现在为止,最开始在html中编写的内容,我们使用
mvvm.js
也可以实现。完整代码内容点这里
4. 源码阅读
上面,我们手写了一个炒鸡简单的Vue,甚至实现的功能都没有写全。这个时候,当然要去正版Vue看下Vue的具体实现,git的地址如下:Vue源码地址。我们直接查看src目录,文件目录结构如下:
本次主要是查看和双向数据绑定相关的observer
模块,以及Vue
构造函数相关的instance
模块。首先我们找到src/core/instance/index.js
。该文件内容如下:
在Vue构造函数中,我们执行了
this._init
该方法来自initMixin(Vue)
,因此我们继续深入查看initMixin(Vue)
,该函数在src/core/instance/init.js
中。
Vue.prototype._init
的核心代码:
由于这次我们主要关注的核心点在数据的初始化,因此我们继续深入到initState
函数查看Vue
初始化进行的操作,该方法来自src/core/instance/state.js
:
我们可以看到在个函数中,对Vue实例中传入的
props
,methods
,data
,computed
,watch
都做了相应的初始化处理。依次查看它们的初始化:
1. initProps
函数
在该函数中,最核心的部分就是
defineReactive
,将props
中每个key
遍历成响应式。defineReactive
在initData
中会讲述,因此这里不过多分析。
2. initMethods
初始化
vm
中的方法则更为简单,将options
中methods
的所有属性方法放一份到vm
中。其中要注意的就是要将这些方法的this
指向vm
。
3. initData
initData
方法则是这次源码分析的重头戏,我们可以看下在Vue中是如何对data
做相应式处理的
在这里我们可以看到,initData主要做了两件事情:
proxy
: 让data
中的属性可以通过vm.xx
直接访问。observer
: 将data
数据变为响应式数据。
因此我们继续深入到observer
方法,该方法来自src/core/observer/index
:
该方法时首先会检查
value
的类型,确定value
是对象并且不是VNode的实例对象,在进行初始化时,函数会执行ob = new Observer(value)
,这里发现了一个熟悉的构造函数~~。前面我们也写过的Observer
对象。查看Vue
中的Observer
跟我们写的有什么区别:
在
OBserver
构造函数中,创建了Dep
实例并保存到自己的属性dep
中,将value
保存到自己的value
属性中,然后将自己(Observer
实例)放置到value
对象的__ob__
属性上(由此可以推断,在Vue
中一个对象如果有__ob__
属性,则它是一个响应式对象)
最后通过Array.isArray
判断value
是否是一个数组,如果是数组则执行数组的响应式处理,为什么要将数组单独从对象中提取出来做特殊处理呢? 在数组中,存在push
,pop
,splice
等改变原数组的原型方法,然而Object.definePrototype
对于对象做的响应式处理在数组执行这些改变数组原型方法时无法对其进行响应,更新相关的视图。
Vue3用proxy
代替Object.defineProtptype
重写数据的响应式,proxy
的一个好处就是对数组的支持比Object.defineProtptype
更好。(后面有空的话,再去看下Vue3,写一篇关于proxy
的文章)。
如果value
不是数组(即value
为对象),执行walk
方法。下面我们先查看对象响应式处理的walk()
方法,随后查看对数组进行的响应式的处理。
1. 对象的响应式处理:walk()
walk
方法的实现非常简单,遍历对象的属性,对每个对象的每个属性调用defineReactive
方法,在initProps
时我们也调用了该方法,接下来,我们可以查看这个方法究竟做了什么:
- 首先每次调用该方法都会创建一个
Dep
,该对象用于收集该属性的相关依赖 - 获取该属性在对象上对应的值,将其赋值给
val
,然后调用observer(val)
(shallow
为defineReactive
的第五个参数,在这里为undefined
),如果val
也是对象。则也会递归遍历val
中的属性,将其设置为响应式对象。在前面observer
方法会返回一个Observer
实例。故当val
为对象时这里的childOb
为val
对象上的Observer
实例,当val
为基本类型时,则childob
为undefined
. - 调用
Object.defineProperty()方法
。查看其get
和set
方法
get
: 首先在函数中,我们获取了一下obj
上属性对应的值并赋值给value
,随后我们判断Dep.target
是否有值(和我们写的window.target
一样,只不过Dep.target
是将target
放到Dep
的原型上),如果存在值的,就满足触发get
和Dep.target
两个条件我们可以去收集相关watcher
,然后会去判断childOb
是否存在。如果存在我们还需要建立childOb
上的dep
与当前触发get
的watcher
的联系:Object.definePrototype
无法响应对象属性的新增和删除,无法响应到数组length属性的变化以及数组原型方法改变原数组的几个方法,因此响应这些变化需要将childob.dep也收集一下Watcher的实例的依赖,方便后续的更新操作。set
: 和set
一样,首先获取value
,然后判断设置的新值与原来的值是否不一样,一样则直接返回。如果不一样则将新值赋值给val
(或者调用setter更新),防止新的val
是对象的情况,我们需要对其调用observe
将其变为响应式对象。最后通知在defineReactive
中创建的dep
去通知相关依赖更新视图。
这里我们可以查看Vue中Dep
和Watcher
是如何实现的:
Dep:
// src/core/observer/dep.js
// 部分与本章无关代码删除
let uid = 0;
export default class Dep {
static target: ?Watcher;
id: number;
subs: Array<Watcher>;
constructor () {
this.id = uid++
this.subs = []
}
addSub (sub: Watcher) {
this.subs.push(sub)
}
depend () {
if (Dep.target) {
Dep.target.addDep(this)
}
}
notify () {
const subs = this.subs.slice()
for (let i = 0, l = subs.length; i < l; i++) {
subs[i].update()
}
}
}
在Dep
的构造函数中,每次创建的新的dep
都会获取到该dep
的唯一id
,并且初始化自己的subs
将其定义为空数组。
在上述代码中有notify
,depend
, addSub
三个方法。
notify
方法就是将subs
数组中收集的watcher
实例依次执行相关的update
函数更新视图。addsub
: 将参数添加到subs
数组。depend
: 调用Dep.target.addDep
方法。(Dep.target
即为watcher
) 在defineReactive
中定义的Object.definePrototype
的get
方法中调用的是depend
方法而不是addSub
。明明要收集依赖,为什么又去Dep.target
(即watcher
)中调用addDep()
方法呢? 我们去Watcher
类中查看其定义:
Watcher:
// src/core/observer/watcher.js
// 部分与本章无关代码删除
export default class Watcher {
constructor (
vm: Component,
expOrFn: string | Function,
cb: Function,
options?: ?Object,
isRenderWatcher?: boolean
) {
this.vm = vm
if (isRenderWatcher) {
vm._watcher = this
}
vm._watchers.push(this)
this.cb = cb
this.id = ++uid // uid for batching
this.deps = []
this.newDeps = []
this.depIds = new Set()
this.newDepIds = new Set()
// parse expression for getter
if (typeof expOrFn === 'function') {
this.getter = expOrFn
} else {
// parsePath为解析路径函数,返回一个函数。返回的函数可得到exprOrFn对应的值
this.getter = parsePath(expOrFn)
}
// 触发依赖收集
this.value = this.lazy
? undefined
: this.get()
}
get () {
// 将自己加入到Dep.target上
pushTarget(this)
let value
const vm = this.vm
try {
// 获取value的值,触发Object.definePrototype中设置的set函数
value = this.getter.call(vm, vm)
} catch (e) {
if (this.user) {
handleError(e, vm, `getter for watcher "${this.expression}"`)
} else {
throw e
}
} finally {
if (this.deep) {
traverse(value)
}
// 将自己从Dep.target上删除
popTarget()
this.cleanupDeps()
}
return value
}
addDep (dep: Dep) {
const id = dep.id
// 如果没有建立和dep之间关系
if (!this.newDepIds.has(id)) {
// 则建立watcher和dep关系
this.newDepIds.add(id)
this.newDeps.push(dep)
// 反向建立dep和watcher关系
if (!this.depIds.has(id)) {
dep.addSub(this)
}
}
}
tips: 在Vue2中,watcher实例每个组件一个watcher,在之前我们写的地方我们是在Compile
解析模板时每次使用data
属性的时候会创建一个watcher,这样会创建大量的watcher对象影响性能,因此在vue2中每个组件创建一个watcher然后再组件内部使用虚拟Dom进行diff算法,然后重新渲染页面
看到这里我们有了一个大致的思路,在Vue
中,我们首先在将数据变为响应式,然后再解析模板的时候将根据组件创建watcher
,再创建watcher
的过程中,调用get
方法去触发依赖收集,然后再dep
中调用当前watcher
的addDep
方法将dep
中的id
以及dep
自己加入到当前的watcher
中存起来,如果dep
中没有添加当前wather
,调用dep.addSub(this)
将watcher
加入到dep.subs
属性中。
看起来很绕,本质上就是在dep
中,我们去收集watch
。反过来,我们也要去了解哪些dep
将watch
收集了,dep
和watcher
是一种多对多的关系:一个数据可能在多个组件中被使用,故一个dep
中可能存多个watcher
。同时比如我们再使用计算属性的时候,一个函数可能用到多个属性,该组件内的watcher
可能被多个属性的dep
实例所收集。
2. 数组的响应式处理
对数组做的响应式处理用的是protoAugment(value, arrayMethods)
方法,该方法直接将arrayMethods
方法赋值给数组实例的__proto__
上。那么继续查看arrayMethods
是什么,代码位置:src/core/observer/array
:
import { def } from '../util/index'
const arrayProto = Array.prototype
// 创建一个全新对象,克隆自数组原型对象
export const arrayMethods = Object.create(arrayProto)
const methodsToPatch = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
]
methodsToPatch.forEach(function (method) {
// cache original method
// 数组原型方法
const original = arrayProto[method]
// 改变数组原型方法,定义新方法
def(arrayMethods, method, function mutator (...args) {
// 执行数组原始方法
const result = original.apply(this, args)
// 获取ob实例(Observer实例)
const ob = this.__ob__
let inserted
switch (method) {
case 'push':
case 'unshift':
inserted = args
break
case 'splice':
inserted = args.slice(2)
break
}
// 新添加的数组项如果是对象则进行响应式处理
if (inserted) ob.observeArray(inserted)
// 获取dep通知watcher更新
ob.dep.notify()
return result
})
})
arrayMethods
对数组原型方法上会改变原数组的方法进行了扩充,将其重新定义,当使用push
,pop
等方法时,除了调用数组原型上的该方法,还对其做了额外处理:
- 当添加新元素时,对新元素做响应式处理。
- 调用
ob
上的dep
实例,通知watcher
更新视图.
tips: 由于对数组中每一项调用的是observeArray
方法,该方法对数组的每一项进行observer(item)
的操作,因此当数组内容为基本数据类型时,直接改变它的值,视图是不会更新的~。
4. initComputed
核心代码:
computed
的初始化主要是将computed
中对应的每个方法创建一个watcher
,然后将每个方法作为exprOrFn
传入Watcher
。在前面实现简单的computed
功能时,已经知道watcher
传入的expr
为函数则可以实现computed
的功能,然后由于computed
还存在缓存功能,这里我们不过度深入,大家有兴趣的可以去看下源码。
5. initWatch
initWatch
代码如下:
function initWatch (vm: Component, watch: Object) {
for (const key in watch) {
// 将handler作为Watcher构造函数的cb
const handler = watch[key]
// 如果handler是一个数组。则创建多个Watcher
if (Array.isArray(handler)) {
for (let i = 0; i < handler.length; i++) {
createWatcher(vm, key, handler[i])
}
} else {
// 当handler不是数组
createWatcher(vm, key, handler)
}
}
}
function createWatcher (
vm: Component,
expOrFn: string | Function,
handler: any,
options?: Object
) {
// 如果handle是对象,则取handler函数,并得到传入的其他选项
if (isPlainObject(handler)) {
options = handler
handler = handler.handler
}
// 如果handler是字符串,尝试在vm上获取vm[handler]方法
if (typeof handler === 'string') {
handler = vm[handler]
}
// 调用Vue.prototype.$watch 创建`watcher`
return vm.$watch(expOrFn, handler, options)
}
Vue.prototype.$watch = function (
expOrFn: string | Function,
cb: any,
options?: Object
): Function {
const vm: Component = this
if (isPlainObject(cb)) {
return createWatcher(vm, expOrFn, cb, options)
}
options = options || {}
options.user = true
// 创建Watcher实例,实现watch功能
const watcher = new Watcher(vm, expOrFn, cb, options)
if (options.immediate) {
try {
cb.call(vm, watcher.value)
} catch (error) {
handleError(error, vm, `callback for immediate watcher "${watcher.expression}"`)
}
}
return function unwatchFn () {
// 返回取消观察的函数
watcher.teardown()
}
}
可以看到watch
属性内部其实也是利用watcher
来实现的,只不过computed
创建的watcher
,在构造是将函数传入Watcher
构造函数的exprOrFn
作为其观察对象, 而watcher
则将vm[key]
作为观察对象,传入Watcher
构造函数的函数作为更新方法。
Vue.$set
和Vue.$delete
在上面的内容中,讲述过Object.definePrototype
有一定的局限性,就是当我们在一个对象上面添加新的属性或者删除属性时,该方法的set
和get
是无法知道的,同样,在数组中使用该方法监听数组的每一项内容,当时数组调用自己的原型方法或者修改数组实例的length
属性。该方法也无法相应。特别的,在Vue
中,直接修改数组的内容,Vue无法实现更新。
这个时候,Vue
提供了$set
和$delete
来实现对数组的更新以及对对象属性的添加和删除。
我们来查看其内部实现:
// Vue.prototype.$set
export function set (target: Array<any> | Object, key: any, val: any): any {
if (Array.isArray(target) && isValidArrayIndex(key)) {
target.length = Math.max(target.length, key)
target.splice(key, 1, val)
return val
}
if (key in target && !(key in Object.prototype)) {
target[key] = val
return val
}
const ob = (target: any).__ob__
if (!ob) {
target[key] = val
return val
}
defineReactive(ob.value, key, val)
ob.dep.notify()
return val
}
// Vue.prototype.$delete
export function del (target: Array<any> | Object, key: any) {
if (Array.isArray(target) && isValidArrayIndex(key)) {
target.splice(key, 1)
return
}
const ob = (target: any).__ob__
if (!hasOwn(target, key)) {
return
}
delete target[key]
if (!ob) {
return
}
ob.dep.notify()
}
Vue.prototype.$set
:- 首先去判断传入的
target
参数是否为数组,如果是数组则在内部调用我们扩展过的splice
原型方法对其进行更新,结束函数。 - 如果不是数组,则判断
target
上是否存在key
属性,存在的话将参数val
直接赋值给target[key]
,然后结束。 - 尝试获取
target
上的__ob__
属性。如果该属性不存在,则说明该对象不是响应式对象,直接将value
赋值给target[key]
- 如果
target
上的__ob__
属性存在,则调用defineReactive
将obj.key
变为响应式属性,并且将val作为该属性的初始值。最后调用ob.dep.notify()完成更新。
- 首先去判断传入的
在上述内容中,可以知道observer
方法中为每一个对象创建一个__ob__
其实是十分有必要的,在我们为对象添加新属性或者删除属性时都需要利用__ob__.dep
去通知watcher
更新。(在数组的splice
中也是利用__ob__.dep
去通知更新的)
Vue.prototype.$delete
:- 判断传入的
target
参数是否为数组,如果是数组则在内部调用我们扩展过的splice
原型方法对其进行更新,结束函数。 - 如果不存在
target[key]
,则直接返回结束函数。 - 删除
target[key]
,如果target
上不存在__ob__
则说明target不是响应式对象,结束函数。如果存在target
,则调用__ob__if,.notify()
更新视图。
- 判断传入的
写在最后
本文参考内容:
掘金小册: 《剖析 Vue.js 内部运行机制》 -染陌同学
《深入浅出Vue.js》 -刘博文
vue.js源码:https://github.com/vuejs/vue
去年就在掘金申请帐号了,想着每个月输出一点东西(求关注~),今年终于强迫自己踏出了第一步,算是走出舒适区的第一步。
各位走过路过的朋友们觉得写的文章还行就点点赞叭~~觉得写的很垃圾也 点个赞鼓励下叭 呜呜呜。
最后找一个名言装下X,开溜。
The farther behind i leave the past, the closer i am to forging my own character.
我把过去抛得越远,便越接近于我自己锻炼的自我。
版权声明:著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 作者: 真难取名字 原文链接:https://juejin.im/post/6861471544200462350