本文共 5949 字,大约阅读时间需要 19 分钟。
在 中,我们使用“脏检测”的机制,实现了一个简单的双向绑定计数器。尽管逻辑比较清晰简单,性能也还可以,但每次都遍历DOM节点,也是会有一些性能浪费的。ES5提供了Object.defineProperty与Object.defineProperties两个API,允许我们为对象的属性增设getter/setter函数。利用它们,我们可以很方便地监听数据变更,并且在变更时加入自己的逻辑。
本文我将利用ES5对象的getter/setter机制,模仿Vue的原理,来实现一个简单的数据动态绑定(暂且称为Lue吧)。
本次我基于Vue的三个指令:v-model、v-bind和v-click,来实现数据双向绑定(不考虑深层次对象的数据绑定)。DOM依然沿用上篇文章中的结构:
我们希望使用类似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/setterLue.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结构。但是这个方案存在两个问题:
为了解决这个问题,我们需要引入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];};
如此便实现了更改某个数据,只触发其对应DOM节点的更新。
下面我们需要考虑的问题是,如何让数据对象的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/setterLue.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节点更新。
实现双向绑定的最后一步,就是编译带有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
改写Lue的_init原型方法,使其在初始化时即对DOM进行编译:
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); this._compile(this.$el); //编译DOM节点};
至此,我们便实现了一个基于getter/setter,模仿Vue的简单的双向绑定。整个体系搭建并不复杂,只需要注意其中三个核心的部分:getter/setter,Directive以及binding。细心的读者不难发现,在本文的实现中,如果线程频繁触发数据变更,会导致DOM频繁更新,非常影响性能。在真正的生产环境中,DOM的更新不是数据变更后立马更新,而是被加入到批处理队列,等待主线程运行完后再进行批处理。
整个Lue实例结构如下:
转载