女王控的博客

TypeScript 入门学习

背景知识

React 中的用法

组件声明

类组件

类组件的定义形式有两种:React.Component<P, S={}>React.PureComponent<P, S={} SS={}>,它们都是泛型接口,接收两个参数,第一个是 props 类型的定义,第二个是 state 类型的定义,这两个参数都不是必须的,没有时可以省略:

tsx 复制代码
interface IProps {
   name: string;
}

interface IState {
   count: number;
}

class App extends React.Component<IProps, IState> {
   state = {
      count: 0
   };

   render() {
      return (
         <div>
            {this.state.count}
            {this.props.name}
         </div>
      );
   }
}

export default App;

React.PureComponent<P, S={} SS={}> 也是差不多的:

tsx 复制代码
class App extends React.PureComponent<IProps, IState> {}

如果定义时候我们不知道组件的 props 的类型,只有在调用时才知道组件类型,该怎么办呢?这时泛型就发挥作用了:

tsx 复制代码
// 定义组件
class MyComponent<P> extends React.Component<P> {
   internalProp: P;
   constructor(props: P) {
      super(props);
      this.internalProp = props;
   }
   render() {
      return <span>hello world</span>;
   }
}

// 使用组件
type IProps = { name: string; age: number };

<MyComponent<IProps> name='React' age={18} />; // Success
<MyComponent<IProps> name='TypeScript' age='hello' />; // Error

函数组件

tsx 复制代码
interface IProps {
   name: string;
}

const App = (props: IProps) => {
   const { name } = props;

   return (
      <div className='App'>
         <h1>hello world</h1>
         <h2>{name}</h2>
      </div>
   );
};

export default App;

除此之外,函数类型还可以使用 React.FunctionComponent<P={}> 来定义,也可以使用其简写React.FC<P={}>,两者效果是一样的。它是一个泛型接口,可以接收一个参数,参数表示 props 的类型,这个参数不是必须的。它们就相当于这样:

tsx 复制代码
type React.FC<P = {}> = React.FunctionComponent<P>

最终的定义形式如下:

tsx 复制代码
interface IProps {
   name: string;
}

const App: React.FC<IProps> = (props) => {
   const { name } = props;
   return (
      <div className='App'>
         <h1>hello world</h1>
         <h2>{name}</h2>
      </div>
   );
};

export default App;

当使用这种形式来定义函数组件时,props 中默认会带有 children 属性,它表示该组件在调用时,其内部的元素

使用 React.FC 声明函数组件和普通声明的区别如下:

  • React.FC 显式地定义了返回类型,其他方式是隐式推导的;
  • React.FC 对静态属性:displayName、propTypes、defaultProps 提供了类型检查和自动补全;
  • React.FC 为 children 提供了隐式的类型(ReactElement | null)。

那如果我们在定义组件时不知道 props 的类型,只有调用时才知道,那就还是用泛型来定义 props 的类型。对于使用 function 定义的函数组件:

tsx 复制代码
// 定义组件
function MyComponent<P>(props: P) {
   return <span>{props}</span>;
}

// 使用组件
type IProps = { name: string; age: number };

<MyComponent<IProps> name='React' age={18} />; // Success
<MyComponent<IProps> name='TypeScript' age='hello' />; // Error

如果使用箭头函数定义的函数组件,直接这样调用时错误的:

tsx 复制代码
const MyComponent = <P>(props: P) {
  return (
   <span>
     {props}
    </span>
  );
}

必须使用 extends 关键字来定义泛型参数才能被成功解析:

tsx 复制代码
const MyComponent = <P extends any>(props: P) {
  return (
   <span>
     {props}
    </span>
  );
}

React 内置类型

JSX.Element

先来看看 JSX.Element 类型的声明:

ts 复制代码
declare global {
   namespace JSX {
      interface Element extends React.ReactElement<any, any> {}
   }
}

可以看到,JSX.Element 是 ReactElement 的子类型,它没有增加属性,两者是等价的。也就是说两种类型的变量可以相互赋值。​

JSX.Element 可以通过执行 React.createElement 或是转译 JSX 获得:

tsx 复制代码
const jsx = <div>hello</div>;
const ele = React.createElement('div', null, 'hello');

React.ReactElement

React 的类型声明文件中提供了 React.ReactElement<T>,它可以让我们通过传入 <T> 来注解类组件的实例化,它在声明文件中的定义如下:

ts 复制代码
interface ReactElement<P = any, T extends string | JSXElementConstructor<any> = string | JSXElementConstructor<any>> {
   type: T;
   props: P;
   key: Key | null;
}

ReactElement 是一个接口,包含 type,props,key 三个属性值。该类型的变量值只能是两种: null 和 ReactElement 实例。​

通常情况下,函数组件返回 ReactElement(JSX.Element)的值。

React.ReactNode

ReactNode 类型的声明如下:

ts 复制代码
type ReactText = string | number;
type ReactChild = ReactElement | ReactText;

interface ReactNodeArray extends Array<ReactNode> {}
type ReactFragment = {} | ReactNodeArray;
type ReactNode = ReactChild | ReactFragment | ReactPortal | boolean | null | undefined;

可以看到,ReactNode 是一个联合类型,它可以是 string、number、ReactElement、null、boolean、ReactNodeArray。由此可知。ReactElement 类型的变量可以直接赋值给 ReactNode 类型的变量,但反过来是不行的。

类组件的 render 成员函数会返回 ReactNode 类型的值:

tsx 复制代码
class MyComponent extends React.Component {
   render() {
      return <div>hello world</div>;
   }
}
// 正确
const component: React.ReactNode<MyComponent> = <MyComponent />;
// 错误
const component: React.ReactNode<MyComponent> = <OtherComponent />;

上面的代码中,给 component 变量设置了类型是 Mycomponent 类型的 react 实例,这时只能给其赋值其为 MyComponent 的实例组件。​

通常情况下,类组件通过 render() 返回 ReactNode 的值。

CSSProperties

先来看看 React 的声明文件中对 CSSProperties 的定义:

ts 复制代码
export interface CSSProperties extends CSS.Properties<string | number> {
   /**
    * The index signature was removed to enable closed typing for style
    * using CSSType. You're able to use type assertion or module augmentation
    * to add properties or an index signature of your own.
    *
    * For examples and more information, visit:
    * https://github.com/frenic/csstype#what-should-i-do-when-i-get-type-errors
    */
}

React.CSSProperties 是 React 基于 TypeScript 定义的 CSS 属性类型,可以将一个方法的返回值设置为该类型:

tsx 复制代码
import * as React from 'react';

const classNames = require('./sidebar.css');

interface Props {
   isVisible: boolean;
}

const divStyle = (props: Props): React.CSSProperties => ({
   width: props.isVisible ? '23rem' : '0rem'
});

export const SidebarComponent: React.StatelessComponent<Props> = (props) => (
   <div id='mySidenav' className={classNames.sidenav} style={divStyle(props)}>
      {props.children}
   </div>
);

这里 divStyle 组件的返回值就是 React.CSSProperties 类型。

我们还可以定义一个 CSSProperties 类型的变量:

tsx 复制代码
const divStyle: React.CSSProperties = {
   width: '11rem',
   height: '7rem',
   backgroundColor: `rgb(${props.color.red},${props.color.green}, ${props.color.blue})`
};

这个变量可以在 HTML 标签的 style 属性上使用:

tsx 复制代码
<div style={divStyle} />

在 React 的类型声明文件中,style 属性的类型如下:

ts 复制代码
style?: CSSProperties | undefined;

React Hooks

useState

默认情况下,React 会为根据设置的 state 的初始值来自动推导 state 以及更新函数的类型

如果已知 state 的类型,可以通过以下形式来自定义 state 的类型:

tsx 复制代码
const [count, setCount] = useState<number>(1);

如果初始值为 null,需要显式地声明 state 的类型:

tsx 复制代码
const [count, setCount] = useState<number | null>(null);

如果 state 是一个对象,想要初始化一个空对象,可以使用断言来处理:

tsx 复制代码
const [user, setUser] = React.useState<IUser>({} as IUser);

实际上,这里将空对象 {} 断言为 IUser 接口就是欺骗了 TypeScript 的编译器,由于后面的代码可能会依赖这个对象,所以应该在使用前及时初始化 user 的值,否则就会报错。

下面是声明文件中 useState 的定义:

ts 复制代码
function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>];
// convenience overload when first argument is omitted
/**
 * Returns a stateful value, and a function to update it.
 *
 * @version 16.8.0
 * @see https://reactjs.org/docs/hooks-reference.html#usestate
 */

function useState<S = undefined>(): [S | undefined, Dispatch<SetStateAction<S | undefined>>];
/**
 * An alternative to `useState`.
 *
 * `useReducer` is usually preferable to `useState` when you have complex state logic that involves
 * multiple sub-values. It also lets you optimize performance for components that trigger deep
 * updates because you can pass `dispatch` down instead of callbacks.
 *
 * @version 16.8.0
 * @see https://reactjs.org/docs/hooks-reference.html#usereducer
 */

可以看到,这里定义两种形式,分别是有初始值和没有初始值的形式。

useEffect

tsx 复制代码
useEffect(() => {
   const subscription = props.source.subscribe();
   return () => {
      subscription.unsubscribe();
   };
}, [props.source]);

当函数的返回值不是函数或者 effect 函数中未定义的内容时,如下:

tsx 复制代码
useEffect(() => {
   subscribe();
   return null; // Type 'null' is not assignable to type 'void | Destructor'
});

来看看 useEffect 在类型声明文件中的定义:

ts 复制代码
// Destructors are only allowed to return void.
type Destructor = () => void | { [UNDEFINED_VOID_ONLY]: never };

// NOTE: callbacks are _only_ allowed to return either void, or a destructor.
type EffectCallback = () => void | Destructor;

// (TypeScript 3.0): ReadonlyArray<unknown>
type DependencyList = ReadonlyArray<any>;

function useEffect(effect: EffectCallback, deps?: DependencyList): void;
// NOTE: this does not accept strings, but this will have to be fixed by removing strings from type Ref<T>
/**
 * `useImperativeHandle` customizes the instance value that is exposed to parent components when using
 * `ref`. As always, imperative code using refs should be avoided in most cases.
 *
 * `useImperativeHandle` should be used with `React.forwardRef`.
 *
 * @version 16.8.0
 * @see https://reactjs.org/docs/hooks-reference.html#useimperativehandle
 */

可以看到,useEffect 的第一个参数只允许返回一个函数。

useRef

当使用 useRef 时,我们可以访问一个可变的引用对象。可以将初始值传递给 useRef,它用于初始化可变 ref 对象公开的当前属性。当我们使用 useRef 时,需要给其指定类型:

tsx 复制代码
const nameInput = React.useRef<HTMLInputElement>(null);

这里给实例的类型指定为了 input 输入框类型。​

当 useRef 的初始值为 null 时,有两种创建的形式,第一种:

tsx 复制代码
const nameInput = React.useRef<HTMLInputElement>(null);
nameInput.current.innerText = 'hello world';

这种形式下,ref1.current 是只读的(read-only),所以当我们将它的 innerText 属性重新赋值时会报以下错误:

复制代码
Cannot assign to 'current' because it is a read-only property.

那该怎么将 current 属性变为动态可变得的,先来看看类型声明文件中 useRef 是如何定义的:

ts 复制代码
function useRef<T>(initialValue: T): MutableRefObject<T>;
// convenience overload for refs given as a ref prop as they typically start with a null value
/**
 * `useRef` returns a mutable ref object whose `.current` property is initialized to the passed argument
 * (`initialValue`). The returned object will persist for the full lifetime of the component.
 *
 * Note that `useRef()` is useful for more than the `ref` attribute. It’s handy for keeping any mutable
 * value around similar to how you’d use instance fields in classes.
 *
 * Usage note: if you need the result of useRef to be directly mutable, include `| null` in the type
 * of the generic argument.
 *
 * @version 16.8.0
 * @see https://reactjs.org/docs/hooks-reference.html#useref
 */

