构建属于你的七牛云文件上传工具:Qiniu Uploader 详解(从 0 到 1 实现)

279 阅读7分钟

Github 仓库地址:github.com/hahala2333/…

📚 简介

在现代 Web 开发中,静态资源的上传和管理是不可避免的需求。为了简化将本地资源上传到七牛云存储的过程,我们构建了 Qiniu Uploader 工具。它具备灵活的配置方式、现代化的构建工具支持(Vite)、兼容多种模块格式以及使用 TypeScript 提供强大的类型支持。本文将手把手带你从 0 开始搭建一个完整的七牛云上传工具,涵盖项目初始化、核心功能实现、打包以及发布到私有仓库的完整流程。


🎯 设计思路与项目规划

在开始编写代码之前,我们需要明确工具的功能和设计思路:

  1. 支持通过配置文件或直接传入参数配置七牛云账号信息。
  2. 使用 Vite 构建项目,支持输出 ESM 和 CommonJS 两种模块格式。
  3. 使用 TypeScript 提供类型支持,提高代码的可读性和可维护性。

🛠 第一步:初始化项目

1️⃣ 创建项目目录

首先,在本地创建一个新的项目目录,并进入该目录:

mkdir qiniu-uploader
cd qiniu-uploader

2️⃣ 初始化 package.json

在项目根目录下运行以下命令,初始化 package.json 文件:

npm init -y

此时,项目目录下会生成一个 package.json 文件。

3️⃣ 安装必要的依赖

接下来,我们需要安装项目所需的依赖:

npm install qiniu vite
npm install -D typescript @types/node tsx
  • qiniu:七牛云官方 SDK。
  • vite:现代化的构建工具。
  • typescript:TypeScript 编译器。
  • @types/node:Node.js 的类型定义。
  • tsx:用于运行 TypeScript 文件的工具。

✏️ 第二步:编写配置文件生成脚本

为了简化用户的配置操作,我们提供一个 CLI 工具来生成默认的配置文件。

1️⃣ 创建 cli 目录和脚本文件

在项目根目录下创建 cli 目录,并在其中创建 init-config.js 文件:

mkdir cli
cd cli
nano init-config.js

2️⃣ 编写配置文件生成脚本

cli/init-config.js 中,编写以下代码:

#!/usr/bin/env node

const fs = require("fs");

const configContent = {
  accessKey: "your-access-key",
  secretKey: "your-secret-key",
  bucket: "your-bucket",
  zone: "Zone_z1",
  basePath: "static/assets",
  distPath: "./dist",
};

fs.writeFileSync(
  "qiniu-config.json",
  JSON.stringify(configContent, null, 2),
  "utf8"
);
console.log("qiniu-config.json 文件已生成!");

3️⃣ 修改 package.json

将该脚本添加到 package.jsonbin 字段中,使其可以通过命令行直接运行:

"bin": {
  "qiniu-config": "cli/init-config.js"
}

4️⃣ 生成配置文件

运行以下命令,生成默认的配置文件:

npx qiniu-config

此时,项目根目录下会生成一个 qiniu-config.json 文件。


💻 第三步:编写核心上传逻辑

接下来,我们开始编写核心的上传逻辑,这部分将是整个工具包的核心功能。

1️⃣ 创建 src/index.ts

src 目录下创建 index.ts 文件,用于编写核心的上传逻辑。

mkdir src
cd src
nano index.ts

2️⃣ 编写核心逻辑

src/index.ts 中编写核心逻辑的主要功能模块:

✅ 加载配置文件并检查是否缺失的必填字段

  1. 在构造函数中,首先尝试加载 qiniu-config.json 文件,如果文件存在,则读取配置。
  2. 如果都存在,则合并配置传入参数会覆盖配置文件中的同名参数
  3. 检查是否缺失的必填字段

✅ 验证存储空间是否存在

通过七牛云的 SDK 验证存储空间(Bucket)是否存在。

✅ 获取上传 Token

生成上传所需的 Token。

✅ 递归获取本地文件路径

递归获取本地文件夹中所有的文件路径,准备上传。

✅ 上传文件到七牛云

使用七牛云的 SDK 将文件上传至指定的存储空间。

详细代码

import qiniu from "qiniu";
import fs from "fs";
import path from "path";

const ZoneMap = {
  Zone_z0: qiniu.zone.Zone_z0, // 华东
  Zone_z1: qiniu.zone.Zone_z1, // 华北
  Zone_z2: qiniu.zone.Zone_z2, // 华南
  Zone_na0: qiniu.zone.Zone_na0, // 北美
  Zone_as0: qiniu.zone.Zone_as0, // 东南亚
};

export interface UploadOptions {
  accessKey?: string; // 七牛云 Access Key
  secretKey?: string; // 七牛云 Secret Key
  bucket?: string; // 存储空间名称
  zone?: keyof typeof ZoneMap; // 存储区域
  basePath?: string; // 在七牛云存储中添加的路径前缀
  distPath?: string; // 本地静态资源路径
}

export class QiniuUploader {
  private mac: qiniu.auth.digest.Mac;
  private config: qiniu.conf.Config;
  private options: UploadOptions;
  // 初始化上传类的配置
  constructor(options?: UploadOptions) {
    const configFilePath = path.join(process.cwd(), "qiniu-config.json");
    const fileOptions = QiniuUploader.loadConfigFile(configFilePath);
    // 合并配置文件和传入参数,优先使用传入参数
    this.options = { ...fileOptions, ...options };

    // 3. 验证必填字段
    const missingFields = this.getMissingRequiredFields();
    if (missingFields.length > 0) {
      throw new Error(`Missing required fields: ${missingFields.join(", ")}`);
    }

    this.mac = new qiniu.auth.digest.Mac(
      this.options.accessKey,
      this.options.secretKey
    );
    this.config = new qiniu.conf.Config();
    this.config.zone = this.options.zone
      ? ZoneMap[this.options.zone]
      : qiniu.zone.Zone_z1; // 默认华东
  }
  // 检查缺失的必填字段
  private getMissingRequiredFields(): string[] {
    const requiredFields = ["accessKey", "secretKey", "bucket", "distPath"];
    const missingFields: string[] = [];

    for (const field of requiredFields) {
      if (!this.options[field as keyof UploadOptions]) {
        missingFields.push(field);
      }
    }

    return missingFields;
  }

  // 静态方法:加载配置文件
  private static loadConfigFile(
    configFilePath: string
  ): Partial<UploadOptions> {
    if (!fs.existsSync(configFilePath)) {
      return {};
    }
    try {
      const fileContent = fs.readFileSync(configFilePath, "utf-8").trim();

      // 如果文件内容为空,则返回一个空对象
      if (!fileContent) {
        return {};
      }

      const config = JSON.parse(fileContent);
      return config;
    } catch (error: any) {
      throw new Error(`Failed to load configuration file: ${error.message}`);
    }
  }

  // 验证存储空间是否存在
  private async verifyBucket(): Promise<boolean> {
    const bucketManager = new qiniu.rs.BucketManager(this.mac, this.config);
    return new Promise((resolve, reject) => {
      // 调用 getBucketInfo 方法
      bucketManager.getBucketInfo(
        this.options.bucket,
        (err, respBody, respInfo) => {
          if (err) {
            return reject(err); // 网络或 SDK 错误
          }
          if (respInfo.statusCode === 200) {
            resolve(true); // Bucket 存在
          } else if (respInfo.statusCode === 631) {
            resolve(false); // Bucket 不存在
          } else {
            reject(new Error(`Unexpected status code: ${respInfo.statusCode}`));
          }
        }
      );
    });
  }

  // 获取上传 Token
  private getUploadToken(): string {
    const options = {
      scope: this.options.bucket,
      expires: 3600,
    };
    const putPolicy = new qiniu.rs.PutPolicy(options);
    return putPolicy.uploadToken(this.mac);
  }

