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, 10 de abril de 2018
ASP.NET Core Cuando desarrollamos una aplicación ASP.NET Core MVC, es muy frecuente encontrar que nuestras vistas definen secciones de contenido usando la directiva @section que más adelante se incluyen en el Layout para formar parte de la información enviada al lado cliente.

Sin embargo, ya sabemos que no es posible utilizar secciones en vistas parciales o cualquier otro tipo de componentes, lo cual complica algunos escenarios.

Por ejemplo, es relativamente frecuente que en el lado servidor generemos código HTML utilizando algún mecanismo de reutilización de marcado, como vistas parciales, templated helperstag helpers, etc., y que éste requiera recursos extra como scripts o CSS que obligatoriamente deberíamos incluir en las vistas principales donde se utilicen.

Más en el terreno práctico, imaginad que creamos una vista parcial que requiere que las páginas donde vayamos a usarla incluyan una referencia a un archivo de scripts y un bloque de inicialización como el siguiente:
@* Partial view: _MyComponent.cshtml *@
<div class="my-component">
    <!-- UI goes here -->
</div>

<!-- And now, add dependencies and initialization code -->
<script src="path/to/my-component.js" ></script>
<link rel="stylesheet" href="path/to/my-component.css" ></script>
<script>
   // Initialization code goes here
    $(".my-component").setup();
</script>
Aquí es donde comienzan los problemas. Si lo enfocamos como hemos mostrado anteriormente y resulta que en una misma página podemos tener más de una instancia de dicha vista parcial, estaremos incluyendo en la página las referencias y código de inicialización más de una vez, lo cual podría traernos bastantes dolores de cabeza:
<html>
    <body>
        ...
        <!-- My Component, instance #1 -->
        @Html.Partial("_MyComponent.cshtml")
        ...
        <!-- My Component, instance #2 -->
        @Html.Partial("_MyComponent.cshtml")
        ...
    </body>
</html>
Además de problemas de rendimiento y mal funcionamiento de la aplicación, también podría ocurrir que al insertar la parcial en la página, los scripts quizás aparecerían antes de referenciar los componentes o frameworks utilizados, pues normalmente esto suele hacerse al cerrarse el <body>.

Por ejemplo, en el código anterior nuestro componente utiliza JQuery, por lo que necesitamos que este framework se cargue antes de que nuestro código aparezca en la página, cosa que no siempre podremos asegurar. Así, el código enviado a la página podría ser el siguiente:
<html>
    <body>
        ...
        <!-- My Component, instance #1 -->
        <div class="my-component">
            <!-- UI goes here -->
        </div>
        <script src="path/to/my-component.js" ></script>
        <link rel="stylesheet" href="path/to/my-component.css" ></script>
        <script>
        // Initialization code goes here
            $(".my-component").setup();
        </script>        
        ...

        <!-- My Component, instance #2 -->
        <div class="my-component">
            <!-- UI goes here -->
        </div>
        <script src="path/to/my-component.js" ></script>
        <link rel="stylesheet" href="path/to/my-component.css" ></script>
        <script>
        // Initialization code goes here
            $(".my-component").setup();
        </script>        
        ...
        <script src="/scripts/jquery.min.js"></script>
    </body>
</html>
Obviamente, esto provocaría un error en la página.

En este post vamos a ver un posible enfoque para solucionar este escenario. La idea es bastante sencilla, pues simplemente vamos a hacer posible que cada vista parcial o componente pueda registrar código HTML que luego sea renderizado en un lugar específico de la página. El objetivo sería algo similar a lo que conseguimos con la definición de secciones @section y @RenderSection(), pero aplicable a cualquier tipo de vista parcial.

¿Qué queremos conseguir?

Creo que para tenerlo más claro, lo mejor es comenzar a explicarlo viendo lo que pretendemos obtener. La idea es que desde cualquier componente de la Vista en aplicaciones MVC podamos registrar bloques de código (scripts, CSS o lo que sea) mediante un tag helper como el siguiente:
@* _MyComponent.cshtml *@
<div class="my-component">
    <!-- UI goes here -->
</div>

