简体   繁体   中英

How to implement observable “bridge” in knockout.js?

I wrote a duration picker control in Knockout.js (snippet below, also on jsFiddle ):

 $(function() { ko.bindingHandlers.clickOutside = { init: function (element, valueAccessor, allBindings, viewModel, bindingContext) { var callback = ko.utils.unwrapObservable(valueAccessor()); var clickHandler = function (e) { if (!($.contains(element, e.target) || element === e.target)) { callback(); } }; $('html').on('click', clickHandler); ko.utils.domNodeDisposal.addDisposeCallback(element, function () { $('html').off('click', clickHandler); }); } }; ko.components.register('durationInput', { viewModel: function (params) { var self = this; if (!ko.isObservable(params.value)) { throw "value param should be an observable!"; } this.value = params.value; var match = /^([0-9]{1,2}):([0-5]?[0-9]):([0-5]?[0-9])$/.exec(this.value()); this.hours = ko.observable(match != null ? match[1] : "00"); this.minutes = ko.observable(match != null ? match[2] : "00"); this.seconds = ko.observable(match != null ? match[3] : "00"); this.label = params.label; this.id = params.id; this.popupVisible = ko.observable(false); this.inputClick = function () { self.popupVisible(!self.popupVisible()); }; this.clickOutside = function () { self.popupVisible(false); }; this.evalValue = function () { var hrs = self.hours(); while (hrs.length < 2) hrs = "0" + hrs; var mins = self.minutes(); while (mins.length < 2) mins = "0" + mins; var secs = self.seconds(); while (secs.length < 2) secs = "0" + secs; self.value(hrs + ':' + mins + ':' + secs); }; this.hours.subscribe(this.evalValue); this.minutes.subscribe(this.evalValue); this.seconds.subscribe(this.evalValue); }, template: '<div class="form-group" data-bind="clickOutside: clickOutside">\\ <label data-bind="text: label, attr: { for: id }" />\\ <div class="input-group">\\ <input class="form-control duration-picker-input" type="text" data-bind="value: value, click: inputClick" readonly>\\ <div class="panel panel-default duration-picker-popup" data-bind="visible: popupVisible">\\ <div class="panel-body">\\ <div class="inline-block">\\ <div class="form-group">\\ <label data-bind="attr: { for: id + \\'-hours\\' }">Hours</label>\\ <input data-bind="textInput: hours, attr: { id: id + \\'-hours\\' }" class="form-control" type="number" min="0" max="99" />\\ </div>\\ </div>\\ <div class="inline-block">\\ <div class="form-group">\\ <label data-bind="attr: { for: id + \\'-minutes\\' }">Minutes</label>\\ <input data-bind="textInput: minutes, attr: { id: id + \\'-minutes\\' }" class="form-control" type="number" min="0" max="59" />\\ </div>\\ </div>\\ <div class="inline-block">\\ <div class="form-group">\\ <label data-bind="attr: { for: id + \\'-seconds\\' }">Seconds</label>\\ <input data-bind="textInput: seconds, attr: { id: id + \\'-seconds\\' }" class="form-control" type="number" min="0" max="59" />\\ </div>\\ </div>\\ </div>\\ </div>\\ </input>\\ <span class="input-group-addon" data-bind="click: inputClick"><span class="hover-action glyphicon glyphicon-chevron-down"></span></span>\\ </div>\\ </div>' }); var viewmodel = function() { this.time = ko.observable("12:34:56"); }; ko.applyBindings(new viewmodel()); }); 
 .hover-action { color: #bbb; cursor: pointer; } .hover-action:hover { color: #333; cursor: pointer; } .inline-block { display: inline-block; } .duration-picker-input { position: relative; } .duration-picker-popup { z-index: 100; position: absolute; top: 100%; right: 0; } 
 <link rel="stylesheet" type="text/css" href="https://maxcdn.bootstrapcdn.com/bootswatch/3.3.7/paper/bootstrap.min.css"> <div style="width: 400px"> <!--ko component: { name: "durationInput", params: { id: "time", value: time, label: "Total time" } } --> <!-- /ko --> </div> <script src="https://cdnjs.cloudflare.com/ajax/libs/knockout/3.4.2/knockout-min.js"></script> <script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script> 

The problem is, that it does not react to changes of value observable, which is being passed via parameters. However, I have no idea how to implement it without falling into an infinite loop. Currently, I have:

(initialization)
       |
Current value is being read from observable passed via params
       |
Hours, minutes and seconds values are generated basing on current value

And then:

(Hours, minutes or seconds change)
       |
New value is generated as hours:minutes:seconds
       |
New value is set to observable passed via params

The problem is that if I react to changes in passed observable and re-generate hours, minutes and seconds, I will cause their subscribers to be notified, so the value will be regenerated, so it will change, so hours, minutes and seconds will be regenerated, so their subscribers will be notified again... and so on.

How can I implement so-called two-way bridge from single observable to three others? Something like a multi-binding in WPF.

You should be using a pure Computed observable rather than composing a data item via subscriptions.

In any case, subscribers should not be notified if the value doesn't actually change, so a change to the composite value that translates into a change to individual values, which then re-assigns the composite value to the same value as it already has should stop the subscription cycle.

Sample implementation:

ko.components.register('durationInput', {
    viewModel: function (params) {

        var self = this;

        if (!ko.isObservable(params.value)) {
            throw "value param should be an observable!";            
        }

        this.value = params.value;
        this.label = params.label;
        this.id = params.id;
        this.popupVisible = ko.observable(false);

        this.hours = ko.observable();
        this.minutes = ko.observable();
        this.seconds = ko.observable();

        this.valueEvaluator = ko.pureComputed({
            read: function () {

                var hrs = self.hours() || "";
                while (hrs.length < 2)
                    hrs = "0" + hrs;

                var mins = self.minutes() || "";
                while (mins.length < 2)
                    mins = "0" + mins;

                var secs = self.seconds() || "";
                while (secs.length < 2)
                    secs = "0" + secs;

                self.value(hrs + ':' + mins + ':' + secs);
            },
            write: function (value) {

                var match = /^([0-9]{1,2}):([0-5]?[0-9]):([0-5]?[0-9])$/.exec(self.value());
                self.hours(match != null ? match[1] : "00");
                self.minutes(match != null ? match[2] : "00");
                self.seconds(match != null ? match[3] : "00");
            }
        });

        // Init
        this.valueEvaluator(this.value());

        this.value.subscribe(function (newValue) {

            if (self.valueEvaluator() != newValue)
                self.valueEvaluator(newValue);
        });

        this.valueEvaluator.subscribe(function (newValue) {

            if (self.value() != newValue)
                self.value(newValue);
        });

        this.inputClick = function () {

            self.popupVisible(!self.popupVisible());
        };

        this.clickOutside = function () {

            self.popupVisible(false);
        };
    },
    template: '<div class="form-group" data-bind="clickOutside: clickOutside">\
        <label data-bind="text: label, attr: { for: id }" />\
        <div class="input-group">\
            <input class="form-control duration-picker-input" type="text" data-bind="value: value, click: inputClick" readonly>\
                <div class="panel panel-default duration-picker-popup" data-bind="visible: popupVisible">\
                    <div class="panel-body">\
                        <div class="inline-block">\
                            <div class="form-group">\
                                <label data-bind="attr: { for: id + \'-hours\' }">Hours</label>\
                                <input data-bind="textInput: hours, attr: { id: id + \'-hours\' }" class="form-control" type="number" min="0" max="99" />\
                            </div>\
                        </div>\
                        <div class="inline-block">\
                            <div class="form-group">\
                                <label data-bind="attr: { for: id + \'-minutes\' }">Minutes</label>\
                                <input data-bind="textInput: minutes, attr: { id: id + \'-minutes\' }" class="form-control" type="number" min="0" max="59" />\
                            </div>\
                        </div>\
                        <div class="inline-block">\
                            <div class="form-group">\
                                <label data-bind="attr: { for: id + \'-seconds\' }">Seconds</label>\
                                <input data-bind="textInput: seconds, attr: { id: id + \'-seconds\' }" class="form-control" type="number" min="0" max="59" />\
                            </div>\
                        </div>\
                    </div>\
                </div>\
            </input>\
            <span class="input-group-addon" data-bind="click: inputClick"><span class="hover-action glyphicon glyphicon-chevron-down"></span></span>\
        </div>\
    </div>'
});

Your logic can be split up in to three parts:

  1. parsing data ( executing the regex ),
  2. modifying it ( your number inputs ),
  3. and preparing the data for display ( converting 7 to 07 )

You managed to do all of these things quite well, but couldn't manage to two-way bind the hours , minutes and seconds of your popup display.

To fix this issue, I propose to change these properties to read/write computed values.

Preparing the change

First, let's create an object internal to the component's view model that keeps track of the raw hour, minute and seconds numerical values:

var time = {
  HH: ko.observable(0),
  mm: ko.observable(0),
  ss: ko.observable(0)
};

Keep this object up to date by subscribing to the value passed to the component:

this.value.subscribe(parseTime);

// Initial settings for time object
parseTime(this.value());

With the parseTime function being:

var parseTime = function(timeString) {
  var parts = /^([0-9]{1,2}):([0-5]?[0-9]):([0-5]?[0-9])$/.exec(timeString);

  time.HH(parts ? +parts[1] : 0); // Note we're casting to Number
  time.mm(parts ? +parts[2] : 0);
  time.ss(parts ? +parts[3] : 0);
}

Now that we have a time object that keeps track of changes made outside the component, we can move on to the individual controls.

Creating the { read, write } computeds

Now, we can create individual computed properties for hours, minutes and seconds.

Their write methods will forward the input values to time.HH , time.mm and time.ss .

Their read methods act as a "display value" and prepend 0 for values < 10 . For example:

this.hours = ko.computed({
  // Ensure two digits
  read: function() {
    return (time.HH() < 10 ? "0" : "") + time.HH();
  },
  // Cast to a number and limit between 0 and 23
  write: function(v) { 
    time.HH(Math.max(Math.min(23, +v), 0));
  }
});

Exposing user input outside of the component

The last step is to make sure the changes made in the internal time object are published back to the component's passed value . Since we've already defined t he read methods to add zeroes when needed, this gets a bit easier:

this.evalValue = function() {
  var hrs = self.hours();
  var mins = self.minutes();
  var secs = self.seconds();
  self.value(hrs + ':' + mins + ':' + secs);
};

this.hours.subscribe(this.evalValue);
this.minutes.subscribe(this.evalValue);
this.seconds.subscribe(this.evalValue);

(If you don't like the three subscribes, you can wrap evalValue in a computed as well...)

Concluding

These changes ensure your values keep in sync. I'm not sure what edge cases and input sanitation you require, but I hope you can build on this example and reconfigure when needed.

Here's the code in a fiddle: https://jsfiddle.net/uxnasqf5/

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM