Pauan / rust-dominator

Zero-cost ultra-high-performance declarative DOM library using FRP signals for Rust!
MIT License
967 stars 62 forks source link

Reusable components #2

Closed davidhewitt closed 6 years ago

davidhewitt commented 6 years ago

Maybe it's too early to start talking this; I have been wondering about it a bit so thought I'd ask:

Do you have any feeling for how you would want to componentise dominator (if you would want to componentise πŸ€”)? I guess Surplus.js is prior art on how something like that could work.

At the moment I can see functions like

fn number_in_div<N: Signal<i32>, OnClick: Fn()> (number: N, callback: OnClick) -> Dom {
   // ... use html! to make a div displaying a number which changes with N
}

being an opportunity for re-use, and maybe that's fine. I'm just used to the React / Vue way of thinking where things are a bit more prescribed. E.g. some kind of trait or other way to express "this is a component" might be cool:

struct NumberDiv<N: Signal, OnClick: Fn()> {
   number: N,
   callback: Fn()
}

impl Component for NumberDiv { /* No idea what would be here, if anything */ }

// Maybe could fit in html! like so:
html!(NumberDiv { number: N, callback: ... }, {
  class(&some_class, true); // apply a class to the Dom of this specific NumberDiv, is that desirable?
});

Thanks for hearing out my rambling πŸ˜„

Pauan commented 6 years ago

I think now is a good time to start discussing this, since I don't want to lock down a bad design and then have pain trying to change it later.

I hadn't heard of Surplus before (thanks for mentioning it!), but I have worked on other FRP systems before in JavaScript, which is the basis for my work on rust-dominator.

I have given some thought to this, but there's many different ways of handling components, with different trade-offs.

So here's (to my knowledge) all of the various ways of handling components:

Then within each category there's different ways to implement it.

You can also add in Queues at various places, which I've been seriously considering.

There's also even weirder things like Cycle.js (which is very cool).

