Cómo crear y descargar un archivo zip con Node.js y JavaScript

Cómo crear y llenar un archivo zip en Node.js y luego descargarlo en el navegador usando JavaScript.

Primeros pasos

Para este tutorial, vamos a utilizar CheatCode Node.js Server Boilerplate así como CheatCode Next.js Boilerplate. Clonemos cada uno de estos ahora e instalemos las dependencias que necesitaremos para ambos.

Comenzando con el servidor:

Terminal

git clone https://github.com/cheatcode/nodejs-server-boilerplate.git

A continuación, instale las dependencias integradas del servidor repetitivo:

Terminal

cd nodejs-server-boilerplate && npm install

Una vez que estén completos, agregue el jszip dependencia que usaremos para generar nuestro archivo zip:

Terminal

npm install jszip

Con ese conjunto, a continuación, clonemos el modelo de Next.js para el front-end:

Terminal

git clone https://github.com/cheatcode/nextjs-boilerplate.git

Nuevamente, instalemos las dependencias:

Terminal

cd nextjs-boilerplate && npm install

Y ahora, agreguemos el b64-to-blob y file-saver dependencias que necesitaremos en el cliente:

Terminal

npm i b64-to-blob file-saver

Ahora, en pestañas/ventanas separadas en su terminal, iniciemos el servidor y el cliente con (ambos usan el mismo comando desde la raíz del directorio clonado—nodejs-server-boilerplate o nextjs-boilerplate ):

Terminal

npm run dev

Agregando un punto final donde recuperaremos nuestro archivo zip

Primero, conectemos un nuevo punto final de Express.js en el servidor al que podemos llamar desde el cliente para activar la descarga de nuestro archivo zip:

/api/index.js

import graphql from "./graphql/server";
import generateZipForPath from "../lib/generateZipForPath";

export default (app) => {
  graphql(app);

  app.use("/zip", async (req, res) => {
    const zip = await generateZipForPath("lib");
    res.send(zip);
  });
};

Muy simple. Aquí, solo queremos una ruta simple que podamos usar como "control remoto" para activar la descarga de nuestro archivo zip y devolvernos su contenido al cliente. Aquí, estamos usando la API principal index.js archivo incluido en el modelo del servidor Node.js (nada más que una función contenedora para organizar el código; no hay convenciones especiales aquí).

Para hacerlo, creamos una nueva ruta en nuestro Express app (pasado a nosotros a través del /index.js archivo en la raíz del repetitivo) con app.use() , pasando /zip para la URL a la que llamaremos. A continuación, en la devolución de llamada de la ruta, llamamos a la función que crearemos a continuación:generateZipForPath() —pasando el directorio en el servidor que queremos "comprimir". En este caso, solo usaremos el /lib directorio en la raíz del servidor como ejemplo.

A continuación, obtengamos generateZipForPath() configure y aprenda a llenar nuestro zip.

Crear un archivo zip con JSZip

Vamos a mostrar dos métodos para agregar archivos a un zip:un archivo a la vez y agregar todo el contenido de un directorio (incluidas sus subcarpetas). Para comenzar, configuremos nuestro archivo zip base y veamos cómo agregar un solo archivo:

/lib/generateZipForPath.js

import JSZip from "jszip";

export default (directoryPath = "") => {
  const zip = new JSZip();

  zip.file(
    "standalone.txt",
    "I will exist inside of the zip archive, but I'm not a real file here on the server."
  );
  
  // We'll add more files and finalize our zip here.
};

Aquí, definimos y exportamos una función ubicada en la ruta que anticipamos en la sección anterior. Aquí, nuestra función toma un solo directoryPath argumento que especifica la ruta a la carpeta que queremos agregar a nuestro zip (esto será útil en el siguiente paso).

En el cuerpo de la función, iniciamos nuestro nuevo archivo zip con new JSZip() . Tal como parece, esto crea un nuevo archivo zip para nosotros en la memoria.

Justo debajo de esto, llamamos a zip.file() pasándole el nombre del archivo que nos gustaría agregar, seguido del contenido que nos gustaría colocar en ese archivo. Esto es importante.

La idea central en juego aquí es que estamos creando un archivo zip en la memoria . Nosotros no escribiendo el archivo zip en el disco (aunque, si lo desea, puede hacerlo con fs.writeFileSync() —Consulte el paso "Conversión de los datos zip" a continuación para obtener una pista sobre cómo hacerlo).

Cuando llamamos zip.file() estamos diciendo "cree un archivo en la memoria y luego rellene ese archivo, en la memoria, con estos contenidos". En otras palabras, este archivo, técnicamente hablando, no existe. Lo generamos sobre la marcha.

/lib/generateZipForPath.js

import fs from "fs";
import JSZip from "jszip";

