Files
u-desk/docs/02-架构设计/OOP架构/OOP服务层实施方案.md
T

16 KiB
Raw Blame History

OOP 服务层实施方案 - 彻底解决初始化问题

日期: 2026-01-31 问题: 第5次依然出现 "Cannot access before initialization" 错误 方案: 采用面向对象的服务层架构


🎯 核心问题

当前 Composition API 方案存在根本性缺陷:

// 问题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. 服务基类

// 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. 文件预览服务

// 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浏览服务

// 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. 文件系统服务(聚合服务)

// 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 适配器

// 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 中使用

// 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. 解决初始化顺序问题

// ❌ 之前:依赖手动保证顺序
const config = computed(() => useHelper())
const useHelper = () => { ... }  // 太晚了

// ✅ 现在:构造函数保证顺序
class Service {
  constructor(helper: HelperService) {  // 必须先创建 helper
    this.helper = helper
  }
}

2. 依赖注入,避免循环依赖

class FileSystemService {
  constructor(
    private preview: FilePreviewService,  // 先初始化
    private zip: ZipBrowserService        // 可以依赖 preview
  ) {}
}

3. 高内聚,状态和行为绑定

class FilePreviewService {
  private _previewUrl = ref('')  // 状态

  updatePreviewUrl(url: string) {  // 行为
    this._previewUrl.value = url
  }
}

4. 类型安全,IDE 友好

const service = new FilePreviewService(...)
service.  // IDE 自动提示所有公共方法

5. 易于测试

// 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周 风险等级: 中等(需要重构,但可以渐进式迁移)