Angular Authoring hot topic (again)

Angular Authoring hot topic (again)

It’s been a wild 48 hours in the Angular community once again, all stemming from Jeremy’s presentation at JetBrains, where he discussed Angular’s future.

Before we go further, I should mention that I’ve also worked on custom authoring over the past year with Treaty, which you can explore in the playground here https://treatyjs.github.io/treaty/. While I’m not a fan of functional components, I do acknowledge both their advantages and drawbacks. I favour Angular classes, although functional programming can be quick and convenient for very basic components.

Since Jeremy's seemingly innocent presentation, the discussions on X have intensified, as highlighted in https://x.com/DanielGlejzner/status/1849772755635294371 and https://x.com/brandontroberts/status/1849541362154127503. This has sparked a recurring debate regarding the use of Classes versus Functions, or a variant of Single File Components (SFC) that utilizes XML tags for separation, similar to the meta-framework Analog.

The confusion around Developer Experience (DX), the compiler, and what exactly Ivy is has grown noticeably. To clarify, Angular’s DX isn’t what is used at runtime with AOT. During compilation, Angular replaces decorators and translates the code into instructions for the runtime and rendering engine — these instructions are called Ivy.

What is IVY?

Ivy is a set of instructions for creating a new instance of a component, managing Dependency Injection (DI) through decorators (rather than the service locator pattern), and rendering and updating each element on the screen.

import { input } from '@angular/core';
import { ExampleComponent } from './example';
import * as i0 from '@angular/core';
import * as i1 from '@angular/common';

class Example {
  name = input('name');
  example = { hello: singal('hello'), world: signal('world') };
}
Example.?fac = function example_Factory(t) {
  return new (t || example)();
};
Example.?cmp = i0.??defineComponent({
  type: example,
  selectors: [['example'], ['example'], ['Example']],
  inputs: { name: [i0.??InputFlags.SignalBased, 'name'] },
  standalone: true,
  signals: true,
  features: [i0.??StandaloneFeature],
  decls: 4,
  vars: 3,
  consts: [
    [1, 'name'],
    [3, 'hello', 'world'],
  ],
  template: function example_Template(rf, ctx) {
    if (rf & 1) {
      i0.??elementStart(0, 'div')(1, 'div', 0);
      i0.??text(2);
      i0.??elementEnd();
      i0.??element(3, 'ExampleComponent', 1);
      i0.??elementEnd();
    }
    if (rf & 2) {
      i0.??advance(2);
      i0.??textInterpolate(ctx.name);
      i0.??advance();
      i0.??property('hello', ctx.example.hello())('world', ctx.example.world());
    }
  },
  styles: [
    '.name[_ngcontent-%COMP%] { display: flex; justify-content: center; cursor: pointer; color: purple; text-decoration: inherit; }',
  ],
  dependencies: [],
});

export default Example;        

In the example above, we have a component called Example. Using type checks enables basic prop spreading if you use a struct (an object that neither adds nor removes properties).

Example.?fac = function example_Factory(t) {
  return new (t || example)();
};        

Angular has two main features related to Ivy: ?cmp, which is a function that returns state and functionality within a context (in Angular, this context is a class). Ivy also includes a ?fac Function, which is invoked each time a component instance is created on the screen. This factory function supplies any Dependency Injection (DI) required by the component via decorators. Straightforward, right? When we need a new state for the component, we simply instantiate the class.

The next critical part is ?cmp, where things start to get interesting:

Example.?cmp = i0.??defineComponent({
  type: example,
  selectors: [['example'], ['example'], ['Example']],
  inputs: { name: [i0.??InputFlags.SignalBased, 'name'] },
  standalone: true,
  signals: true,
  features: [i0.??StandaloneFeature],
  decls: 4,
  vars: 3,
  consts: [
    [1, 'name'],
    [3, 'hello', 'world'],
  ],
  template: function example_Template(rf, ctx) {
    if (rf & 1) {
      i0.??elementStart(0, 'div')(1, 'div', 0);
      i0.??text(2);
      i0.??elementEnd();
      i0.??element(3, 'ExampleComponent', 1);
      i0.??elementEnd();
    }
    if (rf & 2) {
      i0.??advance(2);
      i0.??textInterpolate(ctx.name);
      i0.??advance();
      i0.??property('hello', ctx.example.hello())('world', ctx.example.world());
    }
  },
  styles: [
    '.name[_ngcontent-%COMP%] { display: flex; justify-content: center; cursor: pointer; color: purple; text-decoration: inherit; }',
  ],
  dependencies: [],
});        

The ?cmp property is assigned the return type of i0.??defineComponent, into which we pass all data generated by the Angular Template parser, specifically parseTemplate(htmlCode). This parseTemplate the function is imported directly from @angular/compiler (import { parseTemplate } from '@angular/compiler';),

const This means hard-coded values that we are using in other locations:

i0.??elementStart(0, 'div')(1, 'div', 0);        

This means that the two <div> elements—where the second <div> is nested—and 1 represents the class name we are adding.

<div>
  <div class="name">{{ name }}</div>
</div>        

The most important part, and the foundation for the rest of this blog, revolves around the template function:

template: function example_Template(rf, ctx) {
    if (rf & 1) {
      i0.??elementStart(0, 'div')(1, 'div', 0);
      i0.??text(2);
      i0.??elementEnd();
      i0.??element(3, 'ExampleComponent', 1);
      i0.??elementEnd();
    }
    if (rf & 2) {
      i0.??advance(2);
      i0.??textInterpolate(ctx.name);
      i0.??advance();
      i0.??property('hello', ctx.example.hello())('world', ctx.example.world());
    }
  },        

Key points here: ctx Represents the state or instance of the class. rf & 1 Is used for construction, while rf & 2 is for dynamic content that is evaluated with change detection.

We pass the state values into the Ivy instructions using i0.??textInterpolate(ctx.name);. In this case, ctx is an instance of Example, and .name refers to the property name.

So, does Ivy care about classes?

Kinda. Classes are convenient for holding state and functions. In a functional programming context, we would need to return an object upon creation. If we’re returning state in a function, why not use a class — or even better, a struct?

Take the following code:

function example() {
  const name = input('name');
  const example = { hello: 'hadasdello', world: 'world' };
  console.log('hello');

  return (<div>
    <div class="asdasdad">{{ name }}</div>
    <div class="asdasdasd">{{ name() }}</div>
    <ExampleComponent {...example}></ExampleComponent>
  </div> )
}        

Created in the compiler, we can abstract the return from this point as we do in decorators and then parse the HTML:

function example() {
  const name = input('name');
  const example = { hello: 'hadasdello', world: 'world' };
  console.log('hello');
}        

In Ivy, we would currently have something like:

import { input } from '@angular/core';
import { ExampleComponent } from './example';
import * as i0 from '@angular/core';
import * as i1 from '@angular/common';

function example() {
  const name = input('name');
  const example = { hello: 'hadasdello', world: 'world' };
  console.log('hello');
}

example.?fac = function example_Factory(t) {
  return (t || example)();
};
example.?cmp = i0.??defineComponent({
  type: example,
  selectors: [['example'], ['example'], ['Example']],
  inputs: { name: [i0.??InputFlags.SignalBased, 'name'] },
  standalone: true,
  signals: true,
  features: [i0.??StandaloneFeature],
  decls: 4,
  vars: 3,
  consts: [
    [1, 'asdasdad'],
    [3, 'hello', 'world'],
  ],
  template: function example_Template(rf, ctx) {
    if (rf & 1) {
      i0.??elementStart(0, 'div')(1, 'div', 0);
      i0.??text(2);
      i0.??elementEnd();
      i0.??element(3, 'ExampleComponent', 1);
      i0.??elementEnd();
    }
    if (rf & 2) {
      i0.??advance(2);
      i0.??textInterpolate(ctx.name);
      i0.??advance();
      i0.??property('hello', ctx.example.hello())('world', ctx.example.world());
    }
  },
  styles: [
    '.name[_ngcontent-%COMP%] { display: flex; justify-content: center; cursor: pointer; color: purple; text-decoration: inherit; }',
  ],
  dependencies: [],
});

