最近正在尝试在自己参与的 JavaScript 前端项目中引入静态类型检查。
至于引入的原因,很明显,源自 JavaScript 是一门【弱类型-动态类型】语言。这使得每次调用已写好的组件或者给已写好的组件打补丁时,都需要重新阅读相关组件的源代码、了解相关变量的类型,以确定可以在这些变量上调用哪些方法,然后再开始 Debug。肉眼检查代码很难完全发现和消除代码中的类型错误,这使得项目中频频出现 Cannot read property 'x' of undefined
或 xxx.map is not a function
等问题。这些 Uncaught Error
轻则使得某些功能无法使用,重则引起 React 引擎中断渲染,导致界面白屏(通常称为阻断性 bug)。此类 bug 往往需要很多时间进行排查,尤其是组件中存在异步事件时,bug 的修复将变得更加棘手。
更糟糕的是,当项目引入 Redux 进行状态管理时,在不使用 Redux Toolkit 的情况下,不论是 Selector、Reducer ,还是 Dispatch、Action ,它们的信息都分布在项目的其他目录里的若干文件中,几乎无法通过阅读代码确定类型,只能通过 console.log
进行调试。
因此,每次调用组件或者 debug 时都感觉:“要是 JavaScript 是强类型的,或者项目使用 TypeScript 进行构建就好了” 。但是将拥有成百上千个文件的中大型项目迁移至 TypeScript 谈何容易?在此语境下,渐进式引入静态类型检查,乃至增量地迁移至 TypeScript 是解决这个场景下问题的方法。
为 React 组件引入类型声明
引入 d.ts
d.ts
文件是 TypeScript 的类型声明文件。与 C/C++ 中类的声明与实现分离类似,我们可以为任意 js
文件创建一个同名的 d.ts
文件,然后在 d.ts
中声明这个 js
中有哪些组件及其类型。在使用 JavaScript 编写的 React 项目中,编译阶段 webpack 的编译器会完全忽略 d.ts
文件(貌似),因此引入 d.ts
文件不会对现有项目的运行造成任何影响。
虽然编译器会忽略 d.ts
文件,但是 VSCode 和 WebStorm 自带的 TypeScript 静态检查器可以根据 d.ts
指出潜在的类型错误,帮助我们发现 bug ,保证类型安全,从而提升代码质量。以下内容对前端常用开发工具 VSCode 和 WebStorm 均有效。
假设你写的应用中有某一个登录组件,它大概长这样:
// App.jsx
import React from "react";
import Login from "./Login";
const App = () => {
const user = {
username: "Admin",
password: "123456",
};
const permission = "Tier1";
return (
<div>
<Login user={user} permision={permission} />
</div>
);
};
export default App;
// Login.jsx
import React from "react";
const Login = (props) => {
const { user, permission } = props;
return (
<div>
{permission.toLowerCase() == "tier1" && <button>Edit</button>}
<p>{user.uesrname}</p>
<p>{user.password}</p>
</div>
);
};
export default Login;
为了实践本文所述内容,我们强烈建议您拷贝上述代码至本地,按照上述内容组织好文件布局,然后继续。
很明显,这样的组件是类型不安全的,实际上此处给出的代码存在类型问题,将会导致 bug ,乃至中断渲染 (想一想为什么)。这些 bug 往往很难排查。
创建类型声明
这两个组件对应的 d.ts
分别是:
// App.d.ts
// 暂无内容
// Login.d.ts
export interface LoginProps {
user?: {
username: string;
password: string;
};
permission: "tier1" | "tier2";
}
如果你不会写 TypeScript 这里介绍几个 TypeScript 的概念:
类型系统:TypeScript 允许我们为所有变量声明类型,语法为
const/let/var <变量名>: <类型>
。变量名写作<变量名>?
时表示它的类型为<类型> | undefine
。interface
与type
:TypeScript 中的interface
与一般编程语言的interface
很不一样,它可以用于定义变量的类型。type
是 TypeScript 中更接近interface
本质的东西,定义好的interface
和type
在使用上差别不大,大部分场景中type
与interface
可以混用,通常认为应该主要使用其中一种。如果你有选择困难症,可以遵循以下规则:能用interface
时就用interface
,interface
实现不了你的需求时就用type
。上述LoginProps
用type
可以定义为:export type LoginProps = { user?: { username: string; password: string; }; permission: "tier1" | "tier2"; }
联合类型(Union Type):注意
LoginProps
的permission
类型为"tier1" | "tier2"
,这表示permission
可以从"tier1"
和"tier2"
中任取一种类型。TypeScript 认为任何特定字符串都是一个类型,他们都是类型string
的子集。const permissionTier1: "tier1";
表明permissionTier1
的类型为"tier1"
,其值只能是"tier1"
。
连接声明与实现
编写 JSDoc
以 Login.jsx
为例,可以将代码修改为:
// @ts-check
import React from "react";
/**
*
* @param {import("./Login").LoginProps} props
* @returns
*/
const Login = (props) => {
const { user, permission } = props;
return (
<div>
{permission.toLowerCase() == "tier1" && <button>Edit</button>}
<p>{user?.username}</p>
<p>{user?.password}</p>
</div>
);
};
export default Login;
这种形式的注释被称为 JSDoc ,编辑器自带的 TypeScript 静态检查器可以通过 JSDoc 实现类型检查。这时候如果你在编辑器中将鼠标悬浮在 user
上方可以清晰地看见它的类型:
同时注意到代码的最开始我们添加了一行 // @ts-check
这在告诉 TS 静态检查器我们需要为这个文件启用静态检查。添加完成后代码中对 user
的引用会出现红色警告,表明这里存在类型错误。此处的错误是,因为 user
可能为 undefined
,对 undefined
引用 username
属性将引发渲染中断造成白屏,TS 静态检查器发现了这一潜在错误,我们需要为 user
变量的引用可选链运算符 ?
使得当 user
为 undefined
时直接返回 undefined
,避免错误。
如果你希望 TS 静态检查器忽略某一行,可以在前一行写上 // @ts-ignore
.
提示:如果你创建 d.ts
文件后发现对应 js
文件并没有识别出 d.ts
中的类型,请尝试在 VSCode 中关闭 js
文件后再打开。
使用 declare
语句
回到 App.tsx
,现在我们为组件的调用应用类型检查。因为 App
组件没有 props
因此不需要在组件开头声明类型,不过还是要在文件开头添加 // @ts-check
开静态检查。
但是注意到此时 Login
组件下方和红色报错,这是因为一旦为 Login
创建 d.ts
文件,TS 静态检查器将以 Login.d.ts
文件为准获取组件信息,而不是 Login.jsx
,而 Login.d.ts
中没有包含任何 Login
组件的信息,因此导致报错,注意此时项目仍然可以正常编译运行,因为正如前文所述,在使用 JavaScript 的 React 项目中 Webpack 编译器只关注 .js/.jsx
文件,爆红仅仅是静态检查器的行为。
为了修正错误,我们需要在 Login.d.ts
声明 Login
组件,告诉 TS 静态检查器 Login 是什么:现在将 App.d.ts
和 Login.d.ts
文件改为:
// App.d.ts
import * as React from "react";
declare const App: React.FC<{}>;
export default App;
// Login.d.ts
import * as React from "react";
export interface LoginProps {
user?: {
username: string;
password: string;
};
permission: "tier1" | "tier2";
}
declare const Login: React.FC<LoginProps>;
export default Login;
在 App
和 Login
前添加 declare
关键字是因为 d.ts
中应该只包含 Login
或 App
的类型而没有具体实现,因此需要使用 declare
关键字告诉 TS 静态检查器这里有一个名为 Login
的 React 组件,其 Props 的类型为 LoginProps
,真正的实现在项目的某处,由编译器自己去找。React.FC<LoginProps>
实际上是 React.FunctionComponent<LoginProps>
的别名,等价于:
declare const Login: (props: LoginProps) => React.ReactElement<LoginProps>
在不涉及泛型的情况下,使用 React.FC<>
即可。
为 Login
组件添加类型声明后,我们将发现,TS 静态检查器在 App
中了解到 Login
组件的 Props 信息,知道所有 Props 应该类型,同时发现先前的代码中对 Login
组件的 permision
属性传入是一个拼写错误。因为 TS 了解类型信息,所以可以给出可靠的代码提示,根据代码提示修改错误即可:
最后给出最终修改好的所有代码:
App.jsx
// @ts-check
import React from "react";
import Login from "./Login";
const App = () => {
const user = {
username: "Admin",
password: "123456",
};
const permission = "tier1";
return (
<div>
<Login user={user} permission={permission} />
</div>
);
};
export default App;
App.d.ts
import * as React from "react";
declare const App: React.FC<{}>;
export default App;
Login.jsx
// @ts-check
import React from "react";
/**
*
* @param {import("./Login").LoginProps} props
* @returns
*/
const Login = (props) => {
const { user, permission } = props;
return (
<div>
{permission.toLowerCase() === "tier1" && <button>Edit</button>}
<p>{user?.username}</p>
<p>{user?.password}</p>
</div>
);
};
export default Login;
Login.d.ts
import * as React from "react";
export interface LoginProps {
user?: {
username: string;
password: string;
};
permission: "tier1" | "tier2";
}
declare const Login: React.FC<LoginProps>;
export default Login;
实现 Redux 的类型安全
到目前为止,我们已经能够避免 React-JavaScript 项目中大部分由类型错误产生的 bug 。但是在引入 Redux 的项目中,Redux 部分的静态检查问题仍然没有解决。
Redux 是一种前端状态管理库,在 React 应用中可以实现跨组件通信。这一部分将假设你了解 Redux 的使用方法,知道 Reducer、Action、Selector、Dispatch 等概念,并且使用的是原生 Redux 写法,而不是现在官方推荐的 Redux Toolkit 框架。
此处给出一个在 React 项目中使用 Redux 的传统姿势:
// index.jsx
import store from "./store"
import App from "./App"
ReactDOM.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>,
document.getElementById("root")
);
// store.js
import { applyMiddleware, combineReducers, createStore } from "redux";
import thunk from "redux-thunk";
import { composeWithDevTools } from "redux-devtools-extension";
import { testLoginReducer } from "./TestLoginReducer";
import { useDispatch, useSelector } from "react-redux";
const rootReducer = combineReducers({
testLogin: testLoginReducer,
});
const store = createStore(
rootReducer,
composeWithDevTools(applyMiddleware(thunk))
);
export default store;
// TestLoginReducer.js
import * as actions from "./TestLoginAction";
const initialState = {
user: null,
loginTime: null,
};
export const testLoginReducer = (state = initialState, action) => {
switch (action.type) {
// ...
}
};
// TestLoginAction.js
export const TEST_LOGIN_LOGIN = "testLogin/login";
export const TEST_LOGIN_LOGOUT = "testLogin/logout";
// App.jsx
// @ts-check
import React from "react";
import Login from "./Login";
import { useSelector } from "react-redux";
const App = () => {
const { user } = useSelector((state) => ({
user: state.testLogin.user,
}));
const permission = "tier1";
return (
<div>
<Login user={user} permission={permission} />
</div>
);
};
export default App;
// Login.jsx
// @ts-check
import React from "react";
import { useDispatch } from "react-redux";
/**
*
* @param {import("./Login").LoginProps} props
* @returns
*/
const Login = (props) => {
const { user, permission } = props;
const dispatch = useDispatch();
const handleLogin = () => {
dispatch({
type: TEST_LOGIN_LOGIN,
payload: {
user: {
username: "Admin",
password: "123456",
},
timeStamp: new Date().toISOString(),
},
});
};
return (
<div>
{permission.toLowerCase() == "tier1" && <button>Edit</button>}
<p>{user?.username}</p>
<p>{user?.password}</p>
</div>
);
};
export default Login;
这里的代码示例中 App
与 Login
已经完成了静态检查,唯一不同的是 App
中的 user
从 Redux 中获取,Login
中的 handleLogin
可以修改 testLoginState
中的内容,实际的项目中这里可能表示某个按钮的点击事件,然后进行登录。接着 user
被更新,然后完成 UI 的更新。
可以看到 TS 静态检查器在 App
的 useSelector
中给出了警告,因为此时 state
的类型是 DefaultState
。
一般来说,如果 bug 被定位到这段代码中,那么将非常难以调试。试想这样的场景:
你发现
user
可能是导致 bug 的原因,然后想看看user
里究竟有什么;你鼠标悬停在
user
想看看它是什么类型,TS 当然不知道它是什么类型,毕竟此时state
的类型是DefaultState
,代码编辑器将user
的类型显示为为any
;你发现这是一个来自 Redux 中
TestLogin
部分的State
;接着你翻山越岭,找到了初始化
state.testLogin.user
的地方——TestLoginReducer.js
的initialState
变量内,然后从代码的海洋中(要知道一个reducer
中定义的State
一般来说很可能有几十甚至上百个property
)找到了user
的初始化情况,然而却惊讶地发现它被初始化为null
。这将导致你无法仅通过代码了解到
user
的具体情况,你不得不使用console.log
在运行时打印user
的值来确认其类型,如果仍为null
,那么意味着 bug 可能存在于与此状态相关的某个dispatch
异步事件中,此时你可能尝试搜索引用了能够修改user
值的那些action
的所有dispatch
,或者开始联系曾经接手过这部分代码的所有同事帮助你共同排查 bug …
总之这将会是一个艰难的过程。
你可能会想到利用之前提到的的 JSDoc 在调用 useSelector
时编写注释,让后来的人能够通过类型注解知道此处 user
的类型,但是这不是一个根本上的解决方案,因为这意味着每次使用 useSelector
都需要单独编写类型注释,比较麻烦,而且未必可靠。
我们希望的是,让 useSelector
了解 state
的结构,从而使得传入的 (state)=>({})
函数可以自动推导类型,直接得出 user
的类型是 {username: string; password: string} | undefined
的结论。同时我们还希望规范所有 action
的类型,让所有 type
的 action
都只能对应特定类型的 payload
,并且减少直接使用 dispatch
的机会,转而使用一类被称为 ActionCreator
的函数,通过仅通过调用带类型参数的返回 action
的函数来帮助创建 action
并且 disptach
。
定义 Action
上述 Redux 示例里有两个 action
,均在 TestLoginAction.js
下,和之前做类型检查的步骤一样,添加对应的 TestLoginAction.d.ts
如下:
export declare const TEST_LOGIN_LOGIN: "testLogin/login";
export type TEST_LOGIN_LOGIN = typeof TEST_LOGIN_LOGIN;
interface User {
username: string;
password: string;
}
export interface ITestLoginLoginAction {
type: TEST_LOGIN_LOGIN;
payload: {
user: User;
timeStamp: string;
};
}
export declare const TEST_LOGIN_LOGOUT: "testLogin/logout";
export type TEST_LOGIN_LOGOUT = typeof TEST_LOGIN_LOGOUT;
export interface ITestLoginLogoutAction {
type: TEST_LOGIN_LOGOUT;
}
export type TestLoginAction = ITestLoginLoginAction | ITestLoginLogoutAction;
我们想要为每一种 action
都指定对应的 payload
,这可以使用 interface
实现,这里有两种 action
—— TEST_LOGIN_LOGIN
和 TEST_LOGIN_LOGOUT
,其中 TEST_LOGIN_LOGIN
这个 action
需要特定类型的 payload
,而另一个不需要,TestLogin
的所有 Action
类型被汇总为 TestLoginAction
这种类型,它是所有 Action
的联合类型。
声明 State
的类型
TestLogin
的 state
也需要确定类型,从上可知 user
和 loginTime
都可以是 null
,实际上让它默认是 undefined
也问题不大,除非需要和后端传来的数据做匹配,不过更好的做法是初始化 username
和 password
为 ""
。可以先在 TestLoginReducer.d.ts
加上:
export interface TestLoginState {
user?: {
username: string;
password: string;
};
loginTime?: string;
}
然后在 TestLoginReducer.js
中声明:
/**
* @typedef {import("./TestLoginReducer").TestLoginState} TestLoginState
*/
/**
* @type {TestLoginState}
*/
const initialState = {
user: {
username: "",
password: "",
},
loginTime: "",
};
类型安全的 Reducer
Redux 中的 Reducer 是一种泛型,其原型为 Reducer<S, A> = (prevState: S, action: A) => S
,源于 演算,意思是给定初始的 state
,输入一个 action
序列,最终得到另外一个 state
。JavaScript 中的 reduce
函数式接口概念与此类似。
对于 testLoginReducer
这个函数,可以直接将其声明为:
/**
* @type {import("react").Reducer<TestLoginState, TestLoginAction>}
*/
export const testLoginReducer = (state = initialState, action) => {
switch (action.type) {
// ...
}
};
然后在 testLoginReducer
中启用 // @ts-check
,上述 reducer
中的 switch
语句是故意留空的,这时候代码会爆红,别急,稍后我们会来处理。接着还需要在 TestLoginReducer.d.ts
中声明 testLoginReducer
的存在,即添加代码:
import { Reducer } from "react";
import { TestLoginAction } from "./TestLoginAction";
export declare const testLoginReducer: Reducer<TestLoginState, TestLoginAction>;
此时 testLoginReducer
就是类型安全的了。
现在我们可以试着写一下 testLoginReducer
具体内容,将代码修改为:
// @ts-check
import * as actions from "./TestLoginAction";
/**
* @typedef {import("./TestLoginReducer").TestLoginState} TestLoginState
* @typedef {import("./TestLoginAction").TestLoginAction} TestLoginAction
*/
// ...
/**
* @type {import("react").Reducer<TestLoginState, TestLoginAction>}
*/
export const testLoginReducer = (state = initialState, action) => {
switch (action.type) {
case actions.TEST_LOGIN_LOGIN:
const payload = action.payload;
return {
...state,
user: payload.user,
};
case actions.TEST_LOGIN_LOGOUT:
return {
...state,
user: undefined,
};
}
};
此时爆红消失了。我们建议你手动编写这段代码而不是直接复制,然后你会发现,TS 静态检查器会在每个分支智能地判断出 action
的类型,从而让 VSCode 给出可靠的代码提示。
带类型封装的 useSelector
与 useDispatch
最后,我们需要在使用 dipsatch
和 selector
时也能享受到类型静态类型检查的好处,提升代码质量。为实现这一点,我们需要封装自己的 useSelector
和 useDispatch
。
首先创建 store.d.js
,添加如下代码:
import { EmptyObject, Store } from "redux";
import { TestLoginState } from "./TestLoginReducer";
import { TypedUseSelectorHook, useDispatch } from "react-redux";
import { Dispatch } from "react";
import { TestLoginAction } from "./TestLoginAction";
export type RootState = {
testLogin: TestLoginState;
};
export type RootAction = TestLoginAction;
export type AppDispatch = Dispatch<RootAction>;
export declare const store: Store<EmptyObject>;
我们先定义好整个 state
的类型,因为在 combineReducers
里我们写道:testLogin: testLoginReducer
,那么 TestLoginState
在所谓 RootState
的属性名就是 testLogin
,将来引入其他 Reducer
时同理。
然后定义 RootAction
的类型,这是整个项目所有 Action
的联合类型(此处只有一类 Action
即 TestLoginAction
)。接着定义自己的 dispatch
类型,即 AppDispatch: Dispatch<RootAction>
,这里的意思是凡是使用这种类型的 dispatch
时,传入的 Action
都必须是 RootAction
中的某一个实例,即在 RootAction
中注册的任意一种 Action
。
接着,注意到我们在 store.js
中 export
了 store
这种变量,因此需要在 store.d.ts
中声明这个变量。由于 createStore
方法是一个过时的 API ,store
的类型变得无关紧要,所以简单声明为 Store<EmptyObject>
即可。
最后,创建 hooks.js
和 hooks.d.ts
,内容分别为:
hooks.js
import { useDispatch, useSelector } from "react-redux";
export const useAppSelector = useSelector;
export const useAppDispatch = useDispatch;
hooks.d.ts
import { TypedUseSelectorHook } from "react-redux";
import { AppDispatch, RootState } from "./store";
export declare const useAppSelector: TypedUseSelectorHook<RootState>;
export declare const useAppDispatch: () => AppDispatch;
现在,我们可以在组件中使用类型安全的 useAppSelector
和 useAppDispatch
来与 Redux 交互了。
回到 App.jsx
,将代码修改为:
// @ts-check
import React from "react";
import Login from "./Login";
import { useAppSelector } from "./hooks";
const App = () => {
const { user } = useAppSelector((state) => ({
user: state.testLogin.user,
}));
const permission = "tier1";
return (
<div>
<Login user={user} permission={permission} />
</div>
);
};
export default App;
你将会惊奇地发现使用 useAppSelector
时 state
是包含类型信息的:
同样地在 Login.jsx
中使用全新的 useAppDispatch
:
// @ts-check
import React from "react";
import { TEST_LOGIN_LOGIN } from "./TestLoginAction";
import { useAppDispatch } from "./hooks";
/**
*
* @param {import("./Login").LoginProps} props
* @returns
*/
const Login = (props) => {
const { user, permission } = props;
const dispatch = useAppDispatch();
const handleLogin = () => {
dispatch({
type: TEST_LOGIN_LOGIN,
payload: {
user: {
username: "Admin",
password: "123456",
},
timeStamp: new Date().toISOString(),
},
});
};
return (
<div>
{permission.toLowerCase() == "tier1" && <button>Edit</button>}
<p>{user?.username}</p>
<p>{user?.password}</p>
</div>
);
};
export default Login;
在这里使用 dispatch
时也可以给出可靠的代码提示。
实现 ActionCreator
比起本文所述的 Redux 的使用方法,Redux 官方目前更加推荐另一种写法,即前文数次提到的 Redux Toolkit。Redux Toolkit 是一种对 Redux 更高层次的封装,要使用它实现上述 TestLoginState
需求,代码可以是:
// TestLoginSlice.ts
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
interface User {
username: string;
password: string;
}
interface TestLoginState {
user?: User;
loginTime?: string;
}
const initialState: TestLoginState = {
user: {
username: "",
password: "",
},
loginTime: "",
};
export const testLoginSlice = createSlice({
name: "testLogin",
initialState,
reducers: {
login: (
state,
action: PayloadAction<{
user: User;
timeStamp: string;
}>
) => {
const payload = action.payload;
state.user = payload.user;
state.loginTime = payload.timeStamp;
},
logout: (state) => {
state.user = undefined;
},
},
});
export const { login, logout } = testLoginSlice.actions;
export default testLoginSlice.reducer;
我们注意到,传统的 Redux 写法需要手动编写 Reducer<S, A>
函数,形成大量模板代码,有时候这是难以维护的。Redux Toolkit 的开发者注意到 action.type
大部分时候是可以对开发者隐藏的,因此使用 Slice
来将 Reducer
和 Action
统一起来。
上述代码中 export
的 login
和 logout
并不是带有 type
属性的 action
实例,而是生成 action
的函数,称为 ActionCreator
。以 login
为例,注意到 reducers
中写着 login
的函数签名为 :
(state, action: PayloadAction<{user: User; timeStamp: string;}>) => void
action
的类型实际上是:
{
type: string;
payload: {
user: User;
timeStamp: string;
};
}
而下方 export
的 login
是一个类型为:
(payload: { user: User; timeStamp: string; }) => {
type: string;
payload: { user: User; timeStamp: string; };
}
的 ActionCreator
,返回的正是一个 action
,一般来说,实际执行的时候 type
的值将满足 Redux Toolkit 的命名规范: testLogin/login
,dispatch
再根据这个 type
选择 reducers
中的某个方法执行。
虽然 Redux Toolkit 有诸多好处,既可以化简代码,又保证了类型安全,但是很可惜,从原生 Redux 写法迁移到 Redux Toolkit 的难度不会比一次性从 JavaScript 迁移到 TypeScript 低多少,因为直接迁移意味着所有显示构造 action
并执行 dispatch
的语句都需要进行修改,还需要改写所有 Reducer<S, A>
。就目前我正在尝试引入静态检查的项目而言,这类 dispatch
的调用超过 处,未来还将持续增加。因此直接迁移到 Redux Toolkit 几乎不可行。
不过我们还是可以通过手动创建 ActionCreator
来简化 dispatch
的调用。以前文提到的 login
和 logout
为例。我们只需要在 TestLoginAction.js
中添加两个函数 loginAction
和 logoutAction
:
// @ts-check
export const TEST_LOGIN_LOGIN = "testLogin/login";
export const TEST_LOGIN_LOGOUT = "testLogin/logout";
/**
* @type {import("./TestLoginAction").LoginAction}
*/
export const loginAction = (user) => ({
type: TEST_LOGIN_LOGIN,
payload: {
user: user,
timeStamp: new Date().toISOString(),
},
});
/**
* @type {import("./TestLoginAction").LogoutAction}
*/
export const logoutAction = () => ({
type: TEST_LOGIN_LOGOUT,
});
然后在 TestLoginAction.d.ts
中将其声明为:
export type LoginAction = (user: User) => ITestLoginLoginAction;
export declare const loginAction: LoginAction;
export type LogoutAction = () => ITestLoginLogoutAction;
export declare const logoutAction: LogoutAction;
我们就完成了 ActionCreator
的手动创建。
之后就可以使用形如 dispatch(loginAction(...))
的写法修改 Redux State 的状态了。
总结
一名开发者今天写的代码不仅为了完成当下的任务,还是现在与未来交流的桥梁。我们所做的一切都是为了提升代码的可读性,在未来的自己或他人回来再看这段代码、重构或修复 bug 时能迅速领会其含义、发现其中的错误,从而减少未来维护的成本、提升编码体验。这也是静态类型检查的目的。
遵照上述方法,我们可以渐进式地在使用 JavaScript 编写的 React-Redux 应用类型检查,直至整个项目几乎完全实现类型安全。
参考文献
- TypeScript 渐进迁移指南:https://segmentfault.com/a/1190000038963440
- 利用 TS-Check 对 JavaScript 进行静态类型检测:https://daotin.netlify.app/fw8lsf.html
- TypeScript + React + Redux = ❤️: https://zhuanlan.zhihu.com/p/74749048
附录
一些常用的 Typescript 类型声明示例
因为这不是一本字典,所以这里采用 Q&A 的形式给出一些示例:
如何封装一个 Material-UI 组件?
以封装一个 Box
为例,假设你想要封装一个最外层为 Box
的组件,你想要这个组件的 Props
暴露给外部时让人感觉它是一个包含额外功能的 Box
,只需要让这个组件的 Props
的类型继承 BoxProps
(MUI 中的所有组件都有其 Props
的 type
可以直接 import
) ,以刚才的 Login
组件为例,假设其最外层为 Box
,希望拥有 Box
的所有属性,可以写为:
Login.d.ts
import * as React from "react";
import { BoxProps } from "@mui/material";
export interface LoginProps extends BoxProps {
user?: {
username: string;
password: string;
};
permission: "tier1" | "tier2";
}
declare const Login: React.FC<LoginProps>;
export default Login;
Login.jsx
// @ts-check
import React from "react";
import { Box } from "@mui/material";
import { TEST_LOGIN_LOGIN } from "./TestLoginAction";
/**
*
* @param {import("./Login").LoginProps} props
* @returns
*/
const Login = (props) => {
const { user, permission, sx, children, ...oterhs } = props;
return (
<Box
sx={{
padding: "1.6rem",
...sx,
}}
{...oterhs}
>
{permission.toLowerCase() == "tier1" && <button>Edit</button>}
<p>{user?.username}</p>
<p>{user?.password}</p>
{children}
</Box>
);
};
export default Login;
组件的 props
没有 children
属性怎么办?
在组件的 Props
接口中添加一个字段:children: React.ReactNode
即可。以 Login
为例:
import * as React from "react";
export interface LoginProps {
user?: {
username: string;
password: string;
};
permission: "tier1" | "tier2";
children?: React.ReactNode;
}
如何继承泛型组件的 Props?
以 MUI 中的 Autocomplete
为例,AutocompleteProps
是一个泛型 type
,原型为:
interface AutocompleteProps<T, Multiple extends boolean | undefined, DisableClearable extends boolean | undefined, FreeSolo extends boolean | undefined, ChipComponent extends React.ElementType<any> = "div">
需要泛型参数 T
,如果要声明一个自定义组件 CustomAutocomplete
,可以写为:
export type CustomAutocompleteProps<T> =
AutocompleteProps<T, boolean, boolean, boolean, "div">;
export declare const CustomAutocomplete: <T>(
props: CustomAutocompleteProps<T>
) => React.ReactElement<CustomAutocompleteProps<T>>;
怎样选择性继承属性?
TypeScript 的 Omit
语法允许你剔除类型中的某些字段,假设要剔除下面类型中的 b
和 d
字段:
interface TypeA {
a: string;
b: number:
c: boolean;
d: () => void;
}
可以写为:
type TypeB = Omit<TypeA, "b" | "d">;
TypeB
等价于:
interface TypeB {
a: string;
c: boolean;
}
如何合并两个类型?
个人认为用 type
更加方便,当然 interface
也可以。
interface TypeA {
a: string;
b: number:
}
interface TypeB {
c: boolean;
d: () => void;
}
如果使用 type
合并上面两个类型,可以写为:
type TypeC = TypeA & TypeB;
等价于:
interface TypeC {
a: string;
b: number:
c: boolean;
d: () => void;
}
这被称为 Intersections Type .
其他常见问题
使用 d.ts
进行类型声明后如何快速定位到组件的实现?
这的确是一个尚且无法完美解决的问题。以往当我们引用一个组件时,只需要将鼠标悬浮到组件名上,按下 Ctrl
,然后单击鼠标左键,就编辑器就会直接转跳到组件的实现,而当引入 d.ts
时,上述操作只能转跳到组件的声明。对于组件的使用者,这尚且可以接受,但是对于组件的开发者来说就不方便了。
好消息是,如果你使用的是 WebStorm ,在 d.ts
中选中组件的声明,然后按下 Ctrl+F12
,就可以直接转跳到组件的实现。
然而如果你使用的是 VSCode,很不幸,虽然 Stack Overflow 上相关问题与官方文档都声称: “如果将 VSCode 升级到 1.67 版本、TypeScript 升级到 4.7 版本,然后在 d.ts
中选中组件名,接着右键点击 Go to Source Definition
,就可以转跳到组件的实现。” ,但经过本人多次实验与尝试,这种做法并没有奏效;不过经测试,以下做法勉强可行:在 d.ts
中选中组件的 Props 的定义,然后右键选择 Go to Implementations
(或按下 Ctrl+F12
),编辑器就会转跳至 .js
文件中声明组件 Props 类型的位置,同时也是组件实现的开头位置。举例来说,你想定位到 Login
组件的实现,那么可以在 d.ts
中选中 LoginProps
,一般来说 LoginProps
就在 Login
声明的向上若干行的位置,接着右键点击 Go to Implementations
就转跳到 Login
组件上方写着 @param {import("./Login").LoginProps} props
的位置。这种做法可行的原因是点击 Go to Implementations
后 VSCode 似乎会尝试寻找 .js
中所有引用了这个对象的位置。如果只有一个引用,VSCode 将直接转跳,否则 VSCode 会显示一个列表,允许你选择想要转跳到哪一个引用。而组件的 Props 接口通常与组件名关联,整个项目中通常只有一个或几个。
如何让 TS 静态检查器识别 @
路径别名
为了方便,许多项目会在 Webpack 中将 @
配置为 src/*
路径的别名,这样可以使用形如 @/components/xxx
的路径来引用组件。这样的做法虽然编译没问题,却对静态检查不友好,因为静态检查器并不知道 Webpack 的设置。可以通过配置 jsconfig.json
来解决此问题,具体做法是,在项目根目录下创建 jsconfig.json
(新版 creat-react-app
好像会自动创建这个文件,如果是 TypeScript 版的 React 项目那就是 tsconfig.json
),然后在里面添加如下内容:
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
},
"declaration": true,
"declarationMap": true,
"declarationDir": "./dist",
"jsx": "react",
"target": "ES2015",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true
},
"exclude": ["node_modules", "public", "server"]
}
"@/*": ["src/*"]
是实现上述需求的关键,意思是任何 "@/xxx"
的路径都会被静态检查器视作以项目根目录为基准的 src/xxx
路径。其他配置则是为了解决其他问题,比如下一个问题。
保存文件后,VSCode 将显示 “初始化 JS/TS 语言功能” ,这表明它正在根据 jsconfig.json
配置项目环境。配置完成后我们将发现所有合法的形如 @/xxx
的路径都可以通过 Ctrl+左键
直接转跳,TS 静态检查器也可以正确识别这些路径的内容。
使用 Set
和 Map
时 TS 静态检查器爆红怎么办?
Set
和 Map
是 ES2015 的新特性,其他特性还包括 Promise
、字符串模板、对象字面量、let/const
关键字等,部分特性要求在 jsconfig.json
中显式声明所用的 ES 版本才能让 ts-check
正确识别,因此在 jsconfig.json
的 "compilerOptions"
里加上 "target": "ES2015",
即可。