插件开发最佳实践
本文汇总了插件开发中必须遵循的关键实践,涵盖资源管理、性能优化、兼容性和代码质量。
资源清理
插件的 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() |
| MutationObserver | new 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 # 依赖安全检查