Mathias Mamsch
Mathias Mamsch

Reputation: 343

Component communication in Knockout.JS

I am desiging a "medium" sized application in KnockoutJS and I am wondering how to send events between components.

Imagined a nested component hierarchy in KnockoutJS:

Root Viewmodel -> A -> B -> C
               -> D

How can D react to a message from C? The obvious knockoutJS approach would be to have C write to an observable passed as a parameter, and share this observable with D which reacts to changes to this observable.

What I do not like about this approach is, that A and B need to know about the message, and A and B actively forward the handler via its parameters. With a normal dependency Inject approach I could wire up the components C and D directly with each other, e.g. by injecting D into C without A and B knowing.

So my question:

or rephrased:

ko.components.register("aaa", {
    viewModel: function (params) { this.handler = params.handler;  },
    template: "<span>A</span> <bbb params='handler: handler'></bbb>"
});

ko.components.register("bbb", {
    viewModel: function (params) { this.handler = params.handler;  },
    template: "<span>B</span> <ccc params='handler: handler'></ccc>"
});

ko.components.register("ccc", {
    viewModel: function (params) { this.handler = params.handler;  },
    template: "<span>C</span> <button data-bind='click: handler'>OK</button>"
});

ko.components.register("ddd", {
    viewModel: function (params) { 
        var self = this; 
        this.text = ko.observable("No event received!"); 
        if (params.onClick)     params.onClick.subscribe(function () {
            self.text("Event Received!");
        });
    },
    template: "<span>D</span> <span data-bind='text:text'/>"
});


ko.applyBindings({
    onClick: ko.observable()
});
<script src="https://cdnjs.cloudflare.com/ajax/libs/knockout/3.4.2/knockout-min.js"></script>

<p><aaa params='handler: onClick'> </aaa></p>
<p><ddd params='onClick: onClick'> </ddd></p>

Upvotes: 3

Views: 2101

Answers (4)

Chris Knoll
Chris Knoll

Reputation: 411

What I've done is when D is created by C, C passes a parameter as a reference to D, and D assigns itself to this parameter. The param can be an observable, and the onDispose of D can clear the observable so that C knows if D was disposed.

Upvotes: 0

user3297291
user3297291

Reputation: 23382

Is there a way to manually wire up the components inside the root viewmodel (e.g. by intercepting component creation)?

(emphasis mine)

The knockout mechanism

You can inject custom component loaders by adding an object to the ko.components.loaders array with any combination of the methods getConfig, loadComponent, loadTemplate, and loadViewModel.

Since you're looking to create a connection between view models, this is the only method we need to define. From the docs:

loadViewModel(name, viewModelConfig, callback)

The viewModelConfig value is simply the viewModel property from any componentConfig object. For example, it may be a constructor function, ...

The approach

Your components are defined referencing a constructor function directly. We're going to wrap this constructor in a factory function that replaces the params passed to the component, by a joined sharedParams object that holds everything that's passed to any component up the chain. Whether this is "safe" enough is up to you. It shouldn't be too hard to come up with another way of connecting aaa and ddd once you got the custom loader in place.

In short, our custom loader will:

  1. Retrieve the original constructor for the component's viewmodel (VM)
  2. Dynamically create a factory function that:
    • Adds the passed params to the binding context
    • Construct the VM instance with the shared params
    • returns the new viewmodel
  3. Call the newly created factory function

In code:

ko.components.loaders.unshift({
  loadViewModel: function loadViewModel(name, VM, callback) {
    const factory = function (params, componentInfo) {
      const bc = ko.contextFor(componentInfo.element);

      // Overwrite sharedParams
      bc.sharedParams = Object.assign(
        { },
        bc.sharedParams || {},
        params
      );

      return new VM(bc.sharedParams);
    };

    return callback(factory);
  }
});

Running example

Check out this linked fiddle to play with the custom loader yourself. I've included several nested component structures to show there's no longer the need to pass the params manually.

https://jsfiddle.net/sqLathu9/

Upvotes: 1

Marcelo A
Marcelo A

Reputation: 5241

In that case of scenarios I think it's better to use EventEmitter https://github.com/Olical/EventEmitter

This way you can create a global ee instance of EventEmitter for example in a script on your main html

window.ee = new EventEmitter()

then you can add listeners or trigger actions between components:

as for your example from component C you can trigger an event with: window.ee.emitEvent('some-name-for-the-event', someArgumentsArray)

and in your D component just add a listener for that event to react accordingly like window.ee.addListener('some-name-for-the-event', function (params) { // your own code to handle the event });

Upvotes: 0

user3297291
user3297291

Reputation: 23382

A solution that I personally find quite ugly, but can be considered a valid answer to this question:

You can use $root, $parents[i] or $parent to refer to any layer in the binding context of a component. I removed some of the params from your example to show you how this would work. It shows two flavors:

  1. Get a dependency from the bindingContext inside the params attribute (preferred)
  2. Access the bindingContext inside the component template (not recommended)

ko.components.register("aaa", {
    viewModel: function (params) {  },
    template: "<span>A</span> <bbb></bbb>"
});

ko.components.register("bbb", {
    viewModel: function (params) {  },
    template: "<span>B</span> <ccc></ccc>"
});

ko.components.register("ccc", {
    viewModel: function (params) { },
    // This hides the dependency in the template. It's better to pass it in the DOM via `params`
    template: "<span>C</span> <button data-bind='click: $root.onClick'>OK</button>"
});

ko.components.register("ddd", {
    viewModel: function (params) { 
        var self = this; 
        this.text = ko.observable("No event received!"); 
        if (params.onClick)     params.onClick.subscribe(function () {
            self.text("Event Received!");
        });
    },
    template: "<span>D</span> <span data-bind='text:text'/>"
});


ko.applyBindings({
    onClick: ko.observable()
});
<script src="https://cdnjs.cloudflare.com/ajax/libs/knockout/3.4.2/knockout-min.js"></script>

<p><aaa> </aaa></p>
<!-- This approach isn't *that* ugly, since the dependency is made explicit in the DOM -->
<p><ddd params='onClick: $root.onClick'> </ddd></p>

The weakness here is that you create a fixed dependency on the DOM structure that is hard to document/check. I usually start running in to bugs as soon as I use $parent...

Upvotes: 0

Related Questions