双向数据绑定原理

1.数组的reduce方法

应用场景: 下次操作的初始值,依赖于上次操作的返回值

  • 数组的累加计算
const arr = [3, 8, 9 ,12, 89, 56, 43]

// 普通程序员的实现逻辑
let total = 0;
arr.forEach(item => {
    total += item;
})
console.log(total)


// reduce方法实现
// arr.reduce(函数, 初始值)
// arr.reduce((上次计算的结果, 当前循环的item) => {}, 0)
const total = arr.reduce((oldValue, item) => {
    return oldValue + item
}, 0)
console.log(total)
  • 链式获取对象属性的值
const obj = {
    name: 'zs',
    info: {
        address: {
            location: '北京顺义'
        }
    }
}

const attrs = ['info', 'address', 'location']

// 第一次reduce
    初始值是 obj 这个对象
    当前的 item 项是 info
    第一次 reduce 的结果是 obj.info 属性对应的对象
// 第二次reduce
    初始值是 obj.info 这个对象
    当前的 item 项是 address
    第二次reduce的结果是 obj.info.address 属性对应的对象
// 第三次reduce
    初始值是 obj.info.address 这个对象
    当前的 item 项是 location
    第三次reduce的结果是 obj.info.address.location 属性的值
const val = attrs.reduce((newObj, k) => {
     return newObj[k]
}, obj)
console.log(val)

2.发布订阅模式

1. Dep类

  • 负责进行依赖收集
  • 首先有个数组专门来存放所有的订阅信息
  • 其次,还要提供一个向数组中追加订阅信息的方法
  • 然后,还要提供一个循环,循环触发数组中的每个订阅信息

2. Watcher类

  • 负责订阅一些事件
// 收集依赖/收集订阅者
class Dep {
    constructor() {
        // 这个 subs 数组,用来存放所有订阅者的信息
        this.subs = []
    }
    
    // 向 subs 数组中,添加订阅者信息
    addSub(watcher) {
        this.subs.push(watcher)
    }
    
    // 发布通知(订阅)的方法
    notify() {
        this.subs.forEach(watcher => watcher.update())
    }
}

// 订阅者的类
class Watcher {
    constructor(cb) {
        // 这里的作用就是cb回调函数,根据得到的最新数据来更新自己的DOM结构的
        this.cb = cb
    }
    
    update() {
        this.cb()
    }
}

const w1 = new Watcher(() => {
    console.log('我是第一个订阅者')
})

const w2 = new Watcher(() => {
    console.log('我是第二个订阅者')
})

// 将w1 和 w2这两个观察者放入 Dep 的 subs 数组中
const dep = new Dep()
dep.addSub(w1)
dep.addSub(w2)

// 只要我们为 Vue 中 data 数据重新赋值了,这个赋值操作,会被 Vue 监听到
// 然后 Vue 要把数据的变化,通知到每个订阅者
// 接下来,订阅者(DOM元素)要根据最新的数据,更新自己的内容
dep.notify()

这里 Vue 要做的事情就是要把 data 的变化通知到每一个订阅者,在这里每一个订阅者就是DOM元素,当 Vue 发现数据变化的时候会通知到每个订阅者拿到最新的数据,这里通过 dep.notify 方法来执行watcher中的 update 方法,update 方法中的回调函数来实现 DOM 元素数据的更新

3.使用 Object.defineProperty() 进行数据劫持

  • 通过 get() 劫持取值操作
  • 通过 set() 劫持赋值操作
    Object.defineProperty 语法,在 MDN 上是这么定义的:

Object.defineProperty(obj, prop, descriptor)

(1)参数

  • obj

    要在其上定义属性的对象。

  • prop

    要定义或修改的属性的名称。

  • descriptor

    将被定义或修改的属性描述符。

(2)返回值

被传递给函数的对象。

(3)属性描述符

Object.defineProperty() 为对象定义属性,分 数据描述符 和 存取描述符 ,两种形式不能混用。

数据描述符和存取描述符均具有以下可选键值:

  • configurable

当且仅当该属性的 configurabletrue 时,该属性描述符才能够被改变,同时该属性也能从对应的对象上被删除。默认为 false

  • enumerable

当且仅当该属性的 enumerabletrue 时,该属性允许被循环。默认为 false

Object.defineProperty(obj, 'name', {
    enumerable: true, // 当前属性,允许被循环
    configurable: true // 当前属性允许被配置 delete
})

存取描述符具有以下可选键值

  • get

一个给属性提供 getter 的方法,如果没有 getter 则为 undefined。当访问该属性时,该方法会被执行,方法执行时没有参数传入,但是会传入this对象(由于继承关系,这里的this并不一定是定义该属性的对象)。默认为 undefined

  • set

一个给属性提供 setter 的方法,如果没有 setter 则为 undefined。当属性值修改时,触发执行该方法。该方法将接受唯一参数,即该属性新的参数值。默认为 undefined

const obj = {
    name: 'zs',
    age: '23',
}

Object.defineProperty(obj, 'name', {
    get() {
        return '我不是zs'
    }
    set(newVal) {
        console.log('我不要你给的值', newVal)
        dep.notify()
    }
})

console.log(obj.name) // 我不是张三
// 这里如果没有`defineProperty`对属性进行get操作,那么打印结果应该是zs,但是通过get操作,这里的结果应该是:我不是zs,说明get方法可以拦截这个属性取值操作(getter)
obj.name = ls // 执行后结果为:我不要你给的值 ls
//说明set方法可以拦截这个属性的赋值操作(setter)

4.模拟Vue实现简单的双向数据绑定

  • 原理图:
双向数据绑定原理图
  • html部分:
 <div id="app">
    <h3>姓名是: {{name}}</h3>
    <h3>年龄是:{{age}}</h3>
    <h3>info.a的值是:{{info.a}}</h3>
    <div>name的值是:<input type="text" v-model="name" /></div>
    <div>info.a的值是:<input type="text" v-model="info.a" /></div>
  </div>
  <script src="./vue.js"></script>
  <script>
    const vm = new Vue({
      el: '#app',
      data: {
        name: 'zs',
        age: 20,
        info: {
          a: 'a1',
          b: 'b1'
        }
      }
    })
  </script>
  • vue.js内容:
class Vue {
  // options指向的就是传进来的对象
  constructor(options) {
    this.$data = options.data

    // 调用数据劫持的方法
    Observe(this.$data)

    // 属性代理
    // 我们希望只通过vm就能获取到data中第一层属性的值
    // 这里就比如我们在生命周期中获取data中属性 name 的值可以直接使用 this.name 就是因为我们做了属性代理
    // 即:获取 vm.name -> 自动去找 vm.$data.name vm在这里只是做了一个代理
    Object.keys(this.$data).forEach(key => {
      Object.defineProperty(this, key, {
        enumerable: true,
        configurable: true,
        get() {
          return this.$data[key]
          // 这里只要有访问vm获取data值的时候,它本身并没有,直接去找 $data 获取对应的值
        },
        set(newVal) {
          this.$data[key] = newVal
        }
      })
    })

    // 调用模板编译的函数
    Compile(options.el, this)
  }
}


// 定义一个数据劫持的方法
function Observe(obj) {
  // 这是递归的终止条件
  if(!obj || typeof obj !== 'object') return
  const dep = new Dep()

  // 通过 Object.keys 获取到 obj 上的每一个属性
  Object.keys(obj).forEach(key => {
    // 当前被循环的 key 所对应的属性值
    let value = obj[key]
    // 判断 value 是否是一个对象,如果是对象那么继续递归,如果不是,那么在开头就会被递归终止条件终止了
    // 把 value 这个子节点进行递归
    Observe(value)
    // 需要为当前的 key 所对应的属性添加 getter 和 setter
    Object.defineProperty(obj, key, {
      enumerable: true,
      configurable: true,
      // getter拦截取值后我们应该返回拦截属性所对应的值
      get() {
        // Dep.target 此时还没有为null,还是指向 Watcher 实例
        //只要执行了下面这一行,那么刚才 new 的 Watcher 实例
        // 就被放入了 dep.subs 这个数组中
        // target 所指向的 Watcher 实例,加到数组中
        Dep.target && dep.addSub(Dep.target)

        return value
      },
      // setter拦截赋值,应该把拦截属性当前值修改为新的值
      set(newVal) {
        value = newVal
        // 为新赋值的对象添加 getter 和 setter 
        Observe(value)

        // 通知每一个订阅者更新自己的文本
        dep.notify()
      }
    })
  })
}


