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}`;
}
}
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
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}`;
}
}
and our template become:
<div *ngFor="let person of persons; let index = index">
{{ person | comput: index }}
</div>
Note:
- The syntax of a pipe is as follow:
var | pipeName : arg1: arg2: arg3
- We can concatenate multiple pipes together
var | pipe1 | pipe2
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 itstransform
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);
}
}
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 aTView
object.TView
contains static data needed to efficiently render a template and its instance can be shared among anyLView
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>
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
// ...
}
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);
}
- 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 thetransform
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'}
// ...
}
- 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;
}
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
}
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);
}
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;
}
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;
}
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;
}
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;
}
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"
// ...
}
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. Thememo
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 ownLView
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.
Top comments (0)