【TS實踐】自己動手豐衣足食的TS專案開發

語言: CN / TW / HK

theme: channing-cyan highlight: arduino-light


前言

之前看antd的原始碼,已經使用TypeScript重寫了。對於像我這種喜歡通過實際專案學習技術的人,非常的友好。

一段時間內,我都是通過antd的原始碼來學習TypeScript的,但是紙上得來終覺淺,雖然自我感覺上,已經對TypeScript掌握的不錯了,但是總覺得寫起來沒有自己想的這麼簡單。

空想不如實幹,我的小程式需要做一個文章管理系統,正好可以使用TypeScript開發作為練手。

紙上得來終覺淺,絕知此事要躬行。

帶著問題去尋找答案

專案開始之前,我並沒有問題,寫了一個頁面之後,我就開始懷疑人生了。

  • 所有的變數都需要加型別註釋嗎?
  • 型別註釋之後取值時報錯,很想使用any型別,怎麼克服?
  • interface和type怎麼選擇更加合理?
  • 專案中真的有必要使用TS嗎?

......

列出這些問題的時候,也許我還不能完全能解答,希望整個知識重拾結束之後,我能找到答案。

基礎往往不可或缺

TS官網對基礎型別的介紹是下面這樣一段話

為了讓程式有價值,我們需要能夠處理最簡單的資料單元:數字,字串,結構體,布林值等。 TypeScript支援與JavaScript幾乎相同的資料型別,此外還提供了實用的列舉型別方便我們使用。

從描述中不難提取的幾個關鍵點

  • 基礎資料處理是必不可少的;
  • TypeScript和JavaScript的資料型別基本是一致,降低了學習難度;
  • 提供了列舉型別,常年做業務開發的經驗告訴我列舉型別很實用;

資料型別

```js // 宣告布林型別 let isDone: boolean = false;

// 宣告數字型別 let decLiteral: number = 6; let hexLiteral: number = 0xf00d; // 支援十六進位制、二進位制、八進位制字面量

// 宣告字串型別 let name: string = "bob";

// 宣告陣列型別 let list: number[] = [1, 2, 3]; let list: Array = [1, 2, 3]; // 也可以使用陣列泛型,Array<元素型別>:

// 宣告元組型別 元組型別允許表示一個已知元素數量和型別的陣列 let x: [string, number]; // 初始化變數 x = ['hello', 10];

// 宣告列舉型別 enum Color {Red, Green, Blue} let c: Color = Color.Green; // 列印結果是1,因為預設情況下,從0開始為元素編號。也可以手動的指定成員的數值。

// 宣告any型別 let notSure: any = 4; notSure = "maybe a string instead"; notSure = false; // okay, definitely a boolean

// 宣告void型別 function warnUser(): void { console.log("This is my warning message"); }

// 宣告undefined型別 let u: undefined = undefined; // 宣告null型別 let n: null = null;

// 宣告never型別 // 返回never的函式必須存在無法達到的終點 function error(message: string): never { throw new Error(message); }

// 宣告object型別 declare function create(o: object | null): void; create({ prop: 0 }); // OK create(null); // OK ```

\

型別斷言

用途

一段話,你就明白它的用途了。

有時候,你會比TypeScript更瞭解某個值的詳細資訊。 比如它的確切型別。通過型別斷言這種方式可以告訴編譯器,“相信我,我知道自己在幹什麼”。 這個時候TypeScript會假設你,程式設計師,已經進行了必須的檢查。

寫法

兩種寫法

“尖括號”語法:

```js let someValue: any = "this is a string";

let strLength: number = (someValue).length; ```

as語法:

```js let someValue: any = "this is a string";

let strLength: number = (someValue as string).length; ```

小結

  • 原始型別包括:number,string,boolean,symbol,null,undefined。非原始型別包括:object,any,void,never;
  • any型別是十分有用的,它允許你在編譯時可選擇地包含或移除型別檢查;因為有些時候程式設計階段還不清楚型別的變數指定一個型別,不能一直卡著不動,所以可以使用any型別宣告這些變數。同樣的,需要儘量避免全部宣告成any型別,不然使用TS就沒有太大意義了;
  • 宣告一個void型別的變數沒有什麼大用,因為你只能為它賦予undefined和null;
  • undefined和null,它們的本身的型別用處不是很大,預設情況下null和undefined是所有型別的子型別。但是,當指定了--strictNullChecks標記,null和undefined只能賦值給void和它們各自。 這能避免很多常見的問題;

