15

[译] 使用 TypeScript 开发 React Hooks

 3 years ago
source link: http://mp.weixin.qq.com/s?__biz=MzI0MDYzOTEyOA%3D%3D&%3Bmid=2247484513&%3Bidx=1&%3Bsn=56ac8d93e6f29327b66e9dca86dd9a47
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

qeMRBnB.png!web 原文:https://www.toptal.com/react/react-hooks-typescript-example

React hooks 在 2019 年二月被引入,以改善代码可读性。本文将探讨如何将其和 TypeScript 协同使用。

在 hooks 之前,有两种风格的 React 组件:

  • 处理状态的 类组件(Classes)

  • 完全由其 props 定义的 函数式(Functional)组件

一种常见用法是,由前者构建复杂的容器(Container)组件,而后者负责简单些的展示型(Presentational)组件。

何为 React Hooks ?

容器组件负责状态(state)管理,以及在本文中被称为“副作用(side effects)”的远端请求。状态将经由 props 传播到子组件。

veqMR3j.png!web What Are React Hooks?

但随着代码的增长, 函数式组件也大有取代类组件成为容器的意思。

将函数式组件升级为状态庞杂的容器倒是谈不上痛苦,只是费时费力。此外,严格区分所谓容器和展示组件也不那么被看重了。

Hooks 可以很好地兼顾,能让代码既通用,又拥有几乎所有的优点。这里有个例子,用来演示如何向一个处理报价签署的组件中增添一个本地状态:

// 在一个本地状态中放置签名,并在签署状态改变时切换签名
function QuotationSignature({quotation}) {
   const [signed, setSigned] = useState(quotation.signed);
   useEffect(() => {
       fetchPost(`quotation/${quotation.number}/sign`)
   }, [signed]); // 签署状态改变时,副作用将被触发

   return <>
       <input type="checkbox" checked={signed} 
            onChange={() => {setSigned(!signed)}}/>
       Signature
   </>
}

还有个利好不得不说 -- 虽然相比于 TypeScript 在 Angular 中的丝滑编码,到了 React 中总被诟病臃肿难用;但 用 TypeScript 搭配 React hooks 却变为了一种愉悦的体验。

旧 React 里的 TypeScript

TypeScript 由微软设计并沿着 Angular 的路径一路进发,而彼时 React 开发出的 Flow 已然式微。在 React 类组件中编写原生 TypeScript 着实痛苦,因为 React 开发者不得不同时对 propsstate 定义类型,即便二者的许多属性是相同的。

按原来的方式来说,先得有一个 Quotation 类型,用来管理某些 CRUD 组件的 state 和 props。并在其相关的 state 中,创建一个 Quotation 类型的属性,以及指示已签署或未签署的状态。

interface QuotationLine {
  price: number
  quantity: number
}

interface Quotation{
   id: number
   title: string;
   lines: QuotationLine[]
   price: number
}

interface QuotationState{
   readonly quotation: Quotation;
   signed: boolean
}

interface QuotationProps{
   quotation: Quotation;
}

class QuotationPage extends Component<QuotationProps, QuotationState> {
  // ...
}

但是设想一下,在新建某个报价时我们面临的情况,也就是 QuotationPage 尚未向服务器成功请求到一个 id 时:之前定义的 QuotationProps 将无法获知这个关键的数字值 -- 不完整的数据也无法被 Quotation 类型 精确 匹配。我们可能不得不在 QuotationProps 接口中声明更多的代码:

interface QuotationProps{
   // 除去 id 之外 Quotation 中的所有属性:
   title: string;
   lines: QuotationLine[]
   price: number
}

我们拷贝了除去 id 之外的所有属性搞出一个新类型。这...让我回忆起在 Java 中,被不得不编写的一大堆 DTO (译注:Data Transfer Object,数据传输对象 -- 一种不包含业务逻辑的简单容器,其行为限于内部一致性检查和基本验证等) 所支配的恐惧。

为了克服这种痛苦,我们得在 TypeScript 的知识上补补课了。

TypeScript 结合 hooks 的好处

通过使用 hooks,我们就可以摒弃之前的 QuotationState -- 可以将其拆分为不同的两部分:

// ...

interface QuotationProps{
   quotation: Quotation;
}
function QuotationPage({quotation}:QuotationProps) {
   // 译注:由两个 useXXX 函数分摊了之前接口中的两个属性
   const [quotation, setQuotation] = useState(quotation);
   const [signed, setSigned] = useState(false);
   
   // ...
}

通过拆分状态,就省去了明确创建新的接口。本地状态类型往往能推导出默认的状态值。

因为 hooks 组件就是函数,故可以编写返回 React.FC<Props> 类型(译注:FC 即 function components)的相同组件函数。这样的函数显式声明了其函数式组件的返回类型,并明确了 props 类型。

const QuotationPage: FC<QuotationProps> = ({quotation}) => {
   const [quotation, setQuotation] = useState(quotation);
   const [signed, setSigned] = useState(false);
   
   // ...
}

显然,在 React hooks 中使用 TypeScript 比在类组件中容易。并且因为强类型对于代码安全是个有力的保障,如果你的新项目中用了 hooks 就应该考虑采用 TypeScript 了。

适配 hooks 的 TypeScript 特性

在之前的 React hooks TypeScript 例子中,对于 QuotationProps 接口中的属性如何使用、使用哪些,仍是不甚了了、颇有不便。

TypeScript 其实提供了不少“工具方法”,以便在 React 中描述接口时有效“降噪”。

  • Partial<T> : T 类型所有键的任意子集
  • Omit<T, 'x'> : 除 x 之外的 T 类型所有键
  • Pick<T, 'x', 'y', 'z'> : 从 T 类型中明确拾取 x, y, z
nyMVz2n.png!web Specific Features of TypeScript Suitable for Hooks

在我们的用例中,可以用 Omit<Quotation, 'id'> 的形式来将 id 排除在 Quotation 类型之外。结合 type 关键字反手就能甩出一个新类型。

Partial<T>Omit<T> 并不存在于 Java 等大部分强类型语言中,但常在前端开发中以各种方式大展身手。它们简化了类型定义的负担。

type QuotationProps = Omit<Quotation, id>;
function QuotationPage({quotation}: QuotationProps){
   const [quotation, setQuotation] = useState(quotation);
   const [signed, setSigned] = useState(false);
   
   // ...
}

当然,或许也可以用 extends 更清晰地区分出持久化类型等:

interface Quote{
   title: string;
   lines: QuotationLine[]
   price: number
}

interface PersistedQuote extends Quote{
  id: number;
}

这样在处理相关属性时,也简化了常见的 ifundefined 问题。

慎用 Partial<T> ,它基本不会带来任何保障。

Pick<T, ‘x’|’y’> 是另一种不用声明新接口就能随时定义新类型的方式。如果一个组件只需要简单编辑报价标题的话:

type QuoteEditFormProps = Pick<Quotation, 'id'|'title'>

或直接在行内声明:

function QuotationNameEditor({id, title}: Pick<Quotation, 'id'|'title'>){ ...}

别怀疑,我可是领域驱动设计(DDD - Domain Driven Design)的铁杆拥趸。我并不是懒得为了声明个新接口而懒得多写两行 -- 需要精确描述领域内命名时,我会使用接口;而出于保证本地代码正确性、降噪的目的,我就使用这些 TS 工具语法。

React Hooks 的其他益处

React 团队始终将 React 视为一个函数式框架。过去他们使用类组件以处理自身状态,现在有了 hooks 这种允许一个函数跟踪组件状态的技术。

interface Place{
  city: string,
  country: string
}
const initialState: Place = {
  city: 'Rosebud',
  country: 'USA'
};
function reducer(state: Place, action): Partial<Place> {
  switch (action.type) {
    case 'city':
      return { city: action.payload };
    case 'country':
      return { country: action.payload };
  }
}
function PlaceForm() {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <form>
      <input type="text" name="city" 
        onChange={(event) => {
          dispatch({ type: 'city', payload: event.target.value})
        }}
        value={state.city} />
      <input type="text" name="country"   
        onChange={(event) => {
          dispatch({type: 'country', payload: event.target.value })
        }}
        value={state.country} />
   </form>
  );
}

这就是一个安全使用 Partial 的良好用例。

尽管 reducer 函数会被多次执行,但相关的 useReducer hook 将只被创建一次。

通过 自然而然地 将 reducer 函数定义在组件之外,代码可以被分割成多个独立的函数,而不是都集中在一个类中并共同围绕着其内部状态。

这对可测试性大有裨益 -- 某些函数只处理 JSX,其他一些只处理业务逻辑,等等。

你(几乎)不再需要高阶组件(HOC - Higher Order Components)了。渲染属性(render props)模式更易于编写函数式组件。

这样一来,阅读代码变得更容易了。代码不再是连绵混杂的 类/函数/模式,而仅仅是函数的集合。然而,因为这些函数并未附加到一个对象中,对它们命名可能有点难。

TypeScript 仍是 JavaScript

JavaScript 的乐趣在于你能以任何方式摆弄你的代码。加上 TypeScript 后,你仍可以用 keyof 访问对象的所有键,也能使用类型联合创建出晦涩难搞的某些东西 -- 怕了怕了。

要确保你的 tsconfig.json 设置了 "strict":true 选项。在项目动工前就检查它,否则你将不得不重构很多东西!

对于以何种程度类型化代码是有争议的。你可以手动定义所有东西,也可以让编译器推断出类型。这取决于 linter 工具的配置和团队约定。

同时,你仍会遇到运行时错误!TypeScript 比 Java 简单,并且回避了泛型的协变/逆变问题。

在下例中,有一个 Animal 列表,以及一个相同的 Cat 列表。

interface Animal {}

interface Cat extends Animal {
  meow: () => string;
}

const duck = { age: 7 };
const felix = {
  age: 12,
  meow: () => "Meow"
};

const listOfAnimals: Animal[] = [duck];
const listOfCats: Cat[] = [felix];

function MyApp() {

  const [cats , setCats] = useState<Cat[]>(listOfCats);
  // 问题1:再次被使用的 listOfCats 声明为了一个 Animal[]
  const [animals , setAnimals] = useState<Animal[]>(listOfCats)
  const [animal , setAnimal] = useState(duck)

  return <div onClick={()=>{
      animals.unshift(animal) // 问题2:指鸭为猫
      setAnimals([...animals]) // 造成 dirty forceUpdate
    }}>
    The first cat says {cats[0].meow()} // 不言而喻
  </div>;
}
6rMfuie.png!web

糟糕的是,由于分别用 Cat[]Animal[] 两种泛型声明了 listOfCats,而后把 listOfAnimals 中的 duck 错误地压入了第二次声明为   Animal[] 的 listOfCats 数组 -- 仅从 TS 静态语法上看是一个 Animal 进入了一个 Animal[] ,但这就让随后对第一次声明为 Cat[] 的 listOfCats 元素调用发生了运行时错误。

TypeScript 只有一种泛型的简单 双变(bivariant) 实现,以供 JS 开发者采用。如果对变量命名得当,就能很大程度上避免指鸭为猫。

同时,存在向 TS 中增加 inout 约束的提案(https://github.com/microsoft/TypeScript/issues/10717),以支持协变和逆变。

--End--

YFjIBnu.jpg!web

查看更多前端好文

请搜索 fewelife 关注公众号

转载请注明出处


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK