`
kidneyball
  • 浏览: 326903 次
  • 性别: Icon_minigender_1
  • 来自: 南太平洋
社区版块
存档分类
最新评论

Knockout.js与Primefaces整合日志 4

阅读更多
解决了JSF组件添加属性的问题,现在其实已经可以比较流畅地在JSF页面中使用knockout了。

常用的交互方式有两种,一是提交时使用正常的命令组件(p:commandButton, p:commandLink等),响应时使用Primefaces的RequestContext或OmniFaces的Ajax工具类返回JSON并更新ViewModel。

举例来说,以下是一段纯粹的JSF代码
<h:form>
  <h:panelGroup id="countMessage">
    <h:outputText value="#{myBean.selected.size()"/> Row(s) selected.
  </h:panelGroup>
  <p:dataGrid ... >
    <p:ajax event="rowSelect" action="#{myBean.selectionChanged}" update="countMessage,actionPanel"/>
    <p:ajax event="rowUnselect" action="#{myBean.selectionChanged}" update="countMessage,actionPanel"/>
    <!-- Columns -->
    ...
  </p:dataGrid>
  <h:panelGroup id="actionPanel">
    <p:commandButton id="deleteBtn" value="Delete" disabled="#{myBean.selected.size() eq 0}" action="#{myBean.deleteSelected}"/>
  </h:panelGroup>
</h:form>


它的功能是当选中dataGrid中的一行或多行时,显示有多少行被选中了,并且激活Delete按钮让用户选择。上面代码的问题是显而易见的,首先是无论多简单的动作,都必须通过ajax请求服务器端进行局部渲染。其次是可以看到上面代码中只有myBean.selected集合的尺寸在改变,但由于与其相关的界面元素位于不同位置,所以在每次改变selected集合时,都要在发起请求的组件上显式通知这些区域进行重新渲染。上面的例子只涉及到两个组件,渲染两处目标区域,但实际应用很可能涉及更多的组件和更多的目标区域。在实际开发中,往往为了方便而放弃精确的指向确实需要渲染的位置,而是渲染一个较大的区域(比如在上例中,改为update整个form),这样一来却又增加了不必要的网络流量负担。

那么改为结合knockout.js实现,将会是这样的:
<h:form data-vm="UserListVM">
  <div>
    <span data-bind="text: selectedCount"> </span> Row(s) selected.
  </div>
  <p:dataGrid ... >
    <p:ajax event="rowSelect" action="#{myBean.selectionChanged}" oncomplete="app.vm.UserListVM.selectedCount(args.count)"/>
    <p:ajax event="rowUnselect" action="#{myBean.selectionChanged}" oncomplete="app.vm.UserListVM.selectedCount(args.count)"/>
    <!-- Columns -->
    ...
  </p:dataGrid>
  <h:panelGroup id="actionPanel">
    <p:commandButton id="deleteBtn" value="Delete" data-bind="attr: {disabled: selectedCount() == 0}" action="#{myBean.deleteSelected}"/>
  </h:panelGroup>
  <script>
    app.createVM({
        selectedCount: 0
    }, "UserListVM").bind("[data-vm=UserListVM]")
  </script>
</h:form>


在服务器端,我们需要调用
    RequestContext.getCurrentInstance().addCallbackParam("count", this.selected.size())

来构造响应的json数据。

咋看上去,似乎代码量还多了。但这种写法有以下好处:
1. 即使我们将来继续添加与selectedCount相关的界面元素,两个p:ajax的代码都不用改动了
2. 有需要的话,我们可以非常方便地调用客户端API实现模拟多行选中的效果 (直接调用app.vm.UserListVM.selectedCount(数字)就可以更新各处界面元素)
3. 服务器端的响应只是一个非常简短的json ({count:数字}),而不是各处目标区域的html代码。

在这个基础上,下面我们来对客户端的knockout体系进行一些扩展,让其更为易用。前面已经提过,与Avalon相比,原生的knockout.js库缺少一些提高易用性的功能,但好在knockout非常易于扩展,现在我们来把这些功能补上。



##显示/隐藏 的动画效果##

在显示/隐藏元素时使用动画效果是最常见的功能,因此我们加入slideVisible与fadeVisible两个自定义绑定:

;(function($){
    ko.bindingHandlers.slideVisible = {
            init: function(element, valueAccessor) {
                var value = ko.unwrap(valueAccessor());
                if (!value) $(element).hide()
            },
            update: function(element, valueAccessor, allBindings) {
                var value = ko.unwrap(valueAccessor());
                var duration = allBindings.get('duration') || 400;
                if (value)
                    $(element).slideDown(duration);
                else
                    $(element).slideUp(duration);
            }
        };
})(jQuery)

;(function($){
    ko.bindingHandlers.fadeVisible = {
            init: function(element, valueAccessor) {
                var value = ko.unwrap(valueAccessor());
                if (!value) $(element).hide()
            },
            update: function(element, valueAccessor, allBindings) {
                var value = ko.unwrap(valueAccessor());
                var duration = allBindings.get('duration') || 400;
                if (value)
                    $(element).fadeIn(duration);
                else
                    $(element).fadeOut(duration);
            }
        };
})(jQuery)


使用data-bind="slideVisible: condition"绑定滑动效果,data-bind="fadeVisible: condition"绑定淡出效果。并且可以配合duration绑定来指定动画时间:data-bind="fadeVisible: condition, duration: 200"



##鼠标移入##

旧式浏览器不支持:hover伪类,因此我们引入一个hover绑定,当鼠标移入绑定元素时,添加指定的class。移出时去除。


;(function($) {
    ko.bindingHandlers.hover = {
            init: function(element, valueAccessor) {
                var className = ko.unwrap(valueAccessor())
                $(element).hover(
                    function() {$(this).addClass(className)},
                    function() {$(this).removeClass(className)}
                )
            }
        };
})(jQuery)




用法
<!-- 鼠标悬浮时给DIV加上.hover class -->
<div data-bind="hover: 'hover'">...</div>




##几个常用属性的简写绑定##

knockout.js对所有元素属性统一使用attr来绑定,比如
<button data-bind="attr: {disabled: !valid()}"> </button>


比较冗长,我们可以为href, src, disabled这几个常用属性提供专门的绑定。

ko.bindingHandlers.href = {
    update: function (element, valueAccessor) {
        ko.bindingHandlers.attr.update(element, function () {
            return { href: valueAccessor()}
        });
    }
};

ko.bindingHandlers.src = {
    update: function (element, valueAccessor) {
        ko.bindingHandlers.attr.update(element, function () {
            return { src: valueAccessor()}
        });
    }
};

ko.bindingHandlers.disabled = {
    update: function (element, valueAccessor) {
        ko.bindingHandlers.attr.update(element, function () {
            return { disabled: valueAccessor()}
        });
    }
};


现在我们可以这样写
    <button data-bind="disabled: !valid()"> </button>



##反义绑定##

从上面的例子可以看出,我们往往需要使用data-bind="disabled: !valid()"这样在语义上双重否定的写法,既不方便也不直观。因此我们为常用的disabled和visible这两个布尔类型的绑定提供其相反的绑定:

ko.bindingHandlers.enabled = {
    update: function (element, valueAccessor) {
        var value = ko.unwrap(valueAccessor());
        ko.bindingHandlers.attr.update(element, function () {
            return { disabled: !value}
        });
    }
};

ko.bindingHandlers.hidden = {
    update: function (element, valueAccessor) {
        var value = ko.unwrap(valueAccessor());
        ko.bindingHandlers.visible.update(element, function () { return !value; });
    }
};


现在我们可以这样写
<button data-bind="enable: valid"> </button>




##开关绑定##

让用户选择开关的最常用做法是使用checkbox
<input type="checkbox" data-bind="checked: isSendEmail"/>


但在现实中,出于UI设计考虑,我们往往需要用div来模拟
<div data-bind="css: {checked: isSendEmail}, click: function() {isSendEmail(!isSendEmail())}">...</div>


显然这个function() {isSendEmail(!isSendEmail())}的写法略显丑陋,我们可以引入一个toggle绑定来对付这种需求。

ko.bindingHandlers.toggle = {
    init: function (element, valueAccessor) {
        var value = valueAccessor()
        ko.applyBindingsToNode(element, {
            click: function () {
                value(!value())
            }
        });
    }
};


现在可以这样写:
<div data-bind="css: {checked: isSendEmail}, toggle: isSendEmail">...</div>




##扩展css样式类绑定##

knockout.js自身提供的css样式类绑定有两种,一是根据布尔值添加或移除class
<div data-bind="css: {valid: isValid(), 'ui-disabled': isDisabled()}">


也可以直接把一个字符串值作为class添加进去,并且当值改变时移除旧的class。
<div data-bind="css: severity">


但这种做法有两个缺陷:
1. 会把元素上用class属性定义的静态样式类冲掉。
2. 如果有多个class动态改变,必须在模型中先把class的字符串拼装好,再一次性绑上去,非常不直观。

所以考虑引入一组styleClass-*绑定,其中*是1到9的数字,可以使用这套绑定直接分别绑定字符值。当然如果只需要绑定一个时也可以只使用styleClass。

;(function() {
    var factory = function(n) {
        return {
            init: function(element, valueAccessor) {
                var classNames = ko.unwrap(valueAccessor())
                var el=$(element);
                el.addClass(classNames).data("prev-class-" + n, classNames)
            },
            update: function(element, valueAccessor) {
                var el = $(element)
                var prev = el.data("prev-class-" + n)
                var current = " " + ko.unwrap(valueAccessor()) + " "
                var toRemove = ko.utils.arrayFilter((prev || "").split(/\s+/), function(item) {
                    return item.trim().length > 0 && current.indexOf(" " + item + " ") < 0
                }).join(" ")
                if (toRemove.length > 0) el.removeClass(toRemove)
                el.addClass(current)
            }
        };
    }
    
    ko.bindingHandlers.styleClass = factory(0)
    for (var i = 1; i < 10; i++) {
        ko.bindingHandlers['styleClass' + i] = factory(i)
    }
})(jQuery)


用法:
<div data-bind="styleClass: severity()">...</div>

<div data-bind="styleClass: severity(), styleClass-1: progressStatus, styleClass-2: removed() ? 'removed' : ''"


至此,针对knockout.js自身的简单扩展就差不多了。下面开始重头戏,扩展knockout.js令其更适合与JSF协作。



##从元素值反向初始化模型值##

前面已经提过,knockout.js原本的设计理念是由js驱动页面。所有模型值都需要先在javascript层面初始化,否则就会冲掉元素上的值。通常这是通过载入静态模板页面后再发一个加载数据的请求来实现的。但JSF本身就是动态页面,而且组件绑定服务器端模型值,在首次渲染就把值渲染进去了。再加载一次是画蛇添足,如果不用额外请求加载,就需要在页面上把数据用javascript形式再渲染一次。这种做法不但累赘,而且容易受到脚本注入攻击。

所以,最方便的做法是,加入一个keepInitialValue的绑定,让knockout.js在首次绑定时反过来用元素值来初始化模型值。这样我们只需要保证视图模型的架子搭起来就行了,不需要在javascript层面上用业务数据进行初始化。

显然,有这种需求的必然是一些绑定到可写监控值上的双向绑定,比如说textInput, checked, value, selectedOptions等。我们逐个来扩展

首先是textInput和value,它们的机制相似,可以放到一起处理。

//Wrap the textInput and value handler to handle keepInitialValue binding 
;(function($) {
    var textInputHandler = ko.bindingHandlers.textInput;
    var valueHandler = ko.bindingHandlers.value;
    var initViewModel = function(element, valueAccessor, allBindings) {
        if (allBindings.has("keepInitialValue")) {
            if (ko.isWritableObservable(valueAccessor())) {
                valueAccessor()($(element).val())
            }
        }
    }
    
    ko.bindingHandlers.textInput = { 
        'init': function (element, valueAccessor, allBindings) {
            initViewModel(element, valueAccessor, allBindings)
            textInputHandler.init(element, valueAccessor, allBindings)
        }
    }
    ko.bindingHandlers.textInput.__proto__ = textInputHandler
    
    ko.bindingHandlers.value = {
        'init': function (element, valueAccessor, allBindings) {
            initViewModel(element, valueAccessor, allBindings)
            valueHandler.init(element, valueAccessor, allBindings)
        }
    }
    ko.bindingHandlers.value.__proto__ = valueHandler
})(jQuery);


绑定用法如下:
    <h:inputText value="#{myBean.firstName}" data-bind="textInput: firstName, keepInitialValue"/>


在javascript中,我们只需要定义好firstName属性,随便给它一个初始值(最好保证类型相同)就行了。
app.registerConstructor("MyBeanVM", function() {
    this.firstName = ko.observable("")
})


同样,我们下面处理checked属性,由于checked属性要兼顾radio与checkbox两种元素,并且checkbox又分为绑定布尔和绑定数组两种情况,相对复杂一点

//Wrap the checked handler to handle keepInitialValue binding 
;(function() {
    var checkedHandler = ko.bindingHandlers.checked;
    var initViewModel = function(element, valueAccessor, allBindings) {
        function updateModel() {
            /* A simplified version of internal helper function in ko.bindingHandlers.checked */

            var isCheckbox = element.type == "checkbox",
                isRadio = element.type == "radio",
                isChecked = element.checked;
            var useCheckedValue = isRadio || isValueArray;
            var elemValue = useCheckedValue ? checkedValue() : isChecked;

            // We can ignore unchecked radio buttons, because some other radio
            // button will be getting checked, and that one can take care of updating state.
            if (isRadio && !isChecked) {
                return;
            }
            var isValueArray = isCheckbox && (ko.utils.unwrapObservable(valueAccessor()) instanceof Array)
            if (isValueArray) {
                app.console.error('"keepInitialValue" does not support array value on the "checked" binding.')
            } else {
                valueAccessor()(elemValue)
            }
        };

        if (allBindings.has("keepInitialValue")) {
            if (ko.isWritableObservable(valueAccessor())) {
                updateModel()
            }
        }
    }
    
    ko.bindingHandlers.checked = { 
        'init': function (element, valueAccessor, allBindings) {
            initViewModel(element, valueAccessor, allBindings)
            checkedHandler.init(element, valueAccessor, allBindings)
        }
    }
    ko.bindingHandlers.checked.__proto__ = checkedHandler
})();



最后,处理selectedOptions

;(function($) {
    var selectedOptionsHandler = ko.bindingHandlers.selectedOptions;
    ko.bindingHandlers.selectedOptions = { 
        'init': function (element, valueAccessor, allBindings) {
            if (allBindings.has("keepInitialValue")) {
                (function () {
                    var value = valueAccessor(), valueToWrite = [];
                    ko.utils.arrayForEach(element.getElementsByTagName("option"), function(node) {
                        if (node.selected)
                            valueToWrite.push(ko.selectExtensions.readValue(node));
                    });
                    value(valueToWrite);
                })();
            }
            selectedOptionsHandler.init(element, valueAccessor, allBindings)
        },
        'update': function(element, valueAccessor, allBindings, viewModel, bindingContext) {
            selectedOptionsHandler.update(element, valueAccessor, allBindings, viewModel, bindingContext)
        }
        
    }
    ko.bindingHandlers.selectedOptions.__proto__ = selectedOptionsHandler
})(jQuery);


大功告成,现在主要的几种双向绑定都支持keepInitialValue绑定了。



##绑定内部元素##

虽然我们通过替换ResponseWriter的方式解决了大部分JSF组件渲染data-bind属性的问题。但还是对以下两种场景无能为力:
1. 某些渲染出复杂内部结构的组件,我们想绑定其内部某个元素时。比如说,绑定日期组件的年份下拉框。
2. 某些由父组件负责其子组件渲染的组件,我们想绑定子组件时。比如说,想绑定p:menuButton的其中一个p:menuItem,你会发现,p:menuItem对应的li元素根本没有clientId,它是由p:menuButton组件负责渲染的。

这类情况,最原始的解决办法是在实施绑定之前,用jQuery把data-bind强行加到元素上。但这样一来,元素标签和data-bind就分开了,代码变得很不直观。一个更好的解决方案是,引入一组data-bind-child-* (其中*为数字)绑定,把绑定子元素属性的绑定放到邻近的父元素上,这样至少保证了绑定的定义和绑定的目标不会离开太远。自然,为了触发knockout框架对这些属性进行处理,我们需要引入一个标志性的knockout绑定,命名为bindChildren。也就是说,当元素的data-bind中包含bindChildren绑定时,就触发对元素上data-bind-child-*属性的处理。在每个data-bind-child-*属性中,使用“绑定内容@子元素选择器”的格式,把绑定内容添加到符合选择器的子元素上。

比如说如果要绑定一个菜单项,可以这样写:
<div  data-bind="bindChildren" data-bind-child-1="visible: selectedCount > 0 @ li.delete-all">
    <p:menuButton>
        <p:menuItem styleClass="delete-all" value="Delete All" action="#{myBean.deleteAll}"/>
    </p:menuButton>
</div>


bindChildren绑定的实现代码如下:
;(function($){
    ko.bindingHandlers.bindChildren = {
            init: function(element) {
                $.each(element.attributes, function() {
                    try {
                        var attr = this, name, value, parts
                        if (attr.specified) {
                            name = attr.name
                            if (name.match(/^data-bind-child(?:-\d+)?$/i)) {
                                value = attr.value
                                parts = value.split(/\s*@\s*/)
                                if (parts.length == 2) {
                                    $(element).find(parts[1]).each(function() {
                                        $(this).attr("data-bind", parts[0])
                                    })
                                } else {
                                    app.console.error("Invalid data-bind-child content: " + value)
                                }
                            }
                        }
                    } catch (err) {
                        app.console.error(err)
                    }
                })
            }
    }
})(jQuery)




##跳过区域##

knockout.js不支持视图模型嵌套,如果试图在同一个元素试图实施绑定两次,就会抛出错误。在JSF中,由于大量使用Facelet来组合页面,往往很难避免区域的嵌套。比如说在页面A的绑定区域中嵌入了页面B,而页面B又包含了一个绑定区域。那么当页面A的绑定区域完全渲染完毕,开始进行绑定时,其内部属于页面B的那个区域事实上已经绑定过了,这样A的绑定就会中途出错无法完成。

为了避免这种情况,我们需要引入一个告诉knockout.js跳过某个区域的标志,这样我们就可以用这个标志把区域B保护起来,其内部由页面B的代码独立绑定。而在对A进行绑定时,则跳过此区域。

好在knockout.js其实已经提供了非常方便的解决方案,但却没有提供现成的绑定,需要我们自己定义。

ko.bindingHandlers.stop = ko.bindingHandlers.noKnockout = {
        init: function() {
            return { controlsDescendantBindings: true };
        }
}

ko.virtualElements.allowedBindings.stop = true

ko.virtualElements.allowedBindings.noKnockout = true


在这里我们定义了stop和noKnockout两个绑定,实现代码和功能是完全一样的:告诉knockout.js跳过其内部区域。但在业务语义上有所不同:
* stop用来保护嵌套的内部区域不被外部区域误绑定。也就是解决上面提到的问题
* noKnockout用来提醒开发者,其内部不应该有任何knockout绑定。由于前面已经提到过,被knockout绑定组件不能单独进行局部渲染(局部渲染后必须重新绑定),我们可以使用noKnockout来提醒开发者,这个区域内部不会有任何knockout绑定的元素,可以自由使用JSF的局部渲染。当然这只是一种构想的用法,实际效果如何需要在实际使用中评估。

用法示例如下:

页面A
<div data-vm="vmA">
...
    <ui:include src="b.xhtml"/>
...
</div>
<script>
app.createVM("vmA").bind("[data-vm=vmA]")
</script>


页面B
<div data-bind="stop">
    <div data-vm="vmB">
    ...
    </div>
    <script>
        app.createVM("vmB").bind("[data-vm=vmB]")
    </script>
</div>


至此,在客户端针对JSF整合的扩展就差不多了。下一节我们将回过头去完善服务器端的API。
分享到:
评论

相关推荐

Global site tag (gtag.js) - Google Analytics