数据动态绑定的简单实现——基于ES5对象的getter/setter机制
https://zhuanlan.zhihu.com/p/25003235?refer=e-mill%E5%8F%8C%E5%90%91%E7%BB%91%E5%AE%9A
在双向绑定的简单实现——基于“脏检测”中,我们使用“脏检测”的机制,实现了一个简单的双向绑定计数器。尽管逻辑比较清晰简单,性能也还可以,但每次都遍历DOM节点,也是会有一些性能浪费的。ES5提供了Object.defineProperty与Object.defineProperties两个API,允许我们为对象的属性增设getter/setter函数。利用它们,我们可以很方便地监听数据变更,并且在变更时加入自己的逻辑。
本文我将利用ES5对象的getter/setter机制,模仿Vue的原理,来实现一个简单的数据动态绑定(暂且称为Lue吧)。
语法设计
本次我基于Vue的三个指令:v-model、v-bind和v-click,来实现数据双向绑定(不考虑深层次对象的数据绑定)。DOM依然沿用上篇文章中的结构:
<div id="app">
<form>
<input type="text" v-model="count" />
<button type="button" v-click="increment">increment</button>
</form>
<p v-bind="count"></p>
</div>
我们希望使用类似Vue的语法创建一个Lue实例:
var app=new Lue({
el:"#app",
data:{
count:0,
},
methods:{
increment:function(){
this.count++;
}
}
})
开始
开始的开始,我们需要创建一个Lue类:
function Lue(options){
this._init(options);
}
其中包含一个_init初始化函数,定义如下:
Lue.prototype._init=function(options){
this.$options=options; //传入的实例配置
this.$el=document.querySelector(options.el); //实例绑定的根节点
this.$data=options.data; //实例的数据域
this.$methods=options.methods; //实例的函数域
};
绑定数据对象的改造
为了实现双向绑定,首先我们需要使用Object.defineProperty对data中的数据对象进行改造,添加getter/setter函数,使其在赋值和取值时能够被监听。
/**对象属性重定义
* @param key 数据对象名称,本例为"count"
* @param val 数据对象的值
*/
Lue.prototype.convert=function(key,val){
Object.defineProperty(this.$data,key,{
enumerable:true,
configurable:true,
get:function(){
console.log(`获取${val}`);
return val;
},
set:function(newVal){
console.log(`更新${newVal}`);
val=newVal;
}
})
};
对data中的数据对象进行遍历调用convert:
//遍历数据域,添加getter/setter
Lue.prototype._parseData=function(obj){
var value;
for(var key in obj){
//排除原型链上的属性,仅仅遍历对象本身拥有的属性
if(obj.hasOwnProperty(key)){
value=obj[key];
//如果属性值为对象,则递归解析。本文暂不做实现
//if(typeof value ==='object'){
//this._parseData(value);
//}
this.convert(key,value);
}
}
};
在控制台做如下测试,可以看到已经成功添加了getter与setter:
绑定函数的改造
对于methods域中的函数,由于API要求我们的函数作用域与vm.$data一致,因此需要对其中的函数进行改造:
//对绑定的函数进行改造
//@params {attrVal } "v-click"节点的值,如"alert('hello')"
Lue.prototype._parseFunc=function(attrVal){
var args=/\(.*\)/.exec(attrVal);
if(args) { //如果函数带参数,将参数字符串转换为参数数组
args=args[0];
attrVal=attrVal.replace(args,"");
args=args.replace(/[\(\)\'\"]/g,'').split(",");
}
else args=[];
return this.$methods[attrVal].bind(this.$data,args);
};
上述两个改造流程必须发生在初始化阶段,因此我们需要更改一下之前定义的_init函数:
Lue.prototype._init=function(options){
this.$options=options; //传入的实例配置
this.$el=document.querySelector(options.el); //实例绑定的根节点
this.$data=options.data; //实例的数据域
this.$methods=options.methods; //实例的函数域
this._parseData(this.$data);
};
至此,对于Lue实例的数据与函数的初始化就完成了。下面需要考虑的是,当数据发生变化时,如何更新DOM元素呢?
最容易想到的一个做法是遍历所有含有v-bind指令的DOM模板,利用相应的绑定数据在内存中拼装成一个fragment,然后再将新的fragment替换旧的DOM结构。但是这个方案存在两个问题:
- 修改未绑定至DOM的数据时,也会引发DOM的重新渲染。
- 修改某个数据会导致所有DOM重新渲染,而非只更新数据变动了的相关DOM 。
为了解决这个问题,我们需要引入Directive。
Directive(指令)
Directive的作用就是建立一个DOM节点和对应数据的映射关系。它的定义和原型方法如下:
function Directive(name,el,vm,exp,attr){
this.name=name; //指令名称,例如文本节点,该值设为"text"
this.el=el; //指令对应的DOM元素
this.vm=vm; //指令所属Lue实例
this.exp=exp; //指令对应的值,本例如"count"
this.attr=attr; //绑定的属性值,本例为"innerHTML"
this.update(); //首次绑定时更新
}
Directive.prototype.update=function(){
//更新DOM节点的预设属性值
this.el[this.attr]=this.vm.$data[this.exp];
};
下面我们需要考虑的问题是,如何让数据对象的setter在触发时,调用与之相关的directive?
首先我们需要在实例化时建立一个_binding对象,该对象集合了真正与DOM绑定的那些数据对象(data中声明的对象的子集)。因此我们又一次修改_init函数:
Lue.prototype._init=function(options){
this.$options=options; //传入的实例配置
this.$el=document.querySelector(options.el); //实例绑定的根节点
this.$data=options.data; //实例的数据域
this.$methods=options.methods; //实例的函数域
//与DOM绑定的数据对象集合
//每个成员属性有一个名为_directives的数组,用于在数据更新时触发更新DOM的各directive
this._binding={};
this._parseData(this.$data);
};
_binding对象中属性的一个例子如下:
this._binding={
count:{
_directives:[] //该数据对象的相关指令数组
}
}
然后我们改写遍历数据域的函数与绑定数据时的setter函数:
//遍历数据域,添加getter/setter
Lue.prototype._parseData=function(obj){
var value;
for(var key in obj){
//排除原型链上的属性,仅仅遍历对象本身拥有的属性
if(obj.hasOwnProperty(key)){
this._binding[key]={ //初始化与DOM绑定的数据对象
_directives:[]
};
value=obj[key];
//如果属性值为对象,则递归解析
if(typeof value ==='object'){
this._parseData(value);
}
this.convert(key,value);
}
}
};
set:function(newVal){
console.log(`更新${newVal}`);
if(val!==newVal){
val=newVal;
//遍历该数据对象的directive并依次调用update
binding._directives.forEach(function(item){
item.update();
})
}
}
如此,我们便能实现在数据变更后,进行精准的DOM节点更新。
编译DOM节点
实现双向绑定的最后一步,就是编译带有v-model、v-click与v-bind指令的DOM节点。我们加入一个名为_compile的原型函数:
//解析DOM的指令
Lue.prototype._compile=function(root){
var _this=this;
//获取指定作用域下的所有子节点
var nodes=root.children;
for(var i=0;i<nodes.length;i++){
var node=nodes[i];
//若该元素有子节点,则先递归编译其子节点
if(node.children.length){
this._compile(node);
}
if(node.hasAttribute("v-click")) {
node.onclick = (function () {
var attrVal=nodes[i].getAttribute("v-click");
var args=/\(.*\)/.exec(attrVal);
if(args) { //如果函数带参数,将参数字符串转换为参数数组
args=args[0];
attrVal=attrVal.replace(args,"");
args=args.replace(/[\(|\)|\'|\"]/g,'').split(",");
}
else args=[];
return function () {
_this.$methods[attrVal].apply(_this.$data,args);
}
})()
}
if(node.hasAttribute(("v-model"))
&& (node.tagName=="INPUT" || node.tagName=="TEXTAREA")){
//如果是input或textarea标签
node.addEventListener("input", (function (key) {
var attrVal=node.getAttribute("v-model");
//将value值的更新指令添加至_directives数组
_this._binding[attrVal]._directives.push(new Directive(
"input",
node,
_this,
attrVal,
"value"
))
return function () {
_this.$data[attrVal] = nodes