【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的學習也會隨着實踐逐步積累經驗。