如何实现vue的双向数据绑定

最近看了很多小伙伴写的实现MVVM框架,但是大多是列举了一堆代码,没有很清晰的讲述代码的原理,于是,我花了几天的时间,做了一下整理,看看VUE是如何实现数据双向绑定的,希望对大家学习vue数据双向绑定提供借鉴。
源码在此

Vue的数据绑定写法

先看一下用Vue是怎么写的双向绑定。
代码如下:

<div id="app">
  <input type="text" v-model="message">
  {{message}}
</div>

new Vue({
  el:'#app',
  data:{
    message:'hello world'
  }
})

以上代码,我们能看到如下视图:


image.png

分析如何实现Vue数据双向绑定功能

  1. vue中视图上出现很多 {{message}},v-model,v-text等等模板,我们要对其进行编译。
  2. 数据变化的时候,会动态更新到视图上,使用的Object.defineProperty(),进行数据劫持。
  3. 通过Watcher观察数据的变化,然后重新编译模板,渲染到视图上
    image.png

具体步骤如下

步骤一

自己定义一个Mvvm方法,取代Vue进行模板编译。
html中代码如下:

<div id="app">
  <input type="text" >
  <div>{{message}}</div>
</div>
<script>
  let vm = new Mvvm({//我们自己构造一个Mvvm去实现Vue的功能
    el:'#app',
    data:{
      message:'hello world'
    }
  })
</script>

可以看到,我们在new Mvvm的时候,给其传递了一个对象,这个对象中包含两个属性,eldata。根据这两个属性,对视图进行编译。因此下面我们要写这个Mvvm中的函数体,来实现数据传递,让模板对视图进行编译。

Mvvm函数代码的原理:接收传递过来的参数,得到挂载的节点,然后对节点的内容进行编译,代码如下:

class Mvvm{
  constructor(options){
    this.$el=options.el;
    this.$data=options.data;
    if(this.$el){
      new Compile(this.$el,this);//这里将节点和`实例传给complie进行处理
    }
  }
}

可以看到,在代码的最后,我们把这个节点交给了Compile这个函数进行处理,而这个函数的功能就是实现模板的编译。

步骤二

实现模板的编译

class Compile{
  constructor(el,mvvm){//接收传递过来的两个参数,节点和实例对象
    this.el=document.querySelector(el);
    this.mvvm=mvvm;//将传递的参数放在实例上
  }
}

分析:用Compile获取到这个节点和mvvm实例后,我们要对其进行编译。编译可分为如下三个部分:

  1. 先把这个 DOM 放在内存中
  2. 编译出元素节点(v-model、v-text...)和文本节点{{message}}
  3. 将编译好的内容放回到页面中

根据上述三个部分,逐一对代码进行改进

1.将 DOM 放入内存
class Compile{
  constructor(el,mvvm){//接收传递过来的两个参数,节点和实例对象
    this.el=document.querySelector(el);
    this.vm=vm;//将传递的参数放在实例上
    if(this.el){
      let fragment=this.nodeToFragment(this.el);//将节点放入内存中
    }
  }
  nodeToFragment(el){
    let fragment=document.creatDocumentFragment();
    let firstChild;
    while(firstChild=el.firstChild){
      fragment.appendChild(firstChild)
    }
    return fragment;
  }
}
2.将内存中的代码进行编译

编译要分为元素节点编译和文本编译,即v-model,v-text的编译和{{message}}类型文本编译,因此针对不同的内容,要书写不同的编译方法。

因此首先要判断节点的类型,如果是元素节点,则应判断其是否包含v-modelv-text指令,如果包含,则对齐内容进行编译。
如果是文本节点,则应用正则匹配判断其是否包含{{message}},如果包含,则用正则进行替换。

Compile中的constructor具体代码如下:
constructor(el,vm){
  this.el=this.isElementNode(el)?el:document.querySelector(el);
  this.vm=vm;
  if(this.el){
    let fragment=this.nodeTofragment(this.el);//将代码放入内存
    this.compile(fragment);//在内存中进行编译
    this.el.appendChild(fragment)//编译完成后放回到页面
  }
}
Compile原型中增加方法:
  1. complie方法

遍历节点,判断是否为元素节点,如果是,则编译节点,并递归调用子节点。如果不是元素节点,则编译文本节点。

compile(fragment){
  let childNodes=fragment.childNodes;

  Array.from(childNodes).forEach(node=>{
    if(this.isElementNode(node)){
      this.compileElement(node);
      this.compile(node);  //这里要进行递归调用,编译节点的节点
    }else{
      this.compileText(node)
    }
  })
}
//判断是否为节点
isElementNode(node){
  return node.nodeType===1;
}
  1. compileElement方法(编译元素节点方法)
    判断元素节点是否包含v-model或v-text指令
    如果包含则做相应的编译
compileElement(node){
  let attrs=node.attributes;//取到节点的属性
  Array.from(attrs).forEach(attr=>{
    let attrName=attr.name;
    if(this.isDirective(attrName)){
      let expr=attr.value;
      let [,type]=attrName.split('-');
      CompileUtil[type](node,this.vm,expr) //这里定义了编译元素的方法,代码在后面
    }
  })
}
//判断是否包含 v- 属性
isDirective(name){
  return name.includes('v-');
}
  1. compileText方法(编译文本节点方法)
compileText(node){//编译\{\{\}\}
  let expr=node.textContent;//取文本中的内容,进行正则匹配,然后替换
  let reg=/\{\{([^}]+)\}\}/g; //{{a}},{}
  if(reg.test(expr)){
    CompileUtil['textNode'](node,this.vm,expr)
  }
}
  1. CompileUtil方法

CompileUtil中定义了具体的针对元素节点不同指令,以及文本的编译的方法。

注意:data中的数据可能是对象中嵌套对象,所以要层层取值,因此需要用到下面的getVal方法。

CompileUtil={
  getVal(vm,expr){
    let xxx=expr.split('.');//[a,v]
    return xxx.reduce((prev,next)=>{
      return prev[next];
    },vm.$data);
  },
  textNode(node,vm,expr){ //{{message}} 编译
    let updateFn=this.updater['textUpdater'];

    let value=expr.replace(/\{\{([^}]+)\}\}/g,(...arguments)=>{
      return this.getVal(vm,arguments[1]);
    })
    updateFn&&updateFn(node,value)

  },
  text(node,vm,expr){//v-text编译
    let updateFn=this.updater['textUpdater'];
    updateFn&&updateFn(node,this.getVal(vm,expr))
  },
  model(node,vm,expr){//v-model编译
    let updateFn=this.updater['modelUpdater'];
    updateFn&&updateFn(node,this.getVal(vm,expr))
  },
  updater:{
    textUpdater(node,value){
      node.textContent=value;
    },
    modelUpdater(node,value){
      node.value=value;
    }
  }
}

此时你能看到,已经能将Mvvm中的data数据,编译成我们想要看到的视图了。


image.png

但是这个视图只是静态视图,当你改变data中的数据时,并不能引起视图的更新,因此我们必须用到数据劫持,即在编译前,对数据进行劫持

步骤三

实现

1. 改进Mvvm中代码,在编译前加上数据劫持,代码如下:

class Mvvm{
  constructor(options){
    this.$el=options.el;
    this.$data=options.data;
    if(this.$el){
      new Observer(this.$data);//在Mvvm中加上观察者
      new Compile(this.$el,this);
    }
  }
}

2. 书写Observer中的代码

1.在函数体中,对Observer中的每个属性一一劫持

注意: 有可能data中还包含对象,因此我们要用到递归调用,对data中的值再做一次劫持

class Observer{
  constructor(data){
    this.Observer(data);
  }
  observer(data){
    if(!data||typeof data === 'object'){
      return;
    }
    //将数据一一劫持 先获取 data 的 key 和value
    Object.keys(data).forEach(key=>{
      //劫持
      this.defineReactive(data,key,data[key]);
      this.observer(data[key]);//递归调用
    })
  }
}

关键部分来了
定义双向数据绑定

defineReactive(obj, key, value) {
  let that=this;
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get() {
      return value;
    },
    set(newValue) {
      if (newValue !== value) {
        that.observer(newValue)
        value = newValue;
      }
    }
  })
}

注意:要在set数据时再进行一次劫持

步骤四

定义观察者
给观察者的原型上定义一个更新的方法,当数据发生更新时,调用该方法。

class Watcher{
  constructor(vm,expr,callback){
    this.vm=vm;
    this.expr=expr;
    this.callback=callback;
    this.value=this.get(vm,expr)
  }
  getVal(vm,expr){
    let xxx=expr.split('.');//[a,v]
    return xxx.reduce((prev,next)=>{
      return prev[next];
    },vm.$data);
  }
  get(){
    let value=this.getVal(this.vm,this.expr);
    return value;
  }
  update(){
    let newValue=this.getVal(this.vm,this.expr);
    let oldValue=thisvalue;
    if(newValue!=oldValue){
      this.callback(newValue)
    }
  }
}

