插件开发实战
在掌握了插件开发基础后,本文将深入探讨实际开发中的高级技巧和最佳实践。
视图开发
自定义侧边栏视图
创建可交互的侧边栏面板:
typescript
import { ItemView, WorkspaceLeaf } from 'obsidian';
const VIEW_TYPE = 'my-custom-view';
export class MyCustomView extends ItemView {
constructor(leaf: WorkspaceLeaf) {
super(leaf);
}
getViewType(): string {
return VIEW_TYPE;
}
getDisplayText(): string {
return 'My Custom View';
}
getIcon(): string {
return 'star';
}
async onOpen() {
const container = this.containerEl.children[1];
container.empty();
container.createEl('h4', { text: 'Custom View' });
// 添加交互内容
const button = container.createEl('button', { text: 'Click Me' });
button.addEventListener('click', () => {
new Notice('Button clicked!');
});
}
async onClose() {
// 清理资源
}
}注册视图
typescript
// 在插件中注册视图
export default class MyPlugin extends Plugin {
async onload() {
this.registerView(
VIEW_TYPE,
(leaf) => new MyCustomView(leaf)
);
// 添加侧边栏图标
this.addRibbonIcon('star', 'Open Custom View', () => {
this.activateView();
});
}
async activateView() {
const { workspace } = this.app;
let leaf: WorkspaceLeaf | null = null;
const leaves = workspace.getLeavesOfType(VIEW_TYPE);
if (leaves.length > 0) {
leaf = leaves[0];
} else {
leaf = workspace.getRightLeaf(false);
await leaf?.setViewState({ type: VIEW_TYPE, active: true });
}
if (leaf) {
workspace.revealLeaf(leaf);
}
}
}视图状态管理
typescript
interface ViewState {
searchTerm: string;
filter: string;
items: any[];
}
export class StatefulView extends ItemView {
private state: ViewState = {
searchTerm: '',
filter: 'all',
items: []
};
setState(state: Partial<ViewState>): void {
this.state = { ...this.state, ...state };
this.render();
}
getState(): ViewState {
return this.state;
}
private render() {
const container = this.containerEl.children[1];
container.empty();
// 根据状态渲染内容
const filtered = this.state.items.filter(item => {
if (this.state.filter !== 'all') {
return item.type === this.state.filter;
}
return true;
});
filtered.forEach(item => {
container.createDiv({ text: item.name });
});
}
}设置系统
设置数据结构
typescript
interface MyPluginSettings {
enableFeature: boolean;
apiKey: string;
refreshInterval: number;
theme: 'light' | 'dark' | 'system';
excludedFolders: string[];
}
const DEFAULT_SETTINGS: MyPluginSettings = {
enableFeature: true,
apiKey: '',
refreshInterval: 60,
theme: 'system',
excludedFolders: []
}
export default class MyPlugin extends Plugin {
settings: MyPluginSettings;
async onload() {
await this.loadSettings();
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
}设置面板
typescript
import { App, PluginSettingTab, Setting } from 'obsidian';
class MySettingTab extends PluginSettingTab {
plugin: MyPlugin;
constructor(app: App, plugin: MyPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
// 功能开关
new Setting(containerEl)
.setName('Enable feature')
.setDesc('Enable or disable the main feature')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enableFeature)
.onChange(async (value) => {
this.plugin.settings.enableFeature = value;
await this.plugin.saveSettings();
}));
// API Key 输入
new Setting(containerEl)
.setName('API Key')
.setDesc('Enter your API key')
.addText(text => text
.setPlaceholder('Enter your API key')
.setValue(this.plugin.settings.apiKey)
.onChange(async (value) => {
this.plugin.settings.apiKey = value;
await this.plugin.saveSettings();
}));
// 下拉选择
new Setting(containerEl)
.setName('Theme')
.setDesc('Select the theme')
.addDropdown(dropdown => dropdown
.addOption('light', 'Light')
.addOption('dark', 'Dark')
.addOption('system', 'System')
.setValue(this.plugin.settings.theme)
.onChange(async (value: 'light' | 'dark' | 'system') => {
this.plugin.settings.theme = value;
await this.plugin.saveSettings();
}));
// 滑块
new Setting(containerEl)
.setName('Refresh interval')
.setDesc('Refresh interval in seconds')
.addSlider(slider => slider
.setLimits(10, 300, 10)
.setValue(this.plugin.settings.refreshInterval)
.setDynamicTooltip()
.onChange(async (value) => {
this.plugin.settings.refreshInterval = value;
await this.plugin.saveSettings();
}));
// 多选(文件夹排除)
new Setting(containerEl)
.setName('Excluded folders')
.setDesc('Folders to exclude from processing')
.addTextArea(text => text
.setPlaceholder('folder1\nfolder2\nfolder3')
.setValue(this.plugin.settings.excludedFolders.join('\n'))
.onChange(async (value) => {
this.plugin.settings.excludedFolders = value.split('\n').filter(f => f.trim());
await this.plugin.saveSettings();
}));
}
}注册设置面板
typescript
export default class MyPlugin extends Plugin {
async onload() {
// ... 其他初始化代码
this.addSettingTab(new MySettingTab(this.app, this));
}
}模态框开发
简单模态框
typescript
import { App, Modal, Notice } from 'obsidian';
class SimpleModal extends Modal {
constructor(app: App) {
super(app);
}
onOpen() {
const { contentEl } = this;
contentEl.createEl('h2', { text: 'Simple Modal' });
contentEl.createEl('p', { text: 'This is a simple modal.' });
contentEl.createEl('button', { text: 'Close' })
.addEventListener('click', () => {
this.close();
});
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}
// 使用模态框
new SimpleModal(this.app).open();带输入的模态框
typescript
interface InputResult {
title: string;
content: string;
}
class InputModal extends Modal {
private result: InputResult = { title: '', content: '' };
private onSubmit: (result: InputResult) => void;
constructor(app: App, onSubmit: (result: InputResult) => void) {
super(app);
this.onSubmit = onSubmit;
}
onOpen() {
const { contentEl } = this;
contentEl.createEl('h2', { text: 'Create New Note' });
new Setting(contentEl)
.setName('Title')
.addText(text => text
.setPlaceholder('Enter title')
.onChange(value => {
this.result.title = value;
}));
new Setting(contentEl)
.setName('Content')
.addTextArea(text => text
.setPlaceholder('Enter content')
.onChange(value => {
this.result.content = value;
}));
new Setting(contentEl)
.addButton(btn => btn
.setButtonText('Cancel')
.onClick(() => {
this.close();
}))
.addButton(btn => btn
.setButtonText('Create')
.setCta()
.onClick(() => {
this.onSubmit(this.result);
this.close();
}));
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}
// 使用
new InputModal(this.app, async (result) => {
await this.app.vault.create(`${result.title}.md`, result.content);
}).open();建议框(Suggest Modal)
typescript
import { App, FuzzySuggestModal, TFile } from 'obsidian';
class FileSuggestModal extends FuzzySuggestModal<TFile> {
private files: TFile[];
private onChoose: (file: TFile) => void;
constructor(app: App, files: TFile[], onChoose: (file: TFile) => void) {
super(app);
this.files = files;
this.onChoose = onChoose;
}
getItems(): TFile[] {
return this.files;
}
getItemText(file: TFile): string {
return file.basename;
}
onChooseItem(file: TFile, evt: MouseEvent | KeyboardEvent): void {
this.onChoose(file);
}
}
// 使用
const markdownFiles = this.app.vault.getMarkdownFiles();
new FileSuggestModal(this.app, markdownFiles, (file) => {
console.log('Selected:', file.path);
}).open();数据存储
插件数据存储
typescript
// 存储简单数据
await this.saveData({ key: 'value' });
// 读取数据
const data = await this.loadData();
// 存储复杂数据
interface StoredData {
version: string;
cache: Record<string, any>;
lastSync: number;
}
const data: StoredData = await this.loadData() || {
version: '1.0.0',
cache: {},
lastSync: 0
};
// 更新部分数据
data.cache['key'] = 'value';
data.lastSync = Date.now();
await this.saveData(data);文件操作
typescript
// 创建文件
async function createNote(title: string, content: string) {
const path = `${title}.md`;
const exists = await this.app.vault.adapter.exists(path);
if (exists) {
new Notice('File already exists');
return null;
}
return await this.app.vault.create(path, content);
}
// 读取文件
async function readNote(path: string): Promise<string> {
const file = this.app.vault.getAbstractFileByPath(path);
if (file instanceof TFile) {
return await this.app.vault.read(file);
}
return '';
}
// 更新文件
async function updateNote(file: TFile, content: string) {
await this.app.vault.modify(file, content);
}
// 删除文件
async function deleteNote(file: TFile) {
await this.app.vault.delete(file);
}
// 监听文件变化
this.registerEvent(
this.app.vault.on('modify', (file) => {
if (file instanceof TFile) {
console.log('File modified:', file.path);
}
})
);缓存管理
typescript
class CacheManager {
private cache: Map<string, { data: any; expiry: number }>;
private defaultTTL = 60 * 60 * 1000; // 1 hour
constructor() {
this.cache = new Map();
}
set(key: string, data: any, ttl = this.defaultTTL) {
this.cache.set(key, {
data,
expiry: Date.now() + ttl
});
}
get<T>(key: string): T | null {
const item = this.cache.get(key);
if (!item) return null;
if (Date.now() > item.expiry) {
this.cache.delete(key);
return null;
}
return item.data as T;
}
has(key: string): boolean {
return this.get(key) !== null;
}
delete(key: string): boolean {
return this.cache.delete(key);
}
clear(): void {
this.cache.clear();
}
}API 集成
外部 API 调用
typescript
import { requestUrl, RequestUrlParam } from 'obsidian';
async function fetchAPI<T>(url: string, options?: Partial<RequestUrlParam>): Promise<T> {
try {
const response = await requestUrl({
url,
method: 'GET',
headers: {
'Content-Type': 'application/json',
...options?.headers
},
...options
});
return response.json as T;
} catch (error) {
console.error('API Error:', error);
throw new Error(`Failed to fetch: ${error.message}`);
}
}
// 使用示例
interface User {
id: string;
name: string;
}
const user = await fetchAPI<User>('https://api.example.com/user/1');带认证的 API
typescript
class APIClient {
private baseUrl: string;
private apiKey: string;
constructor(baseUrl: string, apiKey: string) {
this.baseUrl = baseUrl;
this.apiKey = apiKey;
}
async request<T>(endpoint: string, options?: Partial<RequestUrlParam>): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
const response = await requestUrl({
url,
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
},
...options
});
if (response.status !== 200) {
throw new Error(`API Error: ${response.status}`);
}
return response.json as T;
}
async get<T>(endpoint: string): Promise<T> {
return this.request<T>(endpoint, { method: 'GET' });
}
async post<T>(endpoint: string, data: any): Promise<T> {
return this.request<T>(endpoint, {
method: 'POST',
body: JSON.stringify(data)
});
}
}速率限制
typescript
class RateLimiter {
private queue: Array<() => Promise<any>> = [];
private processing = false;
private minInterval: number;
constructor(minInterval: number) {
this.minInterval = minInterval;
}
async add<T>(fn: () => Promise<T>): Promise<T> {
return new Promise((resolve, reject) => {
this.queue.push(async () => {
try {
const result = await fn();
resolve(result);
} catch (error) {
reject(error);
}
});
this.process();
});
}
private async process() {
if (this.processing || this.queue.length === 0) return;
this.processing = true;
while (this.queue.length > 0) {
const fn = this.queue.shift();
if (fn) {
await fn();
await new Promise(resolve => setTimeout(resolve, this.minInterval));
}
}
this.processing = false;
}
}
// 使用
const limiter = new RateLimiter(1000); // 1秒间隔
const result = await limiter.add(() => fetchAPI('/data'));命令系统进阶
条件命令
typescript
this.addCommand({
id: 'format-active-note',
name: 'Format current note',
checkCallback: (checking: boolean) => {
const file = this.app.workspace.getActiveFile();
// 如果没有活动文件,命令不可用
if (!file) return false;
// 如果是 Markdown 文件才可用
if (file.extension !== 'md') return false;
// checking 模式只返回是否可用
if (checking) return true;
// 实际执行
this.formatNote(file);
return true;
}
});带参数的命令
typescript
// 使用模态框获取参数
this.addCommand({
id: 'search-and-replace',
name: 'Search and replace',
callback: () => {
new SearchReplaceModal(this.app, (search, replace) => {
const editor = this.app.workspace.activeEditor?.editor;
if (editor) {
const content = editor.getValue();
const newContent = content.replace(new RegExp(search, 'g'), replace);
editor.setValue(newContent);
}
}).open();
}
});性能优化
虚拟列表
对于大量数据的渲染:
typescript
class VirtualList {
private container: HTMLElement;
private items: any[];
private itemHeight: number;
private visibleStart = 0;
private visibleEnd = 0;
constructor(container: HTMLElement, items: any[], itemHeight: number) {
this.container = container;
this.items = items;
this.itemHeight = itemHeight;
this.container.style.height = `${items.length * itemHeight}px`;
this.container.style.overflow = 'auto';
this.updateVisible();
this.container.addEventListener('scroll', () => this.updateVisible());
}
private updateVisible() {
const scrollTop = this.container.scrollTop;
const viewportHeight = this.container.clientHeight;
const newStart = Math.floor(scrollTop / this.itemHeight);
const newEnd = Math.ceil((scrollTop + viewportHeight) / this.itemHeight);
if (newStart !== this.visibleStart || newEnd !== this.visibleEnd) {
this.visibleStart = newStart;
this.visibleEnd = newEnd;
this.render();
}
}
private render() {
const fragment = document.createDocumentFragment();
for (let i = this.visibleStart; i <= this.visibleEnd && i < this.items.length; i++) {
const item = this.items[i];
const el = document.createElement('div');
el.style.height = `${this.itemHeight}px`;
el.style.position = 'absolute';
el.style.top = `${i * this.itemHeight}px`;
el.textContent = item.name;
fragment.appendChild(el);
}
this.container.innerHTML = '';
this.container.appendChild(fragment);
}
}延迟加载
typescript
// 使用 Intersection Observer 延迟加载
function createLazyLoader(container: HTMLElement, loadFn: () => Promise<HTMLElement>) {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
loadFn().then(el => {
container.appendChild(el);
});
observer.disconnect();
}
});
});
observer.observe(container);
}测试
单元测试设置
typescript
// jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/__tests__/**/*.test.ts'],
moduleNameMapper: {
'obsidian': '<rootDir>/__mocks__/obsidian.ts'
}
};
// __mocks__/obsidian.ts
export class Plugin {
app: any;
settings: any;
async loadData() { return {}; }
async saveData() {}
addCommand() {}
registerView() {}
}
export class App {
vault = {
getMarkdownFiles: () => [],
read: async () => '',
create: async () => {}
};
workspace = {
getActiveFile: () => null
};
}测试示例
typescript
// __tests__/plugin.test.ts
import MyPlugin from '../src/main';
describe('MyPlugin', () => {
let plugin: MyPlugin;
beforeEach(() => {
plugin = new MyPlugin();
});
test('should load settings', async () => {
await plugin.loadSettings();
expect(plugin.settings).toBeDefined();
});
test('should save settings', async () => {
plugin.settings.apiKey = 'test-key';
await plugin.saveSettings();
expect(plugin.settings.apiKey).toBe('test-key');
});
});发布前检查清单
markdown
- [ ] 代码编译无错误
- [ ] TypeScript 类型完整
- [ ] 设置正确保存和加载
- [ ] 命令正确注册和执行
- [ ] 错误处理完善
- [ ] 资源正确清理
- [ ] manifest.json 信息完整
- [ ] README.md 文档清晰
- [ ] 版本号正确
- [ ] CHANGELOG 已更新
- [ ] 兼容性测试通过