重新思考模板语言与 TypeScript 的结合:一条可落地的新路径

0 阅读8分钟

前端框架语法大致可以分为两类:模板语言框架(如 Vue、Svelte、Qingkuai)和 JSX/TSX 框架(如 React、Solid)。

在模板语言中,开发者通常可以在嵌入脚本块里获得接近原生 JS/TS 的编写体验,同时借助更简洁的模板语法完成常见渲染逻辑;代价是组件文件的灵活性会受到一定约束。JSX/TSX 则几乎让你在整份文件里都处在 JS/TS 的表达体系中,灵活性更高,但也会让 HTML 标签、CSS 样式与 JavaScript 代码深度交织,语法边界相对模糊。

以上只是对两类语法核心差异的简化描述,具体体验因人而异。本文聚焦一个长期存在的痛点:模板语言如何更好地支持 TypeScript

一、组件中的类型声明

在日常使用模板语言时,我一直有一个明显感受:主流框架对 TypeScript 的支持虽然已经很强,但在关键场景仍有不小门槛。最典型的就是几乎所有组件化框架都会遇到的 props 类型声明。

在这件事上,VueSvelte 采用了相近思路:通过编译标记(不同框架术语略有区别,例如 Vue 常称为编译器宏)声明类型。对于简单 props 这套方案基本够用;但进入泛型场景后,通常需要在 <script> 标签上额外声明泛型作用域,例如:

<script generics="T extends { id: number; name: string }"></script>

这在一定程度上背离了模板语言的核心优势:在嵌入脚本里提供一个纯净的 JS/TS 编程环境,让开发者专注业务逻辑,而不是额外语法细节。

更现实的问题是隐性成本。比如在 generics 属性中,是否可以访问嵌入脚本块内声明的类型?经过测试,Vue 与 Svelte 的表现一致但并不理想:

对于导入的外部类型,generics 可以访问;对于脚本块内部声明的类型,则无法访问。

导入类型.png

内部类型.png

我推测这与泛型组件的导出形态有关:语言服务可能需要将组件默认导出处理为函数,而 import 声明只能位于模块顶层,因此需要提升到函数外部,进而产生这种可见性差异。无论具体实现原因如何,这都会增加开发成本,并削弱模板语言应有的流畅体验。

这也是我在 Qingkuai 中做的一个核心取舍:保留 Props 作为组件全局类型声明。只要声明了 Props,就等于声明了 props 类型。这样一来,嵌入脚本块的编写体验和普通 JS/TS 基本一致。

props类型声明.png

这个设计还有一个额外收益:在非 TypeScript 项目中,仍可通过 JSDoc 注释声明 Props 类型,从而获得类型检查与补全能力。

jsdoc定义组件类型.png

二、泛型实参的传递

除了 props 声明之外,另一个高频痛点是:无法为组件泛型参数传递实参

在 Vue 与 Svelte 中,目前都缺少一套明确机制来向组件泛型传入实参。这会导致调用方即使具备明确的业务上下文,也无法通过显式传入泛型实参来收窄并主动约束组件类型。

组件泛型实参.png

三、插槽上下文类型推导

在插槽上下文类型推导上,模板语言相较 JSX/TSX 其实有天然优势:多数模板语言通过 slot 标签声明插槽出口,并可在标签上直接绑定要传递给插槽的数据。这为自动推导提供了明确入口,不必强迫开发者在组件内部增加额外类型标注。

反过来看 JSX/TSX(如 React),其并没有原生插槽概念,通常只能通过 children 模拟类似能力。这样一来,类型推导会明显更难,往往需要开发者手工声明函数类型来描述 children 的参数与返回值。

遗憾的是,当前主流模板语言仍未实现插槽上下文自动推导。Vue 支持手动标注插槽上下文类型;Svelte v4 使用 slot 定义插槽但不能标注其类型,v5 虽引入 Snippet 机制,仍需要开发者手动标注片段上下文类型,二者都不支持自动推导。

