关于 raycast-extension
面向使用 TypeScript 和 `@raycast/api` 开发者的 Raycast 扩展指南,涵盖命令配置、界面模式、性能优化、测试与发布。
主要功能
- 01扩展项目结构与配置
- 02视图、无视图和菜单栏命令
- 03缓存与异步性能模式
- 04剪贴板和通知等 Raycast API
- 05开发、测试与发布流程
适用场景
- 01搭建新的 Raycast 扩展
- 02选择命令模式并实现界面
- 03减少阻塞操作并改善加载
- 04测试更改并提交扩展
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ johnlindquist/ claude --skill raycast-extension安装后在 Claude Code 中输入/raycast-extension确认已加载。
详细介绍
Raycast 扩展开发
快速开始
- 创建项目结构
- 编写包含扩展配置的 package.json
- 在 src/ 中实现命令
- 运行
npm install && npm run dev
项目结构
my-extension/
├── package.json # Extension manifest + dependencies
├── tsconfig.json # TypeScript config
├── .eslintrc.json # ESLint config
├── raycast-env.d.ts # Type definitions (auto-generated)
├── assets/
│ └── extension-icon.png # 512x512 PNG icon
└── src/
└── command-name.tsx # Command implementation
package.json 模板
{
"name": "extension-name",
"title": "Extension Title",
"description": "What this extension does",
"icon": "extension-icon.png",
"author": "author-name",
"categories": ["Productivity", "Developer Tools"],
"license": "MIT",
"commands": [
{
"name": "command-name",
"title": "Command Title",
"description": "What this command does",
"mode": "view",
"keywords": ["keyword1", "keyword2"]
}
],
"dependencies": {
"@raycast/api": "^1.83.1",
"@raycast/utils": "^1.17.0"
},
"devDependencies": {
"@raycast/eslint-config": "^1.0.11",
"@types/node": "22.5.4",
"@types/react": "18.3.3",
"eslint": "^8.57.0",
"prettier": "^3.3.3",
"typescript": "^5.5.4"
},
"scripts": {
"build": "ray build --skip-types -e dist -o dist",
"dev": "ray develop",
"fix-lint": "ray lint --fix",
"lint": "ray lint"
}
}
命令模式
| 模式 | 使用场景 |
|---|---|
view | 使用 Detail、List、Form、Grid 显示界面 |
no-view | 仅执行后台任务、剪贴板操作和通知 |
menu-bar | 带下拉菜单的菜单栏图标 |
快捷键配置
在 package.json 的命令中添加:
"hotkey": {
"modifiers": ["opt"],
"key": "m"
}
修饰键:cmd、opt、ctrl、shift
注意:package.json 中的快捷键只是建议。用户可以在 Raycast 偏好设置 → 扩展中设置快捷键。
tsconfig.json
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"allowJs": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"jsx": "react-jsx",
"lib": ["ES2022"],
"module": "ES2022",
"moduleResolution": "bundler",
"noEmit": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true,
"target": "ES2022"
},
"include": ["src/**/*", "raycast-env.d.ts"]
}
.eslintrc.json
{
"root": true,
"extends": ["@raycast"]
}
命令模式示例
无界面命令(后台任务)
import { showHUD, Clipboard, showToast, Toast } from "@raycast/api";
export default async function Command() {
const toast = await showToast({
style: Toast.Style.Animated,
title: "Working...",
});
try {
// Do work
const result = await doSomething();
await Clipboard.copy(result);
await showHUD("✅ Done!");
} catch (error) {
toast.style = Toast.Style.Failure;
toast.title = "Failed";
toast.message = error instanceof Error ? error.message : "Unknown error";
}
}
界面命令(列表)
import { List, ActionPanel, Action } from "@raycast/api";
export default function Command() {
return (
<List>
<List.Item
title="Item"
actions={
<ActionPanel>
<Action.CopyToClipboard content="text" />
</ActionPanel>
}
/>
</List>
);
}
界面命令(详情)
import { Detail } from "@raycast/api";
export default function Command() {
const markdown = `# Hello World`;
return <Detail markdown={markdown} />;
}
性能与缓存
即时加载模式(避免空白闪烁)
同步读取缓存 + 异步刷新,实现感知上的即时加载:
import { List, Cache } from "@raycast/api";
import { useCachedPromise, withCache } from "@raycast/utils";
const cache = new Cache();
const CACHE_KEY = "myData";
// Read cache synchronously at module load (before React renders)
function getInitialData(): MyData[] {
const cached = cache.get(CACHE_KEY);
if (cached) {
try {
return JSON.parse(cached);
} catch {
return [];
}
}
return [];
}
// Expensive async operation wrapped with withCache (5 min TTL)
const fetchExpensiveData = withCache(
async () => {
// Your expensive operation here
return await someSlowOperation();
},
{ maxAge: 5 * 60 * 1000 }
);
async function fetchAllData(): Promise<MyData[]> {
const data = await fetchExpensiveData();
// Update cache for next launch
cache.set(CACHE_KEY, JSON.stringify(data));
return data;
}
export default function Command() {
const { data, isLoading } = useCachedPromise(fetchAllData, [], {
initialData: getInitialData(), // Sync read - instant render!
keepPreviousData: true,
});
return (
<List isLoading={isLoading && !data?.length}>
{data?.map(item => <List.Item key={item.id} title={item.name} />)}
</List>
);
}
关键缓存工具
| 工具 | 用途 |
|---|---|
Cache | 持久化磁盘缓存,同步读写 |
withCache(fn, {maxAge}) | 为异步函数封装 TTL 缓存 |
useCachedPromise | 过期后重新验证模式 |
LocalStorage | 异步键值存储 |
避免 CLS(内容布局偏移)
在一个异步函数中加载所有数据:
// BAD - causes layout shift
const [customData, setCustomData] = useState([]);
useEffect(() => {
loadCustomData().then(setCustomData); // Second render!
}, []);
// GOOD - single fetch, no shift
async function fetchAllData() {
const [dataA, dataB] = await Promise.all([
fetchDataA(),
fetchDataB(),
]);
return combineData(dataA, dataB);
}
非阻塞操作(防止界面冻结)
“微小延迟”的根本原因:重新验证期间,同步操作(execSync、statSync、readdirSync)会阻塞事件循环,即使已显示缓存数据,也会导致界面冻结。
// BAD - blocks event loop, UI freezes during revalidation
import { execSync } from "child_process";
import { statSync, readdirSync, copyFileSync } from "fs";
function fetchData() {
copyFileSync(src, dest); // Blocks!
const result = execSync("sqlite3 query"); // Blocks!
const entries = readdirSync(dir); // Blocks!
for (const entry of entries) {
statSync(join(dir, entry)); // Blocks N times!
}
}
// GOOD - fully async, UI renders cached data while refreshing
import { exec } from "child_process";
import { promisify } from "util";
import { stat, readdir, copyFile, access } from "fs/promises";
const execAsync = promisify(exec);
async function fetchData() {
await copyFile(src, dest); // Non-blocking
const { stdout } = await execAsync("sqlite3..."); // Non-blocking
// Use withFileTypes to avoid extra stat calls
const entries = await readdir(dir, { withFileTypes: true });
const results = entries
.filter(e => e.isDirectory()) // No stat needed!
.map(e => ({ path: join(dir, e.name), name: e.name }));
}
关键优化:
- 用
promisify(exec)替换exec来执行 Shell 命令 - 用
fs/promises中的access()替换existsSync - 用
readdir(dir, { withFileTypes: true })替换readdirSync+statSync循环 - 使用
Promise.all并行运行所有路径验证 - 使用 SQLite URI 模式直接只读访问(无需复制文件)
SQLite 直接访问(跳过文件复制)
读取其他应用(如 Zed、VS Code 等)的 SQLite 数据库时,避免复制数据库文件。使用 URI 模式直接只读访问:
// BAD - copies entire database file (slow, blocks)
import { copyFileSync, unlinkSync } from "fs";
const tempDb = `/tmp/copy-${Date.now()}.sqlite`;
copyFileSync(originalDb, tempDb); // Expensive!
execSync(`sqlite3 "${tempDb}" "SELECT..."`);
unlinkSync(tempDb); // Cleanup
// GOOD - direct read-only access via URI mode
const uri = `file:${originalDb}?mode=ro&immutable=1`;
const { stdout } = await execAsync(`sqlite3 "${uri}" "SELECT..."`);
URI 参数:
mode=ro- 只读模式,不获取写锁immutable=1- 跳过 WAL/锁检查,将文件视为不可变
这样可以完全省去文件复制,大幅节省 I/O 时间。
execFile 与 exec(绕过 Shell)
exec 会启动一个 Shell(开销约 20ms),而 execFile 会直接调用二进制文件(开销约 4ms):
// BAD - spawns shell, parses command string
import { exec } from "child_process";
const execAsync = promisify(exec);
await execAsync(`sqlite3 -separator '|||' "${db}" "${query}"`);
// GOOD - direct binary execution, ~16ms faster
import { execFile } from "child_process";
const execFileAsync = promisify(execFile);
await execFileAsync("sqlite3", ["-separator", "|||", db, query]);
Sidecar 模式(真正的后台预加载)
如需实现真正即时的冷启动,请使用后台工作进程,在用户打开扩展前预热缓存。
问题:view 命令无法使用 interval(后台调度)。只有 no-view 和 menu-bar 模式支持该功能。
**解决方案:**创建两个共享同一缓存的命令:
// package.json
{
"commands": [
{
"name": "main",
"title": "My Extension",
"mode": "view"
},
{
"name": "background-sync",
"title": "Background Sync",
"mode": "no-view",
"interval": "15m"
}
]
}
// shared-cache.ts - both commands import this
import { Cache } from "@raycast/api";
export const sharedCache = new Cache(); // Shared across extension
// background-sync.tsx (no-view worker)
import { sharedCache } from "./shared-cache";
export default async function Command() {
const data = await fetchExpensiveData();
sharedCache.set("projects", JSON.stringify(data));
}
// main.tsx (view command)
import { sharedCache } from "./shared-cache";
function getInitialData() {
const cached = sharedCache.get("projects");
return cached ? JSON.parse(cached) : [];
}
export default function Command() {
const { data } = useCachedPromise(fetchData, [], {
initialData: getInitialData(), // Instant from pre-warmed cache!
});
}
要点:
- 工作进程按间隔静默运行,用户不会看到它
- 两个命令共享同一个
Cache(作用域为扩展,而非命令) - 界面命令从预热的缓存中同步读取
- 使用
15m到1h的间隔,避免电池消耗或速率限制问题
大型数据集:使用 useSQL,而非 JSON 缓存
对于超过 1,000 个条目的数据集,使用 SQLite 而非 JSON 缓存,以实现即时筛选:
// BAD - loads entire 10MB JSON into memory to filter
const allProjects = JSON.parse(cache.get("projects"));
const filtered = allProjects.filter(p => p.name.includes(query));
// GOOD - SQLite queries only matching rows
import { useSQL } from "@raycast/utils";
const { data } = useSQL(dbPath, `SELECT * FROM projects WHERE name LIKE ?`, [`%${query}%`]);
乐观 UI(即时操作)
对于写入操作,在 API 确认前立即更新界面:
const { mutate } = useCachedPromise(fetchItems);
async function deleteItem(id: string) {
await mutate(deleteItemAPI(id), {
optimisticUpdate: (current) => current.filter(i => i.id !== id),
rollbackOnError: true, // Revert if API fails
});
}
用户可以即时看到反馈;如果失败,则会自动回滚。
// BAD - sequential stat calls
const entries = readdirSync(dir);
for (const entry of entries) {
const s = statSync(join(dir, entry)); // N blocking calls
}
// GOOD - parallel async checks
const checkPath = async (p: string) => {
try {
const s = await stat(p);
return s.isDirectory() ? p : null;
} catch { return null; }
};
const results = await Promise.all(paths.map(checkPath));
常用 API
剪贴板
import { Clipboard } from "@raycast/api";
await Clipboard.copy("text");
await Clipboard.paste("text");
const text = await Clipboard.readText();
通知
import { showHUD, showToast, Toast } from "@raycast/api";
// Quick notification (disappears)
await showHUD("Done!");
// Toast with progress
const toast = await showToast({
style: Toast.Style.Animated,
title: "Loading...",
});
toast.style = Toast.Style.Success;
toast.title = "Complete";
AppleScript(macOS 集成)
import { runAppleScript } from "@raycast/utils";
// Get Chrome active tab URL
const url = await runAppleScript(`
tell application "Google Chrome"
return URL of active tab of front window
end tell
`);
// Get Safari URL
const safariUrl = await runAppleScript(`
tell application "Safari"
return URL of current tab of front window
end tell
`);
// Get frontmost app
const app = await runAppleScript(`
tell application "System Events"
return name of first application process whose frontmost is true
end tell
`);
获取数据
// Native fetch works
const response = await fetch("https://api.example.com/data");
const data = await response.json();
偏好设置
在 package.json 中:
"preferences": [
{
"name": "apiKey",
"type": "password",
"required": true,
"title": "API Key",
"description": "Your API key"
}
]
在代码中:
import { getPreferenceValues } from "@raycast/api";
interface Preferences {
apiKey: string;
}
const { apiKey } = getPreferenceValues<Preferences>();
创建扩展图标
使用 ImageMagick:
convert -size 512x512 xc:'#6366F1' -fill white -gravity center \
-font Helvetica-Bold -pointsize 280 -annotate +0+20 'M' \
assets/extension-icon.png
开发工作流
# Install dependencies
npm install
# Start dev server (hot reload)
npm run dev
# Lint and fix
npm run fix-lint
# Build for production
npm run build
Raycast Deeplinks
通过 URL 方案以编程方式触发 Raycast 命令:
# Reload all extensions
open "raycast://extensions/raycast/raycast/reload-extensions"
# Open Raycast
open "raycast://focus"
# Run any extension command
open "raycast://extensions/{author}/{extension}/{command}"
构建后自动重新加载
在 package.json 中添加脚本:
"build": "ray build --skip-types -e dist -o dist && open raycast://extensions/raycast/raycast/reload-extensions"
或者创建重新加载脚本:
#!/bin/bash
npm run build && open "raycast://extensions/raycast/raycast/reload-extensions"
在 Raycast 中测试
- 运行
npm run dev(提供热重载) - 打开 Raycast
- 搜索你的命令名称
- 按 Enter 运行
如果开发服务器未运行,请使用 deeplink 在更改后重新加载:
npm run build && open "raycast://extensions/raycast/raycast/reload-extensions"
发布
npm run publish
提交到 Raycast Store 以供审核。