How does rxjs help business development?

Time:2022-8-2

Hello, everyone, bean skin fans. See you again.
This time I have brought you articles about rxjs. This article will explain the advantages, disadvantages and best practices of rxjs in combination with actual project requirements and examples.

Author: Yang Xian

Background / purpose

Rxjs has been widely used in our actual projects and proved its benefits.
This article shares with you the best practice of revenue and precipitation.

Advantages side effects Management

Personally, I think the strongest advantage of rxjs is side effect management.
What is side effect management?

When you don’t see this flower, it will be silent with your heart. When you look at this flower, the color of this flower suddenly becomes clear — Wang Yangming

Once a subscribable is not subscribed, its logic will stop after executing the current operator.

In order to make the most of this feature, I suggest that the logic be split into smaller functions and connected with more operators.

If you don’t need this feature, there is no need to use rxjs, just adding complexity. If you use rxjs, but don’t use this feature, I suggest you use it directlyPromise

Take a practical example to illustrate how rxjs manages side effects. For example, for common polling requirements, you will generally choose the following wording:

const resultId = await getResultId(dsl);
history.push(`?resultId=${resultId}`);
const result = await polling(resultId);
setState(result);

Is there a problem with the above writing?

In fact, almost every sentence has problems

  • The second sentence: if the user cuts the route immediately after initiating the request, the second sentence will cut the route back
  • The third sentence: if the user initiates a new query request, although it does not need the last request, it will not cancel the last query, wasting the server resources and the number of front-end requests.
  • Fourth sentence: if the component is uninstalled, go again at this timesetStateA warning will be reported. But this is not the key issue, the key issue is the race. If the previous poll is slow and the latest poll is fast, the user is likely to see the result of the previous poll.

At this time, a classmate said, yes, I’ll add a test to each lineunmountperhapsAbortControllerJust don’t.

You think you’re writing go

There are two problems:

  • The code becomes extremely bloated, reducing maintainability and readability
  • Secondly, giving this mechanical logic to people to write, on the one hand, wastes manpower and time, on the other hand, it is not reliable, in case you forget it

Rxjs solves these problems at zero cost:

useEffect(() => {
  const subscription = dslInput$.pipe(
    switchMap(dsl => getResultId(dsl)),
    tap(resultId => history.push(`?resultId=${resultId}`)),
    switchMap(resultId => polling(resultId)),
  ).subscribe(result => setState(result));
  return () => subscription.unsubscribe()
}, [])

When the component is unloaded, callsubscription.unsubscribe(), the side effects will stop immediately and the subsequent logic will no longer be executed.
Of course, there are other advantages, but it is not a unique advantage of rxjs, so I won’t expand it here.

Disadvantages – functional / declarative

Personally, I don’t think rxjs has any major shortcomings. Its biggest weakness is the common fault of functional / declarative, which is inconvenient to deal with variables with long life cycle.

For example, if you want to add embedding points to the above code to see how much time these requests take, the code expands into this:

dslInput$.pipe(
  map(dsl => ({dsl, startTime: new Date()}))
  switchMap(async ({dsl, startTime}) => ({resultId: await getResultId(dsl), startTime})),
  tap(({resultId}) => history.push(`?resultId=${resultId}`)),
  switchMap(async ({resultId, startTime}) => ({result: await polling(resultId), startTime})),
  tap(({startTime}) => Tracker.collect(Date.now() - startTime.getTime())),
).subscribe(({result}) => setState(result));

Ah, this is so messy. I really can’t watch it.

If there is a big man who has a solution, I hope he can take me with him.

actuallyPromiseIt is also a function, so it also has this problem, so how does it solve it?

Upgraded toasync/awaitGrammar sugar.

Basic concepts

flow

I have written the simplest one in rxjs tenet problem analysisSubjectNo magic:

class Subject<T> {
  private fns: Array<(value: T) => void> = []
  public next(value: T) {
    this.fns.forEach(fn => fn(value))
  }
  public subscribe(fn: (value: T) => void) {
    this.fns.push(fn)
    return () => {
      this.fns = this.fns.filter(x => x !== fn)
    }
  }
}

There is no magic for side effect management. It depends on line 4, when there is no magicsubscriptionNo function will be called, that is, the logic will not continue to execute.

