最近在做一个购物车相关的项目,前端开发的需求无非就是点货物数量两边的『加号』和『减号』,或者直接修改购物车数量里的数字(文本框),可以实时改变购物车总金额数量的显示,以及加减号可点状态的变化(到数量 1 就不能再点减号,到库存最大数就不能再点加号……)。当然,这里面还涉及到很多复杂的逻辑,比如购物车里删除单品,跨境商品和普通商品必须分开结账等……
这个任务交给小朋友来实施,结果小朋友很快就把自己绕进去了。细看他们写的代码,可以用下面的伪代码来描述:
当页面载入事件发生: (获取购物车所有商品的 DOM).forEach((每个商品的 DOM) => { price = DOM 里显示的价格; num = DOM 里输入框内的商品的数量; 此商品的购买价 totalPrice = price * num; 购物车结算价 cartCheckPrice += totalPrice; if (商品数量 <=1) { 减号设置为不可点; } if (商品 >= 库存数) { 加号设置为不可点; } }); cartCheckPrice 显示在购物车总价的 DOM 里; 当每一个加号按钮被点击: 被点击加号旁边的数据框里的数字 +1; 又获取购物车所有的 DOM,并从 DOM 拿到每个商品的价格和数量,又乘起来(我为什么要说『又』字),算此商品总价格 if (商品 >= 库存数) { 加号设置为不可点; } 把购物车结算价再次更新在他自己的 DOM 里 当每一个减号按钮被点击... (抱歉我已经不想写了 = =)
这还并不涉及商品从购物车删除,以及刚才说的跨境商品的计算,只要是正常人,是妥妥会晕的。
如果是老鸟来接手这个项目,一定会说:这种需求显然用 Angular.js 或者 Vue.js 提供的数据双向绑定功能来做要轻松得多啊!
的确,但问题是小朋友刚接触 Vue.js 这样的框架,可能对数据双向绑定的意义,以及双向绑定的实现原理会有疑惑,所以我这里将阐述这些疑惑,让大家能够理解,并且在理解的基础上用好数据双向绑定的技术。
哪首先什么是数据双向绑定呢?
我们在给页面写 JS 代码的时候,其实大部分时间都是在跟 DOM 和 DOM 上呈现的数据打交道,DOM 代表了视图,数据就是 Model。一些简单的需求,直接操作 DOM 来显示数据其实并没有太大问题,但如果需求变得很复杂,比如刚才所说的购物车的例子,直接在 DOM 里操作数字,因为显示逻辑和数据逻辑缠绕在一起,代码变得极其冗长而难以维护。如果,视图上显示的数是跟某个 JS 代码里的 Model 是绑定的关系,比如说如果 JS 代码里有一个叫 cart
的购物车对象,cart 上有个属性叫 price 表示购物车的结算价,当 cart 的 price 发生变化时,视图会自动跟着变化,不用在做数据逻辑的时候考虑视图逻辑的事情,那该多清爽。
实现视图自动跟随数据变化而变化,叫做『数据驱动视图』;相反,当视图发生事件(比如用户的点击)改变了数据,叫做『视图操作数据』。这两者结合起来就是『数据双向绑定』了。
『视图操作数据』的实现没什么好解释的,当用户点击了加号按钮,让购物车对象里与之对应的商品对象的购买数加 1,这应该大家都会。而『数据驱动视图』的实现,就不是那么直白了。
这里我提供的数据驱动视图的思路是:使用事件监听机制。
事件监听本质上是使用的『观察者模式』来实现的,关于『观察者模式』的具体信息可以查看多年前我在 segmentfault.com 网站上的一个回答。大家都知道在 JS 里可以多次绑定一个按钮的 onclick 事件,当这个按钮被用户点击后,所有被绑定的事件处理代码都会依次执行。其实『数据驱动视图』也一样,当数据模型里的某个变量被更新,如果能抛出事件,所有要跟随变化的视图的处理,都可以通过监听此事件而得到解决。
一个好消息是,jQuery 这样的民工库,是支持任意对象抛出事件的:
var cart = {
check: () => {
// 做一些购物车结算工作,利用 jQuery 的 trigger 方法抛出事件 checked!
$(cart).trigger('checked');
}
};
这样,我就可以将工作分成两部分,一部分是关注 cart 数据逻辑本身,以及抛出合适的事件,视图的事情一概不管,而另一部分工作,则只关心一件事:当 cart 的结算方法完成时,视图应该如何更新,从而实现分拆关注点,方便理清思路,以及实现团队分工的目的:
$(cart).on('checked', () => {
$('#total-price').html(cart.price);
});
为了体现『数据双向绑定』后代码的简洁易懂性,下面我把给小朋友们写的示例代码全部贴出来(使用 ES6 语法实现,需要在现代浏览器里执行):
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Demo</title>
</head>
<body>
<ul id="cart">
<li class="product">
单价 $<span class="price">1</span>
<button class="incre">+</button>
<input type="number" class="number" value="1">
<button class="decre" disabled>-</button>
单品总价:$<span class="total-price">1</span>
</li>
<li class="product">
单价 $<span class="price">3</span>
<button class="incre">+</button>
<input type="number" class="number" value="2">
<button class="decre">-</button>
单品总价:$<span class="total-price">6</span>
</li>
</ul>
<p>总数量:<span id="total-num">3</span></p>
<p>总价:$<span id="total-price">7</span></p>
<script src="http://cdn.bootcss.com/jquery/2.1.3/jquery.min.js"></script>
<script>
// 定义购物车数据模型
var cart = {
setProducts: (products) => {
cart.products = products;
cart.check();
},
check: () => {
cart.num = 0;
cart.price = 0;
cart.products.forEach((product) => {
cart.num += product.num;
cart.price += product.getTotalPrice();
});
$(cart).trigger('checked');
},
};
class Product {
constructor(price, num) {
this.price = price;
this.num = num;
}
getTotalPrice() {
return this.num * this.price;
}
setNum(num) {
num = +num;
if (this.num != num && num >= 1 && num <= 10) {
this.num = num;
cart.check();
}
$(this).trigger('num.set');
}
canIncre() {
return this.num < 10; // 假设库存都是 10
}
canDecre() {
return this.num > 1;
}
incre() {
this.setNum(this.num + 1);
}
decre() {
this.setNum(this.num - 1);
}
}
$(cart).on('checked', () => {
$('#total-price').html(cart.price);
$('#total-num').html(cart.num);
});
$(() => {
// 初始化数据和视图
var products = [];
$('#cart .product').each((i, elm) => {
var $product = $(elm);
var product = new Product(+$product.find('.price').text(), +$product.find('.number').val());
// 视图改变数据
$product.find('.incre').on('click', () => {
product.incre();
});
$product.find('.decre').on('click', () => {
product.decre();
});
$product.find('.number').on('change', (e) => {
product.setNum($(e.target).val());
});
products.push(product);
// 数据驱动视图
$(product).on('num.set', () => {
$product.find('.number').val(product.num);
$product.find('.total-price').val(product.getTotalPrice());
$product.find('.incre').prop('disabled', !product.canIncre());
$product.find('.decre').prop('disabled', !product.canDecre());
$product.find('.total-price').html(product.getTotalPrice());
});
});
cart.setProducts(products);
});
</script>
</body>
</html>
上面示例代码并没有实现所有购物车功能,可能还有 bug,但双向绑定的含义应该是阐述清楚了。
可能 Vue.js 和 Angular.js 并不一定是通过这种方式来实现的(我猜测很有可能是通过 Object.defineProperty
来实现),但原理都一样,这些框架的作用——也是他们方便的地方——就是省略了给每个数据和视图做绑定的过程。
2017-03-30 补充:关于使用 Object.defineProperty
方法来实现双向绑定,我也写了一个示例,请点此查看
通过『双向绑定』的思想作为工具,可以简化我们的代码,但要用好这个工具,必须保持一个原则,否则还是容易『把自己绕进去』,这个原则就是:数据可以改变别的数据(数据内部逻辑),数据可以驱动视图,视图可以改变数据,但视图一定不要直接改变另外一个视图(除非视图对数据没有改变)(比如点加号后直接改变单品总价格和购物车总价格的显示)。
希望能给大家带来一些帮助。
数据视图双向绑定(jQuery 实现) by Chris Yue is licensed under a Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International License.

写作累,服务器还越来越贵
求分担,祝愿好人一生平安
天使打赏人
2 Comments
匿名
6月 9, 2017 在 11:17 上午小朋友?举报楼主雇佣童工
Chris Yue
6月 9, 2017 在 5:13 下午哭笑不得哭笑不得