这段代码告诉我们,如果需要 useRef 的直接可变,就需要在泛型参数中包含 | null,所以这就是当初始值为 null 的第二种定义形式:

tsx 复制代码
const nameInput = React.useRef<HTMLInputElement | null>(null);

这种形式下,nameInput.current 就是可写的。不过两种类型在使用时都需要做类型检查:

tsx 复制代码
nameInput.current?.innerText = "hello world";

那么问题来了,为什么第一种写法在没有操作 current 时没有报错呢?因为 useRef 在类型定义式具有多个重载声明,第一种方式就是执行的以下函数重载:

ts 复制代码
function useRef<T>(initialValue: T | null): RefObject<T>;
// convenience overload for potentially undefined initialValue / call with 0 arguments
// has a default to stop it from defaulting to {} instead
/**
 * `useRef` returns a mutable ref object whose `.current` property is initialized to the passed argument
 * (`initialValue`). The returned object will persist for the full lifetime of the component.
 *
 * Note that `useRef()` is useful for more than the `ref` attribute. It’s handy for keeping any mutable
 * value around similar to how you’d use instance fields in classes.
 *
 * @version 16.8.0
 * @see https://reactjs.org/docs/hooks-reference.html#useref
 */

从 useRef 的声明中可以看到,function useRef 的返回值类型化是 MutableRefObject,这里面的 T 就是参数的类型 T,所以最终 nameInput 的类型就是 React.MutableRefObject。

注意,上面用到了 HTMLInputElement 类型,这是一个标签类型,这个操作就是用来访问 DOM 元素的。

useCallback

先来看看类型声明文件中对 useCallback 的定义:

ts 复制代码
function useCallback<T extends (...args: any[]) => any>(callback: T, deps: DependencyList): T;
/**
 * `useMemo` will only recompute the memoized value when one of the `deps` has changed.
 *
 * Usage note: if calling `useMemo` with a referentially stable function, also give it as the input in
 * the second argument.
 *
 * ```ts
 * function expensive () { ... }
 *
 * function Component () {
 *   const expensiveResult = useMemo(expensive, [expensive])
 *   return ...
 * }
 * ```
 *
 * @version 16.8.0
 * @see https://reactjs.org/docs/hooks-reference.html#usememo
 */

useCallback 接收一个回调函数和一个依赖数组,只有当依赖数组中的值发生变化时才会重新执行回调函数。来看一个例子:

tsx 复制代码
const add = (a: number, b: number) => a + b;

const memoizedCallback = useCallback(
   (a) => {
      add(a, b);
   },
   [b]
);

这里我们没有给回调函数中的参数 a 定义类型,所以下面的调用方式都不会报错:

tsx 复制代码
memoizedCallback('hello');
memoizedCallback(5);

尽管 add 方法的两个参数都是 number 类型,但是上述调用都能够用执行。所以为了更加严谨,我们需要给回调函数定义具体的类型:

tsx 复制代码
const memoizedCallback = useCallback(
   (a: number) => {
      add(a, b);
   },
   [b]
);

这时候如果再给回调函数传入字符串就会报错了,需要注意在使用 useCallback 时需要给回调函数的参数指定类型。

useMemo

先来看看类型声明文件中对 useMemo 的定义:

ts 复制代码
function useMemo<T>(factory: () => T, deps: DependencyList | undefined): T;
/**
 * `useDebugValue` can be used to display a label for custom hooks in React DevTools.
 *
 * NOTE: We don’t recommend adding debug values to every custom hook.
 * It’s most valuable for custom hooks that are part of shared libraries.
 *
 * @version 16.8.0
 * @see https://reactjs.org/docs/hooks-reference.html#usedebugvalue
 */

useMemo 和 useCallback 是非常类似的,但是它返回的是一个值,而不是函数。所以在定义 useMemo 时需要定义返回值的类型:

ts 复制代码
let a = 1;
setTimeout(() => {
   a += 1;
}, 1000);

const calculatedValue = useMemo<number>(() => a ** 2, [a]);

如果返回值不一致,就会报错

tsx 复制代码
const calculatedValue = useMemo<number>(() => a + 'hello', [a]);
// 类型“() => string”的参数不能赋给类型“() => number”的参数

useContext

useContext 需要提供一个上下文对象,并返回所提供的上下文的值,当提供者更新上下文对象时,引用这些上下文对象的组件就会重新渲染:

tsx 复制代码
const ColorContext = React.createContext({ color: 'green' });

const Welcome = () => {
   const { color } = useContext(ColorContext);
   return <div style={{ color }}>hello world</div>;
};

在使用 useContext 时,会自动推断出提供的上下文对象的类型,所以并不需要我们手动设置 context 的类型。当前,我们也可以使用泛型来设置 context 的类型:

tsx 复制代码
interface IColor {
   color: string;
}

const ColorContext = React.createContext<IColor>({ color: 'green' });

下面是 useContext 在类型声明文件中的定义:

ts 复制代码
function useContext<T>(context: Context<T> /*, (not public API) observedBits?: number|boolean */): T;
/**
 * Returns a stateful value, and a function to update it.
 *
 * @version 16.8.0
 * @see https://reactjs.org/docs/hooks-reference.html#usestate
 */

useReducer

有时我们需要处理一些复杂的状态,并且可能取决于之前的状态。这时候就可以使用 useReducer,它接收一个函数,这个函数会根据之前的状态来计算一个新的 state。其语法如下:

tsx 复制代码
const [state, dispatch] = useReducer(reducer, initialArg, init);

来看下面的例子:

tsx 复制代码
const reducer = (state, action) => {
   switch (action.type) {
      case 'increment':
         return { count: state.count + 1 };
      case 'decrement':
         return { count: state.count - 1 };
      default:
         throw new Error();
   }
};

const Counter = () => {
   const initialState = { count: 0 };
   const [state, dispatch] = useReducer(reducer, initialState);

   return (
      <>
         Count: {state.count}
         <button onClick={() => dispatch({ type: 'increment' })}>+</button>
         <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
      </>
   );
};

当前的状态是无法推断出来的,可以给 reducer 函数添加类型,通过给 reducer 函数定义 state 和 action 来推断 useReducer 的类型,下面来修改上面的例子:

tsx 复制代码
type ActionType = {
   type: 'increment' | 'decrement';
};

type State = { count: number };

const initialState: State = { count: 0 };
const reducer = (state: State, action: ActionType) => {
   // ...
};

这样,在 Counter 函数中就可以推断出类型。当我们视图使用一个不存在的类型时,就会报错:

ts 复制代码
dispatch({ type: 'reset' });
// Error! type '"reset"' is not assignable to type '"increment" | "decrement"'

除此之外,还可以使用泛型的形式来实现 reducer 函数的类型定义:

ts 复制代码
type ActionType = {
   type: 'increment' | 'decrement';
};

type State = { count: number };

const reducer: React.Reducer<State, ActionType> = (state, action) => {
   // ...
};

事件处理

Event 事件类型

常见的 Event 事件对象如下:

  • 剪切板事件对象:ClipboardEvent<T = Element>
  • 拖拽事件对象:DragEvent<T = Element>
  • 焦点事件对象:FocusEvent<T = Element>
  • 表单事件对象:FormEvent<T = Element>
  • Change 事件对象:ChangeEvent<T = Element>
  • 键盘事件对象:KeyboardEvent<T = Element>
  • 鼠标事件对象:MouseEvent<T = Element, E = NativeMouseEvent>
  • 触摸事件对象:TouchEvent<T = Element>
  • 滚轮事件对象:WheelEvent<T = Element>
  • 动画事件对象:AnimationEvent<T = Element>
  • 过渡事件对象:TransitionEvent<T = Element>
tsx 复制代码
type State = {
   text: string;
};

const App: React.FC = () => {
   const [text, setText] = useState<string>('');

   const onChange = (e: React.FormEvent<HTMLInputElement>): void => {
      setText(e.currentTarget.value);
   };

   return (
      <div>
         <input type='text' value={text} onChange={this.onChange} />
      </div>
   );
};

这里就给 onChange 方法的事件对象定义为了 FormEvent 类型,并且作用的对象时一个 HTMLInputElement 类型的标签(input 标签) ​ 可以来看下 MouseEvent 事件对象和 ChangeEvent 事件对象的类型声明,其他事件对象的声明形似也类似:

ts 复制代码
interface MouseEvent<T = Element, E = NativeMouseEvent> extends UIEvent<T, E> {
   altKey: boolean;
   button: number;
   buttons: number;
   clientX: number;
   clientY: number;
   ctrlKey: boolean;
   /**
    * See [DOM Level 3 Events spec](https://www.w3.org/TR/uievents-key/#keys-modifier). for a list of valid (case-sensitive) arguments to this method.
    */
   getModifierState(key: string): boolean;
   metaKey: boolean;
   movementX: number;
   movementY: number;
   pageX: number;
   pageY: number;
   relatedTarget: EventTarget | null;
   screenX: number;
   screenY: number;
   shiftKey: boolean;
}

interface ChangeEvent<T = Element> extends SyntheticEvent<T> {
   target: EventTarget & T;
}

在很多事件对象的声明文件中都可以看到 EventTarget 的身影。这是因为 DOM 的事件操作(监听和触发)都定义在 EventTarget 接口上。EventTarget 的类型声明如下:

ts 复制代码
interface EventTarget {
   addEventListener(
      type: string,
      listener: EventListenerOrEventListenerObject | null,
      options?: boolean | AddEventListenerOptions
   ): void;
   dispatchEvent(evt: Event): boolean;
   removeEventListener(
      type: string,
      listener?: EventListenerOrEventListenerObject | null,
      options?: EventListenerOptions | boolean
   ): void;
}

比如在 change 事件中,会使用的 e.target 来获取当前的值,它的的类型就是 EventTarget。来看下面的例子:

tsx 复制代码
<input onChange={(e) => onSourceChange(e)} placeholder='最多30个字' />;

const onSourceChange = (e: React.ChangeEvent<HTMLInputElement>) => {
   if (e.target.value.length > 30) {
      message.error('请长度不能超过30个字,请重新输入');
      return;
   }
   setSourceInput(e.target.value);
};

事件处理函数类型

React 也为我们提供了贴心的提供了事件处理函数的类型声明,来看看所有的事件处理函数的类型声明:

ts 复制代码
type EventHandler<E extends SyntheticEvent<any>> = { bivarianceHack(event: E): void }['bivarianceHack'];

type ReactEventHandler<T = Element> = EventHandler<SyntheticEvent<T>>;
// 剪切板事件处理函数
type ClipboardEventHandler<T = Element> = EventHandler<ClipboardEvent<T>>;
// 复合事件处理函数
type CompositionEventHandler<T = Element> = EventHandler<CompositionEvent<T>>;
// 拖拽事件处理函数
type DragEventHandler<T = Element> = EventHandler<DragEvent<T>>;
// 焦点事件处理函数
type FocusEventHandler<T = Element> = EventHandler<FocusEvent<T>>;
// 表单事件处理函数
type FormEventHandler<T = Element> = EventHandler<FormEvent<T>>;
// Change事件处理函数
type ChangeEventHandler<T = Element> = EventHandler<ChangeEvent<T>>;
// 键盘事件处理函数
type KeyboardEventHandler<T = Element> = EventHandler<KeyboardEvent<T>>;
// 鼠标事件处理函数
type MouseEventHandler<T = Element> = EventHandler<MouseEvent<T>>;
// 触屏事件处理函数
type TouchEventHandler<T = Element> = EventHandler<TouchEvent<T>>;
// 指针事件处理函数
type PointerEventHandler<T = Element> = EventHandler<PointerEvent<T>>;
// 界面事件处理函数
type UIEventHandler<T = Element> = EventHandler<UIEvent<T>>;
// 滚轮事件处理函数
type WheelEventHandler<T = Element> = EventHandler<WheelEvent<T>>;
// 动画事件处理函数
type AnimationEventHandler<T = Element> = EventHandler<AnimationEvent<T>>;
// 过渡事件处理函数
type TransitionEventHandler<T = Element> = EventHandler<TransitionEvent<T>>;

这里面的 T 的类型也都是 Element,指的是触发该事件的 HTML 标签元素的类型

EventHandler 会接收一个 E,它表示事件处理函数中 Event 对象的类型。bivarianceHack 是事件处理函数的类型定义,函数接收一个 Event 对象,并且其类型为接收到的泛型变量 E 的类型,返回值为 void。

还看上面的那个例子:

tsx 复制代码
type State = {
   text: string;
};

const App: React.FC = () => {
   const [text, setText] = useState<string>('');

   const onChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
      setText(e.currentTarget.value);
   };

   return (
      <div>
         <input type='text' value={text} onChange={this.onChange} />
      </div>
   );
};

这里给 onChange 方法定义了方法的类型,它是一个 ChangeEventHandler 的类型,并且作用的对象时一个 HTMLImnputElement 类型的标签(input 标签)。

HTML 标签类型

常见标签类型

所有的 HTML 标签的类型都被定义在 intrinsicElements 接口中,常见的标签及其类型如下:

ts 复制代码
a: HTMLAnchorElement;
body: HTMLBodyElement;
br: HTMLBRElement;
button: HTMLButtonElement;
div: HTMLDivElement;
h1: HTMLHeadingElement;
h2: HTMLHeadingElement;
h3: HTMLHeadingElement;
html: HTMLHtmlElement;
img: HTMLImageElement;
input: HTMLInputElement;
ul: HTMLUListElement;
li: HTMLLIElement;
link: HTMLLinkElement;
p: HTMLParagraphElement;
span: HTMLSpanElement;
style: HTMLStyleElement;
table: HTMLTableElement;
tbody: HTMLTableSectionElement;
video: HTMLVideoElement;
audio: HTMLAudioElement;
meta: HTMLMetaElement;
form: HTMLFormElement;

那什么时候会使用到标签类型呢,上面 Event 事件类型和事件处理函数类型中都使用到了标签的类型。上面的很多的类型都需要传入一个 ELement 类型的泛型参数,这个泛型参数就是对应的标签类型值,可以根据标签来选择对应的标签类型。这些类型都继承自 HTMLElement 类型,如果使用时对类型类型要求不高,可以直接写 HTMLELement。

比如下面的例子:

tsx 复制代码
<Button
   type='text'
   onClick={(e: React.MouseEvent<HTMLElement>) => {
      handleOperate();
      e.stopPropagation();
   }}
>
   <img src={cancelChangeIcon} alt='' />
   取消修改
</Button>

其实,在直接操作 DOM 时也会用到标签类型,虽然我们现在通常会使用框架来开发,但是有时候也避免不了直接操作 DOM。比如项目中的某一部分组件是通过 npm 来引入的其他组的组件,而在很多时候,需要动态的去个性化这个组件的样式,最直接的办法就是通过原生 JavaScript 获取到 DOM 元素,来进行样式的修改,这时候就会用到标签类型。 ​ 来看下面的例子:

ts 复制代码
document.querySelectorAll('.paper').forEach((item) => {
   const firstPageHasAddEle = (item.firstChild as HTMLDivElement).classList.contains('add-ele');

   if (firstPageHasAddEle) {
      item.removeChild(item.firstChild as ChildNode);
   }
});

在第一页有个 add-ele 元素的时候就删除它。这里我们将 item.firstChild 断言成了 HTMLDivElement 类型,如果不断言,item.firstChild 的类型就是 ChildNode,而 ChildNode 类型中是不存在 classList 属性的,所以就就会报错,当我们把他断言成 HTMLDivElement 类型时,就不会报错了。很多时候,标签类型可以和断言(as)一起使用。

后面在 removeChild 时又使用了 as 断言,为什么呢?item.firstChild 不是已经自动识别为 ChildNode 类型了吗?因为 TS 会认为,我们可能不能获取到类名为 paper 的元素,所以 item.firstChild 的类型就被推断为 ChildNode | null,我们有时候比 TS 更懂我们定义的元素,知道页面一定存在 paper 元素,所以可以直接将 item.firstChild 断言成 ChildNode 类型。

标签属性类型

每个 HTML 标签都有自己的属性,比如 Input 框就有 value、width、placeholder、max-length 等属性,下面是 Input 框的属性类型定义:

ts 复制代码
interface InputHTMLAttributes<T> extends HTMLAttributes<T> {
   accept?: string | undefined;
   alt?: string | undefined;
   autoComplete?: string | undefined;
   autoFocus?: boolean | undefined;
   capture?: boolean | string | undefined;
   checked?: boolean | undefined;
   crossOrigin?: string | undefined;
   disabled?: boolean | undefined;
   enterKeyHint?: 'enter' | 'done' | 'go' | 'next' | 'previous' | 'search' | 'send' | undefined;
   form?: string | undefined;
   formAction?: string | undefined;
   formEncType?: string | undefined;
   formMethod?: string | undefined;
   formNoValidate?: boolean | undefined;
   formTarget?: string | undefined;
   height?: number | string | undefined;
   list?: string | undefined;
   max?: number | string | undefined;
   maxLength?: number | undefined;
   min?: number | string | undefined;
   minLength?: number | undefined;
   multiple?: boolean | undefined;
   name?: string | undefined;
   pattern?: string | undefined;
   placeholder?: string | undefined;
   readOnly?: boolean | undefined;
   required?: boolean | undefined;
   size?: number | undefined;
   src?: string | undefined;
   step?: number | string | undefined;
   type?: string | undefined;
   value?: string | ReadonlyArray<string> | number | undefined;
   width?: number | string | undefined;

   onChange?: ChangeEventHandler<T> | undefined;
}

如果我们需要直接操作 DOM,就可能会用到元素属性类型,常见的元素属性类型如下:

  • HTML 属性类型:HTMLAttributes
  • 按钮属性类型:ButtonHTMLAttributes
  • 表单属性类型:FormHTMLAttributes
  • 图片属性类型:ImgHTMLAttributes
  • 输入框属性类型:InputHTMLAttributes
  • 链接属性类型:LinkHTMLAttributes
  • meta 属性类型:MetaHTMLAttributes
  • 选择框属性类型:SelectHTMLAttributes
  • 表格属性类型:TableHTMLAttributes
  • 输入区属性类型:TextareaHTMLAttributes
  • 视频属性类型:VideoHTMLAttributes
  • SVG 属性类型:SVGAttributes
  • WebView 属性类型:WebViewHTMLAttributes

一般情况下,我们是很少需要在项目中显式的去定义标签属性的类型。如果子级去封装组件库的话,这些属性就能发挥它们的作用了:

tsx 复制代码
import React from 'react';
import classNames from 'classnames';

export enum ButtonSize {
   Large = 'lg',
   Small = 'sm'
}

export enum ButtonType {
   Primary = 'primary',
   Default = 'default',
   Danger = 'danger',
   Link = 'link'
}

interface BaseButtonProps {
   className?: string;
   disabled?: boolean;
   size?: ButtonSize;
   btnType?: ButtonType;
   children: React.ReactNode;
   href?: string;
}

// 使用 交叉类型(&)获得我们自己定义的属性和原生 button 的属性
type NativeButtonProps = BaseButtonProps & React.ButtonHTMLAttributes<HTMLButtonElement>;

// 使用 交叉类型(&)获得我们自己定义的属性和原生 a 标签的属性
type AnchorButtonProps = BaseButtonProps & React.AnchorHTMLAttributes<HTMLAnchorElement>;

// 使用 Partial<> 使两种属性可选
export type ButtonProps = Partial<NativeButtonProps & AnchorButtonProps>;

const Button: React.FC<ButtonProps> = (props) => {
   const { disabled, className, size, btnType, children, href, ...restProps } = props;

   const classes = classNames('btn', className, {
      [`btn-${btnType}`]: btnType,
      [`btn-${size}`]: size,
      disabled: btnType === ButtonType.Link && disabled // 只有 a 标签才有 disabled 类名,button 没有
   });

   if (btnType === ButtonType.Link && href) {
      return (
         <a className={classes} href={href} {...restProps}>
            {children}
         </a>
      );
   } else {
      return (
         <button
            className={classes}
            disabled={disabled} // button 元素默认有 disabled 属性,所以即便没给他设置样式也会和普通 button 有一定区别
            {...restProps}
         >
            {children}
         </button>
      );
   }
};

Button.defaultProps = {
   disabled: false,
   btnType: ButtonType.Default
};

export default Button;

高阶组件

类组件

tsx 复制代码
interface WithLoadingProps {
   loading: boolean;
}

const withLoading = <P extends object>(Component: React.ComponentType<P>) =>
   class WithLoading extends React.Component<P & WithLoadingProps> {
      render() {
         const { loading, ...props } = this.props;
         return loading ? <LoadingSpinner /> : <Component {...(props as P)} />;
      }
   };

函数组件

tsx 复制代码
const withLoading = <P extends object>(Component: React.ComponentType<P>): React.FC<P & WithLoadingProps> => ({
   loading,
   ...props
}: WithLoadingProps) => (loading ? <LoadingSpinner /> : <Component {...(props as P)} />);

工具泛型的实现

Partial

Partial 作用是将传入的属性变为可选项

前置知识

首先我们需要理解两个关键字 keyof 和 in,keyof 可以用来取得一个对象接口的所有 key 值,比如

ts 复制代码
interface Foo {
   name: string;
   age: number;
}
type T = keyof Foo; // -> "name" | "age"

而 in 则可以遍历枚举类型, 例如

ts 复制代码
type Keys = 'a' | 'b';
type Obj = {
   [p in Keys]: any;
}; // -> { a: any, b: any }

源码解析

keyof 产生联合类型,in 则可以遍历枚举类型,所以他们经常一起使用,看下 Partial 源码

ts 复制代码
type Partial<T> = {
   [P in keyof T]?: T[P];
};

上面语句的意思是 keyof T 拿到 T 所有属性名,然后 in 进行遍历,将值赋给 P,最后 T[P] 取得相应属性的值

结合中间的 ? 我们就明白了 Partial 的含义了

使用场景

假设我们有一个定义 user 的接口,如下

ts 复制代码
interface IUser {
   name: string;
   age: number;
   department: string;
}

经过 Partial 类型转化后得到

ts 复制代码
type optional = Partial<IUser>;

// optional的结果如下
type optional = {
   name?: string | undefined;
   age?: number | undefined;
   department?: string | undefined;
};

Required

Required 的作用是将传入的属性变为必选项,源码如下

ts 复制代码
type Required<T> = {
   [P in keyof T]-?: T[P];
};

我们发现一个有意思的用法 -?,这里很好理解就是将可选项代表的 ? 去掉,从而让这个类型变成必选项。

与之对应的还有个+? ,这个含义自然与-?之前相反,它是用来把属性变成可选项的

ts 复制代码
interface IPerson {
   name?: string;
   age?: number;
   height?: number;
}

const person: Required<IPerson> = {
   name: 'zhangsan',
   age: 18,
   height: 180
};

Mutable (未包含)

类似地,其实还有对 + 和 -,这里要说的不是变量的之间的进行加减而是对 readonly 进行加减。

以下代码的作用就是将 T 的所有属性的 readonly 移除,你也可以写一个相反的出来.

ts 复制代码
type Mutable<T> = {
   -readonly [P in keyof T]: T[P];
};

Readonly

将传入的属性变为只读选项, 源码如下

ts 复制代码
type Readonly<T> = {
   readonly [P in keyof T]: T[P];
};
ts 复制代码
interface IPerson {
   name: string;
   age: number;
}

const person: Readonly<IPerson> = {
   name: 'zhangsan',
   age: 18
};

person.age = 20; //  Error: cannot reassign a readonly property

Record

前置知识

此处注意 K extends keyof T 和直接使用 K in keyof T 的区别,keyof T 仅仅代表键的字符串文字类型,而 extends keyof T 将返回该属性相同的类型

ts 复制代码
function prop<T, K extends keyof T>(obj: T, key: K) {
   return obj[key];
}

function prop2<T>(obj: T, key: keyof T) {
   return obj[key];
}

let o = {
   p1: 0,
   p2: ''
};

let v = prop(o, 'p1'); // is number, K is of type 'p1'
let v2 = prop2(o, 'p1'); // is number | string, no extra info is captured

源码解析

将 K 中所有的属性的值转化为 T 类型

ts 复制代码
type Record<K extends keyof any, T> = {
   [P in K]: T;
};

使用场景

ts 复制代码
interface IPageinfo {
   name: string;
}

type IPage = 'home' | 'about' | 'contact';

const page: Record<IPage, IPageinfo> = {
   about: { name: 'about' },
   contact: { name: 'contact' },
   home: { name: 'home' }
};

Pick

从 T 中取出一系列 K 的属性

ts 复制代码
type Pick<T, K extends keyof T> = {
   [P in K]: T[P];
};
ts 复制代码
interface IPerson {
   name: string;
   age: number;
   height: number;
}

const person: Pick<IPerson, 'name' | 'age'> = {
   name: 'zhangsan',
   age: 18
};

Exclude

前置知识

在 ts 2.8 中引入了一个条件类型,示例如下

ts 复制代码
T extends U ? X : Y

以上语句的意思就是如果 T 是 U 的子类型的话,那么就会返回 X,否则返回 Y,甚至可以组合多个

ts 复制代码
type TypeName<T> = T extends string
   ? 'string'
   : T extends number
   ? 'number'
   : T extends boolean
   ? 'boolean'
   : T extends undefined
   ? 'undefined'
   : T extends Function
   ? 'function'
   : 'object';

对于联合类型来说会自动分发条件,例如 T extends U ? X : Y,T 可能是 A | B 的联合类型,那实际情况就变成(A extends U ? X : Y) | (B extends U ? X : Y)

源码解析

ts 复制代码
type Exclude<T, U> = T extends U ? never : T;

使用场景

ts 复制代码
type T = Exclude<1 | 2, 1 | 3>; // -> 2

很轻松地得出结果 2,据代码和示例我们可以推断出 Exclude 的作用是从 T 中找出 U 中没有的元素,换种更加贴近语义的说法其实就是从 T 中排除 U

Extract

根据源码我们推断出 Extract 的作用是提取出 T 包含在 U 中的元素,换种更加贴近语义的说法就是从 T 中提取出 U

源码如下

ts 复制代码
type Extract<T, U> = T extends U ? T : never;

Omit

用之前的 Pick 和 Exclude 进行组合,实现忽略对象某些属性功能,源码如下

ts 复制代码
type Omit<T, K> = Pick<T, Exclude<keyof T, K>>;

// 使用
type Foo = Omit<
   {
      name: string;
      age: number;
   },
   'name'
>; // -> { age: number }

interface IPerson {
   name: string;
   age: number;
   height: number;
}

const person: Omit<IPerson, 'age' | 'sex'> = {
   name: 'zhangsan',
   height: 180
};

ReturnType

在阅读源码之前我们需要了解一下 infer 这个关键字,在条件类型语句中,我们可以用 infer 声明一个类型变量并且对它进行使用,我们可以用它获取函数的返回类型,源码如下

ts 复制代码
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;

其实这里的 infer R 就是声明一个变量来承载传入函数签名的返回值类型,简单说就是用它取到函数返回值的类型方便之后使用

ts 复制代码
function foo(x: number): Array<number> {
   return [x];
}
type fn = ReturnType<typeof foo>;

AxiosReturnType (未包含)

开发经常使用 axios 进行封装 API 层请求,通常是一个函数返回一个 AxiosPromise<Resp>,现在我想取到它的 Resp 类型,根据上一个工具泛型的知识我们可以这样写

ts 复制代码
import { AxiosPromise } from 'axios'; // 导入接口
type AxiosReturnType<T> = T extends (...args: any[]) => AxiosPromise<infer R> ? R : any;

// 使用
type Resp = AxiosReturnType<Api>; // 泛型参数中传入你的 Api 请求函数

巧用 TypeScript

函数重载

TypeScript 提供函数重载的功能,用来处理因函数参数不同而返回类型不同的使用场景,使用时只需为同一个函数定义多个类型即可,简单使用如下所示:

ts 复制代码
declare function test(a: number): number;
declare function test(a: string): string;

const resS = test('Hello World'); // resS 被推断出类型为 string;
const resN = test(1234); // resN 被推断出类型为 number;

它也适用于参数不同,返回值类型相同的场景,我们只需要知道在哪种函数类型定义下能使用哪些参数即可。

考虑如下例子:

ts 复制代码
interface User {
   name: string;
   age: number;
}

declare function test(para: User | number, flag?: boolean): number;

在这个 test 函数里,我们的本意可能是当传入参数 para 是 User 时,不传 flag,当传入 para 是 number 时,传入 flag。TypeScript 并不知道这些,当你传入 para 为 User 时,flag 同样允许你传入:

ts 复制代码
const user = {
   name: 'Jack',
   age: 666
};

// 没有报错,但是与想法违背
const res = test(user, false);

使用函数重载能帮助我们实现:

ts 复制代码
interface User {
   name: string;
   age: number;
}

declare function test(para: User): number;
declare function test(para: number, flag: boolean): number;

const user = {
   name: 'Jack',
   age: 666
};

// bingo
// Error: 参数不匹配
const res = test(user, false);

实际项目中,你可能要多写几步,如在 class 中:

ts 复制代码
interface User {
   name: string;
   age: number;
}

const user = {
   name: 'Jack',
   age: 123
};

class SomeClass {
   /**
    * 注释 1
    */
   public test(para: User): number;
   /**
    * 注释 2
    */
   public test(para: number, flag: boolean): number;
   public test(para: User | number, flag?: boolean): number {
      // 具体实现
      return 11;
   }
}

const someClass = new SomeClass();

// ok
someClass.test(user);
someClass.test(123, false);

// Error,涉及到具体实现时,这个地方报错
someClass.test(123);
someClass.test(user, false);

映射类型

自从 TypeScript 2.1 版本推出映射类型以来,它便不断被完善与增强。在 2.1 版本中,可以通过 keyof 拿到对象 key 类型,内置 Partial、Readonly、Record、Pick 映射类型;2.3 版本增加 ThisType;2.8 版本增加 Exclude、Extract、NonNullable、ReturnType、InstanceType;同时在此版本中增加条件类型与增强 keyof 的能力;3.1 版本支持对元组与数组的映射。这些无不意味着映射类型在 TypeScript 有着举足轻重的地位。

其中 ThisType 并没有出现在官方文档中,它主要用来在对象字面量中键入 this:

ts 复制代码
// Compile with --noImplicitThis

type ObjectDescriptor<D, M> = {
   data?: D;
   methods?: M & ThisType<D & M>; // Type of 'this' in methods is D & M
};

function makeObject<D, M>(desc: ObjectDescriptor<D, M>): D & M {
   let data: object = desc.data || {};
   let methods: object = desc.methods || {};
   return { ...data, ...methods } as D & M;
}

let obj = makeObject({
   data: { x: 0, y: 0 },
   methods: {
      moveBy(dx: number, dy: number) {
         this.x += dx; // Strongly typed this
         this.y += dy; // Strongly typed this
      }
   }
});

obj.x = 10;
obj.y = 20;
obj.moveBy(5, 5);

正是由于 ThisType 的出现,Vue 2.5 才得以增强对 TypeScript 的支持。

虽已内置了很多映射类型,但在很多时候,我们需要根据自己的项目自定义映射类型:

比如你可能想取出接口类型中的函数类型:

ts 复制代码
type FunctionPropertyNames<T> = {
   [K in keyof T]: T[K] extends Function ? K : never;
}[keyof T];
type FunctionProperties<T> = Pick<T, FunctionPropertyNames<T>>;

interface Part {
   id: number;
   name: string;
   subparts: Part[];
   updatePart(newName: string): void;
}

type T40 = FunctionPropertyNames<Part>; // "updatePart"
type T42 = FunctionProperties<Part>; // { updatePart(newName: string): void }

比如你可能为了便捷,把本属于某个属性下的方法,通过一些方式 alias 到其他地方

举个例子:SomeClass 下有个属性 value = [1, 2, 3],你可能在 Decorators 给类添加了此种功能:在 SomeClass 里调用 this.find() 时,实际上是调用 this.value.find(),但是此时 TypeScript 并不知道这些:

ts 复制代码
class SomeClass {
   value = [1, 2, 3];

   someMethod() {
      this.value.find(/* ... */); // ok
      this.find(/* ... */); // Error:SomeClass 没有 find 方法。
   }
}

借助于映射类型和 interface + class 的声明方式,可以实现我们的目的:

ts 复制代码
type ArrayMethodName = 'filter' | 'forEach' | 'find';

type SelectArrayMethod<T> = {
   [K in ArrayMethodName]: Array<T>[K];
};

interface SomeClass extends SelectArrayMethod<number> {}

class SomeClass {
   value = [1, 2, 3];

   someMethod() {
      this.forEach(/* ... */); // ok
      this.find(/* ... */); // ok
      this.filter(/* ... */); // ok
      this.value; // ok
      this.someMethod(); // ok
   }
}

const someClass = new SomeClass();
someClass.forEach(/* ... */); // ok
someClass.find(/* ... */); // ok
someClass.filter(/* ... */); // ok
someClass.value; // ok
someClass.someMethod(); // ok

导出 SomeClass 类时,也能使用。

可能有点不足的地方,在这段代码里 interface SomeClass extends SelectArrayMethod<number> {} 你需要手动添加范型的具体类型(暂时没想到更好方式)。

类型断言

类型断言用来明确的告诉 TypeScript 值的详细类型,合理使用能减少我们的工作量。

比如一个变量并没有初始值,但是我们知道它的类型信息(它可能是从后端返回)有什么办法既能正确推导类型信息,又能正常运行了?有一种网上的推荐方式是设置初始值,然后使用 typeof 拿到类型(可能会给其他地方用)。然而我可能比较懒,不喜欢设置初始值,这时候使用类型断言可以解决这类问题:

ts 复制代码
interface User {
   name: string;
   age: number;
}

export default class NewRoom extends Vue {
   private user = {} as User;
}

在设置初始化时,添加断言,我们就无须添加初始值,编辑器也能正常的给予代码提示了。如果 user 属性很多,这样就能解决大量不必要的工作了,定义的 interface 也能给其他地方使用。

枚举类型

枚举类型分为数字类型与字符串类型,其中数字类型的枚举可以当标志使用:

ts 复制代码
// https://github.com/Microsoft/TypeScript/blob/master/src/compiler/types.ts#L3859
export const enum ObjectFlags {
   Class = 1 << 0, // Class
   Interface = 1 << 1, // Interface
   Reference = 1 << 2, // Generic type reference
   Tuple = 1 << 3, // Synthesized generic tuple type
   Anonymous = 1 << 4, // Anonymous
   Mapped = 1 << 5, // Mapped
   Instantiated = 1 << 6, // Instantiated anonymous or mapped type
   ObjectLiteral = 1 << 7, // Originates in an object literal
   EvolvingArray = 1 << 8, // Evolving array type
   ObjectLiteralPatternWithComputedProperties = 1 << 9, // Object literal pattern with computed properties
   ContainsSpread = 1 << 10, // Object literal contains spread operation
   ReverseMapped = 1 << 11, // Object contains a property from a reverse-mapped type
   JsxAttributes = 1 << 12, // Jsx attributes type
   MarkerType = 1 << 13, // Marker type used for variance probing
   JSLiteral = 1 << 14, // Object type declared in JS - disables errors on read/write of nonexisting members
   ClassOrInterface = Class | Interface
}

在 TypeScript src/compiler/types 源码里,定义了大量如上所示的基于数字类型的常量枚举。它们是一种有效存储和表示布尔值集合的方法。

在 《深入理解 TypeScript》 中有一个使用例子:

ts 复制代码
enum AnimalFlags {
   None = 0,
   HasClaws = 1 << 0,
   CanFly = 1 << 1,
   HasClawsOrCanFly = HasClaws | CanFly
}

interface Animal {
   flags: AnimalFlags;
   [key: string]: any;
}

function printAnimalAbilities(animal: Animal) {
   var animalFlags = animal.flags;
   if (animalFlags & AnimalFlags.HasClaws) {
      console.log('animal has claws');
   }
   if (animalFlags & AnimalFlags.CanFly) {
      console.log('animal can fly');
   }
   if (animalFlags == AnimalFlags.None) {
      console.log('nothing');
   }
}

var animal = { flags: AnimalFlags.None };
printAnimalAbilities(animal); // nothing
animal.flags |= AnimalFlags.HasClaws;
printAnimalAbilities(animal); // animal has claws
animal.flags &= ~AnimalFlags.HasClaws;
printAnimalAbilities(animal); // nothing
animal.flags |= AnimalFlags.HasClaws | AnimalFlags.CanFly;
printAnimalAbilities(animal); // animal has claws, animal can fly

上例代码中 |= 用来添加一个标志,&= 和 ~ 用来删除标志,| 用来合并标志。

Decorator

Decorator 早已不是什么新鲜事物,在 TypeScript 1.5 + 的版本中,我们可以利用内置类型 ClassDecorator、PropertyDecorator、MethodDecorator 与 ParameterDecorator 更快书写 Decorator,如 MethodDecorator:

ts 复制代码
declare type MethodDecorator = <T>(
   target: Object,
   propertyKey: string | symbol,
   descriptor: TypedPropertyDescriptor<T>
) => TypedPropertyDescriptor<T> | void;

使用时,只需在相应地方加上类型注解,匿名函数的参数类型也就会被自动推导出来了。

ts 复制代码
function methodDecorator(): MethodDecorator {
   return (target, key, descriptor) => {
      // ...
   };
}

值得一提的是,如果你在 Decorator 给目标类的 prototype 添加属性时,TypeScript 并不知道这些:

ts 复制代码
function testAble(): ClassDecorator {
   return (target) => {
      target.prototype.someValue = true;
   };
}

@testAble()
class SomeClass {}

const someClass = new SomeClass();

someClass.someValue(); // Error: Property 'someValue' does not exist on type 'SomeClass'.

这很常见,特别是当你想用 Decorator 来扩展一个类时。

GitHub 上有一个关于此问题的 issues,直至目前,也没有一个合适的方案实现它。其主要问题在于 TypeScript 并不知道目标类是否使用了 Decorator 以及 Decorator 的名称。从这个 issues 来看,建议的解决办法是使用 Mixin:

ts 复制代码
type Constructor<T> = new (...args: any[]) => T;

// mixin 函数的声明,需要实现
declare function mixin<T1, T2>(...MixIns: [Constructor<T1>, Constructor<T2>]): Constructor<T1 & T2>;

class MixInClass1 {
   mixinMethod1() {}
}

class MixInClass2 {
   mixinMethod2() {}
}

class Base extends mixin(MixInClass1, MixInClass2) {
   baseMethod() {}
}

const x = new Base();

x.baseMethod(); // OK
x.mixinMethod1(); // OK
x.mixinMethod2(); // OK
x.mixinMethod3(); // Error

当把大量的 JavaScript Decorator 重构为 Mixin 时,这无疑是一件让人头大的事情。

这有一些偏方,能让你顺利从 JavaScript 迁移至 TypeScript:

  • 显式赋值断言修饰符,即是在类里,明确说明某些属性存在于类上:

    ts 复制代码
    function testAble(): ClassDecorator {
      return (target) => {
         target.prototype.someValue = true;
      };
    }
    
    @testAble()
    class SomeClass {
      public someValue!: boolean;
    }
    
    const someClass = new SomeClass();
    someClass.someValue; // true
  • 采用声明合并形式,单独定义一个 interface,把用 Decorator 扩展的属性的类型,放入 interface 中:

    ts 复制代码
    interface SomeClass {
      someValue: boolean;
    }
    
    function testAble(): ClassDecorator {
      return (target) => {
         target.prototype.someValue = true;
      };
    }
    
    @testAble()
    class SomeClass {}
    
    const someClass = new SomeClass();
    someClass.someValue; // true

Reflect Metadata

Reflect Metadata 是 ES7 的一个提案,它主要用来在声明的时候添加和读取元数据。TypeScript 在 1.5+ 的版本已经支持它,你只需要:

  • npm i reflect-metadata —save。
  • 在 tsconfig.json 里配置 emitDecoratorMetadata 选项。

它具有诸多使用场景。

获取类型信息

譬如在 vue-property-decorator 6.1 及其以下版本中,通过使用 Reflect.getMetadata API,Prop Decorator 能获取属性类型传至 Vue,简要代码如下:

ts 复制代码
function Prop(): PropertyDecorator {
   return (target, key: string) => {
      const type = Reflect.getMetadata('design:type', target, key);
      console.log(`${key} type: ${type.name}`);
      // other...
   };
}

class SomeClass {
   @Prop()
   public Aprop!: string;
}

运行代码可在控制台看到 Aprop type: string。除能获取属性类型外,通过 Reflect.getMetadata(“design:paramtypes”, target, key) 和 Reflect.getMetadata(“design:returntype”, target, key) 可以分别获取函数参数类型和返回值类型。

自定义 metadataKey

除能获取类型信息外,常用于自定义 metadataKey,并在合适的时机获取它的值,示例如下:

ts 复制代码
function classDecorator(): ClassDecorator {
   return (target) => {
      // 在类上定义元数据,key 为 `classMetaData`,value 为 `a`
      Reflect.defineMetadata('classMetaData', 'a', target);
   };
}

function methodDecorator(): MethodDecorator {
   return (target, key, descriptor) => {
      // 在类的原型属性 'someMethod' 上定义元数据,key 为 `methodMetaData`,value 为 `b`
      Reflect.defineMetadata('methodMetaData', 'b', target, key);
   };
}

@classDecorator()
class SomeClass {
   @methodDecorator()
   someMethod() {}
}

Reflect.getMetadata('classMetaData', SomeClass); // 'a'
Reflect.getMetadata('methodMetaData', new SomeClass(), 'someMethod'); // 'b'

用例

控制反转和依赖注入

在 Angular 2+ 的版本中,控制反转与依赖注入便是基于此实现,现在,我们来实现一个简单版:

ts 复制代码
type Constructor<T = any> = new (...args: any[]) => T;

const Injectable = (): ClassDecorator => (target) => {};

class OtherService {
   a = 1;
}

@Injectable()
class TestService {
   constructor(public readonly otherService: OtherService) {}

   testMethod() {
      console.log(this.otherService.a);
   }
}

const Factory = <T>(target: Constructor<T>): T => {
   // 获取所有注入的服务
   const providers = Reflect.getMetadata('design:paramtypes', target); // [OtherService]
   const args = providers.map((provider: Constructor) => new provider());
   return new target(...args);
};

Factory(TestService).testMethod(); // 1

Controller 与 Get 的实现

如果你在使用 TypeScript 开发 Node 应用,相信你对 Controller、Get、POST 这些 Decorator,并不陌生:

ts 复制代码
@Controller('/test')
class SomeClass {
   @Get('/a')
   someGetMethod() {
      return 'hello world';
   }

   @Post('/b')
   somePostMethod() {}
}

它们也是基于 Reflect Metadata 实现,不同的是,这次我们将 metadataKey 定义在 descriptor 的 value 上(稍后解释),简单实现如下:

ts 复制代码
const METHOD_METADATA = 'method'const PATH_METADATA = 'path'const Controller = (path: string): ClassDecorator => {
  return target => {
    Reflect.defineMetadata(PATH_METADATA, path, target);
  }
}

const createMappingDecorator = (method: string) => (path: string): MethodDecorator => {
  return (target, key, descriptor) => {
    Reflect.defineMetadata(PATH_METADATA, path, descriptor.value);
    Reflect.defineMetadata(METHOD_METADATA, method, descriptor.value);
  }
}

const Get = createMappingDecorator('GET');
const Post = createMappingDecorator('POST');

接着,创建一个函数,映射出 route:

ts 复制代码
function mapRoute(instance: Object) {
   const prototype = Object.getPrototypeOf(instance);

   // 筛选出类的 methodName
   const methodsNames = Object.getOwnPropertyNames(prototype).filter(
      (item) => !isConstructor(item) && isFunction(prototype[item])
   );
   return methodsNames.map((methodName) => {
      const fn = prototype[methodName];

      // 取出定义的 metadata
      const route = Reflect.getMetadata(PATH_METADATA, fn);
      const method = Reflect.getMetadata(METHOD_METADATA, fn);
      return {
         route,
         method,
         fn,
         methodName
      };
   });
}

我们可以得到一些有用的信息:

ts 复制代码
Reflect.getMetadata(PATH_METADATA, SomeClass); // '/test'

mapRoute(new SomeClass());

/**
 * [{
 *    route: '/a',
 *    method: 'GET',
 *    fn: someGetMethod() { ... },
 *    methodName: 'someGetMethod'
 *  },{
 *    route: '/b',
 *    method: 'POST',
 *    fn: somePostMethod() { ... },
 *    methodName: 'somePostMethod'
 * }]
 *
 */

最后,只需把 route 相关信息绑在 express 或者 koa 上就 ok 了。

至于为什么要定义在 descriptor 的 value 上,我们希望 mapRoute 函数的参数是一个实例,而非 class 本身(控制反转)。

数组与元组

创建一个数组很简单:

ts 复制代码
const arr = [1];

此时 TypeScript 将会推断 arr 类型为 number[]:

ts 复制代码
arr.push('1'); // Error

当数组元素具有其它类型时,可以通过类型注解的方式:

ts 复制代码
const arr: Array<string | number> = [1];

arr.push('1'); // OK
arr.push(true); // Error

或者你也可以通过可选元组的方式:

ts 复制代码
const arr: [number, string?] = [1]; // arr 的成员类型可以是: number, string, undefined
arr.push('1'); // OK
arr.push(true); // Error

使用元组形式,还能提供指定位置的类型检查:

ts 复制代码
arr[0] = '1'; // Error
arr[1] = 1; // Error

使用

通常,我们使用 Promise.all 并行发出多个请求:

ts 复制代码
interface A {
   name: string;
}

interface B {
   age: number;
}

const [{ data: a }, { data: b }] = await Promise.all([axios.get<A>('http://some.1'), axios.get<B>('http://some.2')]);

此时,TypeScript 能推出 a 的类型是 A, b 的类型是 B。

现在,稍作改变:当满足特定条件时,才发出第二个请求:

ts 复制代码
// 使用类型注解
const requestList: [Promise<AxiosResponse<A>>, Promise<AxiosResponse<B>>?] = [axios.get<A>('http://some.1')];
if (flag) {
   requestList[1] = axios.get<B>('http://some.2');
}
const [{ data: a }, response] = await Promise.all(requestList);

我们期望它会如预想时那样工作,可是事与愿违 Promise.all(requestList) 会出现类型兼容性的报错,在这个 Issues 里,描述了相同的问题。

现在,你可以通过断言的方式,来让程序正常运作:

ts 复制代码
const requestList: any[] = [axios.get<A>('http://some.1')]; // 设置为 any[] 类型
if (flag) {
   requestList[1] = axios.get<B>('http://some.2');
}
const [{ data: a }, response] = (await Promise.all(requestList)) as [AxiosResponse<A>, AxiosResponse<B>?]; // 类型安全

字面量类型

在 JavaScript 基础上,TypeScript 扩展了一系列字面量类型,用来确保类型的准确性。

如创建一个字符串字面量:

ts 复制代码
const a = 'hello'; // a 的类型是 'hello'
a = 'world'; // Error

或者你也可以:

ts 复制代码
let a: 'hello' = 'hello'; // a 的类型是 'hello'
a = 'world'; // Error

其它数据类型与此相似。

你也可以定义交叉类型与联合类型的字面量:

ts 复制代码
interface A {
   name: string;
}

interface B {
   name: string;
   age: number;
}

type C = A | B;
type D = A & B;

对象字面量类型

对于对象字面量的类型,TypeScript 有一个被称之为 Freshness 的概念,它也被称为更严格的对象字面量检查,如下例子:

ts 复制代码
let someThing: { name: string };
someThing = { name: 'hello' }; // ok
someThing = { name: 'hello', age: 123 }; // Error, 对象字面量只能指定已知属性, { name: string } 类型中不存在 age 属性

let otherThing = { name: 'hello', age: 123 };
someThing = otherThing; // ok

TypeScript 认为创建的每个对象字面量都是 fresh 状态;当一个 fresh 对象字面量赋值给一个变量时,如果对象的类型与变量类型不兼容时,会出现报错(如上例子中 someThine = { name: 'hello', age: 123 }; 的错误);当对象字面量的类型变宽,对象字面量的 fresh 状态会消失(如上例子中 someThing = otherThing; 赋值以后 someThing 的类型变宽)。

一个更实际的用例如下:

ts 复制代码
function logName(something: { name: string }) {
   console.log(something.name);
}

const obj = {
   name: 'matt',
   job: 'being awesome'
};

logName(obj); // ok
logName({ name: 'matt' }); // ok
logName({ nama: 'matt' }); // Error: nama 属性在 { name: string } 属性中不存在。
logName({ name: 'matt', job: 'being awesome' }); // Error: 对象字面量只能指定已知属性,`job` 属性在这里并不存在。

基本原理与上文中相似,当想用更严格的类型检查时,可以传一个具有 fresh 状态的对象字面量(如 logName({ name: 'matt', job: 'being awesome' });)。当你想多传一些属性至函数,可以将对象字面量赋值至一个新变量,然后再传至函数(如 logName(obj))。或者你也可以通过给函数形参添加多余类型的方式 function logName(someThing: { name: string; [key: string]: string })

用 Decorator 限制类型

Decorator 可用于限制类方法的返回类型,如下所示:

ts 复制代码
const TestDecorator = () => {
   return (
      target: Object,
      key: string | symbol,
      descriptor: TypedPropertyDescriptor<() => number> // 函数返回值必须是 number
   ) => {
      // 其他代码
   };
};

class Test {
   @TestDecorator()
   testMethod() {
      return '123'; // Error: Type 'string' is not assignable to type 'number'
   }
}

你也可以用泛型让 TestDecorator 的传入参数类型与 testMethod 的返回参数类型兼容:

ts 复制代码
const TestDecorator = <T>(para: T) => {
   return (target: Object, key: string | symbol, descriptor: TypedPropertyDescriptor<() => T>) => {
      // 其他代码
   };
};

class Test {
   @TestDecorator('hello')
   testMethod() {
      return 123; // Error: Type 'number' is not assignable to type 'string'
   }
}

泛型的类型推断

在定义泛型后,有两种方式使用,一种是传入泛型类型,另一种使用类型推断,即编译器根据其他参数类型来推断泛型类型。简单示例如下:

ts 复制代码
declare function fn<T>(arg: T): T; // 定义一个泛型函数

const fn1 = fn<string>('hello'); // 第一种方式,传入泛型类型 string
const fn2 = fn(1); // 第二种方式,从参数 arg 传入的类型 number,来推断出泛型 T 的类型是 number

它通常与映射类型一起使用,用来实现一些比较复杂的功能。

Vue Type 简单实现

如下一个例子:

ts 复制代码
type Options<T> = {
   [P in keyof T]: T[P];
};

declare function test<T>(o: Options<T>): T;

test({ name: 'Hello' }).name; // string

test 函数将传入参数的所有属性取出来,现在我们来一步一步加工,实现想要的功能。

首先,更改传入参数的形式,由 { name: 'Hello' } 的形式变更为 { data: { name: 'Hello' } },调用函数的返回值类型不变,即 test({ data: { name: 'Hello' } }).name 的值也是 string 类型。

这并不复杂,这只需要把传入参数的 data 类型设置为 T 即可:

ts 复制代码
declare function test<T>(o: { data: Options<T> }): T;

test({ data: { name: 'Hello' } }).name; // string

当 data 对象里,含有函数时,它也能运作:

ts 复制代码
const param = {
   data: {
      name: 'Hello',
      someMethod() {
         return 'hello world';
      }
   }
};

test(param).someMethod(); // string

接着,考虑一种特殊的函数情景,像 Vue 中 Computed 一样,不调用函数,也能取出函数的返回值类型。现在传入参数的形式变更为:

ts 复制代码
const param = {
   data: {
      name: 'Hello'
   },
   computed: {
      age() {
         return 20;
      }
   }
};

一个函数的类型可以简单的看成是 () => T 的形式,对象中的方法类型,可以看成 a: () => T 的形式,在反向推导时(由函数返回值,来推断类型 a 的类型)可以利用它,需要添加一个映射类型 Computed<T>,用来处理 computed 里的函数:

ts 复制代码
type Options<T> = {
   [P in keyof T]: T[P];
};

type Computed<T> = {
   [P in keyof T]: () => T[P];
};

interface Params<T, M> {
   data: Options<T>;
   computed: Computed<M>;
}

declare function test<T, M>(o: Params<T, M>): T & M;

const param = {
   data: {
      name: 'Hello'
   },
   computed: {
      age() {
         return 20;
      }
   }
};

test(param).name; // string
test(param).age; // number

最后,结合巧用 ThisType 映射类型,可以轻松的实现在 computed age 方法下访问 data 中的数据:

ts 复制代码
type Options<T> = {
   [P in keyof T]: T[P];
};

type Computed<T> = {
   [P in keyof T]: () => T[P];
};

interface Params<T, M> {
   data: Options<T>;
   computed: Computed<M>;
}

declare function test<T, M>(o: Params<T, M>): T & M;

const param = {
   data: {
      name: 'Hello'
   },
   computed: {
      age() {
         return 20;
      }
   }
};

test(param).name; // string
test(param).age; // number

扁平数组构建树形结构

扁平数组构建树形结构即是将一组扁平数组,根据 parent_id(或者是其他)转换成树形结构:

ts 复制代码
// 转换前数据
const arr = [
   { id: 1, parentId: 0, name: 'test1' },
   { id: 2, parentId: 1, name: 'test2' },
   { id: 3, parentId: 0, name: 'test3' }
];

// 转化后
[
   {
      id: 1,
      parentId: 0,
      name: 'test1',
      children: [
         {
            id: 2,
            parentId: 1,
            name: 'test2',
            children: []
         }
      ]
   },
   {
      id: 3,
      parentId: 0,
      name: 'test3',
      children: []
   }
];

如果 children 字段名字不变,函数的类型并不难写,它大概是如下样子:

ts 复制代码
interface Item {
   id: number;
   parentId: number;
   name: string;
}

type TreeItem = Item & { children: TreeItem[] | [] };

declare function listToTree(list: Item[]): TreeItem[];

listToTree(arr).forEach((i) => i.children); // ok

但是在很多时候,children 字段的名字并不固定,而是从参数中传进来:

ts 复制代码
const options = {
   childrenKey: 'childrenList'
};

listToTree(arr, options);

此时 children 字段名称应该为 childrenList:

ts 复制代码
[
   {
      id: 1,
      parentId: 0,
      name: 'test1',
      childrenList: [{ id: 2, parentId: 1, name: 'test2', childrenList: [] }]
   },
   {
      id: 3,
      parentId: 0,
      name: 'test3',
      childrenList: []
   }
];

实现的思路大致是前文所说的利用泛型的类型推断,从传入的 options 参数中,得到 childrenKey 的类型,然后再传给 TreeItem,如下:

ts 复制代码
interface Options<T extends string> {
   // 限制为 string 类型
   childrenKey: T;
}

declare function listToTree<T extends string = 'children'>(list: Item[], options: Options<T>): TreeItem<T>[];

当 options 为 { childrenKey: 'childrenList' } 时,T 能被正确推导出为 childrenList,接着只需要在 TreeItem 中,把 children 修改为传入的 T 即可:

ts 复制代码
interface Item {
   id: number;
   parentId: number;
   name: string;
}

interface Options<T extends string> {
   childrenKey: T;
}

type TreeItem<T extends string> = Item & { [key in T]: TreeItem<T>[] | [] };

declare function listToTree<T extends string = 'children'>(list: Item[], options: Options<T>): TreeItem<T>[];

listToTree(arr, { childrenKey: 'childrenList' }).forEach((i) => i.childrenList); // ok

有一点局限性,由于对象字面量的 Fresh 的影响,当 options 不是以对象字面量的形式传入时,需要给它断言:

ts 复制代码
const options = {
   childrenKey: 'childrenList' as 'childrenList'
};

listToTree(arr, options).forEach((i) => i.childrenList); // ok

infer

infer 最早出现在此 PR 中,表示在 extends 条件语句中待推断的类型变量。

简单示例如下:

ts 复制代码
type ParamType<T> = T extends (param: infer P) => any ? P : T;

在这个条件语句 T extends (param: infer P) => any ? P : T 中,infer P 表示待推断的函数参数。

整句表示为:如果 T 能赋值给 (param: infer P) => any,则结果是 (param: infer P) => any 类型中的参数 P,否则返回为 T。

ts 复制代码
interface User {
   name: string;
   age: number;
}

type Func = (user: User) => void;

type Param = ParamType<Func>; // Param = User
type AA = ParamType<string>; // string

内置类型

在 2.8 版本中,TypeScript 内置了一些与 infer 有关的映射类型:

  • 用于提取函数类型的返回值类型:
ts 复制代码
type ReturnType<T> = T extends (...args: any[]) => infer P ? P : any;

相比于文章开始给出的示例,ReturnType<T> 只是将 infer P 从参数位置移动到返回值位置,因此此时 P 即是表示待推断的返回值类型。

ts 复制代码
type Func = () => User;
type Test = ReturnType<Func>; // Test = User
  • 用于提取构造函数中参数(实例)类型:

一个构造函数可以使用 new 来实例化,因此它的类型通常表示如下:

ts 复制代码
type Constructor = new (...args: any[]) => any;

当 infer 用于构造函数类型中,可用于参数位置 new (...args: infer P) => any; 和返回值位置 new (...args: any[]) => infer P;

因此就内置如下两个映射类型:

ts 复制代码
// 获取参数类型
type ConstructorParameters<T extends new (...args: any[]) => any> = T extends new (...args: infer P) => any ? P : never;

// 获取实例类型
type InstanceType<T extends new (...args: any[]) => any> = T extends new (...args: any[]) => infer R ? R : any;

class TestClass {
   constructor(public name: string, public string: number) {}
}

type Params = ConstructorParameters<typeof TestClass>; // [string, number]

type Instance = InstanceType<typeof TestClass>; // TestClass

一些用例

至此,相信你已经对 infer 已有基本了解,我们来看看一些使用它的「骚操作」:

  • tuple 转 union ,如:[string, number] -> string | number

解答之前,我们需要了解 tuple 类型在一定条件下,是可以赋值给数组类型:

ts 复制代码
type TTuple = [string, number];
type TArray = Array<string | number>;

type Res = TTuple extends TArray ? true : false; // true
type ResO = TArray extends TTuple ? true : false; // false

因此,在配合 infer 时,很容易做到:

ts 复制代码
type ElementOf<T> = T extends Array<infer E> ? E : never;

type TTuple = [string, number];

type ToUnion = ElementOf<TTuple>; // string | number

在 stackoverflow 上看到另一种解法,比较简(牛)单(逼):

ts 复制代码
type TTuple = [string, number];
type Res = TTuple[number]; // string | number
  • union 转 intersection,如:string | number -> string & number

这个可能要稍微麻烦一点,需要 infer 配合 Distributive conditional types 使用。

Distributive conditional types 是由 naked type parameter 构成的条件类型。而 naked type parameter 表示没有被 Wrapped 的类型(如:Array<T>[T]Promise<T> 等都是不是 naked type parameter)。Distributive conditional types 主要用于拆分 extends 左边部分的联合类型,举个例子:在条件类型 T extends U ? X : Y 中,当 T 是 A | B 时,会拆分成 A extends U ? X : Y | B extends U ? X : Y

有了这个前提,再利用在逆变位置上,同一类型变量的多个候选类型将会被推断为交叉类型的特性,即

ts 复制代码
type Bar<T> = T extends { a: (x: infer U) => void; b: (x: infer U) => void } ? U : never;
type T20 = Bar<{ a: (x: string) => void; b: (x: string) => void }>; // string
type T21 = Bar<{ a: (x: string) => void; b: (x: number) => void }>; // string & number

因此,综合以上几点,我们可以得到在 stackoverflow 上的一个答案:

ts 复制代码
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never;

type Result = UnionToIntersection<string | number>; // string & number

当传入 string | number 时:

  • 第一步:(U extends any ? (k: U) => void : never) 会把 union 拆分成 (string extends any ? (k: string) => void : never) | (number extends any ? (k: number)=> void : never),即是得到 (k: string) => void | (k: number) => void

  • 第二步:(k: string) => void | (k: number) => void extends ((k: infer I) => void) ? I : never,根据上文可以推断出 I 为 string & number。

LeetCode 的一道 TypeScript 面试题

前段时间在 GitHub 上发现一道来自 LeetCode TypeScript 的面试题,比较有意思,题目的大致意思是:

假设有一个这样的类型(原题中给出的是类,这里简化为 interface):

ts 复制代码
interface Module {
   count: number;
   message: string;
   asyncMethod<T, U>(input: Promise<T>): Promise<Action<U>>;
   syncMethod<T, U>(action: Action<T>): Action<U>;
}

在经过 Connect 函数之后,返回值类型为

ts 复制代码
type Result {
  asyncMethod<T, U>(input: T): Action<U>;
  syncMethod<T, U>(action: T): Action<U>;
}

其中 Action<T> 的定义为:

ts 复制代码
interface Action<T> {
   payload?: T;
   type: string;
}

这里主要考察两点

  • 挑选出函数
  • 条件类型 + 此篇文章所提及的 infer

接下来就比较简单了,主要是利用条件类型 + infer,如果函数可以赋值给 asyncMethod<T, U>(input: Promise<T>): Promise<Action<U>>,则取值为 asyncMethod<T, U>(input: T): Action<U>

ts 复制代码
interface Action<T> {
   payload?: T;
   type: string;
}

interface Module {
   count: number;
   message: string;
   asyncMethod<T, U>(input: Promise<T>): Promise<Action<U>>;
   syncMethod<T, U>(action: Action<T>): Action<U>;
}

type asyncMethod<T, U> = (input: Promise<T>) => Promise<Action<U>>; // 转换前
type asyncMethodConnect<T, U> = (input: T) => Action<U>; // 转换后
type syncMethod<T, U> = (action: Action<T>) => Action<U>; // 转换前
type syncMethodConnect<T, U> = (action: T) => Action<U>; // 转换后

type methodsPick<T> = {
   [P in keyof T]: T[P] extends Function ? P : never;
}[keyof T];

type ModuleMethodsConnect<T> = T extends asyncMethod<infer U, infer V>
   ? asyncMethodConnect<U, V>
   : T extends syncMethod<infer U, infer V>
   ? syncMethodConnect<U, V>
   : never;

type ModuleMethods = methodsPick<Module>;

type Connect = (
   module: Module
) => {
   [T in ModuleMethods]: ModuleMethodsConnect<Module[T]>;
};

类型编程

泛型

写法

ts 复制代码
function foo<T>(arg: T): T {
   return arg;
}

// 箭头函数下的书写
const foo = <T>(arg: T) => arg;

// tsx 文件下的书写
const foo = <T extends {}>(arg: T) => arg;

class Foo<T, U> {
   constructor(public arg1: T, public arg2: U) {}

   public method(): T {
      return this.arg1;
   }
}

函数泛型取对象的值

ts 复制代码
// 函数泛型取对象的值,第一种形式
function pickSingleValue<T>(obj: T, key: keyof T): T[keyof T] {
   return obj[key];
}

// 针对第一种形式的缺点:keyof 出现了两次,泛型 T 应该被限制为对象类型
function pickSingleValue<T extends object, U extends keyof T>(obj: T, key: U): T[U] {
   return obj[key];
}

// 换一个功能:取出一系列值
function pick<T extends object, U extends keyof T>(obj: T, keys: U[]): T[U][] {
   return keys.map((key) => obj[key]);
}

// const fooObj = { a: 1, b: "1" };
// pick(fooObj, ['a', 'b']) // (string | number)[]

索引类型

ts 复制代码
interface Foo {
   [keys: string]: string;
}

// 由于 JS 可以同时通过数字与字符串访问对象属性,因此 keyof Foo 的结果会是 string | number
const o: Foo = {
   1: '芜湖!'
};

o[1] === o['1'];

映射类型

ts 复制代码
interface A {
   a: boolean;
   b: string;
   c: number;
   d: () => void;
}

type StringifyA<T> = {
   [K in keyof T]: string;
};

type Clone<T> = {
   [K in keyof T]: T[K];
};

条件类型

写法

ts 复制代码
// 在这种情况下,条件类型的推导就会被延迟
declare function strOrnum<T extends boolean>(x: T): T extends true ? string : number;

type TypeName<T> = T extends string
   ? 'string'
   : T extends number
   ? 'number'
   : T extends boolean
   ? 'boolean'
   : T extends undefined
   ? 'undefined'
   : T extends Function
   ? 'function'
   : 'object';

分布式条件类型

ts 复制代码
// 对于属于裸类型参数的检查类型,条件类型会在实例化时自动分发到联合类型上
// 使用上面的TypeName类型别名
type T1 = TypeName<string | (() => void)>; // "string" | "function"

type T2 = TypeName<string | string[]>; // "string" | "object"

type T3 = TypeName<string[] | number[]>; // "object"

type Naked<T> = T extends boolean ? 'Y' : 'N';
type Wrapped<T> = [T] extends [boolean] ? 'Y' : 'N';

/*
 * 先分发到 Naked<number> | Naked<boolean>
 * 然后到 "N" | "Y"
 */
type Distributed = Naked<number | boolean>;

/*
 * 不会分发,因为这里不是裸类型参数,直接是 [number | boolean] extends [boolean]
 * 然后是 "N"
 */
type NotDistributed = Wrapped<number | boolean>;

没有被额外包装的联合类型参数,在条件类型进行判定时会将联合类型分发,分别进行判断。

infer

ts 复制代码
const foo = (): string => {
   return 'linbudu';
};

// string
type FooReturnType = ReturnType<typeof foo>;

type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;

type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never;

类型系统在获得足够的信息后,就能将 infer 后跟随的类型参数推导出来

类型守卫

写法

ts 复制代码
// numOrStrProp: number | string;
export const isString = (arg: unknown): boolean => typeof arg === 'string';

function useIt(numOrStr: number | string) {
   if (isString(numOrStr)) {
      console.log(numOrStr.length); // 这里类型推断依然为 number | string,并没有起到缩小类型范围的作用
   }
}

// 改进,使用 is 关键字,起作用了
export const isString = (arg: unknown): arg is string => typeof arg === 'string';

// 例子
export type Falsy = false | '' | 0 | null | undefined;

export const isFalsy = (val: unknown): val is Falsy => !val;

如何将 A | B 联合类型缩小到 A

ts 复制代码
class A {
   public a() {}

   public useA() {
      return 'A';
   }
}

class B {
   public b() {}

   public useB() {
      return 'B';
   }
}

function useIt(arg: A | B): void {
   if ('a' in arg) {
      arg.useA();
   } else {
      arg.useB();
   }
}

字面量类型作为类型守卫

ts 复制代码
interface IBoy {
   name: 'mike';
   gf: string;
}

interface IGirl {
   name: 'sofia';
   bf: string;
}

function getLover(child: IBoy | IGirl): string {
   if (child.name === 'mike') {
      return child.gf;
   } else {
      return child.bf;
   }
}

// 不同状态下的接口,这里不好判断
interface IUserProps {
   isLogin: boolean;
   name: string; // 用户名称仅在登录时有
   from: string; // 用户来源(一般用于埋点),仅在未登录时有
}

// 分割接口
interface IUnLogin {
   isLogin: false;
   from: string; // 用户来源(一般用于埋点),仅在未登录时有
}

interface ILogined {
   isLogin: true;
   name: string; // 用户名称仅在登录时有
}

function getUserInfo(user: IUnLogin | ILogined): string {
   return user.isLogin === true ? user.name : user.from;
}

工具类型

以上重复的工具类型不再说明,以下只说明自定义的工具类型,大部分工具类型都来自于 utility-types 三方库

提取 Promise 的类型

ts 复制代码
const foo = (): Promise<string> => {
   return new Promise((resolve, reject) => {
      resolve('linbudu');
   });
};

type FooReturnType = ReturnType<typeof foo>; // Promise<string>

// 使用 infer R 来等待类型系统推导出 R 的具体类型。
type PromiseType<T extends Promise<any>> = T extends Promise<infer R> ? R : never;

type NakedFooReturnType = PromiseType<FooReturnType>; // string

递归的工具类型

