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

·  阅读 319
手把手教你实现vue数据双向绑定(上)

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

前言

vue是现在前端最炙手可热的框架之一,平时工作中或者出去面试,都少不了问一些vue的问题。而vue是怎么实现数据绑定的,又是问的频率最高的几个问题之一,今天就分享一下个人实现的一个简易的vue。这个项目实现了简单的模板编译数据双向绑定

源码发布在了个人github上,感兴趣的小伙伴请移步:vue数据双向绑定源码地址

项目初始化

首先,实现这个简易的vue,一个好的工程化工具是必不可少的,对于这个项目来说,使用webpack有点太过笨重了,这里使用了parcel-bundler这个工具(官方文档)。

Parcel 是 Web 应用打包工具,适用于经验不同的开发者。它利用多核处理提供了极快的速度,并且不需要任何配置。

我们只需要先创建一个项目,并且安装这个依赖即可。

mkdir simple-vue && cd simple-vue
npm init -y
npm install -D parcel-bundler
复制代码

安装好了这个依赖之后,我们在根目录下创建一个模板文件index.html,并且创建一个src文件夹,并且在此文件夹下创建一个main.js文件作为我们项目的入口文件。

index.html

<!DOCTYPE html>
<html>

<head>
  <title>简易vue</title>
  <meta charset="UTF-8">
</head>

<body>
  <div id="app">
    <input type="text" id="ceshi">
  </div>

  <script src="./src/main.js"></script>
</body>

</html>
复制代码

再在package.json中新增一条脚本指令:

"dev": "parcel index.html"
复制代码

此时我们启动项目npm run dev

image.png

到现在,我们的项目就初步搭建好了。

数据双向绑定

什么是数据双向绑定呢?写过vue的小伙伴一定知道,在一个vue实例中,通过定义一个data函数返回一个对象,这个对象里面的所有属性经过了一系列的处理就实现了双向绑定。在template中使用v-bind或者{{}}双括号语法,当这些属性改变时,v-bind{{}}绑定的文本节点就会自动赋值。

原理概述

我们先看一张图:

image.png

本文项目的实现基本思路就是根据上述这张图,而数据双向绑定的原理在于发布-订阅模式。

我们先抛开代码不谈,发挥一下想象:把data对象里的每一个属性比作一个youtube账号,它有一个订阅者列表,也就是观众列表。当它发布了一个视频,就会自动通知每个列表里的观众去更新他们的主页(update),推送这个视频。而在哪里用到了这个属性(v-bind{{}}等),那么就把那里设置成一个watcher,也就是一个观众,点击了订阅

好好理解一下上面的这段话,我们就基于此进入具体实现中了。

具体实现

创建一个vue

首先,我们照葫芦画瓢,先创建一个vue构造函数。真正的vue怎么写,我们就怎么写,不过我们这个是青春版的,嘻嘻。

先在我们的项目中,创建一个core文件夹,用来存放我们定义好的各种类。再在此文件夹下面创建一个Vue.js文件,用来存放我们的Vue构造函数。首先,我们在main.js中,先定义一个Vue实例,再去根据这个写我们的构造函数:

main.js

import Vue from './core/Vue';

const app = new Vue({
  el: '#app',
  data() {
    return {
      val: '初始化val',
      info: { text: '123' },
    };
  },
});

复制代码

是不是还挺像那么回事的,这里我们往Vue的构造函数中,传入了一个对象,el为绑定的根节点,我们所有的DOM操作都是以此为根节点来的。data则是我们要实现数据双向绑定的那个对象。

我们再回到Vue.js中去:

Vue.js

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对象
};
复制代码

现在,我们新建好了一个Vue,但是它与数据双向绑定毫无关系。别着急,现在考考你:数据双向绑定的原理是什么? 你可能会回答:因为vue通过Object.defineProperty劫持了data对象(vue3.x是通过proxy),当对象属性被读取时,收集依赖;当对象属性设置时,发布订阅。非常好,既然知道了原理,那我们现在就动手实现吧。

劫持data对象

我们再往Vue构造函数的原型中加东西,加入劫持对象的方法:

// 监听对象属性变化方法,自动订阅以及发布订阅
Vue.prototype.defineReactive = function (obj) {
  if (!(obj instanceof Object)) return;
  // 遍历对象
  for (let key in obj) {
    let val = obj[key]; // 取data对象的每一个属性的值
    Object.defineProperty(obj, key, {
      enumerable: true, // 是否可枚举
      configurable: true, // 是否可配置
      get() {
        console.log(`get:${key} - ${val}`);
        return val;
      },
      set(newVal) {
        if (newVal === val) return;
        console.log(`set:${key} - ${newVal}`);
        val = newVal;
      },
    });
  }
};
复制代码

然后再在_init方法中,调用此方法劫持data对象,并且写一个测试方法来试试是否已经成功的劫持了data对象:

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

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

  // 劫持data对象
  vm.defineReactive(vm.$data);
  
  // 测试方法 - 监听input输入
  const input = document.getElementById("ceshi");
  vm.$data.val; // 试着读取一下值
  input.addEventListener(
    "input",
    (e) => {
      vm.$data.val = e.target.value; // 赋值操作
    },
    false
  );
};
复制代码

