Autor en Google+
Saltar al contenido

Variable not found. Artículos, noticias, curiosidades, reflexiones... sobre el mundo del desarrollo de software, internet, u otros temas relacionados con la tecnología. C#, ASP.NET, ASP.NET MVC, HTML, Javascript, CSS, jQuery, Ajax, VB.NET, componentes, herramientas...

el blog de José M. Aguilar

Inicio El autor Contactar

Artículos, noticias, curiosidades, reflexiones... sobre el mundo del desarrollo
de software, internet, u otros temas relacionados con la tecnología

¡Microsoft MVP!
martes, 14 de febrero de 2012
ASP.NET MVCComo sabemos, los helpers como Url.Action() y Html.ActionLink() son capaces de generar URLs hacia acciones partiendo de la información disponible en la tabla de rutas y de los parámetros que les suministramos.

Por ejemplo, el siguiente código genera un enlace hacia la acción Edit del controlador Friends, suministrándole el valor 3 al parámetro de ruta id:
@Html.ActionLink("Edit friend", "Edit", "Friends", new {id = 3}, null)
Sin embargo, estos helpers no nos ayudan demasiado cuando desconocemos a priori el valor de los parámetros. De hecho, si en lugar del 3 que hemos suministrado quisiéramos enviar un valor obtenido a través de scripting tendríamos un problema, puesto que el bloque de código anterior se ejecuta en servidor y el enlace sería generado antes incluso de ejecutarse el script para obtener el valor a enviar.

Esta es una pregunta que suelo ver muy a menudo en comunidades y foros (como el de ASP.NET MVC en MSDN) y me han realizado más de una vez en el curso de ASP.NET MVC 3 que tutorizo en CampusMVP, así que vamos a ver un par de enfoques para enfrentarnos a este escenario, bastante frecuente al trabajar en sistemas web y, sobre todo, en aquellos muy basados en scripting y Ajax.

1. El escenario

Supongamos que tenemos un controlador como el siguiente, cuya única misión es retornar una cadena de texto basándose en los dos parámetros que se le suministran:
public class InfoController : Controller
{
    // GET /info/show/4?name=Joe
    public ActionResult Show(string id, string name)
    {
        return Content(
            string.Format("{0}, your ID is {1}", name, id)
        );
    }
}
Observad el ejemplo de URL mediante la cual podría accederse a esta acción utilizando la ruta por defecto. Volveremos después a ella.

Imaginad ahora una vista con un código como el siguiente, en el que creamos un par de controles de edición de texto, un botón de envío y un <div> en principio vacío, pero que después utilizaremos para mostrar información obtenida desde el servidor:
Así luce en pantalla<fieldset>
    <legend>Data</legend>
    <div class="field">
        <label for="Id">Your ID:</label>
        <input type="text" id="Id" />    </div>
    <div class="field">
        <label for="Name">Your name:</label>
        <input type="text" id="Name" />    </div>
    <br />
    <input type="button" id="send" value="Send" />
</fieldset>
<fieldset>
    <legend>Result</legend>
    <div id="result"></div>
</fieldset>
Y ya puestos a echarle imaginación, supongamos ahora que añadimos el siguiente script, que añade al evento click del botón algo de lógica:
<script type="text/javascript">
    $(function() {
        $("#send").click(function() {
            var id = $("#Id").val();
            var name = $("#Name").val();
            updateResult(id, name);
            return false;
        });
    });
</script>
Como podéis ver, al pulsar el botón se obtienen los valores de ambos cuadros de texto y se invoca a la función updateResult(). Ésta se encargará de llamar a la acción del servidor pasándole ambos valores e introduciendo el resultado obtenido en el “hueco” (<div id="result"> ) que hemos habilitado para ello; su estructura general será la siguiente:
    function updateResult(id, name) {
        var url = _______ ; // Establecer URL de la acción        
        $("#result").load(url);
    }
