Gonzalo Bilune

Construí tu propia librería de íconos

2021-10-12Frontend

Entendiendo Heroicons

Construí tu propia librería de íconos
Volver

Construir una librería de componentes generalmente puede ayudarnos a reducir el riesgo de terminar usando componentes diferentes en lugares distintos. Esto nos perite evitar el trabajo repetido y enfocarnos en optimizaciones que impactarán en todos los lugares donde nuestros componentes sean usados.

Beneficios similares podemos obtener cuando construimos una librería de íconos:

  • Consistencia: así podemos evitar usar variaciones no documentadas del mismo ícono.
  • Velocidad: todos sabemos que no hay peor cosa que tener que molestar a cada rato a nuestr@ amig@ de UX para que nos pase un ícono (kudos a Agus por la paciencia).
  • Optimizaciones: agregar un ícono en nuestra UI casi nunca suele ser una actividad principal, siempre lo hacemos como parte de agregar un botón o un listado. Tener una librería ya optimizada es lo ideal para estos casos.
  • Organización: ¿alguna vez tuviste una carpeta llena de íconos? ¿no estás seguro si todos están siendo usados?

La solución de Heroicons

Heroicons es una librería de más de 450 íconos construída por los creadores de Tailwind. En este blog analizaremos su implementación para React, aunque también podemos usarlos con Vue o directamente a través de sus SVG.

Qué queremos lograr

Lo que queremos lograr es poder generar una librería que nos permita importar un ícono como si fuera un componente:

import { BeakerIcon } from "@heroicons/react/solid";
 
function MyComponent() {
  return (
    <div>
      <BeakerIcon className="icon" />
    </div>
  );
}

Dependencias

Si analizamos el package.json, vemos que las dependencias principales de este paquete son:

  • svgo: es una herramienta basada en Node.js para optimizar archivos de vectores gráficos SVG

  • svgr: Una herramienta para convertir SVG en componentes de React.

  • Babel: Babel es un conjunto de herramientas que se utiliza principalmente para convertir el código ECMAScript 2015+ en una versión de JavaScript compatible con versiones anteriores en navegadores o entornos actuales y antiguos.

  • @babel/plugin-transform-react-jsx: Un plugin de babel para compilar JSX a código Javascript válido para ejecutar en un navegador.

Viendo estas herramientas podemos tener una idea aproximada de lo que hace heroicons para construir la librería de íconos.

Metiéndonos en el código

Si leemos el package.json podemos entender qué código se está ejecutando para construir la librería:

{
  "scripts": {
    "build-react": "node ./scripts/build.js react"
  }
}

Es decir, se está ejecutando un script ubicado en /scripts/build.js con Node.js y se le pasa un parámetro react.

Antes de empezar

Antes de ejecutar el script build-react, Heroicons utiliza SVGO para optimizar los íconos:

{
  "scripts": {
    "build-outline": "rimraf ./outline ./optimized/outline && svgo --config=svgo.outline.yaml -f ./src/outline -o ./optimized/outline --pretty --indent=2 && cp -R ./optimized/outline ./outline",
    "build-solid": "rimraf ./solid ./optimized/solid && svgo --config=svgo.solid.yaml -f ./src/solid -o ./optimized/solid --pretty --indent=2 && cp -R ./optimized/solid ./solid"
  }
}

Para el ejemplo de la variante outline, este script está ejecutando:

  1. Elimina las carpetas /outline y /optimized/outline que pueden haber sido generadas en una compilación anterior.

  2. Ejecuta svgo con su configuración declarada en el archivo svgo.outline.yaml. También se indica que los archivos a optimizar se encuentran en el directorio /src/outline y el resultado debe ser guardado en /optimized/outline.

SVGO recibe un montón de configuraciones y plugins que podés consultar en su documentación oficial.

El método principal

Si vamos al archivo mencionado y llegamos a las últimas líneas, vamos a ver que se está ejecutando una función main con el parámetro que se le pasa a la hora de ejecutar el script:

