Open leeoniya opened 8 years ago
Closures are objects of a sort, just a different flavor. http://c2.com/cgi/wiki?ClosuresAndObjectsAreEquivalent. So components are instanced, they just aren't defined in the typical way using JS constructors, prototypes, and this.
But you do have some valid ideas here and have hit in a rough area in domchanger. The instances of the components are only exposed internally to the virtual dom tree.
Maybe we can add a new input type that represents an instanced component. Maybe any object containing a function property named "render"? Then you could use whatever object system you want to create the objects and pass in the instances instead of the function at the first json-ml element.
Re: 2,3: You can leave users to create regular vanilla instances (or objects) and just declare an interface they must expose.
some ideas, probably lots of things that dont quite make sense, but...:
function IDE(foo, bar, subCompA, subCompB) {
this.foo = foo;
this.bar = bar;
this.subCompA = subCompA;
this.subCompB = subCompB;
this.optA = 'hello';
this.optB = 'world';
}
IDE.prototype = {
init: function(refresh, emit, refs) {
},
render: function() {
return [
['div', this.foo],
['div', this.bar],
this.subCompA,
[subCompB, this.optA, this.optB],
]
},
doSomething: function() {
}
}
var myIDE = new IDE('lazy', 'dog', ...);
domChanger(myIDE, document.body);
yes, something like this, I'll look it over later.
nice.
another idea is for {refresh, emit, refs}
to be tagged onto the component's view
property, which would start out null/absent, but populate on initial render();, this would then allow the user to call this.view.refresh()
from anywhere, even external to the component myThing.view.refresh()
. and just scap the whole init:
.
so...
function IDE(foo, bar, subCompA, subCompB) {
this.foo = foo;
this.bar = bar;
this.subCompA = subCompA;
this.subCompB = subCompB;
this.optA = 'hello';
this.optB = 'world';
this.selStart = 4;
this.selEnd = 10;
}
IDE.prototype = {
on: {}, // handlers for emitted events
view: null, // will contain {refresh, emit, refs} after initial render
render: function() {
var setSelection = function(el, isInitial) {
isInitial && el.setSelectionRange(this.selStart, this.selEnd);
}.bind(this);
return [
['div', this.foo],
['div', this.bar],
this.subCompA,
[subCompB, this.optA, this.optB],
['textarea.some-area', "sometext", setSelection] // provided fn is a per-element afterRefresh callback
];
},
doSomething: function() {
// something....
this.view.refresh();
}
}
var myIDE = new IDE('lazy', 'dog', ...);
domChanger(myIDE, document.body);
myIDE.doSomething();
This construction opens a lot of doors and offers a ton of flexibility, leaving the rendering aspects almost entirely decoupled and swappable from the rest of the app logic.
i've implemented most of the stuff proposed above in new branch [1]. check out the examples/ide.html
.
still todo:
isInitial
passing to component-level afterRefresh()
afterRefresh(isInitial)
(in the JSON-ML tree)this
for all ops to object/component instance['div#foo$myRef']
-> ['div#foo', {ref: "myRef"}]
[MyComponentFn, param1, param2]
. or maybe not, need to discuss if this is still a useful pattern..refresh()
such as deferred rendering, 2-way data binding, array & object observation (similar to Mithril's m.withAttr
, m.prop()
, etc...)String(number)
-> ""+number
[1] https://github.com/leeoniya/domchanger/tree/rework-components
It seems that you're building a different framework than I originally envisioned for domchanger, but still want to be able to use the dom diff algorithm. I was thinking of a system that had strict one-way data channels where events would bubble up only and data would travel down only. It appears you're looking for more of a traditional system with 2-way bindings, methods on component objects, etc.
Would it make sense to split out the core diff algorithm into it's own module? I don't really want to change the direction of domchanger, but I also want to enable you to build the system you envision.
TBH, i'm not sure it would be worth it since 90% of domChanger is the diff strategy.
The changes i'm making (and in the todo) do not increase the size and in fact can retain 100% of the current functionality while also remaining flexible enough to do these things (i've removed very little but none of it was necessary to implement the features). I like a lot of things about Mithril except how it tries to awkwardly shoehorn some MVC/controlller concepts that dont quite make sense and has a redraw system that's a bit too magical at times.
IMO, the current component strategy of domChanger makes it a very niche and monolithic commitment. If you feel like the changes i'm proposing are too far from your original intent, I promise not to be offended and will rename my fork and maintain it separately :)
I will note that I'm not making these changes for the sake of making changes, I'm exposing functionality/sugar which I'm finding necessary for a pleasant DRY/SRP/API experience while writing an app (personal opinion of course).
btw, the entire code that mimics Mithril's m.prop()
and m.withAttr()
(aside from deferred stuff and edge cases/old browsers) is defined externally like this:
domChanger.prop = function(initVal) {
var curVal = initVal;
return function(newVal, noRefresh) {
if (!arguments.length)
return curVal;
else if (newVal !== curVal) {
curVal = newVal;
!noRefresh && this.view.refresh(); // todo: rAF-debounce?
}
}.bind(this);
};
domChanger.sync = function(elProp, objProp, noRefresh) {
return function(e) {
var ep = e.target[elProp],
op = this[objProp];
if (typeof op == "function")
op(ep, noRefresh);
else if (op !== ep) {
this[objProp] = ep;
!noRefresh && this.view.refresh(); // todo: rAF-debounce?
}
}.bind(this);
};
var sync = domChanger.sync;
var prop = domChanger.prop;
and requires a 3-liner core addition to make work.
Hi again :)
In my ongoing quest for component and view-data independence, I've tried to wrap the unmodified domChanger
to serve as the decoupled view layer in a structure of independently instantiated and assembled components. What resulted is pretty good - component-wrapped views with all data required by render()
being closured, allowing render()
to accept 0 params, eliminating the need for update()
, externally refreshable components, support for external data modification and retaining the ability for each component to expose its own API.
Here's the code: https://jsfiddle.net/gmkhzxba/1/
Some notes:
key
undefined
Function.name
in domChanger
's internal component naming strategy.refresh()
, despite the console.log()
from within the render()
functions showing that the generated JSON-ML is correct. If you open up the console in the example and look at the output, the header text is modified and rendered correctly, but the array is modified and rendered weird.plz lemme know what you think (especially about point 2, hopefully it's an easy fix), thanks!
Re: 1, I've added a function that does some of the boilerplate: https://jsfiddle.net/gmkhzxba/4/
Unfortunately, it turns out that Function.name
is read-only, so I've had to add an alternate .fnName
property that can be tagged onto anon functions (and modify domChanger
slightly [1] to be aware of it). This allows anon functions to be view generators without screwing up the internal component naming strategy.
EDIT: updated the helper to implicitly bind the provided render
and any on
handlers to the wrapping component, eliminating the need to alias var self = this;
each time [2]
[1] https://github.com/leeoniya/domchanger/commit/4c7e3aa8dbff233581897b0c609d0afa4a6f4cdf [2] https://jsfiddle.net/gmkhzxba/8/
Another update, Re: 2,
I realized from the filtered list example that subcomponents must be tagged with a unique key to be re-rendered properly. This key is initialized by the wrapping component and tagged onto the array of [componentFn, arg1...]
[1].
I wanted the unique key initialized not in the parent component. What I did was add a small change to domChanger
[2] that would allow tagging the .key
property to the component constructor directly so it did not need to be handled in the parent. This fixed the diffing [3].
I'm submitting a pull request for the anon-components
branch, which has only minimal changes to the lib while enabling full component/data separation with no breaking changes.
[1] https://github.com/creationix/domchanger/blob/master/examples/filterable-product-table.js#L78 [2] https://github.com/leeoniya/domchanger/commit/499847ec432b7c06cdeb14875c0f9e66426c79b3 [3] https://jsfiddle.net/gmkhzxba/10/
Sorry, I'm super busy this week (working till about 1:30AM every day). Maybe I'll have a chance to look at this in depth later. Feel free to ping me in a few days.
At work, we use a component-based framework called Nagare and written in python, so I can give some insights on how it works and how it translates to javascript, it could inspire this discussion about the future domchanger API.
I share the opinion that domchanger should let the users do what they want with the constructor. It allows users to inject services, configuration parameters, state data, persistence layer into the objects that will be rendered. However, I don't really like the API proposed in the last comments: in my opinion, we must not force users to create View classes when a regular function is probably enough. It's up to the users to create classes if they want to.
In Nagare, the views are defined completely outside the components objects. The components are just pure python objects with business-related data and methods, and the views are added afterwards on the class, like a mixin. Basically, it would translate to that in Javascript:
// define a pure javascript class
function Counter(value) {
this.value = value || 0;
}
Counter.prototype.increment = function() {
this.value += 1;
};
// attach a render function on the Counter class: the default view
render_for(Counter, function(component) {
function onclick() {
this.increment();
component.emit('incremented', this);
}
return [
component.render('readonly'),
["a.increment", { onclick: onclick }]
];
});
// attach another render function on the Counter class: the "readonly" view
render_for(Counter, 'readonly', function(/*component*/) {
return ["div.counter", this.value];
});
We attach render functions to a class with an optional view name. In javascript, we should probably also allow attaching render functions to objects directly.
The rendering function receives a component
object. With this object, we can emit an event that will propagate to the parent component, or we can render another view of the same component, like I've shown above.
In order to render a sub-component in Nagare, we have to wrap the object in a Component
instance and keep this instance in the object state due to technical constraints: it's quite annoying in practice. So I would do that instead:
component.render(mySubComponent, 'view_name');
To catch an event sent by a sub-component in Nagare, we set an on_answer
callback on the sub-component like this :
Component(mySubComponent).on_answer(callback)
I would translate that this way (in the render function):
component.on(mySubComponent, 'incremented', function (data) {
// ...
});
Or:
component.onEvent(mySubComponent, function (event) {
// ...
});
It depends how you want to handle the emit event: send an object or send a event type (string) plus some data.
The render functions are attached to a class, but it can walk up the class hierarchy: if you implement a subclass, the same views as the base class are available in the subclasses. There's a mechanism allowing the subclasses to extend the inherited views: if the render function has a next_method
special parameter, calling this function will render the inherited view. In javascript, views inheritance means attaching the views as attributes of the objects and/or prototypes.
Here is a full example of the proposed API:
function App(headerText, things) {
this.header = new Header(headerText);
this.thingList = new ThingList(things);
}
render_for(App, function(component) {
function onclick() {
component.emit('clicked');
};
component.on(this.thingList, 'elementAdded', function() {
// ...
});
return [
["div", { onclick: onclick }],
[component.render(this.header)],
[component.render(this.thingList)]
];
});
I'm not sure what render_for
does technically. ES5 doesn't have WeakMaps or Symbols, so I'm not sure how you would associate the function with the object.
Also I think walking up inheritance chains is outside the scope of domchanger and unclear. There is no one class system in JavaScript and people write their objects in many different styles.
I think the key is we need a way to initialize a component that is simple and flexible. I'll see if I can come up with a proposal.
So here is the problem from the renderer's point of view:
Suppose on one pass, you get the following data to render
[Foo, 1, 2, 3]
You've never seen a Foo
component at this path, so you create a new component instance.
But then on a refresh, the parameters changed:
[Foo, 1, 2, 5]
Since you have an existing component at this path, we don't want to destroy the old one and create a new one, it would better to reuse the existing component (we think). So we need to find the component we created last round and update it with the new data.
Then at a later pass the component is gone or changed to another type. In this case, we do want to destroy the old component and let it clean up any state.
So for each component, there are 4 types of lifecycle events, create
, update
, afterRender
, and destroy
.
In addition to these lifecycle events, it would be nice to pass in parameters to the component's constructor apart from the update data so that a component constructor can be used in multiple places, but have different internal properties.
We need some sort of unique and persistent identifier for a component to know when the same instance should be reused and when a new instance should be created. This is why I went with the function as the component. Also it makes parsing less ambiguous because functions at the start of a list are always components, while objects can be different things depending on it's shape.
The reason I combined constructor data with update data is so that the user doesn't have to keep track of their component instances, they just declare the type and data and we'll handle it for them.
One possible solution with the existing semantics is to have a function that returns the component function:
[MakeFoo(1, 2), 3, 4]
function MakeFoo(a, b) {
return function Foo(c, d) {
...
}
}
But as a user, when do you call MakeFoo and when do you simply pass in the resulting Foo? It's now up to the user to manage the life-cycle of their components (which may be what you want).
We could even go a step farther and allow an object to be returned instead of the current pseudo-constructor function. We would just need to define the shape and interface of this object as discussed previously. But again, moving from a function to an object moves the burden of managing lifecycles to the user and outside the library. Maybe we should simply allow both and let users decide which is appropriate for their use case?
I guess I was not clear enough. Here is an (rough) example to illustrate my point of view. Imagine I want to build an application having a menu and a main content area whose content change whenever the user click on a menu item. I would implement it like this:
function Menu(items) {
this.items = items;
this.selectedItem = items[0];
}
Menu.prototype.render = function (emit, refresh) {
// render the items
var items = this.items.map(function (item) {
var selected = (item === this.selectedItem) ? '.selected' : '';
return ['div.menu-item' + selected, {
onclick: function() {
// when an item is clicked, we change the state of the
// component, so that it highlights the clicked item
this.selectedItem = item;
// we also send an event to our parent so that it can update
// its state too
emit('itemSelected', item);
// refresh the components tree
refresh();
}
}, item];
});
// wrap the items into the menu div
return ['div.menu', items];
};
function Application() {
this.menu = new Menu(['Home', 'Presentation', 'Contact']);
this.content = new HomeContent();
}
Application.prototype.onMenuItemSelected = function (item) {
var Content = {
'Home': HomeContent,
'Presentation': PresentationContent,
'Contact': ContactContent
}[item];
this.content = new Content();
}
// the default view
Application.prototype.render = function (emit, refresh, renderChild) {
return ['div.application',
// we render the menu component
renderChild(this.menu, {
// when we receive an 'itemSelected' event from the menu
// sub-component, we update our state by swapping the content
// object
'itemSelected': this.onMenuItemSelected
}),
// then, we render the content component
renderChild(this.content);
]
};
var application = new Application();
domChanger(application, document.body);
I hope this example properly expresses what I mean. In this approach, the developer deals with the components life-cycle: instead of using factories to build the sub-components in the render function, you'd use objects, i.e. instances of user-defined classes. Then, you can pass whatever you want into the constructor of the components. Compared to your approach, it justs invert the control: the constructors are now used to pass data or services to the instances and the render functions receive the parameters to render the sub-components, refresh the component tree or emit some events that will trigger changes up in the components tree. In my approach, the application is just a tree of components (pure Javascript objects), the components change their state when an external event occur (user interaction, data arriving from a webservice or websocket, ...), then you render the whole tree and send an update to the DOM.
So, to answer your questions: render_for
just declares a view and provide the function that renders this view. In the example above, I have implemented it as a regular method of the component, but it's an implementation detail. As for the views inheritance, it's an advanced use-case rarely needed in practice (especially if you prefer composition over inheritance), that why I didn't give much details. You can remove it from the discussion ;)
split from #4
IMO, there are a couple issues with components.
render
is called as both a pseudo-constructor to inject params into the component's closure as well as a refresh re-renderer.Components are not instanced, so no reference to them exists, this prevents exposure of any public api. eg not possible:
.update()
method acts as a constructor for the entire component tree and must accept params for all its sub-components.For 1, maybe expose a
render:
and separateinit:
. For 2, creating instances will allow for an API to be exposed for each component. For 3, components can be externally created using their owninit
s and inserted into their parents via the parent'sinit
(dep injection). For 4, should be a non-issue if the others are implemented.I don't know how much of this is do-able and would bloat/complicate the framework, so consider these wishes :)