9102年了, 学习React其实完全可以从react-hooks开始, 这也是我写这篇文章的初衷。

另外我也希望能用比较简洁的语言来解释一些相关的原理, 不过本文还是会以如何使用为主, 所以比较适合想了解个大概后快速上手去写的人。

本文主要内容是解释如何基于Typescript来写React组件, 需要你有javascript基础, 对html有一定的认识。

准备环境

开发环境

已经装过的可以跳过

  1. 安装node 8.10以上的版本, 最好就用最新版
  2. 安装yarn
  3. 修改yarn的registry, 执行下列命令即可
    yarn config set registry http://registry.npm.taobao.org/
    
  4. 安装vscode, 这是对ts支持最好的editor了, 没必要用其他的

创建项目

你可以使用已有的项目, 或者parcel, webpack等其他构建工具。

$ mkdir react-quickstart && cd react-quickstart
$ yarn create umi

为了使项目结构简单一点,这里选择app,并选择使用typescript,最后勾选antd。

然后将src/layoutssrc/pages 下的文件都删掉,然后在src/pages下创建index.jsx,加上以下内容。

import * as React from "react";

export default function Index() {
  return <span>hello world</span>;
};
  • yarn install
  • yarn start

打开 http://localhost:8000/, 就能在屏幕上看到 hello world 了。

React入门

认识组件

使用react你唯一需要做的事情就是写组件, react里的组件和其他UI框架差不多, 就是封装了一系列UI和行为的代码.

上面我们在src/pages/index.jsx里定义了一个页面, 这个页面本身就是一个组件. 在react里定义一个组件的方法如下

// FC is Function Component
function Index() {
  return <span>hello world</span>;
};

没错, 组件就是一个函数(让我们先忽视类的写法), 对于没有状态的组件我们可以将其当作一个纯函数. 比如下面我们用ts来申明组件的参数:

const Index: React.FC<{ name: string; }> = (props) => {
  return <span>hello {props.name}</span>;
};

这个组件有一个 参数 用户名(输入), 以及对应返回的Virtual DOM(输出).

Inversion of Control

React是一个典型的IoC框架, 我们只要写好组件交付给React, 它能帮我们处理好UI(HTML DOM)的创建, 更新和销毁.

             组件                 React
Properties -------> Virtual DOM --------> HTML DOM

JSX(TSX)

上面例子里每个组件都返回了类似HTML的表达式, 这就是JSX.

我觉得官方文档讲得挺清楚了,你可以花几分钟看一下。另外这篇文档讲得比较详细,建议简单看一遍,遇到了问题可以多查阅。

你只要理解下面两点就能完全掌握JSX:

  1. JSX本身就是JS表达式(Expression)
  2. JSX里可以在参数部分(包括children参数)用花括号({})内嵌JS表达式

JSX范式

下面列了一些比较常见的JSX写法

条件渲染

  1. 在组件内
    `jsx
    function MyComponent(props) {
    if (props.visible) {
    return ‘Hi, I\’m visible.’;
    }

    return ‘I\’m invisible now.’;
    }

// Hi, I\’m visible.

// I\’m invisible now.

// I\’m invisible now.


2. 在JSX内,会用到花括号嵌入JS表达式来实现。由于JS中`if..else..`不是表达式,所以会使用逻辑运算符(`&&`或`||`)和三元表达式(`.. ? .. : ..`)
```jsx
function Greeting(props) {
  return (
    <span style={{ color: 'red' }}>
      {props.lang === 'janpanese' ? 'こんにちは' : 'Hello'}
      {props.emphasis && '!'}
    </span>
  );
}

<Greeting /> // <span>Hello</span>
<Greeting lang="janpanese" /> // <span>こんにちは</span>
<Greeting lang="janpanese" emphasis /> // <span>こんにちは!</span>

列表渲染

把一系列数据渲染一个列表或者表格也是相当常见的需求,在JSX里我们可以使用数组上的map方法来实现

function Ranking(props) {
  return (
    <div>
      {props.value && props.value.map((name, index) => (
        // 这里需要给列表每一项加上一个id
        <div key={index}>{index + 1}. {name}</div>
      ))}
    </div>
  )
}

<Ranking /> // <div></div>
<Ranking value={[]} /> // <div></div>
<Ranking value={['dio', 'kars', 'kirayoshikage', 'Diavolo']} />
/* output:
<div>
  <div>1. dio</div>
  <div>2. kars</div>
  <div>3. kirayoshikage</div>
  <div>4. Diavolo</div>
</div>
*/

这个例子有两个小技巧:

  1. 利用&&来判空,兼容value未传入的情况(此时value === undefined)。因为这里没有定义参数类型,使用者可能会传入任何值,所以需要做一些简单处理。不过在使用typescript后就不存在这种问题了。
  2. map中传入的箭头函数其实是以下函数的缩写,
    (name, index) => {
    return (
     <div>{index + 1}. {name}</div>
    );
    }
    
为什么渲染列表时需要传入key这个参数

这是由于React的diff算法所决定的,我们需要给列表中每项一个在当前列表内的唯一ID。你可以参考这篇文章来选择key。

封装复杂的渲染逻辑

当你在一个组件内的JSX写得越来越复杂时,可以将部分逻辑抽离出来单独写一个组件,如果你认为这些逻辑没什么可复用性,再写一个组件要传很多参数比较麻烦,那么单独抽离成一个函数也可以。

function ComplexComponent() {
  // some state code...
  const displayType = ...;

  function renderChart() {
    // codes
  }

  function renderTable() {
    // codes
  }

  return (
    <div className="some-layout-style">
      <div>
        { /* some complex jsx */ }
      </div>
      {displayType === 'chart' ? renderChart() : renderTable()}
    </div>
  )
}

有时候也可以将组件里一些重复用到的JSX写到一个函数内

function TopOfTheYear(props) {

  function renderRank(value) {
    return value && value.map((name, index) => (
      <div>{index + 1}. {name}</div>
    ));
  }

  return (
    <div>
      <div>ANIMES:</div>
      {renderRank(props.animes)}
      <div>MOVIES:</div>
      {renderRank(props.movies)}
    </div>
  )
}

出现嵌套列表时也可以递归调用,比如下面递归渲染的树

function Tree(props) {
  function renderTreeNodes(nodes) {
    return nodes.map((node, i) => (
      <div className="children">
        {node.name}
        {nodes.children && (
          <div className="children">
            {renderTreeNodes(nodes.children)}
          </div>
        )}
      </div>
    ))
  }

  return (
    <div className="tree-root">
      {props.value && renderTreeNodes(props.value)}
    </div>
  );
}

通常我们在刚写jsx时会有疑惑,为什么需要import react?

因为JSX被编译出来的JS表达式会使用React,比如下面的例子

// 编译前
const virtualDom = (
  <MyButton color="blue" shadowSize={2}>
    Click Me
  </MyButton>
);

// 编译后
const virtualDom = React.createElement(
  MyButton,
  { color: 'blue', shadowSize: 2 },
  'Click Me'
);

这里编译后不会自动帮我们加上import,所以需要我们手动加了。如果你不喜欢手动写import,可以通过配置打包工具来少些几行代码,比如webpack的配置方法

这个其实在上面的文档里就有解答,遇到了问题多看看文档。

状态

上面我们写的都是纯函数组件,但是大部分情况组件都会有内部状态,下面让我们看看怎么给组件添加状态。

PS: 有状态的组件称为函数组件

引入状态对应的API是React.useState,看下面的例子

import { useState } from 'react';

function Counter() {
  // 这里声明了我们要使用一个初始值为 0 的状态
  const [count, setCount] = useState(0);
  // count只有在第一次渲染时使用初始值,之后都是内部维持的状态
  // setCount用来更新状态
  return (
    <div>
      <div>count: {count}</div>
      <button
        onClick={() => setCount(count + 1)}
      >+</button>
    </div>
  )
}

