前端新手指南:初探 TypeScript 与 React 的高效开发之路

222 阅读11分钟

在前端开发的领域中,React 已经成为构建用户界面的一大主力框架。然而,随着项目规模的扩大和代码复杂性的提升,JavaScript 的动态类型特性常常让开发者在维护和扩展代码时面临挑战。正是在这种背景下,TypeScript(TS)作为 JavaScript 的强类型扩展语言,逐渐成为 React 开发的理想搭档。

对于刚开始使用 TypeScript 编写 React 的前端同学来说,TS 提供的静态类型检查和丰富的开发工具支持,能够显著提升开发体验。借助 TypeScript,开发者可以更早地在编译阶段捕获潜在的错误,避免由于类型不一致导致的运行时问题。此外,TypeScript 的类型系统为组件和函数提供了更明确的接口定义,这不仅有助于提高代码的可读性和可维护性,还能使团队协作变得更加顺畅。

尽管在刚接触时,TypeScript 可能会让人感觉到一定的学习曲线,尤其是在处理类型推断、泛型等概念时需要一些适应时间。但一旦掌握了这些基础,开发者会发现 TypeScript 能大幅减少调试时间,并让代码更加健壮、可预测。对于刚入门的同学来说,使用 TypeScript 编写 React 代码不仅是在学习一项技能,更是为未来的高效开发和项目的长期维护打下坚实的基础。

如下是针对个人理解整理的一些基础内容,希望能帮助大家更快的上手。

基础的Prop类型举例

type AppProps = {
  message: string;
  count: number;
  disabled: boolean;
  /** 一个类型的数组 */
  names: string[];
  /** 字符串字面量 */
  status: "waiting" | "success";
  /** 含有已知属性的对象,运行时可以添加更多的属性 */
  obj: {
    id: string;
    title: string;
  };
  /** 对象数组(常用)*/
  objArr: {
    id: string;
    title: string;
  }[];
  /** 非原始值,不能访问任何属性,不太常用,但是可以用来作为占位符 */
  obj2: object;
  // 一个空对象但是没有需要必填的属性,不太常用,有点像React.Component<{}, State>
  obj3: {};
  // 字典对象有种任意类型的key值,和同样类型的value值
  dict1: {
    [key: string]: MyTypeHere;
  };
  dict2: Record<string, MyTypeHere>; // 和dict1等价
  // 没有任何参数和返回值的函数,很常用
  onClick: () => void;
  /** 有着具名参数的函数,没有返回值,很常用 */
  onChange: (id: number) => void;
  /** 参数是事件的函数,没有返回值,很常用 */
  onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
  /** 另一个参数是事件的函数,没有返回值,很常用 */
  onClick(event: React.MouseEvent<HTMLButtonElement>): void;
  /** 声明任何你不打算去调用的函数,不推荐使用这个声明 */
  onSomething: Function;
  /** 可选属性,很常用 */
  optional?: OptionalType;
  /** hooks中useState的设置函数类型,number可以认为是泛型 */
  setState: React.Dispatch<React.SetStateAction<number>>;
};

很有用的React Prop类型举例

export declare interface AppProps {
  // 最常用的,泛指React可以render的任何内容
  children?: React.ReactNode; 
  // 单个React.ReactElement
  childrenElement: React.JSX.Element; 
  // 传递样式属性
  style?: React.CSSProperties; 
  // 表单事件,指定的泛型参数是event.target的类型
  onChange?: React.FormEventHandler<HTMLInputElement>; 
}
/**
    编辑器推断为:
    const MyComponent: (props: {
        foo: number;
        bar: string;
    }) => React.JSX.Element
*/
const MyComponent = (props: { foo: number, bar: string }) => <div />;
/**
    编辑器推断为:
    type MyComponentProps = {
        foo: number;
        bar: string;
    }
*/
// 比较好用的一个工具类,直接从组件从提取props类型
type MyComponentProps = React.ComponentProps<typeof MyComponent>;
// 这个地方既可以接受组件类型,也可以接受字符串,字符串一般是svg或者html元素的字面量
/**
    编辑器推断为:
    type ButtonComponentProps = React.ClassAttributes<HTMLButtonElement> & React.ButtonHTMLAttributes<HTMLButtonElement>
*/
type ButtonComponentProps = React.ComponentProps<'button'>;

// 看到上面有个ClassAttributes,我们再来分析一下它
interface ClassAttributes<T> extends RefAttributes<T> {
}
// 可以推测出ButtonComponentProps上会含有一个可有可无的ref属性
// 那么我们日常开发的过程中大概率是不会用到ref的,所以呢官方就推荐了另外两个工具类给我们
// ComponentPropsWithRef和ComponentPropsWithoutRef,官方建议我们显式地去声明一下
interface RefAttributes<T> extends Attributes {
    ref?: LegacyRef<T> | undefined;
}

React.JSX.Element与React.ReactNode的区别

  • React.JSX.ElementReact.createElement函数的返回值,返回的是一个对象。

  • React.ReactNode是组件的返回值。

可以看一下类型声明

type ReactNode =
        | ReactElement
        | string
        | number
        | Iterable<ReactNode>
        | ReactPortal
        | boolean
        | null
        | undefined
        | DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_REACT_NODES[
            keyof DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_REACT_NODES
        ];
     
// 这个结构可以理解成虚拟dom
interface ReactElement<
    P = any,
    T extends string | JSXElementConstructor<any> = string | JSXElementConstructor<any>,
> {
    type: T;
    props: P;
    key: string | null;
}
   
namespace JSX {
    // JSX.Element是ReactElement的子集
    interface Element extends React.ReactElement<any, any> {}
}

可以得出结论ReactNode包含了JSX.Element

Type与Interface的选择

在定义公共 API 时使用 interface

  • 在编写库或第三方环境类型定义时,建议使用 interface 而不是 type
  • 主要原因是 interface 支持 声明合并(declaration merging),这意味着如果使用者需要在后续添加额外的属性或扩展接口,可以很方便地进行扩展。
// 存在于antd的三方包中
interface ButtonProps {
    timeout: number;
}

// 之后,使用者可以在他们自己的代码中扩展它
// 举个例子,如果想对antd的ButtonProps进行拓展

declare module 'antd5' {
    interface ButtonProps {
        kk: number;
    }
}

import { ButtonProps } from 'antd';

function TestButton(a: ButtonProps) {
  const { kk } = a;
  return <div>{kk}</div>;
}

React 组件的 Props State 中使用 type

  • type 更适合在这里使用,因为它更具 约束性,并且更适合 React 组件的需求。
  • React 组件的 propsstate 通常不需要支持声明合并,type 在定义联合类型、映射和组合时更加灵活,因此在定义组件时使用 type 更合适。
type MyComponentProps = {
    title: string;
    isVisible: boolean;
};

const MyComponent: React.FC<MyComponentProps> = ({ title, isVisible }) => {
    return <h1>{isVisible ? title : null}</h1>;
};

简单来说:

  • 使用 interface 保持 可扩展性和灵活性,适用于 公共 API
  • 使用 type 保持 简单性和一致性,适用于 React 组件

函数组件

// 声明属性的类型
type AppProps = {
  message: string;
}; 

// 最简单的声明函数式组件的方式,返回类型让TS去推断
const App = ({ message }: AppProps) => <div>{message}</div>;

// 标注返回类型,这样如果返回值类型不匹配的话就会报错
const App = ({ message }: AppProps): React.JSX.Element => <div>{message}</div>;

// 内联的方式来声明,不过看起来有些多余,类型也没有办法被导出
const App = ({ message }: { message: string }) => <div>{message}</div>;

// 可以使用React.FunctionComponent和React.FC加泛型的方式来声明
const App: React.FunctionComponent<{ message: string }> = ({ message }) => (
  <div>{message}</div>
);
// 或者
const App: React.FC<AppProps> = ({ message }) => <div>{message}</div>;

Hooks

useState

