跨包只写 import type,运行时全走 ctx

import type 编译后完全消失,不留下任何运行时加载。几十个 UI 包因此可以互相知道对方的类型,却谁也不加载谁——插件架构的分界线就在这一行语法上。

Posted by Jessie Jia on 2026-09-24

我在读一个”一切皆插件”的前端代码库,几十个 UI 包,彼此之间几乎没有 import。翻到某个包的开头看到这两行:

1
2
import type { Context } from '@app/core'
import type {} from '@app/ui-conversation/client'

第二行的花括号是空的。当时我以为是谁没删干净的残留。它不是——它是这套架构能成立的关键。

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
2
3
4
5
export const inject = ['slots', 'locale', 'uiConversation']   // 声明我需要什么

export function apply(ctx: Context) {
ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({ /* … */ }))
}

包不去 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 写漏一个,类型检查照样过,报错要等到运行时。

代价是明确的,换来的是插件之间真正的解耦。