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' }nullundefined、甚至 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 当前包含 intdoublebool 等数值/布尔隐式转换,因此 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") 时:

  1. 重载决议:Roslyn 选中 H(string, VueProps, VueChild) 这个重载
  2. SemanticWalker:识别这是白名单里的 h 函数([Description("@#h")]
  3. 参数 lowering"div" → 字符串字面量,VueObject → JS 对象字面量,"hello" → 字符串字面量
  4. ChildrenToSlotIntrinsic:不触发(这是 HTML 元素,不涉及 slot 包装)
  5. 最终 JS 输出h("div", { class: "foo" }, "hello")

而如果是组件调用 Vue.H(MyComponent, props, child)

  1. 重载决议选中 H(IVueComponent, VueProps, IVNode)
  2. ChildrenToSlotIntrinsic 检测到这是 component + child 模式
  3. 把 child 包装成默认插槽:{ default: () => child }
  4. props 合并进去
  5. 输出:h(MyComponent, { ...props, default: () => child })

整个过程在编译期完成。JS 端拿到的是标准的 h() 调用;C# 的 VuePropsVueChild 和具体 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)
    ]);
}

编译器处理:

  1. 重载决议匹配 H(string, VueProps, IVNode[])
  2. new VueObject { Class = "site-footer" } 降低为 JS 对象字面量 { class: "site-footer" }
  3. 子节点数组中的每个 H("p", ...) 独立降低
  4. 字符串拼接 + 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 函数像乐高一样嵌套,命名是显式的(SiteHeaderPageSectionCheckCard),不需要在模板语言和 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() 绑定的哲学:把类型安全留在编译期,把灵活性还给运行时。

项目地址:https://github.com/devhxj/Jazor

posted @ 2026-05-12 16:00  形而下晓寒  阅读(14)  评论(0)    收藏  举报