webiny-js 代码规范:通过命名空间(Namespace)引用抽象类型的实践指南

原创2026-09-26 23:59:05126 阅读
文章标签:CMS后端前端

webiny-js 代码规范:通过命名空间(Namespace)引用抽象类型的实践指南

导读

本文解读 webiny-js 开源仓库中 ai-context/code-style/reach-abstractions-through-the-namespace.md 所确立的一条核心 TypeScript 代码风格规则:在声明抽象的文件之外,一律通过抽象自身的命名空间(namespace)来引用其接口类型(Foo.Interface),而不是直接使用 IFoo 前缀接口。该规则作用于仓库中以 createAbstraction 构建的各类依赖抽象(如 AiSdkToolDefinition、HttpRouteDefinition),贯穿 packages/api-core、packages/event-handler-core 等核心包。读完本文,你将掌握该规范的完整约束、背后动机、源码级实现依据,以及它在真实代码中的落地形态,可直接用于审查与编写符合 webiny-js 风格的代码。

规则一句话概括

每个抽象(abstraction)都会声明一个接口(interface)和一个引用该接口的命名空间(namespace)。在声明它们的文件之外,引用时只能使用命名空间,即写作 Foo.Interface,而绝不使用命名空间所指向的 IFoo。

这一规则不是可选项,而是 webiny-js 代码风格审查的硬性约束,它同时回答了三个问题:

  1. 类型的外部公开名是什么(一个,而不是两个);
  2. 类型归属哪个抽象(通过命名空间一眼可辨);
  3. 抽象关联的其他类型住在哪里(namespace 内部,而不是各自散落的 I 前缀导出)。

为什么「Bad」写法是错误的

文档给出了一个典型的反例(错误写法):

// Bad — 一个类型有俩公开名,且导入看不出它属于哪个抽象
import type { IAiSdkToolDefinition } from "@webiny/api-core/features/ai/index.js";

export const isReadOnly = (tool: IAiSdkToolDefinition): boolean => /* ... */;

这段代码有两个问题:

  • 一个类型出现两个公开名字:IAiSdkToolDefinition 是接口本名,AiSdkToolDefinition.Interface 是命名空间别名,二者指向同一个类型。两个名字同时出现在代码库中,必然导致漂移(drift)——有的文件导入前者、有的文件导入后者,读者必须反复核对二者是否为同一事物。
  • 导入语句无法表明类型归属:IAiSdkToolDefinition 这串字符里没有任何信息告诉你它属于 AiSdkToolDefinition 这个抽象;而 AiSdkToolDefinition.Interface 读起来就是"这个抽象的接口"。

推荐的「Good」写法

// Good — 一个名字,读起来就是"这个抽象的接口"
import { AiSdkToolDefinition } from "@webiny/api-core/features/ai/index.js";

export const isReadOnly = (tool: AiSdkToolDefinition.Interface): boolean => /* ... */;

注意两个细节:

  • 导入的是值(import { AiSdkToolDefinition })而非 import type,因为 AiSdkToolDefinition 本身既是一个运行时存在的抽象令牌(Abstraction 实例),又承载了 Interface 类型别名,两者由同一个标识符提供,正好实现"一个名字"的诉求。
  • 类型引用写作 AiSdkToolDefinition.Interface,这是 namespace 合并(declaration merging)的典型用法:createAbstraction 返回的抽象令牌与 export namespace AiSdkToolDefinition 共享同一名称,类型与值天然绑定。

I 前缀接口为什么仍然必须存在

规则的落点在于消费方,但 I 前缀接口本体的存在是硬性前提。文档明确了两条理由:

export const AiSdkToolDefinition = createAbstraction<IAiSdkToolDefinition>("AiSdkToolDefinition");
  • createAbstraction 需要它:抽象令牌必须绑定一个接口类型作为其类型参数。在 packages/api-core/src/features/ai/abstractions.ts#L147 中,AiSdkToolDefinition 正是通过 createAbstraction<IAiSdkToolDefinition>("AiSdkToolDefinition") 创建;同理,packages/event-handler-core/src/features/http/abstractions.ts#L170 中 HttpRouteDefinition = new Abstraction<IHttpRouteDefinition>("HttpRouteDefinition")。createAbstraction 的底层实现见 packages/feature/src/createAbstraction.ts,它只是 new Abstraction<T>(name) 的一层封装。
  • TypeScript 声明产出(declaration emit)需要它被导出:Foo.Interface 的命名空间引用最终要在 .d.ts 中解析到 IFoo,因此 IFoo 必须从声明它的文件中导出,否则 AiSdkToolDefinition.Interface 无法被下游类型检查。

也就是说:IFoo 的全部职责就是"被抽象绑定 + 被命名空间引用",仅此而已。它不是给调用方用的公开名。

不要在包的入口再次导出 I 前缀接口

命名空间里已经有了 Interface,那么包的 index.ts 就不得重复导出 I 前缀接口:

// Bad — index.ts
export { AiSdkToolDefinition } from "./abstractions.js";
export type { IAiSdkToolDefinition } from "./abstractions.js"; // 同一个类型,导出了两次

文档点名表扬了 event-handler-core 的做法。查看其实际代码:

同样的模式也落实在 api-core 的 AI 功能包上。packages/api-core/src/features/ai/index.ts 导出了 AiSdkToolDefinition、AiSdkToolHandler、AiSdkTools 等全部抽象令牌,而对 I 前缀类型只导出了 IAiConnection、IAiConnectionInline 这类没有命名空间归宿的纯数据类型,IAiSdkToolDefinition 这类抽象接口一律被排除在入口之外。