El método load() de jQuery efectuará una petición GET a la URL indicada y el contenido retornado por el servidor será introducido en los elementos seleccionados previamente, en este caso el elemento con identificador result.

La única cuestión entonces es cómo conseguir establecer el valor correcto de la URL de forma sencilla y sin cometer algunos errores muy frecuentes.

2. Cómo NO hacerlo bien

La implementación que solemos encontrar con más frecuencia es parecida a la siguiente:
    // ¡Ojo, no utilizar!
    function updateResult(id, name) {
        var url = "/info/show/" + id + "?name=" + name;
        $("#result").load(url);
    }
Este código, que funciona perfectamente, compone la URL concatenando la porción estática de la dirección (“/info/show/”) con el identificador (el valor de id) y añade el parámetro name en la query string con su valor correspondiente.

Bueno, y si hemos dicho que este código funciona sin problemas, ¿por qué es incorrecto? Pues básicamente porque al construir la URL mediante esta concatenación estamos asumiendo que una petición de tipo /info/show/007?name=James nos llevará hacia la acción que esperamos ejecutar, cuando esto no tiene por qué ser cierto. De hecho, en este caso hemos podido hacerlo así y funciona porque conocemos de antemano la URL de la acción al estar usando la ruta por defecto de los proyectos ASP.NET MVC; si ésta cambia, dejará de funcionar.

O en otras palabras, al construir la URL de esa forma estamos introduciendo una sutil dependencia hacia el esquema de URLs utilizado por nuestra aplicación y, por tanto, hacia el contenido de la tabla de rutas. De hecho, es bastante fácil que nuestro script deje de funcionar si modificamos la ruta por defecto, por ejemplo como se muestra seguidamente:
routes.MapRoute(
    "Default", // Route name
    "{action}/{controller}/{id}", // URL with parameters
    new { controller = "Home", action = "Index", id = UrlParameter.Optional } 
);
Con esta nueva ruta, el acceso a nuestra acción debería ser a través de una URL del tipo /show/info/007?name=James, por lo que el script anterior dejaría de funcionar de forma silenciosa: no encontraremos ningún error de compilación, ni siquiera en tiempo de ejecución, simplemente no funcionará al estar apuntando hacia una dirección incorrecta.

Una variación del ejemplo incorrecto anterior es cuando se introduce por medio una llamada al helper Url.Action() para obtener la primera parte de la URL:
    // ¡Ojo, no utilizar!
    function updateResult(id, name) {
        var url = "@Url.Action("show", "info")/" + id + "?name=" + name;
        $("#result").load(url);
    }
Esto, que probablemente hayáis utilizado o visto alguna vez, también es incorrecto. Aunque con un poco de suerte funcionará e incluso será sensible a determinados cambios en la tabla de rutas, seguimos asumiendo que las URL responden a un determinado patrón.

En resumen, podríamos decir que la introducción de direcciones “hardcodeadas” en la vista es un antipatrón que conviene evitar a toda costa utilizando los helpers de generación de URL que nos proporciona el framework, como Url.Action() o Html.ActionLink(), donde sí se tiene en cuenta el contenido de la tabla de rutas.

Pero claro, el problema es que estos helpers se ejecutan en servidor, y el valor de los parámetros que debemos suministrarles se conocen exclusivamente en cliente… ¿cómo podemos solucionar esto?

3. Primera solución, versión artesana

Esta solución, bastante generalizada, consiste en generar la URL utilizando los helpers que ASP.NET MVC nos ofrece para ello, pero introduciendo “marcadores” en los parámetros que nos permitan reemplazarlos posteriormente por los valores correctos conocidos por el script:
    function updateResult(id, name) {
        var url = "@Url.Action("Show", "Info", new { id="param-id", name="param-name" })";
        url = url.replace("param-id", id)
                 .replace("param-name", name);
        $("#result").load(url);
    }
Observad que en la variable url lo que se introducirá será una URL perfectamente generada por el helper Url.Action() utilizando el sistema de routing, pero el valor de los parámetros id y name serán, respectivamente “param-id” y “param-name”, que son constantes arbitrarias que estemos completamente seguros de que no van a aparecer en ninguna otra parte de la URL. A continuación, desde el script, reemplazamos esa constante por los valores reales, y listo :-)

Para que lo veáis más claramente, el código real  que sería enviado al cliente tras procesarse la vista sería el siguiente, centrándonos en las líneas donde se genera la URL y se reemplazan los valores de los parámetros:
    ...
    var url = "/Info/Show/param-id?name=param-name"; 
    url = url.replace("param-id", id)
             .replace("param-name", name);
    ...
Sólo añadir un par de consejos si vais a utilizar este método. Primero, al elegir el valor ficticio para los parámetros, mejor no usar caracteres extraños, puesto que Url.Action() los codificará para generar la URL y os causará problemas a la hora de reemplazarlo. Lo mejor es seguir un esquema simple, similar al utilizado en el ejemplo, y sobre todo que sean fácilmente identificables para que el código sea claro y legible.

Recordad también que puede ser bastante conveniente codificar con encodeURIComponent() el valor que vais a reemplazar para que pueda ser introducido de forma segura en una URL, es decir, hacer el replace() de la siguiente forma:

    url = url.replace("param-name", encodeURIComponent(name));

¡Y eso es todo! Ya a partir de implementar esta solución, habremos eliminado la dependencia hacia la tabla de rutas puesto que la URL se generará siempre atendiendo a ella. Sin embargo, es demasiado código para escribir cada vez que nos encontremos con este escenario, ¿no? Veamos varias formas de conseguirlo.

4.  Segunda solución, automatización con helpers

Si prestamos un poco de atención podemos automatizar la generación de todo el código anterior desde un helper, consiguiendo una solución relativamente integrada y compacta.

De hecho, lo que vamos a ver es cómo conseguir dejar nuestra función updateResult() en algo tan simple como lo siguiente:
    function updateResult(id, name) {
        var url = @Script.Action("show", "info", new { id="js:id", name="js:name"});
        $("#result").load(url);
    }
Hey, funciona en mi máquinaEl helper Script.Action(), cuyo código veremos algo más adelante, es exactamente igual al conocido Url.Action(), salvo en que cuando el valor asignado a alguno de sus parámetros de ruta comienza por el prefijo “js:”, su valor es sustituido por la variable de script indicada en él. Así, en el código anterior, al parámetro de ruta id se asignará el valor de la variable id del script, y lo mismo ocurrirá con name.

Partiendo de esta información, el helper será capaz de generar el código script de sustitución de los parámetros en forma de función anónima autoejecutada. El siguiente código muestra el script enviado finalmente al cliente tras procesar la vista anterior:
    function updateResult(id, nombre) {
        var url = (function() {
            return '/js%3aid/show/info?name=js%3Anombre'
                   .replace(/js(%)3[aA]id/, encodeURIComponent(id))
                   .replace(/js(%)3[aA]nombre/, encodeURIComponent(nombre));
        })();
        $("#result").load(url);
    }
Al final del post encontraréis un enlace hacia el proyecto Visual Studio donde podéis obtenerlo completo y verlo en ejecución, pero os muestro aquí el código, que tampoco es que tenga demasiada chicha:
public static partial class Script
{
    public static MvcHtmlString Action(string action, string controller, object routeData)
    {
        var data = new RouteValueDictionary(routeData);
        var scriptVarsToReplace = getScriptVars(data);
        var requestContext = ((MvcHandler)HttpContext.Current.Handler).RequestContext;
        var urlPattern = UrlHelper.GenerateUrl(null, action, controller, data,
                                               RouteTable.Routes, requestContext,
                                               true);
        var url = generateFunction(urlPattern, scriptVarsToReplace);
        return MvcHtmlString.Create(url);
    }
 
