首页 > 代码库 > 迷你MVVM框架 avalonjs 沉思录 第2节 DOM操作的三大问题

迷你MVVM框架 avalonjs 沉思录 第2节 DOM操作的三大问题

jQuery之所以击败Prototype.js,是因为它自一开始就了解这三大问题,并提出完善的解决方案。

第一个问题,DOM什么时候可用。JS不像C那样有一个main函数,里面的逻辑不分主次。但JS是这样玩,并不意味着DOM也是这样。被JS自由惯了的人,于是傻眼了。 这涉及一个时间的概念。牛顿与爱因斯坦的差别,也是在于这个时间的引入。我们的脚本并不是一下子就引入,页面也不是一下加载完毕。前者引发脚本加载管理问题,后者就是DOMReady这个概念的导入。页面是从上到下生成,除了样式或图片在浏览器的主导下会另开线程,不阻塞外,其他都是一步步往下走。每个标签最后都解析成DOM,放在DOM树上。只有待到这DOM树建成,我们做开发才算是最安全的。没有这概念的人,总是遇到“此方法不存在”的问题——因为你这时得到的元素其实是null。浏览器还是很nice的,统一提供了一个onload事件,高级一点还有DOMContentLoaded事件(俗称ready事件)。当jQuery为了让页面尽快进入可用状态而发掘出这宝贝时,世界进入了一场竞赛,各种hack层出不穷。最后各大类库框架都加上这东西。

下面是avalon的实现

    var ready = W3C ? "DOMContentLoaded" : "readystatechange"

    function fireReady() {
        if (DOC.body) { //  在IE8 iframe中doScrollCheck可能不正确
            modules["ready!"].state = 2
            innerRequire.checkDeps()
            fireReady = noop //隋性函数,防止IE9二次调用_checkDeps
        }
    }

    function doScrollCheck() {
        try { //IE下通过doScrollCheck检测DOM树是否建完
            root.doScroll("left")
            fireReady()
        } catch (e) {
            setTimeout(doScrollCheck)
        }
    }

    if (DOC.readyState === "complete") {
        setTimeout(fireReady) //如果在domReady之外加载
    } else if (W3C) {
        DOC.addEventListener(ready, fireReady)
        window.addEventListener("load", fireReady)
    } else {
        DOC.attachEvent("onreadystatechange", function() {
            if (DOC.readyState === "complete") {
                fireReady()
            }
        })
        window.attachEvent("onload", fireReady)
        if (root.doScroll) {
            doScrollCheck()
        }
    }

用法是avalon.ready(fn),只传入一个回调就行了。

jQuery的用法,尤其是第二种,让人眼前一亮:

    $(document).ready(function(){

    })

    $(function(){

    })

第二个问题,如何找到这个元素。这个Prototype很早就注意这问题了,提供了两个方法$与$$,但它只是getElementById,getElementsByTagName的简单封装。其实90%是在用getElementById,于是引发满屏ID,也引发了CSS文件里是否用ID还是类名的讨论。正如我在开篇里说的那样,远古巨神总是被遗忘的,早在Prototype.js诞生前,就有高人想到选择器引擎这东西。在《javascript框架设计》一书里,我考究出两个比较早期的引擎,2003年Simon Willison的getElementsBySelector,2004年Dean Edwards的cssQuery。来自Ruby界的Sam Stephenson醉心于把Ruby的方法移植到javascript中,全然不觉选择器引擎这神器的存在。与Dean Edwards关系非常亲近的John Resig知道这东西的重要性,选择器的行数占全库的一半。待1.3之时,Sizzle放出,江山到手。选择器的发明与普及,改写了前端操作DOM的习惯,所有操作都是围绕CSS选择符展开,并且很多时候我们是一下子得到一大堆节点,因此批量化处理也是jQuery的一大特性。

第三个问题,如何操作元素。这个jQuery与Prototype也是天壤之别。jQuery是将找到的元素存放在类数组结构的jQuery实例上,Prototype则是设法在元素节点的原型上扩展,对于旧式IE,原型没有暴露出来,只能逐个元素上加这些方法了。于是有一问题了,如果这个元素中途被删掉或置换,我们再对它进行操作就出问题了。对于jQuery是做了许多代码防御的,每一步操作都会对它的存在进行判定。而Prototype.js就对不起了,这个只能让用户自己做。易用性立判高下。其次,所有DOM操作API,勿庸置疑,需要做一层厚厚的封装,搞定各种兼容性问题。而DOM的封装一直是jQuery的拿手好戏,现在其他库也一直以抄袭jQuery为荣。虽然比起抄袭工具函数来说,这个有点难度,并还是可以拆出来的。

这是围绕DOM产生的三个问题,其实DOM操作并不是我们想要的,后端只是想让我们把收集的为数据提交上来。后来为了用户体验,出现了弹出一个层,让用户专注在这个区域填写东西;为了防止用户误填需要多少重写,于是需要数据验证,出于tooltip什么东西。再后来,这些辅助性的交互越来越多了,我们真正要做的事情其实很少,并且深陷在复杂的DOM操作里面。

但显然我们也不能开倒车,于是问题的重点变成如何分离这两种逻辑——分层架构引进来了。最初是MVC,但经过jQuery洗礼的人受不了为一点点操作要写这么多代码。不过两层,另一个更NB的东西MVVM终于也登场了。其中angular与knockout功不可没。它们的思路都很相似,就是把一些待操作的DOM,就HTML里就把它们标出来,具体做法是定义一些冗余的绑定属性。然后在JS代码搞一个能偷偷操作它们的特殊对象,我们只要操作它,页面就会发生改变。在这里,你可不到选择元素,操作元素的任何逻辑,一切都这么魔术奇幻。

对于第一个问题,几乎所有MVVM框架的做法都一致,拥有这接口,但一般的示例是没有它的展示。

<!DOCTYPE html>
<html>
    <head>
        <title>ms-css</title>
        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge" /> 
        <script src="../avalon.js" ></script>
        <script>
            avalon.define("test", function(vm) {
                vm.first = {
                    aaa: 111
                }
            })
        </script>
    </head>
    <body>
        <div ms-controller="test">
            <p><input ms-duplex="first.aaa" /></p>
            {{first.aaa}}
        </div>
    </body>
</html>

你看不到window.onload ,ready这样的方法,但它的确是正确将数据填空到页面没有报错。缘由是它们在框架内部偷偷调用DOMReady的逻辑。

    avalon.ready = function(fn) {
        innerRequire("ready!", fn)
    }

    avalon.ready(function() {
        avalon.scan(DOC.body)
    })

对于第二个问题,如何找到待操作的元素节点。avalon, angular, knockout都是通过扫描机制实现。在avalon是通过带有ms-*属性的元素节点或{{}}的文本节点,angular是带有ng-*属性的元素节点,或属性里面有{{}}的元素节点,或存在{{}}的 文本节点,knockout是带有data-bind的元素节点。这些绑定属性与文本绑定一经扫描,就会移除掉(可能也有不移除的),转换为一个刷新函数,与VM的属性关联在一起,因此当VM中的属性发生改变时,那些属性的状态,样式什么都会同步。这是魔术的其中一个端倪。

这个魔法让我们省掉大量查找元素的代码,对于框架来说,扫描机制,就是一个递归遍历节点的过程,比编写一个千行以上的选择器引擎轻松多了。这个是双赢的局面,皆大欢喜。

对于第三个问题,如何操作DOM,上面说过就是将绑定属性转换为视图刷新函数,比如说ms-css-background="color",当VM中的color变成red,那么元素也就变红。底层的代码可以简单的理解为data.element.style[background] = data.value。事实上,这修改样式,还是需要像avalon.css这样高度封装的API。jQuery提出的一系列兼容性处理方案,始终发扬光大。而avalon的实现其实与jQuery的差不了多远。angular就内置了实现了jQuery一系列接口的jqLite对象,并且它与jQuery混用时,干脆用jQuery的。至于当用户修改了VM的某一属性,如何通知视图呢,这又是另一个黑魔法,并且是一个非常高级的魔法,一般的大魔导士是施展不出来了。因此MVVM框架的数量才这么少。只有护国法师 ,创世魔法师才有这能耐。

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <script src="avalon.js"></script>
        <script>
            var model = avalon.define("test", function(vm) {
                vm.color = "green"
                vm.clickfn = function(){
                    vm.color = ‘#‘+(Math.random()*0xffffff<<0).toString(16);
                }
            })
        </script>
    </head>
    <body>
        <div  ms-controller="test">
            <div style="width:200px;height:200px" ms-css-background="color"></div>
            <button type="button" ms-click="clickfn">每点我一次都改变颜色</button>
        </div>
    </body>
</html>

总体来说,这魔法有三种实现。第一种就是将VM的属性全部转换为函数,这个是knockout的实现,是最笨拙的做法。第二种是将定义VM的函数取toString进行重新编译,然后所有操作都受限于各种服务,通过服务对VM函数的属性进行改动。这是angular的做法,还美曰其名为注赖注入。这种魔法对创建魔法的人与施法者都是沉重的负担。当然angular官网上有一些小清新的例子,用法迷惑人展示它们是多少简单。当项目的用法是另一样。第三种是使用Object.defineProperty(ecma262 v5新增的API),当此方法在IE8下有问题,是针对元素节点,想应用于Object还需用IE9+浏览器。这是其他语言都有setter, getter魔法。我们可以把所有操作都列为五大操作,读值,赋值,遍历,删除,方法调用。setter,getter只是把普通的取值语句与赋值语句变成一个方法调用的形式,我们可以hack这个方法,从而实现跟视图的同步。对于IE6-8,就惨了。幸好avalon的作者还会一点VBScript,通过VBscript类的set, get, let语句也能实现类似的效果。这正是avalon的NB之处,通过VBScript与Object.defineProperty,就能用4000行的代码达到angular 16000行的效果,并且性能更好,也不用记这么多概念,这么多API。

            //Object.defineProperty带来的魔法
            var obj = {
                a: 1,
                _b: 2
            }
            Object.defineProperty(obj, "b", {
                set: function(_b) {//重写b的setter, getter
                    this._b = _b
                },
                get: function() {
                    return this._b + 10
                }
            })
            console.log(obj.a)//1
            console.log(obj._b)//2
            console.log(obj.b)//12
            obj.a = 10
            obj.b = 20
            console.log(obj.a)//10
            console.log(obj._b)//20
            console.log(obj.b)//30

正因为有了扫描机制,有了ecma262v5的setter, getter,我们就不用管如何查找元素,如果操作元素。元素在第一次内部扫描时已经就绪,想什么操作,就在目标元素上定义不同的绑定属性。绑定属性与普通的style,class,id那样学习成本很低,它们还类似模板的用途。因此有关页面的构成完成交由专门的HTML制作人员就行了,我们只负责javascript里的VM定义。

重复一次,我们用avalon做前端开发,现在只有两步工作。在JS中定义VM,VM有一个名字,对应页面上某个元素的ms-controller的值,然后VM里面有许多方法与属性名,对应这个区域里面绑定属性的值。第二步,编写页面,通过绑定属性实现原来用jQuery搞的各种效果,如ms-attr对应jQuery的attr方法, ms-css对应jQuery的css方法,ms-visible对应jQuery的show, hide方法, ms-if对应jQuery的append, remove方法, ms-repeat相当于循环实现一整块HTML页面, {{prop}}就是在对应位置将值打印出来……

我们来一个hello world吧

jquery

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <script src="jquery.js"></script>
        <script>
            $(function(){
                $("h3").text("Hello World")
            })
        </script>
    </head>
    <body>
        <div>
            <h3></h3>
        </div>
    </body>
</html>

avalon

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <script src="avalon.js"></script>
        <script>
            avalon.define("helloworld", function(vm) {
                vm.text = "Hello World"
            })
        </script>
    </head>
    <body>
        <div  ms-controller="helloworld">
            <h3>{{text}}</h3>
        </div>
    </body>
</html>

貌似jQuery更干净,来点交互怎么样!

jquery

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <script src="jquery.js"></script>
        <script>
            $(function(){
                $("h3").text("He say Hello World !!!").click(function(){
                    $(this).text("He say Good Bye !!!")
                })
            })
        </script>
    </head>
    <body>
        <div>
            <h3></h3>
        </div>
    </body>
</html>

有点麻烦了吧,因为jQuery的选择器引擎是基于元素节点,因此我们无法把Hello World变成一个变量,重复使用。看avalon!

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <script src="avalon.js"></script>
        <script>
            avalon.define("helloworld", function(vm) {
                vm.text = "Hello World"
                vm.alert = function(){
                    vm.text = "Good Bye"
                }
            })
        </script>
    </head>
    <body>
        <div  ms-controller="helloworld">
            <h3 ms-click="alert">He say {{text}} !!!</h3>
        </div>
    </body>
</html>

再复杂一点

<!DOCTYPE html>
<html>
    <head>
        <title></title>
        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
        <script src="avalon.js"></script>
        <script>
            var model = avalon.define("test", function(vm) {
                vm.w = 100;
                vm.h = 100;
                vm.click = function() {
                    vm.w = parseFloat(vm.w) + 10;
                    vm.h = parseFloat(vm.h) + 10;
                }
                vm.arr = ["aaa", ‘bbb‘, "ccc", "ddd"]
                vm.selected = ["bbb", "ccc"]
                vm.checkAllbool = vm.arr.length === vm.selected.length
                vm.checkAll = function() {
                    if (this.checked) {
                        vm.selected = vm.arr
                    } else {
                        vm.selected.clear()
                    }
                }
            })
            model.selected.$watch("length", function(n) {
                model.checkAllbool = n === model.arr.size()
            })

        </script>
    </head>
    <body>
        <div ms-controller="test">
            <div style=" background: #a9ea00;" ms-css-width="w" ms-css-height="h"  ms-click="click"></div>
            <p>{{ w }} x {{ h }}</p>
            <p>W: <input type="text" ms-duplex="w" data-duplex-event="change"/></p>
            <p>H: <input type="text" ms-duplex="h" /></p>
            <ul>
                <li><input type="checkbox" ms-click="checkAll" ms-checked="checkAllbool"/>全选</li>
                <li ms-repeat="arr" ><input type="checkbox" ms-value="el" ms-duplex="selected"/>{{el}}</li>
            </ul>
        </div>
    </body>
</html>

光是那个全选非全选就够你受的,你们自己思惦一下如何用jQuery实现吧。反正在avalon里,没有了DOM操作,上述说的三大问题都没有了。你可以像后端的JAVAer那样转致于领域建模与业务逻辑的构思。一旦你用上它,你就进入一个更高的台阶。之前你一直疲于亡命、剪不断、理还乱的 DOM操作,交由avalon这个框架在水底下更精细准确地运作着,比你自己写更安全高效。

如果您觉得这文章对您有帮助,可以打赏点钱给我,鼓励我继续写一些高质量的博文