Construyendo data binding en el navegador

Data binding, o actualizar la vista (usualmente UI) desde el código de tu aplicación, es probablemente el santo grial de los frameworks web basados en navegador —…

Un diagrama mostrando el data binding estilo MVC popularizado por Angular.
Data binding como popularizado por Angular (estilo MVC). Muchas flechas y imágenes. Siempre es una buena señal.

El data binding, o actualizar la vista (normalmente la UI) desde el código de tu aplicación es probablemente el santo grial de los frameworks web basados en navegador — la razón por la que existen, y la razón por la que los estándares web han intentado cambiar para hacer más fácil “usar la plataforma”.

En este artículo de blog revisaré cuatro formas de data binding, y escribiré una librería/framework desechable simple para cada uno — principalmente como experiencia de aprendizaje. Usaré Dart, que es un lenguaje de programación de alto nivel en el que trabajo en Google (donde trabajo en el equipo de AngularDart), pero la mayoría de los conceptos a continuación se aplican a JavaScript, TypeScript, o sabores de OtherScript.

Contemplad, los cuatro jinetes del apocalipsis del data-binding:

  • Plantillas estáticas

  • Data-binding manual (es decir, simplemente usar el DOM)

  • Data-binding estilo MVC (como popularizado por Angular)

  • Data-binding reactivo (como popularizado por React)

Si solo estás interesado en el código, está todo disponible en GitHub:

Una nota rápida — nada aquí está optimizado, y he tomado algunos atajos para hacer este tutorial alcanzable en alrededor de una hora, lo que significa que no escribí ningún pre-procesador o generador de código — y en su lugar he escrito a mano cómo se vería el código generado; esos archivos terminan en “.g.dart”.

matanlurey/building-data-binding
building-data-binding - Código de acompañamiento para "Building Data Binding in the Browser"

Plantillas estáticas

#

Como popularizado principalmente por frameworks del lado del servidor, como los de PHP, Python, Ruby, etc. — estos emiten HTML estático que se transfiere y se muestra (normalmente) directamente en el navegador. La mayoría de sitios que no son de JavaScript con cualquier tipo de backend usan esta estrategia.

Así que, en su núcleo, solo estás devolviendo HTML plano. Algunos frameworks web más nuevos también permiten renderizado del lado del servidor (es decir, usar tu mismo código del lado del cliente para renderizar todo o parte del HTML en el servidor), pero eso está fuera del alcance de este artículo.

Como Dart (y la mayoría de OtherScripts, en este punto) tienen interpolación, o la capacidad de evaluar y crear strings en tiempo de ejecución, hacer una forma muy básica de plantillas estáticas es extremadamente fácil:

dart
void main() {
  var name = 'Matan';
  print('Hello $name!');
}

O podemos ser un poco más elegantes, y realmente output algo de HTML. En este ejemplo, devolveré una lista con viñetas de todas las cosas que me gusta hacer los lunes:

dart
void main() {
  var sundays = [
    'sleep in',
    'eat brunch',
    'wash car',
  ];
  print(
    'On Sunday: \n'
    '<ul>\n'
    '${sundays.map((i) => '  <li>$i</li>\n').join('')}'
    '</ul>\n'
  );
}

Esto produce:

El domingo: <ul> <li>dormir hasta tarde</li> <li>comer brunch</li> <li>lavar el coche</li> </ul>

No es exactamente la mejor solución de plantillas, pero algo así puede funcionar para muchos sitios, y cada vez me he vuelto más fan de usar la herramienta más simple posible para un trabajo, y solo escalar cuando realmente lo necesitas. Si realmente necesitas una micro-librería though —OK vamos a escribir una.

¿Qué querrías que hiciera una librería básica de plantillas estáticas? Probablemente en este caso manejar la creación de sintaxis HTML por ti para que sea menos propensa a errores, y darte algunas funciones helper para crear tags comunes y tal. Primero diseñemos una clase llamada Tag, que encapsula un único elemento HTML, y una clase llamada Node y Text:

dart
abstract class Node {}

class Tag implements Node {
  final String name;
  final List<Node> children;

  const Tag(this.name, {this.children: const []});

  @override
  String toString() => '<$name>${children.join('')}</$name>';
}

class Text implements Node {
  final String text;

  const Text(this.text);

  @override
  String toString() => text;
}

void main() {
  var sundays = [
    'sleep in',
    'eat brunch',
    'wash car',
  ];
  var output = new Tag(
    'ul',
    children: sundays.map(
      (i) => new Tag('li', children: [new Text(i)]),
    ).toList(),
  );
  print(output);
}

Esto hace aproximadamente lo mismo que nuestro código anterior, pero hemos implementado algunas abstracciones — un Tag es un elemento HTML, y tiene cualquier número de hijos, y Text es — bueno, texto. Hemos escrito más código que antes, pero quizás esto es más agradable de tener para algunos. Es trivial incluir cosas como atributos HTML, así que lo omitiré por ahora.

Personalmente, sin embargo, algo me molesta, que es usar toString para output código de producción. También soy fan del patrón visitor, que separa limpiamente esta lógica de nuestro DSL:

dart
abstract class Visitor<T> {
  T visitNode(Node node);
  T visitTag(Tag node);
  T visitText(Text node);
}

class HtmlEmitter implements Visitor<String> {
  @override
  String visitNode(Node node) => node.accept(this);

  @override
  String visitTag(Tag node) {
    final buffer = new StringBuffer('<${node.name}>');
    buffer
      ..writeAll(node.children.map(visitNode))
      ..write('</${node.name}>');
    return buffer.toString();
  }

  @override
  String visitText(Text node) => node.text;
}

abstract class Node {
  R accept<R>(Visitor<R> visitor);
}

class Tag implements Node {
  final String name;
  final List<Node> children;

  const Tag(this.name, {this.children: const []});

  @override
  R accept<R>(Visitor<R> visitor) => visitor.visitTag(this);
}

class Text implements Node {
  final String text;

  const Text(this.text);

  @override
  R accept<R>(Visitor<R> visitor) => visitor.visitText(this);
}

void main() {
  var sundays = [
    'sleep in',
    'eat brunch',
    'wash car',
  ];
  var output = new Tag(
    'ul',
    children: sundays.map(
      (i) => new Tag('li', children: [new Text(i)]),
    ).toList(),
  );
  print(new HtmlEmitter().visitNode(output));
}

De nuevo, más código. Pero ahora si quiero HTML con indentación/formato, o añadir lógica de validación, o cualquier otra cosa, puedo implementar un nuevo Visitor y no tengo que cambiar ninguna de mis otras clases. ¡Genial!

Finalmente, vamos a conectar un servidor HTTP muy básico usando mi nueva “librería”. En este caso, haré que escuche automáticamente en el puerto 8080, y que emita mi lista de TODOs:

dart
import 'dart:io';

import 'package:static_templating/static_templating.dart';

main() async {
  final sundays = const [
    'sleep in',
    'eat brunch',
    'wash car',
  ];
  final server = await HttpServer.bind(InternetAddress.LOOPBACK_IP_V4, 8080);
  print('Listening to localhost:8080.');
  await for (final request in server) {
    print('Connection received. Writing TODO list...');
    final list = _createTodoList(sundays);
    final output = list.accept(new HtmlEmitter());
    request.response
      ..headers.contentType = ContentType.HTML
      ..write(output)
      ..close();
    print('Done!');
  }
}

Node _createTodoList(Iterable<String> tasks) {
  return new Tag(
    'ul',
    children: tasks.map((i) => new Tag('li', children: [new Text(i)])).toList(),
  );
}

(Una cosa que me gusta especialmente de Dart es que no tuve que importar ninguna librería de terceros para hacer esto, configurar ninguna herramienta especial, y obtuve análisis estático para ayudarme a averiguar cómo configurar el header ContentType sin buscar nada en Google).

Bastante aburrido, ¡pero funciona! Si quieres escribir tu propio servidor así, primero revisaría lo siguiente, en el orden proporcionado (de nuevo, soy un gran fan de usar la menor cantidad de librerías posible para una tarea dada):

  • dart:io, la librería estándar del VM (no disponible en el navegador)

  • shelf, una tecnología de servidor basada en middleware como Express para NodeJS

  • frameworks más completos como aqueduct o angel

Y eso es todo. Puedes usar técnicas similares para renderizar HTML en el navegador, por supuesto, pero eso es bastante aburrido, y probablemente quieres que el HTML cambie cuando el usuario interactúa con la página.

Data-binding manual

#

