Vue 3 的 h() 渲染函数到 C# 强类型绑定:一套重载族的诞生
把 Vue 3 的 h() 变成强类型 C#:一套重载族的诞生
如果你在 JavaScript 里调
h('div', 42),浏览器不会说半个不字——直到页面白屏。Jazor 做的事很简单:让 C# 编译器替你把关,在代码还没跑起来的时候就告诉你"这里不对"。怎么做到的?一套精心排列的重载,加上编译期就把类型信息删干净。
一、h():Vue 的渲染原语,也是个「来者不拒」的函数
Vue 3 世界里,所有模板最终都会被编译成 h() 调用。你写的 <div class="foo">hello</div>,编译完大概长这样:
h('div', { class: 'foo' }, 'hello')
h() 的签名在 JS 里极其宽松——h(type, props?, children?)。type 可以是字符串(HTML 标签)、组件对象、甚至异步组件;props 是个可选对象;children 嘛,字符串可以、VNode 可以、数组可以、slot 函数也可以。几乎什么都能塞进去。
这就是问题所在。JS 对 h() 不做任何类型审查——参数顺序搞反了?漏了 props?children 类型写错了?统统放行,等浏览器报错。但 Jazor 的目标是让你用 C# 写 Vue 组件,而 C# 是个什么都得说清楚的家伙——"这个参数随便传"在 C# 里根本不存在。
二、Jazor 的方案:把灵活变成重载
Jazor 的思路很直接:既然 JS 的 h() 什么都能接,那就用 C# 的重载系统把常用合法组合显式声明出来。相关 API 位于 src/ECMAScript.Vue3/Api/Vue3.Api.Render.cs,按"组件类型"和"参数组合"两个轴展开;具体 overload 数量属于实现细节,会随 binding 演进:
- 横轴(type):HTML 标签(
string)、非类型化组件(IVueComponent)、带泛型 props 的组件(IVueComponent<TProps>)、带插槽契约的组件(IVueSlotComponent<TSlots>)、两者都带的组件(IVueComponent<TProps, TSlots>) - 纵轴(参数组合):无 props 无 children、有 props 无 children、无 props 单 child、有 props 单 child、无 props 多 children、有 props 多 children、命名插槽……
下面摘录最核心的两组——HTML 元素和非类型化组件——让你感受一下这个"排列的艺术":
HTML 元素(string type)
// 无 props,无 children
public extern static IVNode H(string type);
// 无 props,单个 child
public extern static IVNode H(string type, IVNode child);
public extern static IVNode H(string type, VueChild child);
// 无 props,多个 children
public extern static IVNode H(string type, IVNode[] children);
// 有 props,无 children
public extern static IVNode H(string type, VueProps props);
// 有 props,单个 child
public extern static IVNode H(string type, VueProps props, IVNode child);
public extern static IVNode H(string type, VueProps props, VueChild child);
// 有 props,多个 children
public extern static IVNode H(string type, VueProps props, IVNode[] children);
非类型化组件(IVueComponent)
同样的模式再来一遍:
public extern static IVNode H(IVueComponent component);
public extern static IVNode H(IVueComponent component, IVNode child);
public extern static IVNode H(IVueComponent component, VueChild child);
public extern static IVNode H(IVueComponent component, IVNode[] children);
public extern static IVNode H(IVueComponent component, VueSlots slots); // 命名插槽
public extern static IVNode H(IVueComponent component, VueProps props);
public extern static IVNode H(IVueComponent component, VueProps props, IVNode child);
// ... 依此类推
你可能会想:"这么多重载,谁来写?"答案是有限组合 + 明确规则。实际 overload 数量属于 API 源码实现细节,随着 Vue binding 和 value union 演进会变化;稳定的契约是调用点由 C# 强类型解析,运行时不靠猜测。
三、VueProps:一个挑剔的「传话人」
在 JS 里,h() 的第二个参数想传什么传什么——{ class: 'foo' }、null、undefined、甚至 42(虽然会炸)。Jazor 的做法是给 props 装一道类型安全网——它叫 VueProps。
VueProps 不是万能兜底的 object,也不是 union。它是组件 props 的抽象基 record;具体组件或选项对象通过继承它声明结构化属性,VueDictionary<TValue> 则提供字符串键对象的专用创作表面:
// 常见 props 形态:
// - 继承 VueProps 的明确组件 Props record
// - VueDictionary<TValue> 字典式对象
// - Vue API 定义的具体选项 record
到调用点就舒服了:
// 使用 VueObject 或继承 VueProps 的具体 props record
var vnode = Vue.H("div", new VueObject { Class = "container", Id = "main" });
如果你手滑写成不存在的 Class/Id 成员,具体 props 类型会在编译期拒绝它。VueProps 本质上是把 JS 的"随便"变成了 C# 的结构化 props 合同。
四、VueChild:子节点的「安检口」
如果 VueProps 是 props 的基 record,VueChild 就是 h() 第三个参数的专用创作契约。它不是 native union,而是一个带有强类型隐式转换入口的宿主类型,负责接受 Vue 支持的文本、数字、布尔值和 VNode 数组:
// VueChild 放行的:
// - string(文本节点)
// - IVNode(单个 VNode)
// - IVNode[](VNode 数组)
// - VueSlots(命名插槽对象)
// - Number、bool 等基础类型
调用点写起来和 JS 几乎一样顺手:
// 文本子节点
Vue.H("span", "hello");
// VNode 子节点
Vue.H("div", new VueObject { Class = "wrapper" }, Vue.H("p", "content"));
// 多个子节点
Vue.H("ul", new[] { Vue.H("li", "a"), Vue.H("li", "b") });
有意思的是它会把合法的文本和标量子节点也纳入 C# 合同。VueChild 当前包含 int、double、bool 等数值/布尔隐式转换,因此 Vue.H("span", 42) 是合法的;真正不在契约内的类型才会在编译期被拒绝。
五、组件 + 默认插槽:ChildrenToSlotIntrinsic 的妙用
Vue 的插槽(slot)有默认插槽和命名插槽之分。当你在 C# 里写:
Vue.H(MyComponent, Vue.H("span", "click me"));
这里第二个参数是 IVNode,但 MyComponent 的 h() 调用期望的是一个 slot——不是直接的 child VNode。ChildrenToSlotIntrinsic 就是编译器里处理这个转换的内在逻辑。
它的核心工作:识别 H(IVueComponent, IVNode) 这种调用模式,判断组件是否有类型化插槽,然后把 child VNode 包装成默认插槽的 slot 函数:
// 输入的 C# 调用:
Vue.H(MyComponent, Vue.H("span", "click me"))
// ChildrenToSlotIntrinsic 的处理:
// 1. 识别这是 component + child 的组合
// 2. 判断 MyComponent 是否有类型化插槽声明
// 3. 把 child 包装成: { default: () => h("span", "click me") }
// 输出 JS:
h(MyComponent, { default: () => h("span", "click me") })
有四种不同的组合模式:
| 模式 | C# 调用 | 生成的 JS |
|---|---|---|
| 无类型 + 无 props | H(comp, child) |
h(comp, { default: () => child }) |
| 无类型 + 有 props | H(comp, props, child) |
h(comp, { ...props, default: () => child }) |
| 有类型 + 无 props | H(comp, child) |
用类型化 slot 名称 |
| 有类型 + 有 props | H(comp, props, child) |
用类型化 slot 名称 + props |
编译器根据组件是否声明了 IVueSlotComponent(类型化插槽组件接口)来判断走哪条路径。
六、命名插槽和 VueSlots
命名插槽的场景用 VueSlots 对象:
Vue.H(MyComponent, new VueSlots
{
["header"] = Vue.H("h1", "title"),
["default"] = Vue.H("p", "body content"),
["footer"] = Vue.H("small", "footer text")
});
VueSlots 是一个键为字符串、值为 slot 渲染函数的字典对象。它直接映射到 Vue 3 的 slot 对象:
h(MyComponent, {}, {
header: () => h("h1", "title"),
default: () => h("p", "body content"),
footer: () => h("small", "footer text")
})
这里要注意 slot 值是函数而不是直接的 VNode——Vue 3 的 slot 是惰性求值的,每次父组件重新渲染时 slot 函数被重新调用。ChildrenToSlotIntrinsic 会自动生成箭头函数包装。
七、单次求值保证
在 slot 函数包装中,有一个重要的语义保证:children 参数只被求值一次。 如果 C# 调用里 child 参数是一个方法调用结果:
Vue.H(MyComponent, BuildChildContent())
生成的 JS 必须保证 BuildChildContent() 只执行一次:
// 正确:先求值,再包进 slot
var __child = buildChildContent();
h(MyComponent, { default: () => __child })
// 错误:每次渲染都重新求值
h(MyComponent, { default: () => buildChildContent() })
ChildrenToSlotIntrinsic 里的 BuildSingleEvaluationArrowInvocation 方法就是负责这个保证的——它用箭头函数的立即调用(IIFE 的变体)来缓存子内容:
// 生成的 AST 结构:
// ((__child) => h(comp, { default: () => __child }))(buildChildContent())
八、编译器的视角:这一切最终变成什么
Jazor 编译器看到 Vue.H("div", new VueObject { Class = "foo" }, "hello") 时:
- 重载决议:Roslyn 选中
H(string, VueProps, VueChild)这个重载 - SemanticWalker:识别这是白名单里的
h函数([Description("@#h")]) - 参数 lowering:
"div"→ 字符串字面量,VueObject→ JS 对象字面量,"hello"→ 字符串字面量 - ChildrenToSlotIntrinsic:不触发(这是 HTML 元素,不涉及 slot 包装)
- 最终 JS 输出:
h("div", { class: "foo" }, "hello")
而如果是组件调用 Vue.H(MyComponent, props, child):
- 重载决议选中
H(IVueComponent, VueProps, IVNode) ChildrenToSlotIntrinsic检测到这是component + child模式- 把 child 包装成默认插槽:
{ default: () => child } - props 合并进去
- 输出:
h(MyComponent, { ...props, default: () => child })
整个过程在编译期完成。JS 端拿到的是标准的 h() 调用;C# 的 VueProps、VueChild 和具体 union/value contract 不会作为 CLR wrapper 层注入到 JavaScript。
九、实战:Wiki 项目 —— 重载族能打仗吗?
前面八节把 h() 重载族的"零件"拆开看了一遍。但零件好不等于整车能跑。一个 API 是不是真能打,得看有没有人拿它做过完整的项目。
Jazor 自己的文档站点是一个实际使用 h() binding 的样例:UI 由 C# 的 H() 函数组合,配合路由、搜索和响应式状态。页面数量和文件规模会随仓库演进,文章只讨论 API 形状,不把历史数量当作契约。
9.1 函数组合模式
Wiki 采用"叶子函数 → 布局函数 → 页面函数 → 根渲染函数"的四层组合。每一层都返回 IVNode,上层通过 h() 调用组装下层。
叶子函数——最小的可复用 UI 单元,无 props 参数或只有基础类型参数:
// CheckCard:标题 + 描述
private static IVNode CheckCard(string title, string summary)
=> H("article", new VueObject { Class = "check-card" },
[
H("h3", new VueObject { Class = "check-title" }, title),
H("p", new VueObject { Class = "check-summary" }, summary)
]);
// Callout:提示框
private static IVNode Callout(string title, string summary)
=> H("div", new VueObject { Class = "callout" },
[
H("p", new VueObject { Class = "callout-title" }, title),
H("p", new VueObject { Class = "callout-summary" }, summary)
]);
// MetaCard:元数据卡片(Owner/Audience/Updated)
private static IVNode MetaCard(string title, string value, string summary)
=> H("article", new VueObject { Class = "meta-card" },
[
H("p", new VueObject { Class = "meta-card-title" }, title),
H("strong", new VueObject { Class = "meta-card-value" }, value),
H("p", new VueObject { Class = "meta-card-summary" }, summary)
]);
这些叶子函数本身就是一层薄薄的 h() 包装,命名直接传达了视觉意图,页面代码读起来像声明式文档。
布局函数——组合叶子函数和其他 h() 调用,形成有结构的 UI 块:
// PageSection:文档段落(带锚点、高亮状态、复制链接按钮)
private static IVNode PageSection(string id, string title, IVNode[] content)
{
var className = "doc-section";
if (GetCurrentHashRef()?.Value == id)
className = "doc-section doc-section-active";
return H("section", new VueObject { Id = id, Class = className },
[
H("div", new VueObject { Class = "section-anchor" }, id),
H("div", new VueObject { Class = "section-title-row" },
[
H("h2", title),
H("button", new VueObject
{
Class = "section-permalink",
Type = "button",
Value = id,
Events = CreateSectionPermalinkEvents()
}, "复制链接")
]),
H("div", new VueObject { Class = "section-body" }, content)
]);
}
// RouteCardGrid:相关页面卡片网格
private static IVNode RouteCardGrid(string[] paths)
{
var cards = new List<IVNode>();
for (var i = 0; i < paths.Length; i++)
{
cards.Add(H("a", new VueObject
{
Class = "route-card",
Href = BuildBrowserUrl(paths[i], "", ""),
Events = CreateRouteClickEvents()
},
[
H("strong", new VueObject { Class = "route-card-title" }, GetPageTitle(paths[i])),
H("code", new VueObject { Class = "route-card-path" }, paths[i]),
H("span", new VueObject { Class = "route-card-summary" }, GetPageSummary(paths[i]))
]));
}
return H("div", new VueObject { Class = "route-grid" }, cards.ToArray());
}
注意 PageSection 中的条件逻辑——GetCurrentHashRef()?.Value == id 判断当前 URL hash 是否匹配该段落,决定是否添加高亮 CSS 类。这就是 h() 与 Vue 响应式状态结合的地方。
页面函数——每个路由对应一个 Body 方法,用布局函数和叶子函数拼出完整页面:
// ContentModelBody():内容模型页面
private static IVNode ContentModelBody()
=> H("div", new VueObject { Class = "doc-body" },
[
PageSection("page-contract", "页面契约",
[
H("p", "每个页面在一个中央目录中拥有显式的路由元数据。"),
H("ul",
[
H("li", "路径是真实的 URL,是托管契约的一部分。"),
H("li", "摘要是简短的产品面向说明。"),
H("li", "状态传达成熟度。")
])
]),
PageSection("editing-rules", "编辑规则",
[
H("p", "站点是代码优先的,但它不应该读起来像任意的应用代码。"),
Callout("不要为巧妙性而优化",
"如果文档页面在 C# 中变得难以编辑,答案通常是更清晰的 H 组合,而非新的元语言。")
])
]);
Wiki 页面由 private static IVNode XxxBody() 方法和少数几个 UI 原语组合;页面数量与组件目录会随仓库演进。新增页面就是新增 Body 方法并注册路由元数据。
根渲染函数——组装外壳,路由分发:
private static IVNode Render(string currentPath, string currentHash,
string navFilter, string currentSearchQuery)
{
var article = NotFoundArticle(currentPath);
var toc = EmptyTocRail();
if (IsKnownPage(currentPath))
{
article = DocumentColumn(currentPath);
toc = TocRail(currentPath, currentHash);
}
return H("main", new VueObject { Class = GetShellClassName(), Id = "top" },
[
H("a", new VueObject { Class = "skip-link", Href = "#wiki-main-content" }, "跳到内容"),
SiteHeader(currentPath),
MobileUtilityBar(currentPath),
DrawerBackdrop(),
H("div", new VueObject { Class = "wiki-layout" },
[
NavigationRail(currentPath, navFilter),
article,
toc
]),
SiteFooter(currentSearchQuery)
]);
}
根渲染函数本身就是一个大的 h() 调用。它根据 currentPath 选择正确的页面体,组装头部、导航栏、正文、目录、底部。整个应用的外壳只有这一段。
9.2 状态驱动渲染
Wiki 的很多 UI 状态由 Vue 响应式 refs 驱动。h() 在每次渲染时重新求值,读取当时的 ref 值,Vue 的响应式系统负责在依赖变化时触发重新渲染。
// SiteHeader 中根据主题 ref 切换按钮文案
private static IVNode SiteHeader(string currentPath)
{
var theme = GetCurrentThemeRef()?.Value ?? "dark";
var themeLabel = theme == "light" ? "主题:浅色" : "主题:深色";
return H("header", new VueObject { Class = "site-header" },
[
// ... 品牌信息 ...
H("button", new VueObject
{
Class = "header-toggle",
Type = "button",
Events = CreateThemeToggleEvents() // 点击切换主题
}, themeLabel)
]);
}
GetCurrentThemeRef()?.Value 读取的是一个 Vue ref<string>。当用户点击切换按钮,ref 的值改变,Vue 触发重新渲染,SiteHeader 重新调用 h(),按钮文案自动更新——不需要手动 DOM 操作,不需要 document.querySelector。
同样的模式用在导航抽屉开关、段落高亮、复制反馈等场景。h() 重载族负责构建 VNode 树,Vue 运行时负责响应式更新,两者的边界非常清晰。
9.3 编译产物预览
拿 SiteFooter 作为终极例子——它足够简单,能直观展示编译链路:
// C# 源码:
private static IVNode SiteFooter(string currentSearchQuery)
{
var summary = "jazor.wiki 作为真实文档站点运行。";
if (currentSearchQuery.Length > 0)
summary = "当前搜索:「" + currentSearchQuery + "」 | " + summary;
return H("footer", new VueObject { Class = "site-footer" },
[
H("p", summary),
H("p", "已注册页面:" + TotalPageCount)
]);
}
编译器处理:
- 重载决议匹配
H(string, VueProps, IVNode[]) new VueObject { Class = "site-footer" }降低为 JS 对象字面量{ class: "site-footer" }- 子节点数组中的每个
H("p", ...)独立降低 - 字符串拼接
+ TotalPageCount保持为 JS+运算
生成的 JS:
function siteFooter(currentSearchQuery) {
var summary = "jazor.wiki 作为真实文档站点运行。";
if (currentSearchQuery.length > 0)
summary = "当前搜索:「" + currentSearchQuery + "」 | " + summary;
return h("footer", { class: "site-footer" }, [
h("p", summary),
h("p", "已注册页面:" + totalPageCount)
]);
}
C# 的类型检查、重载决议以及适用的 value-contract 转换全部在编译期完成。JS 端就是一行干净的 h() 嵌套调用——没有运行时类型检查、没有多余的 wrapper、没有注入的框架层。编译器删掉了所有 C# 专有的类型信息,只保留 JS 需要的结构和值。
9.4 这套模式的生产属性
Wiki 项目的存在本身回答了"h() 重载族能不能用于生产"这个问题:
- 编译期安全:
VueObject的属性名是 C# 的属性名,打错字编译不过;VueChild不接受错误类型。 - 可维护性:UI 函数像乐高一样嵌套,命名是显式的(
SiteHeader、PageSection、CheckCard),不需要在模板语言和 C# 之间来回切换。 - 无魔法:没有额外的 DSL 层、没有字符串模板、没有运行时反射。h() 调用的嵌套就是 UI 树的结构。
- 工具链友好:所有代码在同一个 C# 项目中,IDE 的跳转、重构、查找引用、测试覆盖全部可用。
十、收尾
回到最初的问题:怎么把 JS 那个什么都接的 h() 变成 C# 能用的东西?
Jazor 的答案分两层。第一层是 API 设计——用重载表达常用参数组合,用 VueProps 基 record、VueChild 创作契约和具体 value union 在入口处把关。你写 Vue.H("div", props, "hello") 的心智负担和写 h('div', { class: 'foo' }, 'hello') 几乎一样,但前者由 C# 编译器检查。
第二层是编译器——SemanticWalker 在编译期完成重载决议、类型擦除、slot 包装,最终丢给浏览器的是一行干净的标准 h() 调用。没有注入的框架层,没有运行时类型检查开销。
而 Wiki 项目是这两层合在一起的实际证明:一组有限、强类型的 overload 支撑完整文档站。从叶子函数到根渲染函数,全程 h();编译器擦除 C# 类型后,浏览器看到的是标准 Vue 3 render-function 调用。
这就是 Jazor 做 h() 绑定的哲学:把类型安全留在编译期,把灵活性还给运行时。

浙公网安备 33010602011771号