    private static IEnumerable<string> getScriptVars(IDictionary<string, object> dictionary)
    {
        var scriptVariables = new List<string>();
        foreach (var prop in dictionary)
        {
            var value = prop.Value as string;
            if (value != null && value.StartsWith("js:") && value.Length > 3)
            {
                var varName = value.Substring(3);
                scriptVariables.Add(varName);
            }
        }
        return scriptVariables;
    }
 
    private static string generateFunction(string urlPattern, IEnumerable<string> scriptVars)
    {
 
        var sb = new StringBuilder();
        sb.Append("(function() {");
        sb.AppendFormat(" return '{0}'", urlPattern);
        foreach (var variable in scriptVars)
        {
            sb.AppendFormat(".replace(/{0}/, encodeURIComponent({1}))",
                            "js(%)3[aA]" + variable,
                            variable);
        }
        sb.Append("; })()");
        return sb.ToString();
    }
}
Por simplificar el ejemplo no he implementado otras sobrecargas de Script.Action() similares a las que encontramos en Url.Action(), pero sería ya bastante sencillo partiendo de este código.

5. Tercera solución, usando árboles de expresión

Hey, funciona en mi máquinaOtra posibilidad que se me ocurre es utilizar árboles de expresión para llenar virtualmente el hueco existente entre la ejecución en servidor y la ejecución en cliente creando la ilusión de continuidad. Pero ojo, que no he tenido en cuenta aspectos como el rendimiento, se trata sólo de una prueba de concepto.

La idea consiste en crear un helper que reciba un árbol de expresión, cuyos parámetros sean nombrados exactamente igual que las variables de script que deseamos reemplazar en el resultado; el cuerpo de la lambda se ejecutará y se realizarán los reemplazos de forma automática. De esta forma, nuestro método updateResult() quedaría algo así:
    function updateResultExpressionTree(id, name) {
        var url = @Script.Replace((id, name)=> Url.Action("show", "info", new {id, name})) ;
        $("#result").load(url);
    }    
Como podéis comprobar, este sistema se basa en una asunción algo ingenua pero efectiva: que los nombres de los parámetros que reciba la expresión lambda coincidan con los de las variables de script que queremos suministrarle.

Y a partir de ahí, el funcionamiento es simple: al método Replace() le llega un árbol de expresión que una vez compilado es invocado suministrándole como parámetros valores ficticios (generados a partir del nombre del propio parámetro), y es capaz de generar una función de script que realiza el reemplazo de forma automática. El código enviado al cliente tras procesarse la porción anterior será:
    function updateResultExpressionTree2(id, name) {
        var url = (function() {
             return '/info/show/param-id?name=param-name'
                 .replace('param-id', encodeURIComponent(id))
                 .replace('param-name', encodeURIComponent(name));
        })() ;
        $("#result").load(url);
    }  
Y a continuación podéis ver cómo está implementado. Sólo he creado la sobrecarga de Replace() para árboles de expresión con dos parámetros, pero el resto de versiones serían idénticas (las podéis ver en el proyecto de demostración):
public static partial class Script
{
 
    public static MvcHtmlString Replace<TResult>(Expression<Func<string, string, TResult>> expression)
    {
        return replaceInternal(expression);
    }
 
    private static MvcHtmlString replaceInternal(LambdaExpression expression)
    {
        var parameters = expression.Parameters
                            .Select(param => paramPlaceHolder(param.Name)).ToArray();

        var urlPattern = getResultPattern(expression, parameters);
        var sb = new StringBuilder();
        sb.Append("(function() {");
        sb.AppendFormat(" return '{0}'", urlPattern);
        foreach (var parameter in expression.Parameters)
        {
            sb.AppendFormat(".replace('{0}', encodeURIComponent({1}))", 
                            paramPlaceHolder(parameter.Name), parameter.Name);
        }
        sb.Append("; })()");
        return MvcHtmlString.Create(sb.ToString());
    }
 
    private static string paramPlaceHolder(string paramName)
    {
        return "param-" + paramName;
    }
 