Antes de la era de los frameworks de JavaScript (cuando todo era más bien una librería), era común escribir código directamente contra el DOM (en JavaScript “vanilla”) o usar una librería como jQuery para eliminar diferencias entre navegadores y hacer más fáciles las tareas comunes.

Afortunadamente dart:html es una librería de alto nivel proporcionada por el SDK de Dart, e incluso en JavaScript los navegadores modernos son lo suficientemente completos como para no necesitar una librería para data binding simple. ¿Qué pasa si quiero traer mi nueva “lista de TODOs del domingo” — y poder marcar (eliminar) elementos conforme los termino?

Aplicación de lista de TODOs construida en el tutorial.
La mejor UI del mundo

Entra el data binding manual. En lugar de depender de ningún framework o librería específica, simplemente crearé elementos HTML, y escucharé eventos para saber cuándo actualizar el DOM, todo yo mismo.

html
<html>
  <body>
    <div id="todo-list"></div>
  </body>
  <script type="application/dart" src="main.dart"></script>
  <script src="packages/browser/dart.js"></script>
</html>
dart
import 'dart:html';

main() {
  final sundays = const [
    'sleep in',
    'eat brunch',
    'wash car',
  ];
  document.querySelector('#todo-list')
    ..append(new UListElement()
      ..children.addAll(
        sundays.map(_createListItem),
      ));
}

Element _createListItem(String item) {
  final li = new LIElement();
  final button = new ButtonElement()
    ..text = 'Done'
    ..onClick.listen((_) => li.remove());
  li
    ..appendText(item)
    ..append(button);
  return li;
}

De nuevo, bastante aburrido. Pero si estuviera realmente haciendo una simple app de TODOs así, no estaría mal. Los .. o cascadas de métodos de Dart hacen esto relativamente conciso, y podría añadir otros comportamientos básicos bastante fácilmente.

Más o menos, cualquier otra forma de data/template-binding está intentando hacer lo anterior — construir algo de DOM, y actualizar el DOM cuando algo sucede — pero de una manera (a) más performante, (b) más elegante, (c) más ergonómica, y/o (d) más modular.

Data-binding automático

#

Con las especificaciones actuales de los navegadores, no existe nada que haga esto más fácil. Algunos podrían argumentar que esa es la razón por la que WebComponents V1 no ha despegado en comparación con los frameworks existentes — los frameworks han hecho que sea tan fácil y/o ergonómico construir y actualizar el DOM que nadie quiere volver atrás.

Trabajo en el equipo de AngularDart, así que mostraré un ejemplo a continuación de data binding estilo MVC (como popularizado por Angular, pero me dicen que Ember es otro excelente ejemplo — y estoy seguro de que hay muchos otros).

Data-binding estilo MVC

#

El núcleo de MVC es la separación de lógica entre la *vista (*en el navegador esto es usualmente el DOM) y el controller (en el navegador esto es usualmente tu JavaScript). Omitiré el m o modelo, por ahora.

Angular popularizó (o al menos lo intentó) la idea de que deberías poder escribir plain-old dumb-objects (PODOs), incluir cualquier lógica que quieras, y que “simplemente se binde” a una plantilla HTML. Considera la siguiente encapsulación estilo MVC de nuestra lista de TODOs del domingo:

html
<ul>
  <!--
    Invented syntax. *bind="<TYPE> <TO>".

    In this case, a for-loop bound to "items", with every individual "item".
  -->
  <li *bind="for items:item">
    <!-- Pretty much the gold-standard for string interpolation. -->

    <!--
      Invented syntax. *event="<NAME>:<TO>".

      In this case, a "click" event that invokes the "onClick" method.
    -->
    <button *event="click:onClick">Done</button>
  </li>
</ul>

¿Qué te parece? Hay algunas ventajas y desventajas aquí:

PROs:

  • Puedes encapsular mucha/toda tu lógica de vista en un solo archivo.

  • Aunque no es estándar, esto es bastante legible incluso para no programadores con algo tan simple como una “chuleta” que explique las adiciones a la especificación HTML para tu lenguaje de plantillas.

  • No escribiste ningún código real — así que un framework con un buen pre-procesador ahead-of-time puede “compilar” esto, potencialmente de forma eficiente, a invocaciones de JavaScript/DOM.