No matter what approach you choose, you have further trade-offs to make in Rust (because of Rust's memory system). Trying to make it flexible, powerful, easy, and fast is very hard.

davidhewitt commented 6 years ago

Nice way of breaking down the types of components!

Cycle.js is cool but just a bit too weird for my taste (though of course if you wanna go that way don't let me stop you πŸ˜›)

I think the biggest question in my mind is how to construct the state (either bundled or separate). Should it be a single large struct within one signal? I think for more granular updates a struct with many signals contained inside it is better, but that's more awkward to construct (probably would end up wanting to build such things with macros).

I guess internal / bundled might lend itself to better ergonomics in Rust, because anything external is presumably going to force us the way of Rc's (and so I guess lots of clone() calls!)

I'd be interested to hear a bit more about what you're thinking with Queues too, I've not used such an approach before.

I might start trying to do some kind of external "store" thing a la Elm / Redux style, mostly just to see what the ergonomics of that work out like, and see if I can build some kind of composability around that. πŸ€·β€β™‚οΈ

Pauan commented 6 years ago

I'm not sure if I want to create a One True Way to manage state, because in my experience there's many trade-offs, and I want the programmer to choose the trade-off.

Having a single large struct within one Signal is similar to the Elm model, and it has some very nice benefits. The biggest downside is performance, since even a tiny change needs to propagate down the entire app. This is probably still faster than Elm, because it's essentially doing a state diff, not a vdom diff.

My preferred approach is to have a single State struct where the individual fields are Signals. This bundles up all the state in a convenient way, but still lets each field update individually. But I'm still working out how to make that easy to work with in Rust. It's really hard to avoid cloning with Rust's memory model.

When I mentioned Queues, what I meant is basically something very similar to Elm, but with Signals + Queues (and making them internal to the component):

fn make_some_component() -> Dom {
    struct State {
        value: Sender<u32>,
    }

    enum Message {
        Increment,
        Decrement,
        Reset { value: u32 },
    }

    let (value, value_signal) = unsync::mutable(0);

    let state_queue = make_queue(
        // The initial state.
        State {
            value: value,
        },

        // Function which is called for each message. It's supposed to update the state.
        |state, message| {
            match message {
                Message::Increment => {
                    state.value.set(state.value.get() + 1);
                },
                Message::Decrement => {
                    state.value.set(state.value.get() - 1);
                },
                Message::Reset { value } => {
                    state.value.set(value);
                },
            }
        }
    );

    Dom::with_state(state_queue, |state_queue| {
        html!("div", {
            children(&mut [
                html!("button", {
                    event(clone!(state_queue, |event: ClickEvent| {
                        state_queue.push(Message::Increment);
                    }));
                    children(&mut [
                        text("+1")
                    ]);
                }),

                html!("button", {
                    event(clone!(state_queue, |event: ClickEvent| {
                        state_queue.push(Message::Decrement);
                    }));
                    children(&mut [
                        text("-1")
                    ]);
                }),

                html!("button", {
                    event(clone!(state_queue, |event: ClickEvent| {
                        state_queue.push(Message::Reset { value: 0 });
                    }));
                    children(&mut [
                        text("Reset")
                    ]);
                }),

                text(value_signal.map(|x| x.to_string()).dynamic())
            ]);
        })
    })
}

Warning: the above code is completely untested, but it should give the right idea.

The benefit of the above approach is that it completely decouples the state from the things that update the state (like event listeners). All of the state updates are in a single place. Event listeners don't need to know anything about the state: they just push things into the queue.

Composability can be achieved by having the make_some_component accept a Queue as an argument, and then it can push messages into that Queue:

fn make_some_component(parent_queue: Queue<SomeParentMessage>) -> Dom {
    ...

    parent_queue.push(SomeParentMessage::Foo { ... });

    ...
}

This is a lot cleaner than passing in callbacks.

davidhewitt commented 6 years ago

I'm not sure if I want to create a One True Way to manage state, because in my experience there's many trade-offs, and I want the programmer to choose the trade-off.

Agreed. In my experience the React way is a easy to build small applications but if often hurts at larger scale, where the Redux way makes things easier to reason about. It would be nice if dominator was able to support multiple paradigms (either by providing primitives to build both, or simply being flexible enough to allow the user to do it, maybe with a separate helper crate).

I really like your queue design. I haven't come across make_queue, is that something you just typed there for example rather than something that already exists?

Though I'm not sure what I think of passing the parent queue into some composable component; that feels to me like it breaks encapsulation (because the child has to know about the form of the parent). I guess Redux-connected components are similarly coupled to the app's store. To me a reusable component would use callbacks (or emit events) and defer to the parent to map those into messages dispatched on the queue.

Pauan commented 6 years ago

I really like your queue design. I haven't come across make_queue, is that something you just typed there for example rather than something that already exists?

It's just an example, I haven't built the Queue type yet. I'm still trying to figure out a good design for Signals, since they're the foundation for everything else.

Though I'm not sure what I think of passing the parent queue into some composable component; that feels to me like it breaks encapsulation (because the child has to know about the form of the parent). I guess Redux-connected components are similarly coupled to the app's store. To me a reusable component would use callbacks (or emit events) and defer to the parent to map those into messages dispatched on the queue.

Generally speaking the child component would define and export the message type, and the parent would create the queue and pass the queue to the child.

The parent would then map the child component's messages back into its internal state, like this:

mod child {
    // Public message type, this is what is sent to the parent's queue.
    pub enum Message {
        Foo { ... },
        Bar { ... },
    }

    // Make the component
    pub fn make_component(output_queue: Queue<Message>) -> Dom {
        // Internal private messages for this component, it isn't exposed to anyone.
        enum InternalMessage {
            ...
        }

        // Internal private state for this component, it isn't exposed to anyone.
        struct State {
            ...
        }

        // This is an internal queue which is not exposed to anyone.
        let internal_queue = make_queue(
            State { ... },

            // Function which is called for each internal message.
            |state, message| {
                // Update the internal state in here.
            }
        );

        ...

        // Push a message to the parent
        output_queue.push(Message::Foo { ... });

        ...
    }
}

mod parent {
    use child;

    fn make_component() -> Dom {
        // Internal private messages for this component, it isn't exposed to anyone.
        enum InternalMessage {
            ...
        }

        // Internal private state for this component, it isn't exposed to anyone.
        struct State {
            ...
        }

        let state = Rc::new(State { ... });

        // This is the queue for the child component.
        let child_queue = make_queue(
            state.clone(),

            // Function which is called for each message sent from the child component.
            |state, message| {
                match message {
                    child::Message::Foo { ... } => {
                        // Update the internal state in here.
                    },
                    child::Message::Bar { ... } => {
                        // Update the internal state in here.
                    },
                }
            }
        );

        // This is an internal queue which is not exposed to anyone.
        let internal_queue = make_queue(
            state,

            // Function which is called for each internal message.
            |state, message| {
                // Update the internal state in here.
            }
        );

        ...

        child::make_component(child_queue)

        ...
    }
}

So the parent and child are decoupled, neither knows about the internal state of the other, they communicate entirely through public Messages in a Queue.

But other patterns are possible too. I haven't deeply thought this through, so there's still a lot of unresolved questions and potential problems.

davidhewitt commented 6 years ago

Generally speaking the child component would define and export the message type, and the parent would create the queue and pass the queue to the child.

Ah yep this sounds great to me πŸ˜ƒ. I look forward to seeing how this evolves; I'll post any interesting experiments I do in the meanwhile.

Pauan commented 6 years ago

I've given quite a lot of thought to this, and I think these are the fundamental benefits of components:

  1. They allow a DOM node to contain and manage internal state related to that DOM node.

    In other words, they provide state encapsulation.

  2. They allow for bundling commonly-used DOM operations into a reusable library.

  3. They allow for wrapping the clunky DOM APIs and providing a nicer/cleaner API instead.

  4. They allow for being notified when a component is inserted/removed from the DOM.

  5. They allow you to wrap a DOM component from a different framework (e.g. embedding a jQuery/React/Angular/whatever component into the DOM system).

The above use-cases are obviously useful, but I don't think components are the right way to accomplish it. Components feel a lot like OOP class-based inheritance: fragile, restrictive, trying to combine too many features into one, difficult to compose, etc.

Instead, I have added three features that should solve the above use cases in a much faster, cleaner, and more composable way:

  1. You can use Dom::with_state to transfer ownership of some data into a Dom node. When the Dom node is removed, it will automatically drop the data:

    Dom::with_state(my_data, |my_data| {
       html!("div", {
           // ...
       })
    })

    In this case it grabs ownership of my_data, and then it immediately calls the closure with a mutable reference to my_data. That closure must return a Dom. It then transfers the ownership of my_data into the Dom and returns the Dom.

    The reason for the closure is so that you can use my_data when constructing the Dom, even though ownership has been transferred.

    This solves use case 1: state encapsulation.

  2. You can use the after_inserted and after_removed methods, which runs a closure when the Dom is inserted/removed:

    html!("div", {
       after_inserted(|node| {
           println!("Dom inserted!");
       });
    
       after_removed(|node| {
           println!("Dom removed!");
       });
    })

    In the above case, it will call the closures when the Dom is inserted/removed. The closures are passed a single argument: the underlying DOM node (a real DOM node, not the Dom wrapper).

    This solves use cases 4 and 5.

  3. You can create reusable mixins. There is a Mixin trait which you can implement, or you can simply use Fn (which implements Mixin). A Mixin can use all of the same methods that html! uses.

    Here is an example using Fn:

    #[inline]
    fn my_mixin<A: IHtmlElement>(dom: DomBuilder<A>) -> DomBuilder<A> {
       dom.style("background-color", "white")
          .style("width", "50px")
    }

    And here's an example using Mixin:

    struct MyMixin;
    
    impl<A: IHtmlElement> Mixin<A> for MyMixin {
       #[inline]
       fn apply(&self, dom: DomBuilder<A>) -> DomBuilder<A> {
           dom.style("background-color", "black")
              .style("width", "20px")
       }
    }

    In the above example MyMixin doesn't contain any state, but of course you can change it to store whatever state you want.

    Now that you've created a Mixin, you can apply it by using the mixin method:

    html!("div", {
       mixin(my_mixin);
       mixin(MyMixin);
    })

    As you can see, you can call the mixin method as much as you want. How it works is that it applies the Mixins in the same order that you call the mixin method, so in the above example if there is a conflict then MyMixin will overwrite my_mixin.

    Because you can call mixin multiple times, it's trivial to compose mixins. And there is zero performance cost: everything is inlined, so the above example is exactly the same as this:

    html!("div", {
       style("background-color", "white");
       style("width", "50px");
       style("background-color", "black");
       style("width", "20px");
    })

    This solves use cases 2 and 3. If you want to provide a clean API for commonly used DOM functionality, you wouldn't create a component, instead you would create a mixin.

    This has a lot of benefits over components: they're faster, they're simpler, they naturally compose, and they don't provide any encapsulation.

    Why is it a benefit to not provide encapsulation? Let's imagine that we want to use a component which provides drag-and-drop functionality. Because components are encapsulated, the only way to do this is to wrap the component around our code. Using React as an example:

    <DragAndDrop onDragStart={...} onDrag={...} onDragEnd={...}>
       <MyComponentGoesHere />
    </DragAndDrop>

    We want to add drag-and-drop functionality to MyComponentGoesHere, but we have to wrap it in the DragAndDrop component.

    In addition to being a bit annoying, there's a serious problem: in order for the DragAndDrop component to work, it has to create a new DOM element (probably a <div>) and then attach event listeners to that <div>. But because our MyComponentGoesHere is now wrapped in an extra <div>, that can screw up the CSS styling.

    And what if you want to provide some CSS styling for the <div> which is created by the DragAndDrop component? In that case DragAndDrop would need to accept a style prop which allows for customizing the CSS styles. This can become quite complicated if the component creates many DOM nodes, because it needs to provide the ability to customize the styles for all of those DOM nodes.

    Basically, in this situation we don't actually want encapsulation. We are trying to add new functionality to an already existing component (in this case MyComponentGoesHere), so it doesn't really make sense to use a DragAndDrop component for this.

    On the other hand, mixins work out perfectly: you would use a drag_and_drop mixin which can be used to add drag-and-drop functionality to existing elements, and best of all you can combine multiple mixins onto the same element.

By combining these three things together, you can achieve the same use-cases as components. But unlike components, the above features are much faster, they are much simpler to understand and use, and you can mix-and-match them as appropriate.

Unlike components which force encapsulation, you can choose what level of encapsulation you want: full state encapsulation for an entire component, state encapsulation for only one mixin, no state encapsulation, etc.

I predict that most components would be better if they were created as mixins instead. And for the few places where it makes sense to use components, you can easily create them by combining the above three features.

If somebody wants to create a Component trait, or wants to experiment with queues (or other component patterns), I think that's great, but since there isn't a single clear "winner", I think it's better for that experimentation to happen in another crate. After enough real-world experience I'll consider integrating one of those solutions into dominator.

As such, I consider this issue mostly solved.

davidhewitt commented 6 years ago

Thanks, very thorough discussion! I will take some time to play with those concepts and get back to you if I have further thoughts πŸ˜€