// 处理基础类型
const [state, setState] = useState(false);
// `state` 被推断为boolean类型
// `setState` 只能接受boolean类型的参数
// 处理引用类型
// 方法1
const [user, setUser] = useState<User | null>(null);
// 使用
setUser(newUser);
// 方法2
const [user, setUser] = useState<User>({} as User);
// 使用
setUser(newUser);

useCallback

const memoizedCallback = useCallback(
  (param1: string, param2: number) => {
    console.log(param1, param2)
    return { ok: true }
  },
  [...],
);
/**
 * 编辑器会展示如下的类型
 * const memoizedCallback:
 *  (param1: string, param2: number) => { ok: boolean }
 */

useCallback的签名在React18版本前后有些不同

React<18

function useCallback<T extends (...args: any[]) => any>(
  callback: T,
  deps: DependencyList
): T;

React>=18

function useCallback<T extends Function>(callback: T, deps: DependencyList): T;

useReducer

import { useReducer } from "react";

const initialState = { count: 0 };

type ACTIONTYPE =
  | { type: "increment"; payload: number }
  | { type: "decrement"; payload: string };

function reducer(state: typeof initialState, action: ACTIONTYPE) {
  switch (action.type) {
    case "increment":
      return { count: state.count + action.payload };
    case "decrement":
      return { count: state.count - Number(action.payload) };
    default:
      throw new Error();
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <>
      Count: {state.count}
      <button onClick={() => dispatch({ type: "decrement", payload: "5" })}>
        -
      </button>
      <button onClick={() => dispatch({ type: "increment", payload: 5 })}>
        +
      </button>
    </>
  );
}

useEffect / useLayoutEffect

function DelayedEffect(props: { timerMs: number }) {
  const { timerMs } = props;

  useEffect(
    () =>
      setTimeout(() => {
        /* 其他代码 */
      }, timerMs),
    [timerMs]
  );
  // 注意一下这个地方,setTimeout会返回一个数字,我们的箭头函数没有用大括号进行包裹,那么useEffect也会返回一个number类型,使用下面的方式更好一些
  return null;
}

function DelayedEffect(props: { timerMs: number }) {
  const { timerMs } = props;

  useEffect(() => {
    setTimeout(() => {
      /* 其他代码 */
    }, timerMs);
  }, [timerMs]);
  return null;
}

useRef

DOM
function Foo() {
  // 如果可能的话,这个地方尽可能具体一下,`HTMLDivElement`比`HtmlElement`和`Element`更好。
  const divRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    // 注意在你忘记赋值或者dom节点条件渲染的情况下,ref.current有可能是空的
    if (!divRef.current) throw Error("divRef is not assigned");
    // 这个时候的divRef.current就确定为HTMLDivElement了
    doSomethingWith(divRef.current);
  });
  // 将ref赋值给元素,以便React可以帮你管理它
  return <div ref={divRef}>etc</div>;
}

// 如果你确定divRef.current永远不为空的话,可以添加!
const divRef = useRef<HTMLDivElement>(null!);
// 后面操作的代码就可以不用判断是否为空了
doSomethingWith(divRef.current);
Mutable value ref
function Foo() {
  // 编辑器推断的返回值为MutableRefObject<number | null>
  const intervalRef = useRef<number | null>(null);

  // 你也可以自己去管理它
  useEffect(() => {
    intervalRef.current = setInterval(...);
    return () => clearInterval(intervalRef.current);
  }, []);

  // 这个ref没有被传递到htmlElement上
  return <button onClick={/* clearInterval the ref */}>Cancel timer</button>;
}

useImperativeHandle

// Countdown.tsx

// 定义要被传入到forwardRef上的处理类型
export type CountdownHandle = {
  start: () => void;
};

type CountdownProps = {};

const Countdown = forwardRef<CountdownHandle, CountdownProps>((props, ref) => {
  useImperativeHandle(ref, () => ({
    start() {
      alert("Start");
    },
  }));

  return <div>Countdown</div>;
});
// 使用Countdown的组件

import Countdown, { CountdownHandle } from "./Countdown.tsx";

