模拟 Vue.js 响应式原理

93 阅读6分钟

模拟 Vue.js 响应式原理

数据驱动

数据响应式、双向绑定、数据驱动

数据响应式

  • 数据模型仅仅是普通的 javascript 对象,而当我们修改数据时,视图会自动更新,避免了繁琐的 DOM 操作,提高了开发效率;

双向绑定

  • 数据改变,视图随之改变;视图改变,数据也会随之改变;
  • 我们可以使用 v-model 在表单元素上创建双向绑定;

数据驱动:Vue 最独特的特性之一

  • 开发过程中仅需关注数据本身,不需要关心数据是如何渲染到视图;

数据响应式的核心原理

Vue 2.x:defineProperty

// 模拟 Vue 中的 data 选项;
<!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>defineProperty</title>
  </head>
  <body>
    <div id="app">defineProperty</div>
    <script>
      //   let data = { msg: 'defineProperty' };

      //   let vm = {};

      //   Object.defineProperty(vm, key, {
      //     // 可枚举的(可遍历)
      //     enumerable: true,
      //     // 可配置的(可以使用 delete 删除,可以通过 defineProperty 重新定义)
      //     configurable: true,
      //     get() {
      //       console.log('get: ', data.msg);
      //       return data.msg;
      //     },

      //     set(newVal) {
      //       if (newVal === data.msg) {
      //         return;
      //       }

      //       data.msg = newVal;

      //       document.getElementById('app').textContent = data.msg;
      //     }
      //   });

      let data = {
        msg: 'defineProperty',
        count: 11
      };

      let vm = {};

      proxyData(data);

      function proxyData(data) {
        Object.keys(data).map(key => {
          if (Object.hasOwnProperty.call(data, key)) {
            const ele = data[key];
            Object.defineProperty(vm, key, {
              // 可枚举的(可遍历的)
              enumerable: true,
              // 可配置的(可以通过 delete 删除,可以通过 defineProperty 重新定义)
              configurable: true,
              get() {
                console.log(`get ${key}${data[key]}`);
                return data[key];
              },
              set(newVal) {
                if (!newVal || newVal === data[key]) return;

                data[key] = newVal;
                document.querySelector('#app').textContent = data[key];

                console.log(`set ${key}${data[key]}`);
              }
            });
          }
        });
      }
    </script>
  </body>
</html>

Vue 3.x:Proxy

  • MDN - Proxy;
  • 直接监听对象,而非属性;
  • ES6 中新增,IE 不支持,性能由浏览器优化;
// 模拟 Vue 中的 data 选项;
<!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>Proxy</title>
  </head>
  <body>
    <div id="app">Proxy</div>
    <script>
      let data = {
        msg: 'Proxy',
        count: 111
      };

      //   模拟 Vue 实例
      let vm = new Proxy(data, {
        // 执行代理行为的函数
        // 当访问 vm 的成员时会执行
        get(target, key) {
          console.log(`get ${key}${target[key]}`);
          return target[key];
        },

        // 当设置 vm 的成员时会执行
        set(target, key, newVal) {
          console.log(`set ${key}${target[key]}`);
          if (target[key] === newVal) {
            return;
          }

          target[key] = newVal;
          document.querySelector('#app').textContent = target[key];
        }
      });
    </script>
  </body>
</html>

总结

  • 代码层面上:Proxy 比 defineProperty 更加简洁;
  • 性能层面上:Proxy 性能由浏览器进行优化,因此 Proxy 比 defineProperty 性能更优;

发布/订阅模式

  • 订阅者
  • 发布者
  • 信号中心(事件中心)

我们假定,存在一个”信号中心“,某个任务执行完成,就向信号中心”发布“(publish)一个信号,其他任务可以向信号中心“订阅”(subscribe)这个信号,从而知道什么时候自己可以开始执行。这就叫做”发布/订阅模式“(publish-subscribe pattern)

Vue 的自定义事件

API:$on$emit...

