Deep dive into Angular pipes

achtlos

thomas

Posted on February 28, 2023

Deep dive into Angular pipes

The aim of this article is to explain everything related to pipes. First, we will learn how to build a pipe, and then we will explore the benefits of using them. We will dive into the Angular source code to have a better understanding of how they work and what their use cases are.

This article will also provide the solution for challenge #8 of Angular Challenges. This challenge has been designed for beginners to get their first look into pipes. If you haven't tried it yet, I encourage you to do so before coming back to compare your solution with mine. (You can also submit a PR that I'll review)


In the challenge, we start with the following code snippet:

@Component({
  standalone: true,
  imports: [NgFor],
  selector: 'app-root',
  template: `
    <div *ngFor="let person of persons; let index = index">
      {{ heavyComputation(person, index) }}
    </div>
  `,
})
export class AppComponent {
  persons = ['toto', 'jack'];

  heavyComputation(name: string, index: number) {
    // very heavy computation
    return `${name} - ${index}`;
  }
}
Enter fullscreen mode Exit fullscreen mode

We have a simple for loop that iterates over an array of person's name, and for each person, we call the heavyComputation function.

In this example, the function simulates a heavy computation, but in reality it could be a costly calculation such as filtering an array.

In Angular project, it is common to see function calls inside templates because it is the most natural way to get information from our component into our template. However, you may have heard the following phrase: "You should never use function calls!". This statement is often true because your function will be recompute at each change detection cycle, and Angular can run many of those cycles.

That said, calling functions inside a template is not forbidden or bad. If we only want to retrieve an attribute from an object, as in the following example, the cost is very low, so calling this function is totally acceptable.

getFirstname = (index: number) => person[index].firstname
Enter fullscreen mode Exit fullscreen mode

Functions can be easily modified to perform heavier calculation without considering the impact of the modification, which can harm the performance of your application.

That is why, we should use pipes inside our template to transform our data. Pipes are memoized, meaning if none of the inputs have changed , the last computed value will be returned. Otherwise the transform function of our pipe will be re-executed.

Let's modify our code to add a pipe to our template:

@Pipe({
  name: 'comput',
  standalone: true,
  pure: true // default value
})
export class ComputPipe implements PipeTransform {
  transform(name: string, index: number): string {
    // very heavy computation
    return `${name} - ${index}`;
  }
}
Enter fullscreen mode Exit fullscreen mode

and our template become:

<div *ngFor="let person of persons; let index = index">
  {{ person | comput: index }}
</div>
Enter fullscreen mode Exit fullscreen mode

Note: 

  • The syntax of a pipe is as follow:
var | pipeName : arg1: arg2: arg3
Enter fullscreen mode Exit fullscreen mode
  • We can concatenate multiple pipes together
var | pipe1 | pipe2
Enter fullscreen mode Exit fullscreen mode

The argument var will be transformed by pipe1 first and then the result will be transformed by pipe2.

  • By default, a pipe is pure. This means that Angular will memorize the result of the first execution and will re-evaluate the pipe only if one or more inputs change.

Note: A pure pipe must use a pure function meaning that the function should not trigger any side effects. The function should return the same output for the same input.

We can also create impure pipe by setting pure: false inside the pipe decorator. This means that the pipe function will be executed at each change detection cycle. However this property should be used with caution since it can harm your application's performance if your function is costly. Using an impure pipe is similar to calling a function from a template, but it allows you to reuse the function anywhere in your application.

For example, the built-in AsyncPipe is an impure pipe since it needs to re-evaluated the observable at each change detection cycle to refresh the view.

  • One last thing that is often forgotten is that the decorator @Pipe creates an injectable element, which means that we can import the pipe into any component, directive or service and call its transform method.

If we take the previous pipe example, we could write something like that:

@Injectable()
export class MyService {
  // we can inject the pipe
  computPipe = inject(ComputPipe);

  doSomething(){
    return this.computPipe('xxx', 1);
  }
}
Enter fullscreen mode Exit fullscreen mode

We now know how to create a pipe and have some general understanding of it. Let's dive into Angular's source code to better understand how a pipe is implemented under the hood.

Before we look at some code snippets, we need to understand how Angular processes our template information. We need a very basic introduction to what an LView and TView are.

Internally, Angular transforms all of our template information into LView (Logical View) and TView (Template View).

  • TView represents the compiled template of a component or pipe and contains information about the structure and content of the template. It contains information about the directives, bindings, elements, template's metadata, styles,… When Angular compiles a component, its template is transformed into a TView object. TView contains static data needed to efficiently render a template and its instance can be shared among any LView that uses that particular component.

  • LView is a data structure that represents the state of a component and its associated template. It contains information about the component's properties, methods, bindings and also the state of the template. This object is used by the Angular runtime and is updated whenever the component's properties or state change.

If we take the view of our example above:

 

<div *ngFor="let person of persons; let index = index">
  {{ person | comput: index }}
</div>
Enter fullscreen mode Exit fullscreen mode

A TView is created for the AppComponent and for the ComputPipe.

A LView is created for the AppComponent and also a LView is created for each div of our *ngFor loop. (Since the persons array is of length 2 in our example, we have a total of 3 LView as shown below)

LViewComputPipe_toto = LView{
  TViewComputPipe,
  // ...
  // state information
  // ...
}

LViewComputPipe_jack = LView{
  TViewComputPipe,
  // ...
  // state information
  // ...
}

LViewAppComponent = LView{
  TViewAppComponent,
  LViewComputPipe_toto,
  LViewComputPipe_jack,
  // ...
  // state information
  // ...
}
Enter fullscreen mode Exit fullscreen mode

This is a very simplified version of LView, but it's all you need to understand for the following section. Let's go though the code to see how pipe are implemented when a new change detection cycle is triggered.

Depending on the number of arguments, the function being called for our pipe is ɵɵpipeBind[number of arg] which is ɵɵpipeBind2 in our case since comput takes two arguments: persons and index . Thus at each change detection cycle, the function below is executed for each of our pipe.

function ɵɵpipeBind2(index, slotOffset, v1, v2) {
    const adjustedIndex = index + HEADER_OFFSET;
    const lView = getLView();
    const pipeInstance = load(lView, adjustedIndex);
    return isPure(lView, adjustedIndex) ?
        pureFunction2Internal(lView, getBindingRoot(), slotOffset, pipeInstance.transform, v1, v2, pipeInstance) :
        pipeInstance.transform(v1, v2);
}
Enter fullscreen mode Exit fullscreen mode
  • index, HEADER_OFFSET and slotOffset are used to locate the slots of the required element within the LView object. In the case of the pipe, we want the state of each argument, the result of the transform method and the pipe instance.
  • v1 and v2 are the two input arguments.

If we examine the slots that are most relevant to us within the LView, we can see the following:

lView = {
// ...
24: ComputPipe {}
// ...
26: {__brand__: 'NO_CHANGE'}
27: {__brand__: 'NO_CHANGE'}
28: {__brand__: 'NO_CHANGE'}
// ...
}
Enter fullscreen mode Exit fullscreen mode
  • slot 24 stores the instance of the pipe
  • slot 26 stores the value of arg1
  • slot 27 stores the value of arg2
  • slot 28 caches the result of the transform method

Note: This LView shows the initialization state.

To begin, we must determine whether the pipe is pure using the following function:

function isPure(lView: LView, index: number): boolean {
  return (<PipeDef<any>>lView[TVIEW].data[index]).pure;
}
Enter fullscreen mode Exit fullscreen mode

The constant TVIEW = 1 stores the TView slot inside the LView. Since all static data is stored inside the TView, we can retrieve all metadata relating to this pipe (As shown below)

{
  factory: ƒ ComputPipe_Factory(t),
  name: "comput",
  onDestroy: null,
  pure: true,
  standalone: true,
  type: class ComputPipe
}
Enter fullscreen mode Exit fullscreen mode

If the pipe is impure, we simply return the pipe.transform method and re-execute the entire function.

