百行代码实现 Vue 2 响应式

2022-10-31 15:32:19 浏览数 (1)

首先,新建一个html文件 和 vue-reactivity.js 文件

HTML

代码语言:javascript复制
<!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">
      <label for="name">{{name}}</label>
      <input id="name" type="text" v-model="name" />
      <label for="link">{{more.link}}</label>
      <input id="link" type="text" v-model="more.link" />
    </div>
    <script src="./vue-reactivity.js"></script>
    <script>
      const vm = new Vue({
        el: "#app",
        data: {
          name: "张三",
          more: {
            link: "链接",
          },
        },
      });
</script>
  </body>
</html>

vue-reactivity.js

新建一个 Class Vue

代码语言:javascript复制
// 创建一个 Vue 类
class Vue {
  constructor(obj) {
  // 接收示例传递过来额 data,并存储到 $data 中
    this.$data = obj.data;
  }
}

新建一个 观察者(Observer)

代码语言:javascript复制
// 创建观察者
function Observer(data) {
  // 获取 data 中的 key, 进行循环 调用 Object.defineProperty --- 可以使用 ES6 中的 Object.keys() 进行获取
  Object.keys(data).forEach((key) => {
    let value = data[key];
    Object.defineProperty(data, key, {
      enumerable: true, // 是否可迭代, 默认为 true 可不写
      configurable: true, // 是否可配置, 默认为 true 可不写
      get() {
        return value;
      },
      set(nVal) {
        value = nVal;
      },
    });
  });
}

当我们做完这两步之后就已经可以简单的监听数据的读取以及设置了

如果他本来就是引用数据类型,通过上图可以看出在获取more.link的时候并没有触发 获取值这个操作,说明并没有监听到,还有就是在赋值时,类型为引用类型时就会发现没有响应式,所以这里可以使用递归进行处理,修改如下:

代码语言:javascript复制
// 创建观察者
function Observer(data) {
  // 因为要递归,所以要有递归结束条件
  if (!data || typeof data !== "object") return;
  // 获取 data 中的 key, 进行循环 调用 Object.defineProperty --- 可以使用 ES6 中的 Object.keys() 进行获取
  Object.keys(data).forEach((key) => {
    let value = data[key];
    // 如果是引用类型需要递归处理
    Observer(value)
    Object.defineProperty(data, key, {
      enumerable: true, // 是否可迭代, 默认为 true 可不写
      configurable: true, // 是否可配置, 默认为 true 可不写
      get() {
        return value;
      },
      set(nVal) {
        value = nVal;
        // 如果是引用类型需要递归处理
        Observer(nVal)
      },
    });
  });
}

这样我们就能准确的触发到监听了

接下来我们需要创建一个 编译器 先将数据绑定到页面上

编译器(Complie)

代码语言:javascript复制
// 创建一个 Vue 类
class Vue {
  constructor(obj) {
    this.$data = obj.data;
    Observer(obj.data);
    // 调用编译器
    Complie(obj.el, this);
  }
}

// 编译器
function Complie(element, vm) {
  vm.$el = document.querySelector(element);
  // 创建 一个 fragment 将多次 dom 操作合成一次,提高性能
  const fragment = document.createDocumentFragment();
  let child;
  while ((child = vm.$el.firstChild)) {
    fragment.append(child);
  }
  console.log(fragment);
}

这时候就会发现的页面变成空白的了,这是因为我们创建了一个文档碎片,所以当我们每一次 append 的时候,浏览器会自动删除这个标签。

https://developer.mozilla.org/en-US/docs/Web/API/Document/createDocumentFragment

代码语言:javascript复制
// 编译器
function Complie(element, vm) {
  // ...
  console.log(fragment);
  // 将我们处理好的文档碎片添加回网页中
  vm.$el.appendChild(fragment);
}

当使用 vm.$el.appendChild(fragment) 添加回网页中时候,这时网页又有内容了。

接下来就是处理每一个节点了

Complie_fragment

代码语言:javascript复制
// 编译器
function Complie(element, vm) {
  // ...
  //   处理每一个node节点
  Complie_fragment(fragment);
  //   编译处理
  function Complie_fragment(node) {
    // 定义匹配 {{}} 的正则表达式
    const pattren = /{{s*(S )s*}}/;
    // 先判断是不是文本节点
    if (node.nodeType === 3) {
      // 如果是文本节点,就通过正则放回匹配的结果
      const result_regexp = pattren.exec(node.nodeValue);
      //   因为 result_regexp 可能不存在 所以需要过滤一下
      if (result_regexp) {
        console.log(result_regexp);
      }
    }
    // 循环获取每一个节点并 调用 Complie_fragment 进行处理
    node.childNodes.forEach((child) => Complie_fragment(child));
  }
  // ...
}

这时候我们输出控制台可以看到,我们想要的数据在数组的第二项,所以接下来我们就需要拿到它进行处理了