const addFilesFromDirectoryToZip = (directoryPath = "", zip) => {
  const directoryContents = fs.readdirSync(directoryPath, {
    withFileTypes: true,
  });
 
  directoryContents.forEach(({ name }) => {
    const path = `${directoryPath}/${name}`;

    if (fs.statSync(path).isFile()) {
      zip.file(path, fs.readFileSync(path, "utf-8"));
    }

    if (fs.statSync(path).isDirectory()) {
      addFilesFromDirectoryToZip(path, zip);
    }
  });
};

export default async (directoryPath = "") => {
  const zip = new JSZip();

  zip.file(
    "standalone.txt",
    "I will exist inside of the zip archive, but I'm not a real file here on the server."
  );

  addFilesFromDirectoryToZip(directoryPath, zip);

  // We'll finalize our zip archive here...
};

Ahora para la parte complicada. Recuerde, queremos aprender cómo agregar un solo archivo (lo que acabamos de lograr anteriormente), así como también cómo agregar un directorio. Aquí, hemos introducido una llamada a una nueva función addFilesFromDirectoryToZip() pasándole el directoryPath argumento que mencionamos anteriormente junto con nuestro zip instancia (nuestro archivo zip incompleto).

/lib/generateZipForPath.js

import fs from "fs";
import JSZip from "jszip";

const addFilesFromDirectoryToZip = (directoryPath = "", zip) => {
  const directoryContents = fs.readdirSync(directoryPath, {
    withFileTypes: true,
  });
 
  directoryContents.forEach(({ name }) => {
    const path = `${directoryPath}/${name}`;

    if (fs.statSync(path).isFile()) {
      zip.file(path, fs.readFileSync(path, "utf-8"));
    }

    if (fs.statSync(path).isDirectory()) {
      addFilesFromDirectoryToZip(path, zip);
    }
  });
};

export default async (directoryPath = "") => {
  [...]

  addFilesFromDirectoryToZip(directoryPath, zip);

  // We'll finalize our zip archive here...
};

Centrándonos en esa función, podemos ver que toma los dos argumentos que esperamos:directoryPath y zip .

Justo dentro del cuerpo de la función, llamamos a fs.readdirSync() , pasando el directoryPath dado para decir "ve y consíguenos una lista de los archivos dentro de este directorio" asegurándote de agregar withFileTypes: true para que tengamos la ruta completa de cada archivo.

A continuación, anticipando directoryContents para contener una matriz de uno o más archivos (devueltos como objetos con un name propiedad que representa el nombre del archivo que se está reproduciendo actualmente), usamos un .forEach() para iterar sobre cada uno de los archivos encontrados, desestructurando el name propiedad (piense en esto como arrancar una uva de un racimo donde el racimo es el objeto que estamos recorriendo actualmente).

Con ese name propiedad, construimos la ruta al archivo, concatenando el directoryPath pasamos a addFilesFromDirectoryToZip() y name . Usando esto a continuación, realizamos la primera de dos comprobaciones para ver si la ruta que estamos recorriendo actualmente es un archivo.

Si es así, agregamos ese archivo a nuestro zip, tal como vimos antes con zip.file() . Esta vez, sin embargo, pasamos el path como el nombre del archivo (JSZip creará automáticamente cualquier estructura de directorio anidada cuando hagamos esto) y luego usamos fs.readFileSync() para ir y leer el contenido del archivo. Nuevamente, decimos "en esta ruta en el archivo zip tal como existe en la memoria, rellénelo con el contenido del archivo que estamos leyendo".

A continuación, realizamos nuestra segunda verificación para ver si el archivo que estamos recorriendo actualmente no es un archivo, sino un directorio. Si es así, recursivamente llama al addFilesFromDirectoryToZip() , pasando el path generamos y nuestro zip existente instancia.

Esto puede ser confuso. La recursividad es un concepto de programación que básicamente describe código que "hace algo hasta que no puede hacer nada más".

Aquí, debido a que estamos atravesando directorios, decimos "si el archivo que está recorriendo es un archivo, agréguelo a nuestro zip y continúe. Pero, si el archivo que está recorriendo es un directorio, llame esta función nuevamente, pasando la ruta actual como punto de partida y luego recorre eso archivos del directorio, agregando cada uno al zip en su ruta especificada".

Porque estamos usando el sync versión de fs.readdir , fs.stat y fs.readFile , este ciclo recursivo se ejecutará hasta que no haya más subdirectorios para recorrer. Esto significa que una vez que esté completa, nuestra función "desbloqueará" el bucle de eventos de JavaScript y continuará con el resto de nuestro generateZipForPath() función.

Convertir los datos zip a base64

Ahora que nuestro zip tiene todos los archivos y carpetas que queremos, tomemos ese zip y convirtámoslo en una cadena base64 que podemos enviar fácilmente al cliente.

/lib/generateZipForPath.js

import fs from "fs";
import JSZip from "jszip";

