Typescript在react項目中的實踐

一、理解 Typescript 配置文件

熟悉 Typescript 配置文件是 TS 項目開發的最基本要求。TS 使用 tsconfig.json 作為其配置文件,它主要包含兩塊內容:

1.指定待編譯的文件
2.定義編譯選項

我們都知到TS項目的編譯命令為tsc,該命令就是使用項目根路徑下的tsconfig.json文件,對項目進行編譯。

簡單的配置示例如下:

{
  "compilerOptions": {
    "module": "commonjs",
    "noImplicitAny": true,
    "removeComments": true,
    "preserveConstEnums": true,
    "sourceMap": true
  },
  "files": [
    "app.ts",
    "foo.ts",
  ]
}

其中,compilerOptions 用來配置編譯選項,files 用來指定待編譯文件。這里的待編譯文件是指入口文件,任何被入口文件依賴的文件都將包括在內。

也可以使用 include 和 exclude 來指定和排除待編譯文件:

{
  "compilerOptions": {
    "module": "commonjs",
    "noImplicitAny": true,
    "removeComments": true,
    "preserveConstEnums": true,
    "sourceMap": true
  },
  "include": [
    "src/**/*"
  ],
  "exclude": [
    "node_modules",
    "**/*.spec.ts"
  ]
}
/*************************************
            exclude中的通配符
* :匹配 0 或多個字符(注意:不含路徑分隔符)
? :匹配任意單個字符(注意:不含路徑分隔符)
**/ :遞歸匹配任何子路徑
**************************************/

即指定待編譯文件有兩種方式:

  • 使用 files 屬性
  • 使用 include 和 exclude 屬性

這里進行編譯的文件都是TS文件(拓展名為 .ts、.tsx 或 .d.ts 的文件)

  • 如果 files 和 include 都未設置,那么除了 exclude 排除的文件,編譯器會默認包含路徑下的所有 TS 文件。
  • 如果同時設置 files 和 include ,那么編譯器會把兩者指定的文件都引入。
  • exclude 只對 include 有效,對 files 無效。即 files 指定的文件如果同時被 exclude 排除,那么該文件仍然會被編譯器引入。

常用的編譯配置如下:

配置項字段名 默認值 說明
target es3 生成目標語言的版本
allowJs false 允許編譯 JS 文件
noImplicitAny false 存在隱式 any 時拋錯
jsx Preserve 在 .tsx 中支持 JSX :React 或 Preserve
noUnusedLocals false 檢查只聲明、未使用的局部變量(只提示不報錯)
noImplicitThis false this 可能為 any 時拋錯
noImplicitReturns false 不存在 return 時拋錯
types 默認的,所有位于 node_modules/@types 路徑下的模塊都會引入到編譯器 如果指定了types,只有被列出來的包才會被包含進來。

對于types 選項,有一個普遍的誤解,以為這個選項適用于所有的類型聲明文件,包括用戶自定義的聲明文件,其實不然。這個選項只對通過 npm 安裝的聲明模塊有效,用戶自定義的類型聲明文件與它沒有任何關系。默認的,所有位于 node_modules/@types 路徑下的模塊都會引入到編譯器。如果不希望自動引入node_modules/@types路徑下的所有聲明模塊,那可以使用 types 指定自動引入哪些模塊。比如:

{
  "compilerOptions": {
    "types" : ["node", "lodash", "express"]
  }
}
//此時只會引入 node 、 lodash 和 express 三個聲明模塊,其它的聲明模塊則不會被自動引入。

配置復用

//建立一個基礎的配置文件 configs/base.json 
{
  "compilerOptions": {
    "noImplicitAny": true,
    "strictNullChecks": true
  }
}
//tsconfig.json 就可以引用這個文件的配置了:
{
  "extends": "./configs/base",
  "files": [
    "main.ts",
    "supplemental.ts"
  ]
}

二、Typescript在React中的應用

1. 無狀態組件

無狀態組件也被稱為展示型組件。在部分時候,它們也是純函數組件

在@types/react中已經預定義一個類型type SFC,它也是類型interface StatelessComponent的一個別名,此外,它已經有預定義的children和其他(defaultProps、displayName等等…),所以在寫無狀態組件時我們可以直接使用SFC

import React, { MouseEvent, SFC } from 'react';

type Props = { 
  onClick(e: MouseEvent<HTMLElement>): void 
};

