昨天写 UserCard 组件的时候,改个 props 类型改了半小时,控制台一直飘红,最后发现是把 interface 写成 type 还想重复声明搞合并,踩了个结结实实的坑。
事情发生在我这个 Vite + React + TS 的练手项目里,目录结构就是下面这样,当时正对着 components 下的 UserCard.tsx 磨类型定义。
最开始写组件的时候很顺,定义了一个 User 接口,再套一层 UserCardProps 当组件入参,渲染名字、年龄、邮箱,一切都安安稳稳的。后来想给用户信息加个头像字段,我犯了个懒,不想动原来的 interface 代码,随手在下面又写了个同名的 User 接口,把 avatar 加了进去。
按我当时的想法,重名了肯定要报错吧?结果保存完一看,TS 啥提示都没有,组件里直接读 user.avatar 居然能通过类型检查。我当时人都懵了,同样是定义对象,换个关键字待遇差这么多?
先说好:这俩大部分时候真的没区别
说实话,最开始学 TS 的前半个月,我真没把 type 和 interface 当两回事。反正都是用来给对象定结构的,写组件 props 用哪个都能跑,定义变量也都不报错,我甚至一度以为 interface 就是 type 的对象专属写法,换了个壳而已。
就像下面这段代码,不管用 interface 还是 type,定义出来的 u1 和 u2 用起来没有任何差别,传给组件也都能正常识别。
typescript
interface User {
name: string;
age: number;
email: string;
}
type UserType = {
name: string;
age: number;
email: string;
}
const u1: User = {
name: 'xjl',
age: 18,
email: 'xjl@example.com',
}
const u2: UserType = {
name: 'xjl',
age: 18,
email: 'xjl@example.com',
}
写组件的时候也是,给 React.FC 传泛型,俩都能用。我最开始的 UserCard 组件就是用 interface 写的 props,跑起来一点问题没有。
tsx
interface User {
name: string;
age: number;
email: string;
}
interface UserCardProps {
user: User;
onEdit:(id: number) => void;
}
const UserCard:React.FC<UserCardProps> = ({user, onEdit}) => {
return (
<>
<div>{user.name}</div>
<div>{user.age}</div>
<div>{user.useremail}</div>
</>
)
}
export default UserCard;
那时候我还觉得,既然都一样,那随便用就行了。直到踩了声明合并的坑,我才意识到事情没这么简单。
坑 1:同名会不会打架?声明合并是真的不一样
就是开头说的那件事,我想给 User 加字段,拆成了两个同名 interface,结果 TS 悄咪咪把它们合并了。我不死心,把 interface 换成 type 试了一下,当场就红了一大片,提示 "标识符重复"。
typescript
interface Animal {
name: string;
}
// 接口属性可以分多次约束,最后会自动合并
interface Animal {
age: number;
}
const dog: Animal = {
name: '旺财',
age: 3,
}
上面这段是完全合法的,两个 Animal 接口会被合并成一个,同时拥有 name 和 age 属性。但如果换成 type,第二行直接就报错了。
typescript
type AnimalType = { name: string }
// 报错:标识符"AnimalType"重复。
// type AnimalType = { age: number }
后来我去翻了文档才明白,interface 从设计上就是 "开放的",它的本职工作就是描述一个对象的结构,允许多次声明、逐步补充属性,这叫声明合并。而 type 本质是 "类型别名",相当于给一个类型起了个新名字,就跟 const 声明变量一样,同一个名字当然不能声明两次。
注意:只有 interface 支持声明合并,同名的 type 会直接报重复定义错误。如果你要扩展第三方库的类型、给全局对象加属性,用 interface 合并是常规操作;但自己写业务类型时,不建议把一个接口拆得到处都是,后期找属性能找疯。
坑 2:想扩展类型?写法完全不是一回事
既然说到给类型加字段,就绕不开 "继承 / 扩展" 这个话题。最开始我以为只有 interface 能继承,type 只能孤零零定义,后来才发现 type 也能做到类似的事,只是写法和思路完全不一样。
interface 用的是 extends 关键字,就是传统面向对象里 "继承" 的那套思路,子接口继承父接口的所有属性,再加上自己的。
typescript
interface Person {
name: string;
}
interface Employee extends Person {
age: number;
}
const e1: Employee = {
name: 'xjl',
age: 18,
}
而 type 没有 extends,它用的是交叉类型 &,意思是把两个类型的属性 "交叉" 到一起,取并集。
typescript
type PersonType = { name: string }
type EmployeeType = PersonType & { age: number }
const e2: EmployeeType = {
name: 'xjl',
age: 18,
}
说实话,我一开始觉得这不就是写法不一样吗,效果都差不多。后来踩了个属性冲突的小坑才知道,俩玩意儿还真不是一回事。
比如两个类型里有同名但不同类型的属性,interface extends 的时候会直接报错,告诉你属性类型冲突;而 type 用交叉类型的话,会把两个类型合并成 never,不会立刻报错,直到你用的时候才发现不对劲。当然了,正常写业务代码很少会碰到这种情况,知道有这么个区别就行。
坑 3:不是所有类型,interface 都能写
如果说前面的区别还只是 "写法不同、效果相近",那这一点就是本质上的能力边界了 ------interface 只能描述对象结构,type 能描述所有类型。
我刚学的时候干过一件傻事,想给 string | number 起个别名叫 ID,顺手写了个 interface ID = string | number,对着报错愣了五分钟,还以为自己语法写错了。后来才反应过来,interface 根本就干不了这个活。
typescript
// 联合类型,type 轻松搞定
type ID = string | number;
// 元组类型,type 也能写
type Point = [number, number];
// interface 写不了联合类型,硬写只能得到一个空接口
// interface ID { }
像联合类型、元组、给 string/number 这种基础类型起别名,这些都只能用 type 来做。interface 的能力范围很单一,就是描述对象、类、函数的结构,超出对象范畴的事,它都管不了。
小结论:如果你的类型不是一个 "对象结构",比如是联合类型、元组、单个基础类型的别名,直接用 type 就对了,interface 处理不了这些场景。
坑 4:函数类型,俩都能写但手感不一样
函数类型也是平时写得很多的场景,interface 和 type 都能描述函数类型,但写出来的样子差很多。
interface 描述函数用的是调用签名的写法,长得像个只有参数和返回值的方法。
typescript
interface AddFn {
(a: number, b: number): number;
}
const add1: AddFn = (a, b) => a + b;
而 type 就直接得多,直接写一个箭头函数的类型结构。
typescript
type AddType = (a: number, b: number) => number;
const add2: AddType = (a, b) => a + b;
平时写普通的函数类型,我更习惯用 type,短、直观,一眼就能看明白参数和返回值。interface 的写法一般只用在一种场景:这个函数本身还有额外的静态属性。比如一个请求函数,上面还挂着 cancel 方法,这种时候用 interface 的调用签名就更合适。
最后说句实在的:到底该用哪个
捋完这一圈,我再也不纠结 "谁更正宗" 这种问题了。TS 设计这两个东西本来就不是让你二选一的,它们各有各的适用场景,大部分时候重叠,少数时候互补。
说说我自己现在的用法,不一定标准,但写着顺手:
第一,写组件 Props、普通的对象结构,俩都行。我们团队习惯用 interface,那就跟着用 interface,也没什么毛病。如果是写工具类型、偏函数式的代码,我更倾向用 type。
第二,必须用 interface 的场景:需要声明合并的时候,比如扩展第三方库的类型、给全局 Window 加属性;还有配合 class 做面向对象编程、实现接口的时候,这是 interface 的本职工作。
第三,必须用 type 的场景:联合类型、元组、映射类型、条件类型这些高级类型玩法,还有给基础类型起别名。这些 interface 根本做不到,没得选。
说回最开始那个 UserCard 组件,最后我还是把属性合并回一个 interface 里了。拆成两个虽然能跑,但别人维护的时候找属性还要跳来跳去,纯属给自己和同事找麻烦。声明合并这个特性,留给扩展外部类型的时候用就好。
tsx
// App.tsx 里调用的时候就是这样,类型稳稳的
function App() {
const [count, setCount] = useState(0)
return (
<>
<section id="center">
<UserCard
user={{name: 'xjl', age: 18, email: 'xjl@example.com'}}
onEdit={() => {}}
/>
{/* 其余代码省略 */}
</section>
</>
)
}
其实学 TS 最忌讳的就是死抠概念,非要争出个高低。我最开始就是总想着 "哪个更规范",结果真写代码的时候踩了一堆坑。不如就对着报错一个个踩,踩完了自然就懂了。
如果你也有过类似的迷惑,或者有自己的选择习惯,评论区聊两句呗,我也想看看大家平时都是怎么用的。