TypeScript 是 Web 行業一個重要的組成部分。這是有充分理由證明的,它非常的棒。當然了,我說的不僅僅是下面這樣:
function add(a: number, b: number) {
return a + b
}
add(1, 2) // 類型檢查通過了
add('one', 3) // 類型檢查沒通過
這非常的酷~但我想說的是類似下面這樣的:
貫穿整個程序的「類型」(包括了前端與后端)。在現實中它可能就是這樣的,而且可能在未來的某一天你需要做出一個非常艱難的決定:將 剩余座位
這個字段拆分成 總座位
和 已坐座位
兩個字段。如果沒有「類型」來指導重構,那么你將會非常困難。當然了,我也非常希望你有一些很可靠的單元測試。
這篇文章我并不是想說服你 JavaScript 中的「類型」是多么好。而是想跟你聊聊「端到端類型安全」有多么的棒,并且跟你介紹如何將它應用到你的項目中去。
首先,我這里說的「端到端類型安全」指的是,從數據庫層到后端代碼層再到前端 UI 層的全鏈路類型安全。然而我意識到,每個人的環境情況都是不同的。你可能沒有操作數據庫的權限。當年我在某互聯網大廠工作,我經常消費許多來自不同后端團隊的服務。我從未直接操作過數據庫。所以如果要實現真正的「端到端類型安全」,可能是需要多個團隊配合的。但希望我能幫助你走上正確的軌道,盡可能地適應你自己的情況。
讓我們項目的「端到端類型安全」變得困難的最重要因素是:邊界
要實現類型安全的 Web Apps 就是要覆蓋邊界的類型
在 Web 環境中,我們有很多的邊界。有一些你可能比較清楚,有一些你可能沒有意識到。這里有一些你可能會在 Web 環境中遇到的邊界的例子:
// 獲取 localStorge 中 ticket 的值
const ticketData = JSON.parse(localStorage.get('ticket'))
// 它是 any 類型嗎 ??
// 從 form 表單中獲取值
// <form>
// ...
// <input type="date" name="workshop-date" />
// ...
// </form>
const workshopDate = form.elements.namedItem('workshop-date')
// 它是 Element | RadioNodeList | null ?? 這樣的類型嗎
// 從 API 中獲取數據
const data = await fetch('/api/workshops').then(r => r.json())
// 它是 any 類型嗎 ??
// 獲取配置信息或者路由上的參數(比如 Remix 或者 React Router)
const { workshopId } = useParams()
// string | undefined ??
// 通過 node.js 的 fs 模塊,讀取或者解析字符串
const workshops = YAML.parse(await fs.readFile('./workshops.yml'))
// 它是 any 類型嗎 ??
// 從數據庫中讀取數據
const data = await SQL`select * from workshops`
// 它是 any 類型嗎 ??
// 從請求中讀取數據
const description = formData.get('description')
// FormDataEntryValue | null ??
還有更多示例,但這些是你會遇到的一些常見的邊界:
- 本地存儲
- 用戶輸入
- 網絡
- 基礎配置或約定
- 文件系統
- 數據庫請求
事實上, 不能 100% 確定我們從邊界獲取到的內容就是我們預期的內容。重要的事情說三遍:不能,不能,不能 。當然了,你可以使用 as Workshop
這樣的顯示類型聲明來讓 TypeScript 編譯通過并正常運行,但這只是把問題給隱藏起來。文件可能被其他的進程修改,API 可能被修改,用戶可能手動修改 DOM。所以我們無法明確的知道邊界的修改結果是否跟你預期的一樣的。
然而,你是能夠做一些事情來規避一些風險的。比如說:
- 編寫「類型守衛函數」或者「類型斷言函數」
- 使用可以類型生成的工具(能給你 90% 的信心)
- 通知 TypeScript 你的約定 / 配置
現在,讓我們看看使用這些策略通過 Web 應用的邊界來實現端對端類型安全
類型守衛 / 斷言函數
這確實是最有效的方法來按照你的預期處理邊界類型問題。你可以通過寫代碼逐個字段去檢查它!這里有一個簡單的類型守衛例子:
const { workshopId } = useParams()
if (workshopId) {
// 你已經獲取了 workshopId 并且 TypeScript 也知道了
} else {
// 處理你獲取不到 workshopId 的情況
}
在這個時候,有些人可能會因為要遷就 TypeScript 編譯器而感到惱火。如果你十分肯定 workshopId
是你必須要獲取的字段,那么你可以在獲取不到的時候直接拋出錯誤(這樣將對你的程序有非常大的幫助而不是忽略這些潛在的問題)
const { workshopId } = useParams()
if (!workshipId) {
throw new Error('workshopId 不合法')
}
下面這個工具,我在項目中用的最多,因為它十分的便利,也讓代碼可讀性更強
import invariant from 'tiny-invariant'
const { workshopId } = useParams()
invariant(workshopId, 'workshopId 不合法')
tiny-invariant 的 README 中提到
invariant
這個函數校驗入參,如果入參為 false 則該函數會拋出錯誤;為 true 則不會拋出
需要添加額外代碼來進行校驗總是比較難受的。這是一個棘手的問題因為 TypeScript 不知道你的約定和配置。也就是說,如果能讓 TypeScript 知道我們項目中的約定和配置,那么將能夠起到一定的作用。這里有一些項目正在處理這樣的問題:
- routes-gen 和 remix-routes 都可以基于你的 Remix 約定或者配置自動生成類型(這塊在本文還會再細說)
- TanStack Router 會確保所有的工具方法(比如 useParams)都可以訪問到你定義的路由信息(有效地將你的配置通知 TypeScript,這是我們的另一種解決方法)
這個只是一個 URL 邊界相關的例子,但這里關于如何教會 TypeScript 知道我們項目約定的方案是可以移植到其他邊界情況的。
讓我們再來看看另一個更加復雜的「類型守衛」的例子
type Ticket = {
workshopId: string
attendeeId: string
discountCode?: string
}
// 類型守衛函數
function isTicket(ticket: unknown): ticket is Ticket {
return (
Boolean(ticket) &&
typeof ticket === 'object' &&
typeof (ticket as Ticket).workshopId === 'string' &&
typeof (ticket as Ticket).attendeeId === 'string' &&
(typeof (ticket as Ticket).discountCode === 'string' ||
(ticket as Ticket).discountCode === undefined)
)
}
// ticket 是 any 類型 ??
const ticket = JSON.parse(localStorage.get('ticket'))
if (isTicket(ticket)) {
// 我們知道 ticket 的類型了
} else {
// 處理獲取不到 ticket 的情況 ....
}
即便是一個相對簡單的類型,我們好像都需要做不少的工作。想象一下在真實項目中更加復雜的類型!!如果你經常要做這樣類似的工作,那建議你還是選用一些比較好用的工具比如 zod 這樣的。
import { z } from "zod"
const Ticket = z.object({
workshopId: z.string(),
attendeeId: z.string(),
discountCode: z.string().optional()
})
type Ticket = z.infer<typeof Ticket>
const rawTicket = JSON.parse(localStorage.get('ticket'))
const result = Ticket.safeParse(rawTicket);
if (result.success) {
const ticket = result.data
// ^? Ticket 數據
} else {
// result.error 將會返回一個帶有錯誤信息的 error 對象
}
我對于 zod 最大的關心點在于打包后的 bundle 體積比較大(目前在沒壓縮的情況下有 42 KB 左右),所以我不經常在項目中使用到它。但是如果你只是在服務端使用到或者你真的從 zod 中得到很多的便利,那我覺得還是值得使用的。
tRPC 就通過 zod 實現了類型全覆蓋;它在服務端和客戶端共享類型來實現網絡邊界的類型安全。我個人喜歡使用 Remix 所以很少用到 tRPC;如果不使用 Remix ,我 100 % 會使用 tRPC 來實現類型安全這樣的能力。
類型守衛/斷言函數同樣也是你處理表單的FormData
的方法。對我來說,我非常喜歡使用 remix-validity-state ,原因是:代碼通過在運行時檢查類型來保證整個應用的類型安全。
類型生成
上面已經講了一些關于如何為 Remix 約定路由生成類型的工具;類型生成能夠解決端對端的類型安全問題。另一個流行的例子是 Prisma (我最喜歡的 ORM)。許多的 GraphQL 工具同樣也有類似的能力。大致的做法就是允許你去定義一個 schema ,然后 Prisma 來保證你的數據庫表跟這個 schema 是可以匹配上的。然后 Prisma 也會生成跟 schema 匹配的 TypeScript 類型聲明。高效地保持類型跟數據庫同步。比如:
const workshop = await prisma.user.findFirst({
// ^? { id: string, title: string, date: Date } ??
where: { id: workshopId },
select: { id: true, title: true, date: true },
})
任何時候你修改了 schema 并且創建一個 Prisma 的 migration,Prisma 將會直接更新你的 node_modules 目錄下對應的類型文件。所以當你在使用 Prisma ORM 的時候,類型文件始終跟你的 schema 是保持一致的。下面是一個真實項目的 User 數據庫表:
model User {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
email String @unique(map: "User.email_unique")
firstName String
discordId String?
convertKitId String?
role Role @default(MEMBER)
team Team
calls Call[]
sessions Session[]
postReads PostRead[]
}
這個是生成的類型
/**
* Model User
*
*/
export type User = {
id: string
createdAt: Date
updatedAt: Date
email: string
firstName: string
discordId: string | null
convertKitId: string | null
role: Role
team: Team
}
這確實是一個非常棒的開發體驗,并它可以作為我在后端應用程序中類型的起點。
這里主要的風險在于,如果數據庫的 schema 可能會跟數據庫里面的數據因為某種原因導致不同步。但是我還沒有在使用 Prisma 的過程中遇到過這種情況,希望這種情況是很少見,所以我對不在數據庫交互中添加斷言函數還是很有信心的。然而,如果你沒辦法使用像 Prisma 這樣的工具或者你所在的團隊不負責數據庫 schema,我還是建議你去找其他方法生產基于數據庫 schema 的類型,因為這實在是太棒了。
請記住,我們不僅僅是為了服務 TypeScript。即使我們的項目不使用 TypeScript ,我們也應該讓應用邊界之間的數據跟我們預知類型的保持一致。
使用約定 / 配置來幫助 TypeScript
另一個挑戰比較大的邊界是網絡邊界。驗證服務端給到 UI 層的數據是一件比較困難的事情。fetch
沒有提供范型支持,即便是有,你也只是在自欺欺人。
// 這樣不行, 別這么做 :
const data = fetch<Workshop>('/api/workshops/123').then(r => r.json())
請允許我給你說一些關于范型的秘密,基本上大部分函數像下面這么做都是不好的選擇:
function getData<DataType>(one, two, three) {
const data = doWhatever(one, two, three)
return data as DataType // <-- 這里這里!!!
}
任何時候你看到這個寫法 as XXX類型
,你可以認為:這是在欺騙 TypeScript 的編譯器。即使有的時候你為了能夠讓代碼不報錯而不得不這么做,我都依然不建議你像上面這個 getData
函數這樣做。而這個時候,你有兩個選擇:
const a = getData<MyType>() // ?? 我非常難受
const b = getData() as MyType // ?? 好一點,但是我依然難受
在這兩種情況中,你都是在對 TypeScript 撒謊(也是在對自己撒謊),但是第一種情況你不知道你在對自己撒謊。如果你不得不對自己撒謊或者決定對自己撒謊,起碼你要知道你正在這么做。
所以我們應該這么樣做才能不對自己說謊呢?好的,你需要跟你 fetch 的數據建立一個強約定,然后再通知 TypeScript 這個約定。看看 Remix 中是怎么做的,下面是一個簡單的例子:
import type { LoaderArgs } from "@remix-run/node"
import { json } from "@remix-run/node"
import { useLoaderData } from "@remix-run/react"
import { prisma } from "~/db.server"
import invariant from "tiny-invariant"
export async function loader({ params }: LoaderArgs) {
const { workshopId } = params
invariant(workshopId, "Missing workshopId")
const workshop = await prisma.workshop.findFirst({
where: { id: workshopId },
select: { id: true, title: true, description: true, date: true },
})
if (!workshop) {
// 會被 Remix 的錯誤捕獲錯處
throw new Response("Not found", { status: 404 })
}
return json({ workshop })
}
export default function WorkshopRoute() {
const { workshop } = useLoaderData<typeof loader>()
// ^? { title: string, description: string, date: string }
return <div>{/* Workshop form */}</div>
}
useLoaderData
函數接收一個 Remix loader
函數類型并且能夠確定 JSON 相應數據的所有可能。loader
函數運行在服務端,WorkshopRoute
函數運行在服務端和客戶端,由于 userLoaderData
能夠明確 Remix loader 的約定,所以類型可以在網絡邊界同步(共享)。Remix 會確保服務端 loader
的數據是通過 useLoaderData
最后返回的。所有的事情都在同一個文件里面完成,不需要 API 路由。
如果你還沒有這樣實踐過,你也可以相信這是一個非常棒的體驗。想象一下,我們決定要在 UI 中顯示 價格 字段。 這就像在數據庫查詢更新一樣簡單,然后我們突然在我們的 UI 代碼中使用它,而無需更改任何其他內容。完完全全地類型安全!!!如果我們決定不使用 description
這個字段,那么我們只需要在 select
那里刪除這個字段,然后我們就會看到之前所有用到這個字段的地方都飄紅了(類型檢查報錯)。這對于我們重構代碼非常有用。
無處不在的網絡邊界。
你可能已經注意到了,即使 date
在后端是一個 Date
類型, 它在我們的 UI 層代碼使用的卻是 string
類型。這是因為數據經過了網絡邊界,在這個過程中所有數據都會被序列化成字符串(JSON 不支持 Date 類型)。類型工具強制讓這種行為發生。
如何你計劃要去顯示日期,你可能需要在 loader
中格式化它,在它被發送到客戶端之前做這個事情是為了避免出現時區錯亂。如果你不喜歡這么做,你可以使用像 superjson 或者 remix-typedjson 這樣的工具讓這些數據在發送到 UI 層的時候被恢復成日期格式。
在 Remix 中,我們也可以在 action
中保證類型安全。看看下面這個例子:
import type { ActionArgs } from "@remix-run/node"
import { redirect, json } from "@remix-run/node"
import { useActionData, useLoaderData, } from "@remix-run/react"
import type { ErrorMessages, FormValidations } from "remix-validity-state"
import { validateServerFormData, } from "remix-validity-state"
import { prisma } from "~/db.server"
import invariant from "tiny-invariant"
// loader 邏輯寫在這里。。。已省略
const formValidations: FormValidations = {
title: {
required: true,
minLength: 2,
maxLength: 40,
},
description: {
required: true,
minLength: 2,
maxLength: 1000,
},
}
const errorMessages: ErrorMessages = {
tooShort: (minLength, name) =>
`The ${name} field must be at least ${minLength} characters`,
tooLong: (maxLength, name) =>
`The ${name} field must be less than ${maxLength} characters`,
}
export async function action({ request, params }: ActionArgs) {
const { workshopId } = params
invariant(workshopId, "Missing workshopId")
const formData = await request.formData()
const serverFormInfo = await validateServerFormData(formData, formValidations)
if (!serverFormInfo.valid) {
return json({ serverFormInfo }, { status: 400 })
}
const { submittedFormData } = serverFormInfo
// ^? { title: string, description: string }
const { title, description } = submittedFormData
const workshop = await prisma.workshop.update({
where: { id: workshopId },
data: { title, description },
select: { id: true },
})
return redirect(`/workshops/${workshop.id}`)
}
export default function WorkshopRoute() {
// ... loader 處理邏輯。。。已省略
const actionData = useActionData<typeof action>()
// ^? { serverFormInfo: ServerFormInfo<FormValidations> } | undefined
return <div>{/* Workshop form */}</div>
}
同樣,不管我們的 action
返回什么,最終都會返回被 useActionData
序列化之后的類型。在這種情況下,我會使用 remix-validity-state
來保證類型安全。因為我通過提供給remix-validity-state
函數傳遞 schema 的形式進行校驗,所以被提交的數據也同樣是類型安全的。submittedFormData
也是類型安全的。當然,還有其他的庫可以實現類似的能力,但重點是,我們通過這些少量并且簡單的工具就能夠實現效果非常好的邊界類型安全,同時也增強了我們部署和運行代碼的信心。顯然,這些工具的 API 都比較簡單易用,雖然有時候這些工具本身內部的實現是非常復雜的 ??
應該提到的是,這也適用于其他 Remix 工具。meta
export 也可以是類型安全的,useFetcher ,useMatcher 等等都可以。世界又變得無比美好~~
認真的說,loader
只是冰山一角,但也可以說明很多的問題了~讓我們再看看下面這個(請耐心等待 gif 下載~):
這大概就是網絡邊界類型安全吧。而且,這一切都在一個文件中完成,太酷了??
總結
我在這里要說明的一點是,類型安全不僅有價值的,而且是可以做到端到端地跨邊界實現。最后 loader 的例子覆蓋了從數據庫到 UI。數據類型安全從 數據庫
→ node
→ 瀏覽器
,這讓研發效率大大的提升。不管你正在做什么項目,請思考如何減少類似 as XXX類型
這樣的用法,通過我上述的一些建議嘗試將這樣的用法轉換成真正的類型安全。我想日后你會感謝你自己的。這真的是值得投入去做的事情。
如果你想要運行一下這個例子,你可以直接 clone 這個項目:項目地址
最后的最后,希望你可以在下方留言跟我一起探討你的看法~~
歡迎點贊,關注,收藏 ?? ?? ??
本文是翻譯文,原文地址:https://www.epicweb.dev/fully-typed-web-apps