function App() {
  const countdownEl = useRef<CountdownHandle>(null);

  useEffect(() => {
    if (countdownEl.current) {
      // 这个时候就可以推断出countdownEl的类型,并给出提示
      countdownEl.current.start();
    }
  }, []);

  return <Countdown ref={countdownEl} />;
}

自定义hooks

import { useState } from "react";

export function useLoading() {
  const [isLoading, setState] = useState(false);
  const load = (aPromise: Promise<any>) => {
    setState(true);
    return aPromise.finally(() => setState(false));
  };
  /**
     这个地方注意一下,如果as const会推断为元祖,否则会推断为任意类型的数组
     有const 声明的推断
    function useLoading(): readonly [any, (aPromise: Promise<any>) => Promise<any>]
    没有as const的推断
    function useLoading(): any[]
  */
  return [isLoading, load] as const; 
}

类组件

type MyProps = {
  message: string;
};
type MyState = {
  count: number; 
};
class App extends React.Component<MyProps, MyState> {
  state: MyState = {
    count: 0,
  };
  render() {
    return (
      <div>
        {this.props.message} {this.state.count}
      </div>
    );
  }
}

表单与事件

// 如果将函数直接定义在jsx中,那么类型将会被自动处理
const el = (
  <button
    onClick={(event) => {
      /* event will be correctly typed automatically! */
    }}
  />
);

// 如果分开定义的话,我们就需要指定事件类型了
type State = {
  text: string;
};
class App extends React.Component<Props, State> {
  state = {
    text: "",
  };

  // 类型声明在等号的右边
  onChange = (e: React.FormEvent<HTMLInputElement>): void => {
    this.setState({ text: e.currentTarget.value });
  };
  /**
     类型声明在等号的左边
    onChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
    this.setState({text: e.currentTarget.value})
  }   
  */
  render() {
    return (
      <div>
        <input type="text" value={this.state.text} onChange={this.onChange} />
      </div>
    );
  }
}

如果不是特别关心事件类型的话,可以使用React.SyntheticEvent,举一个form的例子

<form
  ref={formRef}
  onSubmit={(e: React.SyntheticEvent) => {
    e.preventDefault();
    const target = e.target as typeof e.target & {
      email: { value: string };
      password: { value: string };
    };
    const email = target.email.value; // typechecks!
    const password = target.password.value; // typechecks!
    // etc...
  }}
>
  <div>
    <label>
      Email:
      <input type="email" name="email" />
    </label>
  </div>
  <div>
    <label>
      Password:
      <input type="password" name="password" />
    </label>
  </div>
  <div>
    <input type="submit" value="Log in" />
  </div>
</form>

常用事件列表

Event TypeDescription
ChangeEventinput,select,textarea元素值的改变
FocusEvent当元素获取或丢失节点的时候
FormEvent当表单和表单元素获取或者失去焦点,表单元素值改变或者表单被提交时触发。
KeyboardEvent用户与键盘的交互
MouseEvent用户与鼠标的交互
SyntheticEvent上面所有事件的基类,如果不确定用哪个的话可以用这个
// 放一个简单的事件使用的例子
"use client";
import {
  useState,
  ChangeEvent,
  FocusEvent,
  FormEvent,
  MouseEvent,
  KeyboardEvent,
} from "react";

export default function Home() {
  const [name, setName] = useState("");
  return (
    <form
      onSubmit={(e: FormEvent<HTMLFormElement>) => {
        e.preventDefault();
        const formData = new FormData(e.currentTarget);
        console.log("🚀 ~ Home ~ formData.get('name'):", formData.get('name'))
      }}
    >
      <div>
        <label htmlFor="name">姓名</label>
        <input
          name="name"
          id="name"
          value={name}
          onChange={(e: ChangeEvent<HTMLInputElement>) =>
            setName(e.target.value)
          }
          onFocus={(e: FocusEvent<HTMLInputElement>) =>
            console.log(e.target.value, "focus")
          }
          onClick={(e: MouseEvent<HTMLInputElement>) =>
            console.log(e.currentTarget, "click")
          }
          onKeyUp={(e: KeyboardEvent<HTMLInputElement>) =>
            console.log(e.key, "keyup")
          }
        />
      </div>
      <button type="submit">提交</button>
    </form>
  );
}

