<div>Price: ${{ price }}</div><div>Total: ...">
最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • 自己动手实现类vue响应式库

    正文概述 掘金(阿.波罗.吹雪)   2020-12-20   400

    Vue中使用方式

    先看一段 vue 代码

    <div id="app">
      <div>Price: ${{ price }}</div>
      <div>Total: ${{ price * quantity }}</div>
      <div>Taxes: ${{ totalPriceWithTax }}</div>
    <div>
    
    <script src="https://cdn.jsdelivr.net/npm/vue"></script>
    <script>
    	var vm = new Vue({
    	  el: '#app',
    	  data: {
    	    price: 5.00,
    	    quantity: 2
    	  },
    	  computed: {
    	    totalPriceWithTax() {
    	      return this.price * this.quantity * 1.03
    	    }
    	  }
    	})
    </script>
    

    每当price改变时,发生了三件事

    1. 更新页面中的price
    2. 页面中重新计算price * quantity 并更新
    3. 重新计算totalPriceWithTax 并更新页面中的totalPriceWithTax

    那么vue是如何自动做上面三件事的呢

    接下来我们一步一步实现它

    引入副作用概念

    首先我们告诉程序,这里有一个副作用/计算方法,存起来我会在数据更新的时候调用它

    let target = null;
    let price = 10;
    let quantity = 2;
    let total = 0;
    
    // 这是计算方法,也可以理解为price的副作用函数之一
    target = () => {
      total = price * quantity;
    };
    
    // 存计算方法的地方,可以理解为副作用函数列表
    let storage = [];
    const record = () => {
      storage.push(target);
    };
    
    // 之后在数据修改后调用
    const replay = () => {
      storage.forEach((run) => run());
    };
    
    // 存计算方法
    record();
    // 先执行一遍计算方法获得total
    target();
    
    console.log(total); // 20
    // 修改price
    price = 20;
    console.log(total); // 20
    // price被修改,产生副作用,执行一遍副作用列表内的函数
    replay();
    
    // 得到price修改副作用生效后的新的total
    console.log(total); // 40
    
    

    发布订阅模式,引入依赖概念

    接下来将与副作用相关的函数整合为Dep对象内,Dep是dependency的缩写,依赖的意思,一个数据的副作用本质上就是数据修改导致副作用被执行,那么该副作用函数也就相当于是该数据的依赖项,副作用函数的执行依赖于数据的修改。Dep也是vue内部的命名定义,这里准确理解Dep的含义是很重要的,框架中大部分变量的命名都是有意义的,包含了作者编写框架时的思路。

    let target = null;
    class Dep {
      constructor() {
        // 存计算方法的地方,可以理解为副作用函数列表
    		// subscribers的缩写
        this.subs = [];
      }
    
      // 添加依赖
      depend() {
        if (target && !this.subs.includes(target)) {
          this.subs.push(target);
        }
      }
      // 之后在数据修改后调用
      notify() {
        this.subs.forEach((sub) => sub());
      }
    }
    
    let dep = new Dep();
    
    let price = 10;
    let quantity = 2;
    let total = 0;
    
    // 这是计算方法,也可以理解为price的副作用函数之一
    target = () => {
      total = price * quantity;
    };
    
    // 存计算方法
    dep.depend();
    // 先执行一遍计算方法获得total
    target();
    
    console.log(total); // 20
    // 修改price
    price = 20;
    console.log(total); // 20
    // price被修改,产生副作用,执行一遍副作用列表内的函数
    dep.notify();
    
    // 得到price修改副作用生效后的新的total
    console.log(total); // 40
    

    代码整理过变得清爽多了,与依赖/副作用相关的方法被聚在了Dep类中,这里与第一部分的代码相比方法名和变量更改了,为了方便之后的理解。可以看到Dep类其实就是一个典型的发布订阅者模式,通过depend方法依赖们对主体进行订阅,通过notify方法主体对订阅者(也就是依赖)进行消息通知

    整理代码,引入观察者概念

    接下来在Dep类外部进行依赖添加的部分也可以抽象,因为从依赖的视角,每个依赖项其实也是一个个观察者,观察price(依赖的值,这里以price举例)的变化,因此定义一个watch方法,进行依赖的订阅操作

    let target = null;
    class Dep {
      constructor() {
        // 存计算方法的地方,可以理解为副作用函数列表
        this.subs = [];
      }
    
      // 添加依赖
      depend() {
        if (target && !this.subs.includes(target)) {
          this.subs.push(target);
        }
      }
      // 之后在数据修改后调用
      notify() {
        this.subs.forEach((sub) => sub());
      }
    }
    
    let dep = new Dep();
    
    let price = 10;
    let quantity = 2;
    let total = 0;
    
    function watch(func) {
      target = func;
      // 存计算方法 依赖收集
      dep.depend();
      // 先执行一遍计算方法获得total
      target();
      // 将target设置为null,供其它响应式数据使用
      target = null;
    }
    
    // 这是计算方法,也可以理解为price的副作用函数之一
    watch(() => {
      total = price * quantity;
    });
    
    console.log(total); // 20
    // 修改price
    price = 20;
    console.log(total); // 20
    // price被修改,产生副作用,执行一遍副作用列表内的函数
    dep.notify();
    
    // 得到price修改副作用生效后的新的total
    console.log(total); // 40
    

    这里进行了一点优化,在watch方法尾部将target置空了,也就是恢复原状了,防止出现未知bug

    数据对象化

    接下来将依赖存入对象data

    let target = null;
    class Dep {
      constructor() {
        // 存计算方法的地方,可以理解为副作用函数列表
        this.subs = [];
      }
    
      // 添加依赖
      depend() {
        if (target && !this.subs.includes(target)) {
          this.subs.push(target);
        }
      }
      // 之后在数据修改后调用
      notify() {
        this.subs.forEach((sub) => sub());
      }
    }
    
    let dep = new Dep();
    
    let data = { price: 10, quantity: 2 };
    let total = 0;
    
    function watch(func) {
      target = func;
      // 存计算方法 依赖收集
      dep.depend();
      // 先执行一遍计算方法获得total
      target();
      // 将target设置为null,供其它响应式数据使用
      target = null;
    }
    
    // 这是计算方法,也可以理解为price的副作用函数之一
    watch(() => {
      total = data.price * data.quantity;
    });
    
    console.log(total); // 20
    // 修改price
    data.price = 20;
    console.log(total); // 20
    // price被修改,产生副作用,执行一遍副作用列表内的函数
    dep.notify();
    
    // 得到price修改副作用生效后的新的total
    console.log(total); // 40
    

    代理模式,defineProperty

    在上面的代码中,副作用我们是通过dep.notify手动触发的,那么我们怎样实现修改了data.price后自动触发呢,这里我们引入代理模式,对data的赋值操作进行代理,js中实现代理模式的方法就莫属Object.defineProperty/Proxy了,这里先讲Object.defineProperty的实现方式,vue2就是采用这种方式的。

    let target = null;
    class Dep {
      constructor() {
        // 存计算方法的地方,可以理解为副作用函数列表
        this.subs = [];
      }
    
      // 添加依赖
      depend() {
        if (target && !this.subs.includes(target)) {
          this.subs.push(target);
        }
      }
      // 之后在数据修改后调用
      notify() {
        this.subs.forEach((sub) => sub());
      }
    }
    function watch(func) {
      target = func;
      // 先执行一遍计算方法获得total
      target();
      // 将target设置为null,供其它响应式数据使用
      target = null;
    }
    
    let data = { price: 10, quantity: 2 };
    let total = 0;
    
    Object.keys(data).forEach((key) => {
      let dep = new Dep();
      let internalValue = data[key];
      Object.defineProperty(data, key, {
        get() {
    			// 存计算方法 依赖收集
          dep.depend();
          return internalValue;
        },
        set(newVal) {
          internalValue = newVal;
          // price被修改,产生副作用,执行一遍副作用列表内的函数
          dep.notify();
        },
      });
    });
    
    // 这是计算方法,也可以理解为price的副作用函数之一
    watch(() => {
      total = data.price * data.quantity;
    });
    
    console.log(total); // 20
    // 修改price
    data.price = 20;
    
    // 得到price修改副作用生效后的新的total
    console.log(total); // 40
    
    setTimeout(() => {
      data.price = 30;
      console.log(total); // 60
    }, 1000);
    

    注意,上面我们把依赖收集的逻辑移入了get方法中,把通知执行副作用函数的逻辑移入了set方法中,这样在调用watch方法时,内部会触发属性的读操作,从而触发依赖收集,在设置值的时候,会触发属性的写操作,从而再次执行副作用,更新total的值

    到此为止,一个纯数据层面的响应式系统已经完成了 ?,可以看到,我们用了发布订阅模式和代理模式搭配使用,很快便搭好了一个简单的响应式系统。

    数据与视图绑定

    但是,vue中我们修改数据是能够实现页面数据的刷新的,接下来我们来探讨如何将数据与视图层面进行绑定。

    首先我们创建一个html,dom结构就照着vue中的template来

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

    Reactive类

    接下来创建一个类似vue的Reactive类

    这个类中我们主要做两件事情

    1. 将我们传入的data变为响应式的
    2. 编译body中的模版,将页面与数据关联
    class Reactive {
      constructor(options) {
        this.options = options;
        // 使data内的数据变为响应式
        this.$data = observe(this.options.data);
        this.el = document.querySelector(this.options.el);
    		// 将模板编译,数据和视图绑定
        this.compile(this.el);
      }
    }
    

    对Reactive类的使用与vue类似

    let vm = new Reactive({
      el: "#app",
      data: {
        name: "飞",
        age: 23
      }
    });
    

    observe

    接下来先实现observe方法,其实就是把上面硬编码的逻辑抽成函数,并且加上递归优化

    function observe (data) {
      if(typeof data !== 'object' || data === null) return
      Object.keys(data).forEach((key) => {
        let dep = new Dep();
        let internalValue = data[key];
    		// 递归使整个对象都变成响应式
        observe(data[key])
        Object.defineProperty(data, key, {
          get () {
            // 依赖注入
            dep.depend()
            return internalValue;
          },
          set(newVal) {
            internalValue = newVal;
            // 数据被修改,产生副作用,执行一遍副作用列表内的函数
            dep.notify();
          },
        });
      });
    }
    

    compile

    下面实现compile方法

    compile(el) {
      // 取出子节点
      let child = el.childNodes;
      // 遍历子节点
      [...child].forEach((node) => {
        // 如果是文本节点
        if (node.nodeType === Node.TEXT_NODE) {
          let text = node.textContent;
          let reg = /{{\s*([^\s{}]+)\s*}}/;
          // 如果文本内容符合 {{xxx}} 的形式
          if (reg.test(text)) {
            let $1 = RegExp.$1;
            // 如果data中有xxx,则用data中的数据替换xxx
            // 监听xxx,如果xxx发生更改,修改dom的内容
            this.$data[$1] && watch(() => {
              node.textContent = text.replace(reg, this.$data[$1]);
            });
          }
        } else if (node.nodeType === Node.ELEMENT_NODE) {
          // 如果是普通元素节点
          let attr = node.attributes;
    
          // 如果属性中存在v-model
          if (attr.hasOwnProperty("v-model")) {
            // 得到v-model属性节点的值
            let keyName = attr["v-model"].nodeValue;
            // 将元素节点的值修改
            node.value = this.$data[keyName];
            // 监听元素节点的input事件,input后修改data中的数据
            node.addEventListener("input", (e) => {
              this.$data[keyName] = node.value;
            });
          }
        }
        // 递归对子节点处理
        this.compile(node);
      });
    }
    

    代码逻辑整体上还是比较简单的,遍历子节点,如果是文本节点,那么看是否符合{{xxx}}这种形式,如果符合,并且data当中存在xxx属性,那么就将{{xxx}}替换为data中的数据,并且将这一操作作为副作用加入依赖中(watch中这些操作都做了);如果是元素节点,那么判断其属性中是否存在v-model,如果存在v-model,就监听元素的input事件,当input时就将data中对应的数据进行修改,因为data是响应式的,所以修改了对于数据后,页面上与之关联的文本节点也会更新数据

    完整代码

    下面是完整代码

    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <title>Document</title>
      </head>
      <body>
        <div id="app">
          {{name}}
          <h2>{{age}}</h2>
          <input type="text" v-model="name" />
        </div>
      </body>
    </html>
    <script>
      let target = null;
      class Dep {
        constructor() {
          // 存计算方法的地方,可以理解为副作用函数列表
          this.subs = [];
        }
    
        // 添加依赖
        depend() {
          if (target && !this.subs.includes(target)) {
            this.subs.push(target);
          }
        }
        // 之后在数据修改后调用
        notify() {
          this.subs.forEach((sub) => sub());
        }
      }
    
      function watch(func) {
        target = func;
        // 先执行一遍计算方法获得total
        target();
        // 将target设置为null,供其它响应式数据使用
        target = null;
      }
    
      function observe(data) {
        if (typeof data !== "object" || data === null) return;
        Object.keys(data).forEach((key) => {
          let dep = new Dep();
          let internalValue = data[key];
          observe(data[key]);
          Object.defineProperty(data, key, {
            get() {
              // 依赖注入
              dep.depend();
              return internalValue;
            },
            set(newVal) {
              internalValue = newVal;
              // 数据被修改,产生副作用,执行一遍副作用列表内的函数
              dep.notify();
            },
          });
        });
      }
      class Reactive {
        constructor(options) {
          this.options = options;
          // 使data内的数据变为响应式
          this.$data = this.options.data;
          observe(this.$data);
          this.el = document.querySelector(this.options.el);
          this.compile(this.el);
        }
    
        compile(el) {
          // 取出子节点
          let child = el.childNodes;
          // 遍历子节点
          [...child].forEach((node) => {
            // 如果是文本节点
            if (node.nodeType === Node.TEXT_NODE) {
              let text = node.textContent;
              let reg = /{{\s*([^\s{}]+)\s*}}/;
              // 如果文本内容符合 {{xxx}} 的形式
              if (reg.test(text)) {
                let $1 = RegExp.$1;
                // 如果data中有xxx,则用data中的数据替换xxx
                // 监听xxx,如果xxx发生更改,修改dom的内容
                this.$data[$1] &&
                  watch(() => {
                    node.textContent = text.replace(reg, this.$data[$1]);
                  });
              }
            } else if (node.nodeType === Node.ELEMENT_NODE) {
              // 如果是普通元素节点
              let attr = node.attributes;
    
              // 如果属性中存在v-model
              if (attr.hasOwnProperty("v-model")) {
                // 得到v-model属性节点的值
                let keyName = attr["v-model"].nodeValue;
                // 将元素节点的值修改
                node.value = this.$data[keyName];
                // 监听元素节点的input事件,input后修改data中的数据
                node.addEventListener("input", (e) => {
                  this.$data[keyName] = node.value;
                });
              }
            }
            // 递归对子节点处理
            this.compile(node);
          });
        }
      }
    </script>
    <script>
      let vm = new Reactive({
        // 挂载元素
        el: "#app",
        data: {
          name: "飞",
          age: 23,
        },
      });
    </script>
    

    使用Proxy优化

    上面我们提到,在js中实现代理模式可以通过defineProperty,也可以通过Proxy,那么这两者有什么区别呢,vue3又为何将defineProperty替换为Proxy呢,我们继续探究

    我们引入数组数据并且添加mounted生命周期钩子

    ...
      <body>
        <div id="app">
          {{name}}
          <h2>{{age}}</h2>
          <input type="text" v-model="name" />
          {{arr}}
        </div>
      </body>
    ...
      class Reactive {
        constructor(options) {
          this.options = options;
          // 使data内的数据变为响应式
          this.$data = this.options.data;
          observe(this.$data);
          this.el = document.querySelector(this.options.el);
          this.compile(this.el);
    
          this.$mounted = this.options.mounted
          this.$mounted.call(this)
        }
    ...
      let vm = new Reactive({
        // 挂载元素
        el: "#app",
        data: {
          name: "飞",
          age: 23,
          arr: [0,1,2]
        },
    	mounted(){
          setTimeout(() => {
            this.$data.arr[3] = 3
            console.log('this.$data.arr[3] = 3: ', this.$data.arr[3] = 3);
          }, 1000);
        }
      });
    </script>
    

    结果发现在一秒后,data中的arr[3]数据的确被修改了,但是页面上的数据还是没变化,这说明我们的数组并非响应式的。这个问题在vue中是老生常谈的问题了,虽然vue通过拦截push,pop等操作一定程度上实现了数组的响应式,可是对于this.$data.arr[3] = 3这种通过下标索引直接赋值的操作是做不到可响应的。以上现象的原因是受限于defineProperty,无法对数组内元素的直接操作进行监听。其实很多依赖于defineProperty的响应式库都有这个问题,mobx中的解决方式就是对于数组创建0-999项,将这1000项全变成响应式的,因此在使用mobx时,明明在需求层面,我们的列表中只有若干项,可是我们在打印数组时会打印出1000个数据。

    Proxy相比于defineProperty,一个显著的优点就是可以通过下标监听数组内元素的变化了,接下来我们使用Proxy进行优化

    ...
      // 使data变为响应式
      function observe(data) {
        if (typeof data !== "object" || data === null) {
          return data;
        }
    
        // 将data中的子对象也变为响应式
        let val;
        Object.keys(data).forEach((key) => {
          val = data[key];
          data[key] = observe(val);
        });
        const dep = new Dep();
        return new Proxy(data, {
          get(target, key, receiver) {
            dep.depend(); // 依赖注入
            return Reflect.get(target, key, receiver);
          },
          set(target, key, val, receiver) {
            Reflect.set(target, key, val, receiver);
            dep.notify(); // 执行
            return true;
          },
        });
      }
      class Reactive {
        constructor(options) {
          this.options = options;
          // 使data内的数据变为响应式
          this.$data = observe(this.options.data);
          this.el = document.querySelector(this.options.el);
          this.compile(this.el);
    
          this.$mounted = this.options.mounted;
          this.$mounted.call(this);
        }
    ...
    

    刷新页面我们可以看到在一秒后页面中的0,1,2变成了0,1,2,3 这说明对于数组我们监听成功

    下面是Proxy版本的完整代码

    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <title>Document</title>
      </head>
      <body>
        <div id="app">
          {{name}}
          <h2>{{age}}</h2>
          <input type="text" v-model="name" />
          {{arr}}
        </div>
      </body>
    </html>
    <script>
      let target = null;
      class Dep {
        constructor() {
          // 存计算方法的地方,可以理解为副作用函数列表
          this.subs = [];
        }
    
        // 添加依赖
        depend() {
          if (target && !this.subs.includes(target)) {
            this.subs.push(target);
          }
        }
        // 之后在数据修改后调用
        notify() {
          this.subs.forEach((sub) => sub());
        }
      }
    
      function watch(func) {
        target = func;
        // 先执行一遍计算方法获得total
        target();
        // 将target设置为null,供其它响应式数据使用
        target = null;
      }
    
      // 使data变为响应式
      function observe(data) {
        if (typeof data !== "object" || data === null) {
          return data;
        }
    
        // 将data中的子对象也变为响应式
        let val;
        Object.keys(data).forEach((key) => {
          val = data[key];
          data[key] = observe(val);
        });
        const dep = new Dep();
        return new Proxy(data, {
          get(target, key, receiver) {
            dep.depend(); // 依赖注入
            return Reflect.get(target, key, receiver);
          },
          set(target, key, val, receiver) {
            Reflect.set(target, key, val, receiver);
            dep.notify(); // 执行
            return true;
          },
        });
      }
      class Reactive {
        constructor(options) {
          this.options = options;
          // 使data内的数据变为响应式
          this.$data = observe(this.options.data);
          this.el = document.querySelector(this.options.el);
          this.compile(this.el);
    
          this.$mounted = this.options.mounted;
          this.$mounted.call(this);
        }
    
        compile(el) {
          // 取出子节点
          let child = el.childNodes;
          // 遍历子节点
          [...child].forEach((node) => {
            // 如果是文本节点
            if (node.nodeType === Node.TEXT_NODE) {
              let text = node.textContent;
              let reg = /{{\s*([^\s{}]+)\s*}}/;
              // 如果文本内容符合 {{xxx}} 的形式
              if (reg.test(text)) {
                let $1 = RegExp.$1;
                // 如果data中有xxx,则用data中的数据替换xxx
                // 监听xxx,如果xxx发生更改,修改dom的内容
                this.$data[$1] &&
                  watch(() => {
                    node.textContent = text.replace(reg, this.$data[$1]);
                  });
              }
            } else if (node.nodeType === Node.ELEMENT_NODE) {
              // 如果是普通元素节点
              let attr = node.attributes;
    
              // 如果属性中存在v-model
              if (attr.hasOwnProperty("v-model")) {
                // 得到v-model属性节点的值
                let keyName = attr["v-model"].nodeValue;
                // 将元素节点的值修改
                node.value = this.$data[keyName];
                // 监听元素节点的input事件,input后修改data中的数据
                node.addEventListener("input", (e) => {
                  this.$data[keyName] = node.value;
                });
              }
            }
            // 递归对子节点处理
            this.compile(node);
          });
        }
      }
    </script>
    <script>
      let vm = new Reactive({
        // 挂载元素
        el: "#app",
        data: {
          name: "飞",
          age: 23,
          arr: [0, 1, 2],
        },
        mounted() {
          setTimeout(() => {
            this.$data.arr[3] = 3;
            console.log("this.$data.arr[3] = 3: ", (this.$data.arr[3] = 3));
          }, 1000);
        },
      });
    </script>
    

    终于我们用了一百行左右的代码实现了一个类似vue的响应式框架 ?

    本人第一篇文章,如有不当之处,望海涵


    起源地下载网 » 自己动手实现类vue响应式库

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元