ObservableJust notnextofSubject。 It is usually controlled by a single data source. Generally speaking, developers do not need to call constructors themselves. The most common is frompipeThe return value of.

ReplaySubjectThere is a save insidevalueThe current array will be recorded before line 4valueWhenever there is a new oneSubscription, the last n items will bevalueReplay to subscribers.

BehaviorSubjectEquivalent to n being 1ReplaySubject。 Because comparedReplaySubjectCommonly used, so it becomes a class alone. I haven’t really used it muchReplaySubject

How does rxjs help business development?

pipeFor connectionObservableGenerally, the main logic will be executed in it. Generally speaking, there are two necessary conditions for the implementation of logic:

  • Output stream inpipeThen generate value

    • BehaviorSubjectandReplaySubjectThere are new ones in subscriptionThe current value will be issued when it is used as input, so the value must be generated when it is used as input
  • With subscription(subscribe)Party

    • Exceptions:shareReplay

pipeThe principle of is chain type(reduce)CallinnerSubscribeFunctions, andsubscribeThe difference is that it will not trigger the execution of side effects.

For each input streamnextCall,pipeThe default number of logical executions in is the number of subscribers. In most cases, you don’t want logic to executeMany times, so you need to use multicast. Many documents describe multicast as very complex. I don’t think it’s necessary to understand these twooperatorOK:shareshareReplay。 Take these twooperatorPut onpipeTo ensure that the number of previous logical executions is independent of the number of subscribers. The difference is,shareReplayWhether there is a subscriber or not, the logic will be executed, andshareThe first subscriber must exist before executing logic.

useshareReplayThe other is called cold flow. When there is a subscription, the cold flow starts executing logic, and when there is no subscription, the cold flow terminates logic,Cold flow has the core advantage of rxjs: side effect management。 The heat flow has nothing to do with whether to subscribe or not. When there is no subscription, the logic will not be terminated. Therefore, individuals seldom putshareReplayUsed in conjunction with asynchrony.

reference material https://blog.thoughtram.io/angular/2016/06/16/cold-vs-hot-observables.html#hot -vs-cold-observables

To sum up, the basic structure of using rxjs packaging logic is as follows:

const input$ = new Subject();
const output$ = new BehaviorSubject(initValue);
const subscription = input$.pipe(
  operator1(),
  operator2(),
).subscribe(output => output$.next(output));
  • Convention: input&dollar when consuming data in UI layer; The end stream can only be usednext; output&dollar; The end stream can only be usedsubscribe

    • General consumptionoutput$Use hook:const output = useSubscribable(output$)
  • Remember tosubscriptionOutgoing, when the component is unloadedunscribable

A classmate asked why he didn’t write like this:

const input$ = new Subject();
const output$ = input$.pipe(
  operator1(),
  operator2(),
);
  • The second method needs to consider multicast. When cold flow and heat flow are mixed, what is implemented and what is not implemented in the end; Or what side effects can be cleaned up, and what can’t be cleaned up, it’s messy. The first method does not need to consider complex multicast at all. You may not need broadcast.md。
  • In the second way of writing,output$The type of isObservable

    • When the component subscribes after the data is sent, the current data will be lost.
    • There is no initial value, which is required when callinguseSubscribable(output$, initValue), the initial values are scattered in various UI components, reducing maintainability.startsWithThis problem can be partially solved.
    • No,nextMethods, when the requirements become complex, it is usually necessary tooutput$At this time, it will be transformed into the first writing method.

In a word: the first way of writing does not need to consider messy conceptual problems

It is worth noting that a streamerrorperhapscompleteAfter that, the subscriber will not receive the new value in any case, and will no longer execute any logic.

input$.pipe(tap(() => throw new Error())).subscribe(x => {
  // never reach here
})

Therefore, we need to pay attention to error handling. In our project, some custom operators are encapsulated to wrap the asynchronous return value as:

loading: boolean
error?: Error
data?: T
input?: any

So as to prevent the abnormal termination of the flow and the inability to continue execution due to errors.
Rxjs has a specialObservable: NEVER。 It never sends any value and is often used to terminate logic in advance, similar to that in ordinary functionsreturn

input$.pipe(
  switchMap(x => {
    if(!x) {
      return NEVER
    }
    return x
  }),
  operator1()
).subscribe(x => {
  // x will never be falsy
})

