GitHub 仓库地址:https://github.com/hahala2333/qiniu-upload
📚 简介
在现代 Web 开发中,静态资源的上传和管理是不可避免的需求。为了简化将本地资源上传到七牛云存储的过程,我们构建了 Qiniu Uploader 工具。它具备灵活的配置方式、现代化的构建工具支持(Vite)、兼容多种模块格式以及使用 TypeScript 提供强大的类型支持。本文将手把手带你从 0 开始搭建一个完整的七牛云上传工具,涵盖项目初始化、核心功能实现、打包以及发布到私有仓库的完整流程。
🎯 设计思路与项目规划
在开始编写代码之前,我们需要明确工具的功能和设计思路:
- 支持通过配置文件或直接传入参数配置七牛云账号信息。
- 使用 Vite 构建项目,支持输出 ESM 和 CommonJS 两种模块格式。
- 使用 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.json
的 bin
字段中,使其可以通过命令行直接运行:
"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
中编写核心逻辑的主要功能模块:
✅ 加载配置文件并检查是否缺失的必填字段
- 在构造函数中,首先尝试加载
qiniu-config.json
文件,如果文件存在,则读取配置。 - 如果都存在,则合并配置传入参数会覆盖配置文件中的同名参数
- 检查是否缺失的必填字段
✅ 验证存储空间是否存在
通过七牛云的 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 两种格式,我们可以通过 Vite 的 build.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();