代码语言:javascript复制
// 编译器
function Complie(element, vm) {
  // ...
  //   编译处理
  function Complie_fragment(node) {
    // 定义匹配 {{}} 的正则表达式
    const pattren = /{{s*(S )s*}}/;
    // 先判断是不是文本节点
    if (node.nodeType === 3) {
      // 如果是文本节点,就通过正则放回匹配的结果
      const result_regexp = pattren.exec(node.nodeValue);
      //   因为 result_regexp 可能不存在 所以需要过滤一下
      if (result_regexp) {
      // 将 {{...}} 替换成实际的值 
      const attr = result_regexp[1];
        node.nodeValue = node.nodeValue.replace(pattren, vm.$data[attr]);
      }
    }
    // 循环获取每一个节点并 调用 Complie_fragment 进行处理
    node.childNodes.forEach((child) => Complie_fragment(child));
  }
  // ...
}

当我们替换之后发现第二个是一个undefined,这是为什么呢?

通过控制台可以发现,我们先来看一下 HTML 文件是怎么绑定的

代码语言:javascript复制
<div id="app">
  <label for="name">{{name}}</label>
  <input id="name" type="text" v-model="name" />
  <label for="link">{{more.link}}</label>
  <input id="link" type="text" v-model="more.link" />
 </div>

可以发现,第一个label是直接绑定 name 的,而第二个 label 是通过链式获取对象 more 中的 link,而我们在替换的时候,采用的是

代码语言:javascript复制
// vm.$data[attr] 转换之后就是 vm.$data[more.link]。
// 这样取值的意思是在 $data 中获取一个名为 more.link 的属性的值
// 而 $data 对象中没有这一个属性,取值时就是 undefined
// 所有这里就有一个小技巧,可以使用 reduce
function Complie_fragment(node) {
// ...   
 const replaceVal = result_regexp[1].reduce((prev, current)=>prev[current], vm.$data)
 node.nodeValue = node.nodeValue.replace(pattren, replaceVal );
// ...
}

刷新浏览器之后发现我们的数据已经渲染完成了

如果我们在渲染 HTML 的时候 不是使用 more.link 而是使用 more['link'] 或者more[‘变量’]这种形式的话,应该怎么处理渲染呢?可以思考一下。

当写到这里的时候,说明就已经成功一半了,但是当我们去修改 name 值的时候,可以看见 Vue 实例中的 name已经改变,但是我们的视图却还是原来的数据。那我们应该怎么去监听数据变化并实时更新视图呢?

VUE 响应式的一个重点---发布订阅(Dep/Watcher)

Dep --- > 主要功能是进行依赖收集,

addSub() 收集依赖,notify() 通知更新

代码语言:javascript复制
class Dep {
  constructor() {
    this.subscription = [];
  }
  addSub(sub) {
    //   依赖收集
    this.subscription.push(sub);
  }
  notify() {
    //   通知更新
    this.subscription.forEach((sub) => sub.update());
  }
}

依赖收集类已经定义好了,那应该在哪个地方去收集依赖呢?

答案是 --- 在 Observer 中,因为在 Observer 中,我们定义了属性的getter 和 setter,而我们收集依赖就应该在 getter 的时候去将他收集(addSub)起来,然后在setter的时候去通知(notify)视图更新,优化如下:

代码语言:javascript复制
// 创建观察者
function Observer(data) {
  // 获取 data 中的 key, 进行循环 调用 Object.defineProperty --- 可以使用 ES6 中的 Object.keys() 进行获取
  // 创建 Dep() 每一个 Observer 都有一个 dep
  const dep = new Dep();
  Object.keys(data).forEach((key) => {
    let value = data[key];
    // 当对象有多级的时候,只监听到了第一级,而子级的其他属性都没有监听到,所以需要进行递归设置
    Observer(value);
    Object.defineProperty(data, key, {
      get() {
        // ...
        // 依赖收集
        Dep.temp && dep.addSub(Dep.temp);
        return value;
      },
      set(nVal) {
        // ...
        // 通知更新 Dep 更新
        dep.notify();
      },
    });
  });
}

Watcher

接收三个参数,当前示例 vm,需要更新的key,如何去更新(callback)

代码语言:javascript复制
class Watcher {
  constructor(vm, key, callback) {
    this.vm = vm;
    this.key = key;
    // 更新函数 这里的callback是如何去更新
    this.callback = callback;
  }
  update() {
    const value = this.key
      .split(".")
      .reduce((prev, current) => prev[current], this.vm.$data);
      
    this.callback(value);
  }
}

为了确保所有变量都能监听的到,所以先触发一遍 data 中的数据,优化如下:

代码语言:javascript复制
class Watcher {
  constructor(vm, key, callback) {
    this.vm = vm;
    this.key = key;
    this.callback = callback;
    // 定义一个临时变量 用于依赖收集时使用
    Dep.temp = this;
    // 为了确保所有变量都能监听的到,所以先触发一遍 data 中的数据
    this.key.split(".").reduce((prev, current) => prev[current], this.vm.$data);
    // 依赖收集完成,清空临时变量
    Dep.temp = null;
  }
  update() {
    const value = this.key
      .split(".")
      .reduce((prev, current) => prev[current], this.vm.$data);
    this.callback(value);
  }
}

当我们做完这些之后,文本节点的处理就已经完成了,下图可以看到当我们在控制台修改 name 属性的时候,左边的视图也及时更新了,接下来就是处理input 类型的双向绑定了。

和之前处理文本节点差不多先取出绑定的属性值

代码语言:javascript复制
// 编译器
function Complie(element, vm) {
  // ...
  //   编译处理
  function Complie_fragment(node) {
    // 定义匹配 {{}} 的正则表达式
    const pattren = /{{s*(S )s*}}/;
    // 先判断是不是文本节点
    if (node.nodeType === 3) {
      // ...
    }
    if (node.nodeType === 1 || node.nodeName === "INPUT") {
    // 由于 input 或者 nodeType===1的v-model属性是绑定在标签上面的,
    // 所以需要使用 node.attributes。
    // 由于 node.attributes 是类数组,无法使用数组额方法,所以转成数组
      const attrArray = [...node.attributes];
      attrArray.forEach((attr) => {
        if (attr.nodeName === "v-model") {
          // 获取额属性和之前额一样所以 reduce 一下
          console.log(attr.nodeValue);
         const value = attr.nodeValue
            .split(".")
            .reduce((prev, current) => prev[current], vm.$data);
          // 这里可以直接给 input 的 value 属性赋值,因为其 value 并没有使用 {{}}进行绑定
          node.value = value;
          // 同样创建 Watcher 进行更新
          new Watcher(vm, attr.nodeValue, (newVal) => {
            node.value = newVal;
          });
        }
      });
    }
    // 循环获取每一个节点并 调用 Complie_fragment 进行处理
    node.childNodes.forEach((child) => Complie_fragment(child));
  }
  // ...
}

下图可以看到,我们的 input 已经绑定上 data 中的数据,而且当data中的数据发生变化时也能实时更新,但是在输入框输入值时,data中的数据便没有进行一个更新,接下来我们实现一下它就大功告成了。

要实现 input 值的改变去改变data中的值,就需要监听 input 输入并获取输入的值,可以使用 addEventListener('事件名',处理函数(event),false/true(冒泡/捕获))

监听 input 的输入可以使用 onInput 事件,其中 e.target.value 就是输入框的值

代码语言:javascript复制
input.addEventListener(
            "input",
            (e) => {
              console.log(e.target.value);
            },
            false
          );
代码语言:javascript复制
// 编译器
function Complie(element, vm) {
  // ...
  //   编译处理
  function Complie_fragment(node) {
    // 定义匹配 {{}} 的正则表达式
    const pattren = /{{s*(S )s*}}/;
    // 先判断是不是文本节点
    if (node.nodeType === 3) {
      // ...
    }
    if (node.nodeType === 1 || node.nodeName === "INPUT") {
    // 由于 input 或者 nodeType===1的v-model属性是绑定在标签上面的,
    // 所以需要使用 node.attributes。
    // 由于 node.attributes 是类数组,无法使用数组额方法,所以转成数组
      const attrArray = [...node.attributes];
      attrArray.forEach((attr) => {
        if (attr.nodeName === "v-model") {
          // 获取额属性和之前额一样所以 reduce 一下
          console.log(attr.nodeValue);
         const value = attr.nodeValue
            .split(".")
            .reduce((prev, current) => prev[current], vm.$data);
          // 这里可以直接给 input 的 value 属性赋值,因为其 value 并没有使用 {{}}进行绑定
          node.value = value;
          // 同样创建 Watcher 进行更新
          new Watcher(vm, attr.nodeValue, (newVal) => {
            node.value = newVal;
          });
          node.addEventListener(
            "input",
            (e) => {
              // 由于属性也是有多级的,所以也是使用 reduce, 但是这里是设置而不是取值,所以需要留出最后一级属性进行赋值
              const arr1 = attr.nodeValue.split(".");
              const arr2 = arr1.slice(0, arr1.length - 1);
              const prefix = arr2.reduce(
                (prev, current) => prev[current],
                vm.$data
              );
              prefix[arr1[arr1.length - 1]] = e.target.value;
            },
            false
          );
        }
      });
    }
    // 循环获取每一个节点并 调用 Complie_fragment 进行处理
    node.childNodes.forEach((child) => Complie_fragment(child));
  }
  // ...
}

下图可以看到,在输入框输入值时,data中的数据也进行了更新,data 更新之后,视图也进行了更新。

好了,上面就是本期主要内容了。

0 人点赞