vue实现双向数据绑定 (附代码及讲解)

喜欢请关注 会不定时更新 ***

简介

  • 最近花功夫研究了一下Vue双向数据绑定的原理,代码看了很多遍,才逐渐掌握它的设计思路。
  • 对于用Vue的人来说,也是估计被面试问的最多的一个问题之一,了解它给自己竞争来增加优势,毕竟当被问时你回答的头头是道,是一件非常nice的事情。
  • 以下实现简易双向绑定以及事件监听,并且尽可能的对代码加了详尽的注释。

首先看下效果图

效果图.gif

文件目录

├─myVue
| ├─compile.js
| ├─index.html
| ├─index.js
| ├─observer.js
| └watcher.js

流程分析

我的源码

点这里去github下载代码

流程分析(请下载我的源码对照来看)

  1. 首先,我们创建一个index.html,如下
   <div  id='app'>
      <input  v-model="title"></input>
      <p>{{title}}</p>
    </div>
  1. 接下来,我们创建一个Vue实例
const vm = new mVue({
      el:'#app',
      data: {
        title: '有一种放手叫余生不打扰'
      }
    })
  1. 那么,我们创建了vue会发生什么尼?在index.js可以看到,它会执行以下两个操作
    // 初始化数据劫持
observe(this.data)
    // 初始化 进行编译
new Compile(this.el ,this)

  1. 先调用观察者observedata中的数据进行劫持,利用Object.defineProperty方法,这样我们就能监听到data数据中的变动。例如上例就会对data里面的title进行数据监听。同时,会对每个属性里创建一个dep,来存放所有订阅这个属性的数组。对于dep,你可能不太清楚,先接着往下看。
  1. 再进行Compile编译,编译是干嘛的尼?
    首先会获取创建Vue实例时,el属性绑定的dom元素的内容,如上例就是获取到div#app的内容,在compile.js里面,我们会对div#app下面的所有元素进行遍历,来比对是否有绑定的数据或事件。
    对于上例,当我们比对到 <input v-model="title"></input>时,我们发现了这个input标签使用v-model绑定了title属性,于是乎,我们会创建一个watcher,问题又来了,watcher又是啥?watcher就是一个封装的对象,创建时会存进去该元素绑定该属性的更新方法。
    例如:刚刚检索到的input标签,创建的watcher大致是下面这样的(看下面代码)。我们创建watcher时,会自动调一遍get()方法,将自己添加到titile属性的dep中,再将自己的update方法放进去。因为我们在observe时已经对所有属性进行了劫持,当每个属性变化时,会遍历一遍自己的dep数组,并逐个触发每个update函数。
// 我是一个Watcher模型
Watcher = {
    get(){
       // 将watcher自己添加到title的dep数组里        
    },
    update(){
       // 将当前这个input标签的值 替换为 新的值
    }
  1. 当我们遍历完成时,会发现两个元素绑定了属性title
<input  v-model="title"></input>
<p>{{title}}</p>

所以title属性的dep数组中存放两条数据,每个是个watcher

// 类似于
[ {watcher-for-p}, {watcher-for-input} ]
  1. 当我们改变titile的值改变时,会触发setter劫持,接着遍历dep数组,触发每个的更新。
    例如:title = 123,遍历dep,先会通知订阅了这个属性的p标签更新,再通知input跟新。从而数据到视图的绑定。
  1. 当我们在发现v-model的时候,还另外干了一件事,就是给这个input添加事件监听,监听输入,改变绑定的数据
// 在compile.js中有这一段代码
node.addEventListener('input',function(e){
                var newVal = e.target.value
                if(val === newVal){
                    return
                }
                self.vm[exp] = newVal;  // 更改绑定属性的值
                val = newVal;
            })

  1. 当我们在input输入时,会改变data中绑定的对应属性的值,触发属性的劫持,执行订阅者更新,完成了input->数据->视图的更新。
    从而完成了数据的双向绑定。

源码

点这里去github下载代码

index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <style>
      #app{
        margin-top:100px;
        text-align: center;
      }
      #app button{  
        color: #0084ff;
        border-color: #0084ff;
        background: #fff;
      }
    </style>
  </head>
  <body>
    <div  id='app'>
      <input  v-model="title"></input>
      <p>{{title}}</p>
      <BUtton v-on:click="clickMe">快来点我</BUtton>
    </div>
  </body>
  <script src="./observer.js"></script>
  <script src="./watcher.js"></script>
  <script src="./compile.js"></script>
  <script src="./index.js"></script>
  <script type="text/javascript">
    const vm = new mVue({
      el:'#app',
      data: {
        title: '有一种放手叫余生不打扰'
      },
      methods:{
        clickMe(){
          this.title = '你点了我'
        }
      }
    })
    setTimeout(()=>{
      vm.id = 888
    },1000)
  </script>