官方说明:v2.cn.vuejs.org/v2/guide/co…

let vm = new Vue();

vm.$on('dataChange', ()=> {
    console.log('dataChange');
});

vm.$on('dataChange', ()=> {
    console.log('dataChange1');
});

vm.$emit('dataChange');

兄弟组建通信过程

// eventBus.js
// 事件中心
let eventHub = new Vue()

// ComponentA.vue
// 发布者
addTodo: function () {
    // 发布消息(事件)
    eventHub.$emit('add-todo'), { Text: this.newTodoText })
    this.newTodoText = ''
}

// ComponentB.vue
// 订阅者
created: function () {
    // 订阅消息(事件)
    eventHub.$on('add-todo', this.addTodo)
}

模拟 Vue 自定义事件的实现

<!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>发布订阅模式</title>
</head>

<body>
    <script>
        // 发布者
        class EventEmitter {
            constructor() {
                // 订阅者 { eventType: [ handler1, handler2 ] }
                this.subs = Object.create(null);
            }

            // 订阅通知
            $on(eventType, handler) {
                this.subs[eventType] = this.subs[eventType] || []
                this.subs[eventType].push(handler)
            }

            // 发布通知
            $emit(eventType) {
                if (this.subs[eventType]) {
                    this.subs[eventType].forEach(handler => {
                        handler()
                    });
                }
            }
        }

        let em = new EventEmitter();
        em.$on('click', () => {
            console.log('on click1');
        });
        em.$on('click', () => {
            console.log('on click2')
        });
    </script>
</body>

</html>

观察者模式

观察者(订阅者)- Watcher

  1. update():当事件发生时,具体要做的事情;

目标(发布者)- Dep

  1. subs 数组:存储所有的观察者;
  2. addSub():添加观察者;
  3. notify():当事件发生,调用所有观察者的 update() 方法;

没有事件中心

模拟观察者模式实现

<!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>观察者模式</title>
  </head>

  <body>
    <script>
      // 1. 发布者(目标)
      class Dep {
        // subs:存储所有的观察者;
        constructor() {
          this.subs = [];
        }
        // addSub:添加一个观察者;
        addSub(sub) {
          if (sub && sub.update) {
            this.subs.push(sub);
          }
        }
        // notify:当事件发生的时候,调用所有观察者的 update 方法;
        notify() {
          this.subs.forEach((sub) => {
            sub.update();
          });
        }
      }

      // 2. 订阅者(观察舌)
      class Watcher {
        constructor(name) {
          this.name = name || "watcherX";
        }

        update() {
          console.log(`update: ${this.name}`);
        }
      **}**

      let dep = new Dep();
      let watcher1 = new Watcher("watcher1");
      let watcher2 = new Watcher("watcher2");
      let watcher3 = new Watcher("watcher3");

      dep.addSub(watcher1);
      dep.addSub(watcher2);
      dep.addSub(watcher3);
    </script>
  </body>
</html>

观察者模式 与 发布/订阅者模式 对比

  • 观察者模式:由具体目标调度,比如当事件触发时 Dep(目标-发布者) 就会去调用 Watcher(观察者-订阅者)的方法,所以观察者模式的订阅者与发布者之间是存在依赖的;
  • 发布/订阅模式:由统一的调度中心调用,因此发布者和订阅者不需要知道对方的存在;

image.png

Vue 响应式原理模拟

整体分析

  • Vue 的基本结构

  • 打印 Vue 实例观察

  • 整体结构

image.png

  1. Vue
  • 把 data 中的成员注入到 Vue 实例,并且把 data 中的成员转成 getter/setter;
  1. Observer
  • 监听 data 中所有属性的变化;
  1. Compiler
  • 解析指令/差值表达式;

Vue

功能

  1. 负责接收初始化的参数(选项);
  2. 负责把 data 中的属性注入到 Vue 实例,转换成 getter/setter;
  3. 负责调用 observer 监听 data 中所有属性的变化;
  4. 负责调用 compiler 解析指令/差值表达式;

