React 和 React Native

React Nativeopen in new window 基于 Reactopen in new window,我们可以通过 React Native 来掌握 React 的核心概念。

本文节奏较快,如果你感到困惑,请参考文末的资料或其它资料

创建一个 React Native 应用

配置 React Native 开发环境open in new window

安装 react-native-create-appopen in new window

npm install -g react-native-create-app

打开一个新的终端,创建一个 React Native Demo

# 如果创建失败,可能需要科学上网
cd ~/Downloads && react-native-create-app MyDemo

通过 VS Code 打开刚刚创建的项目

cd MyDemo && code .

按下 Control + `,打开 VS Code 自带终端

` 在键盘的左上方

启动 Package Server

npm start

如图,点击 "+" 号,打开另一个终端

framework-2021-10-15-01-54-10

启动 ios 或 android 应用

# npm run ios
npm run android

组件

组件负责渲染

认识组件和元素

组件是一个特殊的函数,它返回 null 或元素。

组件返回 null, 代表什么也不渲染

function Welcome() {
  return <Text>Hello World!</Text>
}

如上,Welcome 就是一个组件,Text 也是一个组件,用于渲染文本。

什么是元素呢?

<Text>Hello World!</Text> 就是一个元素。

这种把组件包裹在一对尖括号的语法称为 JSXopen in new window,它是一种语法糖。

const element = <Text>Hello World!</Text>

上面这行代码最终被 Babel 编译为:

const element = React.createElement(Text, null, "Hello World!")

可以自己试试看open in new window

createElement 接受三个参数,第一个指定要渲染的组件,第二个指定组件的属性,第三个参数指定子元素

认识 Props

在下面这个例子中,Welcome 组件渲染的文本是被写死的:

function Welcome() {
  return <Text>Hello World!</Text>
}

如何动态改变呢?我们通过定义属性(Props)来解决。

interface Props {
  name: string
}

function Welcome(props: Props) {
  return <Text>Hello {props.name}!</Text>
}

function App() {
  return (
    <View>
      <Welcome name="Sara" />
      <Welcome name="Cahal" />
      <Welcome name="Edite" />
    </View>
  )
}

nameWelcome 组件的属性,我们在 App 组件中为 name 属性赋值。

这样,name 就没有写死在 Welcome 组件中,而是由使用它的组件来决定。

认识 State

上面这个例子还是不够动态,name 属性虽然没有写死在 Welcome 中,但是却写死在 App 中。有没有更动态的方法呢?

在这里,我们使用状态

import React, { useState } from "react"
import { View, Text, TextInput, Button, StyleSheet } from "react-native"
import { withNavigationItem } from "react-native-navigation-hybrid"

interface Props {
  name: string
}

function Welcome(props: Props) {
  return <Text style={styles.text}>Hello {props.name}!</Text>
}

function App() {
  const [name, setName] = useState("Sara")
  const [text, setText] = useState("")
  return (
    <View style={styles.container}>
      <Welcome name={name} />
      <TextInput value={text} onChangeText={setText} style={styles.input} />
      <Button title="确定" onPress={() => setName(text)} />
    </View>
  )
}

事情开始变得复杂。

第 1 行,我们从 react 中导入 useState,useState 是一个 React Hookopen in new window,它表示组件的状态。

import React, { useState } from "react"

第 14 行,通过调用 useState,并传入初始值 Sara,我们得到一个元组 [name, setName]name 现在的值是 Sara,setName 是一个函数,调用后将改变 name 的值,譬如调用 setName('Cahal'),name 的值将变成 Cahal,是不是很神奇?

const [name, setName] = useState("Sara")

name 和 setName 只是一个自定义变量,可以是其它有意义的名称

第 18 行,我们将变量 name 传递给 Welcome 的属性 name,因此我们在界面上看到 Hello Sara!

<Welcome name={name} />

像 name 这种通过 useState 创建的变量,就被称为状态 (State)。

State vs Props

状态是由组件内部自己维护的,是可变的。

属性是由组件外部传递进来的,是不可变的。

⚠️ 如果属性是一个对象,注意不要改变这个对象的属性,不可变只是规则,是需要我们自觉维护的。

