Vue2 响应式原理

写在最前:本文转自掘金

Vue2.X响应式原理

缺点说明

  • 代理对象只对初始的属性有监听作用,而对新增的属性无效
  • 在原对象上代理,而proxy会生成代理对象
  • 仅代理对象属性,而proxy代理整个对象

1. defineProperty 的应用

语法:Object.defineProperty(obj, property, descriptor)

  • obj 绑定属性的目标对象
  • property 绑定属性名
  • descriptor 属性描述(配置),且此参数本身为一个对象
    • 属性值1:value,设置属性默认值
    • 属性值2:writable,设置属性是否能够修改
    • 属性值3: enumerable,设置属性是否可以枚举,即是否允许遍历
    • 属性值4: confingurable,设置属性是否可以删除或编辑
    • 属性值5:get,获取属性的值
    • 属性值6:set,设置属性的值

在Vue2.X 响应式中使用到了 defineProperty进行数据劫持,所以我们必须要对它有一定了解,我们先简单模拟下Vue中的data

<body>
  <div id="app"></div>
  <script>
    // 将 data 对象上的属性 msg 代理到 vm 对象上,在对 vm 对象数据做修改时,会同步修改 data 数据并作出页面渲染
    // 为了避免循环调用,setter 与 getter 方法名不可与属性名相同
    // 模拟Vue的data
    let data = {
      msg: ' '
    };
    // 模拟 Vue 实例
    let vm = {}
    // 对vm 的msg进行数据劫持
    Object.defineProperty(vm, 'msg', {
      // 获取数据
      get() {return data.msg},
      // 设置set 
      set(newValue){
        // 如果传入的值相等就不用修改
        if(newValue === data.msg) return
        // 修改数据
        data.msg = newValue
        document.querySlector('#app').textContent = data.msg
      }
    })
    vm.msg = '123'
  </script>
</body>

在控制台上修改vm.msg 数据是响应式的

2. defineProperty 修改多个参数为响应式

上面代码只能修改一个属性,实际上我们会有多个属性,所以需要定义个方法把data 中的数据进行遍历劫持。

<body>
  <div id="app"></div>
  <script>
    // 模拟 Vue的data
    let data={
      msg: 'info',
      age: 12,
    }
    // 模拟Vue 实例
    let vm = {}
    // 把多个属性转化响应式
    function proxyData() {
      // 把data中每一项都[msg, age]拿出来操作
      Object.key(data).forEach((key)=>{
        // 对vm 的属性进行数据劫持
        Object.defineProperty(vm, key, {
          // 可枚举
          enumerable: true,
          // 可配置
          configurable: true,
          // 获取数据
          get() {return data[key]},
          // 设置set 
          set(newValue){
          // 如果传入的值相等就不用修改
            if(newValue === data[key]) return
            // 修改数据
            data[key]= newValue
            document.querySlector('#app').textContent = data[key]
          }
        })
      })
    }
    proxyData(data)
  </script>
</body>

6. 模拟Vue的响应式原理

这里来实现一个小型简单的Vue 主要有一下功能

  • 接受初始化的参数,这里只举几个简单的例子 el data options
  • 通过私有方法 _proxyDatadata 注册到 Vue 中,转成getter setter
  • 使用 observer 把data 中的属性转换为响应式添加到自身身上
  • 使用 observer 对象监听 data 的所有属性变化来,通过观察者模式更新视图
  • 使用 compiler 编译元素节点上面指令 和 文本节点
1. Vue.js

在这里 获取到 el data
通过 _proxyData把data的属性 注册到Vue 并转成 getter setter

/* vue.js */

class Vue{
  constructor(options) {
    // 获取到传入的对象 没有默认空对象
    this.$options = options || {}
    // 获取 el
    this.$el = typeof options.el == 'string' ? document.querySelector(options.el) : options.el
    // 获取 data
    this.$data = options.data || {}
    // 调用 _proxyData 处理 data中属性
    this._proxyData(this.$data)
  }
  // 把data 中的属性注册到 Vue
  _proxyData(data){
    Object.keys(data).forEach((key)=>{
      // 进行数据劫持,把每个data的属性 添加到 Vue 转化为 getter setter 方法
      Object.defineProperty(this, key, {
        // 设置可以枚举
        enumerable: true,
        // 设置可以配置
        configurable: true,
        // 获取数据
        get() {
          return data[key]
        },
        set(newValue){
          // 判断新值和旧值是否相等
          if(newValue === data[key]) return 
          // 设置新值
          data[key] = newValue
        }
      })
    })
  }
}
2. observer.js