</html>


index.js


// 创建mVue类
function mVue(options){
    var self = this
    this.data = options.data
    this.el = options.el
    this.methods = options.methods
    // 遍历data对象
    Object.keys(this.data).forEach(function(key){
        // 对data里每个属性进行代理 
        self.proxyKey(key)
    })
    // 对data对象的数据进行劫持 监听
    observe(this.data)
    // 初始化 进行编译
    new Compile(this.el ,this)
}
// 属性代理
mVue.prototype = {
    proxyKey(key){
        var self = this;
        // 对vm实例进行数据劫持
        // 对data中存在的键 使类似于vm.id进行访问时 实际操作的是vm.data.id
        Object.defineProperty(this,key,{
            enumerable:true,    // 可枚举
            configurable:true,  // 可修改
            get(){
                return self.data[key] // 包裹data层
            },
            set(newVal){
                self.data[key] = newVal // 包裹data层
            }
        })
    }
}


Observer.js

// 对 对象进行劫持的初始化方法
function observe(value,vm){
    // 只有当该值为对象时递归遍历劫持
    if(!value || typeof value !== 'object'){
        return
    }
    return new Observe(value)
}
// 观察者构造器
function Observe(data){
    this.data = data;
    // 初始化 对data的所有数据进行劫持
    this.walk(data)
}
Observe.prototype = {
    walk:function(data){
        var self = this
        // 遍历data的所有属性
        Object.keys(data).forEach(function(key){
            // 对属性进行劫持
            self.defineReactive(data,key,data[key])
        })
    },
    defineReactive:function(data,key,val){
        // dep==>用来存储这条属性的订阅信息的构造器
        // dep.subs 存储订阅者的数组
        // 例如 页面中有<p>{{id}}<p/> <h1>{{id}}<h1/> 两个标签都绑定了data中的id属性
        // 进行compile时,会被获取到,添加到该id属性的dep.subs中
        // 上例该dep.subs中就有两条数据,每条数据即一个watcher
        // 每个watcher中包含了该订阅者节点数据更新的方法
        // 当数据变化后只需遍历dep.subs数组,执行相关的更新方法即可
        var dep = new Dep()
        //这里对子属性进行递归操作,因为该属性可能是对象嵌套对象
        var childObj = val
        observe(childObj)
        // 这里开始利用Object.defineProperty进行属性劫持
        Object.defineProperty(data,key,{
            enumerable:true,
            configurable:true,
            // get方法 即数据的获取方法
            get(){ 
                // Dep.target默认是null
                // 只有当compile页面时,每发现一条绑定data中属性时,会生成一个watcher,
                // 让Dep.target = watcher,再触发该属性的get方法,完成添加
                // 再置空
                if(Dep.target){
                    // 添加一个订阅者Dep.target=watcher 到dep.subs
                    dep.addSub(Dep.target) 
                }
                // 普通访问时直接返回数据
                return val
            },
            set(newVal){
                // 当新旧值一样时,不触发更新
                if( newVal === val){
                    return
                }
                // 否则赋值 并 触发dep构造器的notice通知方法
                val = newVal
                dep.notice()
            }
        })
    }
}
// Dep构造器 存储每条属性的订阅信息
// 每个data中属性都会生成一个dep构造器
let Dep = function(){
    // 存储该属性被订阅的数组
    this.subs = []
}
Dep.prototype = {
    // 添加订阅信息
    addSub : function(sub){
        this.subs.push(sub)
    },
    // 触发更新
    notice:function(){
        // 遍历所有的订阅者 执行对应的跟新函数
        this.subs.forEach(function(sub){
            sub.update()
        })
    }
}
Dep.target = null



compile.js

