手把手教你实现vue数据双向绑定(下)

这是我参与8月更文挑战的第N天,活动详情查看:8月更文挑战(已经更文3天)

前言

手把手教你实现vue数据双向绑定(上)中,我们实现了一个青春阳光版的简易的vue。它实现了数据的双向绑定,但是仅此而已。今天我们在继续完善我们的简易版vue,给它加入模板编译以及深度监听对象变化的功能。

修改模板

在上一篇文章中,我们最终实现的vue的HTML结构是这样子的:

  <div id="app">
    <input type="text" id="ceshi">
    <p id="watcher"></p>
  </div>
复制代码

这跟我们平时开发的.vue文件好像长得不太一样,缺少了指令以及双括号语法等。所以,我们把它给改造一下:

  <div id="app">
    val:<input type="text" v-model="val" name="">
    info:<input type="text" v-model="info.text" name="">
    <p v-bind="val"></p>
    <p v-bind="info.text"></p>
    <p>属性节点:{{val}}</p>
    <p>对象节点:{{ info.text}}</p>
  </div>
复制代码

之后,我们把定义在Vue.js里的测试方法部分代码给删除了。我们再来看看现在页面的效果:

01.gif

想一想为什么失去了数据双向绑定的功能?我们的data对象数据劫持这部分代码还是保留着的,那么在我们删除掉的测试方法里,删除掉了什么部分呢?我们来分析一下测试方法都有些什么:

