Generando un Monorepo con Lerna & Yarn Workspaces

SL

Autor: Lee Robinson

Traducción: Sergio Lozano

June 12, 2020––– visitas

Lectura 14 min

Generando un Monorepo con Lerna & Yarn Workspaces

A medida que una aplicación escala, inevitablemente vas a llegar a un punto en el que querrás escribir componentes reutilizables compartidos que se puedan utilizar en cualquier parte de tu aplicación. A lo largo de los años, hemos tenido repositorios para cada paquete. Sin embargo, esto se puede convertir en un problema por las siguientes razones:

  • No escala de forma correcta. Antes de que te des cuenta, vas a tener docenas de paquetes diferentes en un mismo repositorio, repitiendo procesos de desarrollo, prueba y lanzamiento.
  • Se favorece el agrupamiento de componentes innecesarios. ¿Tenemos que crear un nuevo repositorio para un solo botón? Pongámoslo en este otro paquete. ¡Vaya! ahora hemos aumentado el tamaño del paquete para algo que el 95% de los consumidores de este repositorio no usarán.
  • Provoca que las actualizaciones sean más complejas. Si actualizas un componente base, tienes que actualizar a sus consumidores, a los consumidores de los consumidores, etc… Y cuánto más escalas más grande se hace este problema.

Para poder crear nuestras aplicaciones de la forma más eficaz posible, necesitamos empaquetados pequeños. Esto significa que solo vamos a incluir el código que se esté utilizando en nuestros paquetes.

En conjunto a todo lo mencionado, cuando desarrollemos librerías de componentes que queramos compartir, vamos a necesitar semver piezas de código individualizadas en lugar de todo el paquete. Esto evita escenarios en los que:

  1. El consumidor A necesita un paquete para utilizar un componente de la versión 1.
  2. El consumidor B utiliza el paquete para todos los componentes. Han ayudado a crear y modificar otros componentes de un paquete y se ha hecho más grande, por lo que ahora se encuentra en la versión 8.
  3. El consumidor A ahora necesita solucionar un error para el componente que está utilizando. Tienen que actualizarlo en la versión 8.

Lerna#

Lerna y Yarn Workspaces nos dan la oportunidad de desarrollar bibliotecas y aplicaciones en un solo repositorio, también conocidos como mono repositorios o por su versión en inglés Monorepo). Un mono repositorio no nos obliga a publicar en NPM hasta que estemos preparados. Esto hace que sea más rápido realizar iteraciones locales cuando se están desarrollando componentes que dependen los unos de los otros.

Lerna también proporciona una serie de comandos de alto nivel (high-level) para optimizar la gestión de múltiples paquetes. Por ejemplo, con un único comando de Lerna es posible iterar a través de todos los paquetes, ejecutando una serie de operaciones (como por ejemplo, analizar(lint), realizar pruebas, y desarrollar) en cada paquete.

Grandes proyectos de JavaScript utilizan mono repositorios, entre ellos se encuentran: Babel, React, Jest, Vue, Angular, and more.

Mono repositorios#

En esta guía, vamos a utilizar:

  • 🐉 Lerna — Gestor de mono repositorios
  • 📦 Yarn Workspaces — Gestor de paquetes seguro
  • 🚀 React — Librería de JavaScript para interfaces de usuario
  • 💅 styled-components — La elegancia de usar CSS en JS
  • 🛠 Babel — Compilador JavaScript de próxima-generación
  • 📖 Storybook — Entorno para componentes enfocados en interfaz de usuario
  • 🃏 Jest — Pruebas unitarias

Puedes seguir este artículo o ver el resultado final en este repositorio.

Bien, ¡comencemos! Primero, vamos a crear un nuevo proyecto y configurar Lerna.

$ mkdir monorepo
$ cd monorepo
$ npx lerna init

Acabas de crear un archivo package.json en tu proyecto.

package.json
{
  "name": "root",
  "private": true,
  "devDependencies": {
    "lerna": "^3.20.2"
  }
}

Si te fijas verás que también se ha generado un archivo lerna.json, así como también una carpeta denominada /packages que contiene tus librerías. Ahora vamos a modificar el archivo lerna.json para utilizar Yarn Workspaces. Vamos a utilizar una versión independiente, de esta forma podremos utilizar semver de forma correcta en cada paquete.

lerna.json
{
  "packages": ["packages/*"],
  "npmClient": "yarn",
  "useWorkspaces": true,
  "version": "independent"
}

Vamos a tener que modificar el archivo package.json para poder definir dónde se van a guardar los espacios de trabajo Yarn (Yarn Workspaces).

package.json
{
  "name": "root",
  "private": true,
  "workspaces": ["packages/*"],
  "devDependencies": {
    "lerna": "^3.20.2"
  }
}

Babel#

A continuación, vamos a añadir todas las dependencias que vamos a necesitar para utilizar Babel 7.

$ yarn add --dev -W @babel/cli @babel/core @babel/preset-react @babel/preset-env babel-core@7.0.0-bridge.0 babel-loader babel-plugin-styled-components webpack

El uso de -w provoca que Yarn instale las dependencias requeridas para todo el entorno de trabajo. Estas dependencias normalmente se comparten entre todos los paquetes.

Desde el momento en el que yarn se ejecuta, dispones de una carpeta denominada node_modules. No queremos subir ninguno de los paquetes que se encuentran ahí dentro a nuestro repositorio, por lo que vamos a añadir un archivo .gitignore.

.gitignore
.log
.DS_Store
.jest-*
lib
node_modules

De acuerdo, volvamos con Babel. Para establecer una configuración global de Babel, necesitaremos añadir el archivo babel.config.js en ruta principal de nuestro repositorio.

babel.config.js
module.exports = {
  plugins: ['babel-plugin-styled-components'],
  presets: ['@babel/preset-env', '@babel/preset-react']
};

Este archivo indica a Babel como realizar la compilación de los paquetes. Ha llegado el momento de crear un script para ejecutar Babel. Vamos a añadirlo a nuestro package.json.

package.json
"scripts": {
    "build": "lerna exec --parallel -- babel --root-mode upward src -d lib --ignore **/*.stories.js,**/*.spec.js"
}

Veamos este comando parte por parte. lerna exec utilizará cualquier comando y lo ejecutará en todos los paquetes. Este comando indica a Babel como ejecutar funciones paralelas en cada uno de los paquetes, extrayendo de la carpeta /srcy compilando dentro de la carpeta /lib. No queremos incluir ninguna prueba ni historia(story, esto lo veremos más adelante) en el resultado una vez se encuentre compilado.

El uso de --root-mode upward es el condimento ideal para utilizar Yarn workspaces. Este comando indica a Babel que la carpeta node_modules se encuentra en la ruta principal del repositorio en lugar de en cada uno de los paquetes por separado. De esta forma se previene que cada paquete tenga los mismos node_modules extrayéndose todos en la ruta principal. Haremos uso de un enfoque muy similar a este, para realizar pruebas más adelante.

React#

Hemos completado la infraestructura para establecer un único repositorio. Vamos a crear algunos paquetes que puedan hacer uso de ello. Vamos a utilizar React y styled-components para desarrollar los componentes de la interfaz, así que vamos a proceder a su instalación.

$ yarn add --dev -W react react-dom styled-components

Ahora, dentro de /packages vamos a crear una carpeta llamada /button y de esta forma configurar nuestro primer paquete.

packages/button/package.json
{
  "name": "button",
  "version": "1.0.0",
  "main": "lib/index.js",
  "module": "src/index.js",
  "dependencies": {
    "react": "latest",
    "react-dom": "latest",
    "styled-components": "latest"
  },
  "peerDependencies": {
    "react": "^16.0.0",
    "react-dom": "^16.0.0",
    "styled-components": "^5.0.0"
  }
}

Este archivo informa a quien lo utiliza que module se encuentra dentro de la carpeta /src y que el resultado que se realiza a través de Babel (main) se encuentra dentro de /lib. Este es el punto principal de entrada al paquete. Realizar un listado con las dependencias principales peerDependecies asegura que los consumidores añaden los paquetes correctos.

Del mismo modo vamos a querer vincular las dependencias que se encuentran en la ruta principal a nuestro nuevo paquete. Creemos un script para realizar esta acción dentro de nuestro archivo package.json.

package.json
"scripts": {
  "bootstrap": "lerna bootstrap --use-workspaces"
}

Ahora simplemente tenemos que ejecutar yarn bootstrap para instalar y vincular todas las dependencias.

Muy bien, ahora vamos a crear nuestro primer componente: <Button />.

packages/button/src/Button.js
import styled from 'styled-components';

const Button = styled.button`
  background: red;
  color: #fff;
  border-radius: 4px;
  cursor: pointer;
  font-size: 1rem;
  font-weight: 300;
  padding: 9px 36px;
`;

export default Button;

Vamos a vereficar que Babel se encuentra correctamente configurado. En este momento deberíamos de ser capaces de ejecutar yarn build y ver una carpeta /lib que se ha creado para nuestro paquete.

$ lerna exec --parallel -- babel --root-mode upward src -d lib --ignore **/*.stories.js,**/*.spec.js
lerna notice cli v3.20.2
lerna info versioning independent
lerna info Executing command in 1 package: "babel --root-mode upward src -d lib --ignore **/*.stories.js,**/*.spec.js"
button: Successfully compiled 1 file with Babel.
lerna success exec Executed command in 1 package: "babel --root-mode upward src -d lib --ignore **/*.stories.js,**/*.spec.js"
✨  Done in 2.45s.

Storybook#

Storybook nos proporciona un entorno de trabajo con una interfaz interactiva para nuestros componentes. Esto hace que el desarrollo sea pan comido. Configuremos Storybook para ver el componente que acabamos de crear.

$ yarn add --dev -W @storybook/react @storybook/addon-docs

También queremos configurar Storybook para que sepa dónde encontrar nuestras historias (stories).

.storybook/main.js
module.exports = {
  stories: ['../packages/**/*.stories.js'],
  addons: ['@storybook/addon-docs']
};

Ahora, podemos crear nuestra primera historia para nuestro nuevo botón dentro de /packages/button/src.

packages/button/src/Button.stories.js
import React from 'react';

import Button from '.';

export default {
  component: Button,
  title: 'Design System|Button'
};

export const primary = () => <Button>{'Button'}</Button>;

Por último, vamos a añadir un script para ejecutar Storybook.

package.json
"scripts": {
  "dev": "start-storybook -p 5555"
}

Ahora ya podemos usar yarn dev para ver nuestro botón 🎉

Button

Pruebas#

Antes de entrar en detalle, vamos a configurar nuestro entorno de pruebas y crear una prueba sencilla para nuestro componente Button. Usaremos Jest para las pruebas unitarias. Recogerá de forma automatica cualquier archivo que termine con .spec.js.

$ yarn add --dev -W jest jest-styled-components babel-jest react-test-renderer jest-resolve jest-haste-map

A continuación, vamos a configurar Jest en la ruta principal.

jest.config.js
module.exports = {
  cacheDirectory: '.jest-cache',
  coverageDirectory: '.jest-coverage',
  coveragePathIgnorePatterns: ['<rootDir>/packages/(?:.+?)/lib/'],
  coverageReporters: ['html', 'text'],
  coverageThreshold: {
    global: {
      branches: 100,
      functions: 100,
      lines: 100,
      statements: 100
    }
  },
  testPathIgnorePatterns: ['<rootDir>/packages/(?:.+?)/lib/']
};

Puedes modificar todo para que se ajuste a tus necesidades. También vamos a tener que añadir algunos scripts a nuestro package.json.

package.json
"scripts": {
  "coverage": "jest --coverage",
  "unit": "jest"
}

Finalmente, vamos a crear nuestra primera prueba en torno a nuestro componente Button. Utilizaremos pruebas de instantáneas (Snapshots) ya que se trata de un componente puramente visual. Para más información, visita este artículo.

packages/button/src/Button.spec.js
import React from 'react';
import renderer from 'react-test-renderer';
import 'jest-styled-components';

import Button from '.';

describe('Button', () => {
  test('renders correctly', () => {
    const tree = renderer.create(<Button>{'Test'}</Button>).toJSON();
    expect(tree).toMatchSnapshot();
  });
});

Para ejecutar la prueba ejecuta yarn unit.

$ jest
 PASS  packages/button/src/Button.spec.js
  Button
    ✓ renders correctly (23ms)1 snapshot written.
Snapshot Summary
 › 1 snapshot written from 1 test suite.

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   1 written, 1 total
Time:        1.25s
Ran all test suites.
✨  Done in 2.48s.

Múltiples paquetes#

La principal razón para el uso de un mono repositorio es el soporte para múltiples paquetes. Ya que nos permite tener un solo análisis(lint), desarrollo, pruebas y producción de todos los paquetes. Ahora, vamos a crear un paquete Input y añadir un nuevo componente.

packages/input/src/package.json
{
  "name": "input",
  "version": "1.0.0",
  "main": "lib/index.js",
  "module": "src/index.js",
  "dependencies": {
    "react": "latest",
    "react-dom": "latest",
    "styled-components": "latest"
  },
  "peerDependencies": {
    "react": "^16.0.0",
    "react-dom": "^16.0.0",
    "styled-components": "^5.0.0"
  }
}
packages/input/src/Input.js
import styled from 'styled-components';

const Input = styled.input`
  border: 1px solid #ccc;
  border-radius: 4px;
  box-sizing: border-box;
  font-size: 16px;
  font-weight: 300;
  padding: 10px 40px 10px 10px;
  width: 150px;
`;

export default Input;

Vale, en este punto ya tenemos un componente Input. Vamos a ejecutar yarn bootstrap de nuevo para vincular todos los paquetes en conjunto y crear una nueva historia.

packages/input/src/Input.stories.js
import React from 'react';

import Input from '.';

export default {
  component: Input,
  title: 'Design System|Input'
};

export const placeholder = () => <Input placeholder="user@gmail.com" />;

Nuestra instancia de Storybook debería seguir funcionando a través de yarn dev, si no es así, ejecuta de nuevo el comando. Ahora podemos observar que nuestro componente se ha renderizado correctamente.

