棘手的 TypeScript 问题

棘手的 TypeScript 问题

Flying
2021-10-08 / 0 评论 / 123 阅读 / 正在检测是否收录...

眼下使用 TypeScript 开发 React 应用越来越流行。用 TypeScript 写业务代码可以让代码更健壮,使用 TypeScript 开发的组件、库可以享受自动完成的体验。怪不得 Vue 3 升级的一大原因就是新版对 TypeScript 支持得更好。然而 TypeScript 问题博大精深,问题也千奇百怪。本文的问题是我在使用 TypeScript是遇到的比较棘手的问题,如果你正在解决同样的问题,希望对你有点帮助

fix-bug-ts.svg

方法重载

方法重载是有顺序的,不能随意放置,不要把更通用的重载放在更具体的重载之前:

  • 代码
function fn(x: unknown): unknown;
function fn(x: HTMLElement): number;
function fn(x: HTMLInputElement): string;
const myElem: HTMLInputElement = new HTMLInputElement();
const value = fn(myElem);
  • 错误
value 的类型: unknown,不是预期的 string
  • 原因

TypeScript在解析函数调用时选择第一个匹配的重载。当前面的重载比后面的重载“更通用”时,后面的重载实际上是隐藏的,不能被调用。

  • 解决办法

对重载进行排序,将更通用的签名放在更具体的签名之后:

declare function fn(x: HTMLInputElement): string;
declare function fn(x: HTMLElement): number;
declare function fn(x: unknown): unknown;
const myElem: HTMLInputElement = new HTMLInputElement();
const value = fn(myElem);

索引签名

实际应用中,会遇到遍历一个对象键值的情况,那该如何定义对象接口类型呢?

  • 代码
interface Book {
  name: string;
  author: string;
  url: string;
}
const book: Book = {
  name: 'React Hooks Guide',
  author: 'xiaoming',
  url: 'http://www.example.com',
};

let list: JSX.Element[] = [];
for (const key in book) {
  const item = book[key];
  // 元素隐式具有 "any" 类型,因为类型为 "string" 的表达式不能用于索引类型 "Book"。
  //   在类型 "Book" 上找不到具有类型为 "string" 的参数的索引签名。
  list.push(
    <li key={key}>
      {key}: {item}
    </li>
  );
}
索引签名便于将值分配给对象,但不完全是类型安全的。它们表明无论访问哪个属性,对象都应该返回一个值。

更好的办法是遍历对象 entries 的属性来获取键值:

Object.entries(book).forEach((key, value) => {
  list.push(
    <li key={value}>
      {key}: {value}
    </li>
  );
});
  • 错误

    元素隐式具有 "any" 类型,因为类型为 "string" 的表达式不能用于索引类型 "Book"。
    在类型 "Book" 上找不到具有类型为 "string" 的参数的索引签名。
  • 原因

这里通过 for in 语法遍历一个对象 bookkey 是变量导致值 book[key] 的不确定, TypeScript 无法推断其正确类型,只好提示隐式具有 "any" 类型

  • 解决办法

使用索引签名,明确告知 Book 接口的对象 book 可以接受任何键,并在该键下返回特定类型。如下修改 Book 接口的定义:

interface Book {
  [key: string]: string;
}

元组

学习 Vue3 的组合式函数时,我自定义了一个这样的组合式函数:

  • 代码
// message.ts
import { ref } from 'vue'
export function useMessage(initValue: string) {
  const message = ref(initValue)
  function updateMessage(value: string) {
    message.value = value
  }

  return [message, updateMessage]
}

然后在 index.vue 中使用 useMessage

// index.vue
<script lang="ts" setup>
import { useMessage } from './message'
const [message, updateMessage] = useMessage('Hello')
</script>

<template>
  <button
    class="btn"
    @click="updateMessage('world')"
  >
    Change
  </button>
  <div>{{ message }}</div>
</template>

先前没使用 TypeScript,啥问题都没有,加了第 10 行的 updateMessage 函数报了下面的类型错误:

  • 错误
此表达式不可调用。
"Ref<string> | ((value: string) => void)" 类型的部分要素不可调用。
  • 原因

TypeScript 将元组类型视为比可变长度数组类型更具体。这意味着可变长度数组类型不能分配给元组类型。

这里,尽管我们作为人类可能会将返回值视为具有 [Ref<string>, (value: string) => void] 内容,但 TypeScript 推断它是更一般的 (Ref<string>| (value: string) => void)[] 数组类型:

  • 解决办法

其实这不是 Vue3 的 Bug,我没按 Vue3 的套路来,组合式函数返回值应该使用对象,而不是元组。

如果已经习惯 React Hooks,确实要使用元组方式,可以使用显式元组类型,而不是 TypeScript 推断的更一般的数组类型。

// message.ts
import { ref } from 'vue'
export function useMessage(initValue: string) {
  // ...
  return [message, updateMessage] as const
}

上述代码中,对返回值使用元组类型断言,也是可以解决问题的。

类型断言