// 测试方法 - 监听input输入 
const input = document.getElementById("ceshi"); 
input.addEventListener( "input", (e) => { vm.$data.val = e.target.value; // 赋值操作 }, false ); 

// 获取观众 
const watcher = document.getElementById("watcher"); 
// 新建观众 
// 这里订阅的是 val 这个频道 
new Watcher(vm.$data, "val", (newVal) => { watcher.innerHTML = newVal; });
复制代码
  1. 第一部分是监听input输入,我们之前是直接获取这个input节点,给它绑定上事件来实现的。而现在经过我们的改造,我们在input上面绑定上了v-model指令,所以我们的模板编译的一个目标出来了:通过v-model指令来实现input的输入监听包括初始化的赋值。

  2. 第二部分是获取需要实现数据双向绑定的节点,当data对象改变时自动改变其文本节点的值。而经过我们的改造,我们在p标签上绑定了v-bind指令和在文本节点上添加了{{}}双括号语法,所以我们的模板编译的第二个目标出来了:通过v-bind指令和在文本节点上添加{{}}双括号语法实现文本节点的自动更新。

模板编译

我们在Vue.js的原型上面新增一个complie方法,用于进行我们的模板编译

Vue.prototype.complie = function (root) {
  // 遍历根节点下的所有节点,解析指令
  const nodes = root.children;
  for (let i = 0; i < nodes.length; i++) {
    const node = nodes[i];
    // 如果还有子节点,则递归遍历
    if (node.children.length) {
      this.complie(node);
    }
  }
};
复制代码

这个方法接受一个参数,这个参数就是我们在定义vue实例时传入的根节点。我们在这个方法中,遍历此根节点下的所有节点,去逐个检测每个节点中是否存在指令或者双括号语法

我们现在是直接操作浏览器的DOM元素,这样做是非常消耗性能的。而vue处理模板编译是先把template转换成render,通过虚拟DOM去操作真实DOM,具体的在这边就不展开了。

解析模板指令

我们先来实现解析模板指令这一功能,具体的实现逻辑就是:通过hasAttribute去判断某个节点是否存在我们自定义的指令,通过getAttribute去获取这个指令所绑定的data对象的具体属性(也就是频道)。

v-model

v-model的具体作用是绑定input或者textarea标签的输入事件,当输入改变时,改变data对象中的具体属性的值,来发布订阅

    if (
      node.hasAttribute("v-model") &&
      (node.tagName == "INPUT" || node.tagName == "TEXTAREA")
    ) {
      // 如果元素绑定了 v-model指令 且 元素为输入框
      node.addEventListener(
        "input",
        (e) => {
          // 赋值对应的属性,更新订阅
          const attr = node.getAttribute("v-model");
          this.$data[attr] = e.target.value;
        },
        false
      );
    }
复制代码

v-bind

既然发布了订阅,那么就要有响应这些订阅的地方,我们的v-bind就是响应订阅:

    if (node.hasAttribute("v-bind")) {
      const attr = node.getAttribute("v-bind");
      // 创建 观众 - 自动订阅频道
      new Watcher(this.$data, attr, (newVal) => {
        node.innerText = newVal;
      });
    }
复制代码

我们现在的指令已经基本上都解析好了,但是我们先不着急看效果,再来把双括号语法给解析一下。

解析双括号

v-bind的思路一样,区别在于双括号语法是存在文本节点中的,我们无法通过getAttribute去直接获取,所以我们要解析存在双括号语法的文本节点:

    if (node.childNodes.length !== 0) {
      const childList = node.childNodes;
      childList.forEach((child) => {
        if (child.nodeType === 3) {
          // 文本节点
          replaceStr(this.$data, child);
        }
      });
    }
复制代码

replaceStr则是我们定义的一个解析文本节点并替换的方法,我们新建一个utils文件夹,在该文件夹下新建一个index.js来存放我们的这个方法:

src/utils/index.js

import Watcher from "../core/Watcher";

export const replaceStr = function (data, node) {
  const replaceReg = /\{\{((?:.|\r?\n)+?)\}\}/g;
  const attrReg = /\{\{((?:.|\r?\n)+?)\}\}/;
  const str = node.data;
  const attr = str.match(attrReg)[1].trim();
  // 初始化赋值
  const initRes = str.replace(replaceReg, function (...args) {
    return data[attr];
  });
  node.data = initRes;
  // 创建 观众 - 自动订阅频道
  new Watcher(data, attr, (newVal) => {
    const res = str.replace(replaceReg, function (...args) {
      return newVal;
    });
    node.data = res;
  });
};
复制代码

验证一下

我们在Vue.js中引入模板编译的方法:

Vue.js

import Dep from "./Dep";
import Watcher from "./Watcher";
import { replaceStr } from "../utils";

export default class Vue {
  constructor(option) {
    this._init(option);
  }
}

Vue.prototype._init = function (option) {
  const vm = this;

  vm.$el = document.querySelector(option.el); // 项目根节点
  vm.$data = option.data(); // data对象

  // 劫持data对象
  vm.defineReactive(vm.$data);

  vm.complie(vm.$el);
};

Vue.prototype.defineReactive = function (obj) {
  if (!(obj instanceof Object)) return;
  // 遍历对象
  for (let key in obj) {
    let val = obj[key]; // 赋值
    const dep = new Dep();
    Object.defineProperty(obj, key, {
      enumerable: true, // 是否可枚举
      configurable: true, // 是否可配置
      get() {
        console.log(`get:${key} - ${val}`);
        dep.depend(); // 订阅
        return val;
      },
      set(newVal) {
        if (newVal === val) return;
        console.log(`set:${key} - ${newVal}`);
        val = newVal;
        dep.notify(); // 发布
      },
    });
  }
};

Vue.prototype.complie = function (root) {
  // 遍历根节点下的所有节点,解析指令
  const nodes = root.children;
  for (let i = 0; i < nodes.length; i++) {
    const node = nodes[i];
    // 如果还有子节点,则递归遍历
    if (node.children.length) {
      this.complie(node);
    }

    /*-----------------解析模板指令-------------------*/
    // 绑定子节点指令
    if (
      node.hasAttribute("v-model") &&
      (node.tagName == "INPUT" || node.tagName == "TEXTAREA")
    ) {
      // 如果元素绑定了 v-model指令 且 元素为输入框
      node.addEventListener(
        "input",
        (e) => {
          // 赋值对应的属性,更新订阅
          const attr = node.getAttribute("v-model");
          this.$data[attr] = e.target.value;
        },
        false
      );
    }

    if (node.hasAttribute("v-bind")) {
      const attr = node.getAttribute("v-bind");
      // 创建 观众 - 自动订阅频道
      new Watcher(this.$data, attr, (newVal) => {
        node.innerText = newVal;
      });
    }

    /*-----------------解析 {{}} -------------------*/
    if (node.childNodes.length !== 0) {
      const childList = node.childNodes;
      childList.forEach((child) => {
        if (child.nodeType === 3) {
          // 文本节点
          replaceStr(this.$data, child);
        }
      });
    }
  }
};
复制代码

效果如下:

02.gif

image.png

我们可以看到,data对象里的val属性已经监听成功了,但是info属性却还是没有成功,打印除了undefined。这是为什么呢?原来我们读取data对象属性的时候,是用的this.$data[attr],如果属性是一个基础类型,这样取值并没有什么问题,但如果是一个复杂类型呢?那么我们的取值后就变成了this.$data['info.text'],这样当然打印出的就是undefined咯。所以,我们还得解析对象变化

深度监听对象变化

既然是深度监听对象变化,我们的劫持对象事件也需要递归进行深度监听,因为之前我们只监听了第一层的变化。我们来修改一下我们的defineReactive方法:

修改defineReactive方法

Vue.prototype.defineReactive = function (obj) {
  if (!(obj instanceof Object)) return;
  // 遍历对象
  for (let key in obj) {
    let val = obj[key]; // 赋值
    if (val instanceof Object) {
      this.defineReactive(val);
    }
    const dep = new Dep();
    Object.defineProperty(obj, key, {
      enumerable: true, // 是否可枚举
      configurable: true, // 是否可配置
      get() {
        // console.log(`get:${key} - ${val}`);
        dep.depend(); // 订阅
        return val;
      },
      set(newVal) {
        if (newVal === val) return;
        // console.log(`set:${key} - ${newVal}`);
        val = newVal;
        dep.notify(); // 发布
      },
    });
  }
};
复制代码

修改data对象取值方式

我们不能再通过简单的点语法取值了,那么我们该如何取值data对象的属性呢?我们这里只分析点语法取值,也就是a.b.c.d之类的语法解析,中括号语法暂且不解析。那么该怎么取值呢?我们来分析一下a.b.c.d,一个字符串用句号分开,是不是很熟悉?直接用split('.')截取呀,这样我们就能拿到一个层级列表。我们再遍历这个列表,一层一层向下取值,是不是就能完成了。话不多说,让我们来实现吧:

src/utils/index.js

var unicodeRegExp = /a-zA-Z\u00B7\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u037D\u037F-\u1FFF\u200C-\u200D\u203F-\u2040\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD/;

/**
 * Parse simple path.
 */
var bailRE = new RegExp('[^' + unicodeRegExp.source + '.$_\\d]');

// 取值:当 v-bind {{}} 绑定的值是对象时,取到data中的对象值
export const parsePath = function (path) {
  if (bailRE.test(path)) return;

  var segments = path.split('.');

  return function (obj) {
    for (var i = 0; i < segments.length; i++) {
      if (!obj) {
        return;
      }
      obj = obj[segments[i]];
    }
    return obj;
  };
};
复制代码

既然取值有了,那么赋值呢?同样的思路,我们实现一下:

src/utils/index.js

// 赋值:当 v-model 绑定的值是对象时,赋值给data对象
export const setAttr = function (obj, path, val) {
  if (!obj) return;
  if (bailRE.test(path)) return;

  var segments = path.split('.');

  for (var i = 0; i < segments.length - 1; i++) {
    obj = obj[segments[i]];
  }
  obj[segments[segments.length - 1]] = val;
};
复制代码

至此,来看下我们的src/utils/index.js的完整代码:

src/utils/index.js

import Watcher from "../core/Watcher";

var unicodeRegExp =
  /a-zA-Z\u00B7\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u037D\u037F-\u1FFF\u200C-\u200D\u203F-\u2040\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD/;

/**
 * Parse simple path.
 */
var bailRE = new RegExp("[^" + unicodeRegExp.source + ".$_\\d]");

// 取值:当 v-bind {{}} 绑定的值是对象时,取到data中的对象值
export const parsePath = function (path) {
  if (bailRE.test(path)) return;

  var segments = path.split(".");

  return function (obj) {
    for (var i = 0; i < segments.length; i++) {
      if (!obj) {
        return;
      }
      obj = obj[segments[i]];
    }
    return obj;
  };
};

// 赋值:当 v-model 绑定的值是对象时,赋值给data对象
export const setAttr = function (obj, path, val) {
  if (!obj) return;
  if (bailRE.test(path)) return;

  var segments = path.split(".");

  for (var i = 0; i < segments.length - 1; i++) {
    obj = obj[segments[i]];
  }
  obj[segments[segments.length - 1]] = val;
};

export const replaceStr = function (data, node) {
  const replaceReg = /\{\{((?:.|\r?\n)+?)\}\}/g;
  const attrReg = /\{\{((?:.|\r?\n)+?)\}\}/;
  const str = node.data;
  const attr = str.match(attrReg)[1].trim();
  // 初始化赋值
  // 先获取data中此attr属性的值
  const getter = parsePath(attr);
  const initValue = getter(data);
  const initRes = str.replace(replaceReg, function (...args) {
    return initValue;
  });
  node.data = initRes;
  // 创建 观众 - 自动订阅频道
  new Watcher(data, attr, (newVal) => {
    const res = str.replace(replaceReg, function (...args) {
      return newVal;
    });
    node.data = res;
  });
};
复制代码

根据我们的工具函数,改造下我们的Vue.js

import Dep from "./Dep";
import Watcher from "./Watcher";
import { setAttr, parsePath, replaceStr } from "../utils";

export default class Vue {
  constructor(option) {
    this._init(option);
  }
}

Vue.prototype._init = function (option) {
  const vm = this;

  vm.$el = document.querySelector(option.el); // 项目根节点
  vm.$data = option.data(); // data对象

  // 劫持data对象
  vm.defineReactive(vm.$data);

  vm.complie(vm.$el);
};

Vue.prototype.defineReactive = function (obj) {
  if (!(obj instanceof Object)) return;
  // 遍历对象
  for (let key in obj) {
    let val = obj[key]; // 赋值
    if (val instanceof Object) {
      this.defineReactive(val);
    }
    const dep = new Dep();
    Object.defineProperty(obj, key, {
      enumerable: true, // 是否可枚举
      configurable: true, // 是否可配置
      get() {
        // console.log(`get:${key} - ${val}`);
        dep.depend(); // 订阅
        return val;
      },
      set(newVal) {
        if (newVal === val) return;
        // console.log(`set:${key} - ${newVal}`);
        val = newVal;
        dep.notify(); // 发布
      },
    });
  }
};

Vue.prototype.complie = function (root) {
  // 遍历根节点下的所有节点,解析指令
  const nodes = root.children;
  for (let i = 0; i < nodes.length; i++) {
    const node = nodes[i];
    // 如果还有子节点,则递归遍历
    if (node.children.length) {
      this.complie(node);
    }

    // 这里要先解析双括号,因为要防止双括号初始化会向节点中添加文本节点,会在模板解析中再次初始化
    /*-----------------解析 {{}} -------------------*/
    if (node.childNodes.length !== 0) {
      const childList = node.childNodes;
      childList.forEach((child) => {
        if (child.nodeType === 3) {
          // 文本节点
          replaceStr(this.$data, child);
        }
      });
    }

    /*-----------------解析模板指令-------------------*/
    // 绑定子节点指令
    if (
      node.hasAttribute("v-model") &&
      (node.tagName == "INPUT" || node.tagName == "TEXTAREA")
    ) {
      const attr = node.getAttribute("v-model");
      // 初始化赋值
      const getter = parsePath(attr);
      const initValue = getter(this.$data);
      node.value = initValue;
      // 如果元素绑定了 v-model指令 且 元素为输入框
      node.addEventListener(
        "input",
        (e) => {
          // 当input变化时,更新订阅
          setAttr(this.$data, attr, e.target.value);
        },
        false
      );
    }

    if (node.hasAttribute("v-bind")) {
      const attr = node.getAttribute("v-bind");
      // 初始化赋值
      const getter = parsePath(attr);
      const initValue = getter(this.$data);
      node.innerText = initValue;
      // 创建 观众 - 自动订阅频道
      new Watcher(this.$data, attr, (newVal) => {
        node.innerText = newVal;
      });
    }
  }
};
复制代码

细心的同学就会发现了,我们把解析双括号语法提前了,原因就是要防止双括号初始化会向节点中添加文本节点,会在模板解析中再次初始化。感兴趣的小伙伴可以仔细研究一下,这里就不赘述了。

我们再来看下现在的效果:

03.gif

我们发现:现在data.val的初始化以及数据响应都是没问题了,但是data.info.text的初始化是完成了,但是它的数据响应有问题。我们debug一下,到底是哪里出了问题呢?当input输入后,走了setAttr方法,在defineReactive中打开set方法的打印,发现雀食赋值成功了。

04.gif

我们再往下走,对象节点:123是一个观众,那么是不是new Watcher的时候有问题呢?我们来到Watcher构造函数中,发现它确实有一个取值操作,哦,原来问题出在这里啊~

image.png

它还是用的普通的点语法来进行取值操作,这能答应吗?办他!

Watcher.js

import Dep from "./Dep";
import { parsePath } from "../utils";

export default class Watcher {
  constructor(obj, key, cb) {
    this._data = obj; // data对象
    this.key = key; // 频道
    this.cb = cb; // 副作用函数
    this.getter = parsePath(this.key);
    this.get();
  }

  // 订阅频道
  get() {
    Dep.target = this;
    this.getter.call(this._data, this._data); // 触发data的get方法,进行自动订阅
    Dep.target = null;
  }

  // 更新订阅
  update() {
    const newVal = this.getter.call(this._data, this._data);
    this.cb(newVal);
  }
}
复制代码

验证一下

05.gif

现在,我们的青春版vue终于实现了,加上了模板编译深度监听对象变化是不是更像那么回事了?但青春版终究还是青春版,还欠缺一些东西:比如监听数组变化和其他指令v-for等。这里画个饼,下次一定加上~

结语

通过实现这样一个青春版的vue,我们更加能理解vue数据响应原理。在平时的工作中亦或者是出去找工作的时候,我们在面对面试官提出的:你了解vue数据响应原理吗?这样的问题时,我们就能爽快的回答:我了解啊,我还动手实现过一个呢~

本文完整代码:完整代码

觉得这篇文章给你带来了收获的小伙伴不妨点个赞点点关注,谢谢您啦,祝您生活愉快。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享