Back to "HTMX (y un poco de Alpine.js) para una experiencia tipo SPA en ASP.NET Core"

This is a viewer only at the moment see the article on how this works.

To update the preview hit Ctrl-Alt-R (or ⌘-Alt-R on Mac) or Enter to refresh. The Save icon lets you save the markdown file to disk

This is a preview from the server running through my markdig pipeline

Alpine.js ASP.NET HTMX

HTMX (y un poco de Alpine.js) para una experiencia tipo SPA en ASP.NET Core

Sunday, 15 September 2024

Introducción

En este sitio yo uso HTMX extensamente, esta es una manera súper fácil de hacer que su sitio se sienta más sensible y más suave sin tener que escribir un montón de JavaScript.

NOTA: No estoy del todo contento con esto todavía, HTMX tiene algunos elementos que hacen este complicado

HTMX y ASP.NET Core

El uso de HTMX con ASP.NET Core es un poco de un sueño, he escrito sobre esto anteriormente en este post. Es realmente bastante fácil 'HTMXify' su sitio, y es una gran manera de hacer que su sitio se sienta más sensible.

Enlaces normales

Como parte de mi último ir alrededor usando HTMX decidí hacer tantos enlaces en este sitio utilizar HTMX como sea posible. Así, por ejemplo, si hace clic en el botón 'Home' por encima de él ahora utiliza HTMX para cargar el contenido de la página.

Lo logro simplemente añadiendo algunas propiedades a la etiqueta de enlace, como esta:

    <a asp-action="IndexPartial" asp-controller="Home" hx-swap="show:window:top"  hx-target="#contentcontainer" hx-boost="true" class="mr-auto sm:mr-6">
     <div class="svg-container">
        <img src="/img/logo.svg" asp-append-version="true"  width="180px" height="30px"  alt="logo"  :class="{ 'img-filter-dark': isDarkMode }"/>
     </div>
    </a>

Aquí puedes ver que añado el hx-swap atributo, esto le dice a HTMX para intercambiar el contenido del elemento de destino con el contenido devuelto desde el servidor.

NOTA: NO utilice hx-pushurl, ya que esto hará que el navegador vuelva a cargar la página en el botón trasero dos veces.

Los hx-target el atributo le dice a HTMX dónde poner el contenido devuelto desde el servidor. En este caso es la #contentcontainer elemento (donde se carga todo el contenido de la página).

También añado: hx-swap="show:window:top que hace que la ventana se desplace a la parte superior de la página cuando se intercambia el contenido.

La parte C# de esto es bastante simple, sólo duplica mi método Índice normal, pero en su lugar carga la vista parcial. El código se muestra a continuación. Otra vez esto usa HTMX.NET para detectar la hx-request cabecera y devolver la vista parcial. También hay un recorte para devolver la vista normal si la solicitud no es una solicitud HTMX.

    [Route("/IndexPartial")]
    [OutputCache(Duration = 3600, VaryByHeaderNames = new[] { "hx-request" })]
    [ResponseCache(Duration = 300, VaryByHeader = "hx-request",
        Location = ResponseCacheLocation.Any)]
    [HttpGet]
    public async Task<IActionResult> IndexPartial()
    {
        ViewBag.Title = "mostlylucid";
        if(!Request.IsHtmx()) return RedirectToAction("Index");
        var authenticateResult = await GetUserInfo();
        var posts = await BlogService.GetPagedPosts(1, 5);
        posts.LinkUrl = Url.Action("Index", "Home");
     
        var indexPageViewModel = new IndexPageViewModel
        {
            Posts = posts, Authenticated = authenticateResult.LoggedIn, Name = authenticateResult.Name,
            AvatarUrl = authenticateResult.AvatarUrl
        };
        return PartialView("_HomePartial", indexPageViewModel);
    }

El resultado de esto es que este enlace (y otros que utilizan la misma técnica) carga como 'AJAX' peticiones y el contenido se intercambia sin una recarga de página completa. Esto evita el molesto'flash de luz 'que obtiene cuando una página se recarga debido a cómo funciona mi cambio de tema.

Enlaces de blog (la forma más sencilla)

Así que el método a continuación se puede hacer para trabajar sin embargo es súper difícil y un poco incómodo. A HTMX realmente le gusta trabajar usando el enfoque basado en atributos. Uso htmx.ajax es una especie de método "si todo lo demás falla" y significa que necesitas meterte con la historia de Borwser. Desafortunadamente el historial del navegador usando pushState replaceState y popstate es realmente un poco de dolor en el cuello (pero puede ser súper potente para los casos de borde).

En lugar de eso, lo que estoy haciendo ahora es agregar hx-boost a la etiqueta que contiene contenido de blog.

   <div class="prose prose max-w-none border-b py-2 text-black dark:prose-dark sm:py-2" hx-boost="true"  hx-target="#contentcontainer">
        @Html.Raw(Model.HtmlContent)
    </div>

Esto parece funcionar bien. hx-boost se puede colocar en los métodos de los padres y aplicar a todos los enlaces de los hijos. También es más estándar HTMX sin meterse con la historia, etc.

En la rara ocasión que no desea utilizar HTMX para enlaces particulares que sólo puede añadir hx-boost="false" al enlace.

Enlaces de blog (NOTA: He abandonado este enfoque pero mantenerlo aquí como referencia)

Sin embargo, un problema con este enfoque fue mi blog enlaces como este: HTMX para una experiencia tipo SPA que no son enlaces "normales". Estos enlaces son generados por el analizador Markdown; mientras que podría escribir una extensión MarkDig para añadir los atributos HTMX a los enlaces, decidí usar un enfoque diferente (porque ya tengo un montón de contenido que no quiero volver a analizar).

En su lugar he añadido una función JavaScript que busca todos estos tipos de enlace, a continuación, utiliza htmx.ajax para hacer el intercambio. Esto es esencialmente lo que HTMX hace de todos modos sólo'manual'.

Como se puede ver esto es sólo una función simple que busca todos los enlaces en el div.prose elemento que comienza con un / y luego añade un oyente de eventos a ellos. Cuando se hace clic en el enlace se evita el evento, se extrae la URL y luego la htmx.ajax función se llama con la URL y el elemento de destino a actualizar.

(function(window) {
   
    window.blogswitcher =() =>  {
        const blogLinks = document.querySelectorAll('div.prose  a[href^="/"]');

        // Iterate through all the selected blog links
        blogLinks.forEach(link => {
            link.addEventListener('click', function(event) {
               event.preventDefault();
                let link = event.currentTarget;
                let url = link.href;
                htmx.ajax('get', url, {
                    target: '#contentcontainer',  // The container to update
                    swap: 'innerHTML',            // Replace the content inside the target


                }).then(function() {
                    history.pushState(null, '', url);
                  
                        window.scrollTo({
                            top: 0,
                            behavior: 'smooth' // For a smooth scrolling effect
                        });
                    
                });
            });
        });
        
    };
})(window);

Además, una vez que el intercambio se ha hecho a continuación, empujar la nueva URL al historial del navegador y desplazarse a la parte superior de la página. Utilizo este enfoque en algunos lugares (como la búsqueda) para dar una agradable experiencia suave mantequilla.

Botón de espalda (Esto es difícil de conseguir trabajando)

El botón de atrás en este tipo de aplicación puede ser problemático; MUCHOS SPA 'adecuados' deshabilitan el botón de atrás o soportan este comportamiento incorrecto. Sin embargo, quería asegurarme de que el botón de atrás funcionara como se esperaba. Nota: Para conseguir que el botón de atrás funcione como se esperaba también necesitamos escuchar para el popState evento. Este evento se activa cuando el usuario navega hacia atrás o hacia adelante en el historial del navegador. Cuando se dispara este evento podemos recargar el contenido de la URL actual.

window.addEventListener("popstate",   (event) => {
 // When the user navigates back, reload the content for the current URL
 event.preventDefault();
 let url = window.location.href;
 // Perform the HTMX AJAX request to load the content for the current state
 htmx.ajax('get', url, {
  target: '#contentcontainer',
  swap: 'innerHTML'
 }).then(function () {
  // Scroll to the top of the page
  window.scrollTo({
   top: 0,
   behavior: 'smooth'
  });
 });
});

En este código evitamos el comportamiento predeterminado de popstate del navegador con el event.preventDefault() Llama. A continuación, extraer la URL actual de la window.location.href y realice una solicitud HTMX AJAX para cargar el contenido para el estado actual. Una vez cargado el contenido, nos desplazamos a la parte superior de la página.

Como se mencionó anteriormente, no puede utilizar TAMBIÉN hx-pushurl ya que esto hará que el navegador vuelva a cargar la página en el botón trasero dos veces.

HTMX más generalmente (y Alpine.js)

Como habrás adivinado, soy un poco fan de HTMX, combinado con ASP.NET Core y un poco de Alpine.js que permite devs para crear una experiencia de usuario realmente agradable con un esfuerzo mínimo. Lo más importante que prefiero sobre los tipos de los marcos de cliente más 'plenamente formados' como React / Angular etc... es que todavía me da una tubería de renderizado del lado del servidor completo con ASP.NET Core pero el'sentimiento' de un SPA. Alpine.js permite la interactividad simple del lado del cliente, una característica muy simple que añadí recientemente estaba haciendo que mis categorías "ocultan" pero se expanden en un clic.

Usando Alpine.js para esto significa que no se requiere JavaScript adicional.

        <div class="mb-8 mt-6 border-neutral-400 dark:border-neutral-600 border rounded-lg" x-data="{ showCategories: false }">
            <h4 
                class="px-5 py-1 bg-neutral-500  bg-opacity-10 rounded-lg  font-body text-primary dark:text-white w-full flex justify-between items-center cursor-pointer"
                x-on:click="showCategories = !showCategories"
            >
                Categories
                <span>
                    <i
                        class="bx text-2xl"
                        :class="showCategories ? 'bx-chevron-up' : 'bx-chevron-down'"
                    ></i>
                </span>
            </h4>
            <div 
                class="flex flex-wrap gap-2 pt-2 pl-5 pr-5 pb-2"
                x-show="showCategories" 
                x-cloak
                x-transition:enter="max-h-0 opacity-0"
                x-transition:enter-end="max-h-screen opacity-100"
                x-transition:leave="max-h-screen opacity-100"
                x-transition:leave-end="max-h-0 opacity-0"
            >
                @foreach (var category in ViewBag.Categories)
                {
                    <partial name="_Category" model="category"/>
                }
           
            </div>
        </div>

Aquí puedes ver que uso el x-data atributo para crear un nuevo objeto de datos Alpine.js, en este caso showCategories. Este es un booleano que se conmuta cuando el h4 se hace clic en el elemento. Los x-on:click atributo se utiliza para vincular el evento de clic a la función toggle.

Dentro de la lista de categorías utilizo el x-show para mostrar u ocultar la lista de categorías basada en el valor de showCategories. También uso el x-transition atributo para añadir un efecto de diapositiva agradable hacia abajo cuando las categorías se muestran u ocultas. Los x-cloak atributo se utiliza para evitar el 'flicker' de las categorías que se muestran antes de que el JavaScript se haya ejecutado. Tengo una pequeña clase CSS definida para esto:

[x-cloak] { display: none !important; }

Conclusión

Así que eso es todo, un breve artículo sobre cómo utilizar HTMX y un lugar de Alpine.js para hacer que su sitio se sienta más sensible y "SPA-como". Espero que haya encontrado esto útil, si usted tiene alguna pregunta o comentario por favor no dude en dejarlos abajo.

logo

©2024 Scott Galloway