const addFilesFromDirectoryToZip = (directoryPath = "", zip) => {
  [...]
};

export default async (directoryPath = "") => {
  const zip = new JSZip();

  zip.file(
    "standalone.txt",
    "I will exist inside of the zip archive, but I'm not a real file here on the server."
  );

  addFilesFromDirectoryToZip(directoryPath, zip);

  const zipAsBase64 = await zip.generateAsync({ type: "base64" });

  return zipAsBase64;
};

Último paso en el servidor. Con nuestro zip completo, ahora actualizamos nuestra función exportada para usar el async palabra clave y luego llamar a await zip.generateAsnyc() pasando { type: 'base64' } para indicar que queremos recuperar nuestro archivo zip en un formato de cadena base64.

El await aquí hay solo un truco de sintaxis (también conocido como "azúcar sintáctico") para ayudarnos a evitar encadenar .then() devoluciones de llamada a nuestra llamada a zip.generateAsync() . Además, esto hace que nuestro código asíncrono se lea en un formato de estilo síncrono (es decir, JavaScript permite que cada línea de código se complete y regrese antes de pasar a la siguiente). Entonces, aquí, "esperamos" el resultado de llamar a zip.generateAsync() y solo cuando esté completo, return el valor que esperamos obtener de esa función zipAsBase64 .

Eso lo hace por el servidor, luego, pasemos al cliente y veamos cómo descargar esto a nuestra computadora.

Configuración de la descarga en el cliente

Esta parte es un poco más fácil. Hagamos un volcado de código y luego paso a paso:

/pages/zip/index.js

import React, { useState } from "react";
import b64ToBlob from "b64-to-blob";
import fileSaver from "file-saver";

const Zip = () => {
  const [downloading, setDownloading] = useState(false);

  const handleDownloadZip = () => {
    setDownloading(true);

    fetch("http://localhost:5001/zip")
      .then((response) => {
        return response.text();
      })
      .then((zipAsBase64) => {
        const blob = b64ToBlob(zipAsBase64, "application/zip");
        fileSaver.saveAs(blob, `example.zip`);
        setDownloading(false);
      });
  };

  return (
    <div>
      <h4 className="mb-5">Zip Downloader</h4>
      <button
        className="btn btn-primary"
        disabled={downloading}
        onClick={handleDownloadZip}
      >
        {downloading ? "Downloading..." : "Download Zip"}
      </button>
    </div>
  );
};

Zip.propTypes = {};

export default Zip;

Aquí, creamos un componente React ficticio Zip para darnos una manera fácil de activar una llamada a nuestro /zip punto final de nuevo en el servidor. Usando el patrón del componente de función, representamos un <h4></h4> simple junto con un botón que activará nuestra descarga al hacer clic.

Para agregar un poco de contexto, también hemos introducido un valor de estado downloading lo que nos permitirá deshabilitar condicionalmente nuestro botón (y cambiar su texto) dependiendo de si ya estamos tratando de descargar el zip o no.

Mirando el handleDownloadZip() función, primero, nos aseguramos de deshabilitar temporalmente nuestro botón llamando al setDownloading() y configurándolo en true . A continuación, hacemos una llamada al navegador nativo fetch() método para ejecutar una solicitud GET a nuestro /zip punto final en el servidor. Aquí, estamos usando el localhost:5001 predeterminado dominio para nuestra URL porque ahí es donde se ejecuta el modelo del servidor de forma predeterminada.

A continuación, en el .then() devolución de llamada de nuestro fetch() , llamamos a response.text() para decir "transformar el cuerpo de respuesta sin procesar en texto sin formato". Recuerde, en este punto, esperamos que nuestro código postal llegue al cliente como un base64 cuerda. Para hacerlo más útil, en el siguiente .then() devolución de llamada, hacemos una llamada al b64ToBlob() función del b64-to-blob dependencia.

Esto convierte nuestra cadena base64 en un archivo blob (un formato amigable para el navegador que representa un archivo del sistema operativo), configurando el tipo MIME (el método de codificación) en application/zip . Con esto importamos y llamamos al fileSaver dependencia que instalamos anteriormente, invocando su .saveAs() método, pasando nuestro blob junto con el nombre que queremos usar para el zip cuando se descargue. Finalmente, nos aseguramos de setDownloading() volver a false para volver a habilitar nuestro botón.

¡Hecho! Si su servidor aún se está ejecutando, haga clic en el botón y se le pedirá que descargue su archivo zip.

Terminando

En este tutorial, aprendimos cómo generar un archivo zip usando JSZip. Aprendimos cómo agregar archivos individuales al zip, así como directorios anidados usando una función recursiva, y cómo convertir ese archivo zip en una cadena base64 para enviar de vuelta al cliente. También aprendimos cómo manejar esa cadena base64 en el cliente, convirtiéndola en un archivo blob y guardándola en el disco con file-saver .