    private static string getResultPattern(LambdaExpression expression, params object[] args)
    {
        var func = expression.Compile();
        object result = func.DynamicInvoke(args);
        return result.ToString();
    }
}
La parte que podría penalizar el rendimiento principalmente es la compilación de la expresión lambda, pero es algo que puede solucionarse rápidamente implementando un caché. Os lo dejo de deberes ;-)

6. En resumen…

En este post hemos visto distintas fórmulas para solucionar un problema que encontramos con relativa frecuencia: generar URLs en las que debemos incluir parámetros conocidos únicamente en tiempo de cliente, es decir, desde script.

En primer lugar vimos cómo solucionarlo de forma manual, generando URLs con parámetros ficticios y reemplazándolos a posteriori por los valores reales, y a continuación hemos visto dos posibles enfoques para automatizar esta tarea que espero que os sean de utilidad.

Descargar proyecto de ejemplo (VS2010 + MVC 3)

Publicado en Variable not found.

Estos contenidos se publican bajo una licencia de Creative Commons Licencia Reconocimiento-No comercial-Compartir bajo la misma licencia 3.0 España de Creative Commons

16 Comentarios:

@deaquino dijo...

Excelente artículo, me ha encantado! Pese a lo extenso que es, engancha desde el principio hasta el final.

José M. Aguilar dijo...

Muchas gracias! :-))

Anónimo dijo...

Yo cada vez que mezclo código razor con script tengo problemas...

Considera que la última llave de cierre de la función del script pertenece a razor (hasta la pone amarilla) y no hay forma de decirle lo contrario (ni con , ni con @:, ni metiéndolo en llaves...). Tal vez sea por usar la preview de MVC-4

Anónimo dijo...

Perdón, ya sé por qué es... Al meter el script en un apartado @Section, Razor confunde el cierre de la sección con el cierre de una función javascript si se mete en ésta algún código Razor adicional. Y esto pasa incluso creando una nueva solución de Visual Studio y copiando tu código ahí. Sin embargo, si abro tu solución directamente, no me pasa... en fin, entiendo que es un off-topic a este tema. Siento el throlling...

José M. Aguilar dijo...

@Anónimo, esta es tu casa ;-)

De hecho, ahora iba a responderte diciéndote que no debería ocurrirte nada de eso, pero ya me lo has ahorrado.

Un saludo & gracias por comentar.

Anónimo dijo...

Bueno, si no te molestan estos comentarios tan 'laterales' te diré que no está arreglado. Creo que es debido a un bug en MVC4-Preview (o algo en mi Visual Studio). Lo que pasa es que tu fichero de proyecto no hace referencia a MVC-4 (en debe estar E3E379DF-F4C6-4180-9B81-6769533ABE47 en vez de E53F8FEA-EAE0-44A6-8774-FFD645390401, que es de MVC3 - Verás que al añadir una nueva vista te pone el nombre por defecto como ViewPage1 en vez de View1). Tal vez por algo de eso no falla ni aún metiendo el script dentro de una @section (como debe ser). Creando un nuevo proyecto MVC4 colgando de tu solución me falla igualmente. Si creo un MVC3 va todo perfecto... creo que es algo del proyecto.

En fin, gracias por tu respuesta y por toda la ayuda que nos da tu blog.

ramiro dijo...

Muy bueno!!! es lo que estaba necesitando, Gracias

José M. Aguilar dijo...

@anónimo, por si te sirve de algo saberlo, a mi no me pasa. Creo un section, en su interior un script, y dentro uso otros bloques Razor, y me funciona todo bien. Misterios del VS, bug de MVC4, o quién sabe...

@ramiro: me alegro de que te sea útil.

Y gracias a ambos por comentar :-)

Anónimo dijo...

Bueno, con este post termino mi 'extraño' problema.
He actualizado el MVC4 Developer Preview a MVC4 beta y el problema ha desaparecido. Muchas gracias de nuevo por el soporte!

epna dijo...

Genial!!!! Absolutamente brillante!
Me quedo con el mecanismo (4), el (5) creo que no aporta nada especialmente útil y probablemente penalice en rendimiento, no?
La verdad es que yo hasta ahora venía utilizando el (3) pero voy a incorporar este helper a mi batería de helpers!!! :p

;-)

José M. Aguilar dijo...

Hola, eduard! :-)

Gracias por tu comentario.

Sí, la solución (4) es la más razonable y simple.

De hecho, la (5) es más una prueba de concepto que otra cosa, pero me pareció curioso el utilizar el dinamismo y flexibilidad del árbol de expresión como puente entre cliente y servidor.

En cuanto al rendimiento, está claro que la compilación del árbol tiene que penalizar, aunque supongo que usando una caché podría mejorarse bastante. Pero ciertamente tampoco tengo pruebas empíricas que lo demuestren ;-)

Muchas gracias de nuevo por comentar!

Unknown dijo...

Buen artículo,

tengo una pregunta, este código funciona si se encuentra dentro de una vista que interpreta el código de servidor.

¿Como resolver esto mismo si la función js se encuentra dentro de un archivo js?

¿Hay forma de ejecutar código de servidor en archivos js? (en las primeras versiones de asp se podía)

Al no encontrar como uso una url en el script apuntado a una acción en un nuevo controlador para generar el contenido dinámicamente.

Muchas gracias. Saludos!

José M. Aguilar dijo...

Gracias, unknown :-)

En el momento que se trata de un archivo estático (como un .js), poco puedes hacer desde el servidor, aunque afortunadamente hay varias fórmulas para conseguir el mismo resultado.

Una de ellas es la que comentas, hacer que la referencia desde la vista a tu script apunte a una acción que sea la que lo genera en servidor. Conceptualmente, es correcta.

Otra posibilidad sería aislar tu script genérico en un .js, pero que los métodos u objetos que vayas a utilizar reciban parámetros, que serían "inyectados" desde la vista principal (que sí generas en servidor).

Un saludo & gracias por comentar.

Diego dijo...

buen articulo me sirvio de mucho y lo dificil que es pillar algo en español me sorprendio de sobremanera tambien. como en mi link tenia partes que le paso desde el modelo (ya que esto necesitaba para hacer paginacion con pagedlist) yo termine haciendo algo asi

@Html.ActionLink("|Siguiente >", "Index", new { pagina = Model.PageNumber + 1, ordenacion = ViewBag.Ordenacion, filtro = ViewBag.FiltrO , empresa = "param-empresa", ruta = "param-ruta", ciudad = "param-ciudad" }, new { id = "mylinkSig" })

$(function () {
$('#mylinkFirst').click(function () {

var empresa = $("#empresa").val();
var ruta = $("#ruta").val();
var ciudad = $("#ciudad").val();
alert(empresa + "first");
this.href = this.href.replace("param-empresa", encodeURIComponent(empresa))
.replace("param-ruta", encodeURIComponent(ruta))
.replace("param-ciudad", encodeURIComponent(ciudad));
});
});

Anónimo dijo...

Muy bueno, el problema es cuando sacamos todo el codigo js a un archivo .js externo, con estas soluciones estariamos necesitando una variable en la vista asociada al .js por cada accion de cada controlador que utilice dicha vista, cosa que queda bastante desprolija, una solución que he leido a este problema es crear un var masterpath en la master page con la url del proyecto y de ahi en mas concatenar el resto de la ruta hacia la accion, con esto evitamos esos centenares de variables en las vistas definiendo las rutas.

José M. Aguilar dijo...

Hola @anónimo!

Bueno, es lo que comentábamos: si concatenas las rutas estás introduciendo, al fin y al cabo, una dependencia bastante dura respecto al contenido de la tabla de rutas. Si estás seguro de que ésta no va a cambiar puede ser una forma sencilla de implementarlo, pero en cualquier caso siempre arriesgada.

Saludos!