在这里把 data中的属性变为响应式加载自身身上,还有一个主要功能就是观察者模式在 第4.dep.js 会有详细的使用

/*observer.js*/

class Observer {
  constructor(data){
    // 用来遍历 data
    this.walk(data)
  }
  // 遍历 data 转为响应式
  walk(data){
    // 判断data是否为空 和 非对象
    if(!data || typeof data !== 'object') return 
    // 遍历 data
    Object.keys(data).forEach((key)=>{
      // 转为响应式
      this.defineReactive(data, key, data[key])
    })
  }
  // 转为响应式
  // 要注意 和 vue.js 写法不同的是,vue.js中是将属性给了Vue转为getter setter
  // 这里是将 data中的属性转为getter setter
  defineReactive(obj, key, value){
    // 如果是对象类型 的 也调用walk 变成响应式,不是对象类型的直接在walk会被return
    this.walk(value)
    // 保存一下 this
    const self = this
    Object.defineProperty(obj, key, {
      enumerable: true,   // 设置可枚举
      configurable: true,  // 可配置
      // 获取值
      get(){
        return value
      },
      // 设置值
      set(newValue){
        // 判断旧值和新值是否相等
        if(newValue === value) return
        // 设置新值
        value = newValue
        // 注意,如果newValue 是对象的话,还需要将对象里的属性设置为响应式
        self.walk(newValue)
      }
    })
  }
}

在html中引入的话注意下顺序

<script src="./js/observer.js"></script>
<script src="./js/vue.js"></script>

然后在vue.js中使用observer

class Vue{
  constructor(options){
    ...
    // 使用 Observer 把data中的数据转为响应式
    new Observer(this.$data)
  }
  // 把 data 中的属性注册到 Vue
  _proxyData(data){
    ...
  }
}

看到这里为什么做了两个重复性的操作呢?重复两次把data的属性转为响应式
在observer.js中把data的所有属性加到data自身变为响应式转成getter sertter 方式
在vue.js中 也把data的所有属性加到Vue上,是为了以后方便操作可以用Vue的实例直接访问到或者在Vue中使用this访问
现在来使用一下例子

<body>
    <div id="app"></div>
    <script src="./js/observer.js"></script>
    <script src="./js/vue.js"></script>
    <script>
      let vm = new Vue({
        el: '#app',
        data: {
          msg: '123',
          age: 21,
        },
      })
    </script>
  </body>
watermark13.jpg

这样在Vue 和 $data 中都存在了 所有的data属性了,并且是响应式的

3. compiler.js

comilper.js 在这个文件里实现对文本节点和 元素节点指令编译,主要是为了举例子,这个写的很简单,指令主要实现 v-text v-model

/*compiler.js*/

class Compiler {
  // vm 指Vue实例
  constructor(vm){
    // 拿到 vm
    this.vm = vm
    // 拿到 el
    this.el = vm.$el
    // 编译模板
    this.compile(this.el)
  }
  // 编译模板
  compile(el){
    // 获取子节点 如果使用 forEach遍历就把伪数组转为真数组
    let childnodes = [...el.childNodes];
    childenodes.forEach((node)=>{
      // 根据不同的节点类型进行编译
      if(this.isTextNode(node)){
        // 编译文本
        this.compileText(node)
      } else if(this.isElementNode(node)){
        // 元素节点
        this.compileElement(node)
      } //判断是否还存在子节点 需考虑递归
      if(node.childNodes && node.childNodes.length) {
        // this.compile(node)
      }
    })
  }
  // 编译文本节点(简单的实现)
  compileText(node){
    // 核心思想利用正则表达式把{{}}去掉找到里面的变量
    // 再去Vue找到这个变量赋值给node.textContent
    let reg = /\{\{(.+?)\}\}/
    // 获取节点的文本内容
    let val = node.textContent
    // 判断是否含有{{}}
    if(reg.text(val)){
      // 获取分组一 也就是{{}}里面的内容 去掉前后空格
      let key = RegExp.$1.trim()
      // 进行替换再赋值给node
      node.textContent = val.replace(reg, this.vm[key])
    }  
  }
  // 编译元素节点这里只处理指令
  compileElement(node){
    // 获取到元素节点上面的所有属性进行遍历
    [...node.attributes].forEach((attr)=>{
      // 获取属性名
      let attrName = attr.name
      // 判断是否是 v- 开头的指令
      if(this.isDirective(attrName)){
        // 去除 v- 方便操作
        attrName = attrName.substr(2)
        // 获取指令的值就是 v-text = 'msg' 中msg
        // msg 作为key 去 vue 找这个变量
        let key = attr.value
        // 指令操作 执行指令方法
        // vue指令很多为了避免大量的 if 判断这里就写了个 update 方法
        this.update(node, key, attrName)
      }
    })
  }
  // 添加指令方法 并且执行
  update(node, key, attrName){
    // 比如添加 textUpdater 就是用来处理 v-text 方法
    // 我们应该就内置一个 textUpdater 方法进行调用
    // 加个后缀,加什么无所谓,但要定义相应的方法
    let updateFn = this[attrName + 'Updater']
    // 如果存在这个内置方法 就可以调用了
    updateFn && updateFn(node, key, this.vm[key])
  }
  // 提前写好 相应的指定方法 比如 v-text
  // 使用的时候 和 Vue的一样
  textUpdater(node, key, value){
    node.textContent = value
  }
  // v-model
  modelUpdater(node, key, value){
    node.value = value
  }
  // 判断元素的属性是否是 Vue 指令
  isDirective(attr){
    return attr.startsWith('v-')
  }
  // 判断是否是元素节点
  isElementNode(node){
   return node.nodeType == 1  
  }
  // 判断是否是文本节点
  isTextNode(node){
    return node.nodeType == 3
  }
}
4. dep.js

写一个Dep类,它相当于观察者中的目标对象,每个响应式属性都会创建这么一个Dep对象,负责收集该依赖属性的Watcher对象(是在使用响应式数据的时候做的操作)
当我们对响应式属性在 setter 中进行更新的时候,会调用Dep 中的 notify 方法发送更新通知
然后去调用 Watcher中的update实现视图的更新操作(是当数据发生变化时候去通知观察者调用观察者的update更新视图)
总的来说 Dep(这里指目标对象)中负责收集依赖,添加观察者(Wather),然后在setter数据更新的时候通知观察者。
先写Dep类

/*dep.js*/
class Dep{
  constructro(){
    // 储存观察者
    this.subs = []
  }
  // 添加观察者
  addSub(sub){
    // 判断观察者是否存在 和 是否拥有update方法
    if(sub && sub.update){
      this.subs.push(sub)
    }
  }
  // 通知方法
  notify(){
    // 触发每个观察者的更新方法
    this.subs.forEach((sub)=>{
      sub.update()
    })
  }
}

在observer.js中使用Dep
在get中添加Dep.target(观察者)
在set中触发notify(通知)

/* observer.js */
 
class Oberver {
  ...
  walk(data){...}
  definReative(obj, key, value){
    ...
    // 创建Dep对象
    let dep = new Dep()
    Object.defineProperty(obj, key, {
      ...
      // 获取值
      get(){
        // 在这里添加观察者对象 Dep.target 标识观察者
        Dep.target && dep.addSub(Dep.target)
        return value
      },
      // 设置值
      set(newValue) {
        if(newValue === value) return
        value = newValue
        self.walk(newValue)
        // 触发通知更新视图
        dep.notify()
      }
    })
  }
}
5. watcher.js

watcher.js的作用是,数据更新收到通知后,调用update进行更新

/* watcher.js*/

class Watcher{
  constructor(vm, key, cb){
    // vm是Vue实例
    this.vm = vm
    // key是 data中的属性
    this.key = key
    // cb 是回调函数 更新视图的具体方法
    this.cb = cb
    // 把观察者的存放在 Dep.target
    Dep.target = this
    // 旧数据 更新视图的时候要进行比较
    // 还有一点就是 vm[key] 这个时候就触发 get方法
    // 之前在get 把观察者 通过dep.addSub(Dep.target) 添加到了 dep.subs中
    this.oldValue = vm[key]
    // Dep.target 就不用存在了 因为上面的操作已经存好了
    Dep.target = null 
  }
  // 观察者中的必备方法 用来更新视图
  update(){
    // 获取新值
    let newValue = this.vm[this.key]
    // 比较旧值和新值
    if(newValue === this.oldValue) return 
    // 调用具体的更新方法
    this.cb(newValue)
  }
}

那么去哪创建Watcher呢?还记得在compiler.js中对文本节点编译操作吗
在编译完文本节点后,在这里添加一个Wather
还有 v-text v-model 指令,当编译的事元素节点 就添加一个Watcher