function Compile(el, vm){
    this.vm  = vm;
    this.el = document.querySelector(el)
    // 虚拟dom
    this.fragment = null
    // 初始化第一次编译
    this.init()
}
Compile.prototype = {
    init(){
        // 判断绑定的元素是否存在
        if(this.el){
            // 创建一个虚拟dom
            this.fragment = this.nodeToFragment(this.el)
            this.compileElement(this.fragment)
            this.el.appendChild(this.fragment)
        }else{
            console.log('Dom元素不存在')
        }
    },
    nodeToFragment(el){
        // 创建虚拟dom
        var fragment = document.createDocumentFragment()
        var child = el.firstChild
        while(child){
            fragment.appendChild(child)
            child = el.firstChild
        }
        return fragment
    },
    // 编译元素
    compileElement(el){
        var self = this;
        var childNodes = el.childNodes;
        // [].slice.call() 将伪数组转化为数组 遍历
        [].slice.call(childNodes).forEach(function(node){
            var reg =  /\{\{(.*)\}\}/
            var text = node.textContent
            // 如果是文本节点 且 符合表达式
            if(self.isElementNode(node)){
                self.compile(node)
            }else if(self.isTextNode(node) && reg.test(text)){
                // 对文本进行编译
                self.compileText(node,reg.exec(text)[1])
            }
            // 如果存在子节点 递归编译
            if (node.childNodes && node.childNodes.length) {
                self.compileElement(node);
            }
        })
    },
    // 文本进行编译
    compileText(node,exp){
        var self = this;
        var initText = this.vm[exp]
        // 初始化要更新一次文本 用于第一次页面显示内容
        this.updateText(node,initText)
        new Watcher(this.vm,exp,function(value){
            self.updateText(node,value)
        })
    },
    compile(node){
        var nodeAttrs = node.attributes;
        var self = this;
        // 节点伪数组 转化为数组
        [].slice.call(nodeAttrs).forEach(function(attr){
            var attrName = attr.name;
                // 判断是否是v-开头的指令
                if(self.isDirective(attrName)){
                    var exp = attr.value    // 绑定的数据
                    var dir = attrName.substring(2) // 指令名称
                    // 判断是否是:on事件指令
                    if(self.isEventDirective(dir)){
                        self.compileEvent(node, self.vm, exp, dir)
                    }else{
                        //否则 编译v-model指令
                        self.compileModel(node,self.vm,exp,dir);
                    }
                        // 对元素中的指令进行移除
                        node.removeAttribute(attrName);
                }
        })
    },
    // 编译事件
    compileEvent(node, vm, exp, dir){
        var eventType = dir.split(':')[1]
        var cb = vm.methods && vm.methods[exp]
        if(eventType && cb){
            node.addEventListener(eventType,cb.bind(vm),false)
        }
    },
    // 编译v-nodel
    compileModel(node, vm, exp, dir){
        console.log(dir)
            var self = this;
            var val = this.vm[exp]
            if(dir == 'model'){
                self.updateModel(node,val)
            }
            new Watcher(self.vm,exp,function(value){
                self.updateModel(node,value)
            })
            node.addEventListener('input',function(e){
                var newVal = e.target.value
                if(val === newVal){
                    return
                }
                self.vm[exp] = newVal;
                val = newVal;
            })
    },
    // 判断属性是否是指令
    isDirective(attr){
        return attr.indexOf('v-') == 0
    },
    // 判断属性是否是事件指令
    isEventDirective(dir){
        console.log('dir',dir,dir.indexOf('on:') == 0)
        return dir.indexOf('on:') == 0
    },
    // 判断是否是文本节点
    isTextNode(node){
        return node.nodeType == 3
     },
     // 判断是否是元素节点
     isElementNode(node){
         return node.nodeType == 1
      },
      // 跟新文本
      updateText(node,value){
          node.textContent = typeof value == 'undefined' ? '' : value
      },
      // 更新model绑定
      updateModel(node,value){
          node.value = typeof value == 'undefined' ? '' : value
      }
}



watcher.js


// 订阅者构造器
function Watcher(vm,exp,cb){
    this.vm = vm // mVue实例
    this.exp = exp  // 被订阅的属性
    this.cb = cb    //更新该属性的回调方法
    this.value = this.get();  // 初始化时将自己添加到订阅器
}
Watcher.prototype  = {
    get(){
        // 令Dep.target等于当前的watcher实例
        Dep.target = this;
        // 触发对应属性的getter方法
        // 会将当前watcher添加到dep.subs中
        var value = this.vm.data[this.exp]
        // 添加完成后置空
        Dep.target = null
        return value
    },
    // 该订阅者的更新方法
    update(){
        this.run()
    },
    run(){
        // 也就是触发构造函数的cb回调,触发视图跟新
        let value = this.vm.data[this.exp]
        let oldVal = this.value
        if(value !== oldVal){
            this.value = value
            this.cb.call(this.vm,value,oldVal)
        }
    }
}


最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 221,820评论 6 515
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 94,648评论 3 399
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 168,324评论 0 360
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 59,714评论 1 297
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,724评论 6 397
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 52,328评论 1 310
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,897评论 3 421
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,804评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 46,345评论 1 318
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,431评论 3 340
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,561评论 1 352
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 36,238评论 5 350
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,928评论 3 334
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,417评论 0 24
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,528评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,983评论 3 376
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,573评论 2 359