详解Vue中双向绑定原理及简单实现
作者:彩虹修狗
监听器
vue实现双向绑定时,首先要实现目标data的监听(通过 Object.defineProperty 来实现)
(1)遍历整个data,对data下面所有的key进行Object.defineProperty来监听,然后获取监听key的get,set事件。
(2)对每一个key进行Object.defineProperty监听的时候,都单独的创建一个Dep类用于收集和触发依赖,后续的更新函数的触发和收集都会存储在每一个key对应的Dep实例中。
// 数据监听,监听对象的所有get和set function observe(data) { // 如果data不存在或data不是object if (!data || typeof data !== 'object') { return; } // 遍历data,获得当前的key for (const key in data) { // 监听data下的当前key的属性变动 defineReactive(data, key, data[key]); // 一次只处理data和一个key的监听关系 } } // 通过Object.defineProperty去监听data下的key function defineReactive(data, key, value) { // 处理key对应的value是一个对象的情况 observe(value) // 创建Dep() 实例 用于存储key的依赖,以及触发key的依赖 let dep = new Dep() Object.defineProperty(data, key, { // 返回value值 get: function () { // get的时候进行依赖收集 if(Dep.target) { // Dep.target默认为null,不会收集依赖,但是创建watcher实例的时候 // 会为Dep.target赋值,并指向当前的watcher实例,同时会为watcher挂载上update函数 // 然后get的时候会出现触发依赖收集,因为这时候Dep.target指向当前的watcher实例,就可以将这个watcher实例收集起来 dep.addSub(Dep.target) } return value }, set: function (newValue) { // get的时候触发依赖 val = newValue; // 依赖触发,遍历依赖里面的watcher并调用update函数 dep.notify(newValue) } } )} // 创建Dep类,用来存储依赖 class Dep{ constructor() { this.subs = [] // 定义一个subs数组用来存放依赖 } addSub(sub) { // get的时候存储依赖 // 注:更新函数会被挂载到单独创建的watcher实例,存储依赖的时候,实际上存储的是创建的watcher实例 this.subs.push(sub) } notify(newValue) { // set的时候触发依赖 // 注: 遍历依赖,开始执行里面watcher实例的更新函数 this.subs.forEach(item => { item.update(newValue) }) } target = null }
订阅器
创建订阅器,订阅器作用于观察器后面,实际上订阅器会被挂载到被监听的key的依赖上。(细品)
会缓存上一个值,以及提供update方法(set的时候执行)
(1) 创建实例的时候会暂存监听的节点和监听的数据对象等信息,并创建update函数(用于处理数据更新等回调(2) 初始化的时候会调用订阅器实例的get方法,并设置Dep构造函数的target属性指向当前的订阅器实例(注意这个是一个实例,即说明已经)
class Watcher{ constructor(vm, prop, callback) { this.vm = vm // 监听的节点和监听的数据对象 this.prop = prop // 监听的数据对象的某个参数key,如 name、age 等 this.callback = callback // 存储回调 this.value = this.get() // 触发数据get操作,开始依赖收集 } get() { Dep.target = this // 将订阅器赋值给Dep的target // 调用watcher前需要调用observe,所以这里的data已经被监听了,get的时候开始依赖收集因为Dep.target = this,所以target非空,Watcher会被插入到data的key下的依赖集合中 const value = this.vm.data[this.prop] Dep.target = null // 关闭依赖收集,防止每次都收集依赖 return value // 获取当前的值 } // 注意调用update的时候是在监听器的notify里面进行的,说明已经set操作完了,值已经变了,但是watcher的value值在get的时候先缓存了上一次value值 update(newValue) { // 更新函数 const value = newValue // 获取当前的值 const oldValue = this.value // 获取之前缓存的值 if(value != oldValue) { // 如果当前的值和之前缓存的值不同则触发更新函数,并重置value this.value = value this.callback(value); // 触发更新回调函数 } } }
双向绑定构造函数
基于监听器及订阅器,我们已经可以完成简单的数据更新
具体使用时会先创建一个双向绑定构造函数实例,然后将传入的参数缓存起来。
然后调用实例的init方法,init方法中会对传入的参数(包括需要监听的data),对需要监听的data进行observe数据监听,所以这时候传入的data已经被我们监听了,我们已经是可以获取到get,set事件的。
然后我们会显示的单独创建一个订阅器实例
订阅器的构造方法中同样会缓存传入的参数,然后调用订阅器的get方法,订阅器的get方法,会设置公共的Dep类,给Dep类的target参数指向自身,即Dep.target = 订阅器
然后显示的触发data的get操作,因为get操作中会判断Dep.target 是否存在,如果存在则存入缓存(dep),再把Dep.target 重新指向 null,防止后面反复get操作
完成上述步骤之后,我们可以发现目标data的key不仅我们可以监听到它的get,set操作,并成功对齐设置了依赖,后续数据变动的话实际上就会触发set操作,会用更新函数同步视图即可。
class MyVue { constructor(options, prop) { this.options = options // 需要双向绑定的对象,包括数据对象和对应的节点 this.data = options.data // 需要监听的数据对象 this.el = options.el // 需要更新视图节点 this.prop = prop // 对应的key值 this.init() } init() { observe(this.data) // 监听data document.getElementById(this.el).textContent = this.data[this.prop] // 将数据初始化到页面上 // 创建watcher实例,在watcher中对data进行get操作,并将watcher本身挂载到data对应的key的依赖下 new Watcher(this.options, this.prop, value => { document.getElementById(this.el).textContent = value }) } }
初步效果:
编译器
在上面的例子中我们已经完成了如何监听一个数据,然后当数据变动的时候,触发函数依赖去更新页面。
但这是个单向的流程,我们只能监听数据变动操作页面的元素更改,而且在双向绑定构造函数中我们显式的创建了watcher实例(只为id是app的节点负责,且这个节点下面如果嵌套其他节点我们就无法处理了) ,显然是不合理的,我们应该要基于页面元素去分析针对对应的内容进行watcher创建。
// 完全是为一个节点服务,我们应该要针对不同元素动态的挂载watcher init() { observe(this.data) // 监听data document.getElementById(this.el).textContent = this.data[this.prop] // 将数据初始化到页面上 // 创建watcher实例,在watcher中对data进行get操作,并将watcher本身挂载到data对应的key的依赖下 new Watcher(this.options, this.prop, value => { document.getElementById(this.el).textContent = value }) }
而且watcher的get的依赖收集操作,是直接读data下的参数,意味着如果data.a.b这种情况是无法使用的。
// 无法处理复杂数据类型,这种只能处理data对象下的数据,如果是data.a.b就会异常 const value = this.vm.data[this.prop]
所以我们需要有一个编译器,去自动读取页面的目标节点下的所有节点,对每一个节点进行分析,比如a节点是个什么类型的节点。
如果是文本节点的话,内容里面是否包含{{}}语法,如果包含了{{}}里面的内容,那么这个内容是否已经在我们data中声明,声明的话,我们就需要针对这个内容给它加上这个节点的依赖,告诉它如果你的值要改变那么你的回调函数中需要更新这个节点下的值。
如果是input标签等元素节点的话,你需要分析这个节点有什么属性,是否有v-开头的属性,当然你也可以随便自定义个什么东西,如果解析到这种元素节点,你首先要做的是判断这个节点下面有没有子字节,如果有就开始递归调用一直去解析其子节点。
如果没有子节点就要去分析这个是什么元素,上面绑了什么属性。
比如v-html那么在对这个节点创建watcher的时候,就不能像文本节点那样node.textcontent = xxx,而应该设置其html。
同样的最常见的v-model,我们用在对应的元素节点上时,我们在为这个节点创建watcher的时候,还得为这个节点设置oninput事件,因为当其内容改变的时候,我们要用这个回调同步的修改我们目标data下的值。
下面直接开始上代码我们需要暴露一个编译器类
(1)Compile类
在构造函数中暂存我们双向绑定构造函数实例,同时读取当前目标节点的内容,然后将目标节点中的值全部移动到我们创建的文档碎片中,这一步是为了一次性渲染完减少资源损耗,获取到目标节点下所有的节点之后,我们要对这个节点进行分析主要是为每一个节点去建立映射关系为其设置对应的watcher,如果是有v-model这种事件还得给这个节点加上如oninput或者change这种事件。最后把处理完的节点重新放回页面即可。
class Compile { // 对数据进行解析 constructor(el, vm) { // el当前目标元素根节点,vm双向绑定构造函数实例 this.el = this.isElementNode(el) ? el : document.getElementById(el) // 获取目标节点 this.vm = vm // 暂存构造函数实例 // 获取文档碎片节点,暂存在内容中 const fragmentNode = this.node2Fragment(this.el) // 模板编译,主要是为每一个节点去建立映射关系为其设置对应的watcher this.compile(fragmentNode) // 将处理后端 this.el.appendChild(fragmentNode) } .... }
(2)node2Fragment函数
这个是读取目标节点的函数,会创建一个新的空白文档碎片,把原来目标节点下的子节点一个一个放进去
// 解析元素节点 node2Fragment(Node) { // 创建空白文档 const f = document.createDocumentFragment() // 每次先看看Node.firstChild是否存在 while((Node.firstChild)) { // 把子元素加到空白文档中,加过去之后意味着目标节点第一个节点迁移到空白文档中,那么目标节点的元素就会越来越少,全部变成空白文档下的子元素 f.appendChild(Node.firstChild) } return f }
(3)compile函数
这个是解析的核心函数,用来解析每一个节点,判断这个节点是什么类型,然后需要如何为其设置watcher。
可以看到这个函数读取了我们传过来的文档碎片,如果一个一个遍历,判断你这个节点是否是含有子节点的,如果有子节点那就继续递归遍历,如果没有就判断你是什么类型的节点是文本节点还是元素节点,文本节点调用文本编译函数,元素节点调用元素编译函数。
// 编译模板 compile(fragmentNode) { // 获取模板的子节点 const childNodes = fragmentNode.childNodes; // 遍历所有节点 [...childNodes].forEach( child => { // 判断子节点中是否还有节点 递归调用 if (child.childNodes && child.childNodes.length) { this.compile(child); } // 如果是元素节点类型 else if(this.isElementNode(child)) { // 使用元素编译 this.elementCompile(child) } // 如果是文本节点类型 else { // 使用文本编译 this.textCompile(child) } }) }
1.文本编译函数
文本编译函数,针对文本类型节点编译的函数,主要是负责那些{{}}语法的处理,在这里会匹配这个节点里面有没有{{}}语法,如果有就调用compileFunc下的text函数,给这个节点单独添加watcher,使得你数据更新的时候可以用watcher里面的回调去修改页面的对应节点的数据。
// 文本编译函数 textCompile(node) { // 获取节点文字内容 const content = node.textContent; // 匹配插值语法,如果有{{}}语法则为其设置watcher if(/{{.+?}}/.test(content)) { compileFunc['text'](node, content, this.vm) } }
下面是compileFunc关于text模块的代码,首先匹配出所有的{{}}内的字符,生成一个数组,对这个数组去重,因为一个text节点里面是一个字符串,而一个字符串可能不只有一个{{}}所以需要找到所有的{{}}内的字符,因为每一个字符都相当于一个被监听的key,他们都有自己的回调都会对这个节点进行操作(ps当然这个操作有点问题后面再说)
找到所有的字符之后就开始循环,为其单独设置回调函数即
this.updater.textUpdater(node, value, propList[index], content)
回调函数的作用是去设置这个节点下的textcontent的value,然后为这个{{}}内的字符创建一个watcher,值得注意的是因为{{}}里面可能有a.b.c这种情况我们还得返回一个函数,在调用时会取到data下对应的值,只有才能给对象里面对象的可以设置回调
结合上面我们对watcher的设计可以知道,如果我们知道了目标data和需要监听的data下的key,我们在创建watcher实例的时候就可以将这个回调传入这个key的依赖中,因为我们在回调中通过闭包的方法我们提前传入了包括对应元素节点,文案内容等信息,所以我们调用这个函数的时候可以精准的修改到对应节点。
const compileFunc = { text(node, content, vm) { const textMatch = /(?<={{)(.+?)(?=})/g; let propList = content.match(textMatch) propList = [...new Set(propList)] for (let index = 0; index < propList.length; index++) { let func = value => { this.updater.textUpdater(node, value, propList[index], content) } new Watcher(vm, this.propFunc(propList[index], vm), func) } }, // 处理取对象下的值的情况,因为{{}}里面会有{{a.b.c}}这种情况需要通过一个函数的形式在watcher中进行get操作 propFunc(value, vm) { return function() {value.split(".").reduce((p, c) => { return p[c]; }, vm.data)} }, // 实际操作dom方法 updater: { textUpdater(node, value, prop, content) { node.textContent = content.replaceAll(`{{${prop}}}`, value); }, } }
2.元素编译函数(仅做实例只实现v-model)
元素编译函数主要是针对元素类型节点的,不同于文本类型,元素类型主要是处理如v-model这种属性,会读取这个元素节点下的属性,判断是否v-开头的
// 判断是否v-开头的属性 isMyVueAttributes(name) { return name.startsWith('v-') }
如果有那就针对这个节点的这个属性去调用compileFunc下的对应属性函数,如v-model,就调用model函数。
// 元素编译函数 elementCompile(node) { // 获得目标的节点的属性 const attributes = node.attributes; [...attributes].forEach(item => { // 解构出参数的name和value, name相当于v指令如v-model等,value则是对应的值 name, age等 const {name, value} = item if(!this.isMyVueAttributes(name)) return const attributesName = name.split('-')[1] compileFunc[attributesName](node, value, this.vm) }) }
可以看看compileFunc下的model函数干了啥,首先定义一个回调函数,函数的核心是modelUpdater,类似于textUpdater,做的事情是data的key改变时同步更新页面,即数据更新视图,并将这个作为回调传入对应的watcher中,这时data的key就可以通过调用依赖的方式更新视图了。
同时我们也看到我们给这个节点加了oninput事件,因为这个是双向绑定,视图一样要改变数据,所以当输入了内容之后通过oninput事件同步修改页面,这就是双向绑定
model(node, prop, vm) { let func = value => { this.updater.modelUpdater(node, value) } new Watcher(vm, this.propFunc(prop, vm), func) node.oninput =(item)=>{ // 这里还没办法处理字符串转对应对象 vm.data[prop] = item.srcElement.value } }, ... modelUpdater(node, value) { node.value = value; },
基于上面的实现我们已经完成了监听器,订阅器,双向绑定构造函数以及编译器,但是还有一点问题,我们可以看到双向绑定构造函数我们的init()方法是这样子写的,只针对一个节点创建watcher
init() { observe(this.data) // 监听data document.getElementById(this.el).textContent = this.data[this.prop] // 将数据初始化到页面上 // 创建watcher实例,在watcher中对data进行get操作,并将watcher本身挂载到data对应的key的依赖下 new Watcher(this.options, this.prop, value => { document.getElementById(this.el).textContent = value }) }
又因为我们在写编译器的时候其实已经将watcher整合进compile了,所以之后我们其实只需要创建一个complie实例即可监听下面所有的节点。
init() { observe(this.data) // 监听data new Compile(this.el, this.options) }
总结
如何实现一个双向绑定:
(1)监听器:对你的目标data下的所有key进行一个监听,并且提供一个Dep类用于依赖的收集及执行,在进行监听的时候需要对每一个key实例化一个Dep,对这个key进行get操作时需要判断Dep类上target是否有内容,如果有那就将这个内容塞入实例化的Dep中这就是依赖收集,等到set的时候再将实例化的Dep中收集的依赖全部执行一遍这就是依赖执行。
(2)订阅器:负责给监听器设置依赖,当我们实现一个依赖函数时比如我们更新了数据应该要修改页面某个节点值时,这个修改的函数会连同data,key一同传入订阅器的构造函数中,在订阅器的构造方法会先缓存执行参数,然后里面我们会调用一个get方法这个方法会将Dep的target指向watcher实例本身,然后get里面自己读取一次对应data下的key,这一步会触发监听器的get操作,因为监听器的get操作会判断Dep类上target是否有内容,因为这时候Dep的target指向watcher实例本身,所以watcher实例被加入key的实例化Dep的依赖中,完成了依赖的收集,最后再把Dep的target重新指向null,防止反复收集。
(3)编译器:用于动态的为每个节点添加watcher的类,会先创建一个空白的文档碎片,然后将目标节点下的所有节点移动到空白碎片文档中,这一步是为了对节点进行分析,分析完之后一次性加入到页面中防止频繁改动。
然后就会调用编译函数,遍历所有节点,如果有子节点就递归遍历,如果是元素节点就调用元素解析器,如果是文本节点就调用文本解析器,对每一个节点设置相关key的watcher实现数据更新视图,如果这个节点需要更新数据的话还得给他设置回调函数,使得其可以在回调函数中更新数据。这就是双向绑定的原理。
(4)双向绑定构造函数
缓存我们传入的目标节点和data,在init方法中调用监听器,并且实例化编译器对每一个节点挂载对应的watcher和回调函数即可。
效果
完整代码
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>VUE双向绑定原理实现</title> </head> <body> <div id="app">{{name}}{{name}}<div>{{child.name}}</div><input v-model="name"></input></div> <div id="app1" v-model="a.b.c"></div> <script> // 监听器 // 数据监听,监听对象的所有get和set function observe(data) { // 如果data不存在或data不是object if (!data || typeof data !== 'object') { return; } // 遍历data,获得当前的key for (const key in data) { // 监听data下的当前key的属性变动 defineReactive(data, key, data[key]); // 一次只处理data和一个key的监听关系 } } // 通过Object.defineProperty去监听data下的key function defineReactive(data, key, value) { // 处理key对应的value是一个对象的情况 observe(value) // 创建Dep() 实例 用于存储key的依赖,以及触发key的依赖 let dep = new Dep() Object.defineProperty(data, key, { // 返回value值 get: function () { // get的时候进行依赖收集 if(Dep.target) { // Dep.target默认为null,不会收集依赖,但是创建watcher实例的时候 // 会为Dep.target赋值并指向当前的watcher实例,同时会为watcher挂载上update函数 // 然后get的时候会出现触发依赖收集,因为这时候Dep.target指向当前的watcher实例,就可以将这个watcher实例收集起来 dep.addSub(Dep.target) } return value }, set: function (newValue) { // get的时候触发依赖 val = newValue; // 依赖触发,遍历依赖里面的watcher并调用update函数 dep.notify(newValue) } } )} // 创建Dep类,用来存储依赖 class Dep{ constructor() { this.subs = [] // 定义一个subs数组用来存放依赖 } addSub(sub) { // get的时候存储依赖 // 注:更新函数会被挂载到单独创建的watcher实例,存储依赖的时候,实际上存储的是创建的watcher实例 this.subs.push(sub) } notify(newValue) { // set的时候触发依赖 // 注: 遍历依赖,开始执行里面watcher实例的更新函数 this.subs.forEach(item => { item.update(newValue) }) } target = null } // 订阅器 class Watcher{ constructor(vm, prop, callback) { this.vm = vm // 监听的节点和监听的数据对象 this.prop = prop // 监听的数据对象的某个参数key,如 name、age 等 this.callback = callback // 存储回调 this.value = this.get() // 触发数据get操作,开始依赖收集 } get() { Dep.target = this // 将订阅器赋值给Dep的target // 调用watcher前需要调用observe,所以这里的data已经被监听了,get的时候开始依赖收集因为Dep.target = this,所以target非空,Watcher会被插入到data的key下的依赖集合中 const value = this.prop() // 由于要处理取对象下面的对象的值的情况,这里需要返回一个函数在get()的时候再显式的收集依赖 Dep.target = null // 关闭依赖收集,防止每次都收集依赖 return value // 获取当前的值 } // 注意调用update的时候是在监听器的notify里面进行的,说明已经set操作完了,值已经变了,但是watcher的value值在get的时候先缓存了上一次value值 update(newValue) { // 更新函数 const value = newValue // 获取当前的值 const oldValue = this.value // 获取之前缓存的值 if(value != oldValue) { // 如果当前的值和之前缓存的值不同则触发更新函数,并重置value this.value = value this.callback(value); // 触发更新回调函数 } } } // 编译器 class Compile { // 对数据进行解析 constructor(el, vm) { // el当前目标元素根节点,vm双向绑定构造函数实例 this.el = this.isElementNode(el) ? el : document.getElementById(el) // 获取目标节点 this.vm = vm // 暂存构造函数实例 // 获取文档碎片节点,暂存在内容中 const fragmentNode = this.node2Fragment(this.el) // 模板编译,主要是为每一个节点去建立映射关系为其设置对应的watcher,把上面创建的节点传入解析器去编译分析 this.compile(fragmentNode) // 将处理后端 this.el.appendChild(fragmentNode) } // 解析元素节点 node2Fragment(Node) { // 创建空白文档 const f = document.createDocumentFragment() // 每次先看看Node.firstChild是否存在 while((Node.firstChild)) { // 把子元素加到空白文档中,加过去之后意味着目标节点第一个节点迁移到空白文档中,那么目标节点的元素就会越来越少,全部变成空白文档下的子元素 f.appendChild(Node.firstChild) } return f } // 编译模板 compile(fragmentNode) { // 获取模板的子节点 const childNodes = fragmentNode.childNodes; // 遍历所有节点 [...childNodes].forEach( child => { // 判断子节点中是否还有节点 递归调用 if (child.childNodes && child.childNodes.length) { this.compile(child); } // 如果是元素节点类型 else if(this.isElementNode(child)) { // 使用元素编译 this.elementCompile(child) } // 如果是文本节点类型 else { // 使用文本编译 this.textCompile(child) } }) } // 元素编译函数 elementCompile(node) { // 获得目标的节点的属性 const attributes = node.attributes; [...attributes].forEach(item => { // 解构出参数的name和value, name相当于v指令如v-model等,value则是对应的值 name, age等 const {name, value} = item if(!this.isMyVueAttributes(name)) return const attributesName = name.split('-')[1] compileFunc[attributesName](node, value, this.vm) }) } // 文本编译函数 textCompile(node) { // 获取节点文字内容 const content = node.textContent; // 匹配插值语法,如果有{{}}语法则为其设置watcher if(/{{.+?}}/.test(content)) { compileFunc['text'](node, content, this.vm) } } // 判断是否node节点 isElementNode(Node) { return Node.nodeType == 1 } // 判断是否v-开头的属性 isMyVueAttributes(name) { return name.startsWith('v-') } } const compileFunc = { text(node, content, vm) { const textMatch = /(?<={{)(.+?)(?=})/g; let propList = content.match(textMatch) propList = [...new Set(propList)] for (let index = 0; index < propList.length; index++) { let func = value => { this.updater.textUpdater(node, value, propList[index], content) } new Watcher(vm, this.propFunc(propList[index], vm), func) } }, model(node, prop, vm) { let func = value => { this.updater.modelUpdater(node, value) } new Watcher(vm, this.propFunc(prop, vm), func) node.oninput =(item)=>{ // 这里还没办法处理字符串转对应对象 vm.data[prop] = item.srcElement.value } }, // 处理取对象下的值的情况,因为{{}}里面会有{{a.b.c}}这种情况需要通过一个函数的形式在watcher中进行get操作 propFunc(value, vm) { return function() {value.split(".").reduce((p, c) => { return p[c]; }, vm.data)} }, // 实际操作dom方法 updater: { textUpdater(node, value, prop, content) { node.textContent = content.replaceAll(`{{${prop}}}`, value); }, modelUpdater(node, value) { node.value = value; }, } } // 双向绑定构造函数 class MyVue { constructor(options, prop) { this.options = options // 需要双向绑定的对象,包括数据对象和对应的节点 this.data = options.data // 需要监听的数据对象 this.el = options.el // 需要更新视图节点 this.prop = prop // 对应的key值 this.init() } init() { observe(this.data) // 监听data new Compile(this.el, this.options) } } const vm = new MyVue({ el: "app", data: { name: 'xiaoshan',child: {name:123} } }); // // 创建一个对象,对这个对象数据监听 // var user = { name: 'xiaoshan', age: 17, son: {name:'test'} } // // 数据监听函数 // observe(user) // user.name = user.name + '小山' // user.son.name = user.son.name + '小小山' </script> </body> </html>
代码缺陷
上面的代码没有做页面初始化显示,只是一个demo
只做了文本节点和元素节点v-model的解析
v-model的时候还不支持a.b.c因为在oninput事件中set还有点问题
对于文本节点时应该以{{}}为一个单独节点。不然替换的时候太麻烦了
到此这篇关于详解Vue中双向绑定原理及简单实现的文章就介绍到这了,更多相关Vue双向绑定内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!