const Button: SFC<Props> = ({ 
  onClick: handleClick, 
  children 
}) => (
  <button onClick={handleClick}>{children}</button>
);

2. 有狀態組件

我們知道我們在React中不能像下面這樣直接更新state:

this.state.clicksCount = 2;

我們應當通過setState來維護狀態機,但上述寫法,在ts編譯時并不會報錯。此時我們可以作如下限制:

const initialState = { clicksCount: 0 }

/*使用TypeScript來從我們的實現中推斷出State的類型。
好處是:這樣我們不需要分開維護我們的類型定義和實現*/
type State = Readonly<typeof initialState>

class ButtonCounter extends Component<object, State> {
  /*至此我們定義了類上的state屬性,及state其中的各屬性均為只讀*/
  readonly state: State = initialState;

  doBadthing(){
    this.state.clicksCount = 2; //設置后,該寫法編譯報錯
    this.state = { clicksCount: 2 } //設置后,該寫法編譯報錯
  }
}

3.處理組件的默認屬性

如果使用的typescript是3.x的版本的話,就不用擔心這個問題,就直接在jsx中使用defaultProps就可以了。如果使用的是2.x的版本就要關注下述問題了

如果我們想定義默認屬性,我們可以在我們的組件中通過以下代碼定義

type Props = {
  onClick(e: MouseEvent<HTMLElement>): void;
  color?: string;
};

const Button: SFC<Props> = (
{ 
  onClick: handleClick, 
  color, 
  children 
}) => (
  <button style={{ color }} onClick={handleClick}>
    {children}
  </button>
);
Button.defaultProps = {…}

在strict mode模式下,會有這有一個問題,可選的屬性color的類型是一個聯合類型undefined | string。因此,在對color屬性做一些操作時,TS會報錯。因為它并不知道它在React創建中通過Component.defaultProps中已經定義了默認屬性

在這里我采取的方案是,構建可復用的高階函數withDefaultProps,統一由他來更新props類型定義和設置默認屬性。

export const withDefaultProps = 
< P extends object, DP extends Partial<P> = Partial<P> >
(
  defaultProps: DP,
  Cmp: ComponentType<P>,
) => {
  // 提取出必須的屬性
  type RequiredProps = Omit<P, keyof DP>;
  // 重新創建我們的屬性定義,通過一個相交類型,將所有的原始屬性標記成可選的,必選的屬性標記成可選的
  type Props = Partial<DP> & Required<RequiredProps>;

  Cmp.defaultProps = defaultProps;

  // 返回重新的定義的屬性類型組件,通過將原始組件的類型檢查關閉,然后再設置正確的屬性類型
  return (Cmp as ComponentType<any>) as ComponentType<Props>;
};

此時,可以使用withDefaultProps高階函數來定義我們的默認屬性

const defaultProps = {
  color: 'red',
};

type DefaultProps = typeof defaultProps;
type Props = { onClick(e: MouseEvent<HTMLElement>): void } & DefaultProps;

const Button: SFC<Props> = ({ onClick: handleClick, color, children }) => (
  <button style={{ color }} onClick={handleClick}>
    {children}
  </button>
);

const ButtonWithDefaultProps = withDefaultProps(defaultProps, Button);

組件使用如下

render() {
    return (
        <ButtonWithDefaultProps
            onClick={this.handleIncrement}
        >
            Increment
        </ButtonWithDefaultProps>
    )
}

4. 范型組件

范型是指在定義函數、接口或類的時候,不預先指定具體的類型,而在使用的時候再指定類型的一種特性。

interface Props<T> {
  content: T;
}

上述代碼表明 Props 接口定義了這么一種類型:

  • 它是包含一個 content 字段的對象
  • 該 content 字段的類型由使用時的泛型 T 決定

泛型函數:

function Foo<T>(props: Props<T>) {
  console.log(props);
}

/** 此時 Foo 的完整簽名為: function Foo<number>(props: Props<number>): void */
Foo({ content: 42 });

/** 此時 Foo 的完整簽名為: function Foo<string>(props: Props<string>): void */
Foo({ content: "hello" });

泛型組件:
將上面的 Foo 函數返回 JSX 元素,就成了一個 React 組件。因為它是泛型函數,它所形成的組件也就成了 泛型組件。當然你很可能會思考泛型組件的用途。

思考下面的實踐:

import React, { Fragment, PureComponent } from 'react';

interface IYarn {
  ...
}

