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