我在读一个”一切皆插件”的前端代码库,几十个 UI 包,彼此之间几乎没有 import。翻到某个包的开头看到这两行:
1 | import type { Context } from '@app/core' |
第二行的花括号是空的。当时我以为是谁没删干净的残留。它不是——它是这套架构能成立的关键。
import type 编译后是空的
上面两行编译成 JavaScript 之后什么都不剩。没有 require,没有 import,运行时那两个包根本不会被加载。
普通 import 就不一样,它会留下一条真实的模块加载:
1 | import { GoalDock } from './GoalBar.tsx' // 运行时真的去加载这个模块 |
区别就这一个词,但它决定了依赖图长什么样。
后果一:依赖图不一样
import type 不产生运行时依赖边,于是:
- bundler 不会把那个包打进产物
package.json里可以只写devDependencies。我看的那个包里连 React 都是 devDependency,dependencies字段压根不存在- 循环依赖不成立。A 和 B 可以互相
import type,因为运行时谁都没加载谁
最后这条是插件架构最想要的:包 A 需要知道包 B 提供了什么服务,但它不需要、也不应该在运行时把 B 拽进来——B 装没装是运行时的事。
后果二:空花括号是拿来做声明合并的
1 | import type {} from '@app/ui-conversation/client' |
它一个符号都不引入。唯一的作用是把那个包的 declare module 拉进当前编译单元的作用域。
我看的那个包里注释写得很直白:这一行要的不是任何值,是让 ctx.uiConversation 这个属性、和 'conversation.input.dock' 这个 slot id 有类型。
换成普通 import 当然也能拿到类型,但会多一条运行时加载边——而它一个符号都不用。空花括号是”我只要你的类型声明,别的什么都不要”的精确写法。
后果三:运行时连接必须另找路子
这是最关键的一环。既然跨包不 import,运行时怎么拿到对方的东西?
答案是一个 cordis 风格的 ctx 容器:
1 | export const inject = ['slots', 'locale', 'uiConversation'] // 声明我需要什么 |
包不去 import 别人,只声明”我需要这几个服务”。谁提供、什么时候提供、有没有提供,是容器的事。
于是形成两条独立的通道:
flowchart LR
subgraph compile["编译期"]
A1["包 A"] -. "import type" .-> B1["包 B 的类型"]
A1 -. "import type {}" .-> C1["declare module
声明合并"]
end
subgraph runtime["运行时"]
A2["包 A"] -->|"inject: ['uiConversation']"| CTX["ctx 容器"]
B2["包 B"] -->|"注册服务"| CTX
end
类型走 import type,只存在于编译期;值走 ctx,只存在于运行时。两条通道完全不相交,这就是那几十个包能互不 import 的全部原因。
一句话记法
import type 是「我需要知道你长什么样」,普通 import 是「我需要把你装进来」。
这套代码库的规矩很简单:包内两种都用,跨包只用前一种。
值得注意的一点
这个写法把一类错误从构建期推到了运行时。import type 让编译器相信 ctx.uiConversation 存在,但如果那个插件没被装上,运行时它就是 undefined。inject 数组是补这个洞的——容器按它检查依赖是否齐全。所以 inject 写漏一个,类型检查照样过,报错要等到运行时。
代价是明确的,换来的是插件之间真正的解耦。