johnlindquist / vue-streams

Simple Streams for Vue
14 stars 1 forks source link

Observations about vue-streams #1

Open mistyharsh opened 6 years ago

mistyharsh commented 6 years ago

Hi @johnlindquist,

Thanks for creating vue-streams. I am following your Vue-Rx experiments as I am also investigating approaches to bring reactive extensions to Vue.js in an idiomatic way. My first approach was to harness class-based component approach but keep Cycle.js like MVI semantics:


import { Observable } from 'rxjs/Observable';
import { fromEvent } from 'rxjs/observable/fromEvent';
import { map, mapTo, merge, switchMap } from 'rxjs/operators';
import { combineLatest } from 'rxjs/operators/combineLatest';
import { filter } from 'rxjs/operators/filter';
import { scan } from 'rxjs/operators/scan';
import { startWith } from 'rxjs/operators/startWith';

import Vue from 'vue';
import { Component } from 'vue-property-decorator';

import withRender from './Layout2CC.html';

import { runIntent, Source } from 'util/intent';

// TODO: Highly experimentive code

// Intents produced
interface Actions {
    collapse$: Observable<boolean>;
    resize$: Observable<number>;
    transitionEnd$: Observable<any>;
}

// State for Layout2CC component
interface State {
    collapse: boolean;
    minWidth: number;
    tentative: number;
}

// Function to process all the inputs from users
// Similar to Cycle.js intent
// DOM allows to select DOM Events and elements as streams
// streams allows to select domStreams declared for the component
function intent({ DOM, streams }: Source) {

    const leftColumn$ = DOM.select('.layout2cc__left').element();

    // What happens when expand icon is clicked
    const expandClick$ = streams.select('expand$')
        .pipe(mapTo({ type: 'expand' }));

    // What happens when collapse icons is clicked
    const collapseClick$ = streams.select('collapse$')
        .pipe(
            switchMap((toggle: boolean) => leftColumn$
                .pipe(map((elem: HTMLElement) => ({
                    type: 'collapse',
                    payload: {
                        toggle,
                        width: elem.clientWidth
                    }
                })))
            )
        );

    const transitionEnd$ = DOM.select('.layout2cc__left')
        .events('transitionend')
        .pipe(
            filter((event: TransitionEvent) => event.propertyName === 'width'),
            mapTo({ type: 'transitionEnd'})
        );

    return expandClick$.pipe(merge(collapseClick$, transitionEnd$));
}

// Acts as a single source of truth for the component.
// All the streams should converge here
function model(actions): Observable<State> {

    return actions
        .pipe(
            scan((state: State, action: any) => {
                switch (action.type) {
                    case 'collapse':
                        return { ...state,
                            collapse: true,
                            minWidth: action.payload.width,
                            tentative: action.payload.width
                        };

                    case 'expand':
                        return { ...state,
                            collapse: false,
                            minWidth: state.tentative
                        };

                    case 'transitionEnd':
                        return { ...state,
                            minWidth: -1
                        };

                    default:
                        return state;
                }
            }, { collapse: false, minWidth: -1 })
        );
}

@withRender
@Component({
    domStreams: ['collapse$', 'expand$']
})
export class Layout2CC extends Vue {

    // Component `this` to purely act as a ViewModel for Template
    private collapse: boolean = false;
    private minWidth: number = 0;
    private style: any = {};

    private mounted() {

        const actions = runIntent(this, intent);
        const state$ = model(actions);

        // Automatic subscription management
        this.$subscribeTo(state$,
            (state) => {
                this.collapse = state.collapse;

                if (state.minWidth !== -1) {
                    this.style = {
                        'min-width': state.minWidth + 'px'
                    };
                } else {
                    this.style = {};
                }
            }
        );

    }
}

However, I am currently stuck on how to handle event from the component $emit(eventName, data). I could not find an elegant way.

I played with vue-streams. Here are my observations.

Great things:

  1. Reduced string manipulation is great.
  2. Binding event to stream is very neat: <button @click="click$">Click me</button>. Compared to vue-rx, we don't have to write domStreams and v-stream. The syntax for events using @ annotation makes it clear between what is a prop, event, and directive.

Things that can be improved/added:

  1. Subscriptions can be improved. Here, a subscription is basically a single function. This looks a bit annoying as it has a tendency to grow big. I am trying to separate pipeline manipulation from subscriptions. But no solution so far.
  2. Or better yet, avoid any sort of subscriptions; but doesn't look feasible currently. (Something like Angular async pipe | operator)
  3. Uniform reactive approach to emitting events from child components.
  4. Somehow reduce the reliance on this pointer and restrict this to purely act as a ViewModel for View Vue Template.

Please let me know how I can help or contribute in any way!!!

mistyharsh commented 6 years ago

Here is another attempt at segregation:

@Component({})
export default class QuickSearch extends Vue {

    @Prop()
    public placeholder: string;

    private searchQuery: string = '';

    @Observe('click$')
    public observable1(x: Subject<any>): Observable<any> {

        // Do entire observable pipeline here .pipe()

        // This function will be executed once during the creation of component
        // Output of this function should be a stream that will be registered by name passed to the decorator

        // NOT SETTER. CANNOT MUTATE STATE OF THE COMPONENT

        return x.pipe(
            map((_) => Math.random())
        );
    }

    @Subscribe('click$')
    public subscription1(subscribedData: any) {
        // Subscribe to click$ automatically with this function
        // Each time click$ emits, this function is executed

        // MUTATIONS TO COMPONENT STATE ARE ALLOWED
    }

    @Subscribe('click$', { observer: true })
    public subscription2(obs: Observable<any>): Subscription {
        // Subscribe to click$ and get observer in case you wish to handle errors
        // This function will be executed only once to register observer.
        // Or directly return observer from the function.

        // It is mandatory to return subscription, otherwise memory leakage

        // MUTATIONS TO COMPONENT STATE ARE ALLOWED

        return obs.subscribe({
            next: () => {},
            error: () => {},
            complete: () => {}
        });
    }
}

Bad things:

  1. String manipulation
johnlindquist commented 6 years ago
  1. Strings - vue-streams happened because Andre challenged me to avoid Strings. The only solution I found was the sources object (which is essentially a "config" which is read by the library to set up sources. I'm not claiming the "sources config" is the best solution, but I think it conveys the intent of "setup sources" and offers a nice developer experience compared to anything else I came up with.

  2. Big Subscriptions Function - subscriptions mirrors what data does in Vue. It's a function that returns an object where the props are rendered in the template. I'm happy with this approach because it's a natural pair to the Vue way of doing things. If you think the function is getting too big, maybe the solution is to create more, smaller components instead.

  3. Avoiding Custom Directives - I initially tried to copy vue-rx's approach of fromEvent, but directives are not supported on non-dom elements like transition which fire custom events. So instead I just allow my sources to be called as methods (bound to .next) so that it supports anything that can be calleg from a Vue template.

  4. Avoiding this - Love it or hate it, this plays a huge part of Vue's internal reactivity and lifecycle system. Evan's approach (the creator of Vue) in vue-rx was very similar to what I did. I think trying to avoid this in Vue is fighting against the framework.