定义完后,将CompileUtil的代码进行如下修改:
给每个模板编译都new一个Watcher,,并将对应的实例,表达式和方法传过去。

CompileUtil={
  getVal(vm,expr){
    let xxx=expr.split('.');//[a,v]
    return xxx.reduce((prev,next)=>{
      return prev[next];
    },vm.$data);
  },
  getTextVal(vm,expr){
    return expr.replace(/\{\{([^}]+)\}\}/g,(...arguments)=>{
      return this.getVal(vm,arguments[1]);
    })
  },
  textNode(node,vm,expr){
    let updateFn=this.updater['textUpdater'];

    let value=this.getTextVal(vm,expr)
    expr.replace(/\{\{([^}]+)\}\}/g,(...arguments)=>{
      new Watcher(vm,arguments[1],(newValue)=>{
        //如果数据变化了,文本节点需要重新获取依赖的属性更新文本的内容
        updateFn&&updateFn(node,this.getTextVal())
      })
    })
    updateFn&&updateFn(node,value)
  },
  text(node,vm,expr){//v-text处理
    let updateFn=this.updater['textUpdater'];
    new Watcher(vm,expr,(newValue)=>{
      //当值变化后调用 callback 
      updateFn&&updateFn(node,this.getVal(vm,expr))
    })
    updateFn&&updateFn(node,this.getVal(vm,expr))
  },
  model(node,vm,expr){//v-model输入框处理
    let updateFn=this.updater['modelUpdater'];
    new Watcher(vm,expr,(newValue)=>{
      //当值变化后调用 callback 
      updateFn&&updateFn(node,this.getVal(vm,expr))
    })
    updateFn&&updateFn(node,this.getVal(vm,expr))
  },
  updater:{
    textUpdater(node,value){
      node.textContent=value;
    },
    modelUpdater(node,value){
      node.value=value;
    }
  }
}

此时可以发现,虽然定义了Watcher并且在编译模板的时候也创建了实例,但并未对齐进行调用,因此下面将对其进行调用

定义Dep,在其原型上有两个方法,addSubwatcher实例添加到subs数组中,notify调用watcher实例中的update方法

class Dep{
  constructor(){
    //订阅的数组
    this.subs=[];
  }
  addSub(watcher){
    this.subs.push(watcher)
  }
  notify(){
    this.subs.forEach(watcher=>{
      watch.update()
    })
  }
}

Dep定义完后要对其进行调用
我们注意到,在编译模板的时候,调用new Watcher,而new Watcher的时候会进行取值,而取值又会调用Watcherget方法,因此我们可以在其中添加如下
解释: 将这个watcher实例赋值给Dep.target,然后调用取值函数,由于这个数被劫持,所以可以在劫持的get中进行操作。

get(){
  Dep.target=this;
  let value=this.getVal(this.vm,this.expr);
  Dep.target=null;
  return value;
}

并将Observer中的defineReactive修改如下
get数据的同时,将target放入当前实例的的数组中

defineReactive(obj, key, value) {
  let that=this;
  let dep=new Dep()
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get() {
      Dep.target&&dep.addSub(dep.target)
      return value;
    },
    set(newValue) {
      if (newValue !== value) {
        that.observer(newValue)
        value = newValue;
        dep.notify()
      }
    }
  })
}

到此就实现了一个MVVM。

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

推荐阅读更多精彩内容

  • 前言 使用vue也好有一段时间了,虽然对其双向绑定原理也有了解个大概,但也没好好探究下其原理实现,所以这次特意花了...
    指尖跳动阅读 7,982评论 0 16
  • 剖析Vue原理、实现双向绑定MVVM 几种实现双向绑定的做法 目前几种主流的mvc(vm)框架都实现了单向数据绑定...
    不得不爱XIN阅读 725评论 0 1
  • 剖析Vue实现原理 - 如何实现双向绑定mvvm 本文能帮你做什么?1、了解vue的双向数据绑定原理以及核心代码模...
    C楚辉H阅读 8,314评论 0 5
  • # 前言 ? MVVM 是与 MVC 进化出来的,区别在与将view层的数据变动直接响应到viewModel层上而...
    果汁凉茶丶阅读 1,083评论 0 9
  • 今天上午一节微机课,听讲还算挺认真的。 坐在第一排,但是旁边的人在玩手机的时候,自己内心看着也挺想玩的,克制住也花...
    梦夕梦阅读 151评论 0 1