Saltar al contenido

Artículos, tutoriales, trucos, curiosidades, reflexiones y links sobre programación web ASP.NET Core, MVC, Blazor, SignalR, Entity Framework, C#, Azure, Javascript... y lo que venga ;)

17 años online

el blog de José M. Aguilar

Inicio El autor Contactar

Artículos, tutoriales, trucos, curiosidades, reflexiones y links sobre programación web
ASP.NET Core, MVC, Blazor, SignalR, Entity Framework, C#, Azure, Javascript...

¡Microsoft MVP!
martes, 20 de diciembre de 2022
Blazor

Si habéis trabajado algo con Blazor, seguramente sabréis que los componentes escritos en archivos .razor son traducidos en tiempo de compilación a lenguaje C# y posteriormente compilados como cualquier otra clase de nuestro proyecto.

Por tanto, ¿que impediría que nos saltásemos ese paso y escribiéramos nuestros componentes directamente en C#? Efectivamente, nada ;)

En este post veremos cómo hacerlo.

Pero antes, un disclaimer: apenas existen razones prácticas para implementar componentes visuales usando C#. Usando Razor todo será más sencillo, rápido y tendremos menos riesgo a equivocarnos, así que esa debe ser la opción por defecto. Por tanto, lo que vamos a ver aquí no debéis usarlo salvo en casos justificados (por ejemplo, cuando queráis crear componentes sin UI).

Entendiendo el código C# generado por cada componente

Desde Blazor 6, durante la compilación, los archivos .razor son procesados por source generators, que generan el código C# de todos los componentes del proyecto. Una vez generado, este código C# es compilado junto con el resto de la aplicación e incluido en el ensamblado final.

Este proceso es interno, por lo que no podemos ver de forma directa los archivos .cs. Sin embargo, vimos hace tiempo que podemos indicar a estos generadores de código que dejen los archivos generados en una carpeta determinada. Simplemente insertando estas líneas en el .csproj del proyecto, podremos ver el código generado en la carpeta obj/GeneratedFiles tras cada compilación:

<PropertyGroup>
   <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
   <CompilerGeneratedFilesOutputPath>
      $(BaseIntermediateOutputPath)\GeneratedFiles
   </CompilerGeneratedFilesOutputPath>
</PropertyGroup>

Veamos el código generado para un componente sencillo. Un buen ejemplo podría ser la página Counter.razor que encontramos en un proyecto Blazor creado con las plantillas por defecto:

@page "/counter"

<PageTitle>Counter</PageTitle>
<h1>Counter</h1>
<p role="status">Current count: @currentCount</p>
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>

@code {
    private int currentCount = 0;

    private void IncrementCount()
    {
        currentCount++;
    }
}

Este componente será transformado en tiempo de compilación a una clase C# y depositado en el archivo obj/GeneratedFiles/Counter.razor.g.cs. El contenido es más o menos el siguiente (lo he simplificado un poco para hacerlo más legible):

// <auto-generated/>
namespace MyBlazorApp.Pages
{
    using System;
    ... // Otros usings
    using MyBlazorApp;
    using MyBlazorApp.Shared;

    [RouteAttribute("/counter")]
    public partial class Counter : ComponentBase
    {
        protected override void BuildRenderTree(RenderTreeBuilder __builder)
        {
            __builder.OpenComponent<Microsoft.AspNetCore.Components.Web.PageTitle>(0);
            __builder.AddAttribute(1, "ChildContent", (RenderFragment)((__builder2) => {
                __builder2.AddContent(2, "Counter");
            }
            ));
            __builder.CloseComponent();

            __builder.AddMarkupContent(3, "\r\n\r\n");
            __builder.AddMarkupContent(4, "<h1>Counter</h1>\r\n\r\n");
            __builder.OpenElement(5, "p");
            __builder.AddAttribute(6, "role", "status");
            __builder.AddContent(7, "Current count: ");
            __builder.AddContent(8, currentCount);
            __builder.CloseElement();

            __builder.AddMarkupContent(9, "\r\n\r\n");
            __builder.OpenElement(10, "button");
            __builder.AddAttribute(11, "class", "btn btn-primary");
            __builder.AddAttribute(12, "onclick", 
                       EventCallback.Factory.Create<MMouseEventArgs>(this, IncrementCount));
            __builder.AddContent(13, "Click me");
            __builder.CloseElement();
        }

