80 lines of code to achieve simple RxJS

Posted by Spudgun on Thu, 03 Mar 2022 10:58:18 +0100

RxJS is a responsive library. It receives events from the event source. After layer by layer processing of the processing pipeline, it is transmitted to the final receiver. This processing pipeline is composed of operators. Developers only need to select and combine operators to complete various asynchronous logic, which greatly simplifies asynchronous programming. In addition, the design of RxJS also follows the concept of function and flow.

It's difficult to understand the concepts directly. Let's implement a simple RxJS to see these again.

Use of RxJS

RxJS will encapsulate the event source in a layer called Observable, which sends out events one by one.

For example:

const source = new Observable((observer) => {
    let i = 0;
    setInterval(() => {
        observer.next(++i);
    }, 1000);
});

Set a timer in the callback function to continuously pass in events through next.

These events will be monitored by the receiver, called Observer.

const subscription = source.subscribe({
    next: (v) => console.log(v),
    error: (err) => console.error(err),
    complete: () => console.log('complete'),
});

observer can receive the events transmitted from the next. There may be errors in the transmission process. It can also handle errors here and handle the events after the transmission is completed.

Such a monitoring or Subscription is called Subscription.

You can subscribe or unsubscribe:

subscription.unsubscribe();

The callback function when unsubscribing is returned in Observable:

const source = new Observable((observer) => {
    let i = 0;
    const timer = setInterval(() => {
        observer.next(++i);
    }, 1000);
    return function unsubscribe() {
        clearInterval(timer);
    };
});

Sending events and listening to events are only the basis. The process of handling events is the essence of RxJS. It designs the concept of pipeline, which can be assembled by operator:

source.pipe(
    map((i) => ++i),
    map((i) => i * 10)
).subscribe(() => {
    //...
})

The event will be transmitted to the Observer only after passing through the pipeline. During the transmission, it will be processed by operators.

For example, the processing logic here is to add 1 to the transmitted data, and then multiply by 10.

To sum up, the code using RxJS is like this:

const source = new Observable((observer) => {
    let i = 0;
    const timer = setInterval(() => {
        observer.next(++i);
    }, 1000);
    return function unsubscribe() {
        clearInterval(timer);
    };
});
const subscription = source.pipe(
    map((i) => ++i),
    map((i) => i * 10)
).subscribe({
    next: (v) => console.log(v),
    error: (err) => console.error(err),
    complete: () => console.log('complete'),
});

setTimeout(() => {
    subscription.unsubscribe();
}, 4500);

We create an event source through Observable and send one event every second. These events will be processed by the pipeline and then passed to the Observer. The pipeline is composed of two map operators, which process the data with + 1 and * 10.

The Observer receives the transmitted data, prints it, and handles errors and end events. In addition, Observable provides processing logic for unsubscribing. When we unsubscribe in 4.5s, we can clear the timer.

Using RxJS is basically this process. How is it implemented?

Implement RxJS in 80 lines of code

Start with the event source and realize Observable:

Observe its characteristics:

  1. It receives a callback function, which can call next to transfer data.
  2. It has a subscribe method that can be used to add the subscription of the Observer and return the subscription
  3. It can return the processing logic when unsbscribe is returned in the callback function
  4. It has a pipe method that can pass in operators

According to these characteristics, we realize the following:

First, the Observable constructor receives the callback function_ subscribe, but not immediately, but only when subscribing:

class Observable {
    constructor(_subscribe) {
        this._subscribe = _subscribe;
    }
    subscribe() {
        this._subscribe();
    }
}

The parameters of the callback function are objects with next, error and complete methods, which are used to pass events:

class Observable {
    constructor(_subscribe) {
        this._subscribe = _subscribe;
    }
    subscribe(observer) {
        const subscriber = new Subscriber(observer);
        this._subscribe(subscriber);
    }
}

