自定义React中JSX的Element类型
在react-vue中,我定义了一种setup式的函数组件,这种组件返回的是一个render函数,因此在tsx
中需要实现一种新的JSX.Element,否则会出现函数组件返回值类型冲突的问题。本文主要探究如何解决这个问题。
先过一下官方文档
- JSX - TypeScript文档,先看一下文档,了解TypeScript是如何解析JSX的
- 深入 JSX - React文档
- 区分JSX.Element、ReactNode和ReactElement
背景
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;
}
}
}
这就是导致这个错误提示的原因,我们要解决的也是这个问题。
强制类型转换返回值
一种临时的处理办法是修改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
}
这个看起来是最简单的方式,但问题在于每个函数组件都要加一行类型转换,对于某些代码洁癖的人来说是不可接受的,因此需要另找出路。
扩展类型定义
我们需要从TypeScript在JSX中的检测机制出发,找到一种比较优雅的解决办法。
扩展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
函数组件的签名来通过校验。
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> {
}
}
}
因此返回的对象还需要type
、props
和key
这三个属性,因此一个工厂函数的组件,其结构大概如下所示
declare namespace JSX {
interface ElementClass {
render(): () => any;
}
}
function MyFactoryFunction(){
return {
key: '',
type: '',
props: {},
render: () => {
return (<div>hello </div>)
}
}
}
<MyFactoryFunction/>; // 正确
但是这需要修改组件的编写方式,跟预期的写法有出入,且额外增加了无用的type
、props
和key
这三个字段满足类型检测,只能跟强制类型转换一样,做为备用方案。
结论:可以通过修改工厂函数的返回类型来定义render方法,但需要修改数据结构。
扩展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/react
将JSX.Element
类型的定义放开出来也可以,也就是说由开发者自己指定最后的JSX.Element
,遗憾的是我也没找到相关的功能。
既然如此,感觉只有参考@types/react
,重新写一个声明库了。
重写一个类型库
固有元素
对于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
的作用。
现在,我们有了一个极简版的类型库,并且正常地在项目中运行起来了!
大功告成!
小结
本文主要整理了如何扩展ReactElement
类型声明的一些方法,用于支持react-vue中setup返回值类似的写法。
最后通过不太优雅的方式完成了目标:重写一个简易版的类型库,然后通过paths
修改react/jsx-runtime
达到类型校验的目的。
期间阅读了@types/react
相关的源码,并了解了如何实现自定义JSX
的一些知识点,还是有点意思。
最重要的是,可以开始使用react-vue
来重构博客了哈哈~
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。