ts 复制代码
type DeepPartial<T> = {
   [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};

返回键名的工具类型

利用 { [K in keyof T]: ... }[keyof T] 形式返回键名

ts 复制代码
type FunctTypeKeys<T extends object> = {
   [K in keyof T]-?: T[K] extends Function ? K : never;
}[keyof T];

interface IWithFuncKeys {
   a: string;
   b: number;
   c: boolean;
   d: () => void;
}

type WTFIsThis<T extends object> = {
   [K in keyof T]-?: T[K] extends Function ? K : never;
};

type FunctTypeKeys<T extends object> = {
   [K in keyof T]-?: T[K] extends Function ? K : never;
}[keyof T];

type UseIt1 = WTFIsThis<IWithFuncKeys>;
// {
//     a: never;
//     b: never;
//     c: never;
//     d: "d";
// }

type UseIt2 = FunctTypeKeys<IWithFuncKeys>; // d

可选字段

ts 复制代码
type WTFAMI1 = {} extends { prop: number } ? 'Y' : 'N';
type WTFAMI2 = {} extends { prop?: number } ? 'Y' : 'N';

这里的 {} extends K,只有 K 是可选的情况下才成立,使用如下

ts 复制代码
export type RequiredKeys<T> = {
   [K in keyof T]-?: {} extends Pick<T, K> ? never : K;
}[keyof T];

export type OptionalKeys<T> = {
   [K in keyof T]-?: {} extends Pick<T, K> ? K : never;
}[keyof T];

可变、不可变

ts 复制代码
/**
 * @desc 一个辅助类型,判断 X 和 Y 是否类型相同,
 * @returns 是则返回 A,否则返回 B
 */
type Equal<X, Y, A = X, B = never> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2 ? A : B;

export type MutableKeys<T extends object> = {
   [P in keyof T]-?: Equal<{ [Q in P]: T[P] }, { -readonly [Q in P]: T[P] }, P, never>;
}[keyof T];

export type IMmutableKeys<T extends object> = {
   [P in keyof T]-?: Equals<{ [Q in P]: T[P] }, { -readonly [Q in P]: T[P] }, never, P>;
}[keyof T];

interface IWithFuncKeys {
   a: string;
   b?: number;
   readonly c: boolean[];
   d?: () => void;
}

type b = MutableKeys<IWithFuncKeys>; // "a" | "b" | "d"
type a = IMmutableKeys<IWithFuncKeys>; // "c"

基于值类型的 Pick 与 Omit

ts 复制代码
export type PickByValueType<T, ValueType> = Pick<
   T,
   { [Key in keyof T]-?: T[Key] extends ValueType ? Key : never }[keyof T]
>;

export type OmitByValueType<T, ValueType> = Pick<
   T,
   { [Key in keyof T]-?: T[Key] extends ValueType ? never : Key }[keyof T]
>;

实战

参数简化

通过一个简单的功能把

ts 复制代码
distribute({
   type: 'LOGIN',
   email: string
});

这样的函数调用方式给简化为:

ts 复制代码
distribute('LOGIN', {
   email: string
});

分布条件类型的真实用例

举一个类似 redux 中的 dispatch 的例子。

首先,我们有一个联合类型 Action,用来表示所有可以被 dispatch 接受的参数类型:

ts 复制代码
type Action =
   | {
        type: 'INIT';
     }
   | {
        type: 'SYNC';
     }
   | {
        type: 'LOG_IN';
        emailAddress: string;
     }
   | {
        type: 'LOG_IN_SUCCESS';
        accessToken: string;
     };

然后我们定义这个 dispatch 方法:

ts 复制代码
declare function dispatch(action: Action): void;

// ok
dispatch({
   type: 'INIT'
});

// ok
dispatch({
   type: 'LOG_IN',
   emailAddress: 'david.sheldrick@artsy.net'
});

// ok
dispatch({
   type: 'LOG_IN_SUCCESS',
   accessToken: '038fh239h923908h'
});

这个 API 是类型安全的,当 TS 识别到 type 为 LOG_IN 的时候,它会要求你在参数中传入 emailAddress 这个参数,这样才能完全满足联合类型中的其中一项。

等等,我们好像可以让这个 api 变得更简单一点:

ts 复制代码
dispatch('LOG_IN_SUCCESS', {
   accessToken: '038fh239h923908h'
});

参数简化实现

首先,利用方括号选择出 Action 中的所有 type,这个技巧很有用。

ts 复制代码
type ActionType = Action['type'];
// => "INIT" | "SYNC" | "LOG_IN" | "LOG_IN_SUCCESS"

但是第二个参数的类型取决于第一个参数,我们可以使用类型变量来对该依赖关系建模。

ts 复制代码
declare function dispatch<T extends ActionType>(type: T, args: ExtractActionParameters<Action, T>): void;

注意,这里就用到了 extends 语法,规定了我们的入参 type 必须是 ActionType 中一部分。

注意这里的第二个参数 args 用 ExtractActionParameters<Action, T> 这个类型来把 type 和 args 做了关联

来看看 ExtractActionParameters 是如何实现的:

ts 复制代码
type ExtractActionParameters<A, T> = A extends { type: T } ? A : never;

在这次实战中,我们第一次运用到了条件类型,ExtractActionParameters<Action, T> 会按照我们上文提到的分布条件类型,把 Action 中的 4 项依次去和 { type: T } 进行比对,找出符合的那一项。

来看看如何使用它:

ts 复制代码
type Test = ExtractActionParameters<Action, 'LOG_IN'>;
// => { type: "LOG_IN", emailAddress: string }

这样就筛选出了 type 匹配的一项。

接下来我们要把 type 去掉,第一个参数已经是 type 了,因此我们不想再额外声明 type 了。

ts 复制代码
// 把类型中key为"type"去掉
type ExcludeTypeField<A> = { [K in Exclude<keyof A, 'type'>]: A[K] };

这里利用了 keyof 语法,并且利用内置类型 Exclude 把 type 这个 key 去掉,因此只会留下额外的参数。

ts 复制代码
type Test = ExcludeTypeField<{ type: 'LOG_IN'; emailAddress: string }>;
// { emailAddress: string }

然后用它来剔除参数中的 type

ts 复制代码
// 把参数对象中的type去掉
type ExtractActionParametersWithoutType<A, T> = ExcludeTypeField<ExtractActionParameters<A, T>>;
ts 复制代码
declare function dispatch<T extends ActionType>(type: T, args: ExtractActionParametersWithoutType<Action, T>): void;

到此为止,我们就可以实现上文中提到的参数简化功能:

ts 复制代码
// ok
dispatch({
   type: 'LOG_IN',
   emailAddress: 'david.sheldrick@artsy.net'
});

利用重载进一步优化

到了这一步为止,虽然带参数的 Action 可以完美支持了,但是对于 “INIT” 这种不需要传参的 Action,我们依然要写下面这样代码:

ts 复制代码
dispatch('INIT', {});

这肯定是不能接受的!所以我们要利用 TypeScript 的函数重载功能。

ts 复制代码
// 简单参数类型
function dispatch<T extends SimpleActionType>(type: T): void;

// 复杂参数类型
function dispatch<T extends ComplexActionType>(type: T, args: ExtractActionParametersWithoutType<Action, T>): void;

// 实现
function dispatch(arg: any, payload?: any) {}

那么关键点就在于 SimpleActionType 和 ComplexActionType 要如何实现了

SimpleActionType 顾名思义就是除了 type 以外不需要额外参数的 Action 类型

ts 复制代码
type SimpleAction = ExtractSimpleAction<Action>;

我们如何定义这个 ExtractSimpleAction 条件类型?如果我们从这个 Action 中删除 type 字段,并且结果是一个空的接口,那么这就是一个 SimpleAction,所以我们可能会凭直觉写出这样的代码:

ts 复制代码
type ExtractSimpleAction<A> = ExcludeTypeField<A> extends {} ? A : never;

但这样是行不通的,几乎所有的类型都可以 extends {},因为 {} 太宽泛了。

我们应该反过来写:

ts 复制代码
type ExtractSimpleAction<A> = {} extends ExcludeTypeField<A> ? A : never;

现在如果 ExcludeTypeField <A> 为空,则 extends 表达式为 true,否则为 false。

但这仍然行不通!因为分布条件类型仅在 extends 关键字的前面是类型变量时发生。

分布条件类型仅发生在如下场景:

ts 复制代码
type Blah<Var> = Var extends Whatever ? A : B;

而不是:

ts 复制代码
type Blah<Var> = Foo<Var> extends Whatever ? A : B;
type Blah<Var> = Whatever extends Var ? A : B;

但是我们可以通过一些小技巧绕过这个限制:

ts 复制代码
type ExtractSimpleAction<A> = A extends any ? ({} extends ExcludeTypeField<A> ? A : never) : never;

A extends any 是一定成立的,这只是用来绕过 ts 对于分布条件类型的限制,没错啊,我们的 A 确实是在 extends 的前面了,就是骗你 TS,这里是分布条件类型。

而我们真正想要做的条件判断被放在了中间,因此 Action 联合类型中的每一项又能够分布的去匹配了。

那么我们就可以简单的筛选出所有不需要额外参数的 type

ts 复制代码
type SimpleAction = ExtractSimpleAction<Action>;
type SimpleActionType = SimpleAction['type'];

再利用 Exclude 取反,找到复杂类型:

type ComplexActionType = Exclude<ActionType, SimpleActionType>

到此为止,我们所需要的功能就完美实现了:

ts 复制代码
// 简单参数类型
function dispatch<T extends SimpleActionType>(type: T): void;
// 复杂参数类型
function dispatch<T extends ComplexActionType>(type: T, args: ExtractActionParameters<Action, T>): void;
// 实现
function dispatch(arg: any, payload?: any) {}

// ok
dispatch('SYNC');

// ok
dispatch({
   type: 'LOG_IN',
   emailAddress: 'david.sheldrick@artsy.net'
});

完整代码

ts 复制代码
type Action =
   | {
        type: 'INIT';
     }
   | {
        type: 'SYNC';
     }
   | {
        type: 'LOG_IN';
        emailAddress: string;
     }
   | {
        type: 'LOG_IN_SUCCESS';
        accessToken: string;
     };

// 用类型查询查出 Action 中所有 type 的联合类型
type ActionType = Action['type'];

// 把类型中 key 为 type 去掉
type ExcludeTypeField<A> = { [K in Exclude<keyof A, 'type'>]: A[K] };

type ExtractActionParameters<A, T> = A extends { type: T } ? A : never;

// 把参数对象中的 type 去掉
// Extract<A, { type: T } 会挑选出能 extend { type: T } 这个结构的 Action 中的类型
type ExtractActionParametersWithoutType<A, T> = ExcludeTypeField<ExtractActionParameters<A, T>>;

type ExtractSimpleAction<A> = A extends any ? ({} extends ExcludeTypeField<A> ? A : never) : never;

type SimpleActionType = ExtractSimpleAction<Action>['type'];
type ComplexActionType = Exclude<ActionType, SimpleActionType>;

// 简单参数类型
function dispatch<T extends SimpleActionType>(type: T): void;
// 复杂参数类型
function dispatch<T extends ComplexActionType>(type: T, args: ExtractActionParametersWithoutType<Action, T>): void;
// 实现
function dispatch(arg: any, payload?: any) {}

dispatch('SYNC');

dispatch('LOG_IN', {
   emailAddress: 'ssh@qq.com'
});

Ref 类型从零实现

ts 复制代码
const count = ref(ref(ref(ref(2))));

需要支持嵌套后解包,最后只会剩下 { value: number } 这个类型。

泛型的反向推导

泛型的正向用法很多人都知道了。

ts 复制代码
type Value<T> = T;

type NumberValue = Value<number>;

这样,NumberValue 解析出的类型就是 number,其实就类似于类型系统里的传参。

那么反向推导呢?

ts 复制代码
function create<T>(val: T): T;

let num: number;

const c = create(num);

这里泛型没有传入,居然也能推断出 value 的类型是 number。

因为 create<T> 这里的泛型 T 被分配给了传入的参数 value: T,然后又用这个 T 直接作为返回的类型,

简单来说,这里的三个 T 被关联起来了,并且在传入 create(2) 的那一刻,这个 T 被统一推断成了 number。

ts 复制代码
function create<2>(value: 2): 2

索引签名

假设我们有一个这样的类型:

ts 复制代码
type Test = {
   foo: number;
   bar: string;
};

type N = Test['foo']; // number

可以通过类似 JavaScript 中的对象属性查找的语法来找出对应的类型。

条件类型

假设我们有一个这样的类型:

ts 复制代码
type IsNumber<T> = T extends number ? 'yes' : 'no';

type A = IsNumber<2>; // yes
type B = isNumber<'3'>; // no

这就是一个典型的条件类型,用 extends 关键字配合三元运算符来判断传入的泛型是否可分配给 extends 后面的类型。

同时也支持多层的三元运算符(后面会用到):

ts 复制代码
type TypeName<T> = T extends string ? 'string' : T extends boolean ? 'boolean' : 'object';

type T0 = TypeName<string>; // "string"
type T1 = TypeName<'a'>; // "string"
type T2 = TypeName<true>; // "boolean"

keyof

keyof 操作符是 TS 中用来获取对象的 key 值集合的,比如:

ts 复制代码
type Obj = {
   foo: number;
   bar: string;
};

type Keys = keyof Obj; // "foo" | "bar"

这样就轻松获取到了对象 key 值的联合类型:“foo” | “bar”。

它也可以用在遍历中:

ts 复制代码
type Obj = {
   foo: number;
   bar: string;
};

type Copy = {
   [K in keyof Obj]: Obj[K];
};

// Copy 得到和 Obj 一模一样的类型

可以看出,遍历的过程中右侧也可以通过索引直接访问到原类型 Obj 中对应 key 的类型。

infer

这是一个比较难的点,文档中对它的描述是条件类型中的类型推断。

它的出现使得 ReturnType、 Parameters 等一众工具类型的支持都成为可能,是 TypeScript 进阶必须掌握的一个知识点了。

注意前置条件,它一定是出现在条件类型中的。

ts 复制代码
type Get<T> = T extends infer R ? R : never;

注意,infer R 的位置代表了一个未知的类型,可以理解为在条件类型中给了它一个占位符,然后就可以在后面的三元运算符中使用它。

ts 复制代码
type T = Get<number>;

// 经过计算
type Get<number> = number extends infer number ? number : never;

// 得到
number;

它的使用非常灵活,它也可以出现在泛型位置:

ts 复制代码
type Unpack<T> = T extends Array<infer R> ? R : T;
ts 复制代码
type NumArr = Array<number>
type U = Unpack<NumArr>

// 经过计算
type Unpack<Array<number>> = Array<number> extends Array<infer R> ? R : T

// 得到
number

仔细看看,是不是有那么点感觉了,它就是对于 extends 后面未知的某些类型进行一个占位 infer R,后续就可以使用推断出来的 R 这个类型。

评论

阅读上一篇

2021TWeb学习笔记
2021-10-25 10:34:12

阅读下一篇

前端开发环境配置
2021-09-30 13:51:29
目录
0%