Context

import { createContext } from "react";

// 定义Context及其类型
type ThemeContextType = "light" | "dark";

const ThemeContext = createContext<ThemeContextType>("light");


import { useState } from "react";

// 定义Provider
const App = () => {
  const [theme, setTheme] = useState<ThemeContextType>("light");
  return (
    <ThemeContext.Provider value={theme}>
      <MyComponent />
    </ThemeContext.Provider>
  );
};

// 使用
import { useContext } from "react";

const MyComponent = () => {
  const theme = useContext(ThemeContext);

  return <p>The current theme is {theme}.</p>;
};

没有默认值的Context

import { createContext } from "react";

interface CurrentUserContextType {
  username: string;
}

const CurrentUserContext = createContext<CurrentUserContextType | null>(null);

const App = () => {
  const [currentUser, setCurrentUser] = useState<CurrentUserContextType>({
    username: "filiptammergard",
  });

  return (
    <CurrentUserContext.Provider value={currentUser}>
      <MyComponent />
    </CurrentUserContext.Provider>
  );
};

import { useContext } from "react";

const MyComponent = () => {
  const currentUser = useContext(CurrentUserContext);

  return <p>Name: {currentUser?.username}.</p>;
};

另一种更好的方式是不去检查null类型,因为我们知道context不会是null,可以简单的进行异常抛出

import { createContext } from "react";

interface CurrentUserContextType {
  username: string;
}

const CurrentUserContext = createContext<CurrentUserContextType | null>(null);

const useCurrentUser = () => {
  const currentUserContext = useContext(CurrentUserContext);

  if (!currentUserContext) {
    throw new Error(
      "useCurrentUser has to be used within <CurrentUserContext.Provider>"
    );
  }

  return currentUserContext;
};

也可以使用如下断言的方式来处理


// 方法一
import { useContext } from "react";

const MyComponent = () => {
  const currentUser = useContext(CurrentUserContext);

  return <p>Name: {currentUser!.username}.</p>;
};

// 方法二
const CurrentUserContext = createContext<CurrentUserContextType>(
  {} as CurrentUserContextType
);

// 方法三
const CurrentUserContext = createContext<CurrentUserContextType>(null!);

forwardRef/createRef

createRef

import { createRef, PureComponent } from "react";

class CssThemeProvider extends PureComponent<Props> {
  private rootRef = createRef<HTMLDivElement>(); // like this
  render() {
    return <div ref={this.rootRef}>{this.props.children}</div>;
  }
}

forwardRef

import { forwardRef, ReactNode } from "react";

interface Props {
  children?: ReactNode;
  type: "submit" | "button";
}
export type Ref = HTMLButtonElement;

export const FancyButton = forwardRef<Ref, Props>((props, ref) => (
  <button ref={ref} className="MyClassName" type={props.type}>
    {props.children}
  </button>
));

泛型相关的forwardRefs(不常用)

将Ref放到属性里面

type ClickableListProps<T> = {
  items: T[];
  onSelect: (item: T) => void;
  mRef?: React.Ref<HTMLUListElement> | null;
};

export function ClickableList<T>(props: ClickableListProps<T>) {
  return (
    <ul ref={props.mRef}>
      {props.items.map((item, i) => (
        <li key={i}>
          <button onClick={(el) => props.onSelect(item)}>Select</button>
          {item}
        </li>
      ))}
    </ul>
  );
}

重新声明forwardRef

// 重新声明 forwardRef
declare module "react" {
  function forwardRef<T, P = {}>(
    render: (props: P, ref: React.Ref<T>) => React.ReactElement | null
  ): (props: P & React.RefAttributes<T>) => React.ReactElement | null;
}

import { forwardRef, ForwardedRef } from "react";

interface ClickableListProps<T> {
  items: T[];
  onSelect: (item: T) => void;
}

function ClickableListInner<T>(
  props: ClickableListProps<T>,
  ref: ForwardedRef<HTMLUListElement>
) {
  return (
    <ul ref={ref}>
      {props.items.map((item, i) => (
        <li key={i}>
          <button onClick={(el) => props.onSelect(item)}>Select</button>
          {item}
        </li>
      ))}
    </ul>
  );
}

