vue双向绑定原理

一、前言

老规矩, 每一篇文章都要加个前言,今天分享一篇文章,vue双向绑定原理,之前总觉得源码,原理这些东西离我有些远,现在发现并不是,面试中这个问题问的非常频繁,在北京面试,大概5家公司面试,有三家都会问这个问题,不想每次都说不行。

首先附一张图

image.png

大多数文章都是从这个流程开始讲实现,然而我的脑回路和他们不太一样,我觉得这个问题不应该这么思考
既然是双向绑定,那就应该从概念出发,vue是一个MVVM(Model,View,ViewModel)的前端框架,双向绑定是VM部分的核心,也就是通过双向绑定实现V和M的自动同步,因此这个问题也就被分解成两个子问题,实现V -> M的绑定以及从M -> V的绑定,这篇博客也会从这两方面入手

二、知识储备

双向绑定中涉及到的知识点非常多,核心的部分是通过Object.defineProperty()这个方法对vue中数据的get和set进行劫持,配合发布订阅模式,实现一处修改,多处更新的效果,还有一个就是通过DocumentFragment碎片化文档减少dom操作引起的页面回流和重绘

所有涉及到的知识点我会列出网址,具体细节自己看,我只提一下本文用到的地方

1. Object.defineProperty方法

一个对象的属性主要包括六种描述符,分别是enumerableconfigurable, writable, value, get, set

这个方法可以给对象新加一个属性或者修改对象现有属性MDN链接


   
   
let obj = {}; let value = "666"; Object.defineProperty(obj, "test", { get: function() { return value; }, set: function(newVal) { console.log("操作set方法"); value = newVal; } }) obj.test = "666"; console.log(obj.test);

obj.test这行代码会出发get方法,也就是会返回value(值为666),通过调用obj.test = “666”;会触发set方法
本文中会通过这个方法劫持vue实例中的所有声明的数据

2. 发布订阅模式

发布订阅模式
发布订阅模式主要分三部分,发布者,订阅者和维系二者的调度中心(个人习惯这么叫)
通过发布订阅模式实现简单双向绑定


   
   