但从可行性看,这件事并不遥远。通过编译期静态分析、IR 标记与 TypeScript 语言服务提取类型的组合,模板语言完全可以实现插槽上下文自动推导。例如下面两个组件中,组件内部没有额外类型标注,调用方仍可获得完整推导与补全,甚至在纯 JavaScript 项目中也能自动推导插槽上下文类型:

插槽上下文类型推导.png

插槽上下文自动推导的价值不只在于减少类型声明成本,更在于 IDE 交互质量。借助 查找定义查找引用,开发者可以直接跳转到上下文定义源头,而不是落在类型定义中转层。

qingkuai插槽跳转.gif

vue插槽跳转.gif

在复杂组件里,这个差异非常直观。没有自动推导时,你往往需要先定位 <slot>,再分析绑定字段,最后回溯字段定义;有自动推导时,只需在插槽内容处执行一次 查找定义,即可直达源头,开发效率和可维护性都会明显提升。

四、组件类型导出

目前几乎所有模板语言都不要求手工定义组件导出类型,语言服务会根据组件内部声明自动推导默认组件类型。这本身是合理且高效的设计。

但另一个问题是:推导出的导出类型是否足够可读。Vue 可能因兼容历史语法而导致类型展示偏冗长;Svelte 虽然更简洁一些,但仍会暴露部分内部类型细节,容易增加理解成本。

vue组件导出类型.png

svelte组件导出类型.png

通过更清晰的导出类型结构设计,这个问题是可以优化的:

qingkuai组件导出类型.png

另外,很多开发者在写组件时都会习惯把鼠标悬停在组件标签上查看类型,但 Vue 与 Svelte 对这一体验的支持仍不理想:

svelte组件标签查看类型.png

vue组件标签查看类型.png

如果通过 TypeScript 语言服务的 TypeChecker 提取组件导出类型,并在标签悬停中返回该类型,落地并不复杂:

qingkuai组件标签查看类型.png

五、总结

本文从四个问题展开:组件内类型声明、泛型实参传递、插槽上下文类型推导,以及组件导出类型可读性。它们看似分散,本质上都指向同一个目标:让模板语言中的 TypeScript 体验尽可能接近“普通 TypeScript 文件”的直觉与效率。

从工程实践看,真正决定体验的往往不是语法表层,而是类型流是否连续。只要类型信息在“组件定义 -> 编译产物 -> 语言服务 -> IDE 交互”链路上断裂,开发者就会被迫用额外声明、注释和心智记忆去补洞。

围绕这一点,Qingkuai 采取了两项关键策略:

  1. 减少模板内额外语法负担:通过内置 Props / Refs 约定,将组件属性类型声明收敛到标准 TS 类型定义。
  2. 增强语言服务侧类型恢复能力:在编译期保留足够结构化标记,再由 TypeScript 语言服务提取并回填类型,用于补全、跳转与错误检查。

以插槽上下文为例,采用“编译期 IR 标记 + LSP TypeChecker 提取”路径后,类型推导不再依赖开发者逐处手工维护,IDE 也能把定义关系直接连接回真实源头。这不仅降低了类型维护成本,也显著改善了代码阅读与重构体验。

最终结论可以归纳为三点:

  1. 模板语言并不天然弱于 TS 体验:关键在于是否将类型系统纳入语言与工具链的一体化设计。
  2. 编译器与语言服务应协同设计:编译器负责可追踪标记,语言服务负责语义恢复与交互反馈。
  3. 高质量类型体验可以工程化落地:只要类型链路闭环,补全、跳转、诊断与可维护性就能同步提升。

这也意味着,这套思路并不局限于 Qingkuai。本质上,它为其他模板语言也提供了一条可行路线:在尽量保持模板语法简洁的前提下,通过编译器与语言服务协同设计,持续提升 TypeScript 体验。Qingkuai 的后续工作也可以沿着这条路径推进:补齐更多边界场景(复杂泛型、条件类型、跨文件符号映射),并以真实项目数据验证这套机制在大型代码库中的稳定性与性能表现。若你想进一步了解实现细节或直接上手验证,可以参考qingkuai文档在线体验qingkuai