        private int currentCount = 0;

        private void IncrementCount()
        {
            currentCount++;
        }
    }
}

Como podemos observar, el tooling de Blazor ha creado una clase parcial con el mismo nombre del componente, Counter, que hereda de ComponentBase. Por defecto, es generada en el espacio de nombres que coincide con la carpeta física donde se encuentra el archivo .razor de origen (en este caso es MyBlazorApp.Pages porque Counter.razor se encuentra en la carpeta /Pages del proyecto MyBlazorApp.)

Sobre ella vemos también el uso del atributo [RouteAttribute] para definir la ruta de acceso, dado que se trata de una página. Es el "marcador" que usa el sistema de routing de Blazor para identificar las clases que implementan componentes de tipo página, y asociarles las rutas mediante las cuales será posible acceder a ellas.

Observad que el hecho de que la clase generada sea parcial es importante, porque esto permite separar la lógica y estado a una clase code-behind independiente, como vimos por aquí hace algún tiempo.

En el código de la clase podemos distinguir dos zonas claramente diferenciadas:

  • El método BuildRenderTree(), que es donde se renderiza el componente. Lo desmenuzaremos un poco más abajo.
  • Las propiedades y métodos usados para implementar la lógica del componente. En este caso, son simplemente el valor actual del contador (el campo currentCount) y el método IncrementCount() que lo incrementará cuando el usuario haga click en el botón.

No hay mucho más misterio ahí: al fin y al cabo, se trata de una clase C# completamente normal.

El método BuildRenderTree()

Este método es el encargado de componer el HTML que se encontrará en el navegador cuando el componente sea renderizado. En él, podemos ver que se utiliza un parámetro de tipo RenderTreeBuilder para definir el marcado de salida mediante métodos como los que hemos visto utilizados más arriba:

  • AddMarkupElement(), para añadir código de marcado estático,
  • OpenElement(), abre un nuevo elemento,
  • AddAttributes(), para establecer atributos al último elemento abierto,
  • CloseElement(), cierra el elemento actual y termina su definición.

Por ejemplo, la siguiente porción de código es la encargada de generar el párrafo <p> en el que se muestra el valor actual del contador:

__builder.OpenElement(5, "p");
__builder.AddAttribute(6, "role", "status");
__builder.AddContent(7, "Current count: ");
__builder.AddContent(8, currentCount);
__builder.CloseElement();

Aparte, existen muchos más métodos en RenderTreeBuilder que podéis consultar en la documentación oficial, como OpenComponent() para insertar un componente, o CloseComponent() para cerrar su definición.

En el bloque de código anterior, seguramente os habrá llamado la atención que todos los métodos que añaden algún tipo de contenido al resultado envían como primer parámetro un número secuencial. Se trata del número de frame, y es usado por el marco de trabajo para identificar con precisión los cambios efectuados en el DOM tras producirse una renderización, con objeto de actualizar en el navegador exclusivamente el fragmento que haya sido modificado.

Por ejemplo, cuando pulsamos el botón en este componente, se incrementará el valor del contador. Tras ello, Blazor ejecutará el BuildRenderTree() del componente y, comparando el resultado con el contenido anterior de la página, detectará que únicamente cambió el contenido del frame con número secuencia "8", por lo que sabrá que únicamente debe actualizar dicho fragmento de la página.

