ECMA:JavaScript中数据的双向绑定的一种实现

数据绑定

自从使用过Vue,一直对他的双向绑定机制很好奇,今天仿写了一个数据的双向绑定。前段时间,我发现现在很多浏览器可以直接使用ES6的语法而不需要任何转换了,所以我决定这次直接使用ES6的JavaScript来实现这个想法。

效果图

实现基础

类型方法参数作用
ObjectdefineProperty目标对象,字段名,配置向object附加字段
ObjectgetOwnPropertyDescriptor目标对象,字段名获取附加的字段

附加字段指的是,可以把字段添加到指定的Object上面,并且可以为他设置get或set方法,进行一些其他的配置,那么,这又能怎么样呢?在为属性赋值的时候,就会调用这里的set,因此可以通过set截获新的value,并作一些事情。

// 在this上面定义test属性,然后设定他们的get和set
let data = {};
Object.defineProperty(data,'test',{
	get: ()=> {
		// let someVal = data.test 这个时候会使用get
	},
	set: val => {
		// data.test = 123; 这个时候会使用set
	}
}

那么,在这个get或者set里面就可以直接篡改数据,这种手法被称作数据劫持,这就是Vue实现双向绑定所使用的方式。

渲染范围

类似于vue,这些ui框架都会指定一个div或者别的作为自己渲染的范围,在这个范围内,就可以随着数据的变化而重新渲染界面,我这里也做了一个。

export class App {

    constructor(id) {
        // 渲染区域
        this.$el = document.querySelector(id);
        // 数据
        this.$data = this.data();
        // 基本数据类型(包括String)在js里面是不可以附加属性的
        for(let field in this.$data) {
          this.$data[field] = {
              // 数据的值
	          val: this.$data[field],
              // 单向绑定的DOM
              __sigleBind__: [],
              // 双向绑定的DOM(input)
              __fullBind__: []
			};
			// 对this的field字段进行数据劫持
          Object.defineProperty(this, field, {
             set: val => {
                  // 更新数据到真正的data上面
                  this.$data[field].val = val;
                  // 刷新单向dom
                 let refreshSingle = this.$data[field].__sigleBind__;
                 refreshSingle.forEach(elem => {
                    elem.innerText = val;
                 });
                 // 刷新双向dom
                 let refreshFull = this.$data[field].__fullBind__;
                 refreshFull.forEach(elem => {
                     if(elem.value !== val) {
                        elem.value = val;
                      }
                  });
                },
             get: () => {
	              // 返回数据
                 return this.$data[field].val;
             }
           })
       }
       // 绑定DOM到数据
       let childs = this.$el.children;
       for(let elem of childs) {
          this.bindData(elem);
       }
    }

    data() {
        return {
          test: "",
          test2: ""
        }
    }

    bindData(elem) {
         // 检查是不是需要绑定数据的dom
        if(elem.hasAttribute('data-model')) {
           // 要绑定的字段名
          let attrName = elem.attributes['data-model'].value;
          if(this.$data.hasOwnProperty(attrName)) {
               // input是双向绑定,会刷新数值到value
               if(elem.tagName === 'INPUT') {
	              // 添加dom到双向的列表
                  this.$data[attrName].__fullBind__.push(elem);
                 // 监听keyup和change,改变数据的值
                   elem.onkeyup = e => {
                   Object.getOwnPropertyDescriptor(this, attrName).set(elem.value);
                   }
                   elem.onchange = e => {
                    Object.getOwnPropertyDescriptor(this, attrName).set(elem.value);
                   }
                 } else {
                  // 单向绑定
                  this.$data[attrName].__sigleBind__.push(elem);
             }
          }
       }
		// 查找并且递归绑定子节点
       let childs = elem.children;
       for(let childItem of childs) {
          this.bindData(childItem);
       }
    }
}

渲染

那么,Vue其实还是有一个render的,他可以渲染一个界面出来。
但是浏览器是不能直接支持jsx的,所以我又换了一种思路来实现渲染的部分。有一个框架叫做ExtJS,他的渲染部分采用json定义,很结构化,这里我仿照Ext的定义编写了一个render部分。

template() {
    return [{
        // 标签类型
        type: 'input',
        // 数据绑定
        data: {
           model: 'test'
        }
       },{
          type: 'h1',
           // 标签属性
           attr: {
              style: 'color: #999'
           },
           data: {
             model: 'test'
           }
        },{
           type: 'div',
            // 子节点
           comps: [{
              type: 'input',
              data:{
                model: 'test2'
              }
           },{
               type: 'h1',
               data:{
                 model: 'test2'
               }
           }]
     }]
}

然后还需要一个render函数来把他们变成DOM。

render() {
    // 获取需要渲染的内容
    let template = this.template();
    // 执行渲染
    this.renderComp(template, this.$el);
}
renderComp(obj, parent) {
    for(let comp of obj) {
        // 创建标签
        let target = document.createElement(comp.type);
        // 添加属性
        if(typeof comp.attr !== 'undefined') {
            for(let attr in comp.attr) {
                target.setAttribute(attr, comp.attr[attr]);
            }
        }
        // 绑定数据
        if(typeof comp.data !== 'undefined') {
            for(let data in comp.data) {
                target.setAttribute('data-' + data, comp.data[data]);
            }
       }
       // 渲染子节点
       if(typeof comp.comps !== 'undefined') {
           this.renderComp(comp.comps, target);
       }
       // 添加到DOM树
		parent.appendChild(target);
	}
}

完整实现

app.js

export class App {

    constructor(id) {
        this.$el = document.querySelector(id);
        this.render();
        this.$data = this.data();
        for(let field in this.$data) {
            this.$data[field] = {
                val: this.$data[field],
                __sigleBind__: [],
                __fullBind__: []
            };
            Object.defineProperty(this, field, {
                set: val => {
                    this.$data[field].val = val;
                    let refreshSingle = this.$data[field].__sigleBind__;
                    refreshSingle.forEach(elem => {
                        elem.innerText = val;
                    });
                   let refreshFull = this.$data[field].__fullBind__;
                   refreshFull.forEach(elem => {
                       if(elem.value !== val) {
                           elem.value = val;
                       }
                    });
                },
                get: () => {
                   return this.$data[field].val;
                }
             })
           }
           let childs = this.$el.children;
           for(let elem of childs) {
             this.bindData(elem);
           }
    }

    bindData(elem) {
        if(elem.hasAttribute('data-model')) {
            let attrName = elem.attributes['data-model'].value;
            if(this.$data.hasOwnProperty(attrName)) {
                if(elem.tagName === 'INPUT') {
                    this.$data[attrName].__fullBind__.push(elem);
                    elem.onkeyup = e => {
                        Object.getOwnPropertyDescriptor(this, attrName).set(elem.value);
                    }
                    elem.onchange = e => {
                        Object.getOwnPropertyDescriptor(this, attrName).set(elem.value);
                    }
            } else {
                this.$data[attrName].__sigleBind__.push(elem);
            }
          }
        }
       let childs = elem.children;
       for(let childItem of childs) {
            this.bindData(childItem);
       }
    }
    render() {
       let template = this.template();
       this.renderComp(template, this.$el);
	}
    renderComp(obj, parent) {
        for(let comp of obj) {
        let target = document.createElement(comp.type);
	     if(typeof comp.attr !== 'undefined') {
            for(let attr in comp.attr) {
                target.setAttribute(attr, comp.attr[attr]);
             }
         }
         if(typeof comp.data !== 'undefined') {
             for(let data in comp.data) {
                 target.setAttribute('data-' + data, comp.data[data]);
             }
         }
         if(typeof comp.comps !== 'undefined') {
           this.renderComp(comp.comps, target);
         }
         parent.appendChild(target);
        }
     }
}

index.html

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
        <title></title>
    </head>

    <body>
        <div id="app">
        </div>
        <script type="module">
          import {App}  from './js/app.js'
          class TestApp extends App {
              constructor(id) {
                  super(id);
				}
              template() {
                  return [{
                     type: 'input',
                     data: {
                       model: 'test'
                     }
                 },{
                     type: 'h1',
                     attr: {
                       style: 'color: #999'
                     },
                     data: {
                       model: 'test'
                     }
                  },{
                     type: 'div',
                     comps: [{
                        type: 'input',
                        data:{
                          model: 'test2'
                        }
                     },{
                        type: 'h1',
                        data:{
                          model: 'test2'
                        }
                    }]
                }]
              }
              data() {
                 return {
                     test: "",
                     test2: ""
                }
              }
          }
          new TestApp("#app");
        </script>
    </body>
</html>

多级数据的绑定

上面实现了一个很基础的双向绑定,但是有一个问题,只能够绑定最外层的数据,里面就没有办法了,那么,如果想绑定内层的数据就要进行一定的修改。

我在上面的基础上增加了这样的一个方法:

 defineObjectBind(to, field,data) {
    // 如果目标字段没有$data,那么就增加一个,用来存放真实数据
    if (typeof to.$data === 'undefined' ) {
        to.$data = {...to};
    }
    // 基本类型无法附加数据,这里需要把它变成一个Object的引用类型
    to.$data[field] = {
	    val: to.$data[field],
	    __sigleBind__: [],
	    __fullBind__: []
    };
    // 劫持Getter和Setter
    Object.defineProperty(to, field, {
        set: val => {
           to.$data[field].val = val;
           // 刷新单向绑定的DOM
           let refreshSingle = to.$data[field].__sigleBind__;
           refreshSingle.forEach(elem => {
                elem.innerText = val;
           });
           // 刷新双向绑定的DOM
           let refreshFull = to.$data[field].__fullBind__;
           refreshFull.forEach(elem => {
               if(elem.value !== val) {
                   elem.value = val;
               }
           });
         },
         get: () => {
             return to.$data[field].val;
         }
    });
    // 如果字段的val是引用类型,那么就递归绑定它,实现多层绑定
    if (to.$data[field].val instanceof Object) {
        for(let item in to.$data[field].val) {
            this.defineObjectBind(to.$data[field].val, item);
        }
     }
 }

然后需要修改BindData方法,让他可以正确的找到被绑定的数据:

bindData(elem) {
    if(elem.hasAttribute('data-model')) {
        let attrName = elem.attributes['data-model'].value;
        let attrs = attrName.split(".");
        if(this.$data.hasOwnProperty(attrName)) {
            if(elem.tagName === 'INPUT') {
                this.$data[attrName].__fullBind__.push(elem);
                elem.onkeyup = e => {
                     Object.getOwnPropertyDescriptor(this, attrName).set(elem.value);
                }
                elem.onchange = e => {
                    Object.getOwnPropertyDescriptor(this, attrName).set(elem.value);
                }
             } else {
                this.$data[attrName].__sigleBind__.push(elem);
            }
         } else if (attrs.length > 1) {
             // 要绑定的数据是内层的
             let count = 0;
             let data = this;
             let name = "";
             // 逐个属性查找
             while (count < attrs.length - 1) {
                 if(typeof data.$data !=='undefined' && 
                     data.$data.hasOwnProperty(attrs[count])){
                        data = data.$data[attrs[count]].val;
                        count ++;
                  }
             }
             let targetData = data.$data[attrs[count]];
             if(elem.tagName === 'INPUT') {
                 targetData.__fullBind__.push(elem);
                 elem.onkeyup = e => {
                     Object.getOwnPropertyDescriptor(data, attrs[count]).set(elem.value);
                  }
                  elem.onchange = e => {
                      Object.getOwnPropertyDescriptor(data, attrs[count]).set(elem.value);
                  }
              } else {
                  targetData.__sigleBind__.push(elem);
              }
           }
        }
       let childs = elem.children;
       for(let childItem of childs) {
            this.bindData(childItem);
        }
    }

最后,构造方法变为这样,就可以了。

constructor(id) {
     this.$el = document.querySelector(id);
     this.render();
     this.$data = this.data();
     for(let field in this.$data) {
         // 对this进行数据绑定
         this.defineObjectBind(this,field)
     }
     let childs = this.$el.children;
     for(let elem of childs) {
         this.bindData(elem);
     }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值