Operator

Sort by ease of use:

  • Switchmap: super easy to use Op. staysubscribeBefore the next flow, automaticunscbscribeLast stream, so as to solve the racing problem perfectly. If it is related to asynchrony, use it.
  • Debounce: generally, individuals like to work withpairAnd so on, which can be determined by judging which value changesdebounceHow much time?
  • Distinguishuntilchanged: shallow comparison. If two values are equal, no new value will be issued. Generally, it is used by passing in the deep comparison function.
  • Withlatestfrom: get the latest value of the target stream without triggering side effects. andcombineLatestSimilarly, if a flow has no value, it will get stuck. This is quite a pit. The difference is,combineLatestThe new value issued by the flow in the parameter will not be executedpipeLogic in.
  • exhaustMap。 In current flowcompleteBefore, ignore other flows. It is generally used in combination with the download function. It is worth noting that you need to remember to put the previous streamcompleteOtherwise, the next logic will never be executed.
    When writing a custom operator, you must think about how to eliminate side effects. The personal suggestion combination has been written by the operator.

Enabling business – take form result as an example

Next, let’s see how rxjs can help businesses iterate quickly. In our business, the most common is the query analysis page, which is composed of query criteria and result display. This structure I call form result.

However, no matter what the interface is, it can be divided into the following layers:

How does rxjs help business development?

A business component usually has the following structure:

const { input$, output$ } = useXxx(); //  Get data from context
const handleClick = useCallback(() => input$. next(someValue)); //  Use input$
const output = useSubscrible(output$); //  Use output$, which is behaviorsubject in most cases, and the initial value is not required at this time
Return <div onclick={handleclick}>{output}</div> // rendering interface

The outermost layer of all business components is aProvider

const value = useXxxProvider();
return <XxxContext.Provider value={value}>{children}</XxxContext.Provider>

Almost all business logic is inuseXxxProviderLi,useXxxProviderWill callcreateXxxTo generate the logic of each sub business, and finally carry out all the logicmergeAnd return.

function useXxxProvider() {
    const { observables, subscriptions } = useMemo(() => {
        //Create a flow related to the form
        const {
             observables: formObservables,
             subscriptions: formSubscriptions
        } = createForm();
        //Create flow related to result
        const {
            observables: resultObservables,
            subscriptions: resultSubscriptions
        } = createResult(formObservables.formOutput$);
        //Return streams and subscriptions
        return {
            observables: {
                ...formObservables,
                ...resultObservables
            },
            subscriptions: [...formSubscriptions, ...resultSubscriptions]
        }
    }, []) 
    //Generally, there is no dependency. If there is, it needs to be guaranteed to remain unchanged or passed in the form of Ref
    
    //Destroy subscription when component uninstalls
    useEffect(() => {
        return () => subscriptions.forEach(sub => sub.unsubscribe())
    }, []);
    
    //Return generated stream
    return observables;
}

Each piece of business logic is packaged increateXxxIts basic structure is as follows:

  1. Create relevant flows, which are generally divided intoinput$andoutput$
  2. usepipeConnect the two streams and package the main business logic
  3. Return streams and subscriptions
function createXxx() {
    //Create relevant subject
    const someSubject$ = new Subject();
    
    //Connection flow and business logic
    const subscription = someSubject$.pipe(...).subscribe()
    
    //Return flow and subscription
    return {
        observables: { someSubject$ }
        subscriptions: [subscription]
    }
}

Specific to our business, the code of form logic is as follows:

  1. The type of input stream isPartial<T>, because in the formonChangeUsually, I don’t care about the values of all forms. In most cases, I just call my own valuesformInput$.next()That’s it
  2. Compare the value in the input stream with the original valuemergeAnd add the logic of parameter verification and form linkage according to the actual business to form the output flow
function createForm() {
    const formInput$ = new Subject<Pratial<Form>>();
    const formOutput$ = new BehaviorSubject<Form>(INIT_FORM);
    const subscription = formInput$.pipe(
        withLatestFrom(formOutput$),
        map(([input, output]) => {
            //Other logic such as parameter verification and form linkage can be added here
            const extra = {}
            if('someThing' in input) {
                extra.otherThing = someValue
            }
            return {...output, ...input, ...extra}
        })
    ).subscribe(output => formOutput$.next(output));
    
    return {
        observables: {
            formInput$,
            formOutput$
        },
        subscriptions: [subscription]
    }
}

The code of the result business is as follows:

  1. The request flow is generated according to the actual business scenario. If the request parameters are very different from the UI state, it can be used heremapConvert
  2. When the request flow is generated, send the request and generate the result flow
  3. Return the result stream and relevant subscriptions
function createResult(formOutput$){
    //Case 1: there is no query button, and the request will be sent automatically when the form is modified
    const request$ = formOutput$.pipe(
        Pair (), // customize OP, return [undefined, t] for the first time, and others are consistent with pair wise
        Debounce (([prev]) = > timer (prev? 0:800)), // send the request immediately for the first time
        Distinguishuntilchanged (equals) // you can add this according to your business needs. Note that if you do not pass in the deep comparison function, it is a shallow comparison
    );
    
    //Case 2: click the query button to send the request again
    const requestButtonInput$ = new Subject();
    
    const request$ = requestButtonInput$.pipe(
        withLatestFrom(formOutput$),
        map(([,form]) => form)
    );
    
    //Case 3: both
    const requestButtonInput$ = new Subject();
    
    const request$ = merge(formOutput$.pipe(
        Pair (), // customize OP, return [undefined, t] for the first time, and others are consistent with pair wise
        Debounce (([prev]) = > timer (prev? 0:800)) // send the request immediately for the first time
    ), requestButtonInput$.pipe(
        withLatestForm(formOutput$),
        map(([, form]) => form)
    ));
    
    //Prepare the request flow and start sending requests
    const resultOutput$ = new BehaviorSubject(INIT_RESULT)
    
    const subscription = request$.pipe(
        switchMap(request => getResult(request))
    ).subscribe(output => resultOutput$.next(output));
    
    return {
        observables: {
            requestButtonInput$,
            resultOutput$
        },
        subscriptions: [subscription]
    }
}

useSubscribable

encloseduseSubscribablecode:

import useRefWrapper from 'hooks/useRefWrapper'
import { useState, useEffect } from 'react'
import { Subscribable, BehaviorSubject } from 'rxjs'

function getInitValue<T, R>(
  subscribable: Subscribable<T>,
  selector: (value: T) => R,
  initValue?: R
) {
  if (initValue !== undefined) {
    return initValue
  }
  if (subscribable instanceof BehaviorSubject) {
    return selector((subscribable as any)._value)
  }
  return undefined
}

export default function useSubscribable<T>(subscribable: BehaviorSubject<T>): T

export default function useSubscribable<T, R>(
  subscribable: BehaviorSubject<T>,
  selector: (value: T) => R
): R

export default function useSubscribable<T>(
  subscribable: Subscribable<T>,
  initValue: T
): T

export default function useSubscribable<T, R>(
  subscribable: Subscribable<T>,
  selector: (value: T) => R,
  initValue: R
): R

export default function useSubscribable<T>(
  subscribable: Subscribable<T>
): T | undefined

export default function useSubscribable<T, R>(
  subscribable: Subscribable<T>,
  selector: (value: T) => R
): R | undefined

export default function useSubscribable<T, R = T>(
  subscribable: Subscribable<T>,
  selectorOrInitValue?: (value: T) => R,
  initValue?: R
): R | undefined {
  const innerInitValue =
    typeof selectorOrInitValue === 'function' ? initValue : selectorOrInitValue
  const innerSelector =
    typeof selectorOrInitValue === 'function'
      ? selectorOrInitValue
      : (x: T) => (x as unknown) as R
  const innerSelectorRef = useRefWrapper(innerSelector)
  const [state, setState] = useState(() =>
    getInitValue(subscribable, innerSelector, innerInitValue)
  )
  useEffect(() => {
    const subscription = subscribable.subscribe((x) =>
      setState(innerSelectorRef.current(x))
    )
    return () => subscription.unsubscribe()
  }, [innerSelectorRef, subscribable])
  return state
}

summary

  • The advantage of rxjs is side effect management
  • If you use input&dollar output&dollar;, Then use the form of pipe connection, you don’t need to consider complex concepts such as multicast, hot and cold, and you can get started easily
  • Pay attention to error handling

The End

How does rxjs help business development?