结构

classDiagram
class Vue {
    + $options
    + $el
    + $data
    
    - _proxyData()
}    

代码

js/vue.js

class Vue {
  constructor(options) {
    // 1. 通过属性保存选项的数据;
    this.$optinos = options || {};
    this.$data = options.data || {};
    this.$el =
      typeof options.el === 'string'
        ? document.querySelector(options.el)
        : options.el;
    // 2. 把 data 中的成员转换成 getter/setter,注入到 Vue 示例中;
    this._proxyData(this.$data);
    // 3. 调用 observer 对象,监听数据的变化;
    new Observer(this.$data);
    // 4. 调用 compiler 对象,解析指令和差值表达式;
    new Compiler(this);
  }

  _proxyData(data) {
    // 遍历 data 中的所有属性;
    Object.keys(data).forEach(key => {
      Object.defineProperty(this, key, {
        enumerable: true,
        configurable: true,
        get() {
          return data[key];
        },

        set(newVal) {
          if (newVal === data[key]) return;

          data[key] = newVal;
        }
      });
    });
    // 把 data 的属性注入到 Vue 示例中;
  }
}

index.html

<!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>1. vue 基础结构</title>
  </head>
  <body>
    <div id="app">
      <h1>差值表达式</h1>
      <h3>{{ msg }}</h3>
      <h3>{{ count }}</h3>
      <h1>v-text</h1>
      <div v-text="msg"></div>
      <h1>v-model</h1>
      <input type="text" v-model="msg" />
      <input type="text" v-model="count" />
    </div>

    <script src="./js/vue.js"></script>
    <script>
      let vm = new Vue({
        el: '#app',
        data: {
          msg: 'Hello Vue',
          count: 20
        }
      });
    </script>
  </body>
</html>

observer

功能

  1. 负责把 data 选项中的属性转换成响应式数据;
  2. data 中的某个属性也是对象,把该属性转换成响应式数据;
  3. 数据变化发送通知;

结构

classDiagram
class Observer {
    + walk(data)
    + defineReactive(obj, key, value)
}  

代码

js/observer.js

class Observer {
  constructor(data) {
    this.walk(data);
  }

  /**
   * 遍历 data 每个属性,调用 defineReactive 方法,把每个属性转换成响应式数据(getter、setter)
   * 
   * @param { object } data vm 的 data 对象
   */
  walk(data) {
    // 1. 判断 data 是否是对象;
    if (!data || typeof data !== 'object') return;
    // 2. 遍历 data 对象的所有属性;
    Object.keys(data).forEach(key => {
      this.defineReactive(data, key, data[key]);
    });
  }

  /**
   * 调用 defineProperty 方法,把 object 中的 key 对应的属性转换成响应式数据(getter、setter)
   * 
   * @param { object } obj data 对象
   * @param { string } key data 对象的属性名
   * @param { any } val data[key],可能是 object、基础数据类型
   */
  defineReactive(obj, key, val) {
    let _this = this;

    // 如果 val 是对象,把 val 内部的属性转换成响应式数据(getter、settter)
    this.walk(val);

    Object.defineProperty(obj, key, {
      enumerable: true,
      configurable: true,
      get() {
        return val;
      },

      set(newVal) {
        if (newVal === val) {
          return;
        }

        val = newVal;

        _this.walk(newVal);

        // 发送通知;
      }
    });
  }
}

index.html

<!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>1. vue 基础结构</title>
  </head>
  <body>
    <div id="app">
      <h1>差值表达式</h1>
      <h3>{{ msg }}</h3>
      <h3>{{ count }}</h3>
      <h1>v-text</h1>
      <div v-text="msg"></div>
      <h1>v-model</h1>
      <input type="text" v-model="msg" />
      <input type="text" v-model="count" />
    </div>

    <script src="./js/observer.js"></script>
    <script src="./js/vue.js"></script>
    <script>
      let vm = new Vue({
        el: '#app',
        data: {
          msg: 'Hello Vue',
          count: 20,
          lang: {
            js: '5.0',
            nodejs: '2.0',
            java: '3.9',
            python: '1.1'
          },
          person: 'syc'
        }
      });

      vm.person = {
        name: 'syc',
        age: 32
      };

      console.log(vm);
    </script>
  </body>
