Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support plain JS and ko-es5 models #55

Merged
merged 9 commits into from
Jun 23, 2018

Conversation

revengineering
Copy link
Contributor

Resolves Issue #54.

Adds support for initializing plain JS properties in addition to observables.

Also fully supports knockout-es5 models.

…few more of its bindings, by marking them as two-way.

Some of knockout's built-in bindings (checked, hasFocus, selectedOptions, textInput, and value) are already listed as "two-way bindings" by default. For these, knockout generates an additional binding named "_ko_property_writers" which exposes functions for writing these values to non-observable model properties.

We need to tell knockout to generate property writers for more of its built-in bindings, so that `init` can use these to write to the model.
…r that generates custom property writers.

Knockout's _twoWayBindings collection only covers basic `name: value` bindings.
For more complex bindings that receive an object literal (including KO's own "attr"), we need to generate property writers ourselves.
…n-observable model properties.

This commit expands on the existing logic of finding and invoking fieldAccessors. But if no fieldAccessor is found, or it is not observable, then look for a suitable property writer instead.
…in arrays, and also subscribe to changes in a knockout-es5 tracked array property.

The trickiest part of this commit is setting up the subscription with change tracking. When the array comes from a knockout-es5 "tracked" model, it is actually being read from an ES5 property getter which reads from an internal observableArray. But we don't have access to that underlying observableArray here, so we can't call `array.subscribe(..., "arrayChange")`.

Instead, we take advantage of Knockout's dependency tracking and set up a `ko.computed` which reads the array from `valueAccessor()`. This sets up a dependency such that the body of our `computed` will be invoked any time the array changes. This works regardless of whether the model uses an actual observableArray or a ko-es5 tracked array property.

Since we can no longer reliably subscribe to the "arrayChange" notification to get the diffs, we need to take on a bit of that responsibility ourselves (basically doing the same as what KO would do if we did subscribe).  We keep track of the previous array contents, and then use `ko.utils.compareArrays` to get the differences.

(I had to move the biniding's `init` logic into the `InitializedForEach` constructor so that the `ko.computed` could use the `valueAccessor()`)
   * Added knockout-es5 package - This is only required for test and some examples, but is entirely optional when using knockout-pre-render.
   * Added tests against plain JS models.
   * Added tests against knockout-es5 tracked models
   * Example that populates a plain JS model.
   * Example that populates a knockout-es5 model
@coveralls
Copy link

coveralls commented Jun 22, 2018

Coverage Status

Coverage increased (+0.7%) to 94.318% when pulling 1ef7134 on revengineering:POJO-support into 49c7e94 on ErikSchierboom:master.

   * If the incoming data is observable, subscribe to its changes the normal (simpler) way.
   * If it's *not* observable, then use the "computed" approach with explicit change tracking.

This avoids redundant change tracking in the case the observableArray has multiple subscribers to the 'arrayChange' notification.
Copy link
Owner

@ErikSchierboom ErikSchierboom left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall I think this is brilliant improvement. I've left some feedback.

// Knockout automatically generates "_ko_property_writers" for a subset of its
// built-in bindings, registered as "_twoWayBindings". Here, we can compel it to
// do the same for the rest of the bindings that we want to support with "init".
ko.utils.extend(ko.expressionRewriting._twoWayBindings, {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How safe it is to use this _twoWayBindings property? To me, the underscore seems to imply that it might be a private field that is not intended to be used by third parties. Is my view incorrect?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From src/binding/expressionRewriting.js:

// For those developers who rely on _ko_property_writers in their custom bindings, we expose _twoWayBindings as an
// undocumented feature that makes it relatively easy to upgrade to KO 3.0. However, this is still not an official
// public API, and we reserve the right to remove it at any time if we create a real public property writers API.

They expose it as an undocumented feature, for people making their own bindings. So although it's not guaranteed as future-proof, I don't see it as totally off limits. The only side-effect this has on knockout's own internal bindings is to generate _ko_property_writers for them.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this troubles you, I can rework it to generate pre-processors for those bindings, which will generate custom _ko_prerendered_initPropertyWriters, the same way that is being done for the init and attr bindings. But this seems redundant to what KO's own preprocessor already does for these bindings, so I figured it was simpler to leverage that.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aha, that explanation makes it look like it is fine to use _twoWayBindings.

var writers = [];
ko.utils.arrayForEach(expressions, function (expression) {
if (expression != null) {
var writableExpression = getWritableValue(("unknown" in expression) ? expression["unknown"] : expression.value);
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can lose the parentheses in ("unknown" in expression).

var writableExpression = getWritableValue(("unknown" in expression) ? expression["unknown"] : expression.value);
if (writableExpression) {
writers.push(
"'" + (("unknown" in expression) ? defaultWriterName : expression.key)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does this statement do exactly?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It has to do with what ko.utils.parseObjectLiteral returns for different binding syntax.

For example, if your init binding looks like init: { field: myField, value: 123 }, then parseObjectLiteral will return an array of objects like:

[
    {
       key: 'field', 
       value: 'myField' 
    },
   {
       key: 'value', 
       value: '123' 
    },
]

But if the binding looks like init: myField, then parseObjectLiteral returns:

[
   { unknown: 'myField' }
]

The code on line 161 checks if the object contains a property named unknown, and if so, uses the default name passed into the function. Otherwise, it uses the .key property.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, thanks for the reply.

var useRawData = false; // If true, the binding received an array, rather than an object with a "data" property.
var spec = valueAccessor();
if (!isPlainObject(spec)) {
useRawData = (ko.unwrap(context.$rawData) === spec);
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can lose the wrapping parentheses: ko.unwrap(context.$rawData) === spec

if (ko.isObservable(this.data)) {
if (!this.data.indexOf) {
// Make sure the observable is trackable.
this.data = this.data.extend({trackArrayChanges: true});
}
this.changeSubs = this.data.subscribe(this.onArrayChange, this, 'arrayChange');
}
else {
// If not observable, use a ko.computed to as a means of subscribing to array changes via
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to as a means -> as a means

// Look for property writers for explicit fields in the init binding.
var initPropertyWriters = allBindings.get('_ko_prerender_initPropertyWriters') || {};
// Look for property writers from knockout's built-in two-way bindings
var propertyWriters = allBindings.get("_ko_property_writers") || {};
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also looks like a private field. See my previous question.

Copy link
Contributor Author

@revengineering revengineering Jun 22, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As with the use of _twoWayBindings, this seems to be an undocumented feature that is nonetheless exposed by Knockout, for writers of custom bindings.

Again, from src/binding/expressionRewriting.js:

// For those developers who rely on _ko_property_writers in their custom bindings, we expose _twoWayBindings as an
// undocumented feature that makes it relatively easy to upgrade to KO 3.0. However, this is still not an official
// public API, and we reserve the right to remove it at any time if we create a real public property writers API.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay. The comment implies that _twoWayBindings will be the preferred way forward. Is it possible to use _twoWayBindings here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_twoWayBindings is just a flag (or rather, a collection of flags) instructing Knockout to generate property writer functions for the specified bindings. In other words, if you set _twoWayBindings.blah = true, then knockout will generate a _ko_property_writers entry for your blah binding. But this really only works for simple value bindings (something: value) and not complex bindings (somthing: { bunch of values... }). This is why we can't use it for the attr binding, and have to make our own preprocessor, despite it being a knockout built-in binding.

Copy link
Contributor Author

@revengineering revengineering Jun 22, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In other words, if you are writing a simple binding that looks like data-bind="blah: someField", and you want your binding to be able to write to a non-observable field in the model, then you can set ko.expressionRewriting._twoWayBindings['blah'] = true;. When you do that, the Knockout binding preprocessor will generate an entry in _ko_property_writers that looks like: 'blah': function(_z){someField=_z}.

And then your binding can write to someField even if it's not observable, by doing the following:

var writer = allBindings.get("_ko_property_writers")['blah'];
writer(value);

This is all that _twoWayBindings does. It tells knockout to generate property writers.

Several of Knockout's built-in bindings (e.g. textInput) are already registered in _twoWayBindings by default, because they are meant to be bound to user input.

It doesn't do so for text or html bindings because those are not typically editable by the user, so they don't need to be "writable".

However, when using the init binding, we do want to write to those properties, giving them values extracted from the markup.

This is why on Line 127, I'm marking the rest of those built-in bindings as _twoWayBindings:

  ko.utils.extend(ko.expressionRewriting._twoWayBindings, {
    'text': true,
    'html': true,
    'visible': true,
    'enable': true,
    'disable': true
  });

Doing so ensures that there will be _ko_property_writers generated for them as well, and saves us the trouble of having to make a preprocessor for all of them.


// Find the field accessor. If the init binding does not point to an observable
// or the field parameter doesn't, we try the text and value binding
var fieldAccessor = (!value && 'field' in initPropertyWriters) ? initPropertyWriters['field']
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can lose the parentheses.

// or the field parameter doesn't, we try the text and value binding
var fieldAccessor = (!value && 'field' in initPropertyWriters) ? initPropertyWriters['field']
: ko.isObservable(value) ? value
: (isPlainObject(value) && 'field' in value) ?
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can lose the parentheses.

var fieldAccessor = (!value && 'field' in initPropertyWriters) ? initPropertyWriters['field']
: ko.isObservable(value) ? value
: (isPlainObject(value) && 'field' in value) ?
(ko.isObservable(value['field']) ? value['field']
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can lose the parentheses.

}
}
};

generatePropertyWritersForBinding("init", "_ko_prerender_initPropertyWriters", "field", function (expression) {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also looks like a private field. See my previous question.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_ko_prerender_initPropertyWriters is a custom name, meant to be internal to knockout-pre-render. Not internal to Knockout itself.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah okay, thanks for explaining.

@revengineering
Copy link
Contributor Author

I'll make the stylistic changes requested.

   * Removed unnecessary parens around some boolean expressions.
   * Fixed an error in a comment.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants