Skip to content

React 插件开发指南

Obsidian 插件默认使用原生 DOM API,但通过 React 可以获得组件化开发体验、状态管理和丰富的生态。本文介绍如何使用 React 开发 Obsidian 插件。

为什么在 Obsidian 中使用 React

特性原生 DOMReact
组件化手动管理✅ 天然支持
状态管理手动同步✅ 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 install

2. 安装 React 依赖

bash
npm install react react-dom
npm install -D @types/react @types/react-dom

3. 配置 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 使用注意事项

  1. 包体积:React + ReactDOM 约 40KB(gzip),确保插件不超过 Obsidian 限制
  2. 内存管理:视图关闭时必须 root.unmount() 清理
  3. 事件监听:使用 useEffect cleanup 清理 Obsidian 事件监听
  4. 样式隔离:避免使用全局 CSS,使用 CSS Modules 或 BEM 命名
  5. Obsidian API 兼容:不要在 React 组件中直接操作 DOM,使用 Obsidian API

相关文档