  // 上传方法
  public async upload(): Promise<void> {
    // 验证存储空间是否存在
    const bucketExists = await this.verifyBucket();
    if (!bucketExists) {
      throw new Error(`Bucket "${this.options.bucket}" does not exist.`);
    }

    // 获取上传 Token
    const uploadToken = this.getUploadToken();
    // 创建上传器
    const formUploader = new qiniu.form_up.FormUploader(this.config);
    // 设置上传时的额外参数 默认不设置任何参数
    const putExtra = new qiniu.form_up.PutExtra();
    // 获取所有待上传的文件路径
    const files = this.getFiles(this.options.distPath);

    // 逐个上传文件
    for (const file of files) {
      // 计算目标路径(key)
      const key = this.options.basePath
        ? path.join(
            this.options.basePath,
            path.relative(this.options.distPath, file)
          )
        : path.relative(this.options.distPath, file);

      await new Promise((resolve, reject) => {
        formUploader.putFile(
          uploadToken,
          key,
          file,
          putExtra,
          (err, body, info) => {
            if (err) return reject(err);
            if (info.statusCode === 200) {
              console.log(`Uploaded: ${key}`);
              resolve(body);
            } else {
              reject(body);
            }
          }
        );
      });
    }
  }

  // 通过递归方法获取所有需要上传的文件路径。
  private getFiles(dir: string): string[] {
    const files: string[] = [];
    const entries = fs.readdirSync(dir, { withFileTypes: true });
    for (const entry of entries) {
      const fullPath = path.join(dir, entry.name);
      if (entry.isDirectory()) {
        files.push(...this.getFiles(fullPath));
      } else {
        files.push(fullPath);
      }
    }
    return files;
  }
}


✅ 验证核心功能

编写一个测试文件来验证上传功能是否正常工作:

1️⃣ 创建 test.ts

在项目根目录下创建 test.ts 文件:

import { QiniuUploader } from "./src/index";

(async () => {
  try {
    const uploader = new QiniuUploader();
    await uploader.upload();
    console.log("Upload completed successfully!");
  } catch (error) {
    console.error("Upload failed:", error);
  }
})();

2️⃣ 配置 package.json 的导出字段

 "scripts": {
    "dev": "tsx test.ts"
  },

运行以下命令进行测试:

npm run dev

🛠 第四步:如何配置 Vite 打包工具

1️⃣ 配置 vite.config.ts 文件

为了支持 ESM 和 CommonJS 两种格式,我们可以通过 Vitebuild.lib 配置来实现。具体配置如下:

vite.config.ts

import { defineConfig } from "vite";
import path from "path";

export default defineConfig({
  build: {
    lib: {
      entry: path.resolve(__dirname, "src/index.ts"), // 工具库的入口文件
      name: "QiniuUploader", // 打包后的全局变量名称
      fileName: (format) => `index.${format === "es" ? "mjs" : "cjs.js"}`, // 输出的文件名
      formats: ["es", "cjs"], // 同时输出 ESM 和 CommonJS 两种格式
    },
    rollupOptions: {
      external: ["qiniu", "fs", "path"], // 指定外部依赖,不打包进最终文件
      output: {
        globals: {
          qiniu: "qiniu", // 配置全局变量
        },
      },
    },
  },
});

解释:

  • entry:工具库的入口文件,通常是 src/index.ts
  • name:为全局变量命名,适用于在浏览器中直接引入。
  • fileName:根据模块格式不同,分别输出 index.mjs(ESM 格式)和 index.cjs.js(CommonJS 格式)。
  • formats:指定打包的模块格式,可以是 ["es", "cjs"]

2️⃣ 配置 TypeScript 类型文件的输出

为了让用户在使用工具库时获得 TypeScript 类型提示,我们需要配置 TypeScript 编译器 输出类型声明文件。

配置 tsconfig.json 文件

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "declaration": true,             // 启用类型声明文件的生成
    "emitDeclarationOnly": true,     // 只生成类型声明文件
    "outDir": "dist",                // 指定输出目录
    "moduleResolution": "node",
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "resolveJsonModule": true
  },
  "include": ["src/**/*.ts"]         // 指定要编译的文件
}