CONs:

  • Si no compras la religión MVC, puede que no te guste este archivo.

  • Aunque podrías dominar HTML/CSS y Dart o JavaScript, tú y tus desarrolladores tienen que aprender una cosa más que no es ninguna de esas.

  • No escribiste ningún código real — así que más o menos estás esperando que los autores del framework vayan a preprocesar/compilar esto eficientemente y que el output sea casi tan bueno como si hubieras escrito el data binding manualmente (pista: casi nunca lo es).

No mostré ningún código de “controller” todavía, así que vamos a escribir una clase simple:

dart
class ListController {
  final List<String> items;

  ListController(this.items);

  void onClick(String item) {
    items.remove(item);
  }
}

Hacer que esto “simplemente funcione” va a requerir… escribir un framework. Esto es un artículo de blog, así que en lugar de escribir realmente un parser para nuestro lenguaje de plantillas, un pre-procesador/compilador, simplemente escribiremos a mano el código que un generador crearía.

Primero establezcamos nuestro “framework”. Voy a hacerlo simple:

dart
import 'dart:html';

abstract class Controller {
  View get view;

  /// Invoke when something in the DOM should be changed.
  ///
  /// In a "real framework" this would collate and be more intelligent.
  void markDirty() => view.detectChanges(this);
}

abstract class View<C extends Controller> {
  /// Create the initial representation of the DOM.
  Element build();

  /// Applies data-binding for a given [controller].
  void detectChanges(C controller);
}

He creado dos abstracciones, un Controller, o una clase que extenderé para crear mi lógica de negocio (en Angular esto es comúnmente un component), y una View, o la representación de mi archivo HTML como Dart plano (o más adelante, JavaScript).

Vamos a crear el código generado, o “vista”, primero:

dart
// A dart "partial library", that is, in the namespace of another library.
part of list;

class _ViewListController extends View<ListController> {
  UListElement _ul;
  List<String> _itemsLastCheck = const [];

  @override
  Element build() {
    return _ul = new UListElement();
  }

  @override
  void detectChanges(ListController controller) {
    // Not an exhaustive check. In a real framework you'd need to verify that
    // the contents of the list are the same, not just the lists are of
    // different lengths.
    if (_itemsLastCheck.length != controller.items.length) {
      _updateItems(controller);
    }
  }

  void _updateItems(ListController controller) {
    // Intentionally not efficient. In a real framework you'd want to track
    // every <li> individually to avoid blowing away big parts of the DOM and
    // rebuilding.
    _ul.children
      ..clear()
      ..addAll(controller.items.map((i) => _createLi(controller, i)));
    _itemsLastCheck = controller.items.toList();
  }

  Element _createLi(ListController controller, String item) {
    final li = new LIElement()..appendText(item);
    li.append(new ButtonElement()
      ..text = 'Done'
      ..onClick.listen((_) => controller.onClick(item)));
    return li;
  }
}

Tomé algunos atajos arriba, por ejemplo, items solo se actualiza cuando la longitud de la lista cambia, y no estoy manteniendo estado en absoluto, así que si la lista se actualiza destruyo el DOM existente y creo nuevo DOM. Un framework típico (incluyendo Angular) sería mucho más inteligente sobre esto — pero parte de este artículo es mostrar exactamente cuánto trabajo detrás de escena está ocurriendo incluso para el data binding más simple.

Finalmente, actualicemos nuestro controller para usar nuestro framework:

dart
library list;

import 'dart:html';

import 'package:mvc_data_binding/framework.dart';

// Would be generated in a typical framework.
part 'list.g.dart';

class ListController extends Controller {
  final List<String> items;

  ListController(this.items);

  void onClick(String item) {
    items.remove(item);
    markDirty();
  }

  @override
  final view = new _ViewListController();
}

Y lo juntamos todo en main.dart:

html
<html>
  <body>
    <div id="todo-list"></div>
  </body>
  <script type="application/dart" src="main.dart"></script>
  <script src="packages/browser/dart.js"></script>
</html>
dart
import 'dart:html';

import 'package:mvc_data_binding/list.dart';

main() {
  final sundays = [
    'sleep in',
    'eat brunch',
    'wash car',
  ];
  final container = document.querySelector('#todo-list');
  final component = new ListController(sundays);
  container.append(component.view.build());

  // Start change detection. This should be automatic in most frameworks.
  component.view.detectChanges(component);
}

