开发中可能时常遇到这种问题:
开发一个计算器界面如下,
html 结构为:
<div>
<button id="minus">-</button> <input type="number" id="r"> <button id="plus">+</button>
<p>r<sup>2</sup> = <span id="square"></span></p>
<p>r<sup>3</sup> = <span id="cube"></span></p>
</div>
要求:点击加减号,输入框内容自动增加或减去1,输入时允许数字,当数字变动时,自动更新下面的r平方和r立方的内容。
按照一般的开发节奏,应该这么做
const calc = r => {
document.querySelector(`#square`).innerHTML = Math.pow(r, 2);
document.querySelector(`#cube`).innerHTML = Math.pow(r, 3);
}
document.querySelector(`#r`).addEventListener('change', e => {
const r = e.target.value;
calc(r);
})
document.querySelector(`#plus`).addEventListener('click', e => {
const r = document.querySelector(`#r`).value;
const newR = Number(r) + 1;
document.querySelector(`#r`).value = newR;
calc(newR);
})
document.querySelector(`#minus`).addEventListener('click', e => {
const r = document.querySelector(`#r`).value;
const newR = Number(r) - 1;
document.querySelector(`#r`).value = newR;
calc(newR);;
})
触发数据改变的来源不止一个,意味着每个来源都需要调用代码中的calc方法。如果我要加多一个按钮,点击后输入框内容+10,不但要从视图层取数据修改(e.targer.value),input的内容,还得把calc方法带上,十分痛苦。
这时就可以考虑用双向绑定处理r的数据变化。我预想通过一个对象来管理这个案例中的关键数据——r值,为了防止轻易被篡改,通过仿react的getState
和setState
api 来获取和设置。而不再通过视图层取。
其实已经是一个老掉牙的问题了。数据劫持是双向绑定各种方案中比较流行的一种,最著名的实现就是Vue。而vue在2.x版本中使用的是Object.defineProperty
,将在今年8月发布的3.0中,将正式使用Proxy
。
语法:
Object.defineProperty(obj, prop, descriptor)
我们可以通过Object.defineProperty这个方法,直接在一个对象上定义一个新的属性,或者是修改已存在的属性。最终这个方法会返回该对象。
MDN 文档地址:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty
看上去很不错,来写一下:
class Responsive {
constructor(opts) {
this.opts = opts;
const { data } = this.opts;
Object.keys(data).forEach(key => {
Object.defineProperty(data, key, {
get() {
data[key]
},
set(nick) {
console.log(data[key], nick)
data[key] = nick;
}
})
})
}
getState(prop) {
const data = this.opts.data;
return data[prop];
}
setState(prop, value) {
const data = this.opts.data;
data[prop] = value;
}
}
const res = new Responsive({
data:{
r:1
}
})
res.getState('r')
结果浏览器爆栈了。
原因在于:给r定义了setter,然后在setter里面又给r赋值,就是又调用了setter,循环调用了。
处理循环调用可以考虑深拷贝克隆一个data。那么,每次获取,或者是设定,都会从克隆体去取值或者是更新。
// ...
Object.keys(data).forEach(key => {
_data[key] = data[key];
Object.defineProperty(data, key, {
get() {
return _data[key];
},
set(nick) {
console.log(data[key], nick)
_data[key] = nick;
}
})
})
//...
这段代码就是笔者工作中重要的一段处理逻辑了。
现在在setState的时候,我们都可以去劫持数据的变化,那么我们模仿vue,可以加上watch切面:
class Responsive {
constructor(opts) {
this.opts = opts;
const { data } = this.opts;
const _data = {};
Object.keys(data).forEach(key => {
_data[key] = data[key];
Object.defineProperty(data, key, {
get() {
return _data[key];
},
set(nick) {
const old = _data[key];
_data[key] = nick;
opts.watch[key] && opts.watch[key](old,nick);
}
})
})
}
getState(prop) {
const data = this.opts.data;
return data[prop];
}
setState(prop, value) {
const data = this.opts.data;
data[prop] = value;
}
}
接下来去写文章开头的代码:
const calc = r => {
document.querySelector(`#r`).value = r;
document.querySelector(`#square`).innerHTML = Math.pow(r, 2);
document.querySelector(`#cube`).innerHTML = Math.pow(r, 3);
}
const res = new Responsive({
data: {
r: 0
},
watch: {
r: function (oldVal, newVal) {
console.log(`r被修改:${oldVal}->${newVal}`);
calc(newVal)
}
}
});
document.querySelector(`#r`).addEventListener('change', e => {
res.setState('r',Number(e.target.value));
})
document.querySelector(`#minus`).addEventListener('click', e => {
const r = res.getState('r');
res.setState('r',r - 1);
})
document.querySelector(`#plus`).addEventListener('click', e => {
const r = res.getState('r');
res.setState('r',r + 1);
})
上面的代码通过watch
来注入calc的副作用。实现了r对#r
/#square
/#cube
几个视图层的绑定。对于三个触发r值改变的操作——加,减,输入,关注点就可以集中于setState了。比起原来的代码可算优雅了不少。
defineProperty是es5方法。因此可在任何ie >= 8的浏览器上正常运行。
最早传出 vue 3.0 说要出,已经过了2年有余了。vue 3其中一项重要的改变就是在响应式方面,使用Proxy来替代原有的defineProperty。
Proxy是 ES6 中新增的一个特性,翻译过来意思是"代理",用在这里表示由它来“代理”某些操作。
•Proxy 让我们能够以简洁易懂的方式控制外部对对象的访问。其功能非常类似于设计模式中的代理模式。•Proxy 可以理解成,在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。•使用 Proxy 的核心优点是可以交由它来处理一些非核心逻辑(如:读取或设置对象的某些属性前记录日志;设置对象的某些属性值前,需要验证;某些属性的访问控制等)。从而可以让对象只需关注于核心逻辑,达到关注点分离,降低对象复杂度等目的。
语法:
const p = new Proxy(target, handler);
说明:
•target 是用Proxy包装的被代理对象(可以是任何类型的对象,包括原生数组,函数,甚至另一个代理)。•handler 是一个对象,其声明了代理target 的一些操作,其属性是当执行一个操作时定义代理的行为的函数。•p 是代理后的对象。当外界每次对 p 进行操作时,就会执行 handler 对象上的一些方法。Proxy共有13种劫持操作,handler代理的一些常用的方法有如下几个:•重要的方法有get(读取)、set(修改)、has(判断是否有属性)和constructor(构造函数)等。
MDN文档:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Proxy
实现和上面代码一样的api,需要定义一个handler生成器:
class Responsive {
// 根据配置生成hadler
static createHandler = (opts) => {
const { watch } = opts;
const handler = {
get: function (target, prop) {
return target[prop];
},
set: function (target, prop, newVal) {
let old = target[prop];
target[prop] = newVal;
// 在设置对象的属性时
if (watch && watch[prop]) {
watch[prop](old, newVal);
}
return true;
}
}
return handler;
};
// ...
}
然后在构造函数中调用。
constructor(opts) {
const { data } = opts;
const handler = Responsive.createHandler(opts);
this.proxy = new Proxy(data, handler);
}
setState(key, value) {
if (key) {
this.proxy[key] = value;
}
}
getState(key, value) {
if (key) {
return this.proxy[key];
}
}
那么就实现了一样的功能。写法较defineProperty更为优雅些。在笔者的正在进行项目的代码中,也使用了该代码段。
如果是满足日常的封装需要,那么本文就是时候结束于此。
但是写到这里的我,应该有了更多问号。
•目前的封装很不完善。只能监听对象第一层的内容。对于复杂数据类型,不支持。所谓的数据绑定,依然是在watch
api上手工把calc方法写进去的。能不能进一步提供类似vue一样的体验呢?•Proxy对于defineProperty的优势是哪里?•Vue 3 的新变化 ?