Skip to content

插件开发最佳实践

本文汇总了插件开发中必须遵循的关键实践,涵盖资源管理、性能优化、兼容性和代码质量。

资源清理

插件的 onunload() 必须彻底清理所有注册的资源,否则会导致内存泄漏或功能残留。

会自动清理的资源

以下通过 this.registerXxx() 注册的资源会在插件卸载时自动清理

typescript
export default class MyPlugin extends Plugin {
  async onload() {
    // ✅ 自动清理
    this.registerView(VIEW_TYPE, (leaf) => new MyView(leaf));
    this.registerMarkdownCodeBlockProcessor('my-lang', handler);
    this.registerMarkdownPostProcessor(handler);
    this.registerEvent(this.app.vault.on('create', callback));
    this.registerDomEvent(document, 'click', handler);
    this.registerInterval(window.setInterval(fn, 1000));
    this.addCommand({ id: 'my-cmd', name: 'Command', callback });
    this.addSettingTab(new MySettingTab(this.app, this));
  }
}

必须手动清理的资源

typescript
export default class MyPlugin extends Plugin {
  private timer: NodeJS.Timeout | null = null;
  private observer: MutationObserver | null = null;

  async onload() {
    // 非注册方式创建的资源需要手动清理
    this.timer = setInterval(() => this.doWork(), 5000);

    this.observer = new MutationObserver(mutations => {
      // ...
    });

    // ❌ 下面的方式不会被自动清理!
    // 应该用 this.registerInterval 代替 setInterval
    // 应该用 this.registerDomEvent 代替 addEventListener
  }

  onunload() {
    // ✅ 必须手动清理
    if (this.timer) {
      clearInterval(this.timer);
      this.timer = null;
    }
    if (this.observer) {
      this.observer.disconnect();
      this.observer = null;
    }
  }
}

资源清理检查清单

资源类型注册方式自动清理手动清理
命令addCommand()-
视图registerView()-
设置面板addSettingTab()-
Ribbon 图标addRibbonIcon()-
状态栏addStatusBarItem()-
Markdown 处理器registerMarkdownPostProcessor()-
事件监听registerEvent() / registerDomEvent()-
定时器registerInterval()-
原生定时器setInterval() / setTimeout()clearInterval()
原生事件addEventListener()removeEventListener()
MutationObservernew MutationObserver().disconnect()
DOM 元素引用container.createDiv()设为 null
外部连接WebSocket / 数据库连接.close()

注意

onunload() 中清理资源时,先执行清理操作再设置为 null,避免并发问题。

性能优化

防抖与节流

高频事件(如文件变更、编辑器滚动)需要使用防抖或节流:

typescript
import { debounce } from 'obsidian';

export default class MyPlugin extends Plugin {
  async onload() {
    // ✅ 用 registerEvent 包装,自动清理
    this.registerEvent(
      this.app.vault.on('modify', debounce(
        (file) => this.handleFileChange(file),
        500,  // 500ms 防抖
        true  // 首次立即执行
      ))
    );
  }

  private handleFileChange(file: TAbstractFile) {
    // 文件变更后的处理逻辑
  }
}

防抖 vs 节流选择

场景推荐方式说明
搜索输入防抖 300ms等用户停止输入后再搜索
文件修改防抖 500ms批量处理连续写入
编辑器滚动节流 100ms动画帧级别的更新
窗口 resize节流 200ms布局更新
数据同步防抖 1000ms网络请求合并

自定义节流函数

typescript
function throttle<T extends (...args: any[]) => any>(
  fn: T,
  delay: number
): T {
  let lastCall = 0;
  return ((...args: any[]) => {
    const now = Date.now();
    if (now - lastCall >= delay) {
      lastCall = now;
      fn(...args);
    }
  }) as T;
}

// 使用
const throttledScroll = throttle(() => {
  this.updateScrollPosition();
}, 100);

虚拟列表

处理大量数据时使用虚拟列表,只渲染可视区域:

typescript
// 基本虚拟列表实现
class VirtualList {
  private container: HTMLElement;
  private itemHeight = 40;
  private items: any[] = [];
  private visibleRange = { start: 0, end: 0 };

  constructor(container: HTMLElement, items: any[]) {
    this.container = container;
    this.items = items;
    this.init();
  }

  private init() {
    // 设置容器高度为总高度
    this.container.style.position = 'relative';
    this.container.style.overflow = 'auto';

    const spacer = this.container.createDiv();
    spacer.style.height = `${this.items.length * this.itemHeight}px`;

    this.container.addEventListener('scroll', () => {
      this.render();
    });

    this.render();
  }

  private render() {
    const scrollTop = this.container.scrollTop;
    const containerHeight = this.container.clientHeight;

    const start = Math.floor(scrollTop / this.itemHeight);
    const end = Math.min(
      start + Math.ceil(containerHeight / this.itemHeight) + 1,
      this.items.length
    );

    // 仅渲染可见范围内的元素
    // ...
  }
}

DOM 操作优化

typescript
// ❌ 避免:直接操作 DOM 触发多次重排
items.forEach(item => {
  container.createDiv({ text: item.name });
});

// ✅ 推荐:使用 DocumentFragment 批量操作
const fragment = document.createDocumentFragment();
items.forEach(item => {
  const div = createDiv({ text: item.name });
  fragment.appendChild(div);
});
container.appendChild(fragment);

内存管理

缓存策略

实现 LRU 缓存避免无限增长:

typescript
class LRUCache<K, V> {
  private cache = new Map<K, V>();
  private maxSize: number;

  constructor(maxSize: number = 100) {
    this.maxSize = maxSize;
  }

  get(key: K): V | undefined {
    if (!this.cache.has(key)) return undefined;
    const value = this.cache.get(key)!;
    // 移动到末尾(最近使用)
    this.cache.delete(key);
    this.cache.set(key, value);
    return value;
  }

  set(key: K, value: V): void {
    if (this.cache.has(key)) {
      this.cache.delete(key);
    } else if (this.cache.size >= this.maxSize) {
      // 删除最旧的条目
      const firstKey = this.cache.keys().next().value;
      this.cache.delete(firstKey);
    }
    this.cache.set(key, value);
  }
}

避免常见内存泄漏

场景问题解决
闭包引用闭包持有大对象的引用onunload 中将变量设为 null
事件监听未注册的事件监听始终使用 registerEvent
DOM 引用已删除 DOM 元素的引用在视图关闭时清空引用
大型数据结构缓存无限增长使用 LRU 缓存或定期清理
typescript
onunload() {
  // 清空所有可能持有引用的属性
  this.settings = null;
  this.cache = null;
  this.views.clear();
  this.pendingOperations.clear();
}

兼容性处理

API 存在性检查

Obsidian 不同版本的 API 可能存在差异,使用前务必检查:

typescript
// ✅ 安全检查
if (this.app.vault.getConfig) {
  const config = this.app.vault.getConfig('someKey');
}

// ✅ 检查属性是否存在
if ('propertiesInDocument' in this.app.vault.getConfig) {
  // 使用新功能
}

// ✅ 可选 API
const adapter = this.app.vault.adapter;
if (adapter instanceof FileSystemAdapter) {
  const basePath = adapter.getBasePath();
  // 仅在桌面端可用
}

桌面端 vs 移动端

typescript
import { Platform } from 'obsidian';

export default class MyPlugin extends Plugin {
  async onload() {
    // 检查平台
    if (Platform.isMobile) {
      // 移动端特殊处理
      this.setupMobileFeatures();
    } else {
      // 桌面端功能
      this.setupDesktopFeatures();
    }

    // 具体平台判断
    if (Platform.isIosApp) {
      // iOS 特有处理
    } else if (Platform.isAndroidApp) {
      // Android 特有处理
    }
  }
}

版本兼容性

manifest.json 中设定最低版本:

json
{
  "minAppVersion": "0.15.0",
  "versions": {
    "0.15.0": "使用旧版 API",
    "1.0.0": "使用新版 API"
  }
}

代码中检查 Obsidian 版本:

typescript
// 通过 API 存在性判断版本
const hasNewAPI = typeof this.app.vault.getConfig === 'function';

if (hasNewAPI) {
  // 使用新版 API
} else {
  // 降级到兼容方案
}

异步操作处理

竞态条件

多个异步操作并发时,需要处理竞态条件:

typescript
export default class MyPlugin extends Plugin {
  private currentRequestId = 0;

  async fetchData(query: string): Promise<Data | null> {
    const requestId = ++this.currentRequestId;

    try {
      const response = await fetch(`https://api.example.com?q=${query}`);
      const data = await response.json();

      // ✅ 检查请求是否已过期
      if (requestId !== this.currentRequestId) {
        return null; // 后续请求已发起,丢弃此结果
      }

      return data;
    } catch (error) {
      console.error('Fetch failed:', error);
      return null;
    }
  }
}

错误处理模式

typescript
// 模式 1:用户通知 + 日志
async loadData() {
  try {
    const data = await this.loadSettings();
    return data;
  } catch (error) {
    new Notice('设置加载失败');
    console.error('Failed to load settings:', error);
  }
}

// 模式 2:静默降级
async fetchExternalData() {
  try {
    const response = await fetch(url);
    return await response.json();
  } catch {
    // 网络不可用,使用本地缓存
    return this.getCachedData();
  }
}

// 模式 3:重试机制
async fetchWithRetry(
  url: string,
  maxRetries = 3,
  delay = 1000
): Promise<Response> {
  for (let i = 0; i < maxRetries; i++) {
    try {
      return await fetch(url);
    } catch (error) {
      if (i === maxRetries - 1) throw error;
      await new Promise(resolve => setTimeout(resolve, delay * (i + 1)));
    }
  }
  throw new Error('Max retries exceeded');
}

代码质量

日志规范

typescript
// ✅ 使用结构化日志
class Logger {
  private prefix: string;

  constructor(prefix: string) {
    this.prefix = `[${prefix}]`;
  }

  debug(...args: any[]) {
    if (process.env.NODE_ENV === 'development') {
      console.debug(this.prefix, ...args);
    }
  }

  info(...args: any[]) {
    console.log(this.prefix, ...args);
  }

  warn(...args: any[]) {
    console.warn(this.prefix, ...args);
  }

  error(...args: any[]) {
    console.error(this.prefix, ...args);
  }
}

const logger = new Logger('my-plugin');
logger.info('Plugin loaded');

TypeScript 最佳实践

typescript
// ✅ 严格类型定义
interface MySettings {
  apiKey: string;
  enabled: boolean;
  syncInterval: number;
  folders: string[];
  templates: Record<string, string>;
}

// ✅ 类型守卫
function isMarkdownFile(file: TAbstractFile): file is TFile {
  return file instanceof TFile && file.extension === 'md';
}

// ✅ 非空断言前先检查
function getActiveFile(): TFile | null {
  const file = this.app.workspace.getActiveFile();
  return file ?? null;
}

// ❌ 避免 any
function process(data: any) { }  // 不好

// ✅ 使用具体类型
function process(data: ProcessInput): ProcessResult { }  // 好

安全检查清单

发布前验证

检查项说明
onunload 清理所有资源在卸载时正确清理
✅ 无内存泄漏大文件处理、缓存策略合理
✅ API 存在性检查使用的 API 都做了安全调用检查
✅ 桌面/移动端兼容isDesktopOnly 设置正确,移动端有降级方案
✅ 异步错误处理所有 async 函数有错误处理
✅ XSS 防护用户输入正确转义
✅ 依赖安全使用 npm audit 检查依赖漏洞
✅ 无 console.log 残留生产代码中移除了调试日志
✅ manifest.json 完整所有必要字段填写正确

发布流程

bash
# 发布前检查命令
npm run build        # 构建通过
npm test             # 测试通过
npm run lint         # 代码规范检查
npm audit            # 依赖安全检查

相关资源