开发 React Native 需要知道的 React

563 阅读11分钟

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

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

前置条件:

创建一个 React Native 应用

打开一个终端,创建一个 React Native 项目

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

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

cd RnDemo && code .

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

` 在键盘的左上方

启动 Package Server

npm start

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

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

启动 ios 或 android 应用

# npm run ios
npm run android

熟悉原生开发的同学,也可以通过 Xcode 或 Android Studio 来启动应用

组件

组件负责渲染

认识组件和元素

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

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

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

什么是元素呢?

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

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

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

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

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

可以自己试试看

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

一些注意事项:

  • 组件函数名以大写字母开头

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

认识 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 'hybrid-navigation'

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 Hook,它表示组件的状态。

import React, { useState } from 'react'

第 14 行,通过调用 useState,并传入初始值 'Sara',我们得到一个元组 [name, setName]

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

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

name 是一个字符串,它的值是 'Sara'setName 是一个函数,调用后将改变 name 的值,譬如调用 setName('Cahal')name 的值将变成 'Cahal',是不是很神奇?

想一想,namesetName 前面的修饰符 const 代表什么?

第 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,然后用户看到的输入就是一串星星。

可以看到,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>
  )
}

在我们的例子中,setNamesetText 每次被调用,都会触发重新渲染。

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

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

会生成新的 nametext 变量,它们的值就是最后一次调用 setNamesetText 时传递的值,或首次调用 useState 时传递的值。

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

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

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

闭包陷阱

在正式讲解 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))
  • 如果一个状态可以由另外一个或几个状态计算得出,那么不要使用 useState 为这个状态创建一个新的状态变量。

Effect Hook

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

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

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

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 是 hybrid-navigation 导航组件提供的不变对象,用于动态更改页面样式。

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

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 传递第二个参数,是个数组,按官方的说法,表示依赖列表。但是最好把它看作是副作用的原因。

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

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

garden 也出现在依赖列表里面,纯粹是为了通过 eslint-plugin-react-hooks 的检查,由于 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(() => {
  if (appState !== 'active') {
    return
  }

  const cleanup = doSomething()

  return () => cleanup()
}, [])

一些注意事项:

  • 依赖列表应实质是副作用触发的原因,应合理使用 useRef 来规避把 useEffect 使用到的变量都放到依赖列表中。

  • 应当总是指定副作用的原因,哪怕是一个空数组。

  • 应当遵循单一职责原则,一个 useEffect 只对一个行为者负责,只执行一个副作用。

  • 应当分离副作用的原因和行为,后面的 Callback Hook 有具体例子。

Hook 规则

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

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

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

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

Facebook 开发了 eslint-plugin-react-hooks EsLint 插件来帮助我们遵守以上规则。通过 react-native-create-app 这个脚手架创建的 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 时,就让 TextInput 获得焦点,弹出键盘,该如何呢?

function App() {
  const inputRef = useRef<TextInput>(null)

  const handleButtonPress = () => {
    inputRef.current?.focus()
  }

  return (
    <View style={styles.container}>
      <TextInput
        ref={inputRef}
        style={styles.input} />
      <Button title="确定" onPress={handleButtonPress} />
    </View>
  )
}

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

const inputRef = useRef<TextInput>(null)

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

<TextInput ref={inputRef} />

第 8 行,当点击 Button 时,调用 TextInput 组件的 focus 方法,唤起键盘。

inputRef.current?.focus()

Callback Hook

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

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

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

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]) // 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)
  }, []) // 依赖列表为空,text 通过参数传递

  useEffect(() => {
    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={() => alertText(text)} />
    </View>
  )
}

现在,我们明确了副作用的原因是 text

alertText 现在是不变的,意味着它不再是副作用的原因,它之所以出现在依赖列表中,单纯是为了通过 ESLint 的检查。

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

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 用于性能优化,但如果不清楚需不需要做性能优化,那么就不要做。

Context Hook

React.Context 用于跨组件传递数据,无论它们在组件树中的深度如何。

useContext 简化了 React.Context 的使用,作者曾在React Native 可复用 UI 小技巧:分离布局组件和状态组件 一文中演示过 useContext 的用法。

Reducer Hook

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

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

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

参考