let [package] = process.argv.slice(2); // package = react
 
if (!package) {
  throw Error("Please specify a package");
}
 
main(package);

Conocé más sobre process.argv: devuelve una matriz que contiene los argumentos de la línea de comandos pasados ​​cuando se inició el proceso Node.js. Leer más.

Ahora vamos a analizar la función main:

function main(package) {
  console.log(`Building ${package} package...`);
 
  Promise.all([
    rimraf(`./${package}/outline/*`),
    rimraf(`./${package}/solid/*`),
  ])
    .then(() =>
      Promise.all([
        buildIcons(package, "solid", "esm"),
        buildIcons(package, "solid", "cjs"),
        buildIcons(package, "outline", "esm"),
        buildIcons(package, "outline", "cjs"),
        fs.writeFile(
          `./${package}/outline/package.json`,
          `{"module": "./esm/index.js"}`,
          "utf8"
        ),
        fs.writeFile(
          `./${package}/solid/package.json`,
          `{"module": "./esm/index.js"}`,
          "utf8"
        ),
      ])
    )
    .then(() => console.log(`Finished building ${package} package.`));
}
  1. Antes que nada, empieza eliminando las carpetas que podrían haber sido generadas en una compilación anterior ya que crearemos nuevas en la compilación actual.

Promise.all devuelve una promesa que se resuelve cuando todas las promesas que recibe por parámetro fueron resueltas. O sea que la función que le pasamos por then(fn) no se ejecuta hasta que ambas carpetas fueron eliminadas. Leer más.

  1. Una vez que se eliminaron las compilaciones viejas, se ejecutan los métodos que compilan los íconos: buildIcons('react', 'solid', 'esm'). Ahondaremos en este método en la próxima sección.

  2. También se generan los package.json para cada uno de los paquetes compilados.

Construyendo los íconos

Si vamos a la definición del método buildIcons podemos encontrar lo siguiente:

async function buildIcons(package, style, format) {
  let outDir = `./${package}/${style}`;
  if (format === "esm") {
    outDir += "/esm";
  }
 
  await fs.mkdir(outDir, { recursive: true });
 
  let icons = await getIcons(style);
 
  await Promise.all(
    icons.flatMap(async ({ componentName, svg }) => {
      let content = await transform[package](svg, componentName, format);
      let types = `
      import * as React from 'react';
      declare function ${componentName}(props: React.ComponentProps<'svg'>): JSX.Element;
      export default ${componentName};
      `;
 
      return [
        fs.writeFile(`${outDir}/${componentName}.js`, content, "utf8"),
        fs.writeFile(`${outDir}/${componentName}.d.ts`, types, "utf8"),
      ];
    })
  );
 
  await fs.writeFile(`${outDir}/index.js`, exportAll(icons, format), "utf8");
 
  await fs.writeFile(
    `${outDir}/index.d.ts`,
    exportAll(icons, "esm", false),
    "utf8"
  );
}
  1. Primero se crea un directorio (o carpeta) con el método fs.mkDir.

La opción recursive de fs.mkDir nos permite evitar un error en el caso que alguno de los directorios de la ruta ya existan. Leer más.

  1. Obtenemos un arreglo de íconos con el método getIcons() que explicaremos más adelante. El resultado será algo de este estilo:
let icons = [{ componentName: "MyIcon", svg: "<svg></svg>" }];
  1. Una vez que obtiene los íconos, transforma el SVG recibido en un archivo de Javascript válido a través del método transform que explicaremos más adelante.

  2. También genera un archivo con los tipos del ícono para que sean reconocidos por Typescript. Como vemos en el código, todos los íconos compartirán el mismo tipo por lo que lo único que cambia en el archivo generado es el nombre del componente.

Los archivos .d.ts se utilizan para proporcionar información de tipo sobre una API que está escrita en Javascript, ahorrándonos la necesidad de reescribir el código en Typescript. Leer más

  1. Una vez generado el código Javascript con el ícono y el código de tipo de Typescript, crea los archivos en el sistema.

  2. Cuando cada uno de los archivos de íconos fue creado en el sistema, se genera un index.js que importa y reexporta cada uno para que luego podamos importarlos de la siguiente forma:

