在目前主流的三大前端框架中,React 应该是和 TypeScript 联系最为紧密的;TypeScript 已经可以完美的融入到 React 的工具链中,所以若需要开发现代的、整洁的 React 应用, TypeScript 不可或缺;
此外,在 React 工具链中,React-Router 和 React-Redux 是重要的部分,它们也对 TypeScript 有很好的支持;这篇文章将结合刚过去不久的工程实训,谈谈我在使用 TypeScript + React 工具链的一些想法和实践;
TypeScript 入门
有道:学习一门语言的最好方法是看它的官方文档;
下面仅简单的介绍一些可以用的到的东西:
建立项目
考虑到你组项目常用 React + antd 组合,所以可以通过执行下面命令创建:
1 | yarn create react-app antd-demo-ts --template typescript |
均使用 CRA 创建样板项目,具体的配置被隐藏,一般需要 eject
后才可以修改;
虽然理论上需要使用 tsc
将 TypeScript 代码编译成 js 代码,但是这些事情都已经被脚手架集成了,所以在 React 项目中不用考虑这些事情。
需要注意的是,很多 node 模块默认是不包含 TypeScript 支持的;当你需要向一个 TypeScript 工程中引入某个模块时,不仅需要安装这个模块,还需要安装它的类型声明文件,就像这样:
1 | yarn add react-router-dom @types/react-router-dom |
否则,IDE 将会因为找不到类型声明文件而报错,或提示你没有安装对应的包。
类型标识
TypeScript 和 JavaScript 的核心区别;即使 React.js 可以通过 jsdoc 或者 proptype 进行类型检查和 IDE 指导,但是均没有 TypeScript 原生的类型检查来的优雅自然:
jsDoc:受有限的支持,在部分 IDE 中甚至不会有语法高亮;仅能在有限的环境中提供 IDE 建议,而不是强制的语法保证;毕竟本质上只是注释。
propType:是运行时的类型检查,并不能在编译期间提供任何指引。
类型标识的格式:变量名 / 参数名 / 类域名 : 类型标识符
,这些声明和 JavaScript 所在位置一样。
类型标识符包括 JavaScript Object 类型和由库定义的类型;浏览器,网页对象模型中的类型已经在 React 中定义好,也可以直接使用:
组别 | 标识符 |
---|---|
JSON | number , string , object , any , null , undefined , boolean , <class>[] , void |
TypeScript 特有 | never :表示的是永不存在的值的类型any :表示任何类型,TypeScript 将放弃类型检查 |
函数 | (param: any) => void :括号中是参数类型,需要指定参数名,但只对比参数类型 |
用户定义的类型 | 使用 interface 关键字定义的类型和 type 关键字定义的类型 |
React 常见类型 | React.Component<P,S> :组件类,P 是 props 类型,S 是 state 类型JSX.Element :JSX 标签类型,可以作为组件的返回值React.CSSProperties :描述了 style 的对象,连字符被更换成了驼峰命名法React.<...>Event :代表各种 HTML 事件的类型,如 change 和 keyboard 等 |
在 TypeScript 中,声明变量的类型如果不能由上下文自动推导,将提示错误:即你需要在任何不能推断类型的地方使用类型标识规定类型才能使得代码通过编译;
类型运算
比起其他强类型语言类型的麻烦,TypeScript 提供了灵活的类型运算;我们称其结果为类型谓词:
关键字 | 含义 |
---|---|
typeof | 获得其后跟随的变量类型,具体如下: - 跟随普通变量将获得变量的类型 - 跟随字符串将获得字符串字面量的类型 - 跟随对象将获得包含对象中所有属性的类型 - 跟随箭头函数将获得该函数的类型签名 这个表达式运行的结果是类型谓词。 |
interface | 类似 C 中的结构体(下文简称),声明包含特定属性的对象的类型谓词; |
type | 用来声明类型(谓词)或者类型别名;它可以接受: - 一个类型谓词的赋值,形如 typeof xxx - 使用 ` |
Partial<T> | T 为结构体,得到 T 所有域均为可选的类型谓词 |
Required<T> | T 为结构体,得到 T 所有域均为必须的类型谓词 |
Nullable<T> | T 为结构体,得到 T 所有域均为可空的类型谓词 |
↑↑ 后三个这种“类型”还有很多,出现在 typescript/lib/lib.es5.d.ts
的 1440 行左右,可以去看(
声明 interface
时,可以对成员变量标志后增加 ?
来表明它是可选的,也可以在标志前增加 readonly
来表明它是只读的;比如你可以这样声明一个 “结构体”:
1 | interface User { |
当你只需要一个 interface
中的部分属性的时候(比如后端通信),就可以使用 Partial<User>
来获得这样的类型;
在 React 中使用
React 现在有两种写法:一种是比较经典的 class 写法,还有一种是比较新的 React Hook 写法;它们均能和 TypeScript 较好的联动,增加代码的整洁程度:
class 写法
首先,我们继承的 React.Component
的定义是 React.Component<P, S>
;两个泛型参数分别制定了 props 类型和 state 类型,因此我们在创建一个组件时,需要先定义该组件的 props 和 state 的类型谓词;因为它们都是一个对象,所以我们使用 interface
定义它们的类型;一般遵循如下格式:
1 | import React, {Component, CSSProperties} from "react"; |
大体和 JavaScript 一致,只是增加了类型标识;需注意非箭头方法仍然需要 this 绑定;
React Hook 写法
其实只是函数式组件的写法,和 Hook 相关的内容暂时扯不上太多关系:
1 | import React, {CSSProperties, FC, memo, useState} from "react"; |
只是当组件需要持有状态时,采用 useState
钩子创建即可;下面列出了一些常用 Hook:
Hook | 来源 | 作用 |
---|---|---|
useState | React | 为当前的函数组件增加一个状态;返回一个包含状态和修改函数的数组; |
useContext | React | 在 Provider 下使用,获取其提供的共享 Context 对象; |
useEffect | React | 用于取代 class 型组件的生命周期方法: 它接受两个参数:一个回调函数(异步)和一个包含依赖项的数组; 当依赖项发生变化时,它会调用回调函数; 省略第二个参数:每次重新渲染时调用回调函数; 第二个参数为空:仅第一次渲染时调用回调函数 |
useReducer | React | 和 useState 类似;接受 Reducer 函数和初始状态作为参数; |
useHistory | React-Router | 在路由组件下使用,获得该路由依据的 History 对象,可用于跳转页面; |
但是 TypeScript 的强大之处并没有体现出来,当和 Redux 一起使用,组件的类型不能够明晰的分辨出时,TypeScript 所提供的类型提示的作用才可以真正反映出来;
Redux 入门
Redux 是前端的一个中心化数据管理的框架;它将一些组件中需要重复使用的数据提取出来,存储到一个统一的位置供组件拿取;并通过统一的预定义的行为对存储的数据进行修改,从而达成了数据流可控;在 React 中,Redux 通过 React 组件的上下文来实现;
本部分进行简单的 Redux 概念介绍,并结合 React Hook 和 TypeScript 构建代码:
概念介绍
首先先介绍专有名词:
- Store:即存储,中心化状态管理体系中数据存储的地方;对于 React 等现代前端框架而言,每个 SPA 仅持有 1 个;它是一个树状结构,可以用对象的形式表示;
- Action:即行为,用来修改 store 中存储的状态;一般来说,它是一个对象,包含这个 Action 是什么以及执行这个行为所需要的数据;
- Reducer:用来处理 Action,是一个函数;它接受 store 的上一个状态和 Action,返回新的 store 状态;它是一个纯函数,且定义了 store 的结构;
- Action Creator:即行为创造器,它可以接受必须的信息,用来生成一个符合要求的 Action 对象,是一个函数;
实际上,Redux 的工作流程可以概括为下图:
由 Action Creator 创造的 Action 对象可以通过 store.dispatch()
方法分发给 Store,Store 根据 Action 的类型(即这是什么行为)将它交给对应的 Reducer 计算出新的状态进行更新;
和 Store 的树形结构一样,Reducer 也是树形结构——可包含多个子 Reducer,和 Store 一一对应;Store 的树形结构来自 JavaScript 对象的嵌套,Reducer 的树形结构来自于多个子 Reducer 的合并;一般写法如下:
1 | interface StoreState { |
上面代码中的 XxxStore
和 xxxReducer
分别是子 Store 和子 reducer,唯一的根 Store 即由合并得到的根 Reducer 生成的 store
;enhancer
是增强件,可以用来强化 Redux 的功能;上图的 ”Async Middleware“ 就是其中一种,这里先不细说;创建的 Store 需要使用 Provider 提供给虚拟 DOM 某节点下所有的节点,这里通过 React 的上下文来实现;
composeWithDevTools
来自 node 模块 redux-devtools-extension
;它可以和 Chrome 同名插件配合使用,输出 Action 对 Redux Store 的更改轨迹,并随时查看 Store 中存储的值;它也属于 enhancer 的一种,建议使用;
关于 React-Redux
可以看到,Redux 这种设计模式适用于任何根据数据渲染组件的前端框架,这当然包括 React;React-Redux 的出现代替了上面提到的“订阅/通知”,而采用一种更加自然的方式帮助我们完成了这项工作。
在说这个之前先说一种在 React 中非常常见的设计模式:
有状态组件和无状态组件 顾名思义,状态指 React 中的 state;有状态组件不仅需要参与页面渲染,还需要包含逻辑来维护它自身所持有的状态;而无状态组件仅根据父组件传进来的 props 渲染视图;
软件设计中有一个原则,叫做“责任分离”:即让一个模块的责任尽量少;当模块负责的内容过多,应当将模块进行拆分,以使得每一个模块尽可能专注一个事务,以方便后续维护。
显然,渲染视图和维护状态的逻辑并不是一项功能,特别是当逻辑复杂时,会导致很大的组件——这对于后期项目维护来说是致命的;一种显然的解决措施就是将维护状态逻辑和渲染视图分离,即将一个“传统组件”拆分成有状态组件(即“容器”)和无状态组件(即“视图”);
首先,渲染视图和逻辑并不沾边,这样的分割是合理的;其次,这样做可以方便的更换数据管理的方式:比如有状态组件从 Redux 获取数据;也可以方便的更换数据的展示方式,这样做均只需要修改对应的组件即可,避免了对代码的大规模改动。
如果我们要在项目中使用 Redux,显然视图组件的数据来源是 Redux,我们应当使用这种设计模式;但是这方面的工作 React-Redux 已经帮我们做好了,我们只需要专注视图组件的展示即可;React-Redux 提供了方便的 API 实现 Store 中数据和视图状态的双向绑定:
- mapStateToProps:将 Store 中储存的状态双向绑定到组件的 Props,从而组件可以通过 Props 获得渲染视图所需要的状态;它的类型声明是
(state: Store) => object
; - mapDispatchToProps:将 Store 接受的 Action 的 Creator 通过 Props 传递给组件;Action 是改变 Store 状态唯一的途径,组件可以通过这些方法产生需要的 Action 并且自动分发到 Store;
- connect:接受上面的两个用户定义的方法和视图组件,自动生成一个该视图组件的容器组件;这个容器组件可以读取 Store 中特定的数据并交给视图组件渲染,并在必要时触发 Action;
简单的说,就是用户提供用于渲染视图的无状态组件,以及该组件需要进行的 Action,也就是逻辑,由 React-Redux 负责生成对应组件的容器;用户无需进行具体的组件状态管理,只需要定义 Action 和编写视图组件;在所有需要使用到该组件的场合使用 React-Redux 自动生成的组件即可;
使用 Redux
使用 React 的好处不言而喻;状态的更改有迹可循,组件并不需要维持很多状态就可以渲染视图,整个项目的逻辑更加的清晰自然;但是缺点也是显而易见的——将所有的操作定义为 Action 将产生大量的”无用代码“,增加了项目的代码量和复杂程度;React 自身也有优秀的状态管理,如果完全使用 Redux 作为状态管理,也是对 React 的功能的一种浪费;因此要做到有的放矢,才是最佳实践:
- 被多个 React 组件共享的状态需要放进 Redux
- 组件重新装载,仍然需要持有之前的状态时需要放进 Redux
- 否则,这个状态就放在 React 组件中好了
通过上面的方法可以判断一个状态是否要放进 Redux 中;对于放进 Redux 状态,我们仍然需要遵循一些”公理“,来使得代码的逻辑更加的合理:
- Redux 中的数据应当是范式化的 ”Raw Data“
- 通过 selector 将处理后的数据传送给组件
- 只有少量的关键组件 connect 了 Redux Store
Selector 相当于 Vue 中的计算属性 computed:它以 Store 中存储的一些数据为基础,记忆化的存储计算结果,且仅在这些数据源发生改变时才重新计算;比起 React 每次使用数据都进行重复计算要更加高效;Selector 本质上只是接受状态的函数,包含计算出容器所需要的数据的逻辑;它可复用,且一般在 mapStateToProps 中使用;
处理异步事件
如果仅使用前面提到的 Redux,我们需要为一个异步事件确定三个 Action:它们分别在异步事件开始时、异步事件 Fulfilled 和 Rejected 的情况下被触发;对应 Promise 的 resolve 和 reject;如果这些操作都在需要执行异步请求的组件中手动调用,将造成大量的重复代码,不可取;但是 Redux 的设计要求 Action 只能是同步的,我们并不能想当然的将一个异步事件做成一个 Action;此时就需要异步中间件了。
异步中间件仍然需要定义多个 Action,但是它将根据异步请求的结果自动地调用后续 Action,减少了代码重复;同时,异步 Action 遵循了 Redux 的标准,它对状态的修改也是可追溯的;常用的异步中间件如下:
- Redux-thunk:简单易上手,但功能有限;它会在第一个 Action 发出之后,自动地发出第二个 Action 用异步获取的数据来更新状态;
- Redux-observable:基于 RxJS 的
observer
实现,功能强大,可以处理复杂的事件流,但是学习门槛较高; - Redux-Saga:基于 JavaScript Generator 语法,介于上述两者之间;下文将着重介绍它的使用;
异步中间件属于对于 Redux 的拓展,在创建 store 时需要将需要的中间件放在上文代码 enhancer
的位置;
中间件原理
分析 Redux 的工作流程,Reducer 和 Action 是纯函数和纯对象,并不能进行改动;再参考上面说的没有中间件的情况下异步 Action 的方法,考虑到我们需要在 Action 分发的过程中增加中间件来自动完成一些工作;
中间件相当于一个改造过的 store.dispatch
;dispatch 方法可以拿到行为和前状态,并且有和可以改变状态的 Reducer 通信的能力;如果我们需要做什么包含副作用的行为,在 dispatch 函数内最合适不过了;所谓中间件,就是按照 Redux 的标准对 dispatch 方法进行了一些改造,增强了它的功能;
比如 redux-logger
中间件,他会在收到前状态和行为时打印前状态,并且在和 Reducer 通信后打印新状态;又比如上面提到的 redux-thunk
中间件要求 Action Creator 返回一个异步请求执行前和执行后都会发起同步 Action 的函数;这种特别的 Action Craetor 产生的 Action 本不能被 dispatch 函数处理,但是中间件增强了它的功能,使得这种 Action 被中间件处理成可以自动发起第二个 Action 的效果;从而达到了异步通信的目的;
使用 Redux-Saga
Redux-Saga 采用了 JavaScript 的生成器语法;它接管部分 Action,在收到 Action 时产生一个生成器对象,并且逐步执行其中的语句——这个过程保证了其内部语句执行的顺序;这个过程中可以发起新的 Action,由中间件自动完成;为了和普通的 Action 区分开来,我们将这部分被接管的 Action 称为 Saga;
生成器语法
首先先介绍大家都很熟悉的 yield
关键字:它在 C# 和 Python 中都存在也即将出现在 C++ 2a,在各领域(如 Unity 游戏编程)的协程中发挥作用;JavaScript / TypeScript 的 yield
和其它语言类似,如果理解的话就不用看下面了;
生成器函数使用 function*
声明,调用它将返回一个迭代器;每次调用迭代器都将执行到 yield
语句所在位置,并将该语句的值作为阶段性返回值返回,直到该函数中所有的语句都已经执行完毕;简单的说,yield
语句将会确保严格按照其顺序执行;
在 Redux-Saga 中,我们并不需要手动的去调用迭代器的 next
方法,中间件将会替我们完成;
Saga 的组成
和定义 Action 类似,定义 Saga 依然需要包含四个部分;但是它们被 dispatch 后的处理方式不同:
一般的 Action 需要被 Reducer 接受并且利用 switch
语句计算新状态,而 Saga 需要被已注册的监视函数监听后,根据定义的并行策略调用生成器函数,并自动迭代;异步 Action 需要发起的其他 Action 均可以作为该生成器函数中可迭代的一步;
因此,对于一个 Saga,我们还需要额外定义它的监视函数(仅包含一个语句,决定了这个异步行为的策略)和它的生成器函数(包含了这个行为需要进行的异步操作逻辑),代码范例见下文代码范式;
使用 Saga
和普通的 Action 一样,将 Saga 的 Action Creator 通过 React-Redux 的 mapDispatchToProps
方法传递给组件即可;创造出的 Saga 会被注册的监听函数捕捉,并自动迭代生成器函数;
代码范式
对于上一部分提到的一些名词,这里展示一些使用 TypeScript 编写的范式代码供参考:
Action & Action Creator
定义一个 Action 至少需要下面四个声明:
1 | export const LOGIN = 'LOGIN'; |
Action 标识字符串,Action 表示类型谓词,Action 对象的类型谓词和 Action Creator 函数;一般分别采用大写 + 下划线、大写 + 下划线、Pascal 命名法、驼峰命名法来命名;
Reducer
一个 Reducer 也许要包含下面四个部分:接受的 Action 类型,由它更新的子 store 的类型声明,这个子 store 的初始值以及 Reducer 函数;
1 | type Action = actions.Login | actions.Logout; |
从外部导入已经定义好的 Action 对象类型谓词,并将该 Reducer 需要处理的 Action 类型通过类型运算符 |
合并起来,就是这个 Reducer 需要接受的 Action 类型的类型谓词;
Reducer 函数需要接受上一个 Store 的状态和要进行的 Action 对象,通过 switch
语句匹配 Action 标识类型并且进行对应的操作,计算出新对象返回;当上一个 Store 状态不存在时,填入初始值;
React-Redux
下面的代码为视图组件 AppBar 创造了一个容器类:
1 | import {connect} from "react-redux"; |
上述代码中提到的 connect
其实需要接受四个类型参数:<TStateProps = {}, TDispatchProps = {}, TOwnProps = {}, State = DefaultRootState>
;但是因为可以类型推导以及默认值,所以这里没有写明:
TStateProps
:由mapStateToProps
带来的 props,上述代码中已经赋值给了StateProps
;TDispatchProps
:由mapDispatchToProps
带来的 props,上述代码中已经赋值给了DispatchProps
;TOwnProps
:除了这些之外该组件需要的 props;比如需要传入路由参数时,该值应当为RouteComponentProps
;State
:即原始 Store 的类型,已经通过默认值自动赋值了,一般不需要修改;
当你要将路由参数传入容器时,不仅需要显式地传入类型参数 RouteComponentProps
,还需要将组件使用 withRouter
方法处理;即:export default withRouter(connect<StateProps, DispatchProps, RouteComponentProps, StoreState>(mapStateToProps, mapDispatchToProps)(AppBar));
;
Redux-Saga
和其他异步中间件一样,Redux-Saga 接管了部分 Action 的处理,我们称这些 Action 为 Saga;下面是定义一个 Saga 并且将它应用到 store 的代码样例:
1 | import {put, takeLatest, call, all} from 'redux-saga/effects'; |
代码中的 takeLatest
表示不允许并行,仅监听并执行最新的异步 Action;
React-Router 5.x
官方文档还停留在 3.0 版本,可是这玩意从 4.x 开始就已经和之前截然不同了;本部分的内容也无法解释很多,仅从个人使用的过程中总结一下经验和踩过的坑;
安装
1 | yarn add react-router react-router-dom @types/react-router @types/react-router-dom |
组件
常用的组件包括三种:
- 根组件(路由组件):使用 history 模式的
BrowserRouter
和使用 hash 模式的HashRouter
- 路径匹配组件:可嵌套的路径组件
Route
和非嵌套的Switch
组件; - 导航跳转:
Link
组件和NavLink
组件、Redirect
组件等;
除非在静态服务器上部署使用 hash 路由,否则强烈建议使用基于 History 的 BrowswerRouter
;
Route 组件接受的参数主要包括下面的内容:
props | 含义 |
---|---|
path: string | 路由的路径;在没有 exact 标志时只匹配前缀,且按照从上到下的顺序匹配 |
exact: boolean | 精准匹配标志;当包含此标志时,路径匹配将要求完全一致,但忽略末尾的 ‘/’ |
component | 渲染组件;当路径匹配成功时需要渲染的组件 |
render | 渲染函数;当路径匹配成功时将调用此函数渲染视图,可用于 SPA 的懒加载 |
children | 可以是其他路由组件以组成嵌套路由;也可以只是一般的 JSX |
因为 React-Router 导航的 Web 应用本质是单页应用(SPA);当应用包含过多信息时,第一次载入将会十分耗时;我们可以将一些暂时不需要渲染的页面(组件)延迟加载,从而缩短第一次加载所需要的时间,提升用户体验:
1 | const Homepage = lazy(() => import("../containers/pages/homepage")); |
在 load
方法中还可以自定义当按需加载的组件还在加载中时需要显示的内容:比如一个 Loading 的动画、一个进度条或者是骨架屏,显著提升用户体验;
路由传参
在 TypeScript 中,路由传递的参数的类型谓词是 RouteComponentProps
;如果你需要在一个组件中使用路由参数,你需要在导出该组件时使用 withRouter
方法将它包裹,并修改显式声明的 IProps
;
传入 Route 参数之后,就可以通过 match
来获得匹配信息:比如通过 match.params
来获得路由参数;
路由跳转
有两种方法:经典的做法就是通过上面组件中的 Link
等导航组件进行跳转;当你使用 BrowserRouter
作为根路由组件时,你也可以在其子组件中通过 useHistory
钩子获得 History 对象,然后通过 history.push
方法进行路由跳转;需要注意的是 useHistory
是钩子,仅能在函数式 React 组件中使用;且当该组件渲染在跟路由组件外时,通过该钩子会得到 undefined
并且报错;
当然,上述做法都是需要在视图之内的地方才可以使用路由跳转的;如果你想要在视图之外的地方(比如异步逻辑层)进行路由跳转…… 人不能,至少不应该。建议停下来思考有没有更好的实现方法;当然方法还是有的:比如在逻辑层使用 ReactDOM.render
在某个不可见的地方渲染一个 Link
组件,又或者让根组件使用外部的 history 对象;但是这样写出来一定是烂代码所以这里就不细说了。
后记 & 总结
不是多少有技术含量的东西,但是属于读了一些还算整洁的 React 源代码的一些收获;按照这样的实践方法写出的代码确实要来的更加整洁;但是任何事情都没有绝对的最优实践:即使完全按照上面说到的这些写法来构筑代码,如果缺乏一个合理的设计,盲目追求“最佳实践”,最终只会适得其反。
虽然 Redux 提供了可追踪的状态管理,但是根据上面的代码范式也很容易知道,这是建立在大量繁杂的代码基础上的; 本质上 Redux 通过增加了额外的 Action 和 Reducer 代码来增强项目的可维护性,但是增加的代码是不是必要的
参考资料
http://www.ruanyifeng.com/blog/2016/09/redux_tutorial_part_one_basic_usages.html
https://www.tslang.cn/docs/home.html
https://juejin.im/book/5ba42844f265da0a8a6aa5e9/section/5ba4840f5188255c791b0008
http://www.ruanyifeng.com/blog/2016/09/redux_tutorial_part_two_async_operations.html
http://www.ruanyifeng.com/blog/2016/09/redux_tutorial_part_three_react-redux.html
https://www.cnblogs.com/samve/p/12435908.html
https://redux-saga-in-chinese.js.org/
https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Guide/Iterators_and_Generators