复杂状态

有时候状态可能是个复杂的对象,和上面用法差不多,但要注意的是你只更新对象上的一个变量时,也需要生成一个新对象

只是简单更新单层对象可以参考以下范式

const obj1 = { a: 1, b: 2 };
// 解构
const obj2 = { ...obj1, b: 3 }; // { a: 1, b: 3 }
// Object.assign方法
const obj3 = Object.assign({}, obj1, { a: 2 }); // { a: 2, b: 2 }

例子

import { useState } from 'react';

function Counter() {
  const [state, setState] = useState({ count: 0, other: 'a' });
  return (
    <div>
      <div>count: {state.count}</div>
      <button
        onClick={() => setState({
          // 这里setState会覆盖掉之前的状态,所以要保留other的话,需要将新旧对象合并到一起
          ...state,
          count: state.count + 1,
        })}
      >+</button>
    </div>
  )
}

分离逻辑(useReducer)

todo
https://reactjs.org/docs/hooks-reference.html#usereducer

多个状态

useState可以在一个组件中创建任意数量的状态,但你不能在条件、循环和嵌套函数里调用。

import { useState } from 'react';

function Counter(props) {
  // 你只能在函数的顶级调用
  const [c, s] = useState(0);
  // error: 不能放到任何条件block内
  if (props.visible) {
    const [c, s] = useState(0);
  }
  // error: 同上
  const [c, s] = props.visible ? useState(0) : useState(99);

  function renderContent() {
    // error: 不能在嵌套函数中调用
    const [c, s] = useState(0);
  }

  // ...
}

你不用死记这些,因为有eslint会帮你发现这些错误,你只要遇到了知道是什么原因就行。

为什么要按固定的顺序调用?

函数组件的状态是由React帮你维护的,每次调用useState,React就会找到对应的状态返回给你,这是很经典的依赖注入的思想。

下面的例子相信很容易理解

// react 内部的状态列表 internal_state = ['a', 'b', 'c']
const [a] = useState('a'); // 返回 internal_state[0]
const [b] = useState('b'); // 返回 internal_state[1]
const [c] = useState('c'); // 返回 internal_state[2]

// 如果某些原因导致顺序发生了变化,拿到的值就不正确了
const [a] = useState('a'); // 返回 internal_state[0]
const [c] = useState('c'); // 返回 internal_state[1]
const [b] = useState('b'); // 返回 internal_state[2]

useEffect(组件生命周期)

React hook

上面提到的useStateuseReducer以及接下来介绍的useEffect都是React hook,hook你可以理解为函数组件内可以使用的一系列工具,通常 约定use开头。

如何监听生命周期

如果你之前接触过React,那你可能知道一些很常用的生命周期方法,像componentDidMount(组件第一次渲染后)、componentDidUpdate(组件每次更新后)等等。

这些生命周期方法对于一个组件是非常重要的,在函数组件里我们可以使用useEffect来实现。

下面是个如何监听页面滚动的例子,我们需要在组件挂载到节点后添加事件监听,并在组件销毁时移除事件监听以免内存泄漏。

import { useEffect } from 'react';

function MyComponent() {
  // 滚动事件的监听函数
  function scrollHandler() {}

  // 基本等同于componentDidMount
  useEffect(() => {
    document.addEventListener('scroll', scrollHandler);

    // 这里返回的函数会在组件销毁前被调用
    return () => {
      document.removeEventListener('scroll', scrollHandler);
    };
  }, []);

  return <div>...</div>
}

useEffect

useEffect会接收两个参数,第一个参数是一个函数A,其会在适当的时机被调用,该函数如果返回了一个函数B,函数B被称为clean up函数,B会在下一次A被调用前或者组件销毁前被调用。

