React 插件开发指南
Obsidian 插件默认使用原生 DOM API,但通过 React 可以获得组件化开发体验、状态管理和丰富的生态。本文介绍如何使用 React 开发 Obsidian 插件。
为什么在 Obsidian 中使用 React
| 特性 | 原生 DOM | React |
|---|---|---|
| 组件化 | 手动管理 | ✅ 天然支持 |
| 状态管理 | 手动同步 | ✅ hooks |
| JSX 模板 | 字符串拼接 | ✅ 类型安全 |
| 生态复用 | 有限 | ✅ npm 生态 |
| 学习曲线 | 低 | 中 |
| 包体积 | 小 | 较大 |
| 性能 | 直接操作 DOM | 虚拟 DOM 开销 |
项目搭建
1. 基于 sample-plugin 创建项目
bash
# 克隆官方示例插件
git clone https://github.com/obsidianmd/obsidian-sample-plugin.git my-react-plugin
cd my-react-plugin
npm install2. 安装 React 依赖
bash
npm install react react-dom
npm install -D @types/react @types/react-dom3. 配置 esbuild 支持 JSX
修改 esbuild.config.mjs:
javascript
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
const prod = (process.argv[2] === "production");
esbuild.build({
entryPoints: ["main.ts"],
bundle: true,
external: [
"obsidian",
"electron",
"@codemirror/autocomplete",
"@codemirror/collab",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/lint",
"@codemirror/search",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
// React 不标记为 external,需要打包
],
format: "cjs",
target: "es2018",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "main.js",
jsx: "automatic", // 启用 JSX 自动运行时
jsxImportSource: "react", // 使用 react/jsx-runtime
}).catch(() => process.exit(1));4. 配置 tsconfig.json
json
{
"compilerOptions": {
"jsx": "react-jsx",
"baseUrl": ".",
"module": "ESNext",
"target": "ESNext",
"moduleResolution": "node",
"esModuleInterop": true,
"strict": true,
"lib": ["DOM", "ESNext"],
"types": ["react", "react-dom"]
},
"include": ["**/*.ts", "**/*.tsx"]
}核心:React 视图容器
Obsidian 的 ItemView 需要挂载 React 组件。创建一个通用的 React 视图容器:
ReactView.tsx
tsx
import { ItemView, WorkspaceLeaf } from "obsidian";
import * as React from "react";
import * as ReactDOM from "react-dom/client";
import { AppContext } from "./context";
export const VIEW_TYPE_REACT = "react-view";
export class ReactView extends ItemView {
root: ReactDOM.Root | null = null;
constructor(leaf: WorkspaceLeaf) {
super(leaf);
}
getViewType() {
return VIEW_TYPE_REACT;
}
getDisplayText() {
return "React View";
}
getIcon() {
return "layout-dashboard";
}
async onOpen() {
// 创建挂载点
const mountPoint = this.containerEl.children[1];
mountPoint.empty();
mountPoint.addClass("react-view-container");
// 挂载 React 应用
this.root = ReactDOM.createRoot(mountPoint);
this.root.render(
<React.StrictMode>
<AppContext.Provider value={this.app}>
<MainApp />
</AppContext.Provider>
</React.StrictMode>
);
}
async onClose() {
// 卸载 React 应用
if (this.root) {
this.root.unmount();
this.root = null;
}
}
}AppContext — 注入 Obsidian App
tsx
import { createContext } from "react";
import type { App } from "obsidian";
export const AppContext = createContext<App | null>(null);
// 自定义 hook 方便获取 App
export function useApp(): App {
const app = React.useContext(AppContext);
if (!app) {
throw new Error("useApp must be used within AppContext.Provider");
}
return app;
}MainApp.tsx — 主组件
tsx
import * as React from "react";
import { useApp } from "./context";
export function MainApp() {
const app = useApp();
const [notes, setNotes] = React.useState<string[]>([]);
const [search, setSearch] = React.useState("");
// 加载笔记列表
React.useEffect(() => {
const loadNotes = async () => {
const files = app.vault.getMarkdownFiles();
setNotes(files.map(f => f.name));
};
loadNotes();
}, [app]);
const filtered = notes.filter(n =>
n.toLowerCase().includes(search.toLowerCase())
);
return (
<div className="react-app">
<header className="react-app-header">
<h1>📝 笔记管理器</h1>
<input
type="text"
placeholder="搜索笔记..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="react-search-input"
/>
</header>
<main className="react-app-main">
{filtered.length === 0 ? (
<p className="react-empty">没有匹配的笔记</p>
) : (
<ul className="react-note-list">
{filtered.map((note, i) => (
<li
key={i}
className="react-note-item"
onClick={() => app.workspace.openLinkText(note.replace(/\.md$/, ""), "")}
>
{note}
</li>
))}
</ul>
)}
</main>
</div>
);
}状态管理
使用 React Context + useReducer
tsx
// store.tsx
import * as React from "react";
interface NoteItem {
path: string;
name: string;
content: string;
tags: string[];
}
interface AppState {
notes: NoteItem[];
selectedNote: string | null;
loading: boolean;
error: string | null;
}
type Action =
| { type: "SET_NOTES"; notes: NoteItem[] }
| { type: "SELECT_NOTE"; path: string }
| { type: "SET_LOADING"; loading: boolean }
| { type: "SET_ERROR"; error: string | null };
function reducer(state: AppState, action: Action): AppState {
switch (action.type) {
case "SET_NOTES":
return { ...state, notes: action.notes, loading: false };
case "SELECT_NOTE":
return { ...state, selectedNote: action.path };
case "SET_LOADING":
return { ...state, loading: action.loading };
case "SET_ERROR":
return { ...state, error: action.error, loading: false };
default:
return state;
}
}
const initialState: AppState = {
notes: [],
selectedNote: null,
loading: true,
error: null,
};
export const StoreContext = React.createContext<{
state: AppState;
dispatch: React.Dispatch<Action>;
} | null>(null);
export function StoreProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = React.useReducer(reducer, initialState);
return (
<StoreContext.Provider value={{ state, dispatch }}>
{children}
</StoreContext.Provider>
);
}
export function useStore() {
const ctx = React.useContext(StoreContext);
if (!ctx) throw new Error("useStore must be used within StoreProvider");
return ctx;
}设置面板
使用 React 构建插件设置面板:
tsx
// settings.tsx
import * as React from "react";
import { App, PluginSettingTab } from "obsidian";
import { useApp } from "./context";
interface Settings {
apiKey: string;
maxResults: number;
autoSync: boolean;
theme: "light" | "dark" | "auto";
}
export class ReactSettingsTab extends PluginSettingTab {
root: ReactDOM.Root | null = null;
constructor(app: App, plugin: any, settings: Settings, saveSettings: () => void) {
super(app, plugin);
this.settings = settings;
this.saveSettings = saveSettings;
}
display(): void {
this.containerEl.empty();
this.root = ReactDOM.createRoot(this.containerEl);
this.root.render(
<SettingsComponent
settings={this.settings}
onSave={this.saveSettings}
/>
);
}
hide(): void {
if (this.root) {
this.root.unmount();
this.root = null;
}
}
}
function SettingsComponent({ settings, onSave }: { settings: Settings; onSave: () => void }) {
const [localSettings, setLocalSettings] = React.useState(settings);
const update = (key: keyof Settings, value: any) => {
setLocalSettings({ ...localSettings, [key]: value });
};
return (
<div className="react-settings">
<h2>插件设置</h2>
<div className="setting-item">
<label>API Key</label>
<input
type="password"
value={localSettings.apiKey}
onChange={(e) => update("apiKey", e.target.value)}
/>
</div>
<div className="setting-item">
<label>最大结果数</label>
<input
type="number"
value={localSettings.maxResults}
onChange={(e) => update("maxResults", parseInt(e.target.value))}
min={1}
max={100}
/>
</div>
<div className="setting-item">
<label>自动同步</label>
<input
type="checkbox"
checked={localSettings.autoSync}
onChange={(e) => update("autoSync", e.target.checked)}
/>
</div>
<div className="setting-item">
<label>主题</label>
<select
value={localSettings.theme}
onChange={(e) => update("theme", e.target.value)}
>
<option value="light">明亮</option>
<option value="dark">暗色</option>
<option value="auto">跟随系统</option>
</select>
</div>
<button onClick={onSave} className="mod-cta">
保存设置
</button>
</div>
);
}样式适配
CSS 变量复用
React 组件可以直接使用 Obsidian 的 CSS 变量,保持与主题一致:
css
/* styles.css */
.react-app {
padding: 16px;
color: var(--text-normal);
background: var(--background-primary);
}
.react-app-header h1 {
font-size: var(--font-ui-large);
color: var(--text-title);
margin-bottom: 12px;
}
.react-search-input {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-m);
background: var(--background-secondary);
color: var(--text-normal);
font-size: var(--font-ui-medium);
}
.react-search-input:focus {
border-color: var(--interactive-accent);
outline: none;
}
.react-note-list {
list-style: none;
padding: 0;
}
.react-note-item {
padding: 8px 12px;
border-radius: var(--radius-s);
cursor: pointer;
transition: background 0.15s;
}
.react-note-item:hover {
background: var(--background-modifier-hover);
}模态框
使用 React 实现自定义模态框:
tsx
// modal.tsx
import { Modal, App } from "obsidian";
import * as React from "react";
import * as ReactDOM from "react-dom/client";
export class ConfirmModal extends Modal {
root: ReactDOM.Root | null = null;
onConfirm: () => void;
message: string;
constructor(app: App, message: string, onConfirm: () => void) {
super(app);
this.message = message;
this.onConfirm = onConfirm;
}
onOpen() {
this.root = ReactDOM.createRoot(this.contentEl);
this.root.render(
<ConfirmModalContent
message={this.message}
onConfirm={() => {
this.onConfirm();
this.close();
}}
onCancel={() => this.close()}
/>
);
}
onClose() {
if (this.root) {
this.root.unmount();
}
}
}
function ConfirmModalContent({ message, onConfirm, onCancel }: {
message: string;
onConfirm: () => void;
onCancel: () => void;
}) {
return (
<div>
<p>{message}</p>
<div className="modal-button-container">
<button onClick={onCancel} className="mod-cta">
取消
</button>
<button onClick={onConfirm} className="mod-warning">
确认
</button>
</div>
</div>
);
}主入口文件
typescript
// main.ts
import { Plugin } from "obsidian";
import { ReactView, VIEW_TYPE_REACT } from "./ReactView";
import { ReactSettingsTab } from "./settings";
interface Settings {
apiKey: string;
maxResults: number;
autoSync: boolean;
theme: "light" | "dark" | "auto";
}
const DEFAULT_SETTINGS: Settings = {
apiKey: "",
maxResults: 50,
autoSync: false,
theme: "auto",
};
export default class MyReactPlugin extends Plugin {
settings: Settings = DEFAULT_SETTINGS;
async onload() {
// 注册视图
this.registerView(
VIEW_TYPE_REACT,
(leaf) => new ReactView(leaf)
);
// 添加命令
this.addCommand({
id: "open-react-view",
name: "打开 React 视图",
callback: () => {
app.workspace.getRightLeaf(false).setViewState({
type: VIEW_TYPE_REACT,
active: true,
});
},
});
// 加载设置
await this.loadSettings();
// 注册设置面板
this.addSettingTab(new ReactSettingsTab(
this.app,
this,
this.settings,
async () => { await this.saveSettings(); }
));
}
async onunload() {
// 清理视图
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
}构建与发布
package.json 配置
json
{
"name": "obsidian-react-plugin",
"version": "1.0.0",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "node esbuild.config.mjs production"
},
"dependencies": {
"react": "^18.3.0",
"react-dom": "^18.3.0"
},
"devDependencies": {
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"esbuild": "^0.21.0",
"typescript": "^5.5.0",
"obsidian": "^1.5.0"
}
}构建优化
bash
# 开发模式(带 sourcemap)
npm run dev
# 生产构建(压缩 + tree-shaking)
npm run build注意事项
React 使用注意事项
- 包体积:React + ReactDOM 约 40KB(gzip),确保插件不超过 Obsidian 限制
- 内存管理:视图关闭时必须
root.unmount()清理 - 事件监听:使用
useEffectcleanup 清理 Obsidian 事件监听 - 样式隔离:避免使用全局 CSS,使用 CSS Modules 或 BEM 命名
- Obsidian API 兼容:不要在 React 组件中直接操作 DOM,使用 Obsidian API