likes
comments
collection
share

Vue2响应式原理浅析

作者站长头像
站长
· 阅读数 36

0.什么是响应式

vue响应式,我们都很熟悉了。当我们修改vue中data对象中的属性时,页面中引用该属性的地方就会发生相应的改变。避免了我们再去操作dom,进行数据绑定。要实现一个自己的响应式系统,我们首先要明白要做什么事情:

  1. 数据劫持:通过Object.defineProperty来对属性进行监听
  2. 依赖收集:页面在渲染的过程中用到了哪些数据,每个数据生成一个对应的watcher
  3. 派发更新:数据变化时,通过dep来执行内部watchernotify方法

下面我们就来一步步实现数据的响应式.

1.创建vue实例

首先我们先创建一个Vue的实例,传入数据。

 const app = new Vue({
        el: "#app",
        data: {
          name: "dawn",
          age: 12,
          friend: {
            friendName: "ibuki",
          },
          colors: ["red", "orange", "yellow"],
        },
      });

new Vue()后会调用我们自己定义的Vue

     class Vue {
        constructor(options) {
          //添加Vue实例属性
          this.$options = options;
          this.$data = options.data;
          this.$el = options.el;

          //将实例的data加入响应式系统中
          new Observer(this.$data);

          //代理this.$data的数据
          Object.keys(this.$data).forEach((key) => {
            this._proxy(key);
          });

          //处理el
          new Compiler(this.$el, this);
        }

        _proxy(key) {
          // 将this.$data上的数据定义在Vue实例上,这样我们可以自己通过Vue.value来使用数据
          Object.defineProperty(this, key, {
            configurable: true,
            enumerable: true,
            set(newValue) {
              this.$data[key] = newValue;
            },
            get() {
              return this.$data[key];
            },
          });
        }
      }

如果你对Observer,Compiler这些类什么时候实例化有疑问的话,没关系,下面马上就会讲到.\

可以看到new Vue后我们挂载属性到vue实例上,之后通过Observer实现数据响应式,然后将数据代理到Vue实例上,代理后,对el进行处理.

这就是响应式实现的过程,了解了过程,下面我们来介绍详细的类.

2.数据劫持(observer)

当我们实例化vue的时候,首先Vue会使用Object.defineProperty来对data数据进行数据劫持,下面我们来介绍数据劫持的过程。

首先介绍一下Object.defineProperty方法:

Object.defineProperty()  方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。\

Object.defineProperty(obj, prop, descriptor)

descriptor属性可以是对象原本的值Value也可以一个对象,其中可以设置getset属性。

  const data = { name: "dawn" };
      Object.defineProperty(data, "name", {
        //用到data.name时触发
        get() {
          return value;
        },
        //改变data.name时触发
        set(newValue) {
          if (newValue !== value) {
            value = newValue;
          }
        },
      });

经过这样设置后,当data.name发生变化时就会触发set方法,当外界使用data.name时就会触发get方法。这样当数据发生改变时,我们就可以对其进行相应的操作了。

如果data有多个属性呢?我们可以新建一个类Observer来遍历该对象


      class Observer {
        constructor(data) {
          this.data = data;

          //不是对象或空值则返回
          if (typeof data !== "object" || data === null) {
            return data;
          }

          //Object.keys(data)取得data对象keys的数组
          Object.keys(data).forEach((key) => {
              this.defineReactive(this.data, key, data[key]);
          });
        }

        defineReactive(data, key, value) {

          //对属性进行响应式处理
          Object.defineProperty(data, key, {
            //当且仅当该属性的 configurable 键值为 true 时,该属性的描述符才能够被改变,同时该属性也能从对应的对象上被删除。默认为 false。
            configurable: true,
            //当且仅当该属性的 enumerable 键值为 true 时,该属性才会出现在对象的枚举属性中。默认为 false。
            enumerable: true,
            get() {
              return value;
            },
            set(newValue) {
              if (newValue !== value) {
                value = newValue;
                console.log("refresh");
              }
            },
          });
        }
      }

这样做还有缺陷,当传入的data包含对象,或是赋值时给一个对象值。对象里的属性并不能进行数据劫持,因为这时data里对象的属性压根没有做这个 Object.defineProperty方法

如果data内有嵌套的属性(对象,数组)呢?我们可以使用递归来完成嵌套属性的数据劫持

      class Observer {
        constructor(data) {
          this.data = data;

          //不是对象或空值则返回
          if (typeof data !== "object" || data === null) {
            return data;
          }

          //Object.keys(data)取得data对象keys的数组
          Object.keys(data).forEach((key) => {
            //当data里的数据是对象时,再次调用Observer对其进行遍历,确保每个属性都被劫持
            if (typeof data[key] === "object") {
              new Observer(data[key]);
            } else {
              this.defineReactive(this.data, key, data[key]);
            }
          });
        }

        defineReactive(data, key, value) {

          //对属性进行响应式处理
          Object.defineProperty(data, key, {
            //当且仅当该属性的 configurable 键值为 true 时,该属性的描述符才能够被改变,同时该属性也能从对应的对象上被删除。默认为 false。
            configurable: true,
            //当且仅当该属性的 enumerable 键值为 true 时,该属性才会出现在对象的枚举属性中。默认为 false。
            enumerable: true,
            get() {
              return value;
            },
            set(newValue) {
              new Observer(newValue);
              if (newValue !== value) {
                value = newValue;
                console.log("refresh");
              }
            },
          });
        }
      }

数组也属于对象,所以typeof data[key] === "object"对数组也是成立的,这样每个数组属性也可以实现数据劫持,Object.keys(Array)返回的是数组下标组成的数组,至此对data的每个数据进行了劫持.\

这部分,可能有同学会晕,我用例子来梳理一下:

 data: {
          name: "dawn",
          age: 12,
          friend: {
            friendName: "ibuki",
          },
        },
new Observer(data)

当传入这个data数据时,Observer会取得他所有的keys进行判断,当key对应的值为普通数据时,会直接调用defineReactive来进行数据劫持,而如果碰过对象(friend),就会在次调用Observer取出这个对象的值(ibuki)进行判断和监听,这样完成对data每个属性的监听。

当你对data赋值时,会触发set方法,如果你赋的值为对象,则会再次调用Observer对其里面的属性进行监听(不是对象就返回了!),这样即使你赋的值为对象,当对象里的属性发生改变时,也可以触发set

数组方法的重写

delete data.age//无法监听
data.colors.pop()

虽然data的value完成了数据劫持,但当我们通过数组的方法去更改数组时或是直接删除data数据,数据并不能实现响应式,因为Object.defineProperty是没有办法处理属性删除和新增的.


因此vue2的响应式,通过数组方法( pop push ),或是删除,vue是无法监听的

解决办法:vue2中可以通过vue.detele和vue.set这些vue内置api来改变属性,实现响应式。

而数组增加或减少则需要进行如下操作:

  • 对于数组,vue 会更改数组的隐式原型,将其变为 vue 自定义的对象,对象中对原先一些能改变数组的方法进行了重写,这样 vue 就能监听到数组内容的变化,最后将将自定义对象的隐式原型指向 Array.prototype

代码如下:

Object.create() 方法创建一个新对象,使用现有的对象来提供新创建的对象的__proto__。\

  //取得数组的原型对象
      const oldArrayProtype = Array.prototype;
      //通过 Object.create()方法将newArrayProtype的原型对象设为数组的原型对象
      const newArrayProtype = Object.create(oldArrayProtype);
      // 对数组方法进行重写
      ["push", "pop", "shift", "unshift", "splice", "reduce", "filter"].forEach(
        (methodName) => {
          //给我们自定义对象添加数组方法
          newArrayProtype[methodName] = function () {
            //当使用以上方法时,就会触发
            console.log("refresh");
            //用call将this指向当前对象,调用数组原型上的方法,实现原先数组操作
            oldArrayProtype[methodName].call(this, ...arguments);
          };
        }
      );

observer中加入数组的判断

 Object.keys(data).forEach((key) => {
            //当data里的数据是对象时,再次调用Observer对其进行遍历,确保每个属性都被劫持
            if (Array.isArray(data[key])) {
              Object.setPrototypeOf(data[key], newArrayProtype);
            }
            if (typeof data[key] === "object") {
              new Observer(data[key]);
            } else {
              this.defineReactive(this.data, key, data[key]);
            }
          });

首先通过上部分代码,定义我们自己的原型对象,这个对象__proto__数组的原型对象。相当于是一个数组的实例。而后在这个实例上定义一些原先数组的方法,通过call调用数组的原型对象来实现数组的方法,此外我们还可以进行一些其他的操作。

在循环遍历时,当遇到数组,则将其__proto__指向我们定义的原型,这样我们调用数组方法时,实际调用的是我们自己定义的原型对象。可以在其之上进行更新的操作。

这时我们调用数组的方法(pop,push),不仅仅会执行数组原本的操作,还会输出refresh

3.模板解析(Compiler)

还记得第一步的过程吗,当数据进行响应式处理后,我们会对数据进行代理,让我们可以直接在vue实例上调用属性,而后我们会调用Compiler来处理el.

接下来,我们就来讲讲Compilerel.

首先是我们Vue管理的el

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

然后讲讲正则表达式:

const reg = /{{(.*)}}/;

.表示任意字符,*表示0或多出,()是一个区块,{}在正则中有特殊含义,因此需要\转义

这个正则的意思是匹配{{name}}里的name\

最后我们来看Compiler

      class Compiler {
        //el -> #app   vm当前vue实例
        constructor(el, vm) {
          // 获取#app div元素
          this.el = document.querySelector(el);
          this.vm = vm;

          this.frag = this.createFragment();
          this.el.appendChild(this.frag);
        }
        createFragment() {
          // 创建文档片段
          const frag = document.createDocumentFragment();

          let child;
          //获取#app 的第一个节点
          while ((child = this.el.firstChild)) {
            this.compile(child);
            frag.appendChild(child);
          }
          return frag;
        }
        compile(node) {
          //(node.nodeType === 1) 一个元素节点,例如 <p> 和 <div>
          if (node.nodeType === 1) {
            //拿到该节点上的属性 ( type="text" v-model="name")
            const attrs = node.attributes;
            if (attrs.hasOwnProperty("v-model")) {
              //取得v-model绑定的值,即name
              const name = attrs["v-model"].nodeValue;
              //对该元素节点(input)进行事件监听
              node.addEventListener("input", (e) => {
                //通过事件对象e来取得input输入框内的值
                //因为做过代理,vm[name],即使data里的name属性
                this.vm[name] = e.target.value;
              });
            }
          }
          //(node.nodeType === 3) Element或者Attr中实际的文字
          if (node.nodeType === 3) {
            //通过正则来匹配用到的数据
            console.log(reg.test(node.nodeValue));
            if (reg.test(node.nodeValue)) {
              // RegExp.$1 去匹配到的第一个区域 即(.*)里的值name
              const name = RegExp.$1.trim();
              console.log(name);
              //关键点,此处用到了name,我们创建一个wather对象,形成依赖
              new Watcher(node, name, this.vm);
            }
          }
        }
      }

document.createDocumentFragment()创建一个新的空白的文档片段

因为文档片段存在于内存中,并不在DOM树中,所以将子元素插入到文档片段时不会引起页面回流(对元素位置和几何上的计算)。因此,使用文档片段通常会带来更好的性能。\

Node.appendChild()  方法将一个节点附加到指定父节点的子节点列表的末尾处。如果将被插入的节点已经存在于当前文档的文档树中,那么 appendChild() 只会将它从原先的位置移动到新的位置(不需要事先移除要移动的节点)。\

介绍完了类的内容,接下来我们来解释一下类执行的过程

首先我们获取#app,然后创建文档片段,通过while来获取#app中的节点.node.firstChild会获取第一个元素node.appendChild插入时,原先#app的节点会被移到我们创建的文档片段里,下一次循环时拿到的就是下一个节点了,通过这样的方法,我们可以取到#app中的每一个节点。

之后我们会对节点进行判断,如果是元素节点,则获取节点上的属性,如果属性中有v-model,那么就拿到v-model绑定的值,对这个节点进行事件监听,通过事件对象来将input中的值赋给data中对应的属性。

如果是文本节点,我们通过正则取得该节点使用的变量name,然后new Watcher创建一个依赖。

我们再来理一理思路:首先new Vue对数据进行响应式处理,但这时我们并不知道谁用了我们的数据。然后我们对#app进行解析,获取其节点来判断用了哪些data值,当有data值被引用时,创建watcher实例,来作为依赖.

4.生成依赖(watcher)

      class Watcher {
        constructor(node, name, vm) {
          this.node = node;
          this.name = name;
          this.vm = vm;
          Dep.target = this;
          this.update();
          Dep.target = null;
        }
        update() {
          this.node.nodeValue = this.vm[this.name];
        }
      }

new watcher时,传过三个参数,文本节点,用到的属性,以及vue实例。将参数赋给实例后,设置Dep.target = this;this为当前watcher实例,随后调用update方法,将vue实例上的数据赋给当前节点,这时节点上的{{name}}就会变成data里的值。

this.vm[this.name]当我们去vue实例上去data的值时,会触发属性的get方法,这时我们就可以对这个依赖进行操作,但之前我们的get方法并没有添加依赖的操作,我们需要对其进行修改。\

5.依赖收集(Dep)

vue 会为每个响应式数据生成一个 Dep 实例,Dep 实例会做两件事\

  1. 记录依赖:当有人访问这个数据时,把它记录下来
  2. 派发更新:当数据被修改时通知所有的依赖数据更改了

我们定义一个Dep类:

class Dep {
        constructor() {
          // 定义数组,存放依赖
          this.subs = [];
        }
        //当watcher生成时调用sub即使watcher
        addSub(sub) {
          //将watcher加入数组
          this.subs.push(sub);
        }
        notify() {
          this.subs.forEach((sub) => {
            //遍历数组,调用watcher上定义的update方法
            sub.update();
          });
        }
      }

然后再修改Observer

类中

defineReactive里的代码

   Object.defineProperty(data, key, {
            //当且仅当该属性的 configurable 键值为 true 时,该属性的描述符才能够被改变,同时该属性也能从对应的对象上被删除。默认为 false。
            configurable: true,
            //当且仅当该属性的 enumerable 键值为 true 时,该属性才会出现在对象的枚举属性中。默认为 false。
            enumerable: true,
            get() {
              if (Dep.target) {
                dep.addSub(Dep.target);
              }
              return value;
            },
            set(newValue) {
              new Observer(newValue);
              if (newValue !== value) {
                value = newValue;
                dep.notify();
                console.log("refresh");
              }
            },
          });

原先的get方法,只会返回值。我们要对其进行修改。

首先再defineReactive函数中new Dep来生成一个记录依赖的数组。每有一个属性便会调用一次defineReactive便会创建一个dep数组,这样每个属性都会有一个自己的dep数组来记录依赖。

然后再get方法中判断Dep.target的值,当有值时执行dep.addSub(Dep.target),将该watcher加入该值的dep数组中

最后再set方法中添加 dep.notify();这样当我们设置值时,会通知dep上的所有watcher,执行他们的update方法

同样我们来理一理思路,首先new Watcher时让Dep.target = this然后调用update方法,这时会使用data上的name属性,就会触发这个属性的get方法,在get方法中,我们判断是否有Dep.target来将其加入这个属性的dep数组中.这样这个watcher实例就完成了依赖的添加,之后当我们改变这个name属性时,就会触发set方法,这时通过dep.notify()实现数组内所有watcher的更新,最后让Dep.target = null

6.总结代码

现将代码总结如下:\

<!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">
      <input type="text" v-model="name" />
      {{name}}
    </div>

    <script>
      //取得数组的原型对象
      const oldArrayProtype = Array.prototype;
      //通过 Object.create()方法将newArrayProtype的原型对象设为数组的原型对象
      const newArrayProtype = Object.create(oldArrayProtype);
      // 对数组方法进行重写
      ["push", "pop", "shift", "unshift", "splice", "reduce", "filter"].forEach(
        (methodName) => {
          //给我们自定义对象添加数组方法
          newArrayProtype[methodName] = function () {
            //当使用以上方法时,就会触发
            console.log("refresh");
            //用call将this指向当前对象,调用数组原型上的方法,实现原先数组操作
            oldArrayProtype[methodName].call(this, ...arguments);
          };
        }
      );

      //定义Vue实例的类
      class Vue {
        constructor(options) {
          //添加Vue实例属性
          this.$options = options;
          this.$data = options.data;
          this.$el = options.el;

          //将实例的data加入响应式系统中
          new Observer(this.$data);

          //代理this.$data的数据
          Object.keys(this.$data).forEach((key) => {
            this._proxy(key);
          });

          //处理el
          new Compiler(this.$el, this);
        }

        _proxy(key) {
          // 将this.$data上的数据定义在Vue实例上,这样我们可以自己通过Vue.value来使用数据
          Object.defineProperty(this, key, {
            configurable: true,
            enumerable: true,
            set(newValue) {
              this.$data[key] = newValue;
            },
            get() {
              return this.$data[key];
            },
          });
        }
      }

      class Observer {
        constructor(data) {
          this.data = data;

          //不是对象或空值则返回
          if (typeof data !== "object" || data === null) {
            return data;
          }

          //Object.keys(data)取得data对象keys的数组
          Object.keys(data).forEach((key) => {
            //当data里的数据是对象时,再次调用Observer对其进行遍历,确保每个属性都被劫持
            if (Array.isArray(data[key])) {
              Object.setPrototypeOf(data[key], newArrayProtype);
            }
            if (typeof data[key] === "object") {
              new Observer(data[key]);
            } else {
              this.defineReactive(this.data, key, data[key]);
            }
          });
        }

        defineReactive(data, key, value) {
          //一个key -> 对应一个dep对象
          const dep = new Dep();

          //对属性进行响应式处理
          Object.defineProperty(data, key, {
            //当且仅当该属性的 configurable 键值为 true 时,该属性的描述符才能够被改变,同时该属性也能从对应的对象上被删除。默认为 false。
            configurable: true,
            //当且仅当该属性的 enumerable 键值为 true 时,该属性才会出现在对象的枚举属性中。默认为 false。
            enumerable: true,
            get() {
              if (Dep.target) {
                dep.addSub(Dep.target);
              }
              return value;
            },
            set(newValue) {
              new Observer(newValue);
              if (newValue !== value) {
                value = newValue;
                dep.notify();
                console.log("refresh");
              }
            },
          });
        }
      }

      class Dep {
        constructor() {
          // 定义数组,存放依赖
          this.subs = [];
        }
        //当watcher生成时调用sub即使watcher
        addSub(sub) {
          //将watcher加入数组
          this.subs.push(sub);
        }
        notify() {
          this.subs.forEach((sub) => {
            //遍历数组,调用watcher上定义的update方法
            sub.update();
          });
        }
      }

      class Watcher {
        constructor(node, name, vm) {
          this.node = node;
          this.name = name;
          this.vm = vm;
          Dep.target = this;
          this.update();
          Dep.target = null;
        }
        update() {
          this.node.nodeValue = this.vm[this.name];
        }
      }

      const reg = /\{\{(.*)\}\}/;
      class Compiler {
        //el -> #app   vm当前vue实例
        constructor(el, vm) {
          // 获取#app div元素
          this.el = document.querySelector(el);
          this.vm = vm;

          this.frag = this.createFragment();
          this.el.appendChild(this.frag);
        }
        createFragment() {
          // 创建文档片段
          const frag = document.createDocumentFragment();

          let child;
          //获取#app 的第一个节点
          while ((child = this.el.firstChild)) {
            this.compile(child);
            frag.appendChild(child);
          }
          return frag;
        }
        compile(node) {
          //(node.nodeType === 1) 一个元素节点,例如 <p> 和 <div>
          if (node.nodeType === 1) {
            //拿到该节点上的属性 ( type="text" v-model="name")
            const attrs = node.attributes;
            if (attrs.hasOwnProperty("v-model")) {
              //取得v-model绑定的值,即name
              const name = attrs["v-model"].nodeValue;
              //对该元素节点(input)进行事件监听
              node.addEventListener("input", (e) => {
                //通过事件对象e来取得input输入框内的值
                //因为做过代理,vm[name],即使data里的name属性
                this.vm[name] = e.target.value;
              });
            }
          }
          //(node.nodeType === 3) Element或者Attr中实际的文字
          if (node.nodeType === 3) {
            //通过正则来匹配用到的数据
            console.log(reg.test(node.nodeValue));
            if (reg.test(node.nodeValue)) {
              // RegExp.$1 去匹配到的第一个区域 即(.*)里的值name
              const name = RegExp.$1.trim();
              console.log(name);
              //关键点,此处用到了name,我们创建一个wather对象,形成依赖
              new Watcher(node, name, this.vm);
            }
          }
        }
      }
    </script>

    <script>
      const app = new Vue({
        el: "#app",
        data: {
          name: "dawn",
          age: 12,
          friend: {
            friendName: "ibuki",
          },
          colors: ["red", "orange", "yellow"],
        },
      });
    </script>
  </body>
</html>

7. 注意事项

1.函数作用域

defineReactive每次创建一个dep都是独立的个体,因为是在函数作用域中创建的,这样每个对象的每个属性就能保存自己的值value和依赖对象dep。\

2. 为什么要Dep.target = null

我们看到,只有Dep.target为真时才会添加依赖。比如在派发更新时会触发watcherupdate方法,该方法也会触发

get

来取值,但是此时的Dep.targetnull,不会添加依赖。如果没有置为null的话,会继续添加watcher导致混乱\

8.final

那么现在一个简单的响应式就实现了,我们最后来理一遍思路:

  1. 当new Vue时,我们会用Object.defineProperty来对数据继续响应式处理,通过递归的方法来让数组,对象的属性也能进行监听。当然Object.defineProperty并不能监听删除和增加,解决方法是用vue.delete或vue.set来删除,增加属性。然后自己定义一个数组原型来对数组方法进行重写。
  2. 随后我们开始解析dom,通过node.firstChildnode.appendChild来取得每一个节点,对节点进行判断,通过正则匹配出节点中使用的属性(比如上面的name),以此创建watcher实例
  3. 然后在watcher中通过Dep.targetupdate来实现watcher的添加,update时用到data中的响应式数据,触发get,在getnew Dep判断Dep.target的值进行依赖的添加。
  4. 添加后将Dep.target = null以免数据更新时再次添加watcher实例

这种实现方法,每遇到一个插值表达式就会新建一个watcher,这样每个节点就会对应一个watcher。实际上这是vue1.x的做法,以节点为单位进行更新,粒度较细。而vue2.x的做法是每个组件对应一个watcher,实例化watcher时传入的也不再是一个expression,而是渲染函数,渲染函数由组件的模板转化而来,这样一个组件的watcher就能收集到自己的所有依赖,以组件为单位进行更新,是一种中等粒度的方式。要实现vue2.x的响应式系统涉及到很多其他的东西,比如组件化,虚拟DOM等,而这个系列文章只专注于数据响应式的原理,因此不能实现vue2.x,但是两者关于响应式的方面,原理相同。

当然,以上例子只是Vue响应式最简单的实现,真正的响应式代码要比这复杂的多,希望这篇文章可以帮你初步理解Vue响应式,感谢观看!

转载自:https://juejin.cn/post/7084200479005081608
评论
请登录