这篇文章通过简单地剖析源码的方式来理解vue的响应式原理。因为仅仅为了了解其原理,所以简单写了一下响应式大致的原理。还有很多边界情况和实际问题的处理和源码有出入,比如没有使用DOMdiff算法,没有递归劫持数据等。
本文所有代码-github,每小节都对应一个commit
vue、react都是单向数据流,vue双向绑定只是语法糖,react也可以实现双向绑定。
Vue 的双向绑定基于数据劫持+观察者模式实现。
对象内部使用 Object.defineProperty 将属性进行劫持(只会劫持已经存在的属性)。基于观察者模式,当页面使用对应属性时,每个属性都拥有自己的 dep 属性,存放他所依赖的 watcher (依赖收集),当属性变化后会通知自己对应的 watcher 去更新(派发更新)。
整体结构
首先我们使用vue时都会新建一个vue实例
let vm = new Vue({
el:'#app',
data:{
name:"涛涛"
}
});
所以我们写一个vue类。
- 将
options中el和data挂载到实例上。私有属性$el对应的是App元素,私有属性$data对应的是传入的data - 有两个函数需要注意,一个是
observe(this.$data)函数,用来劫持数据,所以要把data传入函数中 - 另一个函数是
nodeToFragment(this.$el,this),负责模板编译(渲染数据),借助Fragment,编译vue语法中的行内属性或小胡子语法等。 - 最后在劫持数据与模板编译的过程中,使用观察者模式,去依赖收集、派发更新
function Vue(option){
// 私有属性 $el 对应的是App元素
// 私有属性 $data对应的是传进来的data
this.$el = document.querySelector(option.el)
this.$data = option.data
// 先去劫持数据
observe(this.$data)
nodeToFragment(this.$el,this) // 负责模板编译(渲染数据)
// 借助Fragment,编译vue语法中的行内属性或小胡子语法等
}
我们先不管如何基于观察者模式进行数据与视图的双向绑定的,我们先看数据劫持和模板编译大概是什么样的过程。
先看数据劫持
数据劫持
对象内部使用 Object.defineProperty 将属性进行劫持。
observe 函数代码如下(这里为了简化,没有进行递归得进行数据劫持,也没有考虑数组的情况):
function observe(data){
// 判断 data是否是一个对象
if(({}).toString.call(data) !== '[object Object]')return ;
let keys = Object.keys(data);// 遍历所有属性,然后对每一个属性进行劫持
keys.forEach(key=>{
defineReactive$$1(data,key,data[key])
})
}
function defineReactive$$1(target,key,val){
Object.defineProperty(target,key,{
enumerable:true,
get(){
return val
},
set(newV){
val = newV
}
})
}
模板编译
nodeToFragment 模板编译。过程:
- 整体过程是借助文档碎片,把文档上的所有节点的vue语法编译完,转移到文档碎片上,然后将文档碎片再转移回$el,完成模板编译的过程
- 使用
while循环遍历所有的节点,对节点进行操作,并将其转移到fragment上,原理是每转移一次,就会少一个节点,下一个节点会变成第一个,最后没有节点的时候,firstChild会返回null,循环结束
- 使用
compile编译函数原理:先要判断 节点的类型,看他是元素节点还是文本节点(nodeType1元素节点,3文本节点)。如果是元素节点我们要考虑是行内属性和他的子节点,文本节点直接进行小胡子语法替换即可。以v-model和小胡子语法的编译为例:- 元素节点处理的是行内属性 ,除了行内属性,还要递归遍历编译子节点。 代码如下
function nodeToFragment(el,vm){
// 借助文档碎片,把文档上的所有节点的vue语法编译完,转移到文档碎片上,然后将文档碎片再转移回$el,完成模板编译的过程
let fragment = document.createDocumentFragment();
let child;
//循环遍历所有的节点,对节点进行操作,并将其转移到fragment上
// 每转移一次,就会少一个节点,下一个节点会变成第一个,最后没有节点的时候,firstChild会返回nul,循环结束
while(child = el.firstChild){
//编译node节点
compile(child,vm)
fragment.appendChild(child)
}
el.appendChild(fragment)
}
function compile(node,vm){
// 编译 node节点.
// 原理:先要判断 节点的类型,看他是 元素节点还是文本节点(nodeType1元素节点 3文本节点 8注释节点 9根节点)
// 元素节点我们要考虑是 行内属性和他的子节点
// 文本节点直接进行小胡子语法替换即可
//例如编译v-model和小胡子语法的处理:
if(node.nodeType == 1){
//元素节点
let attrs = node.attributes; // 获取所有的行内属性
[...attrs].forEach(item=>{
// 获取 v-开头的行内属性 v-model (v-model="name")
console.dir(item)
if(/^v-/.test(item.nodeName)){ //"v-model"
//使用正则取到取到 v-model对应的个值
let vName = item.nodeValue ;// "name"
let val = vm.$data[vName] ;// 获取到了 "涛涛" 这两个字
node.value = val;// 把 "涛涛"这两个字 放到 input框中
}
});
// 以上处理的是行内属性 ,除了行内属性,还要递归遍历编译子节点
[...node.childNodes].forEach(item=>{ // 递归编译 子节点
compile(item,vm)
})
}else{
// 文本节点 我们需要获取对应的文本字符串 然后把里边的小胡子转成真实Data
let str = node.textContent;// "{{name}}"
if(/{{(\w+)}}/.test(str)){//正则解析小胡子语法
str = str.replace(/{{(\w+)}}/,(a,b)=>{
// b ---> name属性
return vm.$data[b]//返回name属性的data 值
})
console.log(str)
node.textContent = str;//替换小胡子语法
}
}
}
此时vue模板已被成功编译
使用观察者模式添加响应式
基于观察者模式,当页面使用对应属性时,每个属性都拥有自己的 dep 属性,存放他所依赖的 watcher (依赖收集),当属性变化后会通知自己对应的 watcher 去更新(派发更新)。
什么是观察者模式
可以看看我以前的文章js设计模式-观察者模式
-
定义观察者
observer(Watcher):形式可以不一样,只需要观察者具备update方法即可,用来接收到消息的时候做出反应 -
定义目标
Subject(Dep):目标用来管理观察者,将具有update方法的观察者加入到观察者列表,observerList中 -
当目标发送信息(
notify)的时候,可以把消息传给观察者,观察者触发update方法即可
所以
//创造一个订阅器(目标)
class Dep{
// 每一个属性 都应该有自己的订阅器
constructor(){
this.subs = [];//观察者池
}
addSub(sub){
this.subs.push(sub)//添加观察者
}
notify(){// 负责通知事件池中的事件执行,通知观察者执行update方法
this.subs.forEach(sub=>{
// sub是watcher实例,在new Watch()依赖收集的的时候,被加进来的
sub.update()
})
}
}
//创造一个订阅者(观察者),进行依赖收集
class Watcher{
constructor(node,key,vm){
console.log('watcher被触发了,依赖收集,将watcher对象添加到目标池子中')
Dep.target = this;// this就是watcher实例,当
this.node = node;
this.key = key;
this.vm = vm;
// 因为进行过数据劫持,所以会触发我们的get函数
//这个代码触发数据的get,可以让我们把 当前的这个watcher实例(也是Dep.target)放到对应的事件池中(依赖手机)
this.getValue();
Dep.target = null
}
update(){
console.log('修改了data,触发set,watcher的update被调用了,更新DOM')
// 负责更新DOM
this.getValue();// 获取新的value值
if(this.node.nodeType == 1){
// 只考虑input框
this.node.value = this.value
}else{//文本节点
this.node.textContent = this.value
}
}
getValue(){
this.value = this.vm.$data[this.key];// 因为进行过数据劫持,所以会触发我们的get函数
}
}
响应式过程
所以整个流程是
-
编译阶段使用
watcher进行依赖收集,把所有用到data的地方记录下来(例如小胡子语法,或者行内属性v-model),放到每一个data属性的目标(sub)的池子里。 -
当修改数据的时候,例如在input框中输入数据,触发
vm.$data[vName] = e.target.value,或者直接修改数据vm.$data[vName] = 'xxx',那么就会触发set,watcher的update被调用,更新DOM
总结
Vue 的双向绑定在劫持数据与模板编译的过程中,使用观察者模式,去实现的
data对象内部使用Object.defineProperty递归地将属性进行劫持。基于观察者模式,每一个被劫持的属性都有一个目标池,用来存放收集到的watcher,就是观察者,然后设置属性的get和set。- 当模板编译到例如
v-model或者小胡子语法的时候,就会触发get,这个时候会将watcher收集到每一个属性的目标池中。这个过程就是依赖收集 - 当修改数据的时候,例如输入框中进行输入,或者直接修改数据,就会触发
set,这个时候会将目标池中的所有watcher的update方法调用,更新DOM,这个过程就是派发更新
本文所有代码-github,每小节都对应一个commit