Typescript代碼整潔之道
最近半年陸續(xù)交接了幾位同事的代碼,發(fā)現(xiàn)雖然用了嚴(yán)格的eslint來規(guī)范代碼的書寫方式,同時項目也全量使用了Typescript,但是在review代碼的過程中,還是有很多不整潔不規(guī)范的地方。良好的代碼具有很好的可讀性,后續(xù)維護(hù)起來也會令人愉悅,也能降低重構(gòu)的概率。本文會結(jié)合Typescript,談?wù)勅绾蝐lean代碼:
- 基礎(chǔ)規(guī)范
- 函數(shù)式
一、基礎(chǔ)規(guī)范
(1)常量
常量必須命名, 在做邏輯判斷的時候,也不允許直接對比沒有命名的常量。
- 錯誤的書寫
- switch(num){
- case 1:
- ...
- case 3:
- ...
- case 7:
- ...
- }
- if(x === 0){
- ...
- }
上述的例子中,根本不知道1 3 7 對應(yīng)的是什么意思,這種寫法就基本上沒有可讀性。
- 正確的寫法
- enum DayEnum {
- oneDay = 1,
- threeDay = 3,
- oneWeek = 7,
- }
- let num = 1;
- switch(num){
- case DayEnum.oneDay:
- ...
- case DayEnum.threeDay:
- ...
- case DayEnum.oneWeek:
- ...
- }
- const RightCode = 0;
- if(x === RightCode)
從上述正確的寫法可以看出來,常量有了命名,在switch或者if等邏輯判斷的時候,我們可以從變量名得知常量的具體含義,增加了可讀性。
(2)枚舉
除了常量枚舉外,在Typescript的編譯階段,枚舉會生成一個maping對象,如果不是字符串枚舉,甚至?xí)梢粋€雙向的mapping。因此在我們的業(yè)務(wù)代碼中,有了枚舉,就不需要一個與枚舉值相關(guān)的數(shù)組。
- 錯誤的寫法
- enum FruitEnum {
- tomato = 1,
- banana = 2,
- apple = 3
- }
- const FruitList = [
- {
- key:1,
- value: 'tomato'
- },{
- key:2,
- value: 'banana'
- },{
- key:3,
- value: 'apple'
- }
- ]
這里錯誤的原因是冗余,我們要得到一個FruitList,并不需要new一個,而是可以直接根據(jù)FruitEnum的枚舉來生成一個數(shù)組,原理就是我們之前所說的Typescript的枚舉,除了常量枚舉外,在編譯的時候是會生成一個map對象的。
- 正確的寫法
- enum FruitEnum {
- tomato = 1,
- banana = 2,
- apple = 3
- }
- const FruitList = Object.entries(FruitEnum)
上述就是正確的寫法,這種寫法不僅僅是不冗余,此外,如果修改了枚舉的類型,我們只要直接修改枚舉,這樣衍生的數(shù)組也會改變。
除此之外,字符串枚舉值和字符串是有本質(zhì)區(qū)別的,在定義類型的時候請千萬注意,要不然會讓你寫的代碼很冗余。
- 錯誤的用法
- enum GenderEnum{
- 'male' = '男生',
- 'female' = '女生'
- }
- interface IPerson{
- name:string
- gender:string
- }
- let bob:IPerson = {name:"bob",gender:'male'}
- <span>{Gender[bob.gender as keyof typeof GenderEnum]}</span>
上述的錯誤的原因就是IPerson的類型定義中,gender不應(yīng)該是string,而應(yīng)該是一個枚舉的key,因此,在將string轉(zhuǎn)枚舉值的時候,必須增加一個as keyof typeof GenderEnum的斷言。
- 正確的寫法
- enum GenderEnum{
- 'male' = '男生',
- 'female' = '女生'
- }
- interface IPerson{
- name:string
- gender:keyof typeof GenderEnum
- }
- let bob:IPerson = {name:"bob",gender:'male'}
- <span>{Gender[bob.gender]}</span>
上述 就是正確的寫法,字符串枚舉和字符串類型是有 明顯區(qū)別的,當(dāng)某個變量需要使用到枚舉時,不能將他定義成string。
(3)ts-ignore & any
Typescript中應(yīng)該嚴(yán)格禁止使用ts-ignore,ts-ignore是一個比any更加影響Typescript代碼質(zhì)量的因素。對于any,在我的項目中曾一度想把any也禁掉,但是有一些場景中是需要使用any的,因此沒有粗魯?shù)慕筧ny的使用。但是絕大部分場景下,你可能都不需要使用any.需要使用any的場景,可以case by case的分析。
- 錯誤使用ts-ignore的場景
- //@ts-ignore
- import Plugin from 'someModule' //如果someModule的聲明不存在
- Plugin.test("hello world")
上述就是最經(jīng)典的使用ts-ignore的場景,如上的方式使用了ts-ignore.那么Typescript會認(rèn)為Plugin的類型是any。正確的方法通過declare module的方法自定義需要使用到的類型.
- 正確的方法
- import Plugin from 'someModule'
- declare module 'someModule' {
- export type test = (arg: string) => void;
- }
在module內(nèi)部可以定義聲明,同名的聲明遵循一定 的合并原則,如果要擴展三方模塊,declare module是很方便的。
同樣的大部分場景下,你也不需要使用any,部分場景下如果無法立刻確定某個值的類型,我們可以 用unknown來代替使用any。
any會完全失去類型判斷,本身其實是比較危險的,且使用any就相當(dāng)于放棄了類型檢測,也就基本上放棄了typescript。舉例來說:
- let fish:any = {
- type:'animal',
- swim:()=> {
- }
- }
- fish.run()
上述的例子中我們調(diào)用了一個不存在的方法 ,因為使用了any,因此跳過了靜態(tài)類型檢測,因此是不安全的。運行時會出錯,如果無法立刻確定某個值的類型,我們可以 用unknown來代替使用any。
- let fish:unknown = {
- type:'animal',
- swim:()=> {
- }
- }
- fish.run() //會報錯
unkonwn是任何類型的子類型,因此跟any一樣,任意類型都可以賦值給unkonwn。與any不同的是,unkonwn的變量必須明確自己的類型,類型收縮或者類型斷言后,unkonwn的變量才可以正常使用其上定義的方法和變量。
簡單來說,unkonwn需要在使用前,強制判斷其類型。
(4)namespace
Typescript的代碼中,特別是偏業(yè)務(wù)的開發(fā)中,你基本上是用不到namespace的。此外module在nodejs中天然支持,此外在es6(next)中 es module也成為了一個語言級的規(guī)范,因此Typescript官方也是推薦使用module。
namespace簡單來說就是一個全局對象,當(dāng)然我們也可以把namespace放在module中,但是namespace放在module中也是有問題的。
- 錯誤的方法
- //在一個shapes.ts的模塊中使用
- export namespace Shapes {
- export class Triangle {
- /* ... */
- }
- export class Square {
- /* ... */
- }
- }
- //我們使用shapes.ts的時候
- //shapeConsumer.ts
- import * as shapes from "./shapes";
- let t = new shapes.Shapes.Triangle(); // shapes.Shapes?
- 正確的方法(直接使用module)
- export class Triangle {
- /* ... */
- }
- export class Square {
- /* ... */
- }
上述直接使用module,就是正確的方法,在模塊系統(tǒng)中本身就可以避免變量命名重復(fù),因此namespace是沒有意義的。
(5)限制函數(shù)參數(shù)的個數(shù)
在定義函數(shù)的時候,應(yīng)該減少函數(shù)參數(shù)的個數(shù),推薦不能超過3個。
- 錯誤的用法
- function getList(searchName:string,pageNum:number,pageSize:number,key1:string,key2:string){
- ...
- }
不推薦函數(shù)的參數(shù)超過3個,當(dāng)超過3個的時候,應(yīng)該使用對象來聚合。
- 正確的用法
- interface ISearchParams{
- searchName:string;
- pageNum:number;
- pageSize:number;
- key1:string;
- key2:string;
- }
- function getList(params:ISearchParams){
- }
同樣的引申到React項目中,useState也是同理
- const [searchKey,setSearchKey] = useState('');
- const [current,setCurrent] = useState(1)
- const [pageSize,setPageSize] = useState(10) //錯誤的寫法
- const [searchParams,setSearchParams] = useState({
- searchKey: '',
- current:1,
- pageSize:10
- }) //正確的寫法
(6)module模塊盡量保證無副作用
請不要使用模塊的副作用。要保證模塊的使用應(yīng)該是先import再使用。
- 錯誤的方法
- //Test.ts
- window.x = 1;
- class Test{
- }
- let test = new Test()
- //index.ts
- import from './test'
- ...
上述在index.ts中import的模塊,其調(diào)用是在test.ts文件內(nèi)部的,這種方法就是import了一個有副作用的模塊。
正確的方法應(yīng)該是保證模塊非export變量的純凈,且調(diào)用方在使用模塊的時候要先import,后調(diào)用。
- 正確的方法
- //test.ts
- class Test{
- constructor(){
- window.x = 1
- }
- }
- export default Test
- //index.ts
- import Test from './test'
- const t = new Test();
(7)禁止使用!.非空斷言
非空斷言本身是不安全的,主觀的判斷存在誤差,從防御性編程的角度,是不推薦使用非空斷言的。
- 錯誤的用法
- let x:string|undefinedundefined = undefined
- x!.toString()
因為使用了非空斷言,因此編譯的時候不會報錯,但是運行的時候會報錯.
比較推薦使用的是optional chaining。以?.的形式。
(8)使用typescript的內(nèi)置函數(shù)
typescript的很多內(nèi)置函數(shù)都可以復(fù)用一些定義。這里不會一一介紹,常見的有Partial、Pick、Omit、Record、extends、infer等等,如果需要在已有的類型上,衍生出新的類型,那么使用內(nèi)置函數(shù)是簡單和方便的。此外還可以使用 聯(lián)合類型、交叉類型和類型合并。
- 聯(lián)合類型
- //基本類型
- let x:number|string
- x= 1;
- x = "1"
- //多字面量類型
- let type:'primary'|'danger'|'warning'|'error' = 'primary'
值得注意的是字面量的賦值。
- let type:'primary'|'danger'|'warning'|'error' = 'primary'
- let test = 'error'
- type = test //報錯
- let test = 'error' as const
- type = test //正確
- 交叉類型
- interface ISpider{
- type:string
- swim:()=>void
- }
- interface IMan{
- name:string;
- age:number;
- }
- type ISpiderISpiderMan = ISpider & IMan
- let bob:ISpiderMan = {type:"11",swim:()=>{},name:"123",age:10}
- 類型合并
最后講一講類型合并,這是一種極其不推薦的方法。在業(yè)務(wù)代碼中,不推薦使用類型合并,這樣會增加代碼的閱讀復(fù)雜度。類型合并存在很多地方。class、interface、namespace等之間都可以進(jìn)行類型合并,以interface為例:
- interface Box {
- height: number;
- width: number;
- }
- interface Box {
- scale: number;
- }
- let box: Box = { height: 5, width: 6, scale: 10 };
上述同名的interface Box是會發(fā)生類型合并的。不僅interface和 interface可以類型合并,class和interface,class和namesppace等等都可能存在同名類型合并,在業(yè)務(wù)代碼中個人不推薦使用類型合并。
(9)封裝條件語句以及ts的類型守衛(wèi)
- 錯誤的寫法
- if (fsm.state === 'fetching' && isEmpty(listNode)) {
- // ...
- }
- 正確的寫法
- function shouldShowSpinner(fsm, listNode) {
- return fsm.state === 'fetching' && isEmpty(listNode);
- }
- if (shouldShowSpinner(fsmInstance, listNodeInstance)) {
- // ...
- }
在正確的寫法中我們封裝了條件判斷的邏輯成一個獨立函數(shù)。這種寫法比較可讀,我們從函數(shù)名就能知道做了一個什么判斷。
此外封裝條件語句也可以跟ts的自定義類型守衛(wèi)掛鉤。來看一個最簡單的封裝條件語句的自定義類型守衛(wèi)。
- function IsString (input: any): input is string {
- return typeof input === 'string';
- }
- function foo (input: string | number) {
- if (IsString(input)) {
- input.toString() //被判斷為string
- } else {
- }
- }
在項目中合理地使用自定義守衛(wèi),可以幫助我們減少很多不必要的類型斷言,同時改善代碼的可讀性。
(10)不要使用非變量
不管是變量名還是函數(shù)名,請千萬不要使用非命名,在業(yè)務(wù)中我就遇到過這個問題,后端定義了一個非命名形式的變量isNotRefresh:
- let isNotRefresh = false //是否不刷新,否表示刷新
isNotRefresh表示不刷新,這樣定義的變量會導(dǎo)致跟這個變量相關(guān)的很多邏輯都是相反的。正確的形式應(yīng)該是定義變量是isRefresh表示是否刷新。
- let isRefresh = false //是否刷新,是表示刷新
二、函數(shù)式
個人非常推薦函數(shù)式編程,主觀的認(rèn)為鏈?zhǔn)秸{(diào)用優(yōu)于回調(diào),函數(shù)式的方式又優(yōu)于鏈?zhǔn)秸{(diào)用。近年來,函數(shù)式編程日益流行,Ramdajs、RxJS、cycleJS、lodashJS等多種開源庫都使用了函數(shù)式的特性。本文主要介紹一下如何使用ramdajs來簡化代碼。
(1)聲明式和命令式
個人認(rèn)為函數(shù)聲明式的調(diào)用比命令式更加簡潔,舉例來說:
- //命令式
- let names:string[] = []
- for(let i=0;i<persons.length;i++){
- names.push(person[i].name)
- }
- //聲明式
- let names = persons.map((item)=>item.name)
從上述例子我們可以看出來,明顯函數(shù)調(diào)用聲明式的方法更加簡潔。此外對于沒有副作用的函數(shù),比如上述的map函數(shù),完全可以不考慮函數(shù)內(nèi)部是如何實現(xiàn)的,專注于編寫業(yè)務(wù)代碼。優(yōu)化代碼時,目光只需要集中在這些穩(wěn)定堅固的函數(shù)內(nèi)部即可。
(2)Ramdajs
推薦使用ramdajs,ramdajs是一款優(yōu)秀的函數(shù)式編程庫,與其他函數(shù)式編程庫相比較,ramdajs是自動柯里化的,且ramdajs提供的函數(shù)從不改變用戶已有數(shù)據(jù)。
來自最近業(yè)務(wù)代碼中的一個簡單的例子:
- /**
- * 獲取標(biāo)簽列表
- */
- const getList = async () => {
- pipeWithP([
- () => setLoading(true),
- async () =>
- request.get('', {
- params: {action: API.getList},
- }),
- async (res: IServerRes) => {
- R.ifElse(
- R.isEqual(res.message === 'success'),
- () => setList(res.response.list);
- )();
- },
- () => setLoading(false)
- ])();
- };
上述是業(yè)務(wù)代碼中的一個例子,利用pipe可以使得流程的操作較為清晰,此外也不用定義中間變量。
再來看一個例子:
- let persons = [
- {username: 'bob', age: 30, tags: ['work', 'boring']},
- {username: 'jim', age: 25, tags: ['home', 'fun']},
- {username: 'jane', age: 30, tags: ['vacation', 'fun']}
- ]
我們需要從這個數(shù)組中找出tags包含fun的對象。如果用命令式:
- let NAME = 'fun'
- let person;
- for(let i=0;i<persons.length;i++){
- let isFind = false
- let arr = persons[i].tags;
- for(let j = 0;j<arr.length;j++){
- if(arr[i] === NAME){
- isFind = true
- break;
- }
- }
- if(isFind){
- personperson = person[i]
- break;
- }
- }
我們用函數(shù)式的寫法可以簡化:
- let person = R.filter(R.where({tags: R.includes('fun')}))
很明顯減少了代碼量且更加容易理解含義。
最后再來看一個例子:
- const oldArr= [[[[[{name: 'yuxiaoliang'}]]]]];
我們想把oldArr這個多維數(shù)組,最內(nèi)層的那個name,由小寫轉(zhuǎn)成大寫,用函數(shù)式可以直接這樣寫。
- R.map(atem =>
- R.map(btem => R.map(ctem => R.map(dtem => R.map(etem => etem.name.toUpperCase())(dtem))(ctem))(btem))(atem),
- )(arr);