// 对HTML结构进行模板编译的方法
function Compile(el, vm) {
  // 获取到的 dom 元素直接挂载到 vm 的 $el 上
  vm.$el = document.querySelector(el)
  
  // 创建文档碎片,提高 DOM 操作性能
  // 如果我们页面中有很多的插值表达式,那么我们要频繁的去更新 dom 元素的内容,这个时候会触发页面的重绘和重排。浪费我们的内存
  // 内容发生变化会触发重绘,定位和位置发生变化会触发重排
  // 这时候我们就要创建一个文档碎片,所谓文档碎片就是一块内存,把页面的每个 dom 节点都存进去
  // 这时候页面中就没有这个 dom 节点了,我们这时候直接在内存中操作 dom 元素
  // 由于文档碎片不在页面上,所以我们这时候随意修改也不会触发重绘和重排
  const fragment = document.createDocumentFragment() // 创建文档碎片
  while(childNode = vm.$el.firstChild) {
    fragment.appendChild(childNode) // 把所有节点都放入文档碎片中,这时候页面中就没有 dom 节点了
  }

  // 再把文档碎片中的节点放回到页面中
  // 在这里进行模板编译
  // 因为在这一行之前页面中还没有dom节点,我可以在这个节点的时候dom元素还在文档碎片中放着呢
  // 此时我们可以操作文档碎片中的每个子节点进行编译,编译完成后在append回去就不会触发重绘和重排)
  Replace(fragment)

  vm.$el.appendChild(fragment)


  // 负责对 dom 节点进行编译的方法
  function Replace(node) {
    // 对插值表达式进行正则
    const regMustache = /\{\{\s*(\S+)\s*\}\}/
    // 证明当前的node节点是一个文本子节点,需要进行正则的替换
    if(node.nodeType === 3) {
      // 注意:文本子节点也是一个 dom 对象
      // 如果要获取文本子节点的字符串内容,需要调用 textContent 属性获取
      const text = node.textContent
      // 进行字符串的正则匹配与提取
      const execResult = regMustache.exec(text)
      if(execResult) {
        const value = execResult[1].split('.').reduce((newObj, k) => newObj[k], vm)
        node.textContent = text.replace(regMustache, value) // 这里的replace方法是字符串本身的方法

        // 在这个时候创建 watcher 类的实例
        // 为什么要在这里调用Watcher类?
        // 当执行到上面这行代码的时候,你是第一次知道怎么来更新自己
        // 这个时候你应该立即把怎么更新自己的代码存到cb这个回调函数中
        // 因为cb回调函数就是来记录怎么更新自己的
        // 怎么存到cb中?这时候需要new一个实例才能存到cb中
        new Watcher(vm, execResult[1], (newVal) =>{
          // 根据最新的value值来更新自己的文本内容
          node.textContent = text.replace(regMustache, newVal)
        })
      }


      // 终止递归的条件
      return
    }

    // 实现文本框数据绑定
    // 如果是一个 dom 节点,就要判断你身上有没有 v-model 这个属性
    // 如果存在我就认为你是一个文本框,并且要给你提供一个值
    // 判断当前的 node 节点是否为 input 输入框
    if(node.nodeType === 1 && node.tagName.toUpperCase() === 'INPUT') {
      const attrs = Array.from(node.attributes)
      const findResult = attrs.find(x => x.name === 'v-model')
      if(findResult) {

        // 获取到当前 v-model 属性的值 v-model="name" v-model="info.a"
        const expStr = findResult.value
        const value = expStr.split('.').reduce((newObj, k) => newObj[k], vm)
        node.value = value

        // 创建 Watcher 的实例
        new Watcher(vm, expStr, (newValue) => {
          node.value = newValue
        })

        // 监听文本框的 input 输入事件,拿到文本框最新的值,把最新的值更新到 vm 上即可
        node.addEventListener('input', (e) => {
          const keyArr = expStr.split('.')
          const obj = keyArr.slice(0, keyArr.length - 1).reduce((newObj, k) => newObj[k], vm) 
          obj[keyArr[keyArr.length - 1]] = e.target.value
        })
      }
    }

    // 走到这一步证明不是文本子节点,需要进行递归处理
    node.childNodes.forEach(child => Replace(child))

  }

}


// 我们只用 Object.defineProperty 我们只能实现在页面打开的一瞬间实现数据编译
// 但是后面页面数据发生变化的时候是没有办法重现渲染页面的
// 这时候就需要用到发布订阅模式来实现数据的实时更新
// 因为加了发布订阅就相当于每个dom订阅了数据更新的一个行为,只要数据更新就会自动进行发布

// 依赖收集的类/收集 watcher 订阅者的类
class Dep {
  constructor() {
    // 今后,所有的 watcher 都要存在这个数组中
    this.subs = []
  }

  // 向 subs 数组中添加 watcher 的方法
  addSub(watcher) {
    this.subs.push(watcher)
  }

  // 负责同志每一个 watcher 的方法
  notify() {
    this.subs.forEach((watcher) => watcher.update())
  }
}


// 订阅者的类
class Watcher {
  // cb 回调函数中,记录着当前 watcher 如何更新自己的文本内容
  // 但是,只知道如何更新自己还不行,还必须拿到最新的数据
  // 因此,还需要在 new Watcher 期间,把vm也传递进来(因为vm中存着最新的数据)
  // 除此之外,还需要知道在 vm 身上众多的数据中,哪个数据才是当前自己所需要的数据
  // 因此必须在 new Watcher 期间,指定watcher对应的数据的名字
  constructor(vm, key, cb) {
    this.vm = vm
    this.key = key
    this.cb = cb

    Dep.target = this
    // 当我们执行这一步的操作的时候可以拿到对应key的值,但是我们的目的不是为了拿到key的值
    // 因为这一步触发了 getter 方法,到这一步会暂缓下面的代码执行,跳到 getter 函数中,这就是我们的目的(具体看上面getter中操作)
    // 我们这里的真正目的是为了将 new Watcher 每次调用的观察者存入 Dep 数组中,要不然下次无法通知到它
    key.split('.').reduce((newObj, k) => newObj[k], vm)
    Dep.target = null
  }

  // watcher 实例需要有 update 函数,从而让发布者能够通知我们进行更新
  update() {
    const value = this.key.split('.').reduce((newObj, k) => newObj[k], this.vm)
    this.cb(value)
  }
}

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

推荐阅读更多精彩内容