解释:

  • declaration:启用类型声明文件的生成(.d.ts)。
  • emitDeclarationOnly:仅输出类型声明文件,不生成 JavaScript 文件。
  • outDir:指定输出目录为 dist,将类型声明文件输出到打包目录中。
  • include:指定编译的文件路径。

3️⃣ 添加打包脚本

package.json 文件中,添加以下脚本用于执行打包命令:

修改 package.json

"scripts": {
  "build": "vite build && tsc"
}

解释:

  • vite build:使用 Vite 构建工具库。
  • tsc:运行 TypeScript 编译器,仅输出类型声明文件。

4️⃣ 配置 package.json 的导出字段

为了让用户能够根据项目的模块类型自动选择 ESM 或 CommonJS 格式,我们需要在 package.json 文件中配置 exports 字段:

修改 package.json

{
  "main": "dist/index.cjs.js",       // CommonJS 入口文件
  "module": "dist/index.mjs",        // ESM 入口文件
  "types": "dist/index.d.ts",        // 类型声明文件入口
  "exports": {
    ".": {
      "import": "./dist/index.mjs",  // ESM 格式的导出路径
      "require": "./dist/index.cjs.js" // CommonJS 格式的导出路径
    }
  }
}

解释:

  • main:指定 CommonJS 格式的入口文件。
  • module:指定 ESM 格式的入口文件。
  • types:指定类型声明文件的入口文件。
  • exports:根据模块类型动态导出对应的文件路径。

5️⃣ 执行打包命令

配置完成后,运行以下命令生成打包文件和类型声明文件:

npm run build

打包完成后,dist 目录下将生成以下文件:

dist/
├── index.cjs.js    # CommonJS 格式的文件
├── index.mjs       # ESM 格式的文件
└── index.d.ts      # 类型声明文件

完整的 package.json

{
  "name": "qiniu-upload",
  "version": "1.0.0",
  "description": "A library for uploading files to Qiniu Cloud",
  "main": "dist/index.cjs.js",
  "module": "dist/index.mjs",
  "types": "dist/index.d.ts",
  "license": "MIT",
  "author": "haha",
  "files": [
    "dist",
    "README.md",
    "cli/init-config.js"
  ],
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs.js"
    }
  },
  "scripts": {
    "build": "vite build && tsc",
    "dev": "tsx test.ts"
  },
  "bin": {
    "qiniu-config": "cli/init-config.js"
  },
  "dependencies": {
    "qiniu": "^7.14.0"
  },
  "devDependencies": {
    "@types/node": "^22.10.5",
    "tsx": "^4.19.2",
    "vite": "^6.0.7"
  }
}


🚀 第五步:发布到私有仓库

1️⃣ 登录私有仓库

运行以下命令,登录你的私有仓库:

npm login --registry=https://your-private-registry.com/

2️⃣ 发布工具库

运行以下命令,将工具库发布到私有仓库:

npm publish

至此,你的七牛云文件上传工具库已经成功发布到私有仓库,可以在其他项目中通过 NPM 进行安装和使用。

✅ 如何使用

1️⃣ 在 ESM 项目中验证

创建一个新的 ESM 项目,并通过 import 语法引入工具库:

import { QiniuUploader } from 'qiniu-uploader';

const uploader = new QiniuUploader();
await uploader.upload();

2️⃣ 在 CommonJS 项目中验证

创建一个新的 CommonJS 项目,并通过 require 语法引入工具库:

const { QiniuUploader } = require('qiniu-uploader');

const uploader = new QiniuUploader();
uploader.upload().then(() => {
  console.log('Upload complete!');
}).catch(console.error);

3️⃣ 在 TypeScript 项目中使用

import { QiniuUploader, UploadOptions } from "qiniu-upload";

const options: UploadOptions = {
  accessKey: "your-access-key",
  secretKey: "your-secret-key",
  bucket: "your-bucket",
  distPath: "./dist",
};

const uploader = new QiniuUploader(options);

await uploader.upload();