自定义React中JSX的Element类型

react-vue中,我定义了一种setup式的函数组件,这种组件返回的是一个render函数,因此在tsx中需要实现一种新的JSX.Element,否则会出现函数组件返回值类型冲突的问题。本文主要探究如何解决这个问题。

<!--more-->

先过一下官方文档

1. 背景

setup式组件,期望的写法是这样的:函数组件返回的是一个render方法,而不是React的函数组件返回ReactElement

const Demo = () => {
  return () => {
    return (<div> this is demo</div>)
  }
}

<Demo />

这种写法在在Webstorm会提示错误

在VSCode中会提示错误

这个提示是返回值跟ReactElement不匹配。这是因为TypeScript在处理JSX的时候,如果是函数组件,会强制它的返回值可以赋值给JSX.Element。而在@types/react中,定义的JSX.Element

declare global {
  namespace JSX {
    interface Element extends React.ReactElement<any, any> { }
    interface ElementClass extends React.Component<any> {
        render(): React.ReactNode;
    }
  }
}

这就是导致这个错误提示的原因,我们要解决的也是这个问题。

2. 强制类型转换返回值

一种临时的处理办法是修改ReactElement的定义

interface ReactElement<P = any, T extends string | JSXElementConstructor<any> = string | JSXElementConstructor<any>> {
  // type: T;
  // props: P;
  // key: Key | null;
}

虽然问题解决了,但看起来就不太靠谱。

那试试强制类型转换,通过as

function Demo({value = 0}: CountProps) {
  const render = () => {
    return (<div> this is demo</div>)
  }

  return render as ReactElement
}

会提示一个警告

还要加个unknown

function Demo({value = 0}: CountProps) {
  const render = () => {
    return (<div> this is demo</div>)
  }

  return render as unknown as ReactElement
}

这个看起来是最简单的方式,但问题在于每个函数组件都要加一行类型转换,对于某些代码洁癖的人来说是不可接受的,因此需要另找出路。

3. 扩展类型定义

我们需要从TypeScript在JSX中的检测机制出发,找到一种比较优雅的解决办法。

3.1. 扩展React FC定义

我们能不能修改FC的定义,让其返回值支持一个函数呢?

type FC<P = {}> = FunctionComponent<P>;

interface CustomFunctionComponent {
  (): ReactElement<any, any> | null
}

interface FunctionComponent<P = {}> {
  // 支持返回新的方法
  (props: P, context?: any): ReactElement<any, any> | CustomFunctionComponent | null;

  propTypes?: WeakValidationMap<P> | undefined;
  contextTypes?: ValidationMap<any> | undefined;
  defaultProps?: Partial<P> | undefined;
  displayName?: string | undefined;
}

其中CustomFunctionComponent就是我们定义的新的返回值类型。但遗憾的是,加上之后仍然会报错

很显然我们的CustomFunctionComponent类型无法通过JSX.Element类型的校验。

结论:无法通过修改FC函数组件的签名来通过校验。

3.2. ElementClass工厂函数组件

既然TypeScript要校验的是返回值的类型,那只有扩展JSX.Element的类型了。

TypeScript除了支持函数组件,也支持类组件的校验。其中类组件的类型又分为了两种

  • class
  • factory工厂函数
// ES6类类型
class MyComponent {
    render() {}
}
// 工厂函数类型
function MyFactoryFunction() {
    return {
      render: () => {
      }
    }
}

除了前者可以写在JSX中之外,后面这种也可以写在JSX中(涨姿势了!!)

JSX标签是对应类的实例类型,元素的实例类型必须赋值给JSX.ElementClass或抛出一个错误,这也是TypeScript要校验的地方

declare namespace JSX {
    interface ElementClass {
        render: any;
    }
}

function MyFactoryFunction() {
    return { render: () => {} }
}
function NotAValidFactoryFunction() {
    return {};
}

<MyFactoryFunction /> // 正常
<NotAValidFactoryFunction /> // 错误

由于在@types/react中重新定义了JSX.Element

declare global {
  namespace JSX {
    interface Element extends React.ReactElement<any, any> {
    }
  }
}

因此返回的对象还需要typepropskey这三个属性,因此一个工厂函数的组件,其结构大概如下所示

declare namespace JSX {
  interface ElementClass {
    render(): () => any;
  }
}

function MyFactoryFunction(){

  return {
    key: '',
    type: '',
    props: {},
    render: () => {
      return (<div>hello </div>)
    }
  }
}

<MyFactoryFunction/>; // 正确

但是这需要修改组件的编写方式,跟预期的写法有出入,且额外增加了无用的typepropskey这三个字段满足类型检测,只能跟强制类型转换一样,做为备用方案。

结论:可以通过修改工厂函数的返回类型来定义render方法,但需要修改数据结构。

3.3. 扩展ReactElement定义

既然TypeScript校验的是函数组件的返回值类型是否为ReactElement,我们是不是只要扩展该类型,支持函数就行了?

// 原本的ReactElement定义
interface ReactElementRaw<P = any, T extends string | JSXElementConstructor<any> = string | JSXElementConstructor<any>> {
  type: T;
  props: P;
  key: Key | null;
}

interface CustomFunctionComponent {
  (): ReactElement<any, any> | null
}

// 替换原本的ReactElement定义
type  ReactElement<P,T> = ReactElementRaw<P,T> | CustomFunctionComponent

测试看看,在Webstrom中

在VSCode中

bingo!这就是我想要的效果。

那么现在只剩一个问题,如何在不修改@types/react源码的情况下扩展ReactElement呢?

一种可能是方式是重写namespace,

import {ReactElement as ReactElementRaw} from 'react'

declare global {
  interface CustomFunctionComponent {
    (): ReactElementRaw<any, any> | null
  }

  namespace React {
    // 重新定义ReactElement,会提示定义重复
    type ReactElement = ReactElementRaw | CustomFunctionComponent
  }

  namespace JSX {
    // 同理,在@types/react中也定义了`JSX.Element`,这里也会提示定义重复
    type Element = ReactElementRaw | CustomFunctionComponent
  }
}

但在重定义ReactElement会提示

TS2300: Duplicate identifier 'ReactElement'.

目前TypeScript还不支持强制覆盖类型声明,参考这个issue的讨论。

找了半天,也没有找到合适的方法来实现类型声明覆盖的问题。也许@types/reactJSX.Element类型的定义放开出来也可以,也就是说由开发者自己指定最后的JSX.Element,遗憾的是我也没找到相关的功能。

既然如此,感觉只有参考@types/react,重新写一个声明库了。

4. 重写一个类型库

固有元素

对于HTML标签而言,需要通过JSX.IntrinsicElements来定义。

默认地,如果这个接口没有指定,会全部通过,不对固有元素进行类型检查。

如果这个接口存在,那么固有元素的名字需要在JSX.IntrinsicElements接口的属性里查找。

组件

按照上面的约定,我们扩展JSX.Element即可

因此最后的声明文件(极简版)大概是下面这个样子

// src/types/jsx-runtime.d.ts
type Key = string | number;

interface ReactElement<P = any, T = any> {
  type: T;
  props: P;
  key: Key | null;
}

interface CustomFunctionComponent {
  (): JSX.Element | null
}

declare global {
  namespace JSX {
    type Element = ReactElement | CustomFunctionComponent

    interface ElementClass {
      (prop: any): any
    }

    interface IntrinsicElements {
      [prop: string]: any
    }
  }
}

export default {}

声明文件写好了,怎么在项目中引用呢?配置一下tsconfig.json

// 其他配置省略
{
  "compilerOptions": {
    "jsx": "react-jsx",
    "paths": {
      "react/jsx-runtime": [
        "./src/types/jsx-runtime.d.ts"
      ]
    }
  },
}

解释一下,当compilerOptions.jsx配置为react-jsx时,输出的结果为

import { jsx as _jsx } from "react/jsx-runtime";
import React from 'react';
export const HelloWorld = () => _jsx("h1", { children: "Hello world" });

详情可参考相关文档

因此,只需要将react/jsx-runtime这个模块映射到我们自己的模块即可,这就是compilerOptions.paths的作用。

现在,我们有了一个极简版的类型库,并且正常地在项目中运行起来了!

大功告成!

5. 小结

本文主要整理了如何扩展ReactElement类型声明的一些方法,用于支持react-vue中setup返回值类似的写法。

最后通过不太优雅的方式完成了目标:重写一个简易版的类型库,然后通过paths修改react/jsx-runtime达到类型校验的目的。

期间阅读了@types/react相关的源码,并了解了如何实现自定义JSX的一些知识点,还是有点意思。

最重要的是,可以开始使用react-vue来重构博客了哈哈~