状态或属性发生变化,将导致 UI 发生变化,我们通过改变数据来改变 UI。数据变了,UI 也就变了。

数据驱动视图。

单向数据流

第 15 行,我们通过 useState 创建了一个名为 text 的状态,它的初始值是空字符串。

const [text, setText] = useState("")

第 19 行,TextInput 是个输入框,我们将变量 text 赋值给了 TextInput 的属性 value,将变量 setText 赋值给了 TexInput 的属性 onChangeText。

<TextInput value={text} onChangeText={setText} style={styles.input} />

onChangeText 的类型是 (text: string) => void,这意味着,当 onChangeText 被调用时,将传入一个类型为 string 的参数,而 setText 刚好接受一个类型为 string 的参数。

属性 value 表示 TextInput 要显示的值,通常和用户输入一致,当用户输入发生变化时,属性 onChangeText 所绑定的方法将被调用。

当用户输入发生变化时,要做什么,以及 TextInput 要显示什么,都是由 App 这个组件说了算。

正常情况下,我们输入 123,将会看到 TextInput 显示 123。我们稍微改变下 onChangeText 的值:

<TextInput value={text} onChangeText={(value) => setText(value.replace(/./g, "*"))} style={styles.input} />

在上面的代码中,我们把用户输入全都替换成 * ,再作为参数传递给 setText。

setText 被调用后,text 就会发生变化,而 text 刚好被赋值给了 TextInput 的属性 value,而 value 决定 TextInput 显示什么,然后用户看到的输入就是一串星星。

可以看到,TextInput 显示什么,不是由它自己决定的,也不是由用户输入决定的,而是由 App 决定的。

我们把这种显示什么,做什么,都由其父组件控制的组件为受控组件,Welcome 是受控组件,TextInput 也是受控组件。

我们编写的自定义组件,都必须让它受控。

现在,让我们还原 TextInput 相关代码

<TextInput value={text} onChangeText={setText} style={styles.input} />

第 20 行,我们给 Button 的 onPress 属性绑定了一个匿名函数,这个函数调用了在 14 行创建的函数 setName,并把第 15 行声明的变量 text 作为参数传递给了 setName

<Button title="确定" onPress={() => setName(text)} />

当我们在输入框中输入 Cahal,并点击确定按钮时,我们可以看到,Hello Sara! 变成了 Hello Cahal!

在 React 的世界里,数据只能由父组件流向子组件,而不能反过来。这就是单向数据流。TextInput 很好地体现了这点。

React Hook

什么是 Hook 呢?

Hook 是让我们可以在函数组件内部勾入(hook into)React 组件状态和生命周期的函数。

React Hook 负责行为和数据

function App() {
  const [name, setName] = useState("Sara")
  const [text, setText] = useState("")
  return (
    <View style={styles.container}>
      <Welcome name={name} />
      <TextInput value={text} onChangeText={setText} style={styles.input} />
      <Button title="确定" onPress={() => setName(text)} />
    </View>
  )
}

在我们的例子中,setName 或 setText 每次被调用,都会引发重新渲染。

重新渲染是指 App 这个函数被重新调用,并返回新的元素。

App 被重新调用,useState 也会被重新调用。

会生成新的 name 和 text 变量,它们的值就是最后一次调用 setName 或 setText 时传递的值,或首次调用 useState 时传递的值。

setName 和 setText 也是新的,它们指向初次调用 useState 时创建的函数,也就是说,不管 App 重新渲染多少次,setText 总是上次那个 setText。

这就是 useState 的魔法,或者说 React Hook 的魔法。

这背后的原理,可以查看官方文档的描述open in new window

闭包陷阱

在正式讲解 React Hook 之前,我们先来了解一个语言特性。

function App() {
  const [name, setName] = useState("Sara")

  function handleButtonPress() {
    setName("Lisa")
    setTimeout(() => Alert.alert("提示", `name is ${name}`), 0)
  }

  return (
    <View style={styles.container}>
      <Welcome name={name} />
      <Button title="确定" onPress={handleButtonPress} />
    </View>
  )
}

当我们点击按钮时,调用了 handleButtonPress 函数,在这个函数里,我们通过 setName 把状态 name 的值变为 Lisa,然后通过 Alert 显示 name 的值。

结果如下图所示:

Welcome 组件如我们所期待的那样,刷新了。但 alert 出来的 name 还是之前那个。为什么会这样呢?

因为 App 这个组件首次渲染时,也就是 App 这个函数首次被调用时,创建了一个名为 name 的变量,这个变量的值是 Sara,同时也创建了一个名为 handleButtonPress 的函数,这个函数使用了 name 这个变量,或者说函数 handleButtonPress 捕获了变量 name。

当我们点击 Button 时,handleButtonPress 这个函数被调用。这个函数做了两件事情:

第一件,调用 setName,改变状态 name 的值为 Lisa,注意,我这里说的是状态 name,而不是变量 name。

setName("Lisa")

setName 被调用,导致 App 组件被重新渲染,也就是 App 函数被重新调用,此时重新创建了一个名为 name 的变量,它的值是 Lisa,也重新创建了一个名为 handleButtonPress 的函数,这个函数使用了刚刚创建的那个名为 name 的变量。

随着 App 执行到 return 语句,Welcome 组件也被重新渲染了,使用了最新创建的 name 的值。

第二件,弹出 Alert,显示变量 name 的值。

setTimeout(() => Alert.alert("提示", `name is ${name}`), 0)

setTimeout(..., 0) 保证 alert 发生在 App 组件重新渲染之后

第二件事情,依然是那个旧的 handleButtonPress 在做,它里面的 name 是那个旧的 name,因此 alert 出来的是 Sara

name 也好,handleButtonPress 也好,不过都是函数 App 的本地变量,每次 App 被调用时,都会被重新创建,重新赋值。

这就是闭包陷阱。看起来反直觉,实际上理所当然。

如果希望 alert 出来的 name,就是最新创建的那个 name,又该如何呢? 我们在讲 Ref Hook 的时候会提到,这里暂且搁下。

State Hook

useSate 前面我们已经接触过,用来添加一个局部状态。

const [state, setState] = useState(initialState)

返回一个 state,以及用于更新 state 的函数。

在初始渲染期间,返回的状态 (state) 与传入的第一个参数 (initialState) 值相同。

initialState 参数只会在组件的初始渲染中起作用,后续渲染时会被忽略。如果初始 state 需要通过复杂计算获得,则可以传入一个函数,在函数中计算并返回初始的 state,此函数只在初始渲染时被调用:

function App(props) {
  // ✅ someExpensiveComputation() 只会被调用一次
  const [state, setState] = useState(() => someExpensiveComputation(props))
  // ...
}

setState 函数用于更新 state。它接收一个新的 state 值,并将组件的一次重新渲染加入队列。

setState(newState)

在后续的重新渲染中,useState 返回的第一个值将始终是更新后最新的 state。

如果新的 state 需要通过使用先前的 state 计算得出,那么可以将函数传递给 setState。该函数将接收先前的 state,并返回一个更新后的值。

setState((preState) => someComputation(preState))

Effect Hook

useEffect 用于函数组件中执行副作用,譬如网络访问,本地存储,等等。

副作用在函数组件渲染完成后执行。

import React, { useEffect, useState } from "react"
import { View, Text, Button, StyleSheet } from "react-native"
import { InjectedProps, withNavigationItem } from "react-native-navigation-hybrid"

function App({ garden }: InjectedProps) {
  const [count, setCount] = useState(0)

  function handleButtonPress() {
    setCount((c) => c + 1)
  }

  useEffect(() => {
    garden.setTitleItem({
      title: `You clicked ${count} times`,
    })
  })

  return (
    <View style={styles.container}>
      <Text style={styles.text}>You clicked {count} times</Text>
      <Button title="确定" onPress={handleButtonPress} />
    </View>
  )
}

第 12 行,我们通过 useEffect 注册了一个副作用函数,根据 count 的值来修改页面标题,这个函数会在 App 完成渲染后,也就是 App 函数被调用后某个时刻执行。

garden 是 react-native-navigation-hybridopen in new window 这个负责 React Native 应用层级结构和页面间导航的库注入进来的一个不变对象,用于修改页面属性。

如果组件中不止一个状态:

