React/TypeScript 中的默认属性
【免责声明:我的开发经验相当丰富,但我才开始接触 TypeScript,大概三周前吧。所以如果这篇文章里有什么地方写错了,欢迎在评论区指出我的愚蠢之处。】
我刚刚遇到了一件非常……奇怪的事情。这是那种编程时会让你停下来,然后说:“等等……这不可能吧??? ”的事情。它与在 React/TypeScript 中实现组件 props 的默认值有关。
准备工作
我们团队刚刚启动了一个全新的“绿地”项目,将使用 React 编写。(太好了!那可是我的专长。)具体来说,它将使用TypeScript和 React。(嗯……好吧,看来我得好好学学了。)我一直很想尝试一下 TypeScript 项目,所以就迫不及待地投入其中。但是,在过去一周左右的时间里,发生了一件让我措手不及的事情。
为了说明这个问题,我将把一个普通的 JS 组件转换成 TS 组件。我的 JS 组件的存根代码如下所示:
export default function MyJSComponent(props) {
return (
<>
Here is MyJSComponent:<br/>
{props.children}
</>
);
}
MyComponent.propTypes = {
requiredString: PropTypes.string.isRequired,
requiredNumber: PropTypes.number.isRequired,
optionalBoolean: PropTypes.bool,
optionalString: PropTypes.string,
optionalNumber: PropTypes.number,
};
MyComponent.defaultProps = {
optionalBoolean: true,
optionalString: 'yo',
optionalNumber: 42,
};
这里没什么特别的。这是一个极其简单的组件,最多可以接受 5 个 props,其中 2 个是必需的。对于 3 个可选 props,会分配默认值。如果组件包裹了其他内容,则该内容将使用 `<div>` 标签进行渲染props.children。这基本上就是React 101……
那么,让我们开始将其转换为 TypeScript 吧。在 TypeScript 中,我们可以直接在函数签名中推断数据类型。而且,就像在 JavaScript 中一样,我们也可以在函数签名中为可选参数提供默认值。所以它看起来可能像这样:
export default function MyTSComponent(
requiredString: string,
requiredNumber: number,
optionalBoolean: boolean = true,
optionalString: string = 'yo',
optionalNumber: number = 42,
) {
return (
<>
Here is MyComponent:<br/>
{props.children}
</>
);
}
但是……这样做行不通,对吧?这在两个关键层面上都行不通:
-
当 React 调用组件时,它不会将 props 作为参数数组传递给组件,而是将它们放在一个单独的对象中——即 `
propsprops` 对象。因此,TS 会对上面的代码报错,因为它会发现该props对象与字符串requiredString类型不匹配。 -
上述代码破坏了 React 的标准约定,即无法调用 `Render` 方法
props.children。我们没有定义任何参数props,因此,没有props.children`Render` 对象可供渲染。
换句话说,上述方法在编写“常规”TS函数时非常有效。但它不适用于TS/React组件。我们需要考虑所有props都会作为一个对象传递给组件这一事实。
一种方法是修改你的tsconfig.json配置,禁用strict模式并允许隐式any类型。代码如下:
export default function MyTSComponent(props) {
return (
<>
Here is MyComponent:<br/>
{props.children}
</>
);
}
如果所有配置都被禁用/放宽,上面的代码确实可以运行/编译。但是,如果你解决 TypeScript 问题的方案是禁用 TypeScript 的功能,那么……就不要使用 TypeScript。
如果你解决任何编程语言问题的方法是关闭strict模式或放宽核心配置结构……那么,可以肯定地说,本文或整个网站的内容都对你没有任何帮助。
假设您不赞成禁用 TypeScript 的核心功能,下一步就是弄清楚如何让 TypeScript “接受”该props对象。换句话说,我们需要明确定义该对象包含 props哪些内容。
行内类型提示
我认为,在 TypeScript 中,只要有可能,最好直接在函数签名中定义数据类型。这样效率更高,也方便其他开发者理解。既然我们知道必须明确定义props传入的对象,那么或许我们可以这样做?
export default function MyTSComponent(props: {
requiredString: string,
requiredNumber: number,
optionalBoolean: boolean = true,
optionalString: string = 'yo',
optionalNumber: number = 42,
children: JSX.Element,
}) {
return (
<>
Here is MyComponent:<br/>
{props.children}
</>
);
}
但是……这行不通,对吧?如果你在 IDE 里尝试输入这段代码,你会发现它大部分情况下都能正常工作——直到你尝试为可选属性定义默认值的时候。(而且,即使默认值有效,手动定义也让人觉得props.children……太麻烦了。)
接口
在我看来,接口是 TypeScript 处理这类情况的“默认”方式。有了好的接口,你就可以明确地为 React 传统props对象中所有预期的值指定类型。经过多次尝试不同的配置,我最终得到了以下方案:
interface Props extends PropsWithChildren<any>{
requiredString: string,
requiredNumber: number,
optionalBoolean?: boolean,
optionalString?: string,
optionalNumber?: number,
}
export default function MyTSComponent({
requiredString,
requiredNumber,
optionalBoolean = true,
optionalString = 'yo',
optionalNumber = 42,
children,
}: Props) {
return (
<>
Here is MyComponent:<br/>
{children}
</>
);
}
与上面提到的其他尝试不同,这种方法确实有效。React 知道哪些值是必需的,哪些是可选的。TypeScript 也理解每个参数的类型。但恕我直言,这种方法仍然存在一些问题。
-
属性的完整列表需要列出两次——一次在接口中,一次在函数签名中。这是必要的,因为如果我们忽略了
requiredString在接口中列出某个属性,TypeScript 就无法确定该属性的类型。如果我们忽略了在函数签名中列出某个属性requiredString,那么该属性在函数内部将无法访问。 -
我们必须把方法写进
children函数签名里。对于一个资深的 React 用户来说,这感觉……很不对劲。这就像必须先定义console.log()方法才能使用它一样。在 React 里,children方法应该是“免费”提供的。 -
说到 React 的约定,对象解构彻底颠覆了 React 中几乎普遍存在的引用方式
props.foo。props.children对某些人来说这可能无关紧要,但对我而言却影响巨大。当我梳理组件的逻辑时,我非常希望能够清晰地表明某个变量是以 prop 的形式传递给组件的。一旦将 prop 从其原始对象中解构出来,这种清晰的作用域就荡然无存了。
默认属性
你可能会想,“如果想要默认属性值,为什么不直接使用内置功能呢defaultProps?” 我确实研究过这个问题。它看起来会是这样:
interface Props extends PropsWithChildren<any>{
requiredString: string,
requiredNumber: number,
optionalBoolean?: boolean,
optionalString?: string,
optionalNumber?: number,
}
const defaultProps: Props = {
requiredString: '',
requiredNumber: 0,
optionalBoolean: true,
optionalString: 'default',
optionalNumber: 42,
}
const MyTSComponent: React.FC<Props> = (props) => {
console.log(props);
return (
<>
Here is MyComponent:<br/>
{props.children}
</>
);
};
MyTSComponent.defaultProps = defaultProps;
export default MyTSComponent;
这款产品有很多优点。它保留了传统props约定,无需显式定义props.children,而且函数签名简洁明了。
这种方法我不喜欢的一点是,除非我在定义中defaultProps 为必需的属性定义默认值,否则它似乎无法正常工作。如果我从定义中移除这些requiredString默认requiredNumber值defaultProps,TypeScript 就会报错。不过,这其实不算什么大问题。
所以文章到此就结束了吗?这就是 React/TS 中默认 props 的“真正”解决方案吗?嗯……并非如此。
我刚开始研究这种模式,就发现现在正大力推动弃用 defaultProps函数式组件。
鉴于我上面提到的问题,我真的不明白为什么有人会想要弃用defaultProps函数式组件。他们会说“函数签名中已经处理了默认值”。嗯……不,并没有(至少没有以一种能够正确兼容 React 对象的方式props)。
不管背后的理由多么牵强,这项弃用似乎真的要发生了。于是,我长叹一声,继续寻找其他解决方案。
我的天啊?!时刻
说实话,到这儿我真的开始有点恼火了。我只是想用 React/JS 做一个五分钟的教程。当你刚开始用纯 JavaScript 写 React 的时候,几分钟就能搞清楚怎么给可选属性设置默认值。然而,在 React/TS 里,这个看似简单的操作却需要费尽周折。这怎么可能呢?!
想象一下,你去另一个国家旅行——那里的语言和你的母语非常相似。在那里,你问导游:“用你们的语言,‘谢谢’怎么说?”导游给你指了十几个网页,每个网页都解释了几种表达“谢谢”的方法——但没有一个确切的答案。最后,导游说:“嗯,在我们这种语言里,其实没有简单的‘谢谢’表达方式。”
什么???
我并不是要从 JavaScript 迁移到 Objective-C,或者从 JavaScript 迁移到 C++。我只是从 React/JS 迁移到 React/TS。而且,我尝试做的事情应该非常简单。然而……我却花了无数个小时来解决这个最基本的问题。
然而,我还是继续前进。尽管我觉得这个问题很荒谬,但这并不能帮助我解决问题。
功能内处理
这时,我开始思考提供默认值的“其他”方法。于是我考虑在函数内部应用默认值。代码如下:
interface Props extends PropsWithChildren<any>{
requiredString: string,
requiredNumber: number,
optionalBoolean?: boolean,
optionalString?: string,
optionalNumber?: number,
}
export default function MyTSComponent(props: Props) {
props.optionalBoolean = props.optionalBoolean !== undefined ? props.optionalBoolean : true;
props.optionalString = props.optionalString !== undefined ? props.optionalString : 'yo';
props.optionalNumber = props.optionalNumber !== undefined ? props.optionalNumber : 42;
console.log(props);
return (
<>
Here is MyComponent:<br/>
{props.children}
</>
);
}
这段代码不会抛出任何 TypeScript 代码检查错误。但是,它无法运行,因为 React 会报错说该props对象不可扩展。为了解决这个问题,我们可以使用我在之前的文章中提到的props一个函数进行深度克隆。cloneObject()
【是是是——我明白。props仅仅为了手动添加默认值而克隆文件感觉有点……不太正规。但我只是在这里概述一下我的思路。】
因此,加上一行用于克隆props对象的代码后,代码如下所示:
interface Props extends PropsWithChildren<any>{
requiredString: string,
requiredNumber: number,
optionalBoolean?: boolean,
optionalString?: string,
optionalNumber?: number,
}
export default function MyTSComponent(props: Props) {
props = cloneObject(props);
props.optionalBoolean = props.optionalBoolean !== undefined ? props.optionalBoolean : true;
props.optionalString = props.optionalString !== undefined ? props.optionalString : 'yo';
props.optionalNumber = props.optionalNumber !== undefined ? props.optionalNumber : 42;
console.log(props);
return (
<>
Here is MyComponent:<br/>
{props.children}
</>
);
}
这种方法……奏效了。它能编译通过。它保留了传统的props对象,以及其他一些东西props.children。在一两天的时间里,我真的以为这就是答案。
然后我开始注意到一些烦心事……
虽然上面的代码确实“运行正常”,但当我在函数式组件内部添加函数时,我发现情况开始变得不稳定。请看以下示例:
interface Props extends PropsWithChildren<any>{
requiredString: string,
requiredNumber: number,
optionalBoolean?: boolean,
optionalString?: string,
optionalNumber?: number,
}
export default function MyTSComponent(props: Props) {
props = cloneObject(props);
props.optionalBoolean = props.optionalBoolean !== undefined ? props.optionalBoolean : true;
props.optionalString = props.optionalString !== undefined ? props.optionalString : 'yo';
props.optionalNumber = props.optionalNumber !== undefined ? props.optionalNumber : 42;
console.log(props);
const getLetterArrayFromOptionalString = (): Array<string> => {
return props.optionalString.split('');
};
return (
<>
Here is MyComponent:<br/>
{props.children}
</>
);
}
'yo'我已经设置了默认值props.optionalString。在函数内部getLetterArrayFromOptionalString(),我试图split()将该字符串转换为字母数组。但是 TypeScript 无法编译这段代码。它提示props.optionalString对象可能未定义——即使我在函数顶部已经明确定义了默认值。
为什么会这样?这是因为 TypeScript 认为该函数是在组件挂载时绑定的。而组件挂载时,还没有设置默认值props.optionalString。即使设置了默认值,该函数也不会被调用,因为在添加默认值之前getLetterArrayFromOptionalString()它都不会被调用。TypeScript 无法完全理解这一点。props.optionalString
TS 无法处理此split()函数,因为它需要一个类型string | RexExp。但props.optionalString类型是:string | undefined。
| undefined我们的类型中这个参数是从哪里来的props.optionalString?它是 TypeScript 动态添加的,因为该optionalString参数被定义为可选参数(即,在?其后附加了参数)。
当你?向接口属性添加值时,TypeScript 会将其| undefined作为类型定义的一部分进行附加。这看似是件好事,但之后可能会带来麻烦,因为 TypeScript 会期望你编写大量能够容忍不同undefined值的代码——即使你知道你已经手动为该变量设置了值,并且该值永远不会为空undefined。
唉……我又得从头再来。
终于——解决方案
目前来看,我觉得我找到了一个可行的解决方案。(直到我遇到其他极端情况导致整个系统崩溃为止……)它看起来是这样的:
//all.props.requires.ts
export type AllPropsRequired<Object> = {
[Property in keyof Object]-?: Object[Property];
};
// my.ts.component.tsx
interface Props extends PropsWithChildren<any>{
requiredString: string,
requiredNumber: number,
optionalBoolean?: boolean,
optionalString?: string,
optionalNumber?: number,
}
export default function MyTSComponent(props: Props) {
const args: AllPropsRequired<Props> = {
...props,
optionalBoolean: props.optionalBoolean !== undefined ? props.optionalBoolean : true,
optionalString: props.optionalString !== undefined ? props.optionalString : 'yo',
optionalNumber: props.optionalNumber !== undefined ? props.optionalNumber : 42,
};
console.log(args);
const getLetterArrayFromOptionalString = (): Array<string> => {
return args.optionalString.split('');
};
return (
<>
Here is MyComponent:<br/>
{props.children}
</>
);
}
那么,这里究竟发生了什么?
首先映入眼帘的是AllPropsRequired类型。在 TypeScript 中,它被称为partial(部分类型)。这里我就不详细讲解了。简单来说,它AllPropsRequired是一个会将其他泛型接口的所有属性都设为必需类型的类型。这一点稍后会很重要……
界面Props相当“标准”——没什么特别之处。
在内部MyTSComponent,我做的第一件事是基于该对象创建一个新对象,props并将其强制转换为该类型AllPropsRequired。换句话说,在这个args对象中,我移除了?每个属性上的可选指示符。
我这样做是因为每个属性要么已经传入了一个值(如果是必需的),要么会添加一个默认值。因此,所有属性都不undefined应该为空,我们也不希望属性的类型反映出它可能是空的undefined。
在定义中args,我做的第一件事就是展开...props对象。这样做是为了避免手动写出对象中的每个必需属性。我只需要写出可选属性,而展开对象...props就能做到这一点。
然后,对于每个可选属性,我都会检查是否传入了任何值。如果没有传入任何值(即,如果该属性为空undefined),则将其值设置为默认值。
这种方法保留了我的props.children功能——因为我没有对原始对象做任何修改或销毁props。但在组件的其他任何需要引用该对象的地方props,我都会直接使用该args对象。
这段代码可以编译,并且这一行代码:
return args.optionalString.split('');
运行正常。它没有抛出任何错误,因为对象中没有类型args,它只有类型。optionalStringstring | undefinedstring
不应该这么难
也许我漏掉了什么。也许过个一两周,我就会意识到这整个过程有多么愚蠢。评论里会有人说:“你为什么不直接用呢setDefaultProps()?”然后我就会觉得自己浪费了好几天时间试图重新发明轮子真是太傻了。
但我知道我并非孤军奋战。如果你在谷歌上搜索“typescript default props 函数式组件”之类的关键词,你会找到很多文章和 Stack Overflow 上的问题,它们都在(尝试)解决这个问题。而且它们都遇到了同样的局限性。这感觉就像是一个……疏忽。
别跟我提弃用 defaultProps函数式组件这件事,我觉得太荒谬了。或许并非如此——我也不知道。可能只是我脑子里有些地方没理解透彻……
【注:本文发布几天后,我想出了一个改进/修订的方法。这将在本系列的第二部分中重点介绍……】
文章来源:https://dev.to/bytebodger/default-props-in-react-typescript-2o5o


