开发 React Native 需要知道的 React
React Nativeopen in new window 基于 Reactopen in new window,我们可以通过 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
如图,点击 "+" 号,打开另一个终端
启动 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>
就是一个元素。
这种把组件包裹在一对尖括号的语法称为 JSXopen in new window,它是一种语法糖。
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> ) }
name
是 Welcome
组件的属性,我们在 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 Hookopen in new window,它表示组件的状态。
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'
,是不是很神奇?
想一想,
name
和setName
前面的修饰符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> ) }
在我们的例子中,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))
- 如果一个状态可以由另外一个或几个状态计算得出,那么不要使用
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-navigationopen in new window 导航组件提供的不变对象,用于动态更改页面样式。
如果组件中不止一个状态:
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-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(() => { if (appState !== 'active') { return } const cleanup = doSomething() return () => cleanup() }, [])
一些注意事项:
- 依赖列表应实质是副作用触发的原因,应合理使用
useRef
来规避把useEffect
使用到的变量都放到依赖列表中。 - 应当总是指定副作用的原因,哪怕是一个空数组。
- 应当遵循单一职责原则,一个
useEffect
只对一个行为者负责,只执行一个副作用。 - 应当分离副作用的原因和行为,后面的 Callback Hook 有具体例子。
Hook 规则
到目前为止,我们已经了解了 useState
和 useEffect
这两个最常见的 Hook。
受 Hook 的底层实现影响,在使用 Hook 时,需要保证 Hook 的调用顺序,因此需要遵若干规则open in new window。
- 总是在 React 函数组件的顶层调用 React Hook,不要在循环语句,条件语句,以及内部函数中调用 React Hook。
- 总是在 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 就是同一个了。
连接 React 组件和 JavaScript
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()
连接面向对象和函数式编程
复杂的业务,可能需要借助面向对象的思想来解决。
作者在如何在 React Native 中实现聊天应用那样的键盘交互open in new window一文演示过这种技巧。
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} </> ) }
我们曾在认识组件和元素一节中提及,组件返回元素。child1
和 child2
都是元素,都是渲染的最终产物,当 Parent 重新渲染时,如果依赖项不变,那么 Child1 或 Child2 就不会被重新渲染,而是复用之前的渲染结果。
一些注意事项:
-
useMemo
用于性能优化,但如果不清楚需不需要做性能优化,那么就不要做。
Context Hook
React.Context
用于跨组件传递数据,无论它们在组件树中的深度如何。
useContext
简化了 React.Context 的使用,作者曾在React Native 可复用 UI 小技巧:分离布局组件和状态组件open in new window 一文中演示过 useContext
的用法。
Reducer Hook
我们很少使用 useReducer
,如果不知道需不需要使用,那么就不要使用。
const [state, dispatch] = useReducer(reducer, initialArg, init)
在某些场景下,useReducer
会比 useState
更适用,例如 state 逻辑较复杂且包含多个子值,或者当前的 state 依赖于上一个 state 等。这里有一个例子open in new window。