FAQ

注:以下所有問題的解答,並不是唯一的答案,大多是我根據開發經驗總結出來的,所以見仁見智。

所有的變數都需要加型別註釋嗎?

問:

剛開始上手TS,不自覺的就按照JS的寫法,很多變數沒有做型別註釋,但是程式碼能編譯通過,功能可以正常執行。怎麼書寫才是規範的?

答:

上面這個問題,正是我最初使用TS開發功能的一個困擾。我閱讀了一些文章,結合自己的理解,我個人建議,能加型別註釋的都加上。尤其是大型的多人協作的專案,新增型別註釋,更有利於增強程式碼的可讀性,也能有利於減少出錯率。

比如下面的程式碼,通過型別註釋我們能清除的瞭解到checked變數是布林型別,但是checkedEmail變數卻不能確定資料型別。

js const [checked, setChecked] = useState<boolean>(false); const [checkedEmail, setCheckedEmail] = useState(null);

當為checked變數賦值其他型別的時候就會報錯

js setChecked(1); // TypeScript error: Argument of type '1' is not assignable to parameter of type 'SetStateAction<boolean>'

所以我更推薦儘可能的新增型別註釋。

型別註釋之後取值時報錯,很想使用any型別,怎麼克服?

問:

有時候根據業務需要會宣告比較複雜的巢狀物件,像登入/註冊的切換功能,展示中按鈕文案不同,我將展示內容提煉成一個公共方法,通過切換的type值區分當前展示的具體內容,但是實際使用formObj[type]時會報錯。如果將formObj宣告成any型別,報錯就會消失,很想一勞永逸的使用any,怎麼克服?

答:

可以分析一下導致報錯的原因,上面的問題的原因是TypeScript不知道type的型別,所以出現了報錯。可以通過型別斷言的方式告訴TypeScript我很確定這個變數的資料型別是什麼,就能解決問題了。

any型別雖然能解決問題,但是治標不治本。一味的使用any型別,TS的意見就不大了。

```js interface formItemInter { btnName: string; }

interface formInter { login: formItemInter; register: formItemInter; }

const getFormTypeItem = (type: string) => { const formObj: formInter = { login: { btnName: '立即登入', }, register: { btnName: '立即註冊', }, }; // let formItem = formObj[type]; // 報錯:Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'formInter'.No index signature with a parameter of type 'string' was found on type 'formInter'. let formItem = formObj[type as keyof typeof formObj]; // OK return formItem; }; ```

interface和type兩兄弟

之前學習的時候,interfacetype這兩個,我有點分不清底用哪個。

介紹對比

interface(介面)

在TypeScript裡,介面的作用就是為這些型別命名和為你的程式碼或第三方程式碼定義契約。

type(類型別名)

類型別名會給一個型別起個新名字。起別名不會新建一個型別,它建立了一個新名字來引用這個型別。

用法對比

interface(介面)

```js interface LabelledValue { label: string; }

function printLabel(labelledObj: LabelledValue) { console.log(labelledObj.label); }

let myObj = {size: 10, label: "Size 10 Object"}; printLabel(myObj); // Size 10 Object ```

type(類型別名)

```js type LabelledValue = { label: string; };

const printLabel = (labelledObj: LabelledValue) => { console.log(labelledObj.label); };

let myObj = { size: 10, label: 'Size 10 Object' }; printLabel(myObj); // Size 10 Object ```

細微差別

類型別名可以像介面一樣;然而,仍有一些細微差別。

  • type可以作用於原始值,聯合型別,元組以及其它任何你需要手寫的型別。但是interface不行。

js type Name = string; // 基本型別 type NameUnion = string | number; // 聯合型別 type NameTuple = [string, number]; // 元組

注:可能有疑問的地方在於,interface不是也可以宣告聯合型別嗎?如下官方的示例,其實不是一個interface可以宣告聯合型別,而是Bird和Fish兩個不同的interface聯合定義型別,和type是不一樣的。

```js interface Bird { fly(); layEggs(); }

interface Fish { swim(); layEggs(); }

function getSmallPet(): Fish | Bird { // ... }

let pet = getSmallPet(); pet.layEggs(); // okay ```

  • interface可以相互繼承,type不可以

```js interface Shape { color: string; }

interface Square extends Shape { sideLength: number; }

let square = {}; square.color = "blue"; square.sideLength = 10; ```

FAQ

interface和type怎麼選擇更加合理?

問:

interface和type,有時候用哪個都可以,那我怎麼確定使用哪個呢?

答:

結合上面的對比,首先可以確定一個能用的兩種情況:

  • 如果使用聯合型別、元組等型別的時候,用type起一個別名使用;
  • 如果需要使用extends進行型別繼承時,使用interface;

其他型別定義能使用interface,使用interface即可。

文章管理系統

React+TS+antd

此次開發的文章管理系統基於React+TS+antd的技術棧完成。

tsconfig.json

TS編輯選項官網很詳情,可以根據需要進行設定。

js { "compilerOptions": { "target": "esnext", // 指定ECMAScript目標版本 "esnext" "lib": [ "dom", "dom.iterable", "esnext" ], // 編譯過程中需要引入的庫檔案的列表。 "allowJs": true, // 允許編譯javascript檔案 "skipLibCheck": true, // 忽略所有的宣告檔案( *.d.ts)的型別檢查。 "allowSyntheticDefaultImports": true, // 許從沒有設定預設匯出的模組中預設匯入。 "strict": true, // 啟用所有嚴格型別檢查選項。 "forceConsistentCasingInFileNames": true, // 禁止對同一個檔案的不一致的引用。 "module": "esnext", // 指定生成哪個模組系統程式碼 "moduleResolution": "node", // 決定如何處理模組。 "Node"對於Node.js/io.js "resolveJsonModule": true, // 匯入 JSON Module "isolatedModules": true, // 將每個檔案作為單獨的模組 "noEmit": true, // 不生成輸出檔案 "jsx": "react", // 在 .tsx檔案裡支援JSX: "React"或 "Preserve"。 "sourceMap": true, // 生成相應的 .map檔案。 "outDir": ".", // 重定向輸出目錄。 "noImplicitAny": true, // 在表示式和宣告上有隱含的 any型別時報錯。 "esModuleInterop": true // 支援使用import d from 'cjs'的方式引入commonjs包。 }, "extends": "./paths.json", "include": [ "src" ], "exclude": [ "node_modules", "dist" ] }

基礎元件

正式開發頁面之前,我首先完成的是基礎元件的開發。後臺系統的基礎元件主要有佈局元件、列表元件、按鈕許可權元件等。因為目前沒有涉及到按鈕許可權,所以我首先實現的是前兩個。

佈局元件

檔案路徑:src/components/layout

index.tsx

```js /* * @description 公共佈局 / import React from 'react'; import { NO_LAYOUT } from '@/constants/common'; import BasicLayout from './Basic'; import BlankLayout from './Blank';

function Layout({ ...props }) { const pathname = window.location.pathname; /* @name 不需要佈局頁面的索引值 / const noLayoutIndex = NO_LAYOUT.indexOf(pathname); return noLayoutIndex === -1 ? : ; }

export default Layout; ```

Blank.tsx

```js /* * @description 純頁面展示 不含頭、底、導航選單 / import React from 'react'; import './index.less'; import Page from './page';

function BlankLayout({ ...props }) { return (

{props.children}
); }

export default BlankLayout; ```

Basic.tsx

```js /* * @description 包含公共頭、底、導航選單的基礎佈局 / import React from 'react'; import Page from './page'; import Sidebar from './sidebar'; import Header from './header'; import Content from './content'; import Main from './main';

function BasicLayout({ ...props }) { return (

{props.children}
); }

export default BasicLayout; ```

列表元件

檔案路徑:src/components/list

index.tsx

```js /* * @description 通用列表元件 / import React, { useState, useEffect } from 'react'; import PropTypes from 'prop-types'; import { Table } from 'antd';

function List({ ...props }) { const { columns, autoQuery, http } = props; const [list, setList] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); const [size, setSize] = useState(20);

const query = (page: number, size: number) => { const params = { page, size }; http(params, (res: any) => { setList(res.list); setTotal(res.total); }); };

// 分頁、排序、篩選變化時回撥函式 const paginationChange = (pages: number, sizes: number) => { setPage(pages); setSize(sizes); query(pages, sizes); };

useEffect(() => { if (autoQuery) { query(page, size); } }, []); // eslint-disable-line react-hooks/exhaustive-deps

return ( <> record['id']} columns={columns} scroll={{ x: '100%' }} pagination={{ total, current: page, pageSize: size, onChange: paginationChange, showQuickJumper: true, showSizeChanger: true, showTotal: total => 共 ${total} 條, }} />
); } List.propTypes = { http: PropTypes.func.isRequired, // 請求 columns: PropTypes.array, // 表格項列表 autoQuery: PropTypes.bool, // 是否第一次載入就進行查詢,預設為true };

List.defaultProps = { columns: [], autoQuery: true, }; export default List; ```

常量管理

將前端需要維護的內容統一在一處管理,有利於提升開發效率和可維護性。這些內容包括網站公共的logo、icon或者其他資訊,某些資料列舉值、表格列的配置描述等。

除了公共常量,其他基本根據頁面模組管理常量。

公共常量

檔案路徑:src/constants/common.js

common.js

js /** * @description 全域性公共常量 */ /** @name 網站公共資訊 */ export const COMMON_SYSTEM_INFO = { avatar: 'http://p6-passport.byteacctimg.com/img/user-avatar/c6c1a335a3b48adc43e011dd21bfdc60~300x300.image', // 頭像 };

使用者常量管理

檔案路徑:src/constants/user.js

user.js

```js /* * @description 使用者常量管理 / import { util } from '@/utils';

/* @name 使用者列表 / export const USER_COLUMNS = [ { title: '使用者ID', dataIndex: 'id', key: 'id', }, { title: '姓名', dataIndex: 'userName', key: 'userName', }, { title: '建立時間', dataIndex: 'creatAt', key: 'creatAt', render(val) { return util.dateFormatTransform(val); }, }, ]; ```

API管理

除了基礎的api,其他基本根據頁面模組管理api。

因為後端部分還沒有開發,所以目前api均由模擬實現。

使用者API管理

檔案路徑:src/api/user.js

user.js

```js import { util } from '@/utils';

// 首頁列表 export const getUserList = function (requestData, successCallback) { const { page, size } = requestData; const total = 24; let numList = new Array(total); let list = []; for (var i = 0; i < numList.length; i++) { const index = i + 1; list[i] = { id: index, name: '花狐狸' + index, creatAt: 1652172686000, }; } let res = { total: total, list: [], }; if (total !== 0) { res.list = util.getListByPageAndSize(total, page, size, list); } successCallback && successCallback(res); }; ```

頁面

目前規劃的四個部分:使用者中心、遊記管理、城市資料管理、活動中心。

首頁

檔案路徑:src/pages/home/index.tsx

展示當前使用者、文章的增長資料。

index.tsx

```js /* * @description 首頁 / import React, { useState, useEffect } from 'react'; import { Statistic, Row, Col, Card } from 'antd'; import './index.less'; import { getHomeData } from '@/api/home';

interface topListInter { title: string; value: number; }

export default function Home() { const [topList, setTopList] = useState>([]);

useEffect(() => { getHomeData({}, (res: Array) => { setTopList(res); }); }, []);

return (

{topList.map((item, index) => { return (
); })} ); } ```

UI

使用者列表

檔案路徑:src/pages/user/index.tsx

因為已提煉了List公共元件,所以列表頁面程式碼非常簡潔。

index.tsx

```js /* * @description 使用者列表 / import React from 'react'; import { getUserList } from '@/api/user'; import List from '@/components/list'; import { USER_COLUMNS } from '@/constants/user';

export default function UserList() { const columns = USER_COLUMNS;

return (

); } ```

UI

心得體會

本次專案總結開始之前先回答上面的一個問題

FAQ

問:

專案中真的有必要使用TS嗎?

答:

以我的實際工作經驗,我推薦使用TS的原因之一,在團隊協作專案中,程式碼可讀性不高的原因之一是程式碼規範不統一,儘管我們做了輔助工作比如命名規範、新增必要註釋、Code Review等,但是這些都是人為干預,遠遠不如程式碼干預的效率高且準確性好。TS在編寫層面已經嚴格約束了程式碼規範,比如通過型別註釋約束了變數型別等,進而增加了程式碼的可讀性。

總結

目前,文章管理系統的基礎元件和頁面已經基本完成了,後續會隨著功能設計內容逐漸豐富。而對TS的學習也會隨著實踐逐步積累經驗。