export interface IProps {
  total: number;
  list: IYarn[];
  title: string;
}

class YarnList  extends PureComponent<IProps> {

}

上述組件就是用于展示一個列表,其實列表中的分頁加載、滾動刷新邏輯等對于所有列表而言都是通用的,當我們想重復利用該容器組件時,你很可能會發現,不同的業務中列表中的屬性字段并不通用。

此時,你為了盡可能滿足大部分數據類型,你很可能將列表的元素類型做如下定義:

interface IYarn {
  [prop: string]: any;
}

interface IProps {
  total: number;
  list: IYarn[];
  title: string;
}

const YarnList: SFC<IProps> = ({ 
  list,total,title
}) => (
   <div>
        {list.map(
             ....
        )}
   </div>
);

在這里已經可以看到類型的丟失了,因為出現了 any,而我們使用 TypeScript 的首要準則是盡量避免 any

對于復雜類型,類型的丟失就完全享受不到 TypeScript 所帶來的類型便利了。

此時,我們就可以使用泛型,把類型傳遞進來。實現如下:

interface IProps<T> {
  total: number;
  list: T[];
  title: string;
}
const YarnList: SFC<IProps> = ({ 
  list,total,title
}) => (
   <div>
        <div>title</div>
        <div>total</div>
        {list.map(
             ....
        )}
   </div>
);

改造后,列表元素的類型完全由使用的地方決定,作為列表組件,內部它無須關心,同時對于外部傳遞的入參,類型也沒有丟失。

具體業務調用示例如下:

interface User {
  id: number;
  name: string;
}
const data: User[] = [
  {
    id: 1,
    name: "xsq"
  },
  {
    id: 2,
    name: "tracy"
  }
];

const App = () => {
  return (
    <div className="App">
      <YarnList list={data} title="xsq_test" total=2/>
    </div>
  );
};

5.在數據請求中的應用

假設我們對接口的約定如下:

{
  code: 200,
  message: "",
  data: {}
}
  • code代表接口的成功與失敗
  • message代表接口失敗之后的服務端消息輸出
  • data代表接口成功之后真正的邏輯

因此,我們可以對response定義的類型如下:

export enum StateCode {
  error = 400,
  ok = 200,
  timeout = 408,
  serviceError = 500
}

export interface IResponse<T> {
  code: StateCode;
  message: string;
  data: T;
}

接下來我們可以定義具體的一個數據接口類型如下:

export interface ICommodity {
  id: string;
  img: string;
  name: string;
  price: number;
  unit: string;
}

export interface IFavorites {
  id: string;
  img: string;
  name: string;
  url: string;
}

/*列表接口返回的數據格式*/
export interface IList {
  commodity: ICommodity[];
  groups: IFavorites[];
}

/*登錄接口返回的數據格式*/
export interface ISignIn{
  Id: string;
  name: string;
  avatar: string;
  permissions: number[];
}

通過開源請求庫 axios在項目中編寫可復用的請求方法如下:

const ROOT = "https://tracy.me"

interface IRequest<T> {
   path: string;
   data: T;
}

export function service<T>({ path, data}: IRequest<T>): Promise<IResponse>{
  return new Promise((resolve) => {
    const request: AxiosRequestConfig = {
      url: `${ROOT}/${path}`,
      method: "POST",
      data: data
    }
    axios(request).then((response: AxiosResponse<IResponse>) => {
      resolve(response.data);
    })
  });
}

在接口業務調用時:

service({
  path: "/list",
  data: {
    id: "xxx"
  }
}).then((response: IResponse<IList>) => {
  const { code, data } = response;
  if (code === StateCode.ok) {
    data.commodity.map((v: ICommodity) => {

    });
  }
})

此時,我們每一個接口的實現,都可以從約定的類型中得到 TypeScript 工具的支持


ts1.jpg

假設哪一天,后端同學突然要變更之前約定的接口字段,以往我們往往采取全局替換,但是當項目過于龐大時,個別字段的變更也是很棘手的,要準確干凈的替換往往不是易事

但此時,由于我們使用的是TypeScript。例如,我們配合后端同學,將前面ISignin接口中的name改成了nickname。

此時,在接口調用的位置,TS編譯器將給我們提供準確的定位與提示


ts2.jpg

隨著代碼量的增加,我們會從Typescript中獲取更多的收益,只是往往開始的時候會有些許苦澀,但與你的收益相比,還是值得的

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容