<!DOCTYPE html> <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>发布订阅模式版本双向绑定</title> </head> <body> <input type="text" id="test"> <p id="t1"></p> <p id="t2"></p> <script> /** * 发布者 */ function Publisher(dep) { this.dep = dep; this.publishArticle = function(value) { dep.notify(value); } } /** * 这是调度中心 * 第一件事是维护一个订阅者列表 * 第二件事是通知所有的订阅者,我发布了新文章 */ function Dep() { this.subs = []; //订阅者列表 this.addSub = function(sub) { this.subs.push(sub); } this.notify = function(value) { this.subs.forEach(function(item) { item.readArticle(value); }) } } /** * 订阅者 */ function Subscriber(name, id) { this.name = name; this.id = id; this.readArticle = function(value) { console.log(this.name + ":收到文章"); document.getElementById(this.id).innerHTML = this.name + "," + value; } } // 三个观察者 let sub1 = new Subscriber("张三", "t1"); let sub2 = new Subscriber("李四", "t2"); // 创建调度中心 let dep = new Dep(); dep.addSub(sub1); dep.addSub(sub2); let pub = new Publisher(dep); document.getElementById("test").addEventListener("input", function(e) { pub.publishArticle(e.target.value); }) </script> </body> </html>

看明白这个例子也就对vue双向绑定有一定的理解了。

3. DocuemntFragment

关于这个碎片化文档,可以把他当成一个dom容器来用,可以减少dom操作引起的回流和重绘,具体使用直接在正文中介绍DocumentFragment

三、具体实现

注意:本文是引导思考,不是直接按照最正确的思路展开的,如果不喜欢这个风格,建议看其他版本。。

实现过程分两部分,第一部分是V->M的映射,也就是视图变更,vue中的数据发生改变,第二部分是M->V的映射,vue中的数据发生变化,页面随之更新。

第一部分

首先是第一部分View -> Model
先列一个简单页面结构,多余部分省略(html头等)


   
   
<div id="app"> <input type="text" v-model="test"> {{test}} <input type="text" v-model="name"> {{name}} </div> <script> let app = new MVVM({ el: "app", data: { test: "This is a test", name: "test" } }) function MVVM(options) { this.el = options.el; this.data = options.data; } </script>

声明了一个MVVM构造方法,并且调用了这个方法
思考,V->M,也就是页面input输入框变化,将变化的数据同步到vue实例的对应部分
然而页面输入框可能不止一个,于是想到遍历id为app的dom节点的所有子节点,找到所有的input输入框,为输入框绑定input事件,在事件中将数据更新到vue中
这些任务的完成应该是在MVVM的构造函数中,声明一个方法,processNode,在这个方法中遍历dom节点,绑定事件,具体代码如下, 后面都只展示js,html部分不会变


   
   
let app = new MVVM({ el: "app", data: { test: "This is a test", name: "test" } }) function MVVM(options) { this.el = options.el; this.data = options.data; let dom = document.getElementById(this.el); // 将id为app的dom节点传入这个方法,处理每一个dom节点 processNode(dom, this); } /** * 处理所有的节点 */ function processNode(dom, vm) { for (let i = 0; i < dom.childNodes.length; i++) { // 通过这个方法处理节点 compile(dom.childNodes[i], vm); } } function compile(node, vm) { // 节点有多种,只处理元素节点,也就是nodeType为1的节点 let type = node.nodeType; if (type === 1) { // 监听元素变化,将变化的数据同步到vue实例中 // 首先判断v-model绑定的哪一个数据 let attributes = node.attributes; for (let attr of attributes) { let name = attr.name; if (name == "v-model") { let key = attr.nodeValue; // 当前元素有v-model,需要监听它的变化,绑定到vue对应属性中 node.addEventListener("input", function(e) { vm.data[key] = e.target.value; console.log(vm.data); }) } } } }

通过在两个input中输入内容,会发现vue中的数据已经发生了改变, 还有个办法,因为js代码已经执行了,因此可以直接在浏览器中输入app,按回车键,看看vue实例现在的各个属性值,都可以发现vue实例中的数据已经发生了改变,完成了第一步,V->M的绑定

第二部分

第二部分要完成M->V的同步,也是双向绑定难点。也就是之前说到的发布订阅模式了,html中{{test}}如何知道vue中的test值发生了改变,这件事应该是发布者(input输入框)告诉调度中心的,然后在由调度中心告诉{{test}}节点进行更新
可能很多人看到这里还是不知道该如何往下做,我当初也想了很久,感觉这个流程乱糟糟,但是根据发布订阅模式中说的,应该是由发布者通知订阅中心,那应该就是在那个监听事件中进行的操作


   
   
node.addEventListener("input", function(e) { vm.data[key] = e.target.value; // 在这里告诉调度中心,我的值改变了 })

声明一个调度中心,思考他的职责,根据发布-订阅模式中定义的,他应该维护一个订阅者列表,同时应该有一个方法通知所有的订阅者,代码如下


   
   
/** * 调度中心 */ function Dep() { // 订阅者列表,用数组保存 this.subs = []; // 添加订阅者的方法 this.addSub = function(sub) { this.subs.push(sub); } this.notify = function() { // 通知所有的订阅者进行更新 this.subs.forEach(function(item) { // 订阅者更新的方法,没想好,先不定义 }) } }

在事件中调用调度中心的notify方法。


   
   
node.addEventListener("input", function(e) { vm.data[key] = e.target.value; let dep = new Dep(); dep.notify(); })

在这里虽然通知了所有订阅者,但是并没有将订阅者添加到调度中心中,那订阅者又是谁呢,自然是{{test}}这种节点了,但是页面中可能有多个这种 订阅者 他们订阅的可能不是同一个消息, 所以在应该让订阅者保持职责的唯一性,也就是一个订阅者只维护vue中的一个属性,在这个属性发生变化的时候,让对应的调度中心通知对应的所有订阅者。
既然我们明确了vue实例的每一个属性都要对应一个调度中心,也就是说我们需要遍历vue的所有数据,给每一个属性加一个调度中心
增加一个observe方法


   
   
function MVVM(options) { this.el = options.el; this.data = options.data; let dom = document.getElementById(this.el); // 为每一个属性增加一个调度中心 observe(this); // 将id为app的dom节点传入这个方法,处理每一个dom节点 processNode(dom, this); } function observe(vm) { let data = vm.data; for (let key of Object.keys(data)) { let dep = new Dep(); } }

现在调度中心有了,订阅者又应该什么时候加入到调度中心呢?比如有个调度中心维护的是vue.data中的test属性,那如果有个dom节点访问的是test属性,那就应该加入到调度中心中,也就是访问这个属性的get方法的时候,我们只需要 拦截 或者叫 劫持 这个get方法,让访问这个属性的dom节点加入到订阅者中
因此修改observe方法,在 观察 vue实例的时候拦截他的get方法


   
   
function observe(vm) { let data = vm.data; for (let key of Object.keys(data)) { defineReactive(data, key, data[key]); } } function defineReactive(obj, key, value) { let dep = new Dep(); Object.defineProperty(obj, key, { get: function() { // 将订阅者加入到调度中心中 return value; } }) }

代码改成这样,但是还没有订阅者的概念,订阅者应该包括哪些属性呢?比如有个dom节点是 {{test}} ,那他对应的订阅者应该保存vue实例,访问的属性(test),以及这个dom节点(方便赋值),还应该有个update方法,让调度中心通知他更新,订阅者代码如下


   
   
function Subscriber(node, vm, key) { // dom节点 this.node = node; // vue实例 this.vm = vm; // 访问的属性,{{test}}中的test属性等 this.key = key; // 更新dom方法 this.update = function() { this.node.nodeValue = this.vm.data[this.key]; } }

Dep的notify方法调用每个订阅者的update方法


   
   
this.notify = function() { // 通知所有的订阅者进行更新 console.log("更新") this.subs.forEach(function(item) { item.update(); }) }

现在概念都有了,就应该修改get,让每个访问这个属性的订阅者加入到调度中心中,就应该在遍历dom节点的时候将 {{}} 这种格式的单独处理一下,修改compile函数,加入else判断


   
   
var current = null; function compile(node, vm) { // 节点有多种,只处理元素节点,也就是nodeType为1的节点 let type = node.nodeType; if (type === 1) { // 监听元素变化,将变化的数据同步到vue实例中 // 首先判断v-model绑定的哪一个数据 let attributes = node.attributes; for (let attr of attributes) { let name = attr.name; if (name == "v-model") { let key = attr.nodeValue; // 当前元素有v-model,需要监听它的变化,绑定到vue对应属性中 node.addEventListener("input", function(e) { vm.data[key] = e.target.value; }) } } } else { let regExp = /\{\{(.*)\}\}/; let value = node.nodeValue.trim(); if (regExp.test(value)) { let key = RegExp.$1; new Subscriber(node, vm, key); } } }

这里调用Subscriber构造函数,同时在构造函数中给Dep绑定一个对象current,用来保存当前是哪个订阅者,同时作为一把锁,保证调度中心每次只能加入一个订阅者
但是这样并不会触发vue对应属性的get方法,因此需要在实例化订阅者的时候手动赋值一次,代码如下


   
   
function Subscriber(node, vm, key) { Dep.current = this; // dom节点 this.node = node; // vue实例 this.vm = vm; // 访问的属性,{{test}}中的test属性等 this.key = key; // 通过这行代码,调用get方法 this.node.nodeValue = this.vm.data[this.key]; // 更新dom方法 this.update = function() { this.node.nodeValue = this.vm.data[this.key]; } Dep.current = null; }

由于发现页面报错,原来是在input事件中调用了dep.notify方法,改版后调度中心是在defineReactive中定义的,因此通知调度中心就行更新就应该在对应属性的set方法中,当input事件触发后,修改vm中的数据,自动触发对应属性的set方法,由调度中心通知所有的订阅者更新
修改后的defineReactive方法如下


   
   
function defineReactive(obj, key, value) { let dep = new Dep(); Object.defineProperty(obj, key, { get: function() { // 将订阅者加入到调度中心中 if (Dep.current) { dep.addSub(Dep.current); } return value; }, set: function(newVal) { value = newVal; dep.notify(); } }) }

完成这些发现页面已经实现了双向绑定,但是有个小问题就是页面初始化的时候input输入框没有值
原因是因为我们在处理元素节点的时候没有给他赋值,只绑定了input事件
在compile函数中给input赋值即可


   
   
node.value = vm.data[key];

附录

全部代码如下:


   
   
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>vue双向绑定</title> </head> <body> <div id="app"> <input type="text" v-model="test"> {{test}} <input type="text" v-model="name"> {{name}} </div> <script> let app = new MVVM({ el: "app", data: { test: "This is a test", name: "test" } }) function MVVM(options) { this.el = options.el; this.data = options.data; let dom = document.getElementById(this.el); observe(this); // 将id为app的dom节点传入这个方法,处理每一个dom节点 processNode(dom, this); } function observe(vm) { let data = vm.data; for (let key of Object.keys(data)) { defineReactive(data, key, data[key]); } } function defineReactive(obj, key, value) { let dep = new Dep(); Object.defineProperty(obj, key, { get: function() { // 将订阅者加入到调度中心中 if (Dep.current) { dep.addSub(Dep.current); } return value; }, set: function(newVal) { value = newVal; dep.notify(); } }) } function Subscriber(node, vm, key) { Dep.current = this; // dom节点 this.node = node; // vue实例 this.vm = vm; // 访问的属性,{{test}}中的test属性等 this.key = key; // 通过这行代码,调用get方法 this.node.nodeValue = this.vm.data[this.key]; // 更新dom方法 this.update = function() { this.node.nodeValue = this.vm.data[this.key]; } Dep.current = null; } /** * 处理所有的节点 */ function processNode(dom, vm) { for (let i = 0; i < dom.childNodes.length; i++) { // 通过这个方法处理节点 compile(dom.childNodes[i], vm); } } function compile(node, vm) { // 节点有多种,只处理元素节点,也就是nodeType为1的节点 let type = node.nodeType; if (type === 1) { // 监听元素变化,将变化的数据同步到vue实例中 // 首先判断v-model绑定的哪一个数据 let attributes = node.attributes; for (let attr of attributes) { let name = attr.name; if (name == "v-model") { let key = attr.nodeValue; node.value = vm.data[key]; // 当前元素有v-model,需要监听它的变化,绑定到vue对应属性中 node.addEventListener("input", function(e) { vm.data[key] = e.target.value; }) } } } else { let regExp = /\{\{(.*)\}\}/; let value = node.nodeValue.trim(); if (regExp.test(value)) { let key = RegExp.$1; new Subscriber(node, vm, key); } } } /** * 调度中心 */ function Dep() { // 订阅者列表,用数组保存 this.subs = []; // 添加订阅者的方法 this.addSub = function(sub) { this.subs.push(sub); } this.notify = function() { console.log(this.subs); // 通知所有的订阅者进行更新 this.subs.forEach(function(item) { // 订阅者更新的方法,没想好,先不定义 item.update(); }) } } </script> </body> </html>
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值