Creación de cargadores de archivos React flexibles y reutilizables

Daniel apareció recientemente en el blog Eventbrite Engineering y estamos muy orgullosos del trabajo que hace allí.

El equipo de Creación de Eventos de Eventbrite necesitaba un cargador de imágenes basado en React que proporcionara flexibilidad a la vez que presentara una interfaz de usuario sencilla. Los componentes del cargador de imágenes debían funcionar en diversos escenarios como partes reutilizables que pudieran componerse de forma diferente según las necesidades. Sigue leyendo para ver cómo resolvimos este problema.

¿Qué es un cargador de archivos?

Antes, si querías obtener un archivo de tus usuarios web, tenías que utilizar un "tipo de entrada "archivo. Este planteamiento era limitado en muchos aspectos, sobre todo en que es una entrada: los datos sólo se transmiten cuando envías el formulario, por lo que los usuarios no tienen la oportunidad de ver los comentarios antes o durante la carga.

Teniendo esto en cuenta, los uploaders de React de los que hablaremos no son entradas de formulario; son "herramientas de transporte inmediato". El usuario elige un archivo, el cargador lo transporta a un servidor remoto, y luego recibe una respuesta con algún identificador único. Ese identificador se asocia inmediatamente a un registro de la base de datos o se introduce en un campo oculto del formulario.

Esta nueva estrategia proporciona una enorme flexibilidad respecto a los procesos de carga tradicionales. Por ejemplo, desacoplar el transporte de archivos del envío de formularios nos permite subir directamente al almacenamiento de terceros (como Amazon S3) sin enviar los archivos a través de nuestros servidores.

La contrapartida de esta flexibilidad es la complejidad; los cargadores de archivos de arrastrar y soltar son bestias complejas. También necesitábamos que nuestro cargador React fuera sencillo y fácil de usar. Encontrar un camino que proporcionara tanto flexibilidad como facilidad de uso no fue tarea fácil.

Identificar responsabilidades

Establecer las responsabilidades de un uploader parece fácil... se sube, ¿no? Pues claro, pero hay muchas otras cosas implicadas para que eso ocurra:

  • Debe tener una zona de caída que cambie en función de la interacción del usuario. Si el usuario arrastra un archivo sobre ella, debe indicar este cambio de estado.
  • ¿Qué pasa si nuestros usuarios no pueden arrastrar archivos? Puede que tengan problemas de accesibilidad o que intenten subir archivos desde su teléfono. En cualquier caso, nuestro cargador debe mostrar un selector de archivos cuando el usuario haga clic o toque.
  • Debe validar el archivo elegido para asegurarse de que es de un tipo y tamaño aceptables.
  • Una vez elegido un archivo, debería mostrar una vista previa del mismo mientras se carga.
  • Debe dar una respuesta significativa al usuario mientras se está cargando, como una barra de progreso o un gráfico de carga que comunique que algo está ocurriendo.
  • Además, ¿qué pasa si falla? Debe mostrar un error significativo al usuario para que sepa que debe intentarlo de nuevo (o abandonar).
  • Ah, y realmente tiene que subir el archivo.

Estas responsabilidades son sólo una pequeña lista, pero ya te haces una idea, pueden complicarse muy rápidamente. Además, al cargar imágenes es nuestro caso de uso principal, podría haber diversas necesidades de carga de archivos. Si te has tomado la molestia de averiguar el comportamiento de arrastrar / soltar / resaltar / validar / transportar / éxito / fracaso, ¿por qué escribirlo todo de nuevo cuando de repente necesitas subir CSV para ese único informe?

Entonces, ¿cómo podemos estructurar nuestro cargador de imágenes React para obtener la máxima flexibilidad y reutilización?

Separación de preocupaciones

En el siguiente diagrama puedes ver una visión general de nuestro enfoque previsto. No te preocupes si te parece complicado: a continuación profundizaremos en cada uno de estos componentes para ver los detalles sobre su finalidad y función.

Nuestra biblioteca de componentes basada en React no debería tener que saber cómo funcionan nuestras API. Mantener esta lógica separada tiene la ventaja añadida de la reutilización; los distintos productos no están encerrados en una única API, ni siquiera en un único estilo de API. En su lugar, pueden reutilizar tanto o tan poco como necesiten.