如果 index.ts 同时导出两种写法,调用方就会在"用 AiSdkToolDefinition.Interface 还是 IAiSdkToolDefinition"之间随意二选一,导致同类型双名并存、命名漂移,阅读者必须逐一比对两个名字是否指向同一个类型——这正是本规则要消灭的认知负担。

命名空间是抽象相关类型的"家"

I 前缀接口只该有一个(供抽象绑定),但一个抽象往往不止一个关联类型。命名空间的另一大价值,就是把这些相关类型收拢在同一处,而不是让每个关联类型都各自再配一个 I 导出:

export namespace AiSdkToolDefinition {
  export type Interface<TInput = any> = IAiSdkToolDefinition<TInput>;
  export type Annotations = IAiSdkToolAnnotations;
}

这段代码取自 packages/api-core/src/features/ai/abstractions.ts#L158-L161,是仓库的真实实现。它把以下信息统一收编进 AiSdkToolDefinition 命名空间:

  • Interface:抽象的核心契约,即 IAiSdkToolDefinition<TInput>;
  • Annotations:工具的行为提示注解类型 IAiSdkToolAnnotations(readOnlyHint、destructiveHint、idempotentHint、openWorldHint,见同文件 L111-L120)。

消费方因此可以这样用,相关名字全部跟随抽象走:

import { AiSdkToolDefinition } from "@webiny/api-core/features/ai/index.js";

function describe(tool: AiSdkToolDefinition.Interface) {
  const annotations: AiSdkToolDefinition.Annotations = tool.annotations ?? {};
  // ...
}

而 AiSdkTools 的实现类正是以这种形式声明依赖的。查看 packages/api-core/src/features/ai/AiSdkTools.ts#L8-L12:

class AiSdkToolsImpl implements AiSdkToolsAbstraction.Interface {
    constructor(
        private definitions: AiSdkToolDefinition.Interface[],
        private resolver: AiSdkToolHandlerResolver.Interface
    ) {}

在这个文件中,AiSdkToolDefinition.Interface、AiSdkToolHandlerResolver.Interface 均通过命名空间引用,I 前缀接口一次都没有出现。这印证了规则的"消费端"形态:即使是在同一个包内、紧邻 abstractions.ts 的实现文件中,也一律走命名空间,而不是 import type { IAiSdkToolDefinition }。

命名空间的类型参数:保留泛型

注意命名空间别名保留了泛型签名:

export type Interface<TInput = any> = IAiSdkToolDefinition<TInput>;

这与 IAiSdkToolDefinition<TInput = any>(abstractions.ts#L137)一一对应,确保通过 AiSdkToolDefinition.Interface<MyInput> 使用时泛型信息不丢失。在 AiSdkTools.ts 中,定义数组以 AiSdkToolDefinition.Interface<a href="https://link.gitcode.com/i/6a7fffe0c1ddea4d73d2e4d9fcc78b23" target="_blank">](默认 any)注入,getToolSet() 再逐一定义读取 name、description、inputSchema 并延迟构建 execute([AiSdkTools.ts#L14-L34),整个链条都保持对命名空间类型的一贯引用。

例外:纯数据类型直接导出,不进命名空间

本规则只适用于抽象接口。对于背后没有抽象、只是承载数据的普通数据类型(例如 AiModel、IAiConnection),它们没有可归属的命名空间,因此直接导出即可。

这一点同样能在源码中得到印证。在 packages/api-core/src/features/ai/abstractions.ts 中:

  • AiModel(L80-L85)是一个纯数据形状(providerId、providerName、modelId、modelName),没有 I 前缀,也没有命名空间,被入口文件直接 export type;
  • IAiConnection、IAiConnectionInline(L44-L51)同样是数据形状,虽然保留 I 前缀,但在 packages/api-core/src/features/ai/index.ts#L11 中被 export type { IAiConnection, IAiConnectionInline, ... } 直接导出——因为它们是纯数据,无处安放命名空间,也不会与任何抽象令牌同名造成双名漂移。

判断标准很简单:问一句"这个类型背后有没有一个 createAbstraction 出来的抽象令牌?" 有,就走命名空间;没有,就直接导出。

实战自查清单

在 webiny-js 仓库内编写或审查代码时,可按以下清单逐条核对:

  1. 消费端引用:在声明文件之外,类型一律写作 Foo.Interface(泛型抽象如 AiSdkToolDefinition.Interface<TInput> 保留泛型参数),禁止 import type { IFoo };
  2. 声明端职责:IFoo 仅在 abstractions.ts 内部存在并被 createAbstraction<IFoo>(...) 绑定、被 export namespace Foo { export type Interface = IFoo; } 引用,不得承担其他公开职责;
  3. 包入口导出:包的 index.ts 只导出抽象令牌(Foo),绝不同时 export type { IFoo };IFoo 只作为模块内部类型可见;
  4. 相关类型归位:抽象的行为注解、参数形状等相关类型放进 Foo 命名空间(如 AiSdkToolDefinition.Annotations),不要各自配一个 I 导出;
  5. 纯数据例外:无抽象承载的普通数据类型(如 AiModel、IAiConnection)不受此规则约束,直接在入口导出。

参考实现与延伸阅读

若想进一步了解 webiny-js 的完整代码风格体系,可继续阅读 ai-context/code-style/ 目录下的其他规范文档(如 one-public-function-per-file.md、no-backwards-compat.md),以及 ai-context/issues/ddb-direct-usage-in-base-packages.md 中对分层与抽象边界的讨论。

登录后查看全文
webiny-js