Appearance
React + Vite 插件开发教程
针对有 UI 的正式插件,推荐使用 React 19 + Vite 7 + TypeScript + Tailwind 的工程化开发方式,并配合启动器内置组件库 @qomicex/plugin-ui。相比纯 HTML 手写,你能获得组件复用、类型检查、HMR 与按需打包等能力。
推荐用 qomicex create 脚手架生成工程(自动配好全部配置),本教程讲解脚手架产物与各配置的作用,方便按需调整。
一、为什么用 Vite
- 类型安全:TS 严格模式,构建前
tsc --noEmit拦截错误 - 组件复用:直接用启动器同款
@qomicex/plugin-ui组件(Card / Button / Dialog / Select 等),样式自动跟随主题 - 产物纯净:仅打包用到的组件,CSS 按需生成(Tree-shaking + Tailwind JIT)
二、用 CLI 生成工程(推荐)
bash
qomicex create com.example.myplugin
cd com.example.myplugin
pnpm install生成的项目结构(Vite + React 19 + TS + Tailwind + @qomicex/plugin-ui,manifest.json/package.json 已替换 id):
my-plugin/
├── manifest.json # 插件清单(发布时打进 .qplugin 根目录)
├── index.html # Vite 入口
├── vite.config.ts # 已设 base: './'
├── tailwind.config.js # 已引用 @qomicex/plugin-ui/tailwind-preset
├── postcss.config.js
├── tsconfig.json
├── package.json # scripts: dev/build/package
└── src/
├── main.tsx # React 入口,挂载 #root
├── api.ts # window.__PLUGIN_API__ 的类型化封装
├── App.tsx # 插件页面
└── index.css # Tailwind 指令TIP
脚手架模板来自 qomicex create 内置模板(packages/qomicex-cli/templates/)。不满足需求时可在此基础上增删。
不用 CLI 的手动初始化(替代方案)
bash
mkdir my-plugin && cd my-plugin
npm init -y
# 依赖
npm i react react-dom @qomicex/plugin-ui@file:../../packages/plugin-ui
# 开发依赖
npm i -D typescript vite @vitejs/plugin-react @types/react @types/react-dom tailwindcss autoprefixer postcss依赖安装
@qomicex/plugin-ui 是启动器仓库内的 workspace 包,用 file:../../packages/plugin-ui 引用即可,无需发布到 npm。
package.json
json
{
"name": "my-plugin",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"package": "bash scripts/build.sh"
}
}vite.config.ts — 关键:相对 base
ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
// 必须为 './':否则产物中的 /assets/xxx 会被解析成站点根路径,沙箱内 404
base: './',
build: { outDir: 'dist' },
})tailwind.config.js — 引用 preset
js
import preset from '@qomicex/plugin-ui/tailwind-preset'
/** @type {import('tailwindcss').Config} */
export default {
content: [
'./index.html',
'./src/**/*.{ts,tsx}',
// 关键:必须扫描 plugin-ui 源码,否则其内部类(bg-card 等)不会生成
'../../packages/plugin-ui/src/**/*.{ts,tsx}',
],
presets: [preset],
darkMode: 'class',
}无样式的最常见原因
Tailwind 的 content 默认只扫 ./src。而 @qomicex/plugin-ui 组件的样式类(bg-card、bg-muted、text-foreground)写在组件源码里,不在你的 src 下。不把 ../../packages/plugin-ui/src/**/*.{ts,tsx} 加进 content,构建出的 CSS 会缺少这些类,插件页面没有样式(产物 CSS 只有几 KB 是典型症状)。
postcss.config.js
js
export default {
plugins: { tailwindcss: {}, autoprefixer: {} },
}tsconfig.json
json
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}src/index.css
css
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body,
#root {
height: 100%;
}三、插件 API 的类型化封装
插件脚本通过 window.__PLUGIN_API__ 与启动器交互。建议封装为类型化模块,并处理独立 npm run dev(浏览器直开)时无 API 桥的情况:
src/api.ts:
ts
/// <reference types="vite/client" />
declare global {
interface Window {
__PLUGIN_API__?: PluginApi
__PLUGIN_ID__?: string
}
}
export interface PluginApi {
call: (method: string, ...args: unknown[]) => Promise<unknown>
registerMethod: (method: string, fn: (...args: unknown[]) => unknown) => void
callPlugin: (pluginId: string, method: string, ...args: unknown[]) => Promise<unknown>
proxyFetchStream: (req: unknown, handlers: { onChunk: (c: string) => void; onError: (e: Error) => void }) => Promise<void>
}
/** 沙箱注入的插件 API 桥;独立 dev(浏览器直开)时返回 null */
export function getApi(): PluginApi | null {
return window.__PLUGIN_API__ ?? null
}
export function getPluginId(): string {
return window.__PLUGIN_ID__ ?? 'unknown'
}四、编写插件页面
src/main.tsx:
tsx
import React from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
import './index.css'
const rootEl = document.getElementById('root')
if (rootEl) createRoot(rootEl).render(<React.StrictMode><App /></React.StrictMode>)导入扩展名
仓库 TS 约定必须写文件扩展名(./App.tsx、./api.ts)。Vite 的路径解析存在 bug,省略扩展名会报错。已在 tsconfig 开启 allowImportingTsExtensions。
src/App.tsx 中使用组件库:
tsx
import { getApi } from './api.ts'
import { Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Input, Label } from '@qomicex/plugin-ui'
export default function App() {
const api = getApi()
if (!api) {
return (
<Card>
<CardHeader>
<CardTitle>未检测到插件 API 桥</CardTitle>
<CardDescription>当前以普通浏览器直接打开页面,`window.__PLUGIN_API__` 未注入。</CardDescription>
</CardHeader>
</Card>
)
}
const handleToast = async () => {
await api.call('showToast', '来自 React 插件的问候', 'success')
}
return (
<div className="flex flex-col gap-4 p-4">
<Card>
<CardHeader>
<CardTitle>Hello, Qomicex!</CardTitle>
<CardDescription>基于 React + Vite + @qomicex/plugin-ui</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
<Button size="sm" onClick={handleToast}>showToast</Button>
<Badge>默认</Badge>
<Badge variant="destructive">危险</Badge>
</div>
</CardContent>
</Card>
</div>
)
}可用的组件
@qomicex/plugin-ui 导出:Button / Badge / Card(Header/Title/Description/Content/Footer) / Checkbox / Combobox / Dialog / Input / Label / Select(+SelectOption/SelectDivider) / Separator / Table / Tabs / Textarea / Tooltip / useMessageBox(+MessageBoxProvider) / cn。完整用法见 plugin-ui 组件库。
五、悬浮窗与主题文件
overlay.html 和 theme.css 不属于 Vite 构建产物,需在打包时手动拷贝到 dist/:
overlay.html→dist/overlay.html(manifestcontributes.overlay.file指向)theme.css→dist/theme.css(manifestentry.theme指向)
悬浮窗桥接时序(重要): 悬浮窗的 __PLUGIN_API__ 桥脚本由启动器追加在页面 <body> 末尾。因此不要在页面加载时同步捕获 window.__PLUGIN_API__(此时桥还没注入),应在事件回调里延迟读取:
html
<script>
(function () {
// 不要在这里写 var api = window.__PLUGIN_API__ —— 此时桥尚未注入,值为 undefined
function api() { return window.__PLUGIN_API__ }
document.getElementById('btn').addEventListener('click', function () {
api().call('showToast', '来自悬浮窗', 'success')
})
})()
</script>六、打包 .qplugin
用 CLI 打包(推荐)
bash
qomicex pack # tsc && vite build → release/<id>-<version>.qplugin
qomicex pack --key ./dev-key.pem # 附 Ed25519 签名(离线可验)
qomicex pack --version 0.2.0 # 覆盖版本号qomicex pack 自动处理:构建前端、拷贝根目录的 overlay.html / theme.css 到 dist/、组装 zip(manifest.json 在根 + dist/**)。entry.theme / contributes.overlay.file 若引用 dist/ 下文件但源码在根目录,会自动拷入。
不用 CLI 的手动打包(替代方案)
打包脚本(scripts/build.sh)
bash
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
VERSION="${1:-0.1.0}"
PLUGIN_ID="my-plugin"
echo "==> 构建前端"
npm run build
echo "==> 整理 dist"
cp "$ROOT/overlay.html" "$ROOT/dist/overlay.html"
cp "$ROOT/theme.css" "$ROOT/dist/theme.css"
echo "==> 组装 .qplugin"
STAGING="$ROOT/.staging"
rm -rf "$STAGING" "$ROOT/release"
mkdir -p "$STAGING/dist" "$ROOT/release"
# 关键:manifest 引用的是 dist/ 下相对路径,zip 内必须保留 dist/ 子目录
cp "$ROOT/dist/index.html" "$STAGING/dist/"
cp -r "$ROOT/dist/assets" "$STAGING/dist/assets"
cp "$ROOT/dist/overlay.html" "$STAGING/dist/"
cp "$ROOT/dist/theme.css" "$STAGING/dist/"
cp "$ROOT/manifest.json" "$STAGING/"
cd "$STAGING"
if command -v zip >/dev/null 2>&1; then
zip -qr "$ROOT/release/${PLUGIN_ID}-${VERSION}.qplugin" .
else
powershell -NoProfile -Command "Compress-Archive -Path '*' -DestinationPath '$ROOT/release/${PLUGIN_ID}-${VERSION}.qplugin' -Force"
fi
cd "$ROOT" && rm -rf "$STAGING"
echo "==> 完成: $ROOT/release/${PLUGIN_ID}-${VERSION}.qplugin"打包后的包结构
my-plugin.qplugin(即 zip)
└── manifest.json # 根目录
└── dist/
├── index.html # Vite 产物(引用 ./assets/... 相对路径)
├── overlay.html
├── theme.css
└── assets/
├── main-xxx.js
└── main-xxx.css包结构与 manifest 必须一致
后端按 plugins/{id}/{path} 解析文件,entry.frontend: "dist/index.html" 会去取 plugins/{id}/dist/index.html。dist/ 子目录必须保留在 zip 里——如果把 dist 内的文件提升到 zip 根目录,安装后 dist/index.html、dist/theme.css、dist/overlay.html 全部 404(页面空白、无样式、悬浮窗打不开)。
七、安装测试
qomicex pack(或手动bash scripts/build.sh 0.1.0,Windows:pwsh ./scripts/build.ps1 0.1.0)- 启动器 → 插件 → 上传
release/my-plugin-0.1.0.qplugin - 启用插件 → 重启启动器 → 侧边栏进入插件页
开发期迭代
上传安装后修改代码需重新打包再上传(后端按文件实时读盘,无缓存)。开发期更推荐用 qomicex dev 走调试 harness——浏览器热重载,无需启动 Tauri/后端(详见 调试与热重载)。
常见问题
| 现象 | 原因 |
|---|---|
| 插件页无样式(CSS 只有几 KB) | tailwind.config.js 的 content 未包含 ../../packages/plugin-ui/src/**/*.{ts,tsx} |
| 页面空白 / 资源 404 | vite.config.ts 的 base 不是 './',或 zip 内未保留 dist/ 目录 |
悬浮窗按钮报 Cannot read properties of undefined (reading 'call') | 同步捕获 window.__PLUGIN_API__ 太早,桥尚未注入;改为事件回调内延迟读取 |
独立 npm run dev 页面报错 | 浏览器直开无 __PLUGIN_API__ 桥;用 getApi() 判空降级 |
callBackend 请求 /api/api/... | callBackend 内部已拼接 /api 前缀,传 /diagnostics/health 而非 /api/diagnostics/health |
下一步
- 完整 API 参考:插件 API 完整参考
- 组件库:UI 组件库(.p-* 样式)
- 悬浮窗:悬浮窗开发
- CLI 命令参考:CLI 工具参考
- 调试与热重载:调试与热重载
- 发布规范:发布规范