export default example;        

ISSUE:

The example ?fac does not return any context that we can pass back from the factory, which is necessary as the CTX inside the example_Template.

This highlights a pitfall of functional components or even Analog. While Ivy doesn’t care whether it’s a class or a function, it must understand the context or state. Therefore, the context cannot be passed around. You might think that’s simple; let’s return the methods and variables at compile time, like the following:

function example() {
  const name = input('name');
  const example = { hello: 'hadasdello', world: 'world' };
  console.log('hello');

  return { name, example };
}        

Sweet, that works. We’re not mutating, and we are returning a signal, so we can update it, right? But what about the following function?

function example() {
 let count = 0
  const example = { hello: 'hadasdello', world: 'world' };
  console.log('hello');

  function add(num) {
    count += num
  }

  return { example, add, count };
}

const test = example()
console.log('count', test.count);
test.add(5)
console.log('count', test.count);        

In the example above, the count will ALWAYS equal 0. The add function doesn't retain the context of the returned count. For instance, in CodePen, this happens because the count is stored inside the example function, and when you return it, you only get its initial value of 0. So, even if you change the count latter, the returned value doesn’t update since it only holds a copy of that starting value.

Now we need even more compile magic. Instead of merely removing the template return (as we do in class decorators) and incorporating everything into the new return, we also need to add count as a getter and setter:

function example() {
 let count = 0
  const example = { hello: 'hadasdello', world: 'world' };
  console.log('hello');

  function add(num) {
    count += num
  }

  return {
    example,
    add,
    get count() {
      return count
    },
    set count(value) {
      return count = value
    }
  };
}

const test = example()
console.log('count', test.count);
test.add(5)
console.log('count', test.count);        

Here’s a working example: CodePenCheck the console. This works because, in the example function, the count The variable remains private within the function scope, but it now employs a getter method to access its value. This means that whenever you call test.count, it retrieves the current value of count, allowing you to see the updated value after changes are made by the add function.

I’ve created an example of the compiler magic to ensure our function returns correctly: Gist.

Why is this important? Now, let’s ignore Ivy for a moment and focus on a run function that is a set of commands to execute based on the context:

function run(ctx: example) {
  console.log(ctx.count)
  ctx.add(5)
  console.log(ctx.count)
}        

In this case, run represents the template context, ctx, and it logs the count before adding 5 to it, followed by logging the new count. So, if we revisit the age question regarding Ivy not supporting functions, that’s factually incorrect as long as we return the correct closure state from the function. We can now achieve this, and your Ivy implementation will look like the following:

import { input } from '@angular/core';
import { ExampleComponent } from './example';
import * as i0 from '@angular/core';
import * as i1 from '@angular/common';

function example() {
  let count = 0
  const name = input('name');
  const example = { hello: 'hadasdello', world: 'world' };
  console.log('hello');
  function add(value) { count +=value }

  return {
    name,
    example,
    add,
    get count() {
      return count
    },
    set count(value) {
      return count = value
    }
  };
}

example.?fac = function example_Factory(t) {
  return (t || example)();
};
example.?cmp = i0.??defineComponent({
  type: example,
  selectors: [['example'], ['example'], ['Example']],
  inputs: { name: [i0.??InputFlags.SignalBased, 'name'] },
  standalone: true,
  signals: true,
  features: [i0.??StandaloneFeature],
  decls: 4,
  vars: 3,
  consts: [
    [1, 'asdasdad'],
    [3, 'hello', 'world'],
  ],
  template: function example_Template(rf, ctx) {
    if (rf & 1) {
      i0.??elementStart(0, 'div')(1, 'div', 0);
      i0.??text(2);
      i0.??elementEnd();
      i0.??element(3, 'ExampleComponent', 1);
      i0.??elementEnd();
    }
    if (rf & 2) {
      i0.??advance(2);
      i0.??textInterpolate(ctx.name);
      i0.??advance();
      i0.??property('hello', ctx.example.hello())('world', ctx.example.world());
    }
  },
  styles: [
    '.name[_ngcontent-%COMP%] { display: flex; justify-content: center; cursor: pointer; color: purple; text-decoration: inherit; }',
  ],
  dependencies: [],
});

export default example;        

FAQ:

Would anything have to change in Angular Ivy? Yes, only a very minor adjustment to the ?fac interface is needed so that it does not expect a new (for type reasons).

Does change detection (CD) have to run every time? No, due to compiling to Ivy and not using a virtual DOM, change detection does not need to run every time in functional components. Signals will still function as they do now.

Would Angular syntax need to change? Not really. The HTML and control flow can remain exactly the same, as Angular is the DNA of the framework. Here’s a link to a thread showcasing random authoring that compiles and utilises functional programming. There’s also @analogjs, which is fantastic as it pre-transpiles functions to classes, allowing Angular to handle the heavy lifting.

DX vs. Compiler, Class vs. Functions

Now back to the main point: now you understand a little about Ivy and the issues with functional programming. The comparison of classes versus functions relates to developer experience. We have compiler magic that can translate functions correctly. While functions can be easier to remember and involve less boilerplate, we need to stop comparing the current DX of classes. In my view, using classes requires less compile magic and avoids context scope issues that could arise. However, there is a valid argument for functional components. The goal of the community is to seek better DX while allowing the compiler to do the heavy lifting.

Angular authoring is close to being clean and easy to use. In the video below, I’ll explain why this is the case and what we can do to improve the developer experience (DX) while still maintaining classes. With a few small changes, we can significantly enhance DX. I’ll also explore what these changes could look like in the coming years.

If you’re interested in my approach to selector-less components and reducing double imports, you can find a detailed plan on my Treaty GitHub page, where I wrote an RFC-like document titled Optional Selectors and Implicit Import Resolution.

That said, before we jump into a discussion on functions or classes, the ‘community’ needs a critical pause to understand the following points before we move forward:

  1. What is Angular?
  2. What is Angular’s DNA (what can’t change)?
  3. What don’t you like about the current DX?
  4. What issues are you trying to resolve with any changes in authoring?

Without addressing these fundamental questions, the community and the Angular team need to take a deep look. For me, Angular changed some of its DNA when it introduced the new control flow syntax. I dislike it compared to near-native JavaScript and HTML.

What is Angular? A highly flexible framework based on classes, offering flexibility through dependency injection (DI) and the new service locator. It allows for the swapping of parts of the framework, such as the renderer or platform, to create better native applications.

What is Angular’s DNA (what can’t change)? The HTML template mostly remains untouched, as well as the way binding works with [], [()], and (). The use of services and a comprehensive DI/service locator is essential, and there should be no changes to DI that would affect fundamental community components (renderer, platform, etc.).

What I don’t like about the current DX?

  • Injectables on Services: It’s cumbersome, but this can be resolved with tokens. It would be nice if the compiler handled it, allowing our services to be clean and agnostic of Angular.
  • Directives: In the template, it’s hard to distinguish between attributes and directives. A way to make directives stand out would be beneficial. Leptos, I believe, uses use:DirectiveFunc.
  • DX Metadata: Duplicated imports are problematic. The language service can now detect this and should look into removing redundancy. Selectorless directives and components should be named if they are optional, as in many cases, it’s simply the class name without the word ‘directive’ or ‘component’ at the end.

These are my answers. What I think should be achieved in the new DX is simply to make it more straightforward. However, I would love to know your thoughts on these questions in the comments. Please share what you like and dislike.

He who does not know his past cannot make the best of his present and future, for it is from the past that we learn. Angular, like all frameworks, is a forever revolving door of changes until it dies.

要查看或添加评论,请登录

Jordan Hall的更多文章

社区洞察

其他会员也浏览了