好奇一下如何在 Typescript 中自定义 JSX 类型和解析方法
最近在做项目的 Vue3 迁移,由于项目中有许多地方用了 jsx 的语法,所以在迁移过程中按照官方文档的说明,更新了tsconfig.json的配置:
{
"jsx": "preserve",
"jsxImportSource": "vue"
}
这里将jsx设置成preserve是为了让typescript不对jsx进行处理,原样输出给后续其他流程进行处理,比如babel。而jsxImportSource是 Vue3 中要求新增的配置,这个配置是自定义 JSX 的关键,从 Typescript 的官方文档可以了解到,当我们设置了jsxImportSource之后,Typescript 会从一个以我们配置的路径为前缀的特定目录下面去加载 JSX 的类型定义以及生成最终代码需要用到的解析函数,比如上面的 Vue3 的配置,实际上会去加载vue/jsx-runtime下面的 JSX 类型定义。下面我们来从 0 开始创建一个我们自己的 JSX 类型和解析方法。
开始
首先,我们将jsxImportSource配置成jsx,也就是告诉 Typescript 去jsx/jsx-runtime下面加载类型定义。下面是完整的tsconfig.json的配置内容:
{
"compilerOptions": {
"outDir": "./dist/",
"target": "ESNext",
"lib": ["dom", "esnext", "scripthost", "es2015.promise"],
"allowSyntheticDefaultImports": true,
"strict": true,
"moduleResolution": "node",
"module": "CommonJS",
"baseUrl": "./",
"jsx": "react-jsx",
"jsxImportSource": "jsx"
},
"include": ["src/**/*", "jsx/*"]
}
我们知道在 JSX 中有两种类型的标签:一个是平台的原生元素,比如浏览器下的div,span等元素;另一个是用户自定义的组件。我们先来看看原生元素的情况,我们在平时开发过程中会发现,如果我们写了一个不是浏览器原生元素的标签的时候,Typescript 是会进行类型报错的,那么它是怎么知道那些元素原生元素呢?答案是JSX.IntrinsicElements:
JSX.IntrinsicElements
export namespace JSX {
export interface IntrinsicElements {
div: any;
span: any;
// ...more
}
}
JSXnamespace 下面有一个叫做IntrinsicElements的 interface,这个 interface 中的所有字段就是我们支持的所有原生元素列表,这些元素是可以直接在 JSX 中使用的。比如:
function App() {
return <div>App</div>;
}
这时候如果我们希望增加一些新的原生元素,就可以在IntrinsicElements下面增加新的 key,比如:
export namespace JSX {
export interface IntrinsicElements {
div: any;
span: any;
"custom-div": any;
// ...more
}
}
这样我们就可以在 JSX 中使用custom-div这个标签了:
function App() {
return <custom-div>App</custom-div>; // 如果IntrinsicElements中没有定义custom-div的key,这里就会报类型错误
}
看了上面的类型定义,可能会奇怪IntrinsicElements中每个 key 的类型是用来做什么的呢?实际上每个 tag 的类型就是来约束这个元素的 attributes,也就是每个元素所支持的属性有哪些以及每个属性的类型是什么,比如:
export namespace JSX {
export interface IntrinsicElements {
div: any;
span: any;
"custom-div": {
name: string;
};
// ...more
}
}
上面就是定义了custom-div必须有一个叫做name的 attribute,比如:
function App() {
return <custom-div name="app">App</custom-div>; // 这里如果没有指定name属性就会有类型报错,因为我们没有把name定义成可选属性,大部分情况下属性的字段都是可选属性
}
JSX.ElementClass
除了平台相关的原生元素以外,更重要的就是用户自己定义的组件,用户定义的组件又分为 ClassComponent 和 FunctionComponent,对于 ClassComponent 来说,Typescript 也需要一个方式来告诉它哪些对象可以被作为 JSX 的标签,充当这个角色的就是JSX.ElementClass:
export namespace JSX {
export interface ElementClass {
render: (...args: any[]) => any;
}
}
只要一个对象的类型能够和JSX.ElementClass匹配,那么这个对象就能作为 JSX 的标签。这里我们告诉 Typescript 只要一个对象的类型中保函了一个render函数,那么这个对象就能作为 JSX 的标签:
class ClassComponent {
render() {
return <custom-div name="name">class component</custom-div>;
}
}
function App() {
return <ClassComponent>App</ClassComponent>;
}
对于 FunctionComponent 来说,我们还可以在 JSX namespace 下定义一个叫做Element的 interface,所有 JSX 类型的数据都会被推断成JSX.Element这个类型,默认情况下 Typesciprt 会将所有 JSX 类型的数据推断成any。而只要我们的函数返回类型是JSX.Element,这个函数就可以作为 JSX 中的标签,简单来说只要函数的返回值是一个 JSX 表达式就可以。比如:
function FunctionComponent() {
return <custom-div>function component</custom-div>;
}
function App() {
return <FunctionComponent>App</FunctionComponent>;
}
JSX.ElementAttributesProperty
上面讲完了如何让 Typescript 识别我们自定义的元素,对应到原生元素,我们同样也需要有一个方式告诉 Typescript 如何对自定义元素的属性进行类型推断,这里我们就要定一个叫做ElementAttributesProperty的 interface:
export namespace JSX {
export interface ElementClass {
render: (...args: any[]) => any;
}
export interface ElementAttributesProperty {
props: {};
}
}
这里我们就告诉 Typescript 对于 ClassComponent,可以根据 ClassComponent 中的 props 属性的类型来推断 JSX 中自定义元素的属性类型,而对于 FunctionComponent 来说,Typescript 会把函数的第一个参数类型作为自定义元素的属性类型,比如:
class ClassComponent {
props: {
name: string
},
render() {
return <custom-div name="name">class component</custom-div>;
}
}
function FunctionComponent(props: { name: string }) {
return <custom-div>function component</custom-div>;
}
function App() {
return (
<custom-div>
<ClassComponent name="name">App</ClassComponent>
<FunctionComponent name="name">App</FunctionComponent>
</custom-div>
); // 这里的name属性就是一个必传的属性,如果没有传会有类型报错
}
上面在 Typescript 中自定义 JSX 类型的几个关键步骤,下面我们来看看 Vue3 的 jsx-runtime 文件长什么样,有上面的了解之后,再来看这个文件就很好理解了:
/* eslint-disable @typescript-eslint/prefer-ts-expect-error */
import type { NativeElements, ReservedProps, VNode } from "@vue/runtime-dom";
export namespace JSX {
export interface Element extends VNode {}
export interface ElementClass {
$props: {}; // Vue组件实例中都会有一个$props属性,包含了props的类型定义
}
export interface ElementAttributesProperty {
$props: {};
}
// NativeElement包括了所有浏览器原生支持的元素
export interface IntrinsicElements extends NativeElements {
// allow arbitrary elements
// @ts-ignore suppress ts:2374 = Duplicate string index signature.
[name: string]: any;
}
// IntrinsicAttributes接口定义了自定义元素中的一些通用属性,比如Vue中的key和ref
export interface IntrinsicAttributes extends ReservedProps {}
}
jsx
上面完成了 JSX 类型相关的定义,除了类型以外,另一个很重要的内容就是生成 JSX 运行时的代码,再看上面的例子:
class ClassComponent {
props: {
name: string
},
render() {
return <custom-div name="name">class component</custom-div>;
}
}
function FunctionComponent(props: { name: string }) {
return <custom-div>function component</custom-div>;
}
function App() {
return (
<custom-div>
<ClassComponent name="name">App</ClassComponent>
<FunctionComponent name="name">App</FunctionComponent>
</custom-div>
);
}
这段代码最终会被 Typescript 编译成下面这样:
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const jsx_runtime_1 = require("jsx/jsx-runtime");
class ClassComponent {
props;
render() {
return (0, jsx_runtime_1.jsx)("custom-div", {
children: "class component",
});
}
}
function FunctionComponent(props) {
return (0, jsx_runtime_1.jsx)("custom-div", {
children: "function component",
});
}
function App() {
return (0, jsx_runtime_1.jsxs)("custom-div", {
children: [
(0, jsx_runtime_1.jsx)(ClassComponent, { name: "name", children: "App" }),
(0, jsx_runtime_1.jsx)(FunctionComponent, {
name: "name",
children: "App",
}),
],
});
}
我们发现最终的代码会调用从jsx/jsx-runtime中暴露的jsx和jsxs函数,所以我们还需要定义这两个运行时函数:
export function jsx(elem: any, props: any) {
const { children, ...otherProps } = props;
if (typeof elem === "string") {
return `<${elem} ${Object.keys(otherProps)
.map((key) => `${key}="${otherProps[key]}"`)
.join(" ")}>
${Array.isArray(children) ? children.join("\n") : children}
</${elem}>`;
} else if (typeof elem === "function") {
try {
return elem(props);
} catch (e) {
return new elem(props).render();
}
}
return "";
}
export const jsxs = jsx;
这里我们写了一个简单的运行时函数,返回 jsx 的字符串展示,其中jsxs是用来处理有多个子元素的情况。现在我们来运行一下App函数,最终会返回下面的结果:
<custom-div>
<custom-div> class component </custom-div>
<custom-div> function component </custom-div>
</custom-div>
以上我们就自定义了一个简单的JSX语法,如果在一些特殊场景需要我们自定义JSX语法,那么今天这些内容就能派上用场了。感谢!