一、理解 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 工具的支持
假設哪一天,后端同學突然要變更之前約定的接口字段,以往我們往往采取全局替換,但是當項目過于龐大時,個別字段的變更也是很棘手的,要準確干凈的替換往往不是易事
但此時,由于我們使用的是TypeScript。例如,我們配合后端同學,將前面ISignin接口中的name改成了nickname。
此時,在接口調用的位置,TS編譯器將給我們提供準確的定位與提示
隨著代碼量的增加,我們會從Typescript中獲取更多的收益,只是往往開始的時候會有些許苦澀,但與你的收益相比,還是值得的