我们打开控制台,并向输入框中输入一些文本,发现打印了一些信息:

qwe2.gif

到这里,我们成功的劫持了data对象。

我们再来思考下原理概述那里的那段话,data对象里的每一个属性比作一个youtube账号,我们现在劫持了data,就相当于给每个属性都创建了一个频道。而一个频道要有什么呢,当然是订阅者列表,我们要给我们的粉丝一个家(也希望看文章的小伙伴点点赞点点关注,给我一个温暖的家~)。那么这个订阅者列表是什么呢?我们就来创建一个。

创建订阅者列表 - Dep

我们在core文件夹下新建一个Dep.js文件,用来存放我们的Dep构造函数:

Dep.js

// Dep类为每个频道的订阅者列表,提供一个subs数组来维护此频道的订阅者
export default class Dep {
  static target = null;

  constructor() {
    this.subs = []; // 订阅者列表
  }

  // 添加订阅
  addSub(sub) {
    this.subs.push(sub);
  }

  // 删除订阅
  removeSub(sub) {}

  // 自动订阅
  depend() {
    if (Dep.target) {
      this.addSub(Dep.target);
    }
  }

  // 发布订阅
  notify() {
    this.subs.forEach((m) => {
      m.update();
    });
  }
}
复制代码

上述代码中,subs为我们的订阅者列表,有添加订阅删除订阅自动订阅发布订阅这几个方法。我掐指一算,小伙伴们最疑惑的就是target这个静态变量了。别急,我们先在这里埋下一个伏笔,到后面再来解释这个target究竟是何方神圣。我们还是回到劫持对象的那个方法中,为我们创建的每一个频道来创建一个订阅者列表

Vue.js

import Dep from "./Dep";

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);

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

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(); // 发布
      },
    });
  }
};
复制代码

既然频道已经创建好了,那么我们是时候要创建观众了。

创建观众 - Watcher

我们在core文件夹下创建一个Watcher.js的文件,用来存放我们的Watcher构造函数。

Watcher.js

import Dep from './Dep';

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

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

  // 更新订阅
  update() {
    const newVal = this._data[this.key];
    this.cb(newVal);
  }
}
复制代码

看了上面的代码,仔细的小伙伴就看到了一个东西,我们的老朋友target。分析一下Watcher这个构造函数,初始化的时候把频道传入:data对象以及具体的属性keydata[key]就是我们具体的频道了。而cb则是我们的副作用函数,用来在频道发布内容后,调用这个函数去更新我们的主页(主页长啥样子只有自己知道,所以cb由每个订阅者提供)。接着,自己调用了一下get方法。

我们来详细解剖一下这个get方法的逻辑,首先,它把Dep构造函数的静态变量target赋值为了自己,也就是自己这个观众,赋值过去的意义是什么呢?

我们回到Dep函数,发现target只被用在了depend方法里。

image.png

那么depend又是何时被调用的呢?我们回到Vue.js中,在劫持data对象的方法里,找到了depend的调用时机,在执行每个频道get方法时被调用的:

image.png

哦,到这里链路都通了,我们再回过头来捋一下Watcher里自执行的get函数的逻辑:

  1. Dep.target = this:当创建一个观众(new 一个 Watcher)时,它会内部自行调用一个订阅频道的方法,这个方法先把自己(this)存放到Dep.target里。

  2. this._data[this.key]:而紧接着调用了一下该频道(也就是this._data[this.key]),又因为频道被劫持了,调用的时候会自动触发Object.defineProperty 的 get方法,而去通知订阅者列表去执行自动订阅。又因为此时Dep.target已经存在了这个观众,它就真正的被addSubs进了订阅者列表中了。

image.png

  1. Dep.target = null:到最后初始化Dep.target

到现在为止,发布-订阅的类都已经创建完毕了,链路也都已经打通了,那么我们写个列子来验证一下。

验证一下

我们先在index.html中创建一个p标签,把它当成一个watcher - 观众

index.html

<!DOCTYPE html>
<html>

<head>
  <title>简易vue</title>
  <meta charset="UTF-8">
</head>

<body>
  <div id="app">
    <input type="text" id="ceshi">
    <p id="watcher"></p>
  </div>

  <script src="./src/main.js"></script>
</body>

</html>
复制代码

再完善一下Vue.js里的测试方法:

Vue.js

import Dep from "./Dep";
import Watcher from "./Watcher";

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);

  // 测试方法 - 监听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;
  });
};

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(); // 发布
      },
    });
  }
};
复制代码

ok,到这里我们来试一下最终的效果:

qwe.gif

结语

到现在为止,我们实现了一个简单的数据双向绑定,它通过发布-订阅模式来实现。但是,我们再想想,他是不是缺少了点什么东西。一开始说好的v-bind{{}}双括号呢?别急,这里我们先实现一个青春阳光版本,下一篇文章再来实现v-bind{{}}双括号的模板编译以及监听属性为对象时的变化。希望看到这里有收获的小伙伴能点个赞点个关注,感谢小伙伴们的支持~

分类:
前端
标签: