这是我参与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:
到现在,我们的项目就初步搭建好了。
数据双向绑定
什么是数据双向绑定呢?写过vue的小伙伴一定知道,在一个vue实例中,通过定义一个data函数返回一个对象,这个对象里面的所有属性经过了一系列的处理就实现了双向绑定。在template中使用v-bind或者{{}}双括号语法,当这些属性改变时,v-bind和{{}}绑定的文本节点就会自动赋值。
原理概述
我们先看一张图:
本文项目的实现基本思路就是根据上述这张图,而数据双向绑定的原理在于发布-订阅模式。
我们先抛开代码不谈,发挥一下想象:把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
);
};
我们打开控制台,并向输入框中输入一些文本,发现打印了一些信息:
到这里,我们成功的劫持了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对象以及具体的属性key,data[key]就是我们具体的频道了。而cb则是我们的副作用函数,用来在频道发布内容后,调用这个函数去更新我们的主页(主页长啥样子只有自己知道,所以cb由每个订阅者提供)。接着,自己调用了一下get方法。
我们来详细解剖一下这个get方法的逻辑,首先,它把Dep构造函数的静态变量target赋值为了自己,也就是自己这个观众,赋值过去的意义是什么呢?
我们回到Dep函数,发现target只被用在了depend方法里。
那么depend又是何时被调用的呢?我们回到Vue.js中,在劫持data对象的方法里,找到了depend的调用时机,在执行每个频道的get方法时被调用的:
哦,到这里链路都通了,我们再回过头来捋一下Watcher里自执行的get函数的逻辑:
-
Dep.target = this:当创建一个
观众(new 一个 Watcher)时,它会内部自行调用一个订阅频道的方法,这个方法先把自己(this)存放到Dep.target里。 -
this._data[this.key]:而紧接着调用了一下该
频道(也就是this._data[this.key]),又因为频道被劫持了,调用的时候会自动触发Object.defineProperty 的 get方法,而去通知订阅者列表去执行自动订阅。又因为此时Dep.target已经存在了这个观众,它就真正的被addSubs进了订阅者列表中了。
- 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,到这里我们来试一下最终的效果:
结语
到现在为止,我们实现了一个简单的数据双向绑定,它通过发布-订阅模式来实现。但是,我们再想想,他是不是缺少了点什么东西。一开始说好的v-bind、{{}}双括号呢?别急,这里我们先实现一个青春阳光版本,下一篇文章再来实现v-bind、{{}}双括号的模板编译以及监听属性为对象时的变化。希望看到这里有收获的小伙伴能点个赞点个关注,感谢小伙伴们的支持~