好奇一下如何在 Typescript 中自定义 JSX 类型和解析方法

598 阅读5分钟

好奇一下如何在 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中暴露的jsxjsxs函数,所以我们还需要定义这两个运行时函数:

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语法,那么今天这些内容就能派上用场了。感谢!