Ahora tenemos un framework MVC estilo Angular, e imagino que tomaría entre unas horas y unos días (o semanas, o meses) crear un generador de código para emitir automáticamente el archivo .g.dart por ti, dependiendo de qué tan simple o complejo quieras ir. Eso está fuera del alcance de este artículo sin embargo.

Data-binding estilo reactivo

#

El enfoque FP o reactivo para data-binding es diferente — en lugar de separar tu lógica de vista y controller, modelas tu aplicación como funciones (o clases) que toman como entrada valores (datos) y emiten como salida “vista” (html en este caso).

Este es el enfoque tomado por React, Vue, e incluso Flutter.

Considera lo siguiente:

dart
Node todoList(List<String> todos) => new Tag(
  'ul',
  children: todos.map(
    (i) => new Tag('li', children: [
      new Text(i),
      new Tag('button', children: [
        'Done',
      ], events: {
        'click': () => throw new UnimplementedError(),
      }),
    ]),
  ).toList(),
);

Esto en realidad no es muy diferente de lo que hicimos arriba en static template binding, excepto que las librerías y frameworks modernos usan una serie de algoritmos para minimizar el impacto en el DOM. Por ejemplo, si eliminas un solo <li> un framework típico como React no reconstruirá nada.

PROs:

  • Puedes encapsular toda la lógica de tu “componente” en un solo lugar.

  • No requiere ningún pre-procesamiento especial o compiladores.

  • Para programas escritos con, digamos, el SDK de Flutter, tu IDE se convierte en tu dios. Auto-completar todo, y nunca necesitas aprender a escribir (más o menos).

  • Fácil de usar pero herramientas opcionales como JSX pueden hacer esto bastante más conciso.

CONs:

  • Como antes, los puristas de MVC (y partes de varios equipos de DevRel de plataformas web de empresas) podrían gritar un poco — ahora estás poniendo HTML, JavaScript, y potencialmente CSS en una función (o clase) de JavaScript.

  • A diferencia de algo como Angular (aunque tiene sus propios problemas), no puedes realmente “inspeccionar” el código que produce esta función, es decir, tienes que estar muy familiarizado con las idiosincrasias del framework al optimizar listas de miles de elementos, lazy scrolling, y así sucesivamente.

Prometí escribir una librería simple para un framework de data-binding estilo reactivo, pero decidí al final de este artículo que ya había sido hecho mejor por mi colega Yegor con su framework “Butterfly”:

yjbanov/butterfly
butterfly - Un framework web para Dart basado en el modelo de widgets de Flutter
dart
// Copyright 2016 Google Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import 'package:butterfly/butterfly.dart';

class App extends StatelessWidget {
  Node build() {
    return div(attrs: {'id': 'greeting'})([
      text('Hello, World!'),
      button(eventListeners: {
        EventType.click: (_) {
          print('Woohoo!');
        }
      })([
        text('Update'),
      ]),
    ]);
  }
}

¿Cuál es la conclusión?

#

No hay ninguna, así que siento decepcionar si llegaste hasta aquí buscando una tabla de benchmarks o algo para justificar las 200 horas que pasaste migrando un proyecto al último-y-mejor framework. Podría decirse que cada enfoque tiene fortalezas y debilidades, y he visto más frameworks empezar a usar modelos híbridos en algunos lugares también.

Algunas reglas generales para llevar a casa:

  • El data binding es difícil. Recuerda eso la próxima vez que preguntes por qué no existe una “librería de data binding simple y ligera que solo haga X e Y”.

  • Los sistemas de plantillas se construyen en torno a tradeoffs. O intercambias ergonomía, velocidad, o complejidad. Aún no he usado un único framework que tenga éxito completo en todo — y mi suposición es que nadie más lo ha hecho dado el número de frameworks y modelos que existen.

  • Si estás construyendo tu propio sitio, y estás tentado a escribir un framework “micro”, empieza simple. Haz solo la menor cantidad de cosas posible, y solo añade complejidad cuando resuelva un gran problema. El boiler-plate en sí mismo no es malo — solo el boiler-plate que es propenso a errores y difícil de escribir correctamente.

Me interesaría saber de los lectores qué piensan, y si les gustaría que profundizara en más detalle sobre cualquier tema en particular. ¡Que tengan un maravilloso 4 de julio!

Más de Dart