【TS】726- 編寫高效 TS 代碼的一些建議
本文阿寶哥將分享編寫高效 TS 代碼的 5 個建議,希望這些建議對大家編寫 TS 代碼能有一些幫助。
一、盡量減少重復(fù)代碼
對于剛接觸 TypeScript 的小伙伴來說,在定義接口時,可能一不小心會出現(xiàn)以下類似的重復(fù)代碼。比如:
interface?Person?{
??firstName:?string;
??lastName:?string;
}
interface?PersonWithBirthDate?{
??firstName:?string;
??lastName:?string;
??birth:?Date;
}
很明顯,相對于 Person 接口來說,PersonWithBirthDate 接口只是多了一個 birth 屬性,其他的屬性跟 Person 接口是一樣的。那么如何避免出現(xiàn)例子中的重復(fù)代碼呢?要解決這個問題,可以利用 extends 關(guān)鍵字:
interface?Person?{?
??firstName:?string;?
??lastName:?string;
}
interface?PersonWithBirthDate?extends?Person?{?
??birth:?Date;
}
當(dāng)然除了使用 extends 關(guān)鍵字之外,也可以使用交叉運算符(&):
type?PersonWithBirthDate?=?Person?&?{?birth:?Date?};
另外,有時候你可能還會發(fā)現(xiàn)自己想要定義一個類型來匹配一個初始配置對象的「形狀」,比如:
const?INIT_OPTIONS?=?{
??width:?640,
??height:?480,
??color:?"#00FF00",
??label:?"VGA",
};
interface?Options?{
??width:?number;
??height:?number;
??color:?string;
??label:?string;
}
其實,對于 Options 接口來說,你也可以使用 typeof 操作符來快速獲取配置對象的「形狀」:
type?Options?=?typeof?INIT_OPTIONS;
而在使用可辨識聯(lián)合(代數(shù)數(shù)據(jù)類型或標簽聯(lián)合類型)的過程中,也可能出現(xiàn)重復(fù)代碼。比如:
interface?SaveAction?{?
??type:?'save';
??//?...
}
interface?LoadAction?{
??type:?'load';
??//?...
}
type?Action?=?SaveAction?|?LoadAction;
type?ActionType?=?'save'?|?'load';?//?Repeated?types!
為了避免重復(fù)定義 'save' 和 'load',我們可以使用成員訪問語法,來提取對象中屬性的類型:
type?ActionType?=?Action['type'];?//?類型是?"save"?|?"load"
然而在實際的開發(fā)過程中,重復(fù)的類型并不總是那么容易被發(fā)現(xiàn)。有時它們會被語法所掩蓋。比如有多個函數(shù)擁有相同的類型簽名:
function?get(url:?string,?opts:?Options):?Promise<Response>?{?/*?...?*/?}?
function?post(url:?string,?opts:?Options):?Promise<Response>?{?/*?...?*/?}
對于上面的 get 和 post 方法,為了避免重復(fù)的代碼,你可以提取統(tǒng)一的類型簽名:
type?HTTPFunction?=?(url:?string,?opts:?Options)?=>?Promise;?
const?get:?HTTPFunction?=?(url,?opts)?=>?{?/*?...?*/?};
const?post:?HTTPFunction?=?(url,?opts)?=>?{?/*?...?*/?};
二、使用更精確的類型替代字符串類型
假設(shè)你正在構(gòu)建一個音樂集,并希望為專輯定義一個類型。這時你可以使用 interface 關(guān)鍵字來定義一個 Album 類型:
interface?Album?{
??artist:?string;?//?藝術(shù)家
??title:?string;?//?專輯標題
??releaseDate:?string;?//?發(fā)行日期:YYYY-MM-DD
??recordingType:?string;?//?錄制類型:"live"?或?"studio"
}
對于 Album 類型,你希望 releaseDate 屬性值的格式為 YYYY-MM-DD,而 recordingType 屬性值的范圍為 live 或 studio。但因為接口中 releaseDate 和 recordingType 屬性的類型都是字符串,所以在使用 Album 接口時,可能會出現(xiàn)以下問題:
const?dangerous:?Album?=?{
??artist:?"Michael?Jackson",
??title:?"Dangerous",
??releaseDate:?"November?31,?1991",?//?與預(yù)期格式不匹配
??recordingType:?"Studio",?//?與預(yù)期格式不匹配
};
雖然 releaseDate 和 recordingType 的值與預(yù)期的格式不匹配,但此時 TypeScript 編譯器并不能發(fā)現(xiàn)該問題。為了解決這個問題,你應(yīng)該為 releaseDate 和 recordingType 屬性定義更精確的類型,比如這樣:
interface?Album?{
??artist:?string;?//?藝術(shù)家
??title:?string;?//?專輯標題
??releaseDate:?Date;?//?發(fā)行日期:YYYY-MM-DD
??recordingType:?"studio"?|?"live";?//?錄制類型:"live"?或?"studio"
}
重新定義 Album 接口之后,對于前面的賦值語句,TypeScript 編譯器就會提示以下異常信息:
const?dangerous:?Album?=?{
??artist:?"Michael?Jackson",
??title:?"Dangerous",
??//?不能將類型“string”分配給類型“Date”。ts(2322)
??releaseDate:?"November?31,?1991",?//?Error
??//?不能將類型“"Studio"”分配給類型“"studio"?|?"live"”。ts(2322)
??recordingType:?"Studio",?//?Error
};
為了解決上面的問題,你需要為 releaseDate 和 recordingType 屬性設(shè)置正確的類型,比如這樣:
const?dangerous:?Album?=?{
??artist:?"Michael?Jackson",
??title:?"Dangerous",
??releaseDate:?new?Date("1991-11-31"),
??recordingType:?"studio",
};
另一個錯誤使用字符串類型的場景是設(shè)置函數(shù)的參數(shù)類型。假設(shè)你需要寫一個函數(shù),用于從一個對象數(shù)組中抽取某個屬性的值并保存到數(shù)組中,在 Underscore 庫中,這個操作被稱為 “pluck”。要實現(xiàn)該功能,你可能最先想到以下代碼:
function?pluck(record:?any[],?key:?string):?any[]?{
??return?record.map((r)?=>?r[key]);
}
對于以上的 pluck 函數(shù)并不是很好,因為它使用了 any 類型,特別是作為返回值的類型。那么如何優(yōu)化 pluck 函數(shù)呢?首先,可以通過引入一個泛型參數(shù)來改善類型簽名:
function?pluck<T>(record:?T[],?key:?string):?any[]?{
??//?Element?implicitly?has?an?'any'?type?because?expression?of?type?'string'?can't?be?used?to?
??//?index?type?'unknown'.
??//?No?index?signature?with?a?parameter?of?type?'string'?was?found?on?type?'unknown'.(7053)
??return?record.map((r)?=>?r[key]);?//?Error
}
通過以上的異常信息,可知字符串類型的 key 不能被作為 unknown 類型的索引類型。要從對象上獲取某個屬性的值,你需要保證參數(shù) key 是對象中的屬性。
說到這里相信有一些小伙伴已經(jīng)想到了 keyof 操作符,它是 TypeScript 2.1 版本引入的,可用于獲取某種類型的所有鍵,其返回類型是聯(lián)合類型。接著使用 keyof 操作符來更新一下 pluck 函數(shù):
function?pluck<T>(record:?T[],?key:?keyof?T)?{
??return?record.map((r)?=>?r[key]);
}
對于更新后的 pluck 函數(shù),你的 IDE 將會為你自動推斷出該函數(shù)的返回類型:
function?pluck<T>(record:?T[],?key:?keyof?T):?T[keyof?T][]
對于更新后的 pluck 函數(shù),你可以使用前面定義的 Album 類型來測試一下:
const?albums:?Album[]?=?[{
??artist:?"Michael?Jackson",
??title:?"Dangerous",
??releaseDate:?new?Date("1991-11-31"),
??recordingType:?"studio",
}];
//?let?releaseDateArr:?(string?|?Date)[]
let?releaseDateArr?=?pluck(albums,?'releaseDate');
示例中的 releaseDateArr 變量,它的類型被推斷為 (string | Date)[],很明顯這并不是你所期望的,它的正確類型應(yīng)該是 Date[]。那么應(yīng)該如何解決該問題呢?這時你需要引入第二個泛型參數(shù) K,然后使用 extends 來進行約束:
function?pluck<T,?K?extends?keyof?T>(record:?T[],?key:?K):?T[K][]?{
??return?record.map((r)?=>?r[key]);
}
//?let?releaseDateArr:?Date[]
let?releaseDateArr?=?pluck(albums,?'releaseDate');
三、定義的類型總是表示有效的狀態(tài)
假設(shè)你正在構(gòu)建一個允許用戶指定頁碼,然后加載并顯示該頁面對應(yīng)內(nèi)容的 Web 應(yīng)用程序。首先,你可能會先定義 State 對象:
interface?State?{
??pageContent:?string;
??isLoading:?boolean;
??errorMsg?:?string;
}
接著你會定義一個 renderPage 函數(shù),用來渲染頁面:
function?renderPage(state:?State)?{
??if?(state.errorMsg)?{
????return?`嗚嗚嗚,加載頁面出現(xiàn)異常了...${state.errorMsg}`;
??}?else?if?(state.isLoading)?{
????return?`頁面加載中~~~`;
??}
??return?`${state.pageContent}`;
}
//?輸出結(jié)果:頁面加載中~~~
console.log(renderPage({isLoading:?true,?pageContent:?""}));
//?輸出結(jié)果:大家好,我是阿寶哥
console.log(renderPage({isLoading:?false,?pageContent:?"大家好,我是阿寶哥"}));
創(chuàng)建好 renderPage 函數(shù),你可以繼續(xù)定義一個 changePage 函數(shù),用于根據(jù)頁碼獲取對應(yīng)的頁面數(shù)據(jù):
async?function?changePage(state:?State,?newPage:?string)?{
??state.isLoading?=?true;
??try?{
????const?response?=?await?fetch(getUrlForPage(newPage));
????if?(!response.ok)?{
??????throw?new?Error(`Unable?to?load?${newPage}:?${response.statusText}`);
????}
????const?text?=?await?response.text();
????state.isLoading?=?false;
????state.pageContent?=?text;
??}?catch?(e)?{
????state.errorMsg?=?""?+?e;
??}
}
對于以上的 changePage 函數(shù),它存在以下問題:
在 catch 語句中,未把 state.isLoading的狀態(tài)設(shè)置為false;未及時清理 state.errorMsg的值,因此如果之前的請求失敗,那么你將繼續(xù)看到錯誤消息,而不是加載消息。
出現(xiàn)上述問題的原因是,前面定義的 State 類型允許同時設(shè)置 isLoading 和 errorMsg 的值,盡管這是一種無效的狀態(tài)。針對這個問題,你可以考慮引入可辨識聯(lián)合類型來定義不同的頁面請求狀態(tài):
interface?RequestPending?{
??state:?"pending";
}
interface?RequestError?{
??state:?"error";
??errorMsg:?string;
}
interface?RequestSuccess?{
??state:?"ok";
??pageContent:?string;
}
type?RequestState?=?RequestPending?|?RequestError?|?RequestSuccess;
interface?State?{
??currentPage:?string;
??requests:?{?[page:?string]:?RequestState?};
}
在以上代碼中,通過使用可辨識聯(lián)合類型分別定義了 3 種不同的請求狀態(tài),這樣就可以很容易的區(qū)分出不同的請求狀態(tài),從而讓業(yè)務(wù)邏輯處理更加清晰。接下來,需要基于更新后的 State 類型,來分別更新一下前面創(chuàng)建的 renderPage 和 changePage 函數(shù):
更新后的 renderPage 函數(shù)
function?renderPage(state:?State)?{
??const?{?currentPage?}?=?state;
??const?requestState?=?state.requests[currentPage];
??switch?(requestState.state)?{
????case?"pending":
??????return?`頁面加載中~~~`;
????case?"error":
??????return?`嗚嗚嗚,加載第${currentPage}頁出現(xiàn)異常了...${requestState.errorMsg}`;
????case?"ok":
??????`第${currentPage}頁的內(nèi)容:${requestState.pageContent}`;
??}
}
更新后的 changePage 函數(shù)
async?function?changePage(state:?State,?newPage:?string)?{
??state.requests[newPage]?=?{?state:?"pending"?};
??state.currentPage?=?newPage;
??try?{
????const?response?=?await?fetch(getUrlForPage(newPage));
????if?(!response.ok)?{
??????throw?new?Error(`無法正常加載頁面?${newPage}:?${response.statusText}`);
????}
????const?pageContent?=?await?response.text();
????state.requests[newPage]?=?{?state:?"ok",?pageContent?};
??}?catch?(e)?{
????state.requests[newPage]?=?{?state:?"error",?errorMsg:?""?+?e?};
??}
}
在 changePage 函數(shù)中,會根據(jù)不同的情形設(shè)置不同的請求狀態(tài),而不同的請求狀態(tài)會包含不同的信息。這樣 renderPage 函數(shù)就可以根據(jù)統(tǒng)一的 state 屬性值來進行相應(yīng)的處理。因此,通過使用可辨識聯(lián)合類型,讓請求的每種狀態(tài)都是有效的狀態(tài),不會出現(xiàn)無效狀態(tài)的問題。
四、選擇條件類型而不是重載聲明
假設(shè)你要使用 TS 實現(xiàn)一個 double 函數(shù),該函數(shù)支持 string 或 number 類型。這時,你可能已經(jīng)想到了使用聯(lián)合類型和函數(shù)重載:
function?double(x:?number?|?string):?number?|?string;
function?double(x:?any)?{
??return?x?+?x;
}
雖然這個 double 函數(shù)的聲明是正確的,但它有一點不精確:
//?const?num:?string?|?number
const?num?=?double(10);?
//?const?str:?string?|?number
const?str?=?double('ts');
對于 double 函數(shù),你期望傳入的參數(shù)類型是 number 類型,其返回值的類型也是 number 類型。當(dāng)你傳入的參數(shù)類型是 string 類型,其返回的類型也是 string 類型。而上面的 double 函數(shù)卻是返回了 string | number 類型。為了實現(xiàn)上述的要求,你可能想到了引入泛型變量和泛型約束:
function?double<T?extends?number?|?string>(x:?T):?T;
function?double(x:?any)?{
??return?x?+?x;
}
在上面的 double 函數(shù)中,引入了泛型變量 T,同時使用 extends 約束了其類型范圍。
//?const?num:?10
const?num?=?double(10);
//?const?str:?"ts"
const?str?=?double('ts');
console.log(str);
不幸的是,我們對精確度的追求超過了預(yù)期。現(xiàn)在的類型有點太精確了。當(dāng)傳遞一個字符串類型時,double 聲明將返回一個字符串類型,這是正確的。但是當(dāng)傳遞一個字符串字面量類型時,返回的類型是相同的字符串字面量類型。這是錯誤的,因為 ts 經(jīng)過 double 函數(shù)處理后,返回的是 tsts,而不是 ts。
另一種方案是提供多種類型聲明。雖然 TypeScript 只允許你編寫一個具體的實現(xiàn),但它允許你編寫任意數(shù)量的類型聲明。你可以使用函數(shù)重載來改善 double 的類型:
function?double(x:?number):?number;
function?double(x:?string):?string;
function?double(x:?any)?{
??return?x?+?x;
}
//?const?num:?number
const?num?=?double(10);?
//?const?str:?string
const?str?=?double("ts");?
很明顯此時 num 和 str 變量的類型都是正確的,但不幸的是,double 函數(shù)還有一個小問題。因為 double 函數(shù)的聲明只支持 string 或 number 類型的值,而不支持 string | number 聯(lián)合類型,比如:
function?doubleFn(x:?number?|?string)?{
??//?Argument?of?type?'string?|?number'?is?not?assignable?to?
??//?parameter?of?type?'number'.
??//?Argument?of?type?'string?|?number'?is?not?assignable?to?
??//?parameter?of?type?'string'.
??return?double(x);?//?Error
}
為什么會提示以上的錯誤呢?因為當(dāng) TypeScript 編譯器處理函數(shù)重載時,它會查找重載列表,直到找一個匹配的簽名。對于 number | string 聯(lián)合類型,很明顯是匹配失敗的。
然而對于上述的問題,雖然可以通過新增 string | number 的重載簽名來解決,但最好的方案是使用條件類型。在類型空間中,條件類型就像 if 語句一樣:
function?double<T?extends?number?|?string>(
??x:?T
):?T?extends?string???string?:?number;
function?double(x:?any)?{
??return?x?+?x;
}
這與前面引入泛型版本的 double 函數(shù)聲明類似,只是它引入更復(fù)雜的返回類型。條件類型使用起來很簡單,與 JavaScript 中的三目運算符(?:)一樣的規(guī)則。T extends string ? string : number 的意思是,如果 T 類型是 string 類型的子集,則 double 函數(shù)的返回值類型為 string 類型,否則為 number 類型。
在引入條件類型之后,前面的所有例子就可以正常工作了:
//?const?num:?number
const?num?=?double(10);?
//?const?str:?string
const?str?=?double("ts");?
//?function?f(x:?string?|?number):?string?|?number
function?f(x:?number?|?string)?{
??return?double(x);
}
五、一次性創(chuàng)建對象
在 JavaScript 中可以很容易地創(chuàng)建一個表示二維坐標點的對象:
const?pt?=?{};?
pt.x?=?3;?
pt.y?=?4;
然而對于同樣的代碼,在 TypeScript 中會提示以下錯誤信息:
const?pt?=?{};
//?Property?'x'?does?not?exist?on?type?'{}'
pt.x?=?3;?//?Error
//?Property?'y'?does?not?exist?on?type?'{}'
pt.y?=?4;?//?Error
這是因為第一行中 pt 變量的類型是根據(jù)它的值 {} 推斷出來的,你只能對已知的屬性賦值。針對這個問題,你可能會想到一種解決方案,即新聲明一個 Point 類型,然后把它作為 pt 變量的類型:
interface?Point?{
??x:?number;
??y:?number;
}
//?Type?'{}'?is?missing?the?following?properties?from?type?'Point':?x,?y(2739)
const?pt:?Point?=?{};?//?Error
pt.x?=?3;
pt.y?=?4;
那么如何解決上述問題呢?其中一種最簡單的解決方案是一次性創(chuàng)建對象:
const?pt?=?{?
??x:?3,
??y:?4,?
};?//?OK
如果你想一步一步地創(chuàng)建對象,你可以使用類型斷言(as)來消除類型檢查:
const?pt?=?{}?as?Point;?
pt.x?=?3;
pt.y?=?4;?//?OK
但是更好的方法是一次性創(chuàng)建對象并顯式聲明變量的類型:
const?pt:?Point?=?{?
??x:?3,
??y:?4,?
};
而當(dāng)你需要從較小的對象來構(gòu)建一個較大的對象時,你可能會這樣處理,比如:
const?pt?=?{?x:?3,?y:?4?};
const?id?=?{?name:?"Pythagoras"?};
const?namedPoint?=?{};
Object.assign(namedPoint,?pt,?id);
//?Property?'id'?does?not?exist?on?type?'{}'.(2339)
namedPoint.name;?//?Error
為了解決上述問題,你可以使用對象展開運算符 ... 來一次性構(gòu)建大的對象:
const?namedPoint?=?{...pt,?...id};?
namedPoint.name;?//?OK,?type?is?string
此外,你還可以使用對象展開運算符,以一種類型安全的方式逐個字段地構(gòu)建對象。關(guān)鍵是在每次更新時使用一個新變量,這樣每個變量都會得到一個新類型:
const?pt0?=?{};
const?pt1?=?{...pt0,?x:?3};
const?pt:?Point?=?{...pt1,?y:?4};?//?OK
雖然這是構(gòu)建這樣一個簡單對象的一種迂回方式,但對于向?qū)ο筇砑訉傩圆⒃试S TypeScript 推斷新類型來說,這可能是一種有用的技術(shù)。要以類型安全的方式有條件地添加屬性,可以使用帶 null 或 {} 的對象展開運算符,它不會添加任何屬性:
declare?var?hasMiddle:?boolean;
const?firstLast?=?{first:?'Harry',?last:?'Truman'};
const?president?=?{...firstLast,?...(hasMiddle???{middle:?'S'}?:?{})};
如果在編輯器中鼠標移到 president,你將看到 TypeScript 推斷出的類型:
const?president:?{
??middle?:?string;
??first:?string;
??last:?string;
}
最終通過設(shè)置 hasMiddle 變量的值,你就可以控制 president 對象中 middle 屬性的值:
declare?var?hasMiddle:?boolean;
var?hasMiddle?=?true;
const?firstLast?=?{first:?'Harry',?last:?'Truman'};
const?president?=?{...firstLast,?...(hasMiddle???{middle:?'S'}?:?{})};
let?mid?=?president.middle
console.log(mid);?//?S
六、參考資源
effective-typescript-specific-ways-improve
「1.8W字」2020不可多得的 TS 學(xué)習(xí)指南
聚焦全棧,專注分享 TypeScript、Web API、Deno 等技術(shù)干貨。