class Subscriber{
    constructor(observer) {
        super();
        this.observer = observer;
        this.isStopped = false;
    }
    next(value) {
        if (this.observer.next && !this.isStopped) {
            this.observer.next(value);
        }
    }
    error(value) {
        this.isStopped = true;
        if (this.observer.error) {
            this.observer.error(value);
        }
    }
    complete() {
        this.isStopped = true;
        if (this.observer.complete) {
            this.observer.complete();
        }
        if (this.unsubscribe) {
            this.unsubscribe();
        }
    }
}

In this way, you can call the next, error and complete methods in the callback function:

In addition, the return value of the callback function is the processing logic when unsubscribe. To collect it, call when unsubscribing:

class Subscription {
    constructor() {
        this._teardowns = [];
    }
    unsubscribe() {
        this._teardowns.forEach((teardown) => {
            typeof teardown === 'function' ? teardown() : teardown.unsubscribe()
        });
    }
    add(teardown) {
        if (teardown) {
            this._teardowns.push(teardown);
        }
    }
}

Provide unsubscribe method to unsubscribe_ Teardown is used to collect all callbacks during unsubscribe, and call all teardown callbacks when unsubscribe.

This logic is more general and can be used as the parent class of Subscriber.

Then, call add in Observable to add teardown and return subscription (it has unsubscribe method):

class Observable {
    constructor(_subscribe) {
        this._subscribe = _subscribe;
    }
    subscribe(observer) {
        const subscriber = new Subscriber(observer);
        subscriber.add(this._subscribe(subscriber));
        return subscriber;
    }
}
class Subscriber extends Subscription {
    constructor(observer) {
        super();
        this.observer = observer;
        this.isStopped = false;
    }
    next(value) {
        if (this.observer.next && !this.isStopped) {
            this.observer.next(value);
        }
    }
    error(value) {
        this.isStopped = true;
        if (this.observer.error) {
            this.observer.error(value);
        }
    }
    complete() {
        this.isStopped = true;
        if (this.observer.complete) {
            this.observer.complete();
        }
        if (this.unsubscribe) {
            this.unsubscribe();
        }
    }
}

class Subscription {
    constructor() {
        this._teardowns = [];
    }
    unsubscribe() {
        this._teardowns.forEach((teardown) => {
            typeof teardown === 'function' ? teardown() : teardown.unsubscribe()
        });
    }
    add(teardown) {
        if (teardown) {
            this._teardowns.push(teardown);
        }
    }
}

In this way, we implemented Observable and Observer, and only wrote 50 lines of code. Let's test it first:

const source = new Observable((observer) => {
    let i = 0;
    const timer = setInterval(() => {
        observer.next(++i);
    }, 1000);
    return function unsubscribe() {
        clearInterval(timer);
    };
});
const subscription = source.subscribe({
    next: (v) => console.log(v),
    error: (err) => console.error(err),
    complete: () => console.log('complete'),
});

setTimeout(() => {
    subscription.unsubscribe();
}, 4500);

The Observer listens to the data of 1, 2, 3 and 4 passed by Observable. Because the subscription is cancelled at 4.5s, there is no data later.

We implemented the basic RxJS with 50 lines!

Of course, the most essential operator has not yet been implemented, and we will continue to improve it.

We add a pipe method to Observable, which will call the incoming operator, and the last result is the input of the next one. In this way, it is concatenated, that is, the concept of pipe:

class Observable {
    constructor(_subscribe) {
        //...
    }
    subscribe(observer) {
       //...
    }
    pipe(...operations) {
        return pipeFromArray(operations)(this);
    }
}

function pipeFromArray(fns) {
    if (fns.length === 0) {
        return (x) => x;
    }
    if (fns.length === 1) {
        return fns[0];
    }
    return (input) => {
        return fns.reduce((prev, fn) => fn(prev), input);
    };
}

When the incoming parameters are 0, the previous Observable will be returned directly, and when 1, it will be returned directly. Otherwise, it will be connected in series through reduce to form a pipeline.

The implementation of the operator is to listen to the last Observable and return a new one.

For example, the implementation of map is to pass in the project to process the value and pass the result down with next:

function map(project) {
    return (observable) => new Observable((subscriber) => {
        const subcription = observable.subscribe({
            next(value) {
                return subscriber.next(project(value));
            },
            error(err) {
                subscriber.error(err);
            },
            complete() {
                subscriber.complete();
            },
        });
        return subcription;
    });
}

In this way, we have implemented the operator to test:

We called the pipe method, used two map operators to organize the processing flow, and processed the data by + 1 and * 10.

So, when it is passed to Observable 1 and Observable 3 and Observable 4, it will be passed to Observable 3 and Observable 20.

So far, we have realized the concepts of Observable, Observer, Subscription and operator of RxJS, which is a simple version of RxJS. Only 80 lines of code were used.

Let's look at the initial ideas:

Why is it called responsive?

Because it monitors and processes the event source, this programming mode is called responsive.

Why is it called functional formula?

Because each step of the operator is a pure function and returns a new Observable, which is consistent with the immutability of the functional formula. After modification, it returns a new concept.

Why is it called flow?

Because each event is generated and transmitted dynamically, the dynamic generation and transmission of this data can be called flow.

The complete code is as follows:

function pipeFromArray(fns) {
    if (fns.length === 0) {
        return (x) => x;
    }
    if (fns.length === 1) {
        return fns[0];
    }
    return (input) => {
        return fns.reduce((prev, fn) => fn(prev), input);
    };
}
class Subscription {
    constructor() {
        this._teardowns = [];
    }
    unsubscribe() {
        this._teardowns.forEach((teardown) => {
            typeof teardown === 'function' ? teardown() : teardown.unsubscribe()
        });
    }
    add(teardown) {
        if (teardown) {
            this._teardowns.push(teardown);
        }
    }
}
class Subscriber extends Subscription {
    constructor(observer) {
        super();
        this.observer = observer;
        this.isStopped = false;
    }
    next(value) {
        if (this.observer.next && !this.isStopped) {
            this.observer.next(value);
        }
    }
    error(value) {
        this.isStopped = true;
        if (this.observer.error) {
            this.observer.error(value);
        }
    }
    complete() {
        this.isStopped = true;
        if (this.observer.complete) {
            this.observer.complete();
        }
        if (this.unsubscribe) {
            this.unsubscribe();
        }
    }
}
class Observable {
    constructor(_subscribe) {
        this._subscribe = _subscribe;
    }
    subscribe(observer) {
        const subscriber = new Subscriber(observer);
        subscriber.add(this._subscribe(subscriber));
        return subscriber;
    }
    pipe(...operations) {
        return pipeFromArray(operations)(this);
    }
}
function map(project) {
    return (observable) => new Observable((subscriber) => {
        const subcription = observable.subscribe({
            next(value) {
                return subscriber.next(project(value));
            },
            error(err) {
                subscriber.error(err);
            },
            complete() {
                subscriber.complete();
            },
        });
        return subcription;
    });
}


const source = new Observable((observer) => {
    let i = 0;
    const timer = setInterval(() => {
        observer.next(++i);
    }, 1000);
    return function unsubscribe() {
        clearInterval(timer);
    };
});
const subscription = source.pipe(
    map((i) => ++i),
    map((i) => i * 10)
).subscribe({
    next: (v) => console.log(v),
    error: (err) => console.error(err),
    complete: () => console.log('complete'),
});

setTimeout(() => {
    subscription.unsubscribe();
}, 4500);

summary

In order to understand the concepts of response, function and flow of RxJS, we have implemented a simple version of RxJS.

We have implemented the concepts of Observable, Observer and Subscription, and completed the generation, Subscription and unsubscribe of events.

Then the operator and pipe are implemented. Each operator returns a new Observable and processes the data layer by layer.

After writing, we can more clearly understand how the concepts of response, function and flow are embodied in RxJS.

To realize the simple version of RxJS, only 80 lines of code are required.