JSX 语法实在是太香了。不过常见的方式要用上的话,必须得有一个 MVVM 框架,或者至少得有一个 VDOM 库(e.g. snabbdom)。如果不想用这些库的话,Vanilla JS 对应的 document.createElement 相比起来实在是过于繁琐。正常人这个时候应该都去用 choojs/html 这类 HTML template literal 库了。

不过我们可以搞点灵的。

JSX-style DOM invocation

回忆 JSX 会被 tsc / 其他 build tools 翻译成 React/Preact style h calls,或者 React.createElement style calls。如果只要能弄出来一个语义一样的函数,在内部调用 DOM,那就可以用 JSX 直接创建 DOM 元素了。

事实上,我们可以搞出来带 namespace 的版本,这样我们还可以用 JSX 来写 SVG:

export function jsxFactory(
  ns?: string,
): (tag: string, data: JSXData, ...children: RecursiveElement[]) => Element {
  return (tag, data, ...children) => {
    const el =
      ns !== undefined
        ? document.createElementNS(ns, tag)
        : document.createElement(tag);
    if (data?.class !== undefined)
      el.setAttribute("class", evalClassDef(data.class));
    if (data?.style !== undefined)
      el.setAttribute("style", evalStyleDef(data.style));
    if (data !== null)
      for (const key in data) {
        if (key === "class" || key === "style" || key === "__html") continue;
        el.setAttribute(key, data[key]);
      }

    if (data?.__html !== undefined) el.innerHTML = data.__html;
    else el.append(...flatten(children));
    return el;
  };
}

export const jsx = factory();
export const jsxSVG = factory("http://www.w3.org/2000/svg");

其中,JSXData 的定义为:

export type JSXData = {
  class?: classDef;
  style?: styleDef;
  __html?: string;
  [other: string]: any;
} | null;

如果在 tsconfig.json 中添加如下配置:

{
  "compilerOptions": {
    "jsx": "react",
    "jsxFactory": "jsx",
  }
}

那么只要在用到 JSX 的文件内引入 jsx 即可:

import { jsx } from './jsx.ts'
// Or: 
import { jsxSVG as jsx } from './jsx.ts'

类型炸了!

然而,直接这么搞的话,tsc 会非常不开心:

JSX element implicitly has type ‘any’ because no interface ‘JSX.IntrinsicElements’ exists.

学习了一下 tsc 提供这个 interface 的方法,居然是通过在 jsx 定义的文件内放一个同名的 namespace 来实现:

export namespace jsx {
  export namespace JSX {
    export type Element = HTMLElement;
    export interface IntrinsicElements {
      // TODO: restrict to HTML elements
      [elemName: string]: JSXData;
    }
  }
}

jsx.JSX.Element 指定了 JSX 吐出来的东西的类型。这里的实现略微偷懒了,其实可以给每一种元素名搞出来不同的参数类型。

最有趣/灵车的地方是,如果在 namespace jsxSVG 中给出另一个不同的定义,在 import { jsxSVG as jsx } 的时候会用 jsxSVG 中的定义。感觉 namespace xxx 有点像 Scala 的 companion object 的对偶,可以强行往一个值上加一些类型信息。

SSR?

那么 SSR 呢?

简单的方法是我们可以搞一个输出字符串版本的 Factory,然后动态通过运行时检查是否是 SSR 环境内,决定使用哪个 jsx 实现:

const jsx = context.SSR ? jsxDom : jsxStr;

然而这个时候上述 namespace jsx 的解析就不工作了。正常人这个时候就去用 jsdom 给 JS Runtime 加上 DOM API 了。

不过我们可以再搞点灵的。

上述问题出现的主要原因是 tsc 的类型检查无法携带运行时信息。如果我们有依值类型,可以把我们想要编译器看到的东西表达地更清楚一点:

const JSXElementType: (ssr: boolean) -> type = (ssr) => ssr ? string : HTMLElement;
type Element = JSXElementType(context.SSR);

很遗憾我们显然没有这种东西。不过 TypeScript 的类型检查和运行时不一定完全对应,所以我们可以搞一些 Type hack:

  • 定义一个 jsxStr 是 SSR 版本的字符串拼接,返回的是字符串。
  • 依旧 const jsx = context.SSR ? jsx : jsxStr,但是 jsx.JSX.Element 永远是 HTMLElement
  • 在使用这个元素的时候,区分一下是不是在 SSR 环境
    • 如果不在,那么 document.querySelector('balabala')!.appendChild(elem).
    • 如果在,那么 template.replace('placeholder', elem.toString()).

Perfectly type-checks!

什么?问为啥 tsc 没有检查 jsx 返回值是不是和 jsx.JSX.Element 声称的一致?或者说我们为什么甚至需要一个 jsx.JSX.Element 来标记返回值?

如果真的是这么搞的,那上述 hack 就不工作了。还是灵一点好,有前端开发那味儿。


完整代码:https://gist.github.com/CircuitCoder/4d36f706ddd84e93eb3a999257fe239b