数据视图双向绑定(jQuery 实现)

数据视图双向绑定(jQuery 实现)

Chris Yue 2 comments
Posts

最近在做一个购物车相关的项目,前端开发的需求无非就是点货物数量两边的『加号』和『减号』,或者直接修改购物车数量里的数字(文本框),可以实时改变购物车总金额数量的显示,以及加减号可点状态的变化(到数量 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

匿名

六月 9, 2017 在 11:17 上午

小朋友?举报楼主雇佣童工

    Chris Yue

    六月 9, 2017 在 5:13 下午

    哭笑不得哭笑不得

     

发表评论

+ 26 = 34