</html>

compiler

功能

  1. 负责编译模板,解析指令/差值表达式;
  2. 负责页面的首次渲染;
  3. 当数据变化后重新渲染视图;

其实就是 dom 操作

结构

classDiagram
class Compiler {
    + el
    + vm
    
    + compile(el)
    + compileElement(node)
    + compileText(node)
    + isDirective(attrName)
    + isTextNode(node)
    + isElementNode(node)
}  

代码

js/compiler.js

class Compiler {
  constructor(vm) {
    this.el = vm.$el;
    this.vm = vm;
    this.compile(this.el);
  }

  /**
   * 编译模板,处理文本节点和元素节点
   *
   * @param { object } el dom 对象
   */
  compile(el) {
    let childNodes = el.childNodes;
    Array.from(childNodes).forEach(node => {
      // 处理文本节点
      if (this.isTextNode(node)) {
        this.compileText(node);
      }

      // 处理元素节点
      if (this.isElementNode(node)) {
        this.compileElement(node);
      }

      // 判断 node 节点是否有子节点,如果有子节点,要递归调用 compile
      if (node.childNodes && node.childNodes.length) {
        this.compile(node);
      }
    });
  }

  /**
   * 编译元素节点,处理指令
   *
   * @param { object } node 元素节点
   */
  compileElement(node) {
    // console.log(node.attributes);
    // 遍历所有的属性节点
    Array.from(node.attributes).forEach(attr => {
      // 判断是否是指令
      let attrName = attr.name;
      if (this.isDirective(attrName)) {
        // v-text ---> text
        attrName = attrName.substr(2);
        let key = attr.value;

        this.update(node, key, attrName);
      }
    });
  }

  update(node, key, attrName) {
    let updateFn = this[`${attrName}Updater`];
    updateFn && updateFn(node, this.vm[key]);
  }

  // 处理 v-text 指令
  textUpdater(node, value) {
    node.textContent = value;
  }

  // 处理 v-model 指令
  modelUpdater(node, value) {
    node.value = value;
  }

  /**
   * 编译文本节点,处理指令
   *
   * @param { object } node 文本节点
   */
  compileText(node) {
    // console.dir(node);
    // {{ msg }}
    let reg = /\{\{(.+?)\}\}/;
    let val = node.textContent;
    if (reg.test(val)) {
      let key = RegExp.$1.trim();
      node.textContent = val.replace(reg, this.vm[key]);
    }
  }

  /**
   * 判断元素属性是否是指令
   *
   * @param { string } attrName 属性名称
   */
  isDirective(attrName) {
    return attrName.startsWith('v-');
  }

  /**
   * 判断节点是否是文本节点
   *
   * @param { object } node 节点
   */
  isTextNode(node) {
    return node.nodeType === 3;
  }

  /**
   * 判断节点是否是元素节点
   *
   * @param { object } node 节点
   */
  isElementNode(node) {
    return node.nodeType === 1;
  }
}


index.html

<!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>1. vue 基础结构</title>
  </head>
  <body>
    <div id="app">
      <h1>差值表达式</h1>
      <h3>{{ msg }}</h3>
      <h3>{{ count }}</h3>
      <h1>v-text</h1>
      <div v-text="msg"></div>
      <h1>v-model</h1>
      <input type="text" v-model="msg" />
      <input type="text" v-model="count" />
    </div>

    <script src="./js/compiler.js"></script>
    <script src="./js/observer.js"></script>
    <script src="./js/vue.js"></script>
    <script>
      let vm = new Vue({
        el: '#app',
        data: {
          msg: 'Hello Vue',
          count: 20,
          lang: {
            js: '5.0',
            nodejs: '2.0',
            java: '3.9',
            python: '1.1'
          },
          person: 'syc'
        }
      });

      vm.person = {
        name: 'syc',
        age: 32
      };

      vm.person.name = {
        first: 'song',
        second: 'yuncheng'
      };

    </script>
  </body>
