最近在做一个购物车相关的项目,前端开发的需求无非就是点货物数量两边的『加号』和『减号』,或者直接修改购物车数量里的数字(文本框),可以实时改变购物车总金额数量的显示,以及加减号可点状态的变化(到数量 1 就不能再点减号,到库存最大数就不能再点加号……)。当然,这里面还涉及到很多复杂的逻辑,比如购物车里删除单品,跨境商品和普通商品必须分开结账等……
这个任务交给小朋友来实施,结果小朋友很快就把自己绕进去了。细看他们写的代码,可以用下面的伪代码来描述:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | 当页面载入事件发生: (获取购物车所有商品的 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 这样的民工库,是支持任意对象抛出事件的:
1 2 3 4 5 6 7 | var cart = { check: () => { // 做一些购物车结算工作,利用 jQuery 的 trigger 方法抛出事件 checked! $(cart).trigger('checked'); } }; |
这样,我就可以将工作分成两部分,一部分是关注 cart 数据逻辑本身,以及抛出合适的事件,视图的事情一概不管,而另一部分工作,则只关心一件事:当 cart 的结算方法完成时,视图应该如何更新,从而实现分拆关注点,方便理清思路,以及实现团队分工的目的:
1 2 3 4 | $(cart).on('checked', () => { $('#total-price').html(cart.price); }); |
为了体现『数据双向绑定』后代码的简洁易懂性,下面我把给小朋友们写的示例代码全部贴出来(使用 ES6 语法实现,需要在现代浏览器里执行):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 | <!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
匿名
六月 9, 2017 在 11:17 上午小朋友?举报楼主雇佣童工
Chris Yue
六月 9, 2017 在 5:13 下午哭笑不得哭笑不得