/* compiler.js */
class Compiler{
  // vm 指 Vue实例
  constructor(vm){...}
  compile(el){...}
  // 编译文本节点
  compileText(node){
    ...
    if(reg.test(val)){
      let key = RegExp.$1.trim()
      node.textContent = val.replace(reg, this.vm[key])
      // 创建观察者
      new Watcher(this.vm, key, newValue =>{
        node.textContent = newValue
      })
    }
  }
  ...
}

8. 五个文件代码

/*Vue */
class Vue {
  constructor(options) {
    // 获取到传入的对象 没有默认空对象
    this.$options = options || {}
    // 获取 el
    this.$el = typeof options.el == 'string' ? document.querySelector(options.el) : options.el
    // 获取 data
    this.$data = options.data || {}
    // 调用 _proxyData 处理 data中属性 转为响应式,添加到Vue身上
    this._proxyData(this.$data)
    // 把data中的属性转为响应式,添加到自身身上
    new Observer(this.$data)
    // 编译模板
    new Compiler(this)
  }
  // 把data 中的属性注册到 Vue
  _proxyData(data) {
    Object.keys(data).forEach((key) => {
      // 进行数据劫持,把每个data的属性 添加到 Vue 转化为 getter setter 方法
      Object.defineProperty(this, key, {
        // 设置可以枚举
        enumerable: true,
        // 设置可以配置
        configurable: true,
        // 获取数据
        get() {
          return data[key]
        },
        set(newValue) {
          // 判断新值和旧值是否相等
          if (newValue === data[key]) return
          // 设置新值
          data[key] = newValue
        }
      })
    })
  }
}
/*Observer */
class Observer {
  constructor(data) {
    // 用来遍历 data
    this.walk(data)
  }
  // 遍历 data 转为响应式
  walk(data) {
    // 判断data是否为空 和 非对象
    if (!data || typeof data !== 'object') return
    // 遍历 data
    Object.keys(data).forEach((key) => {
      // 转为响应式
      this.defineReactive(data, key, data[key])
    })
  }
  // 转为响应式
  // 要注意 和 vue.js 写法不同的是,vue.js中是将属性给了Vue转为getter setter
  // 这里是将 data中的属性转为getter setter
  defineReactive(obj, key, value) {
    // 如果是对象类型 的 也调用walk 变成响应式,不是对象类型的直接在walk会被return
    this.walk(value)
    // 保存一下 this
    const self = this
    // 创建 Dep 对象
    let dep = new Dep()
    Object.defineProperty(obj, key, {
      enumerable: true, // 设置可枚举
      configurable: true, // 可配置
      // 获取值
      get() {
        // 编译模板的时候会创建炸弹,创建炸弹的时候会调用此方法,并且传入Dep.target(炸弹)
        // 在这里添加观察者对象 Dep.target 表示观察者
        Dep.target && dep.addSub(Dep.target)
        return value
      },
      // 设置值
      set(newValue) {
        // 判断旧值和新值是否相等
        if (newValue === value) return
        // 设置新值
        value = newValue
        // 注意,如果newValue 是对象的话,还需要将对象里的属性设置为响应式
        self.walk(newValue)
         // 触发通知 更新视图
         dep.notify()
      }
    })
  }
}
/*Compiler */
class Compiler {
  // vm 指Vue实例
  constructor(vm){
    // 拿到 vm
    this.vm = vm
    // 拿到 el
    this.el = vm.$el
    // 编译模板
    this.compile(this.el)
  }
  // 编译模板
  compile(el){
    // 获取子节点 如果使用 forEach遍历就把伪数组转为真数组
    let childnodes = [...el.childNodes];
    childnodes.forEach((node)=>{
      // 根据不同的节点类型进行编译
      if(this.isTextNode(node)){
        // 编译文本
        this.compileText(node)
      } else if(this.isElementNode(node)){
        // 元素节点
        this.compileElement(node)
      } // 判断是否还存在子节点 需考虑递归
      if(node.childNodes && node.childNodes.length) {
        this.compile(node)
      }
    })
  }
  // 编译文本节点(简单的实现)
  compileText(node){
    // 核心思想利用正则表达式把{{}}去掉找到里面的变量
    // 再去Vue找到这个变量赋值给node.textContent
    let reg = /\{\{(.+?)\}\}/
    // 获取节点的文本内容
    let val = node.textContent
    // 判断是否含有{{}}
    if(reg.test(val)){
      // 获取分组一 也就是{{}}里面的内容 去掉前后空格
      let key = RegExp.$1.trim()
      // 进行替换在赋值给node
      node.textContent = val.replace(reg, this.vm[key])
      // 创建炸弹的时候,会把当前的实例对象(炸弹)挂载在Dep构造函数的原型上,在调用data的get方法自动触发装载当前炸弹,再销毁Dep构造函数原型上的炸弹
      new Watcher(this.vm, key, newValue => {
        node.textContent = newValue
      })
    }  
  }
  // 编译元素节点这里只处理指令
  compileElement(node){
    // 获取到元素节点上面的所有属性进行遍历
    [...node.attributes].forEach((attr)=>{
      // 获取属性名
      let attrName = attr.name
      // 判断是否是 v- 开头的指令
      if(this.isDirective(attrName)){
        // 去除 v- 方便操作
        attrName = attrName.substr(2)
        // 获取指令的值就是 v-text = 'msg' 中msg
        // msg 作为key 去 vue 找这个变量
        let key = attr.value
        // 指令操作 执行指令方法
        // vue指令很多为了避免大量的 if 判断这里就写了个 update 方法
        this.update(node, key, attrName)
      }
    })
  }
  // 添加指令方法 并且执行
  update(node, key, attrName){
    // 比如添加 textUpdater 就是用来处理 v-text 方法
    // 我们应该就内置一个 textUpdater 方法进行调用
    // 加个后缀,加什么无所谓,但要定义相应的方法
    let updateFn = this[attrName + 'Updater']
    // 如果存在这个内置方法 就可以调用了(当前执行函数的环境不同,所有需要指定方法里的this为当前this)
    updateFn && updateFn.call(this,node, key, this.vm[key])
  }
  // 提前写好 相应的指定方法 比如 v-text
  // 使用的时候 和 Vue的一样
  textUpdater(node, key, value){
    node.textContent = value
    // 创建观察者2
    new Watcher(this.vm, key, (newValue) => {
      node.textContent = newValue
    })
  }
  // v-model
  modelUpdater(node, key, value){
    node.value = value
    // 创建观察者
    new Watcher(this.vm, key, (newValue) => {
      node.value = newValue
    })
    // 这里实现双向绑定 监听input 事件修改 data中的属性
    node.addEventListener('input', () => {
      this.vm[key] = node.value
    })
  }
  // 判断元素的属性是否是 Vue 指令
  isDirective(attr){
    return attr.startsWith('v-')
  }
  // 判断是否是元素节点
  isElementNode(node){
   return node.nodeType == 1  
  }
  // 判断是否是文本节点
  isTextNode(node){
    return node.nodeType == 3
  }
}
/*Dep */
// 装炸弹的容器类
class Dep {
  constructor() {
    // 存储观察者
    this.subs = []
  }
  // 添加观察者
  addSub(sub) {
    // 判断观察者是否存在 和 是否拥有update方法
    if (sub && sub.update) {
      this.subs.push(sub)
    }
  }
  // 通知方法
  notify() {
    // 触发每个观察者的更新方法
    this.subs.forEach((sub) => {
      sub.update()
    })
  }
}
/*Watcher */
// 炸弹类
class Watcher {
  constructor(vm, key, cb) {
    // vm 是 Vue 实例
    this.vm = vm
    // key 是 data 中的属性
    this.key = key
    // cb 回调函数 更新视图的具体方法
    this.cb = cb
    // 把观察者的存放在 Dep.target
    Dep.target = this
    // 旧数据 更新视图的时候要进行比较
    // 还有一点就是 vm[key] 这个时候就触发了 get 方法
    // 之前在 get 把 观察者 通过dep.addSub(Dep.target) 添加到了 dep.subs中
    this.oldValue = vm[key]
    // Dep.target 就不用存在了 因为上面的操作已经存好了
    Dep.target = null
  }
  // 观察者中的必备方法 用来更新视图
  update() {
    // 获取新值
    let newValue = this.vm[this.key]
    // 比较旧值和新值
    if (newValue === this.oldValue) return
    // 调用具体的更新方法
    this.cb(newValue)
  }
}

测试代码

<!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>Document</title>
</head>

<body>
  <div id="app">
    {{msg}} <br />
    {{age}} <br />
    <div v-text="msg"></div>
    <input type="text" v-model="msg" />
  </div>
  <script src="./dep.js"></script>
  <script src="./watcher.js"></script>
  <script src="./compiler.js"></script>
  <script src="./oberver.js"></script>
  <script src="./vue.js"></script>
  <script>
   let vm = new Vue({
     el:'#app',
     // 两个数据,目标对象 被创建两个
     data: {
       msg: '没名字的某某人',   //3个节点中使用到该属性,目标对象容器里装有3个观察者
       age:22
     }
   })
  </script>
</body>

</html>
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容