Private
Public Access
1
0
Files
u-desk/docs/02-架构设计/OOP架构/OOP服务层实施方案.md

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