Vue2 响应式原理

作者: 没名字的某某人 | 来源:发表于2022-01-17 16:48 被阅读0次

    写在最前:本文转自掘金

    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>
    

    相关文章

      网友评论

        本文标题:Vue2 响应式原理

        本文链接:https://www.haomeiwen.com/subject/qjslbltx.html