什么是简易版? 众所周知,vue想要实现数据绑定需要有以下几个主要成分:
- Vue类,负责初始化一些信息
- Observer,负责订阅data中的各个变量
- Watcher,负责发布变化,如果数据改动,通知各个HTML节点跟着改动
- Compiler,负责解析挂载到
#app
上的HTML节点
理论上这三个就足够了,如果要仅仅实现一个简单的vue。什么是简单的vue?比如数据只有一层,如下所示:
new Vue({
data: {
name: "诸葛连弩",
age: 18
},
methods: {
add () {
this.age ++;
}
}
})
这就是简易版的Vue,它说容易其实也很容易实现,把watcher全部挂到Vue实例上就好了。
什么是困难版的Vue?即数据支持多层嵌套,且加入Dep类。这个Dep类可太tm抽象了,就Dep和Watcher的关系,watcher和dep的来回调用,那够你琢磨两天的了。
这篇文章主要讲述的是简易版的Vue应该如何实现。正式开始
1 从MVC到MVVM
1.1 MVC
View UI布局,展示数据。
Model 管理数据。
Controller 响应用户操作,并将 Model 更新到 View 上。
但这依然有三个痛点
- 开发者在代码中大量调用相同的 DOM API, 处理繁琐 ,操作冗余,使得代码难以维护。
- 大量的DOM 操作使页面渲染性能降低,加载速度变慢,影响用户体验。
- 当 Model 频繁发生变化,开发者需要主动更新到View ;当用户的操作导致 Model 发生变化,开发者同样需要将变化的数据同步到Model 中,这样的工作不仅繁琐,而且很难维护复杂多变的数据状态。
其实,早期jquery 的出现就是为了前端能更简洁的操作DOM 而设计的,但它只解决了第一个问题,另外两个问题始终伴随着前端一直存在。
1.2 MVVM 的出现,完美解决以上三个问题
MVVM 由 Model,View,ViewModel 三部分构成,Model 层代表数据模型,也可以在Model中定义数据修改和操作的业务逻辑;View 代表UI 组件,它负责将数据模型转化成UI 展现出来,ViewModel 是一个同步View 和 Model的对象。
在MVVM架构下,View 和 Model 之间并没有直接的联系,而是通过ViewModel进行交互,Model 和 ViewModel 之间的交互是双向的, 因此View 数据的变化会同步到Model中,而Model 数据的变化也会立即反应到View 上。
ViewModel 通过双向数据绑定把 View 层和 Model 层连接了起来,而View 和 Model 之间的同步工作完全是自动的,无需人为干涉,因此开发者只需关注业务逻辑,不需要手动操作DOM, 不需要关注数据状态的同步问题,复杂的数据状态维护完全由 MVVM 来统一管理。
参考资料: 一像素的博客
2 数据绑定
*重要补充
网上对vue的简单实现各不统一,可以说水平参差不齐,因此,这里强烈推荐阅读Vue2.6的源码,还是比较平易近人的,但是有的地方还是比较难理解,比如Dep类和Watcher类的关系,以及Dep.target的作用,以及addDep到底是在干嘛。比较好的参考资料有:
- 重点源码分析,observer、dep、compiler、watcher等
- 依赖收集(讲解Dep和Watcher的关系)
- 好的文章1,其实看这篇文章就够了,比下面那篇更清楚
- 好的文章2
- 究极之更好的文章
在Vue3.0到来之前,抓紧补一下Vue2的知识——数据绑定。
先来看一下当前(2020年)主流MVVM框架的实现原理:
-
发布-订阅者模式(backbone.js) 最早的mvvm框架
一般通过pub、sub的方式来实现数据和视图的绑定 使用起来比较麻烦。 -
脏值检查(angular.js)
用定时器轮训检测数据是否发生改变,性能低。 -
(vue用什么方式实现的数据绑定?)
数据劫持 Object.defineProperty给对象的属性增加修饰符来劫持各个属性的setter getter(获取值和设置值的时候 你都能知道然后就可以修改页面的视图了) 此外还结合了发布订阅模式 把所有订阅 指定 统一做更新的处理。IE8以下不支持Object.defineProperty
这个属性的 所以vue只兼容到ie9。
2.1 我的理解
首先要知道,vue的数据双向绑定原理是“数据劫持” + “发布、订阅”。
数据改变只发生在以下两种情况:
- View层先改变,体现在用户手动改变网页中的元素,可以通过监听HTML事件来改变数据层
- Model层先改变,体现在按照业务需求改变Js中的数据(例如Ajax向后台通信拿到新的数据),可以通过
Object.defineProperty
来劫持数据的变化,监听者察觉到数据变化后把变化通知给发布者,再由发布者去更新网页中的数据,然后再释放变化。
因此,我们的简单实现需要以下几个模块:
observer
,负责监听js中数据的变化,其实就是监听Vue.$data
中的数据,如果发现数据变化通知发布者改变网页内容。watcher
,负责发布页面变化。compiler
,这是个隐藏的,虽然上面没有提到他,但它可以说是很重要的一环,它负责连接Model和View,它是一个模板解析器,负责处理拥有Vue特性的HTML元素,它把每一个需要特殊处理的HTMLElement
元素都记录了下来,并保存为watcher
,比如拥有v-model
的input
组件,v-on:click
的按钮,v-bind
的p
标签。当发布者watcher
要去更新视图的时候,直接调用自身的upload
函数即可。
2.2 先看看myVue长什么样子
// myVue实例
window.myapp = new myVue({
el: "#app",
data: {
number: 0,
number2: 1
},
methods: {
increment () {
this.number ++;
},
increment2 () {
this.number2 ++;
}
}
});
并且准备一个myVue对象
// myVue对象
function myVue(options = {}) {
this.$options = options;
// 挂载的html dom元素
this.$el = document.querySelector(options.el);
// 初始化数据
this.$_data = options.data;
this.$_methods = options.methods;
// 一个保存发布者watchers的队列
this._binding = {};
// 开始订阅(监听)$_data对象
this._observe(this.$_data);
// 解析vue模板
this._compile(this.$el);
}
2.3 创建订阅者
然后实现_observe
函数
myVue.prototype._observe = function (obj) {
// 非空且是一个对象才会被订阅
if (!obj || typeof obj !== "object") {
return;
}
// 遍历每一个key
Object.keys(obj).forEach( key => {
// 一定要先把value保存出来,不然每一次访问obj都会触发get函数
let value = obj[key];
// 递归订阅该value
this._observe(value);
// 对每一个key创建一个指令池,里面存放的是发布者watchers
this._binding[key] = {
_directives: []
}
// 取出当前key的指令池
let binding = this._binding[key];
// 开始劫持
Object.defineProperty(obj, key, {
get () {
console.log(`获取`);
return value;
},
set (newVal) {
console.log(`更新${newVal}`);
if (value !== newVal) {
value = newVal;
// 如果数据变化,通知涉及该key的全部发布者,并让他们更新页面
// 当然,目前还没有发布者,指令池是空的
binding._directives.forEach(item => item.Update());
}
}
})
})
}
2.4 编写模板编译器
myVue.prototype._compile = function (root) { // root等于要挂载的html元素<div id="app"></div>对应的dom节点
let _this = this;
let nodes = root.children;
// 从根节点开始,解析html文件
for (let i=0; i<nodes.length; i++) {
let node = nodes[i];
// 如果root的子元素还有子元素,那么递归解析
if (node.children.length !== 0) {
this._compile(node);
}
// 解析v-click指令,注意这里要用闭包
if (node.hasAttribute("v-click")) {
// 为html元素绑定click事件
node.onclick = (function () {
// 获取html中定义的函数名
let attrVal = node.getAttribute("v-click");
// 使this指向一致
return _this.$_methods[attrVal].bind(_this.$_data);
})();
}
// 解析v-model指令,闭包同理
if (node.hasAttribute("v-model") &&
(node.tagName === "INPUT" || node.tagName === "TEXTAREA")
) {
// 为input\textarea元素添加事件监听
node.addEventListener("input", (function (index) {
let attrVal = node.getAttribute("v-model");
// 核心!为v-model指定的变量,添加发布者watcher,并保存到该变量的指令池
// 并且在其中记录是哪个HTML元素对应该变量,并记录如果要修改内容,对应的是该HTML元素的那个属性
// 比如<input />对应的就是value属性
_this._binding[attrVal]._directives.push(new Watcher(
"input",
node, // HTML元素
_this, // myVue实例
attrVal, // 对应的myVue.$_data中的哪个变量
"value" // 比如<input />对应的就是value属性
))
return function () {
// 这才是input真正触发的事件,把value赋值给$_data
// 这里有问题吧,这只能一层data
_this.$_data[attrVal] = nodes[index].value;
}
})(i))
}
// v-bind,同上
if (node.hasAttribute("v-bind")) {
let attrVal = node.getAttribute("v-bind");
_this._binding[attrVal]._directives.push(new Watcher(
"text",
node,
_this,
attrVal,
"innerHTML"
))
}
}
}
2.5 编写发布者
// 构造函数
function Watcher (name, el, vm, exp, attr) {
this.name = name;
this.el = el;
this.vm = vm;
this.exp = exp;
this.attr = attr;
this.Update();
}
// 发布更新
Watcher.prototype.Update = function () {
this.el[this.attr] = this.vm.$_data[this.exp];
}
完整代码
上源码:一共不到150行,其中主要是参考了呆头呆脑丶的文章进行学习,感谢这些热爱分享的人。当然不可能照搬,结合ES6进行了一些小修改。
// index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="app">
<form>
<input type="text" v-model="number">
<button type="button" v-click="increment">add one</button>
</form>
<h3 v-bind="number"></h3>
<form>
<input type="text" v-model="number2">
<button type="button" v-click="increment2">add one</button>
</form>
<h3 v-bind="number2"></h3>
</div>
<script type="module" src="./index.js"></script>
</body>
</html>
// index.js
import myVue from "./myVue.js";
window.myapp = new myVue({
el: "#app",
data: {
number: 0,
number2: 1
},
methods: {
increment () {
this.number ++;
},
increment2 () {
this.number2 ++;
}
}
});
// myVue.js
function myVue(options = {}) {
this.$options = options;
this.$el = document.querySelector(options.el);
this.$_data = options.data;
this.$_methods = options.methods;
this._binding = {};
this._observe(this.$_data);
this._compile(this.$el);
}
myVue.prototype._compile = function (root) {
let _this = this;
let nodes = root.children;
for (let i=0; i<nodes.length; i++) {
let node = nodes[i];
if (node.children.length !== 0) {
this._compile(node);
}
// 解析v-click
if (node.hasAttribute("v-click")) {
node.onclick = (function () {
let attrVal = node.getAttribute("v-click");
return _this.$_methods[attrVal].bind(_this.$_data); // 这里不是很理解
})();
}
// 解析v-model
if (node.hasAttribute("v-model") &&
(node.tagName === "INPUT" || node.tagName === "TEXTAREA")
) {
node.addEventListener("input", (function (index) {
let attrVal = node.getAttribute("v-model");
_this._binding[attrVal]._directives.push(new Watcher(
"input",
node,
_this,
attrVal,
"value"
))
return function () {
_this.$_data[attrVal] = nodes[index].value;
}
})(i))
}
// 解析v-bind
if (node.hasAttribute("v-bind")) {
let attrVal = node.getAttribute("v-bind");
_this._binding[attrVal]._directives.push(new Watcher(
"text",
node,
_this,
attrVal,
"innerHTML"
))
}
}
}
myVue.prototype._observe = function (obj) {
if (!obj || typeof obj !== "object") {
return;
}
Object.keys(obj).forEach( key => {
let value = obj[key];
this._observe(value);
this._binding[key] = {
_directives: []
}
let binding = this._binding[key];
Object.defineProperty(obj, key, {
get () {
console.log(`获取`);
return value;
},
set (newVal) {
console.log(`更新${newVal}`);
if (value !== newVal) {
value = newVal;
binding._directives.forEach(item => item.Update());
}
}
})
})
}
function Watcher (name, el, vm, exp, attr) {
this.name = name;
this.el = el;
this.vm = vm;
this.exp = exp;
this.attr = attr;
this.Update();
}
Watcher.prototype.Update = function () {
this.el[this.attr] = this.vm.$_data[this.exp];
}
export default myVue;