<register-block dynamic-section="scripts" key="my-component">
    <script src="path/to/my-component.js" ></script>
    <link rel="stylesheet" href="path/to/my-component.css" />
</register-block>
Observad que el sistema que vamos a crear es muy similar a las secciones que en Razor definimos de forma estática con @section y renderizamos con @RenderSection(), por lo que llamaremos a nuestro componente Dynamic Sections.
El tag helper <register-block> que veis en el código anterior no generará nada en la vista desde la que se renderice nuestra vista parcial "_MyComponent.cshtml"; simplemente irá guardando el contenido en un diccionario en memoria asociado a la sección “scripts” y bajo la clave “my-component”. Si existen en una página varias instancias de nuestro componente, en memoria sólo se registrará una vez porque todas utilizarán la misma clave y sección.

Ya desde el layout o cualquier otro punto, podemos introducir el contenido que ha ido siendo registrado mediante otro tag helper:
@* _Layout.cshtml *@
<html>
    <body>
        ...
        <dynamic-section name="scripts" />
    </body>
</html>
Como podréis suponer, el tag helper <dynamic-section> renderizará el contenido asociado a la sección dinámica llamada “scripts” justo en el punto de la página en el que ha sido invocado. Ahí es donde se incluirá todo el código registrado por los distintos componentes que conforman la página.

Profundizando un poco en la idea podemos conseguir cosas bastante interesantes. Por ejemplo, jugando con la clave única del bloque de código podemos conseguir que determinados componentes incluyan en la página un código común para todas las instancias, y luego un código específico por cada instancia:
@* _MyComponent.cshtml *@
@model string
@{
    var id = "c" + DateTime.Now.Ticks; // Unique id for this instance
}
<div class="my-component" id="@id">
    <h3>@Model</h3>
</div>

<register-block dynamic-section="scripts" key="my-component-common">
    <!-- Common initialization code for my component (generated only once) -->
</register-block>

<register-block section="scripts" key="my-component-@id">
    <!-- Initialization code for @id  -->
</register-block>
De esta forma, si renderizamos una vista como la siguiente, en la que se introducen dos instancias distintas del la vista parcial "_MyComponent", obtendremos el resultado mostrado justo a continuación:
@* View: Index.cshtml *@
...
<div class="jumbotron">
    @Html.Partial("_MyComponent", "Hello, world")
    @Html.Partial("_MyComponent", "How are u doing?")
</div>
...

<!-- Página renderizada -->
<html>
    <body>
        ...
        <div class="jumbotron">
            <div class="my-component" id="c636551599516842141">
                <h1>Hello, world</h1>
            </div>
            <div class="my-component" id="c636551599516868415">
                <h1>How are u doing?</h1>
            </div>
        </div>
        ...

        <!-- Common initialization code for my component (generated only once) -->
        <!-- Initialization code for c636551599516842141 -->
        <!-- Initialization code for c636551599516868415 -->
    </body>
</html>

Implementando el core

Como podremos ver a nivel de código, el funcionamiento creo que es el más simple posible, pues aprovechamos la posibilidad que nos brinda ASP.NET Core para almacenar información personalizada en la colección HttpContext.Items. El contenido que vayamos guardando ahí persistirá exclusivamente durante la petición actual y será descartado cuando ésta finalice, por lo que parece el lugar indicado para ir almacenando los contenidos registrados mediante el tag helper <register-block> hasta que sean introducidos en la página por <dynamic-section>.
Spoiler: el código fuente del proyecto está disponible en GitHub, y también podéis usarlo directamente descargando el paquete NuGet. Al final del post tenéis más información al respecto.
Aunque hemos comentado que estas funcionalidades de registro y obtención de bloques de código estarán disponibles en forma de tag helper, con muy poco esfuerzo podríamos implementarlas también en forma de HTML helpers, por lo que vamos a crear el “core” del sistema de forma que podamos utilizarlo desde ambas opciones.
public static class DynamicSectionsHelpers
{
    private static string HttpContextItemName = nameof(DynamicSectionsHelpers);

