# @client-compose/composer 中文文档

规范地址：https://npmcn.com/package/%40client-compose/composer/

版本：0\.15\.0
npm 元数据更新时间：2026-11-09T16:04:35.140Z
中文译文更新时间：2026-11-10T01:49:16.443Z
软件包许可：MIT

本文由上游 README 翻译，可能存在偏差，请结合原文核对 API、命令与配置。版权与许可归原作者所有。

原始 npm 页面：https://www.npmjs.com/package/%40client-compose/composer
原文对照：https://npmcn.com/package/%40client-compose/composer/?lang=original
项目主页：https://github.com/client-compose/client-compose/tree/main/composers/typescript/core#readme
源码仓库：https://github.com/client-compose/client-compose

## 关键信息（来自 npm 元数据）

- 包体积（解压后）：423 kB，83 个文件
- 模块格式：ES Module
- TypeScript 类型：包内自带
- Node.js 要求：\>=20
- 运行依赖：1 个
- 首次发布：2026-10-19T14:06:44.424Z
- 最新版本发布：2026-11-09T16:04:34.691Z
- 近 30 天下载量：2487（2026-10-10 至 2026-11-08，npm 官方下载统计）
- 已发布版本数：18（近 12 个月 18 个）

## 中文简介

框架-不可知客户端作曲:决定一屏岛屿 从后端服务中解析道具.

## 安装

```sh
npm install @client-compose/composer
```

也可以使用：`pnpm add @client-compose/composer`、`yarn add @client-compose/composer`、`bun add @client-compose/composer`

## README

# @ client- compose/ 压缩器

作曲家为 [客户端编译](https://github.com/client-compose/client-compose),在类型脚本中. 它决定了每个屏幕的构造 并解决了岛屿的道具 平行地从你的 服务。 您声明屏幕为数据; 它处理版本谈判、 超时和失败 隔离,并从 HTTP 堆栈中为它们服务.

等级 : **核心项目** 议定书: **页:1** 20+节点、Bun、Deno、Cloudflare工人(与 `fetch`是因为 `Request` 财务报告和审定财务报表 `Response`(中文(简体) )

```sh
npm install @client-compose/composer
```

## 使用量

```ts
import { action, createComposer, island, place, placeStatic, reportErrors, screen } from "@client-compose/composer";
import { FollowButtonV1, PostGridV1, ProfileHeaderV2, ReportLinkV1 } from "./islands.generated"; // from @client-compose/codegen

interface Ctx {
  viewer?: User;
}

const ProfileHeader = island(ProfileHeaderV2, async ({ params, load }) => {
  const user = await load(`user:${params.userId}`, (signal) => users.get(params.userId, { signal }));
  return { displayName: user.name, avatarUrl: user.avatar };
});

const profile = screen<Ctx>("profile", {
  requires: [signedIn],
  islands: [
    place("header", ProfileHeader, { required: true }),
    place("follow", FollowButton, { when: ({ ctx, params }) => ctx.viewer?.id !== params.userId }),
    place("posts", [PostGridV1Island, PostGridV2Island], { timeoutMs: 300 }),
    placeStatic("report", ReportLinkV1, { label: "Report this profile" }),
  ],
  cache: { maxAge: 30 },
});

export const composer = createComposer<Ctx>({
  screens: [profile],
  actions: [followUser],
  context: async (request) => ({ viewer: await sessions.viewer(request) }),
  use: [reportErrors((failure) => logger.error(failure, "composer failure"))],
});

export default { fetch: composer.fetch }; // Bun, Deno, Workers, Hono, Next.js route handlers
```

## 屏幕

一个屏幕列出它的岛屿. `place(key, versions, options)` 写一个:

-   **版本**:一个岛屿,或者每个版本都可以解决. 发送的应用程序支持最高; 如果没有的话, 小岛就被排除在外了.
-   **`required`**:屏幕没有它就失效了.
-   **`timeoutMs`**: 覆盖 `defaultTimeoutMs` (原始内容存档于2018-09-25). (2000).
-   **`when`**:决定每个请求是否出现,例如,在您自己的配置文件中没有后续按钮。 这个 可以加载数据(与岛屿共享载荷),所抛出的东西按布局处理: `NotFoundError` 请回答404.

`placeStatic(key, Descriptor, props)` 将一个其道具已知的岛屿放在前面:一种形式,一种 标题.

当屏幕的岛屿只能根据请求(从CMS或实验)决定时,给出一个 `layout` 函数代替 `islands`投弃 `NotFoundError` 当众生谈论它的时候 不存在( 批量加载器缺失的密钥数也存在); 屏幕响应 404 `not_found`。 。 。 .

`meta` 可选地描述屏幕整体(页标题,描述,共享图像). 它运行 与岛屿并肩作战 `load`s;如果失败,该响应只是没有元:

```ts
meta: async ({ params, load }) => {
  const article = await load(`article:${params.articleId}`, () => articles.get(params.articleId));
  return { title: article.title, description: article.summary };
},
```

## 二. 背景和要求

`context` 每个请求运行一次,先于其他任何, 并返回每个要求,布局, 岛屿和行动 `ctx`:取景者,其地取地,为地租. 它给了你的服务器 请求: 获取 `Request` 默认情况下,或无论适配器通过什么(快递或快递请求), 节点: http `IncomingMessage`) (中文(简体) ). 说用作曲家的第二类型参数: `createComposer<Ctx, express.Request>(…)`。 。 。 .

要求检查屏幕或动作运行前的背景和请求。 掷出 `UnauthenticatedError`, (中文(简体) ), `ForbiddenError` 或者说 `NotFoundError` 和应用程序得到401,403或404, 无需运行您的岛屿或动作 :

```ts
const signedIn: Requirement<Ctx> = (ctx) => {
  if (!ctx.viewer) throw new UnauthenticatedError();
};

// A screen for editing an article: 404 if there's no such article, 403 unless the viewer may edit it.
const editsArticle: Requirement<Ctx> = async (ctx, { params }) => {
  await articles.editable(ctx.viewer, params.articleId);
};
```

所需经费按部就班,所以 `signedIn` 先说 行动要求的客户背景 但没有参数; 请检查它在动作本身中的输入 .

岛屿和行动在发现这些错误很晚时也会造成这些错误.

## 行动

动作( 第5节)改变事物。 以生成的描述符定义每个版本并返回 a 结果(和它改变的屏幕),或它宣布的错误之一:

```ts
import { FollowUserV1 } from "./actions.generated"; // from @client-compose/codegen

const followUser = action(
  FollowUserV1,
  async ({ userId }, { ctx, idempotencyKey }) => {
    if (await blocks.has(userId, ctx.viewer)) return { error: { code: "blocked" } }; // typed
    const followers = await follows.add(ctx.viewer, userId, idempotencyKey);
    return { result: { followers }, effects: { invalidate: [{ screen: "profile", params: { userId } }] } };
  },
  { requires: [signedIn] },
);
```

-   先检查要求,然后对照清单验证输入(400) `invalid_input`) (中文(简体) ). 无效的可选属性被删除, 因此您的代码会看到它的类型所说的话 .
-   抛出的错误(除 `NotFoundError`, (中文(简体) ), `UnauthenticatedError` 或者说 `ForbiddenError`),或错误 代码操作不宣布, 是失败: 500 `action_failed`,向插件报告.
-   `load` 分批装货者在岛上工作,范围与行动请求相同.

### 一个动作改变了什么

一个动作说它以最狭义的方式改变了什么是真实的,所以应用更新而不解决 超过必须(第5.3节):

```ts
const toggleTodo = action(ToggleTodoV1, ({ id, done }) => {
  todos.setDone(id, done);
  return {
    result: {},
    effects: {
      // Entities' properties: apps apply them to every island showing them. Nothing is resolved again.
      changes: [changed(TodoEntity, id, { done }), changed(TodoCountsEntity, "me", { left: todos.left() })],
    },
  };
});
```

-   **`changes`** 设置一个实体的属性( y)`changed`或说它是 失踪( e)`deleted(TodoEntity, id)`) (中文(简体) ). 应用程序适用于显示该实体的每一个岛屿(请参见 §2.1),在他们所有的屏幕上. 依靠他人的值,就像一个伯爵一样,是 自己来的.
-   **`update`** 名称 内容发生变化的岛屿,如 a 列表 : `{ screen: "todos", key: "list" }`作曲家按照岛的要求再次解决了每一个问题 在新的请求范围中(因此它不会读取更改前装入的动作) 应用程序所知道的版本,并发送其条目。 如果小岛不在屏幕上,或者 失败, 它会使屏幕失效 .
-   **`invalidate`** 重新装入整个屏幕:用于改变屏幕布局的动作.

`app.checkEffects` 对照新的决心检查动作的更改和更新(见测试).

## 插件

插件钩入作曲家. 设置所需的钩子 :

| 钩子 | 环绕 | 对于 |
| --- | --- | --- |
| `context` | 每个请求,在您之后 `context` 函数 | 在上下文中添加: 观音符上的取景者, 租户 |
| `screen` | 组合整个屏幕 | 追踪、度量衡、隐藏整个匿名屏幕 |
| `island` | 解决一个岛屿版本 | 暗指道具、追踪、每个岛屿的衡量标准 |
| `action` | 执行动作 | 同位素储存、费率限制、审计记录 |
| `error` | 每一次失败 | 日志、 错误跟踪 |

插件按排列顺序运行 `use`第一个最外层:

```ts
const tracing: Plugin<Ctx> = {
  island: (next) => async (call) => {
    const span = tracer.startSpan(`island ${call.island.type}`);
    try {
      return await next(call);
    } finally {
      span.end();
    }
  },
};
```

`reportErrors(fn)` 是一个插件,只有一个 `error` 钩.

## 有页的岛屿

种子或结果列表随屏幕一起发送其第一页,其下页作为人物 卷轴 (spec §4.5). 定义它与 `pagedIsland`: 它解决了页面 `cursor` 请求(请求) 首先当它未定义时)到该页道具,然后是页面的光标. 光标是 你的:无论你需要继续什么,例如抵消,以及第一页的制作时间,以便 新的项目不会推动列表向前走.

```ts
const Results = pagedIsland(ResultListV1, async ({ params, cursor }) => {
  const from = readCursor(cursor); // throws InvalidCursorError: 400 invalid_cursor
  const { items, more } = await search(params.q, from);
  return { props: { items }, ...(more && { next: makeCursor(from.after(items)) }) };
});
```

小岛的入道 `next` 还有更多的页面。 客户端从 `POST /screens/{screen}/islands/{key}`即使屏风如出一辙, 版式, `when`并解决这个岛屿。 没有光标的同一请求解决任何 这样客户端就可以刷新一个 没有整个屏幕。 页面岛屿不是被隐藏的.

在测试中, `app.nextPage(screen, "results")` 在屏幕岛后取出页面, `app.next` 之后的那个 `page.props(ResultListV1)` 读它.

## 字符串

应用程序显示的每个词都来自作曲家(spec §4.7),所以复制更改,以及新语言 船,没有应用程序发布。 岛国在清单中宣布它们的关系 拥有应用程序字符串; 单词是一个目录, 每个语言一个 JSON 文件 :

```json
// strings/pl.json
{
  "app": { "loading": "Ładowanie" },
  "islands": {
    "article-list@1": {
      "heading": "Najnowsze artykuły",
      "count": { "#1": "{count} artykuł", "#few": "{count} artykuły", "#*": "{count} artykułów" }
    }
  }
}
```

```ts
const composer = createComposer({
  screens,
  strings: { catalog: loadCatalog("strings"), defaultLocale: "en" },
});
```

每一个岛的入口 都带有它的弦在客户的 `locale`,或最近的语言 目录有(`pl-PL`,则 `pl`,然后默认值),在任意字符串中,语言缺少从 默认。 溪头说哪一种语言,用它的复数规则计算 字符串. `POST /strings` 解答应用程序字符串, 不运行 `context`,因为应用程序需要 在任何人签字前. `client-compose-codegen strings` 检查目录与 名单.

屏幕本身的单词, 如其元标题, 是它的屏幕字符串: 在屏幕字符串中声明 显示在目录下 `screens`,并用生成的描述符阅读:

```ts
meta: (lc) => ({ title: lc.strings(AuthorScreenStrings).title({ author: name }) }),
```

其成果包含如下内容的行动: `ac.strings(SearchScreenStrings)`。单词 任何请求,如电子邮件或通知,从语言字符串中读取: `stringsFor(config.strings, locale).strings(InviteScreenStrings)`。 。 。 .

在测试中, `app.strings({ locale: "pl" })` 接通了应用程序 岛上有它 `strings`。 。 。 .

## 缓存岛屿(可选)

为了在请求之间保持岛屿道具,给作曲家一个 `Cache` 选择岛屿 这个 缓存是您的: 执行 `get`, (中文(简体) ), `set` 财务报告和审定财务报表 `invalidate` 无论你跑什么 无 `cache`, (中文(简体) ), 没有什么是隐藏的,每个岛屿都会满足每一个要求.

```ts
// Your adapter, e.g. over node-redis. Each tag is a set of the keys filed under it.
const redisCache: Cache = {
  get: async (key) => (await redis.get(key)) ?? undefined,
  set: async (key, value, ttlMs, tags) => {
    const tx = redis.multi().set(key, value, { PX: ttlMs });
    for (const tag of tags) tx.sAdd(tag, key).pExpire(tag, ttlMs);
    await tx.exec();
  },
  invalidate: async (tags) => {
    for (const tag of tags) {
      const keys = await redis.sMembers(tag);
      if (keys.length) await redis.multi().unlink(keys).sRem(tag, keys).exec();
    }
  },
};

// Islands say how long, and what their props depend on:
const Trending = island(TrendingV1, resolveTrending, { cache: { ttlMs: 300_000, key: () => "all" } });
const Team = island(TeamV1, resolveTeam, {
  cache: { ttlMs: 300_000, key: ({ params }) => params.team },
});

const composer = createComposer({ screens, cache: redisCache });
```

-   密钥函数返回道具所依赖的: 它所读的参数,地语. 这个 作曲家又增加了该岛的类型和版本. 返回未定义以跳过此请求的缓存 .
-   动作会使其调用者的密钥名称无效 。 所以,缓存岛屿是相同的 凡看见他们的人:当有人看见一个岛屿,而有人看见一个岛屿,而有人看见一个岛屿 比游客还多,你们把它分割成每个岛屿,谁看见它,谁就见它.
-   **行动不断更新.** 每个条目在标记下存档:岛屿(其类型,版本) 和每个实体的道具。 在行动之后,在更新之前 被解决,作曲者将无效 其效果名称: 它改变的实体,岛屿 它更新,每个缓存的岛 屏幕 它无效(如呼叫者的要求) 把它们放好 使用由多位作曲家共享的缓存,将在下一个请求上显示更改 他们。 TTL是当某事改变其他方式时,一个条目可能过时多久.
-   这个作曲家已经解决了 而不是解决 再来一次.
-   失败永远不会被缓存.
-   如果缓存出错,则岛屿无论如何都会解决(或者动作的更改会被缓存,直到它们 TTL)和错误作为插件到达 `"cache"` 失败, 缓存断层不会断开屏幕 或行动.
-   在测试中, `memoryCache()` 从 `@client-compose/composer/testing` 是一个记忆 `Cache`。 。 。 .

## 重新审理的行动(可选)

Apps发送每个动作的一等能键,当它们重试同一意图时再使用. 给 作曲家为 `IdempotencyStore` 它尊重每一个动作的钥匙: 第一个呼叫运行, 重试得到第一个结果而不是再次运行。 店是你的,没有 `idempotency`键直接传递到您的动作 `idempotencyKey`。 。 。 .

```ts
const composer = createComposer<Ctx>({
  actions,
  idempotency: {
    store: redisIdempotency, // your adapter: begin, finish, release
    scope: (ctx) => ctx.viewer?.id ?? "", // whose keys these are
  },
});
```

-   与雷迪斯, `begin` 是一个 `SET NX` “运行”标记(如果存储结果返回结果), `finish` (单位:千美元) `SET` 会议结果,以及 `release` (单位:千美元) `DEL`。 。 。 .
-   `scope` 因此,一个呼叫者永远不能重放另一个键。 返回时 `""` 任何人的行动 可能会分享.
-   只保留已完成的结果:结果和宣布的错误。 失败释放出密钥,所以 重试运行 .
-   重复到来的时候,第一个呼叫正在等待它(`waitMs`,默认为 10s).
-   成果用于 `ttlMs` (默认24h).
-   如果存储出错, 动作无论如何都会运行, 错误会作为插件到达 `"idempotency"` 失败.
-   在测试中, `memoryIdempotencyStore()` 从 `@client-compose/composer/testing` 是一个内幕商店.

## 流线

每个屏幕被作为一流(spec §4.6)回答,因此应用程序可以在到达时显示. 那个作曲家 一旦所有可能故障的屏幕都解决了,就会发出它的头: 布局(包括每个) `when`页:1 `required` 群岛及其 `meta`. 到那时尚未准备好的岛屿 `pending` 在头部,并跟随, 各在自己的线上, 随着他们的解决。 后入溪而终.

哪个岛屿值得标记 `required`,以及需要多少条件,现在决定一个屏幕多久 显示: 需要的岛屿会挡住头部 `when` 等待缓慢的数据。 共享 负载活到溪流结束.

`composer.fetch` 适配器发送每行的准备。 正在取得成功 `composer.compose(req, ctx)` 结果 `body` 是一个 `ScreenStream` (数量) `head`,则 `islands` 还是 以至来; `collect(stream)` 把屏幕放在一起.

## 为它服务

`composer.fetch` 是一个获取标准的处理器。 它回答 POST 请求,其路径结束于 `/screens/{screen}`, (中文(简体) ), `/screens/{screen}/islands/{key}` 或者说 `/actions/{action}`,这样你就可以挂起它 在任何前缀下:

```ts
// Bun, Deno, Cloudflare Workers
export default { fetch: composer.fetch };

// Hono
app.all("/compose/*", (c) => composer.fetch(c.req.raw));

// Next.js route handler: app/compose/[...path]/route.ts
export const POST = composer.fetch;
```

对于节点服务器,有适配器,将自己的请求类型传递给 `context`数字 :

| 服务器 | 软件包 |
| --- | --- |
| 节点:http | [`@client-compose/node-http`](https://www.npmjs.com/package/@client-compose/node-http) |
| 快递 | [`@client-compose/express`](https://www.npmjs.com/package/@client-compose/express) |
| 快捷化 | [`@client-compose/fastify`](https://www.npmjs.com/package/@client-compose/fastify) |

为另一个服务器写一个是几行:手 `composer.handle(path, rawBody, request)` 页:1 ,并写入它返回的状态、内容类型和正文。 身体是绳子,还是 屏幕,其流出行为 aync tractionable : 随来而写.

## 高效地装入数据

岛屿同时解决,几个岛屿往往需要同样的数据。 三层,从大多数到 最具体:

**1\. 请求中各岛屿之间分享数据: `load(key, fn)`。 。 。 .** 第一个呼叫运行 `fn`; 每一个 同一请求中其他按键相同的呼叫(其他岛屿、版式、元、元), `when`获得 同样的结果。 用一个小帮手包住每张相片 这样每个岛都用同样的钥匙:

```ts
// LayoutContext works for layouts, `when`, meta and islands.
const user = (rc: LayoutContext<Ctx>, id: string) => rc.load(`user:${id}`, (signal) => users.get(id, { signal }));
```

通过 `signal` 那个 `load` 给 `fn` 来接你 它属于请求,而不是一个 岛上,所以一个岛的计时 永远不会取消工作 另一个岛正在等待.

**2\. 将各岛屿的考察合并为一个电话: `defineBatchLoader`。 。 。 .** 岛屿需要时 不同物品来自同一服务,一个分批装货机收集它们的钥匙(去复制)并制作 一个电话:

```ts
// Define once, at module level; batches and results are per request.
const userById = defineBatchLoader((ids: string[], signal) => users.getMany(ids, { signal }));

// in any island, layout or action:
const author = await userById.load(rc, post.authorId);
const authors = await userById.loadMany(rc, authorIds); // rejects if any is missing
```

`fetch` 返回 a `Map` 由 id 键入,或与 `ids` (单位:千美元)`undefined` 对于一个缺失的id, 拒绝者 `LoadNotFoundError`选项 : `maxBatchSize`, (中文(简体) ), `windowMs`, (中文(简体) ), `cacheKey`。 。 。 .

**3\. 客户端的缓存请求,不是这里.** `load` 和分批装货机 因此一个用户的数据不会泄露到另一个屏幕中.

## 测试

`@client-compose/composer/testing` 调用您的编曲进程, 应用程序的方式, 所以您的背景 函数、要求和插件全部运行,并给您输入的结果 :

```ts
import { testComposer } from "@client-compose/composer/testing";

const app = testComposer(composer);

const profile = await app.screen("profile", { userId: "u1" }, { headers: { authorization: "Bearer t" } });
assert.deepEqual(profile.keys(), ["header", "posts"]);
const header = profile.props(ProfileHeaderV2, "header");

const followed = await app.perform(FollowUserV1, { userId: "u1" });
assert.equal(followed.result?.followers, 8);
```

-   默认情况下,应用程序宣布每个岛屿 你宣布的屏幕位置。 为了测试一个更老的应用程序, `testComposer(composer, { declare: [ProfileHeaderV1, PostGridV1] })`。 。 。 .
-   屏幕显示 `status`, (中文(简体) ), `keys()`, (中文(简体) ), `island(key)`, (中文(简体) ), `props(Descriptor, key)` 和全文 `response`, (中文(简体) ), 每一个岛屿都到位; `head` 是它的头,检查什么是待决。 错误来了 返回为他们的协议代码( r)`"not_found"`, (中文(简体) ), `"unauthenticated"`) (中文(简体) ).
-   `perform` 给出类型 `result`, (中文(简体) ), `effects`,声明 `error`,或协议 `failure`。 。 。 .
-   `await app.checkEffects("todos", {}, () => app.perform(ToggleTodoV1, input))` 显示屏幕, 执行操作, 应用其更改和更新作为应用程序, 并构成屏幕 再一次,如果有岛屿不同,它会扔出。 改变动作忘记了,比如一个计数 它没有 更新, 显示在这里而不是在应用程序中.
-   一个作曲家 `context` 接受快递或快速请求,通过 `request` 来建立一个.

## 遵约

`conformanceScreen()` 财务报告和审定财务报表 `conformanceActions()` 是用于测试的屏幕和动作 共享兼容套房;仅在测试中注册。 套房对面 `composer.fetch` 正在处理中, 没有服务器 : 见 [`@client-compose/conformance`](https://www.npmjs.com/package/@client-compose/conformance)。 。 。 .

## 链接

-   [协议规格](https://github.com/client-compose/client-compose/blob/main/spec/PROTOCOL.md)
-   [能力](https://github.com/client-compose/client-compose/blob/main/spec/CAPABILITIES.md)
-   [实例](https://github.com/client-compose/client-compose/tree/main/examples/todo)数字 : [`composer-ts`](https://github.com/client-compose/client-compose/tree/main/examples/todo/composer-ts) 为待办事宜提供示例

## 常见问题

### @client-compose/composer 是什么？

框架-不可知客户端作曲:决定一屏岛屿 从后端服务中解析道具\.

### 如何安装 @client-compose/composer？

在项目目录运行 npm install @client-compose/composer，或使用 pnpm add / yarn add / bun add。

### @client-compose/composer 支持 TypeScript 吗？

支持。包内自带 TypeScript 类型声明（package\.json 声明了 types 入口），安装后即可获得类型提示，无需再安装 @types 包。

### @client-compose/composer 支持 ESM 还是 CommonJS？

以 ES Module 形式发布，推荐使用 import 引入。在 CommonJS 项目中，Node\.js 20\.19\+ / 22\.12\+ 可以直接 require\(\) 不含顶层 await 的 ES Module，更早的版本需要改用动态 import\(\)。

### @client-compose/composer 需要什么版本的 Node\.js？

package\.json 的 engines 字段要求 Node\.js \>=20。版本不符时 npm 默认只给出警告，开启 engine-strict 后会拒绝安装。

### @client-compose/composer 可以商用吗？

@client-compose/composer 使用 MIT 许可。宽松许可：允许商业使用、修改和再分发，通常只需在分发时保留原版权与许可声明。以上为概括，不构成法律意见，请以许可原文为准。

### @client-compose/composer 的下载量有多少？

根据 npm 官方下载统计，@client-compose/composer 在 2026/10/10 – 2026/11/08 这 30 天内被下载 2,487 次，日均约 83 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入，适合横向比较热度，不能直接等同于使用人数。

### @client-compose/composer 还在维护吗？

最新版本 0\.15\.0 发布于 2026/11/10（今天）；至今共发布 18 个版本，其中过去 12 个月发布了 18 个；首次发布于 2026/10/19。发布频率只是参考信号：功能稳定的工具可能很少需要更新，建议结合源码仓库的 issue 和提交记录判断。