export const ClickableList = forwardRef(ClickableListInner);

更改调用签名

// 添加到 `index.d.ts`
interface ForwardRefWithGenerics extends React.FC<WithForwardRefProps<Option>> {
  <T extends Option>(props: WithForwardRefProps<T>): ReturnType<
    React.FC<WithForwardRefProps<T>>
  >;
}

export const ClickableListWithForwardRef: ForwardRefWithGenerics =
  forwardRef(ClickableList);

Portals

// 假定在跟root同级的地方有一个#modal-root元素
const modalRoot = document.getElementById("modal-root") as HTMLElement;

export class Modal extends React.Component<{ children?: React.ReactNode }> {
  el: HTMLElement = document.createElement("div");

  componentDidMount() {
    modalRoot.appendChild(this.el);
  }

  componentWillUnmount() {
    modalRoot.removeChild(this.el);
  }

  render() {
    return ReactDOM.createPortal(this.props.children, this.el);
  }
}

// hooks版本
import { useEffect, useRef, ReactNode } from "react";
import { createPortal } from "react-dom";

const modalRoot = document.querySelector("#modal-root") as HTMLElement;

type ModalProps = {
  children: ReactNode;
};

function Modal({ children }: ModalProps) {
  const elRef = useRef<HTMLDivElement | null>(null);
  if (!elRef.current) elRef.current = document.createElement("div");

  useEffect(() => {
    const el = elRef.current!; // 非空断言,因为这个地方不会是空的
    modalRoot.appendChild(el);
    return () => {
      modalRoot.removeChild(el);
    };
  }, []);

  return createPortal(children, elRef.current);
}

使用示例

import { useState } from "react";

function App() {
  const [showModal, setShowModal] = useState(false);

  return (
    <div>
      // you can also put this in your static html file
      <div id="modal-root"></div>
      // 这个地方要注意一下事件冒泡
      {showModal && (
        <Modal>
          <div
            style={{
              display: "grid",
              placeItems: "center",
              height: "100vh",
              width: "100vh",
              background: "rgba(0,0,0,0.1)",
              zIndex: 99,
            }}
          >
            I'm a modal!{" "}
            <button
              style={{ background: "papyawhip" }}
              onClick={() => setShowModal(false)}
            >
              close
            </button>
          </div>
        </Modal>
      )}
      <button onClick={() => setShowModal(true)}>show Modal</button>
      // rest of your app
    </div>
  );
}

Error Boundaries

React-error-boundary

内置TS支持的轻量级的包。

自定义

import React, { Component, ErrorInfo, ReactNode } from "react";

interface Props {
  children?: ReactNode;
}

interface State {
  hasError: boolean;
}

class ErrorBoundary extends Component<Props, State> {
  public state: State = {
    hasError: false
  };

  public static getDerivedStateFromError(_: Error): State {
    return { hasError: true };
  }

  public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
    console.error("Uncaught error:", error, errorInfo);
  }

  public render() {
    if (this.state.hasError) {
      return <h1>Sorry.. there was an error</h1>;
    }

    return this.props.children;
  }
}

export default ErrorBoundary;

通过这些例子,我们可以看到,TypeScript 在 React 开发中的优势不止于提供类型检查,它更像是一位智能的助手,帮助我们写出更安全、易维护的代码。虽然对于刚入门的前端开发者来说,TS 的学习过程可能会有些复杂和不适应,但它所带来的长远收益是显而易见的——从减少运行时错误,到提升团队协作效率,再到为代码提供清晰的结构和文档。

作为刚开始使用 TypeScript 编写 React 的开发者,最重要的是保持耐心,逐步适应类型系统,并从小项目开始积累经验。相信随着时间的推移,TypeScript 会成为你开发工具箱中不可或缺的一部分,让你在编写 React 应用时更加自信和高效。拥抱这个学习曲线,不仅能帮助你编写出更强健的代码,还能让你在日后的开发中走得更远更顺畅。