domingo, 12 de abril de 2015

ASP.NET MVC. Gestión de Scripts en Plantillas y Vistas Parciales

En este artículo mostraré cómo podemos gestionar los scripts de plantillas y vistas parciales sin cargar código de más en páginas que no lo necesitan ni repetir código en páginas que muestran varias veces una misma plantilla.

En el artículo anterior (ASP.NET MVC. Platilla de editor para fecha y hora) mostré cómo crear editores personalizados para los tipos de datos de fecha y fecha/hora.

Al final del artículo se presentaban algunos problemas referentes a los scripts utilizados: el hecho de que el archivo javascript del plugin datetimepicker.js se carga en todas las páginas (sea o no necesario) y que por cada campo de texto  creado en una página se ejecutaba el código de inicialización de todos los campos, lo que provocaba que se ejecutara el mismo código varias veces de forma innecesaria.

Comprendiendo el problema


Para mostrar el problema voy a continuar con el código del proyecto que creé en el artículo anterior.

En primer lugar voy a añadir dos nuevas propiedades a la clase Persona: el campo Fecha de Registro de tipo fecha y el campo Última Operación de tipo fecha/hora.

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Web;

namespace MVCDateTimePicker.Models
{
    public class Persona
    {
        public string Nombre { get; set; }
        public string Apellido { get; set; }
        public string Ciudad { get; set; }
        [Display(Name="Fecha de Nacimiento")]
        [DataType(DataType.Date)]
        public DateTime FechaNacimiento { get; set; }
        [Display(Name="Último Acceso")]
        public DateTime UltimoAcceso { get; set; }
        [DataType(DataType.Date)]
        public DateTime FechaRegistro { get; set; }
        [Display(Name = "Última Operación")]
        public DateTime UltimaOperacion { get; set; }
    }
}
Imports System.ComponentModel.DataAnnotations

Public Class Persona
    Property Nombre As String
    Property Apellido As String
    Property Ciudad As String
    <Display(Name:="Fecha de Nacimiento")> _
    <DataType(DataType.Date)> _
    Property FechaNacimiento As DateTime
    <Display(Name:="Último Acceso")> _
    Property UltimoAcceso As DateTime
    <Display(Name:="Fecha de Registro")> _
    <DataType(DataType.Date)> _
    Property FechaRegistro As DateTime
    <Display(Name:="Última Operación")> _
    Property UltimaOperacion As DateTime
End Class

A continuación voy a quitar la referencia al plugin datetimepicker de la página de diseño principal y la incluirá en las plantillas de los editores para tener todo el código relacionado con cada editor en su propio fichero y evitar así que se incluya el código del plugin en las páginas en las que no sea necesario.

La cabecera del archivo _Layout.cshtml/_Layout.vbhtml quedaría:

<head>
    <meta name="viewport" content="width=device-width" />
    <title>@ViewBag.Title</title>
    <link href="~/Content/jquery.datetimepicker.css" rel="stylesheet" />
    <script src="~/Scripts/jquery-2.1.3.min.js"></script>
</head>
<head>
    <meta name="viewport" content="width=device-width" />
    <title>@ViewData("Title")</title>
    <link href="~/Content/jquery.datetimepicker.css" rel="stylesheet" />
    <script src="~/Scripts/jquery-2.1.3.min.js"></script>
</head>

El editor Date.cshtml/Date.vbhtml:

@model DateTime

<script src="~/Scripts/jquery.datetimepicker.js"></script>
<script>
    $(function () {
        $(".datepicker").datetimepicker({
            lang: 'es',
            format: 'd/m/Y',
            formatDate: 'd/m/Y',
            dayOfWeekStart: 1,
            mask: true,
            timepicker: false
        });
    });
</script>

@Html.TextBox("", (Model == DateTime.MinValue ? null : Model.ToString("dd/MM/yyyy"))
    , new { @class = "datepicker" })
@ModelType DateTime

<script src="~/Scripts/jquery.datetimepicker.js"></script>
<script>
    $(function () {
        $(".datepicker").datetimepicker({
            lang: 'es',
            format: 'd/m/Y',
            formatDate: 'd/m/Y',
            dayOfWeekStart: 1,
            mask: true,
            timepicker: false
        });
    });
</script>

@Html.TextBox("", IIf(Model = DateTime.MinValue, Nothing, Model.ToString("dd/MM/yyyy")) _
                   , New With {.class = "datepicker"})

Y el editor DateTime.cshtml/DateTime.vbhtml:

@model DateTime

<script src="~/Scripts/jquery.datetimepicker.js"></script>
<script>
    $(function () {
        $(".datetimepicker").datetimepicker({
            lang: 'es',
            format: 'd/m/Y H:i',
            formatDate: 'd/m/Y',
            dayOfWeekStart: 1,
            mask: true
        });
    });
</script>

@Html.TextBox("", (Model == DateTime.MinValue ? null : Model.ToString("dd/MM/yyyy hh:mm"))
    , new { @class = "datetimepicker" })
@ModelType DateTime

<script src="~/Scripts/jquery.datetimepicker.js"></script>
<script>
    $(function () {
        $(".datetimepicker").datetimepicker({
            lang: 'es',
            format: 'd/m/Y H:i',
            formatDate: 'd/m/Y',
            dayOfWeekStart: 1,
            mask: true
        });
    });
</script>

@Html.TextBox("", IIf(Model = DateTime.MinValue, Nothing, Model.ToString("dd/MM/yyyy hh:mm")) _
                   , New With {.class = "datetimepicker"})

Si arrancamos la aplicación en el navegador y obtenemos el código HTML del formulario podemos ver cuál es el problema.

Código HTML del formulario

Como muestra la imagen, la referencia al plugin se repite por cada campo (hasta un total de 4 veces) y el código de inicialización de los controles también (2 para los de tipo Date y 2 para los de tipo DateTime).



Creando el Helper para gestionar los scripts


Para solucionar el problema voy a crear tres métodos de extensión de la clase HtmlHelper.

Los métodos de extensión permiten "agregar" métodos a los tipos existentes sin crear un nuevo tipo derivado, recompilar o modificar de otra manera el tipo original. Los métodos de extensión son una clase especial de método estático, pero se les llama como si fueran métodos de instancia en el tipo extendido. En el caso del código de cliente escrito en C# y Visual Basic, no existe ninguna diferencia aparente entre llamar a un método de extensión y llamar a los métodos realmente definidos en un tipo.

La idea es sencilla: ir incluyendo todos los scripts en una colección que controle que no se incluyan scripts repetidos y renderizarlos al final de la página. Para ello, voy a crear una nueva clase ScriptHelper, en una nueva carpeta Infrastructure en la que definiré tres métodos de extensión, uno para añadir referencias a archivos javascript (AddScriptFile), otro para añadir código javascript (AddScript)  y un último método que se encargará de escribir los bloques script en la página (RenderScripts).

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;

namespace MVCDateTimePicker.Infrastructure
{
    public static class ScriptHelper
    {

        private const string scriptItemsKey = "ScriptBlocks";

        private enum ScriptType { Code, File }

        private class ScriptBlock
        {
            public ScriptType ScriptType { get; set; }
            public string Script { get; set; }
        }

        private static Dictionary<string, ScriptBlock> ScriptCollection(HtmlHelper html)
        {
            HttpContextBase _httpContext = html.ViewContext.HttpContext;
            if (!_httpContext.Items.Contains(scriptItemsKey))
                _httpContext.Items[scriptItemsKey] = new Dictionary<string, ScriptBlock>();
            return (Dictionary<string, ScriptBlock>)_httpContext.Items[scriptItemsKey];
        }

        public static MvcHtmlString AddScript(this HtmlHelper html, string key, string scriptCode)
        {
            Dictionary<string, ScriptBlock> scripts = ScriptCollection(html);
            if (!scripts.ContainsKey(key))
                scripts.Add(key
                    , new ScriptBlock { ScriptType = ScriptType.Code, Script = scriptCode });
            return MvcHtmlString.Empty;
        }

        public static MvcHtmlString AddScriptFile(this HtmlHelper html, string key, string scriptFile)
        {
            Dictionary<string, ScriptBlock> scripts = ScriptCollection(html);
            if (!scripts.ContainsKey(key))
                scripts.Add(key
                , new ScriptBlock { ScriptType = ScriptType.File, Script = scriptFile });
            return MvcHtmlString.Empty;
        }

        public static MvcHtmlString RenderScripts(this HtmlHelper html)
        {
            Dictionary<string, ScriptBlock> scripts = ScriptCollection(html);
            string result = string.Empty;
            foreach (ScriptBlock item in scripts.Values)
            {
                TagBuilder scriptTag = new TagBuilder("script");
                scriptTag.MergeAttribute("type", "text/javascript");
                if (item.ScriptType == ScriptType.File)
                    scriptTag.MergeAttribute("src", UrlHelper.GenerateContentUrl(item.Script, html.ViewContext.HttpContext));
                else
                    scriptTag.InnerHtml = Environment.NewLine + item.Script + Environment.NewLine;
                result += scriptTag.ToString(TagRenderMode.Normal) + Environment.NewLine;
            }
            html.ViewContext.HttpContext.Items.Remove(scriptItemsKey);
            return MvcHtmlString.Create(result);
        }
    }
}
Imports System.Web.Mvc
Imports System.Runtime.CompilerServices

Public Module ScriptHelper

    Private Const ScriptItemsKey As String = "ScriptBlocks"

    Private Enum ScriptType
        Code
        File
    End Enum

    Private Class ScriptBlock
        Property ScriptType As ScriptType
        Property Script As String
    End Class

    Private Function ScriptCollection(html As HtmlHelper) As Dictionary(Of String, ScriptBlock)
        Dim _httpContext As HttpContextBase = html.ViewContext.HttpContext
        If Not _httpContext.Items.Contains(ScriptItemsKey) Then
            _httpContext.Items(ScriptItemsKey) = New Dictionary(Of String, ScriptBlock)
        End If
        Return CType(_httpContext.Items(ScriptItemsKey), Dictionary(Of String, ScriptBlock))
    End Function

    <Extension> _
    Public Function AddScript(html As HtmlHelper, key As String, scriptCode As String) As MvcHtmlString
        Dim scripts As Dictionary(Of String, ScriptBlock) = ScriptCollection(html)
        If Not scripts.ContainsKey(key) Then
            scripts.Add(key _
                        , New ScriptBlock() With {.ScriptType = ScriptType.Code, .Script = scriptCode})
        End If
        Return MvcHtmlString.Empty
    End Function

    <Extension> _
    Public Function AddScriptFile(html As HtmlHelper, key As String, scriptFile As String) As MvcHtmlString
        Dim scripts As Dictionary(Of String, ScriptBlock) = ScriptCollection(html)
        If Not scripts.ContainsKey(key) Then
            scripts.Add(key _
                        , New ScriptBlock() With {.ScriptType = ScriptType.File, .Script = scriptFile})
        End If
        Return MvcHtmlString.Empty
    End Function

    <Extension> _
    Public Function RenderScripts(html As HtmlHelper) As MvcHtmlString
        Dim scripts As Dictionary(Of String, ScriptBlock) = ScriptCollection(html)
        Dim result As String = String.Empty
        For Each item As ScriptBlock In scripts.Values
            Dim scriptTag As TagBuilder = New TagBuilder("script")
            scriptTag.MergeAttribute("type", "text/javascript")
            If item.ScriptType = ScriptType.File Then
                scriptTag.MergeAttribute("src", UrlHelper.GenerateContentUrl(item.Script, html.ViewContext.HttpContext))
            Else
                scriptTag.InnerHtml = Environment.NewLine & item.Script & Environment.NewLine
            End If
            result = result & scriptTag.ToString(TagRenderMode.Normal) & Environment.NewLine
        Next
        html.ViewContext.HttpContext.Items.Remove(ScriptItemsKey)
        Return MvcHtmlString.Create(result)
    End Function

End Module

Los métodos AddScript y AddScriptFile crean un objecto ScriptBlock, cada uno de un tipo diferente, y lo incluyen en una colección Dictionary. A cada objecto ScriptBlock le asignamos una key que nos permite evitar incluir en la colección objetos repetidos.

Esta colección Dictionary se almacena en la colección Items del objecto HttpContext al que tenemos acceso a través de la propiedad ViewContext del HtmlHelper.

Finalmente el método RenderScripts genera el código html a enviar a la página para cada uno de los elementos ScriptBlock de la colección.

Para probarlo voy a incluir una llamada al método RenderScripts al final de la página principal de diseño (_Layout.cshtml/_Layout.vbhtml) que se encargará de generar el código de todos los scripts que se hayan ido añadiendo a lo largo del procesamiento de la vista.

@using MVCDateTimePicker.Infrastructure
<!DOCTYPE html>

<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <title>@ViewBag.Title</title>
    <link href="~/Content/jquery.datetimepicker.css" rel="stylesheet" />
    <script src="~/Scripts/jquery-2.1.3.min.js"></script>
</head>
<body>
    <div>
        @RenderBody()
    </div>

@Html.RenderScripts()
</body>
</html>
<!DOCTYPE html>

<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <title>@ViewData("Title")</title>
    <link href="~/Content/jquery.datetimepicker.css" rel="stylesheet" />
    <script src="~/Scripts/jquery-2.1.3.min.js"></script>
</head>
<body>
    <div>
        @RenderBody()
    </div>

@Html.RenderScripts()
</body>
</html>

Y por último sólo queda modificar las plantillas de editor para incluir los scripts a través de los nuevos métodos AddScript y AddScriptFile.

La plantilla Date.cshtml/Date.vbhtml:

@using MVCDateTimePicker.Infrastructure
@model DateTime

@Html.AddScriptFile("datetimepicker_plugin", "~/Scripts/jquery.datetimepicker.js")
@Html.AddScript("init_Date_Editor"
    , @"    $(function () {
        $('.datepicker').datetimepicker({
            lang: 'es',
            format: 'd/m/Y',
            formatDate: 'd/m/Y',
            dayOfWeekStart: 1,
            mask: true,
            timepicker: false
        });
    });")

@Html.TextBox("", (Model == DateTime.MinValue ? null : Model.ToString("dd/MM/yyyy"))
    , new { @class = "datepicker" })

@ModelType DateTime

@Html.AddScriptFile("datetimepicker_plugin", "~/Scripts/jquery.datetimepicker.js")
@Html.AddScript("init_Date_Editor" _
                     , "$(function () {" & vbCrLf &
                     "$('.datepicker').datetimepicker({" & vbCrLf &
                     "lang: 'es'," & vbCrLf &
                     "format: 'd/m/Y'," & vbCrLf &
                     "formatDate: 'd/m/Y'," & vbCrLf &
                     "dayOfWeekStart: 1," & vbCrLf &
                     "mask: true," & vbCrLf &
                     "timepicker: false" & vbCrLf &
                     "});" & vbCrLf &
                     "});" & vbCrLf)

@Html.TextBox("", IIf(Model = DateTime.MinValue, Nothing, Model.ToString("dd/MM/yyyy")) _
                   , New With {.class = "datepicker"})

Y la plantilla DateTime.cshtml/DateTime.vbhtml:

@using MVCDateTimePicker.Infrastructure
@model DateTime

@Html.AddScriptFile("datetimepicker_plugin", "~/Scripts/jquery.datetimepicker.js")
@Html.AddScript("init_DateTime_Editor"
    , @"    $(function () {
        $('.datetimepicker').datetimepicker({
            lang: 'es',
            format: 'd/m/Y H:i',
            formatDate: 'd/m/Y',
            dayOfWeekStart: 1,
            mask: true
        });
    });")

@Html.TextBox("", (Model == DateTime.MinValue ? null : Model.ToString("dd/MM/yyyy hh:mm"))
    , new { @class = "datetimepicker" })
@ModelType DateTime

@Html.AddScriptFile("datetimepicker_plugin", "~/Scripts/jquery.datetimepicker.js")
@Html.AddScript("init_DateTime_Editor" _
                     , "$(function () {" & vbCrLf &
                     "$('.datetimepicker').datetimepicker({" & vbCrLf &
                     "lang: 'es'," & vbCrLf &
                     "format: 'd/m/Y H:i'," & vbCrLf &
                     "formatDate: 'd/m/Y'," & vbCrLf &
                     "dayOfWeekStart: 1," & vbCrLf &
                     "mask: true" & vbCrLf &
                     "});" & vbCrLf &
                     "});" & vbCrLf)

@Html.TextBox("", IIf(Model = DateTime.MinValue, Nothing, Model.ToString("dd/MM/yyyy hh:mm")) _
                   , New With {.class = "datetimepicker"})

Ahora si arrancamos la aplicación y comprobamos el código HTML generado por el formulario veremos que los scripts se agrupan al final de la página y sin repetirse ninguno de los bloques:

El código javascript al final de la página

El código completo de este artículo y el anterior (ASP.NET MVC. Platilla de editor para fecha y hora), tanto en C# como en Visual Basic, está disponible en:

Códigos de muestra - Ejemplos MSDN

4 comentarios:

  1. Muy interesante. Para el caso de helpers personalizados, el registro de scripts seria con la misma clase ScriptHelper? Podria incluirse el script ahi, sin necesidad de picarlo en la vista? Como podria hacerlo para ponerlo en la clase HtmlHelper de la que extiendo y donde escribo mi helper?

    Muchas preguntas?

    Gracias de antemano

    ResponderEliminar
    Respuestas
    1. Hola PIMPROYECT.

      Efectivamente, aunque la clase ScriptHelper está pensada para evitar repeticiones de scripts utilizados en plantillas y vistas parciales, se puede utilizar en cualquier parte del código para registrar tanto archivos javascript a incluir como bloques de código javascript.

      La clase, o el módulo si trabajas en VB, puedes crearlo en cualquier ubicación dentro de tu proyecto o en una librería externa. En este segundo caso simplemente tendrías que tener en tu proyecto web una referencia a la librería.
      Para poder utilizarlo en cualquier parte de tu proyecto simplemente deberías incluir una instrucción using para el namespace de la clase Helper.

      No sé si he conseguido aclarar las dudas o simplemente liarte más...

      Eliminar
    2. Cuando disponga de algo de tiempo haré unas pruebas, muchas gracias por tu respuesta

      Eliminar
  2. Un gran post, me ayudo bastante en el uso de vistas parciales, estuve buen tiempo tratando de encontrar esta solución, muchas gracias ... Y a seguir desarrollando :D

    ResponderEliminar