数据绑定
自从使用过Vue,一直对他的双向绑定机制很好奇,今天仿写了一个数据的双向绑定。前段时间,我发现现在很多浏览器可以直接使用ES6的语法而不需要任何转换了,所以我决定这次直接使用ES6的JavaScript来实现这个想法。
实现基础
类型 | 方法 | 参数 | 作用 |
---|---|---|---|
Object | defineProperty | 目标对象,字段名,配置 | 向object附加字段 |
Object | getOwnPropertyDescriptor | 目标对象,字段名 | 获取附加的字段 |
附加字段指的是,可以把字段添加到指定的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);
}
}