类型断言只能够“欺骗”TypeScript 编译器,无法避免运行时的错误,反而滥用类型断言可能会导致运行时错误:

interface Cat {
    name: string;
    run(): void;
}
interface Fish {
    name: string;
    swim(): void;
}

function swim(animal: Cat | Fish) {
    (animal as Fish).swim();
}

const tom: Cat = {
    name: 'Tom',
    run() { console.log('run') }
};
swim(tom);
  • 错误

上面的例子编译时不会报错,但在运行时会报错:

Uncaught TypeError: animal.swim is not a function
  • 原因

(animal as Fish).swim() 这段代码隐藏了 animal 可能为 Cat 的情况,将 animal 直接断言为 Fish 了,而 TypeScript 编译器信任了我们的断言,故在调用 swim() 时没有编译错误。

可是 swim 函数接受的参数是 Cat | Fish,一旦传入的参数是 Cat 类型的变量,由于 Cat 上没有 swim 方法,就会导致运行时错误了。

  • 解决办法

使用类型断言时一定要格外小心,尽量避免断言后调用方法或引用深层属性,以减少不必要的运行时错误。

泛型箭头函数

编译扩展名为 tsx 的文件时,泛型箭头函数的语法与 JSX 语法冲突:

// sample.tsx
const identity = <T>(input: T) => input;
  • 错误

    JSX 元素“T”没有相应的结束标记。
  • 原因

.tsx 文件中,尝试为箭头函数编写类型参数 <T> 会导致语法错误,因为没有为开放的 T 元素提供结束标记。

  • 解决办法

可以在类型参数中添加 = unknown 约束。类型参数默认为 unknown 类型,因此这根本不会更改代码行为。它只是指示 TypeScript 读取类型参数,而不是 JSX 元素:

const identity = <T = unknown>(input: T) => input;

HOC

下面是一个高阶函数的示例:

  • 代码
const withPersistentData = (key: string) => (WrappedComponent) => {
  return class extends Component {
    state: Persistent = { data: '' };
    componentDidMount() {
      const data = localStorage.getItem(key);
      this.setState({ data });
    }
    render() {
      return (
        <WrappedComponent
          data={this.state.data}
          {...this.props}
        />
      );
    }
  };
};

const App = (props: Persistent) => {
  return <div>{props.data}</div>;
};

const app = withPersistentData('name')(App);
export default app;
  • 错误
参数“WrappedComponent”隐式具有“any”类型。
  • 原因

TypeScript 类型系统推断不出 WrappedComponent 参数的类型,需要手动添加类型注解。

  • 解决办法

WrappedComponent 参数添加 FC<State> 类型

const withPersistentData = (key: string) => (WrappedComponent: FC<State>) => {
  // ...
}

MouseEvent

  • 代码
import { useState } from 'react';
import styles from './App.module.css';
function App() {
  const [point, setPoint] = useState({ x: 0, y: 0 });

  const handleMove = (e: MouseEvent) => {
    setPoint({ x: e.clientX, y: e.clientY });
  };
  return (
    <div
      className={styles.container}
      onMouseMove={handleMove}
    >
      {point.x}, {point.y}
    </div>
  );
}

export default App;
  • 错误
不能将类型“(e: MouseEvent) => void”分配给类型“MouseEventHandler”。
  • 原因

将 HTML MouseEvent 事件类型和 React MouseEvent 事件类型搞混淆了,此处应该使用 React MouseEvent 事件类型。

  • 解决办法

从 React 包 导入 MouseEvent:

import { MouseEvent } from 'react';

RouteRecordRaw

使用 Vite vue-router 做了个文件系统功能:

const routes = [
  {
    path: '/',
    name: 'home',
    component: HomeView
  }
]
const modulesFiles = import.meta.glob('../views/**/*App.vue')
const map = new Map()
for (const module in modulesFiles) {
  // ...
  if (paths?.length === 1) {
    routes.push({
      path: '/' + path,
      name: path,
      component: modulesFiles[module],
      children: []
    })
  }
  // ...
}

结果 component报 TypeScript 类型报错。

  • 错误
不能将类型“() => Promise”分配给类型“DefineComponent<{}, {}, {}, {}, {}, ComponentOptionsMixin, ComponentOptionsMixin, {}, string, PublicProps, Readonly<ExtractPropTypes<{}>>, {}, {}>”。

DefineComponent 之类的类型匹配问题通常是没有正确使用第三方库的 TypeScript 类型造成的。

  • 原因

代码没有显式定义 routes 类型

  • 解决办法

routes 定义为 vue-router 内置的 RouteRecordRaw 类型:

const routes: RouteRecordRaw[] = [
  {
    path: '/',
    name: 'home',
    component: HomeView,
    children: []
  }
]
RouteRecordSingleViewWithChildren 还有个子类型 RouteRecordRaw ,该类型定义了 children 类型必须为 RouteRecordRaw[]

指定 routes 类型后,component数据类型也从 RawRouteComponent | null | undefined

2

评论 (0)

取消