1. 项目背景与重构动机
四年前,当我第一次接触PicGo这个图床工具时,发现它缺少一个关键的FTP上传插件。作为一个经常需要将图片上传到自建服务器的开发者,这个功能的缺失让我决定自己动手开发一个。最初的版本(imba97/picgo-plugin-ftp-uploader/003b4e)基于官方提供的插件模板,虽然功能可用,但随着前端技术的快速发展和个人经验的积累,这个老项目逐渐暴露出几个问题:
- 代码组织混乱:所有逻辑都堆砌在单个index.ts文件中
- 构建流程简单:仅使用tsc进行基础编译
- 缺乏规范约束:没有统一的代码风格和提交规范
- 维护成本高:随着功能增加,代码变得越来越难以维护
提示:在长期维护的开源项目中,良好的代码组织和规范是保证项目可持续发展的关键因素。
2. 技术选型与架构设计
2.1 现代前端技术栈
重构过程中,我选择了以下技术组合:
- TypeScript:作为类型安全的JavaScript超集,它能在编译阶段捕获大部分类型错误
- unbuild:替代原始的tsc,提供更灵活的打包配置和插件系统
- eslint + @antfu/eslint-config:保证代码风格一致性
- simple-git-hooks + lint-staged:在提交前自动执行代码检查
- bumpp:简化版本管理和发布流程
为什么选择unbuild而非其他打包工具?
相比webpack或rollup,unbuild具有以下优势:
- 配置更简单,专注于库的打包
- 内置对TypeScript的支持
- 可以同时生成ESM和CJS格式的输出
- 支持自动生成类型定义文件
2.2 代码结构优化
重构后的项目结构更加清晰:
code复制src/
├── client/ # FTP客户端封装
│ ├── index.ts
│ └── types.ts
├── uploader/ # 上传逻辑
│ ├── index.ts
│ └── utils.ts
├── config.ts # 配置处理
└── index.ts # 插件入口
这种模块化设计使得:
- 各功能职责分明
- 便于单元测试
- 后续功能扩展更容易
3. 核心实现细节
3.1 FTP客户端封装
为了避免直接暴露底层FTP库的复杂性,我创建了一个封装类:
typescript复制class FTPClient {
private connection: Client
constructor(config: FTPConfig) {
this.connection = new Client()
this.connection.connect(config)
}
async upload(localPath: string, remotePath: string): Promise<void> {
// 处理路径格式化
// 检查目录是否存在
// 执行上传
}
async disconnect(): Promise<void> {
// 安全关闭连接
}
}
注意:FTP连接需要显式关闭,否则可能导致资源泄漏。最佳实践是在try-finally块中确保连接被正确关闭。
3.2 上传流程优化
原始实现中的上传逻辑存在几个问题:
- 没有处理路径中的特殊字符
- 缺少重试机制
- 没有完善的错误处理
重构后的上传流程:
typescript复制async function upload(file: File): Promise<string> {
const client = new FTPClient(config)
try {
// 1. 验证文件类型
validateFileType(file)
// 2. 生成远程路径
const remotePath = generatePath(file)
// 3. 执行上传(带重试)
await withRetry(() => client.upload(file.path, remotePath))
// 4. 返回访问URL
return buildAccessUrl(remotePath)
} finally {
await client.disconnect()
}
}
3.3 配置管理
PicGo插件通常需要用户配置各种参数。重构后的配置处理:
typescript复制interface PluginConfig {
host: string
port: number
user: string
password: string
pathTemplate: string
}
function validateConfig(config: Partial<PluginConfig>): PluginConfig {
// 检查必填字段
// 设置默认值
// 验证格式
return validatedConfig
}
4. 工程化实践
4.1 代码规范与Git钩子
使用@antfu/eslint-config提供的规则集:
javascript复制// .eslintrc
{
"extends": "@antfu",
"rules": {
// 项目特定规则
}
}
配合simple-git-hooks和lint-staged实现提交前检查:
json复制// package.json
{
"simple-git-hooks": {
"pre-commit": "lint-staged"
},
"lint-staged": {
"*.ts": "eslint --fix"
}
}
4.2 构建与发布
unbuild配置示例:
typescript复制// build.config.ts
import { defineBuildConfig } from 'unbuild'
export default defineBuildConfig({
entries: ['src/index'],
declaration: true,
clean: true,
rollup: {
emitCJS: true
}
})
发布文件控制(避免发布源码和测试文件):
json复制{
"files": [
"dist/index.cjs",
"logo.png"
]
}
5. PicGo插件模板开发
基于重构经验,我创建了一个PicGo插件模板:
bash复制picgo init imba97/picgo-template-plugin picgo-plugin-name
模板特点:
- 预置TypeScript配置
- 集成现代工具链
- 包含基础插件结构
- 内置CI/CD配置
6. 常见问题与解决方案
6.1 连接超时问题
现象:在某些网络环境下FTP连接超时
解决方案:
- 增加连接超时设置
- 实现自动重试机制
- 提供详细的错误日志
typescript复制const client = new Client()
client.timeout = 30000 // 30秒超时
6.2 路径处理不一致
现象:Windows和Unix系统路径格式不同
解决方案:统一使用posix格式:
typescript复制function normalizePath(path: string): string {
return path.replace(/\\/g, '/')
}
6.3 大文件上传失败
现象:上传大文件时内存占用过高
解决方案:使用流式处理:
typescript复制function uploadStream(localPath: string, remotePath: string): Promise<void> {
return new Promise((resolve, reject) => {
const readStream = fs.createReadStream(localPath)
const writeStream = client.uploadFrom(remotePath)
readStream.pipe(writeStream)
.on('finish', resolve)
.on('error', reject)
})
}
7. 性能优化实践
7.1 连接池管理
频繁创建和销毁FTP连接会影响性能。实现简单的连接池:
typescript复制class FTPConnectionPool {
private pool: FTPClient[] = []
private maxSize: number
async get(): Promise<FTPClient> {
if (this.pool.length > 0) {
return this.pool.pop()!
}
return createNewConnection()
}
release(client: FTPClient): void {
if (this.pool.length < this.maxSize) {
this.pool.push(client)
} else {
client.disconnect()
}
}
}
7.2 并行上传控制
对于批量上传场景,需要控制并发数:
typescript复制async function uploadFiles(files: File[], concurrency = 3): Promise<void> {
const queue = new PQueue({ concurrency })
return queue.addAll(files.map(file => () => upload(file)))
}
8. 测试策略
8.1 单元测试
使用vitest框架编写核心逻辑测试:
typescript复制describe('FTP Client', () => {
it('should normalize path correctly', () => {
expect(normalizePath('path\\to\\file')).toBe('path/to/file')
})
})
8.2 集成测试
使用testcontainers创建临时FTP服务器进行真实环境测试:
typescript复制describe('Uploader', () => {
let ftpContainer: GenericContainer
beforeAll(async () => {
ftpContainer = new GenericContainer('stilliard/pure-ftpd')
await ftpContainer.start()
})
it('should upload file to FTP server', async () => {
// 测试上传逻辑
})
})
9. 项目收获与经验总结
这次重构让我深刻体会到几个关键点:
- 技术债务要及时偿还:四年前写的代码虽然能用,但随着项目发展会变得越来越难以维护
- 工程化实践很重要:良好的代码规范和自动化工具能显著提高开发效率
- 抽象和封装是关键:合理的模块划分和接口设计能使代码更健壮、更易扩展
- 文档和示例不可或缺:好的README和示例代码能大大降低其他开发者的使用门槛
在实际操作中,有几个特别值得注意的地方:
- FTP连接的管理要格外小心,确保在任何情况下都能正确关闭
- 路径处理要考虑跨平台兼容性
- 错误处理要全面,提供有意义的错误信息
- 性能优化要从实际场景出发,避免过早优化
这个项目从最初满足个人需求,到最终成为PicGo生态的一部分,整个过程让我收获颇丰。如果你也在开发类似工具,希望这些经验能对你有所帮助。