新增:文档体系重构+CHANGELOG补充+发布产物清理
This commit is contained in:
648
docs/02-架构设计/OOP架构/OOP服务层实施方案.md
Normal file
648
docs/02-架构设计/OOP架构/OOP服务层实施方案.md
Normal file
@@ -0,0 +1,648 @@
|
||||
# OOP 服务层实施方案 - 彻底解决初始化问题
|
||||
|
||||
**日期**: 2026-01-31
|
||||
**问题**: 第5次依然出现 "Cannot access before initialization" 错误
|
||||
**方案**: 采用面向对象的服务层架构
|
||||
|
||||
---
|
||||
|
||||
## 🎯 核心问题
|
||||
|
||||
当前 Composition API 方案存在**根本性缺陷**:
|
||||
|
||||
```typescript
|
||||
// 问题1: 函数定义顺序依赖手动保证
|
||||
const config = computed(() => useHelper()) // Line 362
|
||||
const useHelper = () => { ... } // Line 869 ❌
|
||||
|
||||
// 问题2: 解构容易遗漏
|
||||
const { previewUrl, isImageView } = useFilePreview()
|
||||
// ❌ 忘记解构 updatePreviewUrl
|
||||
|
||||
// 问题3: 返回值过多
|
||||
const { 15+个返回值 } = useFilePreview()
|
||||
|
||||
// 问题4: 状态分散
|
||||
const state1 = ref()
|
||||
const state2 = ref()
|
||||
const state3 = ref()
|
||||
// 状态和行为分离,内聚性差
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 OOP 解决方案
|
||||
|
||||
### 架构设计
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ View Layer (Vue) │
|
||||
│ <template> | index.vue | 组件 │
|
||||
└─────────────┬───────────────────────────┘
|
||||
│ 调用
|
||||
┌─────────────▼───────────────────────────┐
|
||||
│ Adapter Layer (Composables) │
|
||||
│ 轻量级适配器,提供响应式接口 │
|
||||
└─────────────┬───────────────────────────┘
|
||||
│ 使用
|
||||
┌─────────────▼───────────────────────────┐
|
||||
│ Service Layer (OOP) │
|
||||
│ 核心业务逻辑,状态+行为封装 │
|
||||
└─────────────┬───────────────────────────┘
|
||||
│ 依赖
|
||||
┌─────────────▼───────────────────────────┐
|
||||
│ Infrastructure Layer │
|
||||
│ 文件API、ZIP处理等底层服务 │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 具体实现
|
||||
|
||||
### 1. 服务基类
|
||||
|
||||
```typescript
|
||||
// core/ServiceBase.ts
|
||||
export abstract class ServiceBase {
|
||||
protected readonly logger = console
|
||||
protected readonly scope: string
|
||||
|
||||
constructor(scope: string) {
|
||||
this.scope = scope
|
||||
this.initialize()
|
||||
}
|
||||
|
||||
protected initialize(): void {
|
||||
// 子类可以覆盖
|
||||
}
|
||||
|
||||
protected log(message: string, ...args: any[]): void {
|
||||
this.logger.log(`[${this.scope}]`, message, ...args)
|
||||
}
|
||||
|
||||
protected error(message: string, error: Error): void {
|
||||
this.logger.error(`[${this.scope}]`, message, error)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 文件预览服务
|
||||
|
||||
```typescript
|
||||
// services/FilePreviewService.ts
|
||||
import { ref, type Ref } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { ServiceBase } from '@/core/ServiceBase'
|
||||
import type { FileApiService } from './FileApiService'
|
||||
import { FileTypes } from '@/utils/fileTypeHelpers'
|
||||
|
||||
/**
|
||||
* 文件预览服务
|
||||
* 负责处理各种文件类型的预览逻辑
|
||||
*/
|
||||
export class FilePreviewService extends ServiceBase {
|
||||
// ========== 状态(私有) ==========
|
||||
private readonly _previewUrl = ref<string>('')
|
||||
private readonly _imageLoading = ref<boolean>(false)
|
||||
private readonly _currentImageDimensions = ref<string>('')
|
||||
|
||||
// ========== 依赖注入 ==========
|
||||
constructor(
|
||||
private readonly fileApi: FileApiService,
|
||||
private readonly filePath: Ref<string>
|
||||
) {
|
||||
super('FilePreviewService')
|
||||
}
|
||||
|
||||
// ========== 公共接口(访问器) ==========
|
||||
|
||||
/** 获取预览URL(响应式) */
|
||||
get previewUrl(): Ref<string> {
|
||||
return this._previewUrl
|
||||
}
|
||||
|
||||
/** 获取图片加载状态(响应式) */
|
||||
get imageLoading(): Ref<boolean> {
|
||||
return this._imageLoading
|
||||
}
|
||||
|
||||
/** 获取图片尺寸(响应式) */
|
||||
get currentImageDimensions(): Ref<string> {
|
||||
return this._currentImageDimensions
|
||||
}
|
||||
|
||||
// ========== 公共方法 ==========
|
||||
|
||||
/**
|
||||
* 更新预览URL
|
||||
*/
|
||||
updatePreviewUrl(url: string): void {
|
||||
this._previewUrl.value = url
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览文件
|
||||
*/
|
||||
async previewFile(filePath: string): Promise<void> {
|
||||
const ext = FileTypes.getExtension(filePath)
|
||||
|
||||
if (this.isImageFile(filePath)) {
|
||||
await this.previewImage(filePath)
|
||||
} else if (this.isVideoFile(filePath)) {
|
||||
await this.previewVideo(filePath)
|
||||
} else if (this.isAudioFile(filePath)) {
|
||||
await this.previewAudio(filePath)
|
||||
} else if (this.isPdfFile(filePath)) {
|
||||
await this.previewPdf(filePath)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为图片文件
|
||||
*/
|
||||
isImageFile(path: string): boolean {
|
||||
return FileTypes.isImage(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为视频文件
|
||||
*/
|
||||
isVideoFile(path: string): boolean {
|
||||
return FileTypes.isVideo(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为音频文件
|
||||
*/
|
||||
isAudioFile(path: string): boolean {
|
||||
return FileTypes.isAudio(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为PDF文件
|
||||
*/
|
||||
isPdfFile(path: string): boolean {
|
||||
return FileTypes.isPdf(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为HTML文件
|
||||
*/
|
||||
isHtmlFile(path: string): boolean {
|
||||
return FileTypes.isHtml(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为Markdown文件
|
||||
*/
|
||||
isMarkdownFile(path: string): boolean {
|
||||
return FileTypes.isMarkdown(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否支持编辑/预览切换
|
||||
*/
|
||||
isEditableWithPreview(filename: string): boolean {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || ''
|
||||
return ['html', 'htm', 'md', 'markdown'].includes(ext)
|
||||
}
|
||||
|
||||
// ========== 私有方法 ==========
|
||||
|
||||
private async previewImage(path: string): Promise<void> {
|
||||
this._imageLoading.value = true
|
||||
try {
|
||||
const url = await this.fileApi.getImageUrl(path)
|
||||
this.updatePreviewUrl(url)
|
||||
this.log('图片预览加载成功', path)
|
||||
} catch (error) {
|
||||
this.error('图片预览加载失败', error as Error)
|
||||
Message.error('图片加载失败')
|
||||
} finally {
|
||||
this._imageLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
private async previewVideo(path: string): Promise<void> {
|
||||
const url = await this.fileApi.getFileUrl(path)
|
||||
this.updatePreviewUrl(url)
|
||||
}
|
||||
|
||||
private async previewAudio(path: string): Promise<void> {
|
||||
const url = await this.fileApi.getFileUrl(path)
|
||||
this.updatePreviewUrl(url)
|
||||
}
|
||||
|
||||
private async previewPdf(path: string): Promise<void> {
|
||||
const url = await this.fileApi.getFileUrl(path)
|
||||
this.updatePreviewUrl(url)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. ZIP浏览服务
|
||||
|
||||
```typescript
|
||||
// services/ZipBrowserService.ts
|
||||
import { ref, computed, type Ref } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { ServiceBase } from '@/core/ServiceBase'
|
||||
import type { FileApiService } from './FileApiService'
|
||||
import type { FilePreviewService } from './FilePreviewService'
|
||||
import type { FileItem } from '@/types/file-system'
|
||||
|
||||
/**
|
||||
* ZIP浏览服务
|
||||
* 负责ZIP文件浏览逻辑
|
||||
*/
|
||||
export class ZipBrowserService extends ServiceBase {
|
||||
// ========== 状态 ==========
|
||||
private readonly _isBrowsingZip = ref<boolean>(false)
|
||||
private readonly _currentZipPath = ref<string>('')
|
||||
private readonly _currentZipDirectory = ref<string>('')
|
||||
private readonly _pathBeforeZip = ref<string>('')
|
||||
|
||||
// ========== 依赖注入 ==========
|
||||
constructor(
|
||||
private readonly fileApi: FileApiService,
|
||||
private readonly previewService: FilePreviewService,
|
||||
private readonly fileList: Ref<FileItem[]>,
|
||||
private readonly filePath: Ref<string>
|
||||
) {
|
||||
super('ZipBrowserService')
|
||||
// 构造函数保证依赖已初始化
|
||||
}
|
||||
|
||||
// ========== 计算属性 ==========
|
||||
|
||||
/** 是否正在浏览ZIP */
|
||||
get isBrowsingZip(): Ref<boolean> {
|
||||
return this._isBrowsingZip
|
||||
}
|
||||
|
||||
/** 当前ZIP路径 */
|
||||
get currentZipPath(): Ref<string> {
|
||||
return this._currentZipPath
|
||||
}
|
||||
|
||||
/** 显示路径 */
|
||||
get displayPath(): Ref<string> {
|
||||
return computed(() => {
|
||||
if (this._currentZipDirectory.value) {
|
||||
return `📦 ${this._currentZipPath.value} [${this._currentZipDirectory.value}]`
|
||||
}
|
||||
return `📦 ${this._currentZipPath.value}`
|
||||
})
|
||||
}
|
||||
|
||||
// ========== 公共方法 ==========
|
||||
|
||||
/**
|
||||
* 进入ZIP浏览模式
|
||||
*/
|
||||
async enterZipMode(zipPath: string): Promise<void> {
|
||||
this._pathBeforeZip.value = this.filePath.value
|
||||
this._currentZipPath.value = zipPath
|
||||
this._isBrowsingZip.value = true
|
||||
this._currentZipDirectory.value = ''
|
||||
|
||||
await this.loadZipDirectory()
|
||||
this.log('进入ZIP浏览模式', zipPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出ZIP浏览模式
|
||||
*/
|
||||
exitZipMode(): void {
|
||||
this._isBrowsingZip.value = false
|
||||
this._currentZipPath.value = ''
|
||||
this._currentZipDirectory.value = ''
|
||||
this.log('退出ZIP浏览模式')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取ZIP文件名
|
||||
*/
|
||||
getZipFileName(zipPath: string): string {
|
||||
const parts = zipPath.split(/[/\\]/)
|
||||
return parts[parts.length - 1] || zipPath
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取面包屑
|
||||
*/
|
||||
getZipBreadcrumbs(): ZipBreadcrumbItem[] {
|
||||
const crumbs: ZipBreadcrumbItem[] = [
|
||||
{ name: this.getZipFileName(this._currentZipPath.value), path: '' }
|
||||
]
|
||||
|
||||
if (this._currentZipDirectory.value) {
|
||||
const parts = this._currentZipDirectory.value.split('/')
|
||||
let currentPath = ''
|
||||
for (const part of parts) {
|
||||
currentPath += (currentPath ? '/' : '') + part
|
||||
crumbs.push({ name: part, path: currentPath })
|
||||
}
|
||||
}
|
||||
|
||||
return crumbs
|
||||
}
|
||||
|
||||
/**
|
||||
* 导航到指定目录
|
||||
*/
|
||||
async navigateToZipDirectory(path: string): Promise<void> {
|
||||
this._currentZipDirectory.value = path
|
||||
await this.loadZipDirectory()
|
||||
}
|
||||
|
||||
// ========== 私有方法 ==========
|
||||
|
||||
private async loadZipDirectory(): Promise<void> {
|
||||
// 加载逻辑
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 文件系统服务(聚合服务)
|
||||
|
||||
```typescript
|
||||
// services/FileSystemService.ts
|
||||
import { ref, type Ref } from 'vue'
|
||||
import { ServiceBase } from '@/core/ServiceBase'
|
||||
import { FilePreviewService } from './FilePreviewService'
|
||||
import { ZipBrowserService } from './ZipBrowserService'
|
||||
import { FileEditService } from './FileEditService'
|
||||
import type { FileItem } from '@/types/file-system'
|
||||
|
||||
/**
|
||||
* 文件系统服务(门面)
|
||||
* 聚合所有文件相关服务
|
||||
*/
|
||||
export class FileSystemService extends ServiceBase {
|
||||
// ========== 状态 ==========
|
||||
private readonly _fileList = ref<FileItem[]>([])
|
||||
private readonly _fileLoading = ref<boolean>(false)
|
||||
private readonly _selectedFile = ref<FileItem | null>(null)
|
||||
|
||||
// ========== 子服务(依赖注入) ==========
|
||||
readonly preview: FilePreviewService
|
||||
readonly zip: ZipBrowserService
|
||||
readonly edit: FileEditService
|
||||
|
||||
// ========== 构造函数(保证初始化顺序) ==========
|
||||
constructor(
|
||||
private readonly fileApi: FileApiService,
|
||||
private readonly filePath: Ref<string>
|
||||
) {
|
||||
super('FileSystemService')
|
||||
|
||||
// 按顺序初始化子服务
|
||||
// 1. 预览服务(无依赖)
|
||||
this.preview = new FilePreviewService(this.fileApi, this.filePath)
|
||||
|
||||
// 2. 编辑服务(依赖预览服务)
|
||||
this.edit = new FileEditService(this.fileApi, this.preview)
|
||||
|
||||
// 3. ZIP服务(依赖预览服务)
|
||||
this.zip = new ZipBrowserService(
|
||||
this.fileApi,
|
||||
this.preview,
|
||||
this._fileList,
|
||||
this.filePath
|
||||
)
|
||||
|
||||
this.log('文件系统服务初始化完成')
|
||||
}
|
||||
|
||||
// ========== 公共接口 ==========
|
||||
|
||||
/** 文件列表(响应式) */
|
||||
get fileList(): Ref<FileItem[]> {
|
||||
return this._fileList
|
||||
}
|
||||
|
||||
/** 加载状态(响应式) */
|
||||
get fileLoading(): Ref<boolean> {
|
||||
return this._fileLoading
|
||||
}
|
||||
|
||||
/** 选中文件(响应式) */
|
||||
get selectedFile(): Ref<FileItem | null> {
|
||||
return this._selectedFile
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载目录
|
||||
*/
|
||||
async loadDirectory(path: string): Promise<void> {
|
||||
this._fileLoading.value = true
|
||||
try {
|
||||
const files = await this.fileApi.listDirectory(path)
|
||||
this._fileList.value = files
|
||||
this.log('目录加载成功', path, files.length, '个文件')
|
||||
} catch (error) {
|
||||
this.error('目录加载失败', error as Error)
|
||||
Message.error('加载目录失败')
|
||||
} finally {
|
||||
this._fileLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览文件
|
||||
*/
|
||||
async previewFile(file: FileItem): Promise<void> {
|
||||
if (this.zip.isBrowsingZip.value) {
|
||||
await this.zip.previewZipFile(file.path)
|
||||
} else {
|
||||
await this.preview.previewFile(file.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Composable 适配器
|
||||
|
||||
```typescript
|
||||
// composables/useFileSystem.ts
|
||||
import { createSingleton } from '@/utils/singleton'
|
||||
import { FileSystemService } from '@/services/FileSystemService'
|
||||
|
||||
/**
|
||||
* 文件系统 Composable
|
||||
* 轻量级适配器,桥接 Vue 响应式系统和服务层
|
||||
*/
|
||||
export function useFileSystem(options: UseFileSystemOptions = {}) {
|
||||
// 创建或获取服务单例
|
||||
const service = createSingleton(() => {
|
||||
const fileApi = new FileApiService()
|
||||
const filePath = ref(options.initialPath || '')
|
||||
return new FileSystemService(fileApi, filePath)
|
||||
}, 'fileSystemService')
|
||||
|
||||
// 返回响应式接口
|
||||
return {
|
||||
// 状态(直接返回 ref)
|
||||
fileList: service.fileList,
|
||||
fileLoading: service.fileLoading,
|
||||
selectedFile: service.selectedFile,
|
||||
|
||||
// 方法(委托给服务)
|
||||
loadDirectory: (path: string) => service.loadDirectory(path),
|
||||
previewFile: (file: FileItem) => service.previewFile(file),
|
||||
|
||||
// 类型判断(委托给预览服务)
|
||||
isImageFile: (path: string) => service.preview.isImageFile(path),
|
||||
isVideoFile: (path: string) => service.preview.isVideoFile(path),
|
||||
isPdfFile: (path: string) => service.preview.isPdfFile(path),
|
||||
|
||||
// 服务实例(可选,用于高级用法)
|
||||
$service: service
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6. 在 Vue 中使用
|
||||
|
||||
```typescript
|
||||
// index.vue
|
||||
<script setup lang="ts">
|
||||
import { useFileSystem } from './composables/useFileSystem'
|
||||
|
||||
// 简单使用(和之前一样)
|
||||
const {
|
||||
fileList,
|
||||
fileLoading,
|
||||
loadDirectory,
|
||||
previewFile
|
||||
} = useFileSystem()
|
||||
|
||||
// 或者访问完整服务
|
||||
const { $service } = useFileSystem()
|
||||
|
||||
// 可以访问所有服务方法
|
||||
$service.preview.updatePreviewUrl(url)
|
||||
$service.zip.enterZipMode(path)
|
||||
|
||||
// Computed 配置
|
||||
const fileEditorPanelConfig = computed(() => ({
|
||||
// 使用服务方法,不会出现初始化问题
|
||||
isImageView: $service.preview.isImageFile(currentFileName),
|
||||
canPreviewFile: $service.preview.isEditableWithPreview(currentFileName),
|
||||
// ...
|
||||
}))
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 方案优势
|
||||
|
||||
### 1. 解决初始化顺序问题
|
||||
|
||||
```typescript
|
||||
// ❌ 之前:依赖手动保证顺序
|
||||
const config = computed(() => useHelper())
|
||||
const useHelper = () => { ... } // 太晚了
|
||||
|
||||
// ✅ 现在:构造函数保证顺序
|
||||
class Service {
|
||||
constructor(helper: HelperService) { // 必须先创建 helper
|
||||
this.helper = helper
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 依赖注入,避免循环依赖
|
||||
|
||||
```typescript
|
||||
class FileSystemService {
|
||||
constructor(
|
||||
private preview: FilePreviewService, // 先初始化
|
||||
private zip: ZipBrowserService // 可以依赖 preview
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 高内聚,状态和行为绑定
|
||||
|
||||
```typescript
|
||||
class FilePreviewService {
|
||||
private _previewUrl = ref('') // 状态
|
||||
|
||||
updatePreviewUrl(url: string) { // 行为
|
||||
this._previewUrl.value = url
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 类型安全,IDE 友好
|
||||
|
||||
```typescript
|
||||
const service = new FilePreviewService(...)
|
||||
service. // IDE 自动提示所有公共方法
|
||||
```
|
||||
|
||||
### 5. 易于测试
|
||||
|
||||
```typescript
|
||||
// Mock 依赖
|
||||
const mockApi = new MockFileApiService()
|
||||
const service = new FilePreviewService(mockApi, filePath)
|
||||
|
||||
// 测试方法
|
||||
expect(service.isImageFile('test.jpg')).toBe(true)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 实施步骤
|
||||
|
||||
### 阶段1:创建服务层(1周)
|
||||
|
||||
1. 创建核心基类 `ServiceBase`
|
||||
2. 实现 `FilePreviewService`
|
||||
3. 实现 `ZipBrowserService`
|
||||
4. 实现 `FileSystemService`
|
||||
|
||||
### 阶段2:创建适配器(3天)
|
||||
|
||||
1. 实现 `useFileSystem` composable
|
||||
2. 确保向后兼容
|
||||
|
||||
### 阶段3:迁移功能(2周)
|
||||
|
||||
1. 逐步迁移现有功能到服务层
|
||||
2. 保持老代码可用
|
||||
3. 充分测试
|
||||
|
||||
### 阶段4:清理优化(1周)
|
||||
|
||||
1. 移除旧的 composables
|
||||
2. 优化性能
|
||||
3. 完善文档
|
||||
|
||||
---
|
||||
|
||||
## 🎯 总结
|
||||
|
||||
### 当前问题的根本原因
|
||||
|
||||
Composition API + 函数式方案**无法从架构层面**保证初始化顺序,只能依赖开发者手动保证。
|
||||
|
||||
### OOP 方案的核心优势
|
||||
|
||||
**构造函数 + 依赖注入**可以从编译器和运行时两个层面保证初始化顺序。
|
||||
|
||||
### 建议
|
||||
|
||||
立即采用 OOP 服务层方案,彻底解决初始化顺序问题。
|
||||
|
||||
---
|
||||
|
||||
**生成时间**: 2026-01-31
|
||||
**预计工作量**: 3-4周
|
||||
**风险等级**: 中等(需要重构,但可以渐进式迁移)
|
||||
Reference in New Issue
Block a user