</html>


Dep(Dependency)

image.png

功能

  • 收集依赖,添加观察者(watcher);
  • 通知所有观察者;

结构

classDiagram
class Dep {
    + subs
    
    + addSub(sub)
    + notify()
}

代码

js/dep.js

class Dep {
  constructor() {
    // subs 存储所有的观察者
    this.subs = [];
  }

  /**
   * 添加观察者
   *
   * @param { object } sub 观察者对象
   */
  addSub(sub) {
    if (sub && sub.update) {
      this.subs.push(sub);
    }
  }

  /**
   * 发送通知
   */
  notify() {
    this.subs.forEach(sub => {
      sub.update();
    });
  }
}

js/observer.js

class Observer {
  constructor(data) {
    this.walk(data);
  }

  /**
   * 遍历 data 每个属性,调用 defineReactive 方法,把每个属性转换成响应式数据(getter、setter)
   * 
   * @param { object } data vm 的 data 对象
   */
  walk(data) {
    // 1. 判断 data 是否是对象;
    if (!data || typeof data !== 'object') return;
    // 2. 遍历 data 对象的所有属性;
    Object.keys(data).forEach(key => {
      this.defineReactive(data, key, data[key]);
    });
  }

    /**
   * 调用 defineProperty 方法,把 object 中的 key 对应的属性转换成响应式数据(getter、setter)
   * 
   * @param { object } obj data 对象
   * @param { string } key data 对象的属性名
   * @param { any } val data[key],可能是 object、基础数据类型
   */
  defineReactive(obj, key, val) {
    let _this = this;

    // 负责收集依赖,并发送通知
    let dep = new Dep();
    
    // 如果 val 是对象,把 val 内部的属性转换成响应式数据(getter、settter)
    this.walk(val);

    Object.defineProperty(obj, key, {
      enumerable: true,
      configurable: true,
      get() {
        // 收集依赖(添加 watcher)
        Dep.target && dep.addSub(Dep.target);
        return val;
      },

      set(newVal) {
        if (newVal === val) {
          return;
        }

        val = newVal;

        _this.walk(newVal);

        // 发送通知;
        dep.notify();
      }
    });
  }
}

Watcher

1665464463317.jpg

功能

  • 当数据变化触发依赖,dep 通知所有的 watcher 示例更新视图;
  • 自身实例化的时候往 dep 对象中添加自己;

结构

classDiagram
class Watcher {
    + vm
    + key
    + cb
    + oldValue
    
    + update()
}

代码

js/watcher.js

class Watcher {
  constructor(vm, key, cb) {
    this.vm = vm;
    // data 中的属性名称
    this.key = key;
    // 回调函数:负责更新视图
    this.cb = cb;

    // 把 watcher 对象记录到 Dep 类的静态属性 target
    Dep.target = this;

    // 出发 get 方法,再 get 方法中会调用 addSub
    this.oldValue = vm[key];

    // 设置完以后,要将 Dep 类中的 target 静态属性置 null,避免后续重复调用
    Dep.target = null;
  }

  /**
   * 当数据发生变化的时候更新视图
   */
  update() {
    let newValue = this.vm[this.key];
    if (this.oldValue === newValue) {
      return;
    }

    this.cb(newValue);
  }
}

创建 watcher 对象

更新视图的操作其实就是操作 dom,所有的 dom 操作都在 compiler.js 中进行(把数据渲染到 dom 的位置,即处理指令和差值表达式的位置)

编译文本节点,处理差值表达式

js/compiler.js

class Compiler {
...