function App({ garden }: InjectedProps) {
  const [count, setCount] = useState(0)

  function handleButtonPress() {
    setCount((c) => c + 1)
  }

  useEffect(() => {
    garden.setTitleItem({
      title: `You clicked ${count} times`,
    })
  })

  const [text, setText] = useState("")

  return (
    <View style={styles.container}>
      <Text style={styles.text}>You clicked {count} times</Text>
      <TextInput value={text} onChangeText={setText} style={styles.input} />
      <Button title="确定" onPress={handleButtonPress} />
    </View>
  )
}

我们在第 14 行添加了一个新的状态 text,在第 19 行添加了一个 TextInput 组件,当 text 发生变化时,我们在第 8 行注册的副作用也会执行,这可能不是我们想要的,有没有办法只有当 count 发生变化时,才触发这个副作用呢?

答案是有的。

useEffect(() => {
  garden.setTitleItem({
    title: `You clicked ${count} times`,
  })
}, [count, garden])

我们给 useEffect 传递第二个参数,是个数组,表示依赖列表。

count 的变化是这个副作用执行的唯一原因

garden 也出现在依赖列表里面,是为了通过 eslint-plugin-react-hooksopen in new window 的检查,由于 garden 本身是不变的,不影响 count 作为唯一原因。

否则 garden 不应该出现在依赖列表里面,尽管副作用使用了 garden。如何在副作用中使用那些可变的但又不是该副作用执行原因的变量呢?答案是合理使用 Ref Hook

应该总是指定依赖列表,哪怕是一个空数组

有时,我们需要清理副作用,这时,我们只需要在副作用函数中返回一个 clean up 函数即可

const [isOnline, setIsOnline] = useState(null)

useEffect(() => {
  function handleStatusChange(status) {
    setIsOnline(status.isOnline)
  }

  ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange)
  return () => {
    ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange)
  }
})

clean up 函数会在组件卸载时,或下一次副作用函数执行之前执行。

副作用应当遵循单一职责原则,一个 useEffect 只执行一个副作用。useEffect 和 useState 一样,都是可以有多个的。

Hook 规则

到目前为止,我们已经了解了 State 和 Effect 这两个最常见的 Hook。

受 Hook 的底层实现影响,在使用 Hook 时,需要保证 Hook 的调用顺序,因此需要遵若干规则open in new window

  1. 总是在 React 函数组件的顶层调用 React Hook,不要在循环语句,条件语句,以及内部函数中调用 React Hook。

  2. 总是在 React 函数组件或自定义 Hook 中调用 Hook,不要在普通函数中调用 Hook。

Facebook 开发了 eslint-plugin-react-hooksopen in new window EsLint 插件来帮助我们遵守以上规则。通过 react-native-create-appopen in new window 这个脚手架创建的 React Native 应用,已经帮我们集成了这个插件。

自定义 Hook 必须以 use 作为前缀,这是一种约定,就像高阶函数或高阶组件以 with 作为前缀一样。

自定义 Hook

在日常开发中,我们经常自定义 Hook,遵循单一职责原则,将不同业务隔离到不同的自定义 Hook 中。方便维护和复用。

function App({ garden }: InjectedProps) {
  const [count, setCount] = useState(0)

  function handleButtonPress() {
    setCount((c) => c + 1)
  }

  useEffect(() => {
    garden.setTitleItem({
      title: `You clicked ${count} times`,
    })
  }, [count, garden])

  const [text, setText] = useState("")

  return (
    <View style={styles.container}>
      <Text style={styles.text}>You clicked {count} times</Text>
      <TextInput value={text} onChangeText={setText} style={styles.input} />
      <Button title="确定" onPress={handleButtonPress} />
    </View>
  )
}

我们可以自定义一个 Hook,将 count 的相关数据和行为封装在里面

// useCount.ts
function useCount(garden: Garden) {
  const [count, setCount] = useState(0)

  function increase() {
    setCount((c) => c + 1)
  }

  function decrease() {
    setCount((c) => c - 1)
  }

  useEffect(() => {
    garden.setTitleItem({
      title: `You clicked ${count} times`,
    })
  }, [count, garden])

  return { count, increase, decrease }
}

// App.tsx
function App({ garden }: InjectedProps) {
  const { count, increase } = useCount(garden)
  const [text, setText] = useState("")

  return (
    <View style={styles.container}>
      <Text style={styles.text}>You clicked {count} times</Text>
      <TextInput value={text} onChangeText={setText} style={styles.input} />
      <Button title="确定" onPress={increase} />
    </View>
  )
}

App 组件是不是清晰了许多?它不需要关心 count 是如何变化的,有哪些副作用,只需要专注渲染即可。

组件负责渲染,React Hook 负责行为和数据

Ref Hook

我们使用 useRef 来创建 Ref Hook。Ref Hook 有两个主要作用。

其一,跨越组件整个生命周期,贯穿过去和未来。

我们稍微修改下闭包陷阱中的例子,解决遗留的问题:

function App() {
  const [name, setName] = useState("Sara")

  const nameRef = useRef(name)
  nameRef.current = name

  function handleButtonPress() {
    setName("Lisa")
    setTimeout(() => Alert.alert("提示", `name is ${nameRef.current}`), 0)
  }

  return (
    <View style={styles.container}>
      <Welcome name={name} />
      <Button title="确定" onPress={handleButtonPress} />
    </View>
  )
}

我们创建了一个名为 nameRef 的 Ref Hook 对象,该对象的 current 属性总是指向最新的 name 值。

// 在 App 组件的整个生命周期中,nameRef 总是指向同一个对象
// useRef 第一次调用时,name 作为 nameRef 对象的 current 属性的初始值
const nameRef = useRef(name)
// 每次 App 渲染时,都将当前的 name 值保存到 nameRef 对象的 current 属性中
nameRef.current = name

当 alert 时,通过 nameRef.current 读取状态 name 最新的值

Alert.alert("提示", `name is ${nameRef.current}`)

这样 Welcome 渲染的 name 和 alert 的 name 就是同一个了。

其二,获得子组件的引用,直接调用其方法

function App() {
  const [name, setName] = useState("Sara")
  const [text, setText] = useState("")
  return (
    <View style={styles.container}>
      <Welcome name={name} />
      <TextInput value={text} onChangeText={setText} style={styles.input} />
      <Button title="确定" onPress={() => setName(text)} />
    </View>
  )
}

假设有这么一个需求,当点击 Button 时,如果用户没有更改默认的 name,就让 TextInput 获得焦点,弹出键盘,该如何呢?