However, in most cases, the pipe will be pure and we will call pureFunction2Internal.

export function pureFunction2Internal(...): any {
  const bindingIndex = bindingRoot + slotOffset;
  return bindingUpdated2(lView, bindingIndex, exp1, exp2) ?
      updateBinding(
          lView, bindingIndex + 2,
          thisArg ? pureFn.call(thisArg, exp1, exp2) : pureFn(exp1, exp2)) :
      getPureFunctionReturnValue(lView, bindingIndex + 2);
}
Enter fullscreen mode Exit fullscreen mode

The pureFunction2Internal method needs to validate a new condition. First, we must compare the two new input arguments with the ones stored inside the LView. The bindingUpdated2 is called to archive this:

export function bindingUpdated2(lView: LView, bindingIndex: number, exp1: any, exp2: any): boolean {
  const different = bindingUpdated(lView, bindingIndex, exp1);
  return bindingUpdated(lView, bindingIndex + 1, exp2) || different;
}
Enter fullscreen mode Exit fullscreen mode
export function bindingUpdated(lView: LView, bindingIndex: number, value: any): boolean {
  const oldValue = lView[bindingIndex];

  if (Object.is(oldValue, value)) {
    return false;
  } else {
    lView[bindingIndex] = value;
    return true;
  }
Enter fullscreen mode Exit fullscreen mode

The bindingUpdated method compares the stored value of arg1 with the current value using the Object.is method. If both value are different, we update the LView with the new argument.

Object.is is quite similar to === except that -0 !== 0 and Number.NaN === NaN

We run bindingUpdated for arg2 as well and we sum both results.

If all of the pipe's arguments are identical to the previous change detection cycle, we return the cached value by calling getPureFunctionReturnValue:

function getPureFunctionReturnValue(lView: LView, returnValueIndex: number) {
  const lastReturnValue = lView[returnValueIndex];
  return lastReturnValue === NO_CHANGE ? undefined : lastReturnValue;
}
Enter fullscreen mode Exit fullscreen mode

Otherwise, we run the transform method and we save the result inside the LView using the updateBinding function:

export function updateBinding(lView: LView, bindingIndex: number, value: any): any {
  return lView[bindingIndex] = value;
}
Enter fullscreen mode Exit fullscreen mode

After one cycle of change detection, we can see that the LView contains our new state values:

lView = {
// ...
24: ComputPipe {}
// ...
26: "toto"
27: 0
28: "toto - 0"
// ...
}
Enter fullscreen mode Exit fullscreen mode

Now if both arg don't change, the transform method of the pipe will not be called and the cached value (slot 28) will be returned. However if one or both arguments change, the pipe will be re-executed.

Note:

  • If your pipe function has one argument or more than two, the logic is strictly identical. All arguments will be checked in order to determine if the pipe needs to be recalculated.
  • If you have read this excellent article from Enea Jaholli (link below), we can argue about using a memo function to wrap our function inside our component instead of using a pipe. However the example above will not work with this approach. The memo function will only work efficiently if you call your function with the same set of arguments because there is only one 'instance' of the function. (For example, if you call your function with the argument 'toto', 'toto' will be memoized. Then you call the same function with 'titi', since 'toto'!='titi', the function will rerun. At the next change detection cycle, you will run the function with 'toto' again and even if the argument is the same, the function will get re-executed since the memoized value is now 'titi' and so on). In comparison, pipes have their own LView and thus their own instance. So you can have multiple identical pipes inside the same template with different arguments and still leverage the cached algorithm of Angular.

I hope that pipe has no more secrets and you will use it efficiently in your application.

I hope you learned new Angular concept. If you liked it, you can find me on Twitter or Github.

💖 💪 🙅 🚩
achtlos
thomas

Posted on February 28, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Angular Form Array
angular Angular Form Array

November 29, 2024

Can a Solo Developer Build a SaaS App?
undefined Can a Solo Developer Build a SaaS App?

November 29, 2024

Angular's New Feature: Signals
javascript Angular's New Feature: Signals

November 29, 2024