  /**
   * 编译文本节点,处理差值表达式
   *
   * @param { object } node 文本节点
   */
  compileText(node) {
    // console.dir(node);
    // {{ msg }}
    let reg = /\{\{(.+?)\}\}/;
    let val = node.textContent;
    if (reg.test(val)) {
      let key = RegExp.$1.trim();
      node.textContent = val.replace(reg, this.vm[key]);

      // 创建 watcher 对象,
      new Watcher(this.vm, key, newVal => {
        node.textContent = newVal;
      });
    }
  }
  
...
}

编译元素节点,处理指令

js/compiler.js

class Compiler {
...

  /**
   * 编译元素节点,处理指令
   *
   * @param { object } node 元素节点
   */
  compileElement(node) {
    // console.log(node.attributes);
    // 遍历所有的属性节点
    Array.from(node.attributes).forEach(attr => {
      // 判断是否是指令
      let attrName = attr.name;
      if (this.isDirective(attrName)) {
        // v-text ---> text
        attrName = attrName.substr(2);
        let key = attr.value;

        this.update(node, key, attrName);
      }
    });
  }

  update(node, key, attrName) {
    let updateFn = this[`${attrName}Updater`];
    updateFn && updateFn.call(this, node, this.vm[key], key);
  }

  // 处理 v-text 指令
  textUpdater(node, val, key) {
    node.textContent = val;

    new Watcher(this.vm, key, newVal => {
      node.textContent = newVal;
    });
  }

  // 处理 v-model 指令
  modelUpdater(node, val, key) {
    node.value = val;

    new Watcher(this.vm, key, newVal => {
      node.value = newVal;
    });
  }

...
}

测试

index.html

<!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>1. vue 基础结构</title>
  </head>
  <body>
    <div id="app">
      <h1>差值表达式</h1>
      <h3>{{ msg }}</h3>
      <h3>{{ count }}</h3>
      <h1>v-text</h1>
      <div v-text="msg"></div>
      <h1>v-model</h1>
      <input type="text" v-model="msg" />
      <input type="text" v-model="count" />
    </div>

    <script src="./js/dep.js"></script>
    <script src="./js/watcher.js"></script>
    <script src="./js/compiler.js"></script>
    <script src="./js/observer.js"></script>
    <script src="./js/vue.js"></script>
    <script>
      let vm = new Vue({
        el: '#app',
        data: {
          msg: 'Hello Vue',
          count: 20,
          lang: {
            js: '5.0',
            nodejs: '2.0',
            java: '3.9',
            python: '1.1'
          },
          person: 'syc'
        }
      });

      vm.person = {
        name: 'syc',
        age: 32
      };

      vm.person.name = {
        first: 'song',
        second: 'yuncheng'
      };
    </script>
  </body>
</html>

双向绑定

数据发生变化自动更新视图,视图发生变化自动更新数据

代码

class Compiler {
...

  // 处理 v-model 指令
  modelUpdater(node, val, key) {
    node.value = val;

    new Watcher(this.vm, key, newVal => {
      node.value = newVal;
    });

    // 双向绑定
    node.addEventListener('input', () => {
      this.vm[key] = node.value;
    });
  }

...
}

总结

问题

  • 给属性重新赋值成对象,是否是响应式的?是

  • 给 Vue 实例新增一个成员是否是响应式的?否,参考 Vue.set( target, propertyName/index, valu…

  • Vue 中操作 data 的数据方法那些可以触发视图更新?

    1. 可以触发视图更新的方法有:push()、pop()、shift()、unshift()、splice()、sort()、reverse()以上这些方法会改变被操作的数组; filter()、concat()、 slice() 这些方法不会改变被操作的数组,而是返回一个新的数组;

    2. 不能触发视图更新的方法有: 利用索引直接设置一个数组项,如:this.array[index] = newValue;直接修改数组的长度,如:this.array.length = newLength

    3. 有以下两种方法可以解决不能触发视图更新:

    • 可以用 this.$set(this.array, index, newValue) 或 this.array.splice(index, 1, newValue);
    • 可以用 this.array.splice(newLength);

整体流程

image.png