    /// <summary>
    /// Registers a code block in a dynamic section.
    /// </summary>
    public static void RegisterBlock(this HttpContext httpContext,
        string dynamicSectionName, string key, string content)
    {
        var sections = (SectionDictionary)httpContext.Items[HttpContextItemName];
        if (sections == null)
        {
            sections = new SectionDictionary();
            httpContext.Items[HttpContextItemName] = sections;
        }
        dynamicSectionName = dynamicSectionName ?? string.Empty;
        key = key ?? string.Empty;
        if (!sections.ContainsKey(dynamicSectionName))
        {
            sections[dynamicSectionName] = new BlockDictionary();
        }
        sections[dynamicSectionName][key] = content;
    }

    /// <summary>
    /// Gets the content of the specified dynamic section. 
    /// </summary>
    public static string GetDynamicSection(this HttpContext httpContext, 
        string dynamicSectionName, bool remove = true)
    {
        var result = string.Empty;
        var sections = (SectionDictionary)httpContext.Items[HttpContextItemName];
        if (sections == null)
            return result;

        dynamicSectionName = dynamicSectionName ?? String.Empty;
        if (dynamicSectionName == "*")
        {
            // Get all sections
            result = GetSectionAndRemoveWhenRequested(sections, remove, sections.Keys.ToArray());
        }
        else if (sections.ContainsKey(dynamicSectionName))
        {
            // Get only the specified section
            result = GetSectionAndRemoveWhenRequested(sections, remove, dynamicSectionName);
        }

        return result;
    }

    private static string GetSectionAndRemoveWhenRequested(SectionDictionary sections, 
        bool remove, params string[] sectionNames)
    {
        var sb = new StringBuilder();
        foreach (var sectionName in sectionNames)
        {
            var contents = sections[sectionName].Select(c => c.Value);
            foreach (var content in contents)
            {
                sb.Append(content);
            }

            if (remove) // This section can be obtained only once!
            {
                sections.Remove(sectionName); 
            }
        }
        return sb.ToString();
    }

    private class BlockDictionary : Dictionary<string, string>
    {
        public BlockDictionary() : base(StringComparer.CurrentCultureIgnoreCase) { }
    }

    private class SectionDictionary : Dictionary<string, BlockDictionary>
    {
        public SectionDictionary() : base(StringComparer.CurrentCultureIgnoreCase) { }
    }
}
Creo que el código se entiende relativamente bien, pero comentaré rápidamente sus dos principales métodos, porque el resto ya son detalles de implementación:
  • El método RegisterBlock() es el encargado de registrar un contenido en una sección, utilizando la clave que le llega como parámetro.
  • El método GetDynamicSection() retorna el contenido de todos los bloques registrados en una sección determinada. Sin embargo, hemos implementado una convención que puede venir bien en algunas ocasiones: si el nombre de la sección a renderizar es “*”, se renderizarán todas las secciones registradas. También, mediante un parámetro es posible indicar si el contenido debe ser eliminado del diccionario una vez haya sido obtenido (por defecto, true).

Implementación de HTML Helpers

Partiendo de la clase anterior, que prácticamente implementa todo lo que necesitamos, crear helpers HTML que faciliten su uso desde vistas es trivial.
public static class DynamicSectionsHtmlExtensions
{
    /// <summary>
    /// Registers a code block in a dynamic section.
    /// </summary>
    public static string RegisterBlock(this IHtmlHelper helper,
        string dynamicSectionName, string key, string content)
    {
        helper.ViewContext.HttpContext
            .RegisterBlock(dynamicSectionName, key, content);
        return string.Empty;
    }

    /// <summary>
    /// Gets the content of the specified dynamic section. 
    /// </summary>
    public static IHtmlContent DynamicSection(this IHtmlHelper helper, 
        string dynamicSectionName = "*", bool remove = true)
    {
        var blocks = helper.ViewContext.HttpContext
            .GetDynamicSection(dynamicSectionName, remove);
        return new HtmlString(blocks);
    }
}
De esta forma, podremos hacer uso de construcciones como las siguientes para registrar un contenido:
...
@Html.RegisterBlock("scripts", "my-component", "<script>console.log('Hello, world!');</script>")
...
Y también para renderizar las secciones más adelante:
<html>
    <body>
        ...
        @Html.DynamicSection("scripts")
    </body>
</html>    
Recordad que para que los extensores de IHtmlHelper estén disponibles en una vista debemos importar en ella el espacio de nombres donde han sido definidos, bien mediante algo como @using DynamicSections en la propia vista, o bien de forma global en /Views/_ViewImports.cshtml.

Implementación de tag helpers

Comencemos creando el tag helper <register-block>. Como podemos observar, el código es bastante simple porque lo único que hace es renderizar el contenido de la etiqueta y llamar al método DynamicSections.RegisterBlock(), visto anteriormente, para almacenarlo. Tras ello, elimina el contenido para que no sea volcado a la página en ese momento.
[HtmlTargetElement("register-block")]
public class RegisterBlockTagHelper: TagHelper
{
    [ViewContext, HtmlAttributeNotBound]
    public ViewContext ViewContext { get; set; }

    public string Key { get; set; }
    public string DynamicSection { get; set; }

    public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
    {
        var childContents = await output.GetChildContentAsync(useCachedResult: true);
        var content = childContents.GetContent();
        ViewContext.HttpContext.RegisterBlock(DynamicSection, Key, content);
        output.SuppressOutput();
    }
}
A continuación, veamos el tag helper <dynamic-section>, cuyo código es igual de sencillo:
[HtmlTargetElement("dynamic-section", Attributes="Name")]
public class DynamicSectionTagHelper : TagHelper
{
    [ViewContext, HtmlAttributeNotBound]
    public ViewContext ViewContext { get; set; }

    public string Name { get; set; }
    public bool Remove { get; set; }

    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        output.SuppressOutput();
        output.Content.SetHtmlContent(ViewContext.HttpContext.GetDynamicSection(Name, Remove));
    }
}
Recordad que para que los nuevos tag helpers estén disponibles en una vista debemos importarlos mediante usando la directiva como sigue, bien en la propia vista o bien de forma global en /Views/_ViewImports.cshtml:
@addTagHelper *, DynamicSections
El código anterior asume que los tag helpers han sido definidos en un ensamblado llamado DynamicSections. Si no es así, sustituid este nombre por el que hayáis usado (por ejemplo, el nombre del ensamblado principal de vuestro proyecto).

Me viene bien este código, ¿cómo lo incluyo en mi proyecto?

Lo primero, recuerda que no debes confiar en el código de extraños ;-D

Pero si es tu intención, mejor que copiarlo y pegarlo desde el blog quizás te sea más sencillo acudir a descargarlo desde el repositorio en GitHub, o utilizar directamente el paquete NuGet “DynamicSections” donde ya lo tendrás cocinado. En cualquier caso, es interesante que eches un vistazo primero a la home del proyecto en GitHub, donde encontrarás algo de documentación y algunos ejemplos de uso.

Por supuesto, feedback is welcome ;)

Publicado en Variable not found.

2 Comentarios:

Albert Capdevila dijo...

Muy interesante y muy práctico.

En un proyecto que lleva activo desde el 2013, cuya primera versión fue en MVC2, utilicé una extensión de HtmlHelper para reproducir este mismo comportamiento.

La verdad, nunca estuve cómodo con ella porque no imitaba el código HTML. Eran sentencias de Razor en la parte superior de las páginas y no revelaban bien su intención.

Me ha gustado mucho tu ejemplo y si tengo la oportunidad seguro que lo pongo en práctica en el próximo proyecto en ASP.NET Core que pueda caer :)

¡Gracias por compartirlo!

José María Aguilar dijo...

Hola, Albert!

Gracias por tu comentario :)

Efectivamente, el concepto es aplicable a MVC5 y anteriores; e incluso la forma de implementarlo en estos frameworks podría ser bastante parecida, pues también ahí existía el HttpContext.Items que usamos aquí. Pero como comentas, el resultado no era igual de limpio que en ASP.NET Core, creo que más que nada porque los tag helpers añaden mucha claridad a nivel de código.

Un saludo!