第二个参数用来提示A的调用时机,通常有3种情况:

  1. 传入undefined(等同不传):这种情况会在每次渲染时调用A,可以理解为在组件首次渲染或者更新时调用。

    这种情况使用得不多,通常用来手动跟踪某些值和节点的变化。

  2. 传入[]:这种情况会在组件首次渲染时调用。

    通常用于组件内发起一次性的请求。

     const [result, setResult] = useState();
     const [loading, setLoading] = useState(true);
    
     useEffect(() => {
       // 这里你不能直接使用async function,因为它会返回的Promise会被误认为cleanup函数
       async function fn() {
         setResult(await fetchSomething());
         setLoading(false);
       }
       fn();
     }, [])
    
  3. 传入非空数组:在2的基础上,如果数组中任意值发生了变化,就会去调用。

    通常用来监听某些状态的变化并执行一些副作用函数,比如将状态保存到浏览器。

     const [value, setValue] = useState(() => localStorage.getItem('VALUE_ID'));
    
     useEffect(() => {
       // 每次value变化就会将其持久化到浏览器
       localStorage.setItem('VALUE_ID', value);
     }, [value]);
    

使用typescript来写React组件

为什么要用typescript?

没有类型辅助写大中型项目(尤其在多人协作方面)是件心智负担很重的事, typescript是目前使用最为广泛的 compile to javascript 语言. 当然你也可以使用 flow, reasonml, elm 等语言, 不过这些不是本文的目标。

如果只想用js, 那么只需要将文件后缀改为 jsx 并且去掉所有类型即可。

本节只会解释本文会用到的ts特性, 深入可以参考推荐阅读里的内容。

你需要知道的Typescript

ts里大部分时间不需要显式声明类型, 因为它能自动推断。

// 基础类型
const a: number = 1;
const b: string = 'abc';
const c: boolean = true;
const d: undefined = undefined;
const e: null = null;

// 函数类型
function parseInt(input: string, radix?: number): number { /* implement code */ }
// 箭头函数类型
const parseInt: (input: string, radix?: number) => number = (input, radix) => { /* implement code */ };

// Union Type
let union: number | string | undefined = 1;
union = undefined; // ok
union = 'ok'; // ok

// 字面量类型
const f: 'jotaro' | 'giorno' = 'giorno';
const g: 1 | 2 = 3; // ts will throw error
// 对象字面量类型
const obj: { field: string } = { field: 'abc' };

// 接口
interface A {
  message?: string; // '?'将会给类型添加undefined, 这里相当于 string | undefined
}
const h: A = { message: 'now' };
const i: A = {}; // message is undefined

const j = { message: 'now' }; // 如果我们不加类型定义, 这里j会被推断为 { message: string }

Type Compatibility

ts里如果两个接口的每项参数的类型是匹配的那么, 这两个接口就是匹配的. 比如下面两个接口在ts里其实没有什么区别

interface Point {
  x: number;
  y: number;
}

interface Rectangle {
  x: number;
  y: number;
}

这个特性主要对于经常出现的对象字面量很有帮助

interface A {
  message?: string;
}
let obj = { message: 'now' }; // 这个对象的类型是 { message: string }
// 虽然这个类型和A不是同一个类型,但是它们是兼容的, 所以这里可以将这个对象赋值给类型为 A 的变量 h.
const h: A = obj;

Union Type和其他字面量类型也是很符合直觉的

const a = 'a';
const b: 'a' | 'b' = a; // ok
const c: string | number = b; // ok
const d: number = c; // error
if (typeof c === 'number') {
  const e: number = c; // ok, c is number type here
}

还有其他比如函数、枚举、泛型的类型匹配可以看官方文档。因为这些比较少用到,所以碰到了再看文档就行。

泛型

和其他语言差不多, 在本文里就不深入解释了

import

通常你会看到在很多项目的tsx文件里会这么import react

import * as React from 'react';

这种写法是因为react本身是没有export default,而且ts不像js会默认去兼容commonjs的写法,所以你需要import entire module。如果你不喜欢这种写法,可以修改tsconfig.json来兼容。

编写可维护的组件

todo

推荐阅读

常用组件和库