function App() {
  const [name, setName] = useState("Sara")
  const [text, setText] = useState("")
  const inputRef = useRef<TextInput>(null)

  const handleButtonPress = () => {
    if (text.length === 0) {
      inputRef.current?.blur()
      inputRef.current?.focus()
    } else {
      setName(text)
    }
  }

  return (
    <View style={styles.container}>
      <Welcome name={name} />
      <TextInput ref={inputRef} value={text} onChangeText={setText} style={styles.input} />
      <Button title="确定" onPress={handleButtonPress} />
    </View>
  )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

第 4 行,我们创建了一个名为 inputRef 的 Ref Hook 对象,它的 current 属性的类型是 TextInput | null,它的 current 属性的初始值是 null

const inputRef = useRef<TextInput>(null)

第 17 行,我们将 inputRef 赋值给 TextInput 的 ref 属性。

<TextInput ref={inputRef} value={text} onChangeText={setText} />

第 8、9 行,当点击 Button 时,发现输入框中没有值,就依次调用 TextInput 组件的 blurfocus 方法,确保唤起键盘。

inputRef.current?.blur() // 使失去焦点
inputRef.current?.focus() // 使获得焦点

Callback Hook

const memoizedCallback = useCallback(() => {
  doSomething(a, b)
}, [a, b])

useCallback 接受两个参数,一个是要记住的函数,一个是依赖列表,返回一个被记住的函数。当组件重新渲染时,如果依赖列表没有变化,那么返回的被记住的函数和上次是同一个,否则就返回一个新的被记住的函数。

当这个被记住的函数作为属性传递给子组件时,就很有用。可以避免子组件重新渲染。

function App() {
  const [count, setCount] = useState(0)

  const handleButtonPress = useCallback(() => {
    setCount((c) => c + 1)
  }, [])

  return (
    <View style={styles.container}>
      <Text style={styles.text}>You clicked {count} times</Text>
      <Button title="确定" onPress={handleButtonPress} />
    </View>
  )
}

因为依赖列表是空数组,无论 App 被渲染多少次, handleButtonPress 总是指向同一个函数。由于传递给 Button 的属性总是不变的,当 App 重新渲染时,则不会导致 Button 的重新渲染。

现在有这么一个虚构的需求,每当输入框中的文字发生变化时,或者点击确定按钮时,alert 输入框的当前值,同时记录 alert 的次数。下面这个代码是能满足需求的。

function App() {
  const [text, setText] = useState("Sara")
  const [count, setCount] = useState(0)

  const alertText = useCallback(() => {
    Alert.alert("提示", `Current text is ${text}`)
    setCount((c) => c + 1)
  }, [text])

  useEffect(() => {
    alertText()
  }, [alertText])

  return (
    <View style={styles.container}>
      <Text style={styles.text}>You alert {count} times</Text>
      <TextInput value={text} onChangeText={setText} style={styles.input} />
      <Button title="确定" onPress={alertText} />
    </View>
  )
}

但是被记住的函数 alertText 不仅仅是依赖项,还是要执行的副作用本身,这就很不善啰。

useEffect(() => {
  alertText()
}, [alertText]) // alertText 既是副作用的原因,也是副作用本身

我们无法通过 useEffect 的依赖列表,知晓副作用的原因,既不方便阅读,也不好维护。

最佳实践:分离副作用的原因和行为

下面是修改过后的代码:

function App() {
  const [text, setText] = useState("Sara")
  const [count, setCount] = useState(0)

  const alertText = useCallback((text: string) => {
    Alert.alert("提示", `text is ${text}`)
    setCount((c) => c + 1)
  }, [])

  useEffect(() => {
    alertText(text)
  }, [text, alertText])

  const handleButtonPress = useCallback(() => {
    alertText(text)
  }, [text, alertText])

  return (
    <View style={styles.container}>
      <Text style={styles.text}>You alert {count} times</Text>
      <TextInput value={text} onChangeText={setText} style={styles.input} />
      <Button title="确定" onPress={handleButtonPress} />
    </View>
  )
}

alertText 现在是不变的,意味着它不再是副作用的原因。

const alertText = useCallback((text: string) => {
  Alert.alert("提示", `text is ${text}`)
  setCount((c) => c + 1)
}, []) // 依赖列表为空,text 通过参数传递

副作用的原因是 textalertText 出现在依赖列表中,单纯是为了通过 EsLint 的检查

useEffect(() => {
  alertText(text)
}, [text, alertText])

通过定义带参数的被记住函数,分离副作用的原因和行为,可以面对非常复杂的情况。

Memo Hook

useCallback(fn, deps) 等同于 useMemo(() => fn, deps)

可以使用 useMemo 记住那些高开销的计算结果,避免每次渲染时重新计算,仅在依赖列表发生变化时才重新计算

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b])

useMemo 也允许我们跳过一次子节点的昂贵的重新渲染:

function Parent({ a, b }) {
  // Only re-rendered if `a` changes:
  const child1 = useMemo(() => <Child1 a={a} />, [a])
  // Only re-rendered if `b` changes:
  const child2 = useMemo(() => <Child2 b={b} />, [b])
  return (
    <>
      {child1}
      {child2}
    </>
  )
}

我们曾在认识组件和元素一节中提及,组件返回元素。child1child2 都是元素,都是渲染的最终产物,当 Parent 重新渲染时,如果依赖项不变,那么 Child1 或 Child2 就不会被重新渲染,而是复用之前的渲染结果。

useMemo 用于性能优化,但如果不清楚需不需要做性能优化,那么就不要做。

useReducer

const [state, dispatch] = useReducer(reducer, initialArg, init)

我们很少使用这个 Hook,如果不知道需不需要使用,那么就不要使用。

在某些场景下,useReducer 会比 useState 更适用,例如 state 逻辑较复杂且包含多个子值,或者下一个 state 依赖于之前的 state 等。这里有一个例子open in new window

学习参考资料:

Reactopen in new window

React Nativeopen in new window

React Native 原理与实践open in new window

编写有弹性的组件open in new window

在 React Hooks 中如何请求数据?open in new window

useEffect 完整指南open in new window

上次更新: 6/30/2022, 6:52:25 AM