Incluso dentro de componentes de presentación, existe la oportunidad de separar la función de la presentación. Así que tomamos nuestra lista de responsabilidades y creamos una pila de componentes, desde los más generales en la parte inferior hasta los más específicos en la superior.

Componentes fundamentales

UploaderDropzone

Este componente es el corazón de la interfaz de usuario del cargador, donde comienza la acción. Se encarga de los eventos de arrastrar y soltar, así como de hacer clic para navegar. No tiene estado, sólo sabe cómo normalizar y reaccionar (¿ves lo que he hecho?) a determinadas acciones del usuario. Acepta retrollamadas como props para poder informar a su implementador de cuándo ocurren las cosas.

Escucha si se arrastran archivos sobre él, y luego invoca una llamada de retorno. Del mismo modo, cuando se elige un archivo, ya sea mediante arrastrar/soltar o haciendo clic para navegar, invoca otra llamada de retorno con el objeto archivo JS.

Tiene una de esas entradas de tipo "archivo" que he mencionado antes oculta en su interior para los usuarios que no pueden (o prefieren no) arrastrar archivos. Esta funcionalidad es importante, y al abstraerla aquí, los componentes que utilicen el dropzone no tienen que pensar en cómo se eligió el archivo.

A continuación se muestra un ejemplo de UploaderDropzone utilizando React:

¡Arrastra un archivo aquí!

UploaderDropzone tiene muy poca opinión sobre su aspecto, por lo que su estilo es mínimo. Por ejemplo, algunos navegadores tratan los eventos de arrastre de forma diferente cuando se producen en descendientes profundos del nodo objetivo. Para solucionar este problema, la zona de soltar utiliza un único div transparente para cubrir a todos sus descendientes. Esto proporciona la experiencia necesaria a los usuarios que arrastran/sueltan, pero también mantiene la accesibilidad para lectores de pantalla y otras tecnologías de asistencia.

UploaderLayoutManager

El componente UploaderLayoutManager gestiona la mayoría de las transiciones de estado y sabe qué diseño debe mostrarse en cada paso del proceso, al tiempo que acepta otros componentes React como props para cada paso.

Esto permite a los ejecutores pensar en cada paso como una idea visual separada, sin preocuparse de cómo y cuándo se produce cada transición. Los partidarios de este componente React sólo tienen que pensar en qué diseño debe ser visible en un momento dado en función del estado, no en cómo se rellenan los archivos o cómo debe ser el diseño.

Aquí tienes una lista de pasos que puedes proporcionar al LayoutManager como accesorios:

  • Pasos gestionados por LayoutManager:
    • Despoblado - un dropzone vacío con una llamada a la acción ("¡Sube una gran imagen!")
    • Archivo arrastrado sobre la ventana pero no sobre la zona de caída ("¡Suelta ese archivo aquí!")
    • Archivo arrastrado sobre dropzone ("¡Suéltalo ahora!")
    • Carga de archivo en curso ("Espera, lo estoy enviando...")
  • Paso gestionado por componente que implementa LayoutManager:
    • El archivo se ha cargado y se ha rellenado. Para nuestro cargador de imágenes, esto es una vista previa de la imagen con un botón "Eliminar".

El propio LayoutManager tiene poco o ningún estilo, y sólo muestra los elementos visuales que se le han pasado como props. Es responsable de mantener qué paso del proceso ha alcanzado el usuario y de mostrar algún contenido para ese paso.

El único paso del diseño que se gestiona externamente es "Vista previa" (si el cargador tiene una imagen rellenada). Esto se debe a que el componente de implementación necesita definir el estado en el que se inicia el cargador. Por ejemplo, si el usuario ha subido previamente una imagen, queremos mostrar esa imagen cuando vuelva a la página.

Ejemplo de uso de LayoutManager:

dropzoneElement={}
    windowDragDropzoneElement={}
    dragDropzoneElement={}
    loadingElement={}
    vista previaElement={}
 
    showPreview={!archivo}
 
    onRecibirArchivo={manejarRecibirArchivo}
/>

Componentes específicos de los recursos

Cargador de imágenes

El componente ImageUploader está orientado casi por completo a la presentación: definir el aspecto de cada paso y pasarlos como props a un UploadLayoutManager. También es un buen lugar para hacer validaciones (tipo de archivo, tamaño del archivo, etc.).

Los usuarios de esta herramienta pueden centrarse casi por completo en el aspecto visual del cargador. Este componente mantiene muy poca lógica, ya que las transiciones de estado las gestiona el UploaderLayoutManager. Podemos cambiar los elementos visuales de forma fluida sin preocuparnos apenas de dañar la función del cargador.

 

Ejemplo ImageUploader:

const DropzoneLayout = () => (
    <p>Arrastra un archivo aquí o haz clic para examinar</p>
);
const ArrastrarDropzoneLayout = () =&gt; (
    <p>¡Suelta el archivo ahora!</p>
);
const LoadingLayout = () =&gt; (
    <p>Espera, cargando...</p>
);
const PreviewLayout = ({archivo, onRemove}) =&gt; (
    <div>
        <p>Nombre: {nombre.archivo}</p>
        <button onclick="{onRemove}">Eliminar archivo</button>
    </div>
);
clase ImageUploader extends React.Component {
    state = {archivo: indefinido};
 
    _handleRemove = () =&gt; this.setState({archivo: undefined});
 
    _handleRecibirArchivo = (archivo) =&gt; {
        this.setState({archivo});
 
        return new Promise((resolver, rechazar) =&gt; {
            // ¡sube el archivo!
        })
        .catch(() =&gt; this.setState({archivo: indefinido}))
    }
 
    render() {
        let {archivo} = this.estado
        let vista previa;
 
        si (archivo) {
            vista previa = (
                <previewlayout file="{file}" onremove="{this._handleRemove}"></previewlayout>
            );
        }
 
        devolver (
            <uploaderlayoutmanager
                dropzoneelement="{<DropzoneLayout"></uploaderlayoutmanager>}
                dragDropzoneElement={<dragdropzonelayout></dragdropzonelayout>}
                loadingElement={<loadinglayout></loadinglayout>}
                previewElement={prevista}
                showPreview={!archivo}
                onRecibirArchivo={this._handleRecibirArchivo}
            /&gt;
        );
    }
};

Capa específica de la aplicación

El ejemplo anterior tiene un aspecto destacado que no tiene que ver con la presentación: el transporte de archivos que ocurre en _handleReceiveFile. Queremos que este componente ImageUploader viva en nuestra biblioteca de componentes y esté desacoplado del comportamiento específico de la API, así que tenemos que eliminar eso. Afortunadamente, es tan sencillo como aceptar una función mediante props que devuelva una promesa que se resuelva cuando se complete la carga.

_handleRecibirArchivo(archivo) {
    // podría hacer aquí la validación del archivo antes del transporte. Si el archivo no pasa la validación, devuelve una promesa rechazada.
    let {uploadImage} = this.props;
 
    this.setState({archivo});
 
    return subirImagen(archivo)
        .catch(() => this.setState({archivo: indefinido}))
}

Con este pequeño cambio, este mismo cargador de imágenes puede utilizarse para diversas aplicaciones. Una parte de tu aplicación puede subir imágenes directamente a un tercero (como Amazon S3), mientras que otra puede subirlas a un servidor local con una finalidad y un manejo totalmente distintos, pero utilizando la misma presentación visual.

Y ahora, como toda esa complejidad está compartimentada en cada componente, el ImageUploader tiene una implementación muy limpia:

<imageuploader uploadImage={S3ImageUploadApi}></imageuploader>

Con esta base, las aplicaciones pueden utilizar este mismo ImageUploader de diversas formas. Hemos proporcionado la flexibilidad deseada manteniendo la API limpia y sencilla. Se pueden construir nuevas envolturas sobre UploadLayoutManager para manejar otros tipos de archivo o nuevos diseños.

Para terminar

Imagina cargadores de imágenes creados específicamente para cada escenario, pero que sólo contienen unos pocos componentes sencillos hechos de marcas de presentación. Cada uno de ellos puede utilizar la misma funcionalidad de carga, si tiene sentido, pero con una presentación totalmente diferente. O dale la vuelta a la idea, utilizando los mismos elementos visuales de carga pero con interfaces API totalmente diferentes.

¿De qué otras formas utilizarías estos componentes básicos? ¿Qué otros cargadores construirías? El cielo es el límite si te tomas el tiempo de construir componentes reutilizables.

es_ESES
Scroll al inicio