Esta es la segunda parte de la primera entrega que iniciamos aqui. En ella vamos a exponer distintas soluciones para integrar React y D3.js correctamente, veremos las ventajas y desventajas de cada aproximación y ejemplos de código.
1 - DOM Compartido - Juntos pero no revueltos
Es la solución más efectiva, sencilla y a veces, elegante. Que cada uno se dedique a lo que de verdad se le da bien, para lo que fue diseñado. Dejemos que React sea dueño de los componentes y del UI en general, pero otorguemos a D3 el jardín de los gráficos. Ambas tecnologías deben compartir el DOM pero siempre sin solaparse, debe haber una clara frontera entre ellas. A tal fin, el gráfico que queremos visualizar vivirá dentro de un componente básico de React. El truco está en no dejar a React inmiscuirse y que sea D3 el que gestione el DOM que le corresponde. Por su parte, D3 no debe salir de ese sub-árbol del DOM.
Existen distintas variantes para esta solución aunque por lo general, comparten la misma filosofía que podría explicarse del siguiente modo:
- El componente React será tan sencillo como renderizar un simple <div/>. Este <div\> servirá de punto de entrada para D3. No es más que un simple componente contenedor.
- D3 se encargará de generar todos los elementos SVG necesarios para nuestra visualización, siempre dentro del contenedor <div\>. Usaremos código vanilla D3 para ello.
- Los datos para el gráfico los pasaremos como propiedad del componente React.
- ¿En que momento debemos ejecutar el código de D3? Aqui tenemos distintas opciones:
- Si el gráfico es estático (los datos no van a cambiar), podemos optar por lo más sencillo: ejecutar nuestro código D3 una sola vez en el componentDidMount() y bloquear todo update de React devolviendo siempre false en shouldComponentUpdate(). En este caso basta con llamar al patrón enter de D3.
- Si los datos pudieran cambiar (real time), deberíamos invocar a D3 cada vez que el componente reciba un nuevo set de datos. En este caso es mejor ubicar la llamada para el patrón update de D3 en componentDidUpdate() y bloquear todo re-render mediante shouldComponentUpdate() excepto el que nos interesa: cuando haya cambio en los datos.
En resumen, suspendemos las operaciones de React en el interior del contenedor, bloqueamos su paso. React verá el contenedor como una caja negra, donde D3 tiene total libertad de actuar sin que React se entere.
Versión Estática
En este caso supondremos que nuestra fuente de datos no cambia, por tanto, nos basta con implementar e invocar el patrón enter de D3 una única vez. Es decir, pintaremos nuestro gráfico una sola vez, al principio, en cuanto el componente se monta. Es importante hacerlo en este momento y no antes ya que necesitamos una referencia de nuestro contenedor en el DOM real para pasársela a D3 y que tenga un punto de entrada. Hay que esperar, por tanto, a que nuestro contenedor se monte en el DOM para tener una referencia válida.
interface Props { // Datos de nuestro gráfico como propiedad. data: any[]; } class ChartComponent extends React.Component<Props, {}> { constructor(props) { super(props); } // Lógica de clase para almacenar la referencia // al DOM real. private nodeRef = null; private setRef = (ref) => { this.nodeRef = ref; } public componentDidMount() { // LLamada a D3. Patrón enter. createChart(this.nodeRef, this.props.data); } public shouldComponentUpdate() { // Suspendemos los renders de React. return false; } public render() { return ( // Nos basta con un sencillo DIV. // Tan sólo queremos su referencia. <div className="container" ref={this.setRef} /> ); } }
interface Props { // Datos de nuestro gráfico como propiedad. data: any[]; } class ChartComponent extends React.Component<Props, {}> { constructor(props) { super(props); } // Lógica de clase para almacenar la referencia // al DOM real. private nodeRef = null; private setRef = (ref) => { this.nodeRef = ref; } public componentDidMount() { // LLamada a D3. Patrón enter. createChart(this.nodeRef, this.props.data); } public shouldComponentUpdate(prevProps) { // Permitimos renders sólo si los datos // han cambiado. Así alcanzaremos el siguiente // punto del ciclo de vida. return Boolean(prevProps.data !== this.props.data); } public componentDidUpdate() { // LLamada a D3. Update(). updateChart(this.props.data); } public render() { return ( // Nos basta con un sencillo DIV. // Tan sólo queremos su referencia. <div className="container" ref={this.setRef} /> ); } }
Demo en vivo aquí.
Código fuente de esta demo aquí.
Ventajas
- Muy sencilla y efectiva de implementar.
- Podemos hacer uso de todo el API completo de D3.
- Especialmente útil para incorporar código D3 nativo. Hay miles de ejemplos disponibles.
- Podemos incorporar transiciones que en React serían problemáticas.
Inconvenientes
- No hacemos uso del DOM virtual y la reconciliación para los nodos de nuestro gráfico.
- Porción del DOM mutable. Aunque esto no genera un problema como tal, los más puristas argumentarán que no respeta la filosofía inmutable de React.
- No se puede hacer server side rendering. Recordad que D3 debe mutar el DOM real, y éste estará situado en el lado de cliente.
2 - React renderiza, D3 calcula - D3 al servicio de React
Esta aproximación reduce el protagonismo de D3 al mínimo, mientras que React se convierte en la estrella de la película. Se trata de utilizar D3 con el único propósito de realizar cálculos matemáticos que ayuden a dar forma y posición a los elementos del gráfico. Pero será React quien se encargue realmente de renderizarlos. También puede entenderse como una solución maestro-esclavo donde D3 queda al servicio de React.
Resulta que una gran parte de la funcionalidad de D3 no accede o modifica el DOM, sino que está aislada del mismo, son meras funciones de cálculo y ayuda. Tan solo el core de D3 es el encargado de manipular el DOM. Por tanto podemos servirnos de toda esa funcionalidad para calcular, por ejemplo, la posición y el tamaño de un path SVG a partir de nuestros datos de entrada, y que React sea el responsable de pintarlo.
Como podéis imaginar, debemos descomponer nuestro gráfico en piezas pequeñitas y encontrar la forma de representarlo con React. Aqui nos olvidamos de los patrones enter, update y exit, de las mutaciones directas del DOM, etc. D3 tan sólo nos ayudará en momentos puntuales con ciertos cálculos. Esta solución es lo más parecido a decir: "píntalo todo directamente con React y olvídate de D3".
Como ejemplo, pondremos un breve código con el que se renderizan una serie de líneas:
interface Props { // Datos de nuestro gráfico como propiedad. data: any[]; } class ChartComponent extends React.PureComponent<Props, {}> { constructor(props) { super(props); } private calculatePath = (lineData) => { // Usamos D3 para cálculos // d3.scaleLinear()... // d3.line()... return } public render() { return ( // Preparamos nuestro contenedor SVG. <svg width="500px" height="300px"> { // Por cada datum de entrada, renderizamos una // línea (path) con la ayuda de D3. this.props.data.map(lineData => ( <path d={this.calculatePath(lineData)} className="line" /> )) } </svg> ); } }
Demo en vivo aquí.
Código fuente de esta demo aquí.
No nos detendremos demasiado en esta solución ya que, si bien es interesante para gráficos muy sencillos, la complejidad puede volverse en nuestra contra con gráficos más elaborados. Existe un caso concreto en el que si resultaría beneficioso optar por esta aproximación: gráficos sencillos pero con muchos elementos, de modo que plasmarlo en código React no sea una tarea compleja pero nos beneficiemos de la eficiencia del DOM virtual.
Ventajas
- Integración real en React. Código 100% React.
- Toda la eficiencia del DOM virtual.
- Server side rendering.
Desventajas
- Complejidad exponencial. Para gráficos complejos es casi inviable.
- Podemos acabar reimplementando funcionalidad de D3 ya existente que, por modificar el DOM, no podamos usar dentro de React.
- Perdemos la gran cantidad de ejemplos existentes en código D3 nativo.
- Se requiere un amplio conocimiento del API de D3.
- Las animaciones caen en el terreno de React, con la problemática que supone animaciones de entrada - salida sobre componentes que se montan por primera vez o que se destruyen. También, las animaciones complejas sobre elementos SVG no están soportadas.
3 - Faux DOM - React y D3 totalmente separados
Se trata de una ingeniosa solución ideada por Oliver Caldwell consistente en alimentar a D3 con un DOM ... falso. Si si, falso. Originalmente denominado faux-DOM, se trata de un DOM de mentira, no montado, pero con todos los métodos y características que D3 esperaría. Esto significa que lo que suceda en ese DOM falso no se estará viendo en ningún sitio, no está conectado a ningún navegador. D3 se encargará de mutarlo como sea necesario para plasmar el árbol de nodos de nuestro gráfico, todo ello sin mezclarse con React y su mecanismo de DOM virtual.
Una vez D3 ha hecho su trabajo en el faux DOM, lo traduciremos a algo que React pueda entender, es decir, a componentes de React. De este modo, el árbol completo que D3 ha representado en el faux DOM podrá ser inyectado en nuestra aplicación (presumiblemente a través de un contenedor) y renderizado siguiendo el proceso normal de React.
Esta aproximación, en realidad, comparte filosofía con la primera: separar ambas librerías en los puntos donde colisionan. El falso DOM es el encargado de mantenerlas a raya pero también sirve de pegamento entre ambas, proporcionando la conversión necesaria desde nodos de un DOM a componentes de React. Aunque pueda parecer complejo a simple vista, la librería de Oliver nos abstrae en gran medida y nos proporciona helpers para que la implementación resulte muy sencilla. Como contrapartida el faux-DOM es una capa intermedia y por tanto una nueva entidad que también podría introducir efectos no deseados.
Al igual que en la solución primera, presentaremos dos ejemplos con código que se diferenciarán según si nuestra fuente de datos es estática o dinámica. En ambos casos la forma de proceder es muy similar, veamos:
Versión Estática
Utilizaremos la utilidad react-faux-dom a través de un HOC que nos proporcionará toda la funcionalidad que necesitamos dentro de nuestro componente React, envolviéndolo y enriqueciendo sus propiedades con nuevos atributos para la creación de faux DOMs y para la obtención (previa traducción) de dichos DOMs. Puesto que nuestros datos no van a cambiar (estáticos), me basta con invocar a D3 una sola vez con el patrón enter. ¿En qué momento? Pues al igual que en la solución primera, cuando el componente se monta. La peculiaridad es que en este caso vamos a alimentar a D3 con un falso DOM en lugar de usar una referencia al DOM real.
interface Props extends ReactFauxDomProps { // Datos de nuestro gráfico como propiedad. data: any[]; } class InnerChartComponent extends React.Component<Props, {}> { constructor(props) { super(props); } public componentDidMount() { // Creamos un Faux DOM para D3, le asignamos un tipo y nombre. const fauxDom = this.props.connectFauxDOM("div", "chart"); // LLamada a D3 con el faux DOM. Patrón enter. createChart(fauxDom, this.props.data); } public render() { return ( <div className="container"> // Inyectamos el faux DOM traducido a componentes // de React como hijo de nuestro contenedor. {this.props.chart} </div> ); } } // Nuestro componente es envuelto por un HOC que enriquece // las propiedades e inyecta funcionalidad. const ChartComponent = withFauxDOM(InnerChartComponent);
Versión Dinámica
Al igual que sucedía en la primera solución, aquí la versión dinámica será idéntica a la estática salvo por la llamada al patrón update de D3 que debemos añadir cada vez que nuestros datos se actualicen.
interface Props extends ReactFauxDomProps { // Datos de nuestro gráfico como propiedad. data: any[]; } class InnerChartComponent extends React.Component<Props, {}> { constructor(props) { super(props); } public componentDidMount() { // Creamos un Faux DOM para D3, le asignamos un tipo y nombre. const fauxDom = this.props.connectFauxDOM("div", "chart"); // LLamada a D3 con el faux DOM. Patrón enter. createChart(fauxDom, this.props.data); } public componentDidUpdate(prevProps) { if (prevProps.data !== this.props.data) { // Obtenemos de nuevo el faux DOM. const fauxDom = this.props.connectFauxDOM("div", "chart"); // Invocamos patrón update de D3 para actualizar el gráfico. updateChart(fauxDom, this.props.data); // Iniciamos período de muestreo para animaciones. this.props.animateFauxDOM(1000); } } public render() { return ( <div className="container"> // Inyectamos el faux DOM traducido a componentes // de React como hijo de nuestro contenedor. {this.props.chart} </div> ); } } // Nuestro componente es envuelto por un HOC que enriquece // las propiedades e inyecta funcionalidad. const ChartComponent = withFauxDOM(InnerChartComponent);
Demo en vivo aquí.
Código fuente de esta demo aquí.
Un detalle muy interesante a mencionar es la última línea del componentDidUpdate() en la versión dinámica. Puesto que todas las mutaciones que D3 realiza las hace sobre un DOM desconectado, en caso de implementar una transición o animación, ésta no se vería en ningún sitio. Gracias a la llamada this.props.animateFauxDOM() iniciamos un muestreo del faux DOM y provocamos una actualización de la propiedad chart cada 16ms. En otras palabras, conseguimos que cada 16ms. react-faux-dom nos envíe una foto de aquello que está sucediendo en el faux DOM y de esta forma podamos simular en React la animación que tiene lugar en el falso DOM. Estos 16ms. no están escogidos por capricho, sino que representan un frame rate de 60fps, considerado como el estándar para animaciones web de calidad.
A pesar de que la reproducción artificial de estas animaciones es ingeniosa, la fluidez final alcanzada no siempre será la esperada y dependerá de la complejidad de nuestro gráfico y el estado de carga de nuestra hebra en Javascript.
Ventajas
- D3 funciona a pleno potencial, con toda su API disponible.
- Incorporación de código D3 nativo ya existente: tendremos todos los ejemplos de D3 publicados a nuestro alcance.
- Todas las ventajas de React, incluido el DOM virtual y la reconciliación para toda la aplicación.
- Posibilidad de realizar server-side rendering. Podremos hacer que D3.js genere una salida compatible con React desde un servidor.
- Transiciones simuladas a través de muestreo para que no perder detalle de lo que sucede en el faux DOM.
Desventajas
- Introducimos una nueva entidad en el flujo de renderizado, que comienza a ser bastante complejo, y por tanto el rendimiento podría verse afectado.
- Para gráficos pesados, con muchos nodos, no es la solución más eficiente.
- Las transiciones y animaciones artificiales no siempre son lo fluidas en comparación con sus homólogas nativas.
- Toda la responsabilidad del buen funcionamiento recae sobre la utilidad del falso DOM.
- Cabe plantearse, acerca de react-faux-dom si este proyecto ¿está actualizado? ¿siguen manteniéndolo? ¿seguirá funcionando con nuevas versiones de React?
Material Extra
Todo el contenido que os hemos presentado tanto en este artículo como en la primera parte proceden de unas charlas impartidas en el Open South Code 2018. Disponéis de todo el material para vuestra consulta en distintos formatos:
- Presentación esquematizada.
- Repositorio con los ejemplos implementados en la presentación.
- Webinar con explicaciones ampliadas.