Como Construir Mejores Componentes de Alto Nivel (Higher-Order Components) con el Vue 3

by Luigi Nori Date: 07-08-2020 javascript vue vue3 hoc template


El Vue 3 será lanzado pronto con la introducción del API de composición (Composition API). Viene con muchos cambios y mejoras en el rendimiento.

Los componentes de orden superior (HOC o Higher-order components) son componentes que añaden ciertas funcionalidades a su aplicación de forma declarativa utilizando la plantilla. Creo que seguirán siendo muy relevantes incluso con la introducción de la API de composición.

Los HOC siempre han tenido problemas para exponer toda la potencia de su funcionalidad, y como no son tan comunes en la mayoría de las aplicaciones de Vue, a menudo están mal diseñados y pueden introducir limitaciones. Esto se debe a que la plantilla es sólo eso - una plantilla, o un lenguaje restringido en el que se expresa algo de lógica. Sin embargo, en los entornos JavaScript o JSX, es mucho más fácil expresar la lógica porque tienes todo el JavaScript disponible para que lo utilices.

Lo que Vue 3 aporta es la capacidad de mezclar y combinar sin problemas la expresividad de JavaScript utilizando el API de composición y la facilidad declarativa de las plantillas.

Estamos usando activamente HOCs en las aplicaciones que construimos para varias piezas de lógica como red, animaciones, interfaz de usuario y estilo, utilidades y bibliotecas de código abierto. Hay algunos consejos para compartir sobre cómo construir HOCs, especialmente con la próxima API de composición de Vue 3.

La plantilla (template)

Asumamos el siguiente componente de búsqueda (fetch component). Antes de entrar en cómo implementar tal componente, deberías pensar en cómo usarías tu componente. Luego, debes decidir cómo implementarlo. Esto es similar a TDD pero sin las pruebas - es más como jugar con el concepto antes de que funcione.

Lo ideal sería que ese componente utilizara un endpoint y devolviera su resultado como una propriedad de scoped slot:

<fetch endpoint="/api/users" v-slot="{ data }">
  <div v-if="data">
    <!-- Show the response data -->
  </div>
</fetch>

Ahora bien, aunque esta API tiene el propósito básico de buscar algunos datos en la red y mostrarlos, hay muchas cosas que faltan y que sería útil tener.

Empecemos con el manejo de errores. Lo ideal sería poder detectar si se ha producido un error de red o de respuesta y mostrar alguna indicación de ello al usuario. Vamos a esbozar eso en nuestro recorte de uso:

<fetch endpoint="/api/users" v-slot="{ data, error }">
  <div v-if="data">
    <!-- Show the response data -->
  </div>
  
  <div v-if="error">
    {{ error.message }}
  </div>
</fetch>

Hasta ahora todo bien. ¿Pero qué hay del estado de carga (loading state)? Si seguimos el mismo camino, terminamos con algo como esto:

<fetch endpoint="/api/users" v-slot="{ data, error, loading }">
  <div v-if="data">
    <!-- Show the response data -->
  </div>
  
  <div v-if="error">
    {{ error.message }}
  </div>
  
  <div v-if="loading">
    Loading....
  </div>
</fetch>

Genial. Ahora, asumamos que necesitamos tener soporte de paginación:

<fetch endpoint="/api/users" v-slot="{ data, error, loading, nextPage, prevPage }">
  <div v-if="data">
    <!-- Show the response data -->
  </div>
  
  <div v-if="!loading">
    <button @click="prevPage">Prev Page</button>
    <button @click="nextPage">Next Page</button>
  </div>
  
  <div v-if="error">
    {{ error.message }}
  </div>
  
  <div v-if="loading">
    Loading....
  </div>
</fetch>

Ves a dónde va esto, ¿verdad? Estamos agregando demasiadas propiedades a nuestro scoped slot predeterminado. En su lugar, vamos a dividirlo en múltiples slots:

<fetch endpoint="/api/users">
  <template #default="{ data }">
    <!-- Show the response data -->
  </template>
  
  <template #pagination="{ prevPage, nextPage }">
    <button @click="prevPage">Prev Page</button>
    <button @click="nextPage">Next Page</button>
  </template>
  
  <template #error="{ message }">
    <p>{{ message }}</p>
  </div>
  
  <template #loading>
    Loading....
  </template>
</fetch>

Si bien el número de caracteres que tenemos es en su mayoría el mismo, es mucho más limpio en el sentido de que utiliza múltiples slots para mostrar diferentes UI durante los diferentes ciclos de funcionamiento del componente. Incluso nos permite exponer más datos por cada slot, en lugar del componente en su conjunto.

Por supuesto, hay espacio para mejorar aquí. Pero decidamos que estas son las características que quieres para ese componente. Nada funciona todavía. Todavía tienes que implementar el código real que hará que esto funcione.

Empezando con la plantilla, sólo tenemos 3 slots que se muestran usando v-if:

<template>
  <div>
    <slot v-if="data" :data="data" />    
    <slot v-if="!loading" name="pagination" v-bind="{ nextPage, prevPage }" />    
    <slot v-if="error" name="error" :message="error.message" />    
    <slot v-if="loading" name="loading" />
  </div>
</template>

Usando v-if con múltiples slots aquí es una abstracción, por lo que los consumidores de este componente no tienen que rendir su UI condicionalmente. Es una característica conveniente de tener en su lugar.

El API de composición permite oportunidades únicas para construir mejores HOC, que es de lo que trata este artículo en primer lugar.

El JavaScript

Con la plantilla fuera del camino, la primera implementación ingenua será en una sola función de setup:

import { ref, onMounted } from 'vue';

export default {
  props: {
    endpoint: {
      type: String,
      required: true,
    }
  },
  setup({ endpoint }) {
    const data = ref(null);
    const loading = ref(true);
    const error = ref(null);
    const currentPage = ref(1);
    
    function fetchData(page = 1) {
      // ...
    }
    
    function nextPage() {
      return fetchData(currentPage.value + 1);
    }
    
    function prevPage() {
      if (currentPage.value <= 1) {
        return;
      }
  
      fetchData(currentPage.value - 1);
    }
    
    
    onMounted(() => {
      fetchData();
    });
    
    return {
      data,
      loading,
      error,
      nextPage,
      prevPage
    };
  }
};

Esa es una visión general de la función de setup. Para completarlo, podemos implementar la función de fetchData como aqui:

function fetchData(page = 1) {
    loading.value = true;
    // I prefer to use fetch
    // you cause use axis as an alternative
    return fetch(`${endpoint}?page=${page}`, {
    // maybe add a prop to control this
    	method: 'get',
        headers: {
          'content-type': 'application/json'
        }
    })
    .then(res=> {
      // a non-200 response code
      if (!res.ok) {
        // create error instance with HTTP status text
        const error = new Error(res.statusText);
        error.json = res.json();
        throw error;
      }
    
      return res.json();
    })
    .then(json=> {
      // set the response data
      data.value = json;
      // set the current page value
      currentPage.value = page;
    })
    .catch(err=> {
      error.value = err;
      // incase a custom JSON error response was provided
      if (err.json) {
        return err.json.then(json=> {
          // set the JSON response message
          error.value.message = json.message;
        });
      }
    })
    .then(() => {
      // turn off the loading state
      loading.value = false;
    });
}

Con todo eso en su lugar, el componente está listo para ser usado. Sin embargo, este componente HOC es similar al que tendrías en el Vue 2. Sólo lo reescribiste usando el API de composición, que, si bien es limpio, no es muy útil.

Hemos descubierto que, para construir un mejor componente HOC para el Vue 3 (especialmente un componente orientado a la lógica como este), es mejor construirlo de una manera “Composition-API-first”. Incluso si sólo planeas enviar un HOC.

Verás que ya lo hemos hecho. La función de setup delcomponente fetch puede ser extraída a su propia función, que se llama useFetch:

export function useFetch(endpoint) {
  // same code as the setup function
}

Y en su lugar nuestro componente se verá así:

import { useFetch } from '@/fetch';

export default {
  props: {
   // ...
  },
  setup({ endpoint }) {
      const api = useFetch(endpoint);
      
      return api;
  }
}

Este enfoque permite algunas oportunidades. Primero, nos permite pensar en nuestra lógica mientras estamos completamente aislados de la UI. Esto permite que nuestra lógica se exprese completamente en JavaScript. Puede ser enganchada más tarde a la interfaz de usuario, que es la responsabilidad de la componente fetch.

En segundo lugar, permite que nuestra función useFetch para descomponer su propia lógica en funciones más pequeñas. Piense en ello como "agrupar" cosas similares, y tal vez crear variaciones de nuestros componentes incluyendo y excluyendo esas características más pequeñas.

Descomponiéndolo

Aclaremos eso extrayendo la lógica de la paginación a su propia función. El problema se convierte en: ¿cómo podemos separar la lógica de paginación de la lógica de fetching? Ambas parecen estar entrelazadas.

Puedes averiguarlo centrándote en lo que hace la lógica de la paginación. Una forma divertida de averiguarlo es quitándolo y comprobando el código que has eliminado.

Actualmente, lo que hace es modificar el endpoint añadiendo un parámetro de query page, y manteniendo el estado de currentPage mientras se exponen las funciones de siguiente y anterior. Eso es literalmente lo que se está haciendo en la iteración anterior.

Creando una función llamada usePagination que sólo hace la parte que necesitamos, obtendrás algo como esto:

import { ref, computed } from 'vue';

export function usePagination(endpoint) {
  const currentPage = ref(1);
  const paginatedEndpoint = computed(() => {
    return `${endpoint}?page=${currentPage.value}`;
  });
  
  function nextPage() {
    currentPage.value++;
  }
  
  function prevPage() {
    if (currentPage.value <= 1) {
      return;
    }
    
    currentPage.value--;    
  }
  
  return {
    endpoint: paginatedEndpoint,
    nextPage,
    prevPage
  };
}

Lo bueno de esto es que hemos escondido la referencia currentPage fde los consumidores externos, que es una de mis partes favoritas de la Composición API. Podemos ocultar fácilmente detalles no importantes a los consumidores de la API.

Es interesante actualizar el useFetchpara reflejar esa página, ya que parece que necesita hacer un seguimiento del nuevo punto final expuesto por usePagination. Afortunadamente, watchhas us covered.

En lugar de esperar que el argumento endpoint pase a ser una cadena regular, podemos permitir que sea un valor reactivo. Esto nos da la capacidad de verlo, y cada vez que la página de paginación cambie, resultará en un nuevo valor final, desencadenando un re-fetch.

import { watch, isRef } from 'vue';

export function useFetch(endpoint) {
  // ...
  function fetchData() {
    // ...
    
    // If it's a ref, get its value
    // otherwise use it directly
      return fetch(isRef(endpoint) ? endpoint.value : endpoint, {
        // Same fetch opts
      }) // ...
  }
  
  // watch the endpoint if its a ref/computed value
  if (isRef(endpoint)) {
    watch(endpoint, () => {
      // refetch the data again
      fetchData();
    });
  } 
  
  return {
    // ...
  };
}

Notese que useFetch y usePaginationno se conocen en absoluto, y ambos se implementan como si el otro no existiera. Esto permite una mayor flexibilidad en nuestro HOC.

También notarán que al construir para Composition API primero, creamos un JavaScript ciego que no es consciente de su UI. En nuestra experiencia, esto es muy útil para modelar los datos correctamente sin pensar en la UI o dejar que la UI dicte el modelo de datos.

Otra cosa genial es que podemos crear dos variantes diferentes de nuestro HOC: una que permite la paginación y otra que no. Esto nos ahorra unos pocos kilobytes.

Aquí hay un ejemplo de uno que sólo hace la paginación:

import { useFetch } from '@/fetch';

export default {
  setup({ endpoint }) {
    return useFetch(endpoint);
  }
};

Aquí hay otro que hace ambas cosas:

import { useFetch, usePagination } from '@/fetch';

export default {
  setup(props) {
    const { endpoint, nextPage, prevPage } = usePagination(props.endpoint);
    const api = useFetch(endpoint);
    
    return {
      ...api,
      nextPage,
      prevPage
    };
  }
};

Mejor aún, se puede aplicar condicionalmente la función usePagination basada en un accesorio para una mayor flexibilidad:

import { useFetch, usePagination } from '@/fetch';

export default {
  props: {
      endpoint: String,
      paginate: Boolean
  },
  setup({ paginate, endpoint }) {
    // an object to dump any conditional APIs we may have
    let addonAPI = {};

    // only use the pagination API if requested by a prop
    if (paginate) {
      const pagination = usePagination(endpoint);
      endpoint = pagination.endpoint;
      addonAPI = {
        ...addonAPI,
        nextPage: pagination.nextPage,
        prevPage: pagination.prevPage
      };
    }

    const coreAPI = useFetch(endpoint);
    
    // Merge both APIs
    return {
      ...addonAPI,
      ...coreAPI,
    };
  }
};

Esto podría ser demasiado para sus necesidades, pero permite que sus HOC sean más flexibles. De lo contrario, serían un cuerpo rígido de código que es más difícil de mantener. También es definitivamente más amigable con las pruebas de unidad.

 

 
by Luigi Nori Date: 07-08-2020 javascript vue vue3 hoc template visitas : 585  
 
Luigi Nori

Luigi Nori

He has been working on the Internet since 1994 (practically a mummy), specializing in Web technologies makes his customers happy by juggling large scale and high availability applications, php and js frameworks, web design, data exchange, security, e-commerce, database and server administration, ethical hacking. He happily lives with @salvietta150x40, in his (little) free time he tries to tame a little wild dwarf with a passion for stars.

 
 
 

Artículos relacionados