// Con index.js
import { BeakerIcon } from "icons-library";
 
// Sin index.js
import BeakerIcon from "icons-library/some-folder/BeakerIcon";

Transformando los SVG en componentes de React

Veamos ahora el método transform:

async (svg, componentName, format) => {
  let component = await svgr(svg, {}, { componentName });
  let { code } = await babel.transformAsync(component, {
    plugins: [
      [require("@babel/plugin-transform-react-jsx"), { useBuiltIns: true }],
    ],
  });
 
  if (format === "esm") {
    return code;
  }
 
  return code
    .replace('import * as React from "react"', 'const React = require("react")')
    .replace("export default", "module.exports =");
};
  1. SVGr es el encargado de transformar nuestro archivo SVG a JSX. Por ejemplo, el siguiente ícono:
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
  <path
    stroke-linecap="round"
    stroke-linejoin="round"
    stroke-width="2"
    d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
  />
</svg>

se va a convertir en:

import * as React from "react";
 
function SvgComponent(props) {
  return (
    <svg fill="none" viewBox="0 0 24 24" stroke="currentColor" {...props}>
      <path
        strokeLinecap="round"
        strokeLinejoin="round"
        strokeWidth={2}
        d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
      />
    </svg>
  );
}
 
export default SvgComponent;

Puedes probar transformar cualquier archivo SVG en SVGR Playground

El problema con esto es que si intentamos ejecutar ese código en un navegador no vamos a poder porque no es código Javascript válido. Necesita primero ser transpilado con babel.

  1. A través del método babel.transformAsync y el plugin plugin-transform-react-jsx se hará esa transpilación, que convertirá el código anterior en el siguiente:
import * as React from "react";
 
function SvgComponent(props) {
  return React.createElement(
    "svg",
    _extends(
      {
        fill: "none",
        viewBox: "0 0 24 24",
        stroke: "currentColor",
      },
      props
    ),
    React.createElement("path", {
      strokeLinecap: "round",
      strokeLinejoin: "round",
      strokeWidth: 2,
      d: "M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z",
    })
  );
}
 
export default SvgComponent;

Esto sí es código Javascript válido ejecutable listo para ser importado y utilizado por otros paquetes.

Para no extender este artículo ahondaremos en el concepto de sistemas de módulos de Javascript y la diferencia entre esm y cjs en otro artículo pero mientras tanto puedes leer más aquí y aquí respectivamente.

Resultado

El resultado podría variar según la configuración que usemos pero para la que usa Heroicons quedaría de la siguiente forma:

react
├── outlined
│   ├── esm
│   │    ├── BeakerIcon.js
│   │    └── BeakerIcon.d.ts
│   ├── BeakerIcon.js
│   ├── BeakerIcon.d.ts
│   ├── index.js
│   └── index.d.ts
└── solid
    └── ...

La estructura completa se encuentra en este link.

De esta forma, una vez que se publique el paquete, ya podríamos utilizar la librería importando los íconos como componentes de React.

import { BeakerIcon } from "@heroicons/react/solid";
 
// or
 
import BeakerIcon from "@heroicons/react/solid/BeakerIcon";

En resumen

  • Crear una librería puede ser útil para organizar mejor nuestros íconos, sobre todo cuando son muchos o cuando somos muchas personas utilizando el sistema de íconos.

  • Heroicons es una librería de íconos que podemos utilizar de inspiración.

  • Si seguimos su método, podemos usar

    • SVGO para optimizar nuestros archivos SVG
    • SVGR para convertir nuestros archivos SVG en componentes de React
    • Babel y el plugin plugin-transform-react-jsx para transpilar los componentes de React en código Javascript válido.
    • Todos los íconos comparten el mismo tipo por lo que es fácil generar los archivos .d.ts.

Fuentes: