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:
-
Elimina las carpetas
/outline
y/optimized/outline
que pueden haber sido generadas en una compilación anterior. -
Ejecuta
svgo
con su configuración declarada en el archivosvgo.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.`));
}
- 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 porthen(fn)
no se ejecuta hasta que ambas carpetas fueron eliminadas. Leer más.
-
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. -
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"
);
}
- Primero se crea un directorio (o carpeta) con el método
fs.mkDir
.
La opción
recursive
defs.mkDir
nos permite evitar un error en el caso que alguno de los directorios de la ruta ya existan. Leer más.
- 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>" }];
-
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. -
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
-
Una vez generado el código Javascript con el ícono y el código de tipo de Typescript, crea los archivos en el sistema.
-
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 =");
};
- 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
.
- A través del método
babel.transformAsync
y el pluginplugin-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 entreesm
ycjs
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: