649 lines
16 KiB
Markdown
649 lines
16 KiB
Markdown
# 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周
|
||
**风险等级**: 中等(需要重构,但可以渐进式迁移)
|