Es importante tener en cuenta que este número de secuencia debe ser único en cada elemento, y jamás debe compartirse entre distintas llamadas durante la renderización de un componente. Por ello, es totalmente recomendable que sea hard-coded y no generarlo con contadores o por cualquier otro tipo de vía programática. Podéis leer algo más sobre esto en este Gist del propio Steve Sanderson, aunque básicamente se debe a evitar riesgos y problemas de rendimiento que podrían derivarse del uso del mismo número de secuencia en varios elementos distintos.

De hecho, en la documentación oficial se suele decir que el número secuencial debemos asimilarlo mentalmente como un "número de línea del código fuente" y no como un orden de ejecución o similar, y recomiendan no generarlos dinámicamente.

2. Implementar componentes Blazor con C#

Dicho todo lo anterior, lo único que necesitamos para escribir un componente Blazor en C# es crear una clase pública y hacer que herede de ComponentBase, cuya implementación dependerá del tipo y utilidad del componente:

  • Si tiene interfaz de usuario, habrá que definirla en BuildRenderTree().
  • Si es una página de la aplicación, deberemos decorarla con [Route].
  • Si requiere dependencias, podemos definirlas como propiedades públicas decoradas con [Inject].
  • Si tiene parámetros, debemos añadirle propiedades decoradas con [Parameter] ... etc

Veamos un ejemplo. El siguiente código muestra un componente muy simple, que muestra en el navegador un saludo con el nombre que le enviamos como parámetro. Fijaos que definimos el parámetro exactamente de la misma forma que en un .razor, y que en BuildRenderTree() armamos la interfaz de usuario de forma manual.

public class Greeter: ComponentBase
{
    [Parameter] public string Name { get; set; }

    protected override void BuildRenderTree(RenderTreeBuilder builder)
    {
        builder.OpenElement(0, "p");
        builder.AddContent(1, "Hola, ");
        builder.AddContent(2, Name);
        builder.CloseElement();
    }
}

Si insertamos esta clase en un proyecto Blazor, veremos que a partir de ese momento podemos usar el componente <Greeter> desde cualquier punto:

<Greeter Name="John"></Greeter>

Observad que realmente no había ningún motivo para haberlo implementado exclusivamente con C#: podríamos conseguir lo mismo utilizando un .razor, y de hecho sería la forma más sencilla y natural de hacerlo, pues es un componente eminentemente visual. Pero bueno, el post iba de esto ;)

Echemos un vistazo a otro ejemplo, y esta vez le veremos un poco más de sentido porque se trata de un componente sin UI y, por tanto, no echaremos nada de menos a Razor. Se trata del componente RedirectToLogin, cuya única misión es redirigir al usuario hacia la página de login de la aplicación en cuanto es inicializado (en la práctica, inmediatamente después de ser instanciado):

public class RedirectToLogin : ComponentBase
{
    [Inject]
    protected NavigationManager NavManager { get; set; }

    protected override void OnInitialized()
    {
        var uri = NavManager.ToBaseRelativePath(NavManager.Uri);
        NavManager.NavigateTo("login?returnUrl=" + uri);
    }
}

Este componente podría ser utilizado en el interior del fragmento NotAuthorized del componente AuthorizeRouteView de App.razor para forzar esta redirección cuando se detecte un intento de acceso por parte de un usuario anónimo a una página que requiere autorización:

...
<AuthorizeRouteView RouteData="@routeData"
                    DefaultLayout="@typeof(MainLayout)">
    <NotAuthorized>
        @if (!context.User.Identity.IsAuthenticated)
        {
            <RedirectToLogin />
        }
        else
        {
            <p>You are not authorized to access this resource.</p>
        }
    </NotAuthorized>
</AuthorizeRouteView>
...

¡Y hasta aquí hemos llegado! Creo que con esto es suficiente para que tengáis una idea de cómo implementar componentes sin usar Razor, por si en alguna ocasión os hace falta, pero recordad: si creáis los componentes usando la sintaxis Razor, todo será más sencillo.

Publicado en Variable not found.

Aún no hay comentarios, ¡sé el primero!