Storybook

En último lugar, vamos a asegurarnos de que Babel funciona correctamente para múltiples paquetes ejecutando yarn build.

$ lerna exec --parallel -- babel --root-mode upward src -d lib --ignore **/*.stories.js,**/*.spec.js
lerna notice cli v3.20.2
lerna info versioning independent
lerna info Executing command in 2 packages: "babel --root-mode upward src -d lib --ignore **/*.stories.js,**/*.spec.js"
button: Successfully compiled 1 file with Babel.
input: Successfully compiled 1 file with Babel.
lerna success exec Executed command in 2 packages: "babel --root-mode upward src -d lib --ignore **/*.stories.js,**/*.spec.js"
✨  Done in 2.45s.

Ambos paquetes se han compilado a la perfección 🎉 pero... ¿Qué pasa con las pruebas? Vamos a crear otra prueba para el componente Input.

packages/input/src/Input.spec.js
import React from 'react';
import renderer from 'react-test-renderer';
import 'jest-styled-components';

import Input from '.';

describe('Input', () => {
  test('renders correctly', () => {
    const tree = renderer
      .create(<Input placeholder="user@gmail.com" />)
      .toJSON();
    expect(tree).toMatchSnapshot();
  });
});

Ahora podemos ejecutar yarn unit de nuevo.

$ jest
 PASS  packages/button/src/Button.spec.js
 PASS  packages/input/src/Input.spec.js
 › 1 snapshot written.

Snapshot Summary
 › 1 snapshot written from 1 test suite.

Test Suites: 2 passed, 2 total
Tests:       2 passed, 2 total
Snapshots:   1 written, 1 passed, 2 total
Time:        1.96s, estimated 2s
Ran all test suites.
✨  Done in 3.22s.

Publicación#

Nota: Antes de publicar tienes que hacer commit y subir los cambios a tu repositorio. Si todavía no lo has hecho, hazlo ahora.

Publiquemos la primera versión de nuestros paquetes. Podemos utilizar npx lerna changed para ver qué paquetes han sido modificados. También puedes utilizar npx lerna diff para ver de forma específica que líneas han cambiado.

Nota: lerna puede ser instalado de forma global para eliminar la necesidad de utilizar npx. A su vez puedes añadir algunos scripts en package.json debido a que lernase ubica en las dependencias de desarrollo (devDependency).

$ npx lerna changed
lerna notice cli v3.20.2
lerna info versioning independent
lerna info Assuming all packages changed
button
input
lerna success found 2 packages ready to publish

Aquí podemos ver cómo se reconocen ambos componentes. Ahora, ejecutemos npx lerna version para simular su publicación.

$ npx lerna version
lerna notice cli v3.20.2
lerna info versioning independent
lerna info Assuming all packages changed
? Select a new version for button (currently 1.0.0)
? Select a new version for button (currently 1.0.0)

Changes:
 -  button: 1.0.0 => 1.0.1
 -  input: 1.0.0 => 1.0.1

? Are you sure you want to create these releases? Yes
lerna info execute Skipping GitHub releases
lerna info git Pushing tags...
lerna success version finished

¡Felicidades! 🎉 Las etiquetas han sido enviadas a GitHub. Si quisiéramos publicar los paquetes en NPM, podríamos utilizar npx lerna publish en su lugar.

Yarn Workspaces

Análisis de Código y Formato#

Siempre se debe facilitar el poder contribuir a otras personas. Por ese motivo el formato y el análisis de errores en el código(lint) se mantienen utilizando:

Gracias a husky podemos realizar pre-commits hooks, asegurándonos de que todo el código tiene un formato correcto antes de subirlo a GitHub. Un buen análisis de código y un formato correcto nos ahorra tiempo y dolores de cabeza a la hora de revisar el código cuando lo recuperamos del repositorio (pull request). Recomendamos que se establezcan una serie de reglas para dar formato al código y corregir errores, de esta forma, reducirás considerablemente la cantidad de comentarios de tipo “nitpick”. Puedes ver las reglas que utilizamos aquí.

Conclusión#

Enhorabuena, ahora tienes un mono repositorio con todas las funciones configuradas. Si quieres profundizar más e ir más lejos, aquí tienes otras ideas:

  • Automatizar semver en tus lanzamientos usando Conventional Commits.
  • Crear un paquete "theme" que se comparta con todos los demás paquetes. Este podría un comienzo para tu sistema de diseño - compartiendo colores, espaciado, iconografía, etc.
  • Amplia tu Storybook con una gran variedad de add ons.
  • Configura Webpack/Rollup para generar una carpeta /dist.

Lecturas adicionales#

Suscribete a mi newsletter

Recibe emails sobre desarrollo web, tecnología y acceso anticipado cada vez que publique un nuevo artículo.