在 Base UI 看到一个有趣的 API 设计

Last updated:

今天,Base UI 发布了 1.0 版本。在浏览官网示例时,我注意到了一个很有意思的 API 设计,大致如下:

import { Tooltip } from '@base-ui/react/tooltip';

const App = (props: Tooltip.Props) => <Tooltip {...props} />;

你发现了吗?Tooltip 的入参类型是 Tooltip.Props,而不是 TooltipProps,也不是 Tooltip['Props'],这意味着 Tooltip 既是组件又携带类型!

这种「组件即类型」的 API 设计让组件的调用显得非常整洁。

# Tooltip.Props 是怎么实现的?

我很好奇它是如何实现的,于是翻看了一下 源码,实现方案大致如下:

export namespace Tooltip {
  export type Props = { title: string };
}

export function Tooltip(props: Tooltip.Props) {
  return props.title;
}

嗯,居然使用了 namespace

模仿一下这种写法,然后你就会发现 namespace@typescript-eslint/no-namespace 禁用了。简而言之,这是因为 namespace 是 TypeScript 在 ECMAScript Modules 尚未普及时的遗产,对现代工程来说,它有诸多的陷阱。

比如,namespace 会把值和类型混合在一起,然后 Bundler(esbuild、babel、swc)就很难判断出哪些东西是类型,哪些东西是值,于是 Tree Shaking 就会失效,甚至打包出 bug。

那么,Zod 的 z.infer 呢?它也有类似的 API 设计,不是吗?

import { z } from 'zod';

type User = z.infer<typeof user>;

const user = z.object({ id: string });

# z.infer 是怎么实现的?

Zod 的 z.infer 是不是也用了 namespace,还是有更好的解决方案?于是我翻看了一下 源码,实现方案大致如下:

// zod/core.ts
export type infer<T> = T;
export const object = () => {};

// zod/index.ts
export * as z from './core';

喔,事实上,Zod 的 API 设计并不是「组件即类型」,它和 Base UI 虽然相似但不相同,它们之间有一个细微但关键的区别。

Library区别
Zod挂载类型 inferz 是一个朴素的对象
Base UI挂载类型 PropsTooltip 是一个可调用的函数

举例来说,如果把 Zod 的 API 设计套用给 Base UI,那么 Base UI 就会变成下面这样:

// base-ui/core.ts
export type Props = { title: string };
export const Comp = (props: Props) => props.title;

// base-ui/index.ts
export * as Tooltip from './core';
// your-project.ts
import { Tooltip } from '@base-ui/react/tooltip';

const App = (props: Tooltip.Props) => <Tooltip.Comp {...props} />;

# 选 Tooltip.Props,还是 z.infer?

我喜欢 Base UI 的「组件即类型」这种设计,它真是漂亮。出于工程考虑,我不会采用它,因为我不想和 namespace 的陷阱做斗争。

Oops,namespace 是实现「组件即类型」的唯一方法。

事实上,Base UI 已经碰到 陷阱 了,然后就 转向了直接导出 TooltipProps 这种传统设计Tooltip.Props 则计划报废。

总而言之,今天看到了一件漂亮的设计。