React Native 入门:安装、测试与发布移动应用

React Native 入门:安装、测试与发布移动应用

用 React Native,你可以用 JavaScript 和 React 开发 Android 和 iOS 应用。组件只需编写一次,同一份代码就能在两个平台上以真正的原生视图运行。本文用 Expo 创建项目,讲解六个小示例,用 Jest 逐一测试,最后说明如何导出和发布应用。文中的每个代码文件都是经过测试的真实文件,测试结果也是真实输出。

需要准备的东西

需要 Node.js(本文使用 Node 24.19.0 测试)、npm、代码编辑器。运行步骤需要一部安装了 Expo Go 应用的手机,或 Android 模拟器。测试在电脑上运行,不需要手机。发布版本构建需要一个免费的 Expo 账号(用于 EAS Build)。要上架 Google Play,需要 Google Play 开发者账号(一次性费用 25 美元);要上架 App Store,需要 Apple Developer 账号(按年收费)。

示意图

React Native 的运行方式:Metro 打包 JavaScript,Hermes 在手机上执行,并绘制原生界面
React Native 的运行方式:Metro 打包 JavaScript,Hermes 在手机上执行,并绘制原生界面

第 1 步:安装并创建项目

从空白模板创建 Expo 项目,然后安装测试和 Web 所需的包。npx expo install 会选择与你的 Expo SDK 匹配的版本,所以对 Expo 支持的包都应使用它。运行 npx expo start 启动应用,用 Expo Go 扫描二维码,或按 w 在浏览器中打开。

npx create-expo-app@latest app --template blank
cd app
npx expo install jest-expo jest react-dom react-native-web @expo/metro-runtime
npm install -D @testing-library/react-native@14 test-renderer@1
npx expo start

安装: `package.json`

"jest": {
  "preset": "jest-expo"
},
"scripts": {
  "test": "jest"
}

示例 1: Hello:组件与 StyleSheet

组件是返回要显示内容的函数。View 和 Text 分别代替 div 和 p,StyleSheet.create 用来组织样式对象。

import { StyleSheet, Text, View } from 'react-native';

// A component is a function that returns what to show on screen.
export default function Hello() {
  return (
    <View style={styles.box}>
      <Text style={styles.title}>Hello, React Native</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  box: { padding: 16, backgroundColor: '#e8f1ff', borderRadius: 8 },
  title: { fontSize: 20, fontWeight: 'bold', color: '#1b3a6b' },
});

真实测试结果

✓ 1. Hello › shows the heading
组件树:App 渲染六个区块,每个区块包住一个示例组件
组件树:App 渲染六个区块,每个区块包住一个示例组件

示例 2: Props:向组件传递数据

App 通过 props 把 name 传给 Greeting。同一个组件无需修改代码就能显示不同的名字。

import { Text } from 'react-native';

// Props are the data a parent passes in. The same component can show different names.
export default function Greeting({ name }) {
  return <Text style={{ fontSize: 18 }}>{`Hello, ${name}`}</Text>;
}

真实测试结果

✓ 2. Props › uses the name prop

示例 3: State:用 Pressable 做计数器

useState 保存计数值,Pressable 是可以点击的元素。写成 setCount(c => c + 1) 的形式,总是使用最新的值。

import { useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';

// State is a value that changes. Calling the setter makes React render again.
export default function Counter() {
  const [count, setCount] = useState(0);

  return (
    <View style={styles.row}>
      <Pressable
        style={styles.button}
        accessibilityRole="button"
        accessibilityLabel="decrease"
        onPress={() => setCount(c => c - 1)}>
        <Text style={styles.buttonText}>-</Text>
      </Pressable>
      <Text testID="count" style={styles.count}>{count}</Text>
      <Pressable
        style={styles.button}
        accessibilityRole="button"
        accessibilityLabel="increase"
        onPress={() => setCount(c => c + 1)}>
        <Text style={styles.buttonText}>+</Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  row: { flexDirection: 'row', alignItems: 'center', gap: 16 },
  button: { width: 48, height: 48, borderRadius: 24, backgroundColor: '#2f6fed', alignItems: 'center', justifyContent: 'center' },
  buttonText: { color: '#fff', fontSize: 24, fontWeight: 'bold' },
  count: { fontSize: 28, minWidth: 48, textAlign: 'center' },
});

真实测试结果

✓ 3. State › increments and decrements the count

示例 4: 受控的 TextInput

输入框的值保存在 state 中。onChangeText 在每次按键时更新它,预览文字也由这个 state 计算得出。

import { useState } from 'react';
import { StyleSheet, Text, TextInput, View } from 'react-native';

// TextInput is controlled: its value comes from state and onChangeText updates it on each keystroke.
export default function NameForm() {
  const [name, setName] = useState('');
  const trimmed = name.trim();

  return (
    <View style={styles.box}>
      <TextInput
        testID="name-input"
        style={styles.input}
        placeholder="Your name"
        value={name}
        onChangeText={setName}
      />
      <Text testID="preview" style={styles.preview}>
        {trimmed ? `Hi, ${trimmed}!` : 'Type your name above'}
      </Text>
    </View>
  );
}

const styles = StyleSheet.create({
  box: { gap: 8 },
  input: { borderWidth: 1, borderColor: '#9aa6c2', borderRadius: 8, padding: 10, fontSize: 16 },
  preview: { fontSize: 16, color: '#333' },
});

真实测试结果

✓ 4. Controlled input › shows a greeting from the typed name

示例 5: 可添加的列表

用展开语法(spread)创建新数组来添加条目,而不是修改原数组。短列表用 map 即可;长列表应使用带 keyExtractor 的 FlatList。

import { useState } from 'react';
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';

// Add an item by creating a new array with spread instead of changing the old one.
export default function TodoList() {
  const [text, setText] = useState('');
  const [items, setItems] = useState([{ id: '1', title: 'Learn components' }]);

  function addItem() {
    const title = text.trim();
    if (!title) return;
    setItems([...items, { id: `${Date.now()}-${items.length}`, title }]);
    setText('');
  }

  return (
    <View style={styles.box}>
      <View style={styles.row}>
        <TextInput
          testID="todo-input"
          style={styles.input}
          placeholder="New task"
          value={text}
          onChangeText={setText}
        />
        <Pressable testID="add" style={styles.button} accessibilityRole="button" onPress={addItem}>
          <Text style={styles.buttonText}>Add</Text>
        </Pressable>
      </View>
      {items.map(item => (
        <Text key={item.id} testID="todo-item" style={styles.item}>{item.title}</Text>
      ))}
    </View>
  );
}

const styles = StyleSheet.create({
  box: { gap: 8 },
  row: { flexDirection: 'row', gap: 8 },
  input: { flex: 1, borderWidth: 1, borderColor: '#9aa6c2', borderRadius: 8, padding: 10, fontSize: 16 },
  button: { backgroundColor: '#2f6fed', borderRadius: 8, paddingHorizontal: 16, justifyContent: 'center' },
  buttonText: { color: '#fff', fontWeight: 'bold' },
  item: { fontSize: 16, paddingVertical: 6, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#ccc' },
});

真实测试结果

✓ 5. List with add › adds a task and clears the input
✓ 5. List with add › ignores an empty task

示例 6: 用 useEffect 和 fetch 加载数据

useEffect 在第一次渲染之后运行。请求等待期间显示 ActivityIndicator,然后显示用户列表。清理函数会忽略组件被移除后才到达的响应。

import { useEffect, useState } from 'react';
import { ActivityIndicator, Text, View } from 'react-native';

export const USERS_URL = 'https://jsonplaceholder.typicode.com/users';

// useEffect runs after the first render. Here it loads data from a web API.
// The cleanup function ignores a response that arrives after the component is gone.
export default function Users({ url = USERS_URL }) {
  const [users, setUsers] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    let active = true;
    fetch(url)
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then(data => { if (active) setUsers(data); })
      .catch(err => { if (active) setError(err.message); });
    return () => { active = false; };
  }, [url]);

  if (error) return <Text testID="error">{`Error: ${error}`}</Text>;
  if (!users) return <ActivityIndicator testID="loading" />;

  return (
    <View testID="users" style={{ gap: 6 }}>
      {users.map(u => <Text key={u.id}>{u.name}</Text>)}
    </View>
  );
}

真实测试结果

✓ 6. Load data › shows a loading indicator while the request is pending
✓ 6. Load data › shows the users after the request finishes
✓ 6. Load data › shows an error when the response is not ok

在 App.js 中组合所有示例

import { StatusBar } from 'expo-status-bar';
import { ScrollView, StyleSheet, Text, View } from 'react-native';

import Hello from './src/examples/Hello';
import Greeting from './src/examples/Greeting';
import Counter from './src/examples/Counter';
import NameForm from './src/examples/NameForm';
import TodoList from './src/examples/TodoList';
import Users from './src/examples/Users';

// The root component combines smaller components, just like the examples in the article.
export default function App() {
  return (
    <ScrollView contentContainerStyle={styles.container}>
      <StatusBar style="auto" />
      <Text style={styles.heading}>React Native Examples</Text>

      <Section title="1. Hello"><Hello /></Section>
      <Section title="2. Props"><Greeting name="Somchai" /></Section>
      <Section title="3. State"><Counter /></Section>
      <Section title="4. Controlled input"><NameForm /></Section>
      <Section title="5. List with add"><TodoList /></Section>
      <Section title="6. Load data"><Users /></Section>
    </ScrollView>
  );
}

function Section({ title, children }) {
  return (
    <View style={styles.section}>
      <Text style={styles.sectionTitle}>{title}</Text>
      {children}
    </View>
  );
}

const styles = StyleSheet.create({
  container: { padding: 20, paddingTop: 60, gap: 24 },
  heading: { fontSize: 26, fontWeight: 'bold' },
  section: { gap: 10 },
  sectionTitle: { fontSize: 14, fontWeight: '600', color: '#2f6fed', textTransform: 'uppercase' },
});

第 2 步:用 Jest 测试每个示例

测试运行器是 Jest,并使用 jest-expo 预设,让 React Native 能在 Node 中运行。@testing-library/react-native 负责渲染组件,并像用户一样操作它:查找文字或标签、按下按钮、修改文字。在 14 版本中,render 和 fireEvent 都是异步的,因此每次调用都要写 await。Users 的测试把 global.fetch 替换成模拟函数,所以测试期间不会发出真实的网络请求。运行 npm test 即可执行全部测试。

npm test

测试文件

import { fireEvent, render, screen } from '@testing-library/react-native';

import Hello from '../src/examples/Hello';
import Greeting from '../src/examples/Greeting';
import Counter from '../src/examples/Counter';
import NameForm from '../src/examples/NameForm';
import TodoList from '../src/examples/TodoList';
import Users from '../src/examples/Users';

describe('1. Hello', () => {
  it('shows the heading', async () => {
    await render(<Hello />);
    expect(screen.getByText('Hello, React Native')).toBeTruthy();
  });
});

describe('2. Props', () => {
  it('uses the name prop', async () => {
    await render(<Greeting name="Somchai" />);
    expect(screen.getByText('Hello, Somchai')).toBeTruthy();
  });
});

describe('3. State', () => {
  it('increments and decrements the count', async () => {
    await render(<Counter />);
    expect(screen.getByTestId('count')).toHaveTextContent('0');
    await fireEvent.press(screen.getByLabelText('increase'));
    await fireEvent.press(screen.getByLabelText('increase'));
    expect(screen.getByTestId('count')).toHaveTextContent('2');
    await fireEvent.press(screen.getByLabelText('decrease'));
    expect(screen.getByTestId('count')).toHaveTextContent('1');
  });
});

describe('4. Controlled input', () => {
  it('shows a greeting from the typed name', async () => {
    await render(<NameForm />);
    expect(screen.getByTestId('preview')).toHaveTextContent('Type your name above');
    await fireEvent.changeText(screen.getByTestId('name-input'), '  Somchai ');
    expect(screen.getByTestId('preview')).toHaveTextContent('Hi, Somchai!');
  });
});

describe('5. List with add', () => {
  it('adds a task and clears the input', async () => {
    await render(<TodoList />);
    await fireEvent.changeText(screen.getByTestId('todo-input'), 'Buy milk');
    await fireEvent.press(screen.getByTestId('add'));
    expect(screen.getByText('Buy milk')).toBeTruthy();
    expect(screen.getByTestId('todo-input')).toHaveProp('value', '');
  });

  it('ignores an empty task', async () => {
    await render(<TodoList />);
    await fireEvent.press(screen.getByTestId('add'));
    expect(screen.getAllByTestId('todo-item')).toHaveLength(1);
  });
});

describe('6. Load data', () => {
  afterEach(() => {
    delete global.fetch;
  });

  it('shows a loading indicator while the request is pending', async () => {
    global.fetch = jest.fn(() => new Promise(() => {}));
    await render(<Users url="https://test.local/users" />);
    expect(screen.getByTestId('loading')).toBeTruthy();
  });

  it('shows the users after the request finishes', async () => {
    global.fetch = jest.fn(() =>
      Promise.resolve({ ok: true, json: () => Promise.resolve([{ id: 1, name: 'Leanne Graham' }]) }),
    );
    await render(<Users url="https://test.local/users" />);
    expect(await screen.findByText('Leanne Graham')).toBeTruthy();
    expect(global.fetch).toHaveBeenCalledWith('https://test.local/users');
  });

  it('shows an error when the response is not ok', async () => {
    global.fetch = jest.fn(() => Promise.resolve({ ok: false, status: 500 }));
    await render(<Users url="https://test.local/broken" />);
    expect(await screen.findByTestId('error')).toHaveTextContent('Error: HTTP 500');
  });
});

真实输出

Test Suites: 1 passed, 1 total
Tests:       9 passed, 9 total
Snapshots:   0 total
Time:        1.934 s, estimated 3 s
Ran all test suites.

第 3 步:导出与发布

npx expo export --platform web 会把静态网站输出到 dist 文件夹。你可以把这个文件夹部署到 Cloudflare Pages、Netlify 或其他静态托管服务。npx expo export --platform android 会输出原生应用加载的 JavaScript 包,并显示其大小。应用商店发布使用 EAS Build,它在 Expo 的云端构建 APK 或 AAB,然后提交上架。EAS 命令需要 Expo 账号和应用商店账号,因此本文没有运行这些命令。两个导出命令已经运行,并展示了真实输出。

npx expo export --platform web
npx expo export --platform android
npx eas-cli@latest login
npx eas-cli@latest build --platform android --profile preview
npx eas-cli@latest submit --platform android
从安装到上架的步骤。第 1 到 3 步在电脑上完成,第 4 到 6 步是发布流程
从安装到上架的步骤。第 1 到 3 步在电脑上完成,第 4 到 6 步是发布流程

真实输出 (export --platform web)

Starting Metro Bundler

Web Bundled 324ms index.js (155 modules)

› web bundles (1):
_expo/static/js/web/index-2cd770f3379787ce05344930a3b77994.js (411KB)

› Files (3):
favicon.ico (15KB)
index.html (1.2KB)
metadata.json (49B)

Exported: dist

真实输出 (export --platform android)

Starting Metro Bundler

Android Bundled 4251ms index.js (622 modules)

› android bundles (1):
_expo/static/js/android/index-2f8b5ff9ff67c29c92fd35ff3cb9dfec.hbc (1.6MB)

› Files (1):
metadata.json (150B)

Exported: dist

下一步

下一步,可以用 Expo Router 添加导航,它每个页面对应一个文件。用 AsyncStorage 或 expo-sqlite 保存数据。发布前,准备应用名称、图标、隐私政策和商店截图,并在真机上测试,因为模拟器无法显示所有情况。iOS 需要 Apple Developer 账号,但使用 EAS Build 可以不需要 Mac 也能完成构建。

总结

React Native 应用就是一棵绘制原生视图的组件树。Expo 负责准备项目,Jest 检查每个示例,导出或 EAS Build 把项目变成网站或发布文件。从小组件开始,逐个测试,应用可用后再发布。