前言
我们知道vue.js用v-model实现了数据双向绑定,原理大约是:vue使用Object.defineProperty属性,重写data的get和set方法来实现,但如果让你再具体解释一下,可能你就不清楚了,网上有一张图片给了比较详细的解释,但你可能依然看不懂,没关系,今天我们从0写代码,实现一个简单的双向绑定。
注意
- 下方代码的一些指令模仿了vue,但是并不是说代码要引入vue,因为我们是从0开始写。
- 本文假设你了解:面向对象编程、set和get、Object.keys()、Object.defineProperty(),尤其是Object.defineProperty(),可以提前学习一下:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty
HTML
- 先有2个按钮,负责增加和减少数字。
- 一个输入框可以显示数字也可以修改数字。
- 一个h3标签只用来显示数字。
<div id="app">
<input type="button" value="增加" v-click="increment"/>
<input type="button" value="减少" v-click="subtract">
<input type="text" v-model="number">
<h3 v-bind="number"></h3>
</div>
先定义一个Updater构造函数
这个构造函数的唯一作用就是更新DOM某个元素。
- el:将操作的DOM元素
- vm:所属的实例。
- attr:将操作的DOM元素的属性名,比如
innerHTML
或value
。 - data:将操作的DOM元素的属性值在data中的映射,在本例中就是
number
,也就是说该元素的该属性的值就等于data中的number
的值。 - update原型方法的作用就是更新,
this.el[this.attr] = this.vm.$data[this.dataKey];
就类似于H3.innerHtml = this.data.number,每当number改变时,就应当new一个Updater,保证对应的DOM内容进行更新。
function Updater(el, vm, attr, dataKey) {
this.el = el;
this.vm = vm;
this.attr = attr;
this.dataKey = dataKey;
this.update();
}
Updater.prototype.update = function () {
this.el[this.attr] = this.vm.$data[this.dataKey];
}
定义Vue构造函数
我们模仿vue定义一个构造函数,为了更好地对照理解,构造函数就叫Vue
。当new这个构造函数的时候,就会执行_init
原型方法。
function Vue(options) {
this._init(options);
}
定义_init原型方法
-
$options
保存传入的参数。 -
$el
、$data
、$methods
顾名思义,分别保存DOM容器、data、methods。 -
_binding
是一个对象,它保存着model与view的映射关系,也就是我们前面定义的Updater的实例。当model改变时,会触发其中的指令类更新,保证view也能实时更新。它不好理解,可以先往下看。 -
_observer
是负责监听的实例方法,具体见下文。 -
_complie
是负责编译的实例方法,具体见下文。
Vue.prototype._init = function (options) {
this.$options = options;
this.$el = document.querySelector(options.el);
this.$data = options.data;
this.$methods = options.methods;
this._binding = {};
this._observer(this.$data);
this._complie(this.$el);
}
定义_observer原型方法
observer方法是双向绑定的核心,它用来重写data的set和get函数。它做的事包括:
- Object.keys()遍历data中所有属性,返回data的键名数组。
- 遍历键名数组,给上面提到的
_binding
压入键值对,键名就是data的键名,键值就是{_updaterList: []}
。为什么要这样?因为data的一个属性可能要更新到多个DOM位置,所以我们要把需要更新的位置存下来。 - 关键核心来了,用Object.defineProperty给data的添加get和set属性。
-
enumerable: true
表示可枚举,也就是可被for-in和Object.keys()枚举; -
configurable: true
表示值为true时,该属性才能够被改变,也能被删除; - get就不说了,就是个return;
- set要说一下,forEach就是要依次执行更新DOM。
-
Vue.prototype._observer = function (data) {
var self = this;
Object.keys(data).forEach(function (key) {
var oldValue = data[key];
self._binding[key] = {
_updaterList: []
}
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get: function () {
return oldValue;
},
set: function (newValue) {
if (oldValue === newValue) return;
oldValue = newValue;
self._binding[key]._updaterList.forEach(function (updater) {
updater.update();
})
}
});
})
}
定义_complie原型方法
complie原型方法用来解析指令(v-bind,v-model,v-click),并在解析过程中对view与model进行绑定,也就是push一些Updater实例。
- 递归遍历子元素,如果元素上有v-click属性,就监听onclick事件,触发methods里面的increment、subtract方法。这里注意bind,如果没有bind,那么increment中的this指向的是调用increment的对象,也就是input节点,而我们的本意是this指向Vue实例的
$data
,所以使用bind,修改this指向为_this.$data。 - 如果元素上有v-model属性,并且元素为input或者textarea,就监听它的input事件。这里注意几点:
- 在本案例中,
attrVal
是什么?从HTML可以看到,就是字符串'number'
,而你的data的一个属性也必然是number
,这就形成了data属性跟自定义指令的映射。 - 从
_updaterList.push(new Updater(...))
我们大致可以看出_updaterList
是干什么的,它是保存更新器的。 - 读到
return function () {}
返回一个对象,你才恍然发现,原来input绑定的函数是一个自执行函数,自执行函数返回的函数才是input会触发的函数。_this.$data[attrVal] = nodes[key].value
这句就是使number的值与input节点的value保持一致,也就是实现了双向绑定。
- 在本案例中,
- 如果元素上有v-bind属性,就简单了,只要这个节点的innerHTML及时更新为data中number的值即可。
Vue.prototype._complie = function (el) {
var _this = this;
var nodes = el.children;
for (var i = 0; i < nodes.length; i++) {
var node = nodes[i];
if (node.children.length) {
this._complie(node);
}
if (node.hasAttribute("v-click")) {
node.onclick = _this.$methods[nodes[i].getAttribute("v-click")].bind(_this.$data)
}
if (node.hasAttribute("v-model") && (node.tagName == "INPUT" || node.tagName == "TEXTAREA")) {
node.addEventListener("input", (function (key) {
var attrVal = node.getAttribute("v-model");
_this._binding[attrVal]._updaterList.push(new Updater(
node,
_this,
"value",
attrVal,
));
return function () {
_this.$data[attrVal] = nodes[key].value;
}
})(i));
}
if (node.hasAttribute("v-bind")) {
var attrVal = node.getAttribute("v-bind");
_this._binding[attrVal]._updaterList.push(new Updater(
node,
_this,
"innerHTML",
attrVal
))
}
}
}
测试
跟vue.js一样,new一个实例看看。可以看到,双向绑定已经实现。
var vm = new Vue({
el: "#app",
data: {
number: 10,
age: 18
},
methods: {
increment: function () {
this.number++;
},
subtract: function () {
this.number--;
}
}
})
总结
第一步,我们要脱离任何复杂概念,写一个更新器,专门用来更新DOM。
第二步,创建构造函数,准备接收options。
第三步,为data的每一项设置get和set,这是ES5就实现的内置方法。其中set方法内除了修改data每一项的值,还要触发更新器,这样才能做到:data也更新,DOM也更新。
第四步,解析指令,将指令翻译为addEventListener,这样才能做到,DOM的输入框有更新,则data也跟着更新。同时给_updaterList压入更新器。