React 转 Vue 无缝迁移:跨框架的桥梁探索


目录

  1. 前言
  2. 核心理念对比

    • 2.1 响应式机制
    • 2.2 渲染方式:JSX vs 模板
    • 2.3 组件注册与组织
    • 2.4 生命周期钩子
  3. 概念映射图解

    • 3.1 响应式数据流图
    • 3.2 组件生命周期对比图
  4. 实战示例:Todo List 组件迁移

    • 4.1 React 版 Todo List(初始化)
    • 4.2 Vue 版 Todo List(迁移成果)
    • 4.3 迁移步骤详解

      • 4.3.1 将 JSX 转成 Vue 模板
      • 4.3.2 状态管理:useState → ref/reactive
      • 4.3.3 事件绑定:onClick → @click
      • 4.3.4 Props 与事件传递
      • 4.3.5 生命周期钩子替换
  5. 高级迁移策略

    • 5.1 Hooks 模式到 Composition API
    • 5.2 Redux / Context 到 Vuex / Pinia
    • 5.3 第三方库适配(路由、请求库等)
  6. 常见痛点与解决方案
  7. 总结

前言

在前端生态中,React 与 Vue 各自拥有广泛的社区和生态体系。有时项目需求会让我们不得不进行框架迁移:例如,团队技术栈从 React 迁向 Vue,或同时维护 React 与 Vue 多套代码。本文将帮助你快速搭建一座“跨框架的桥梁”,让你能无缝地把 React 组件、思路与代码迁移到 Vue,并且不失“优雅与高效”。

本文特色:

  1. 从核心理念对比到实战示例,一步步拆解。
  2. 配有 ASCII 图解,直观理解数据流与生命周期。
  3. 提供完整代码示例,手把手演示如何从 React 版搬到 Vue 版。
  4. 涵盖进阶迁移策略,如 Hooks → Composition API,Redux → Pinia 等。

如果你已经具备 React 基础,并对 Vue 有所接触(或零基础也没关系),本文会让你快速上手,将 React 思维映射到 Vue 生态中。下面,让我们从最基础的“核心理念对比”说起。


核心理念对比

迁移的前提是要搞清楚两个框架背后的核心设计思路与 API 约定,便于一一映射。

2.1 响应式机制

分类React (18+)Vue (3.x)
核心思想函数式更新 + 虚拟 DOM Diff
组件通过 useState 维护局部 state,当 state 改变时,React 会触发虚拟 DOM 重新渲染并进行 diff。
Proxy + 响应式追踪 + 虚拟 DOM Diff
使用 refreactive 创建响应式对象,访问或修改时触发依赖收集与更新。
数据更新方式纯函数式:setState(或 Hooks useState 返回的 setter)会将新状态传给渲染函数。Proxy 拦截:对 ref.valuereactive 对象直接赋值,Vue 自动跟踪依赖并重新渲染。
优势函数式更新带来的可预测性;Hooks 可组合性。原生 Proxy 性能更优且语法简洁;Composition API 逻辑复用灵活。

小结:

  • React 用 “函数式” 更新,Vue 用 “响应式引用/对象” 更新。
  • 迁移时,只需要把 useState 状态换成 Vue 的 ref / reactive,并把对 state 的读写改成 .value 或直接访问属性即可。

2.2 渲染方式:JSX vs 模板

  • React(JSX):在 JavaScript 里使用类似 XML 的语法,以 classNameonClick 等属性绑定。所有逻辑都写在 .jsx(或 .tsx)文件里。

    function Hello({ name }) {
      return (
        <div className="hello-container">
          <h1 onClick={() => alert(`你好,${name}!`)}>Hello, {name}!</h1>
        </div>
      );
    }
  • Vue(模板 + <script>.vue 文件分为 <template><script setup><style> 三个部分。模板语法更贴近 HTML,事件改成 @click,绑定指令用 v-bind 或简写 :

    <template>
      <div class="hello-container">
        <h1 @click="sayHello">Hello, {{ name }}!</h1>
      </div>
    </template>
    
    <script setup>
    import { defineProps } from 'vue';
    const props = defineProps({
      name: String
    });
    function sayHello() {
      alert(`你好,${props.name}!`);
    }
    </script>

小结:

  • JSX 中一切写在 JavaScript 表达式里,模板更贴近 HTML + 插值表达式。
  • 迁移时,需要把 JSX 里 {} 插值、三元表达式、事件绑定等映射到 Vue 模板语法:{{}}v-if/v-for@click:

2.3 组件注册与组织

  • React:默认所有组件都需要手动 import 并通过 export defaultexport 导出;父组件里直接 <Child someProp={value} />
  • Vue:有两种模式——全局注册(应用启动时 app.component('MyComp', MyComp))与局部注册(在组件内 components: { MyComp })。在 Vue 3 的 <script setup> 下,局部组件可以直接在 <template> 用到,前提在 <script setup> 已经 import MyComp from './MyComp.vue'

小结:

  • React 与 Vue 都需要 import/export。Vue <template> 下的 <component> 名字必须与 import 的变量对应或在 components 里注册。
  • 迁移时,只要把 React 的 import 语句放到 Vue 的 <script setup>,然后在 <template> 里使用即可。

2.4 生命周期钩子

React HooksVue Composition API说明
useEffect(() => { ... }, [])onMounted(() => { ... })组件挂载后的副作用
useEffect(() => { return () => {...} }, [])onUnmounted(() => { ... })组件卸载时清理
useEffect(() => { ... }, [dep1, dep2])watch([dep1, dep2], ([new1, new2], [old1, old2]) => { ... })监听依赖变化
无直接对比onUpdated(() => { ... })每次更新后回调(React 里没有直接等价,若需可放到 effect)

小结:

  • React 通过 useEffect 的依赖数组实现不同时机的副作用。
  • Vue 拆成多个钩子(onMountedonUnmountedonUpdated),或用 watch 监听具体响应式值。

概念映射图解

为了更直观感受两者在“数据流”和“生命周期”上的差异,下面用 ASCII 图示做简单对比。

3.1 响应式数据流图

【React 数据流】                         【Vue 数据流】
┌────────────┐       setState              ┌────────────┐
│  UI 渲染   │ <----------------------------│ useState   │
│ (function) │                              │   / useRef │
└──────┬─────┘漫游 diff 后更新 virtual DOM─>└──────┬─────┘
       │                                         │
       │ render()                                │ render() 成 template 编译
       │                                         │
┌──────┴─────┐                             ┌──────┴─────┐
│虚拟 DOM 1  │                             │ 响应式对象 ├─> 自动收集依赖 & 重新渲染
└──────┬─────┘                             └────────────┘
       │
       │ diff patch
       ↓
┌────────────┐
│ 真实 DOM   │
└────────────┘
  1. React:调用 setState → 触发组件重新渲染(render) → 产生新的虚拟 DOM(Virtual DOM 2)→ 与上一次进行 diff → 最终 Patch 到真实 DOM。
  2. Vue:更新 ref.value / reactive 后,触发响应式系统标记该依赖(Watcher),收集依赖后再次执行渲染函数编译模板,得到新的虚拟 DOM → diff → Patch。

3.2 组件生命周期对比图

       React 生命周期                         Vue 生命周期
┌─────────────────────┐              ┌────────────────────────┐
│   (Mounting 阶段)   │              │ (onBeforeMount → onMounted) │
│  - render()         │              │  - setup()                    │
│  - componentDidMount│              │  - onMounted                  │
└─────────┬───────────┘              └──────────┬─────────────────┘
          │ Update 阶段 (依赖变化)          │ Update 阶段 (响应式变化)
┌─────────┴───────────┐              ┌──────────┴─────────────────┐
│  render()           │              │  template 编译 → render()   │
│  componentDidUpdate │              │  onUpdated                  │
└─────────┬───────────┘              └──────────┬─────────────────┘
          │ Unmount 阶段                    │ Unmount 阶段
┌─────────┴───────────┐              ┌──────────┴─────────────────┐
│  componentWillUnmount│             │  onBeforeUnmount → onUnmounted │
└─────────────────────┘              └─────────────────────────────┘
  • React:componentDidMount → 每次 render → componentDidUpdate → 卸载时 componentWillUnmount。现代 Hooks 里用 useEffect 模拟。
  • Vue:setup 里初始化所有响应式,在挂载前可用 onBeforeMount、挂载后 onMounted;更新后 onUpdated;卸载前 onBeforeUnmount、卸载后 onUnmounted

实战示例:Todo List 组件迁移

接下来,通过一个典型的 Todo List 示例,演示从 React 到 Vue 的完整迁移步骤。在此之前,先准备一个功能简单、结构清晰的 React 版组件。

4.1 React 版 Todo List(初始化)

// 文件:src/components/TodoList.jsx
import React, { useState, useEffect } from 'react';

function TodoItem({ item, onDelete }) {
  return (
    <li style={{ display: 'flex', alignItems: 'center' }}>
      <span style={{ flex: 1 }}>{item.text}</span>
      <button onClick={() => onDelete(item.id)}>删除</button>
    </li>
  );
}

export default function TodoList() {
  // 1. 状态:todos 列表和 input 文本
  const [todos, setTodos] = useState([]);
  const [input, setInput] = useState('');

  // 2. 模拟从 localStorage 读取初始列表
  useEffect(() => {
    const stored = JSON.parse(localStorage.getItem('todos') || '[]');
    setTodos(stored);
  }, []);

  // 3. 更新 localStorage
  useEffect(() => {
    localStorage.setItem('todos', JSON.stringify(todos));
  }, [todos]);

  // 添加函数
  const addTodo = () => {
    if (!input.trim()) return;
    const newItem = { id: Date.now(), text: input.trim() };
    setTodos([...todos, newItem]);
    setInput('');
  };

  // 删除函数
  const deleteTodo = (id) => {
    setTodos(todos.filter((t) => t.id !== id));
  };

  return (
    <div style={{ width: '400px', margin: 'auto' }}>
      <h2>Todo List (React)</h2>
      <div>
        <input
          type="text"
          value={input}
          placeholder="输入待办事项"
          onChange={(e) => setInput(e.target.value)}
        />
        <button onClick={addTodo}>添加</button>
      </div>
      <ul>
        {todos.map((item) => (
          <TodoItem key={item.id} item={item} onDelete={deleteTodo} />
        ))}
      </ul>
    </div>
  );
}

4.1.1 功能说明

  1. TodoList 组件

    • todos:待办事项数组,每一项 { id, text }
    • input:输入框文字。
    • useEffect(无依赖)用于加载本地存储数据。
    • useEffect(依赖 [todos])用于 Todos 数组更新时,同步到本地存储。
    • addTodo、新建一条并更新数组。
    • deleteTodo、通过 id 过滤删除。
  2. TodoItem 子组件

    • 接收 itemonDelete 函数,渲染单个待办并绑定删除事件。

4.2 Vue 版 Todo List(迁移成果)

<!-- 文件:src/components/TodoList.vue -->
<template>
  <div class="container">
    <h2>Todo List (Vue)</h2>
    <div class="input-area">
      <input
        type="text"
        v-model="input"
        placeholder="输入待办事项"
        @keyup.enter="addTodo"
      />
      <button @click="addTodo">添加</button>
    </div>
    <ul>
      <TodoItem
        v-for="item in todos"
        :key="item.id"
        :item="item"
        @delete-item="deleteTodo"
      />
    </ul>
  </div>
</template>

<script setup>
import { ref, onMounted, watch } from 'vue';
import TodoItem from './TodoItem.vue';

const todos = ref([]);
const input = ref('');

// 1. 初始读取 localStorage
onMounted(() => {
  const stored = JSON.parse(localStorage.getItem('todos') || '[]');
  todos.value = stored;
});

// 2. 监控 todos 变化,同步到 localStorage
watch(
  todos,
  (newTodos) => {
    localStorage.setItem('todos', JSON.stringify(newTodos));
  },
  { deep: true }
);

// 添加函数
function addTodo() {
  if (!input.value.trim()) return;
  const newItem = { id: Date.now(), text: input.value.trim() };
  todos.value.push(newItem);
  input.value = '';
}

// 删除函数(通过事件触发)
function deleteTodo(id) {
  todos.value = todos.value.filter((t) => t.id !== id);
}
</script>

<style scoped>
.container {
  width: 400px;
  margin: auto;
}
.input-area {
  display: flex;
  gap: 8px;
  margin-bottom: 12px;
}
input {
  flex: 1;
  padding: 4px 8px;
}
button {
  padding: 4px 12px;
}
ul {
  padding-left: 0;
}
</style>
<!-- 文件:src/components/TodoItem.vue -->
<template>
  <li class="item">
    <span>{{ item.text }}</span>
    <button @click="$emit('delete-item', item.id)">删除</button>
  </li>
</template>

<script setup>
import { defineProps } from 'vue';

const props = defineProps({
  item: {
    type: Object,
    required: true
  }
});
</script>

<style scoped>
.item {
  display: flex;
  align-items: center;
  padding: 4px 0;
}
.item span {
  flex: 1;
}
button {
  padding: 2px 8px;
}
</style>

4.2.1 功能对比

  • Vue 用到的 API:refonMountedwatchv-modelv-for@click$emit
  • Vue 数据都挂在 ref.value,模板里直接写 todosinput(Vue 自动解包);
  • 事件改为 $emit('delete-item', item.id),父组件通过 @delete-item="deleteTodo" 接收。
  • v-model="input" 在回车时也绑定了 addTodo,提升用户体验。

4.3 迁移步骤详解

下面细化从 React 版到 Vue 版的每一步转换思路。

4.3.1 将 JSX 转成 Vue 模板

  • React JSX(片段)

    <div style={{ width: '400px', margin: 'auto' }}>
      <h2>Todo List (React)</h2>
      <div>
        <input
          type="text"
          value={input}
          placeholder="输入待办事项"
          onChange={(e) => setInput(e.target.value)}
        />
        <button onClick={addTodo}>添加</button>
      </div>
      <ul>
        {todos.map((item) => (
          <TodoItem key={item.id} item={item} onDelete={deleteTodo} />
        ))}
      </ul>
    </div>
  • Vue 模板(对应代码)

    <div class="container">
      <h2>Todo List (Vue)</h2>
      <div class="input-area">
        <input
          type="text"
          v-model="input"
          placeholder="输入待办事项"
          @keyup.enter="addTodo"
        />
        <button @click="addTodo">添加</button>
      </div>
      <ul>
        <TodoItem
          v-for="item in todos"
          :key="item.id"
          :item="item"
          @delete-item="deleteTodo"
        />
      </ul>
    </div>
  1. 最外层容器

    • React:<div style={{ width: '400px', margin: 'auto' }}>
    • Vue:利用 CSS(<style scoped>)把 .container 设置为同样宽度与居中。
  2. 输入框绑定

    • React:value={input} + onChange={(e) => setInput(e.target.value)}
    • Vue:v-model="input" 一行搞定双向绑定,并且扩展了对回车的监听(@keyup.enter="addTodo")。
  3. 事件绑定

    • React:onClick={addTodo}onChange={...}
    • Vue:统一用 @click="addTodo"@keyup.enter="addTodo"
  4. 循环渲染

    • React:{todos.map(item => <TodoItem key={item.id} ... />)}
    • Vue:<TodoItem v-for="item in todos" :key="item.id" ... />,并把传递 prop 改为 :item="item",事件回调从 onDelete={deleteTodo} 变成 $emit('delete-item', ...) + 父组件 @delete-item="deleteTodo"

4.3.2 状态管理:useState → ref/reactive

  • React 用法:

    const [todos, setTodos] = useState([]);
    const [input, setInput] = useState('');
  • Vue 对应:

    import { ref } from 'vue';
    
    const todos = ref([]);
    const input = ref('');

要点:

  • React todos 是普通数组,更新时需调用 setTodos(newArray)
  • Vue todos.value 是数组;如果用 .push().splice() 等操作,Vue 会拦截并自动触发视图更新。若要整个重置数组,可以直接 todos.value = [...]

4.3.3 事件绑定:onClick → @click

  • React:<button onClick={deleteTodo}>删除</button>
  • Vue:<button @click="deleteTodo(item.id)">删除</button>

要点:

  • React 的事件属性都是驼峰式,比如 onClickonChange;Vue 则是 @click@change,或者完整写成 v-on:clickv-on:change
  • 回调写法也要从 JSX 插值({})切换到模板表达式(""),并注意:在 Vue 模板里访问的是组件实例作用域下的函数或属性。

4.3.4 Props 与事件传递

  • React 里,父组件写:

    <TodoItem key={item.id} item={item} onDelete={deleteTodo} />

    子组件:

    function TodoItem({ item, onDelete }) {
      return (
        <li> 
          … 
          <button onClick={() => onDelete(item.id)}>删除</button>
        </li>
      );
    }
  • Vue 里,父组件写:

    <TodoItem
      v-for="item in todos"
      :key="item.id"
      :item="item"
      @delete-item="deleteTodo"
    />

    子组件:

    <template>
      <li class="item">
        <span>{{ item.text }}</span>
        <button @click="$emit('delete-item', item.id)">删除</button>
      </li>
    </template>
    
    <script setup>
    import { defineProps } from 'vue';
    const props = defineProps({
      item: { type: Object, required: true }
    });
    </script>

要点:

  1. Prop 传值

    • React:item={item};子组件通过函数参数拿取。
    • Vue::item="item";子组件通过 defineProps 解构 props 对象拿取。
  2. 事件回调

    • React:父组件把函数 deleteTodo 当做 prop onDelete 传给子,子组件里直接调用 onDelete(item.id)
    • Vue:子组件通过 $emit('delete-item', item.id) 派发事件,父组件通过 @delete-item="deleteTodo" 监听并执行。
  3. 命名规范

    • React 可以自由命名 prop,常用驼峰式:onDelete
    • Vue 提倡事件名用中划线分隔(kebab-case),模板里必须一致:@delete-item。组件内部若用 emits 验证,可书写 ['delete-item']

4.3.5 生命周期钩子替换

  • React:

    useEffect(() => {
      // 组件挂载后的读取
      const stored = JSON.parse(localStorage.getItem('todos') || '[]');
      setTodos(stored);
    }, []);
    
    useEffect(() => {
      // todos 变化后写入 localStorage
      localStorage.setItem('todos', JSON.stringify(todos));
    }, [todos]);
  • Vue:

    import { onMounted, watch } from 'vue';
    
    onMounted(() => {
      const stored = JSON.parse(localStorage.getItem('todos') || '[]');
      todos.value = stored;
    });
    
    watch(
      todos,
      (newTodos) => {
        localStorage.setItem('todos', JSON.stringify(newTodos));
      },
      { deep: true }
    );

要点:

  • 组件挂载后:React useEffect(..., []) → Vue onMounted(...)
  • 监测依赖变化:React useEffect(..., [todos]) → Vue watch(todos, callback, { deep: true })
  • Vue 的 watch 默认不会深度监听嵌套对象,需 { deep: true },但针对数组这种一维结构可省去 deep。不过为了保险,示例加了 deep: true
  • 若需要在组件销毁时做清理,Vue 可用 onUnmounted(...),而 React 则在 useEffect 返回的函数中。

高级迁移策略

当项目较大,包含路由、状态管理、复杂的 Hooks 逻辑等,需要更系统的迁移思路。下面列出几种常见场景及建议做法。

5.1 Hooks 模式到 Composition API

  • React Hooks:自定义 Hook 把复用逻辑封装成函数,返回 state、方法等。

    // useFetchData.js
    import { useState, useEffect } from 'react';
    export function useFetchData(url) {
      const [data, setData] = useState(null);
      useEffect(() => {
        fetch(url)
          .then((r) => r.json())
          .then((json) => setData(json));
      }, [url]);
      return data;
    }
  • Vue Composition API:同样把复用逻辑封装成函数,但需要返回 refcomputed、方法等。

    // useFetchData.js
    import { ref, watchEffect } from 'vue';
    export function useFetchData(url) {
      const data = ref(null);
      watchEffect(async () => {
        if (url.value) {
          const res = await fetch(url.value);
          data.value = await res.json();
        }
      });
      return { data };
    }
    • 注:如果 url 是一个纯字符串,可直接传入;若在组件中需要动态响应,则可把 url 定义为 ref 再传。

迁移要点:

  1. React 中自定义 Hook 里用 useState/useEffect,Vue 里用 ref/reactive + onMountedwatch/watchEffect
  2. 返回的对象都要包含“数据”与“方法”,供组件直接解构使用。
  3. React Hook 每次都要写依赖数组,Vue 的 watchEffect 则会自动跟踪依赖。

5.2 Redux / Context 到 Vuex / Pinia

  • React Redux:在组件中用 useSelectoruseDispatch;自定义 Action、Reducer。
  • Vuex(3.x/4.x)或 Pinia(推荐)

    • Vuex:类似 Redux,需要手动定义 statemutationsactionsgetters,并用 mapStatemapActions 在组件里拿到。
    • Pinia:更贴近 Composition API,使用 defineStore 定义 store,组件可直接用 useStore = defineStore(...) 拿到,访问属性就像访问普通对象。
// Pinia 示例:src/stores/todoStore.js
import { defineStore } from 'pinia';
import { ref } from 'vue';

export const useTodoStore = defineStore('todos', () => {
  const todos = ref([]);
  function addTodo(text) {
    todos.value.push({ id: Date.now(), text });
  }
  function removeTodo(id) {
    todos.value = todos.value.filter((t) => t.id !== id);
  }
  return { todos, addTodo, removeTodo };
});

迁移要点:

  1. 如果之前在 React 里用 Redux,只需把各个 Action/Reducer 概念迁移成 Pinia 的 actionsstate
  2. 组件里不再使用 useDispatchuseSelector,而是直接 const todoStore = useTodoStore(),并且用 todoStore.todostodoStore.addTodo()
  3. Pinia 的热重载与 DevTools 支持比 Vuex 更友好,建议直接采用 Pinia。

5.3 第三方库适配(路由、请求库等)

  1. 路由

    • React Router → Vue Router

      • React Router: <BrowserRouter><Route path="/" element={<Home />} />
      • Vue Router: 在 router/index.js 里定义 createRouter({ history: createWebHistory(), routes: [...] }),组件里用 <router-link><router-view>
  2. 请求库

    • Axios、Fetch 在两端是一致的,不需要迁移。
    • 若用 React Query,可考虑在 Vue 里用 Vue Query 或直接用 Composition API 手动封装。
  3. UI 组件库

    • Ant Design React → Ant Design Vue(API 大同小异)。
    • Material-UI → Vuetify 或 Element Plus 等,根据团队偏好选择替代。迁移时注意 API 差异,比如组件属性名、主题配置项。

迁移要点:

  • 路由:需要重写配置文件,组件内切换页面的逻辑也要由 <Link to="/path"> 换成 <router-link to="/path">,并在 JS 里用 useNavigate()useRouter().push()
  • 请求:一般不用改,兼容性好。
  • UI 组件库:需要整体替换,组件名、属性、插槽机制都要检查并重写。

常见痛点与解决方案

  1. JSX 表达式复杂逻辑 → Vue 模板写不下

    • 现象:在 React 里,复杂逻辑直接写在 JSX 里,比如三元表达式嵌套。Vue 模板写会显得啰嗦。
    • 解决:把逻辑抽离到 <script setup> 里的计算属性 computed 或函数里,在模板里只调用。
    <script setup>
    import { computed } from 'vue';
    const items = ref([/* ... */]);
    const filtered = computed(() => {
      return items.value.filter((i) => i.active).map((i) => /* ... */);
    });
    </script>
    
    <template>
      <div v-for="item in filtered" :key="item.id">
        {{ item.name }}
      </div>
    </template>
  2. React Context → Vue Provide / Inject

    • 现象:React 用 Context 共享状态,Vue 却对新手较陌生。
    • 解决:Vue 里在父组件用 provide('key', value),在子组件里 inject('key')。若用 Pinia,更建议直接把共享状态放到 Store 里。
  3. Hooks 依赖数组遗漏 → 逻辑难以调试

    • 现象:Vue 的 watch 依赖也有类似问题,需加 deep
    • 解决:在关键路径写单独的 watchEffect,并在必要时手动停止监听(const stop = watch(...); stop())。
  4. 组件样式隔离

    • 现象:React 用 CSS Modules、Styled Components,Vue 用 <style scoped> 或 CSS Modules。
    • 解决:在 Vue 里,保留 <style scoped>,也可以用 CSS Modules,写法为 <style module>,然后在模板里使用 :class="$style.className"

总结

本文详细剖析了从 React 迁移到 Vue 的各个关键点:

  1. 核心理念对比:响应式 vs 函数式更新、JSX vs 模板、生命周期钩子映射。
  2. 概念图解:通过 ASCII 示意图直观理解数据流与生命周期差异。
  3. 实战示例:一步步把 React 版 Todo List 拆解、迁移到 Vue 版,涵盖模板、状态、事件、Props、生命周期等核心内容。
  4. 高级策略:包括 Hooks → Composition API、Redux → Pinia、路由与 UI 库替换的实践建议。
  5. 常见痛点:针对繁琐逻辑、Context、依赖监听、样式隔离等迁移难题给出解决方案。

完成迁移的关键在于:

  • 找准映射关系:把 React 的 Hook、JSX、Context、Redux 等概念,对应到 Vue 的 Composition API、模板语法、Provide/Inject、Pinia。
  • 分阶段逐步替换:先完成最核心的组件渲染、状态更新,然后再处理路由、状态管理等外部依赖。
  • 善用 Vue 高级特性:合理运用 ref / reactivecomputedwatchEffect,以及 <script setup> 带来的简洁写法,让迁移后的代码保持高可读性。

希望本文能够帮助你快速搭建 “React → Vue” 的迁移桥梁,让你在新旧框架之间游刃有余。

目录

  1. 简介:为何要将 React 与 Node.js 结合
  2. 环境准备与项目结构

    1. Node.js 后端侧:使用 Express 快速搭建
    2. React 前端侧:使用 Create React App
  3. RESTful API 设计与数据交互

    1. 后端:定义 RESTful 接口
    2. 前端:使用 Axios/Fetch 进行请求
    3. 跨域与代理配置
    4. 数据流向图解
  4. 实时通信:Socket.io 实战

    1. 后端:集成 Socket.io
    2. 前端:在 React 中使用 Socket.io Client
    3. 实时通信流程图解
  5. GraphQL 方式:Apollo Server 与 Apollo Client

    1. 后端:搭建 Apollo Server
    2. 前端:使用 Apollo Client 查询
    3. GraphQL 查询流程示意
  6. 身份验证与授权

    1. 后端:基于 JWT 的登录与中间件
    2. 前端:React 中存储与刷新令牌
    3. 流程图解:登录到授权数据获取
  7. 服务器端渲染(SSR)与同构应用

    1. Next.js 简介与示例
    2. 自定义 Express + React SSR
    3. SSR 渲染流程图解
  8. 性能优化与发布部署

    1. 前端性能:Code Splitting 与懒加载
    2. 后端性能:缓存与压缩
    3. 生产环境部署示例
  9. 总结与最佳实践

1. 简介:为何要将 React 与 Node.js 结合

现代 Web 应用对于交互速度、灵活性和扩展性要求越来越高。前后端分离的架构备受欢迎,其中 React 负责构建用户界面与交互体验,Node.js 则作为后端服务器提供数据 API 或实时通信功能。将二者高效组合可以带来以下好处:

  • 统一语言栈:前后端皆使用 JavaScript/TypeScript,减少学习成本与团队沟通成本。
  • 易于构建同构(Isomorphic)应用:可将 React 组件在服务器端渲染,提升首次渲染速度与 SEO 友好度。
  • 实时通信能力:借助 Node.js 的事件驱动与非阻塞特性,可在 React 中轻松实现 WebSocket、Socket.io 等双向通信。
  • 灵活扩展:可在 Node.js 中集成数据库、第三方服务、身份认证等,然后通过 RESTful 或 GraphQL 对外暴露。

本指南将从基础的 REST API、实时通信到 GraphQL、SSR 及发布部署,深入剖析如何让 React 与 Node.js 配合得更加高效。


2. 环境准备与项目结构

在动手之前,需要先搭建一个最简的 React 前端项目与 Node.js 后端项目,并演示它们如何协同运行。

2.1 Node.js 后端侧:使用 Express 快速搭建

  1. 新建目录并初始化

    mkdir react-node-guide
    cd react-node-guide
    mkdir server client
    cd server
    npm init -y
  2. 安装依赖

    npm install express cors body-parser jsonwebtoken socket.io apollo-server-express graphql
    • express:Node.js 最流行的 Web 框架
    • cors:处理跨域
    • body-parser:解析 JSON 请求体(Express 4.16+ 可内置)
    • jsonwebtoken:JWT 身份验证
    • socket.io:实时双向通信
    • apollo-server-expressgraphql:GraphQL 服务
  3. 项目目录结构

    server/
    ├── package.json
    ├── index.js          // 入口,整合 Express、Socket.io、GraphQL
    ├── routes/           // RESTful 路由
    │   └── user.js
    ├── controllers/      // 控制器逻辑
    │   └── userController.js
    ├── middlewares/      // 中间件
    │   └── authMiddleware.js
    └── schema/           // GraphQL 模式与解析器
        ├── typeDefs.js
        └── resolvers.js
  4. Express 服务器示例(index.js)

    // server/index.js
    
    import express from 'express';
    import http from 'http';
    import cors from 'cors';
    import { json } from 'body-parser';
    import { Server as SocketIOServer } from 'socket.io';
    import { ApolloServer } from 'apollo-server-express';
    import typeDefs from './schema/typeDefs.js';
    import resolvers from './schema/resolvers.js';
    import userRouter from './routes/user.js';
    
    const app = express();
    const server = http.createServer(app);
    const io = new SocketIOServer(server, {
      cors: { origin: 'http://localhost:3000', methods: ['GET', 'POST'] }
    });
    
    // 中间件
    app.use(cors({ origin: 'http://localhost:3000' }));
    app.use(json());
    
    // RESTful 路由
    app.use('/api/users', userRouter);
    
    // Socket.io 逻辑
    io.on('connection', (socket) => {
      console.log('新客户端已连接:', socket.id);
      socket.on('message', (msg) => {
        console.log('收到消息:', msg);
        io.emit('message', `Echo: ${msg}`);
      });
      socket.on('disconnect', () => {
        console.log('客户端已断开:', socket.id);
      });
    });
    
    // GraphQL 服务
    const apolloServer = new ApolloServer({ typeDefs, resolvers, context: ({ req }) => ({ req }) });
    await apolloServer.start();
    apolloServer.applyMiddleware({ app, path: '/graphql', cors: false });
    
    // 启动服务器
    const PORT = process.env.PORT || 4000;
    server.listen(PORT, () => {
      console.log(`Express+Socket.io+GraphQL 服务已启动,端口 ${PORT}`);
    });
    • 该示例整合了:

      • /api/users RESTful 路由
      • Socket.io 实时通信
      • ApolloServer GraphQL
    • 后续各部分会详细拆解路由与中间件。

2.2 React 前端侧:使用 Create React App

  1. 进入 client 目录并初始化

    cd ../client
    npx create-react-app .   # 或使用 Vite: `npm init vite@latest . --template react`
  2. 安装前端依赖

    npm install axios socket.io-client @apollo/client graphql
    • axios:HTTP 请求库
    • socket.io-client:Socket.io 前端客户端
    • @apollo/clientgraphql:GraphQL 客户端
  3. 项目目录结构

    client/
    ├── package.json
    ├── src/
    │   ├── index.js        // React 入口
    │   ├── App.js
    │   ├── services/       // 封装 API 请求逻辑
    │   │   ├── api.js
    │   │   ├── socket.js
    │   │   └── graphql.js
    │   ├── components/     // 组件
    │   │   ├── UserList.jsx
    │   │   ├── ChatRoom.jsx
    │   │   └── GraphQLDemo.jsx
    │   └── ...             
    └── public/
  4. 启动脚本
    client/package.json 中添加:

    "proxy": "http://localhost:4000",
    "scripts": {
      "start": "react-scripts start",
      "build": "react-scripts build",
      "test": "react-scripts test"
    }
    • proxy 配置可直接将 /api 请求代理到 http://localhost:4000(避免开发阶段 CORS 问题)。

此时前后端工程已创建完毕。接下来将分别讲解如何进行 RESTful、Socket.io、GraphQL 等高效互连。


3. RESTful API 设计与数据交互

这是最常见的前后端数据交换方式:后端暴露 RESTful 接口(JSON 数据),前端使用 fetchaxios 发起请求并渲染数据。

3.1 后端:定义 RESTful 接口

以用户管理为例,后端提供基本的 CRUD 接口:

  1. 路由定义:server/routes/user.js

    // server/routes/user.js
    import express from 'express';
    import {
      getAllUsers,
      getUserById,
      createUser,
      updateUser,
      deleteUser
    } from '../controllers/userController.js';
    
    const router = express.Router();
    
    router.get('/', getAllUsers);        // GET /api/users
    router.get('/:id', getUserById);     // GET /api/users/:id
    router.post('/', createUser);        // POST /api/users
    router.put('/:id', updateUser);      // PUT /api/users/:id
    router.delete('/:id', deleteUser);   // DELETE /api/users/:id
    
    export default router;
  2. 控制器逻辑:server/controllers/userController.js

    // server/controllers/userController.js
    
    // 模拟内存中的用户数据
    let users = [
      { id: 1, name: 'Alice', email: 'alice@example.com' },
      { id: 2, name: 'Bob', email: 'bob@example.com' }
    ];
    let nextId = 3;
    
    export const getAllUsers = (req, res) => {
      res.json(users);
    };
    
    export const getUserById = (req, res) => {
      const id = parseInt(req.params.id);
      const user = users.find(u => u.id === id);
      if (user) {
        res.json(user);
      } else {
        res.status(404).json({ message: '用户未找到' });
      }
    };
    
    export const createUser = (req, res) => {
      const { name, email } = req.body;
      if (!name || !email) {
        return res.status(400).json({ message: '参数不完整' });
      }
      const newUser = { id: nextId++, name, email };
      users.push(newUser);
      res.status(201).json(newUser);
    };
    
    export const updateUser = (req, res) => {
      const id = parseInt(req.params.id);
      const { name, email } = req.body;
      const userIndex = users.findIndex(u => u.id === id);
      if (userIndex === -1) {
        return res.status(404).json({ message: '用户未找到' });
      }
      users[userIndex] = { id, name, email };
      res.json(users[userIndex]);
    };
    
    export const deleteUser = (req, res) => {
      const id = parseInt(req.params.id);
      const userIndex = users.findIndex(u => u.id === id);
      if (userIndex === -1) {
        return res.status(404).json({ message: '用户未找到' });
      }
      const deleted = users.splice(userIndex, 1);
      res.json(deleted[0]);
    };
  3. 启动后端

    cd server
    node index.js
    # 或:npm run dev(若使用 nodemon)

    此时后端已在 http://localhost:4000/api/users 提供 RESTful 接口。

3.2 前端:使用 Axios/Fetch 进行请求

在 React 中,我们通常创建一个封装好的 API 服务文件,统一管理 REST 请求。

  1. 封装 API 服务:client/src/services/api.js

    // client/src/services/api.js
    
    import axios from 'axios';
    
    // 由于 package.json 中已设置 "proxy": "http://localhost:4000",
    // 直接请求 "/api/users" 即可,无需写全地址。
    const API_BASE = '/api/users';
    
    export const fetchUsers = async () => {
      const res = await axios.get(API_BASE);
      return res.data; // 返回用户列表
    };
    
    export const fetchUserById = async (id) => {
      const res = await axios.get(`${API_BASE}/${id}`);
      return res.data;
    };
    
    export const createUser = async (user) => {
      const res = await axios.post(API_BASE, user);
      return res.data;
    };
    
    export const updateUser = async (id, user) => {
      const res = await axios.put(`${API_BASE}/${id}`, user);
      return res.data;
    };
    
    export const deleteUser = async (id) => {
      const res = await axios.delete(`${API_BASE}/${id}`);
      return res.data;
    };
  2. 在 React 组件中调用:client/src/components/UserList.jsx

    // client/src/components/UserList.jsx
    
    import React, { useEffect, useState } from 'react';
    import {
      fetchUsers,
      createUser,
      updateUser,
      deleteUser
    } from '../services/api';
    
    export default function UserList() {
      const [users, setUsers] = useState([]);
      const [newName, setNewName] = useState('');
      const [newEmail, setNewEmail] = useState('');
    
      useEffect(() => {
        loadUsers();
      }, []);
    
      const loadUsers = async () => {
        try {
          const data = await fetchUsers();
          setUsers(data);
        } catch (err) {
          console.error('加载用户失败:', err);
        }
      };
    
      const handleAdd = async () => {
        if (!newName || !newEmail) return;
        try {
          await createUser({ name: newName, email: newEmail });
          setNewName('');
          setNewEmail('');
          loadUsers();
        } catch (err) {
          console.error('创建用户失败:', err);
        }
      };
    
      const handleUpdate = async (id) => {
        const name = prompt('请输入新用户名:');
        const email = prompt('请输入新邮箱:');
        if (!name || !email) return;
        try {
          await updateUser(id, { name, email });
          loadUsers();
        } catch (err) {
          console.error('更新用户失败:', err);
        }
      };
    
      const handleDelete = async (id) => {
        if (!window.confirm('确认删除?')) return;
        try {
          await deleteUser(id);
          loadUsers();
        } catch (err) {
          console.error('删除用户失败:', err);
        }
      };
    
      return (
        <div>
          <h2>用户列表</h2>
          <ul>
            {users.map(user => (
              <li key={user.id}>
                {user.name} ({user.email}){' '}
                <button onClick={() => handleUpdate(user.id)}>编辑</button>{' '}
                <button onClick={() => handleDelete(user.id)}>删除</button>
              </li>
            ))}
          </ul>
          <h3>新增用户</h3>
          <input
            placeholder="姓名"
            value={newName}
            onChange={e => setNewName(e.target.value)}
          />
          <input
            placeholder="邮箱"
            value={newEmail}
            onChange={e => setNewEmail(e.target.value)}
          />
          <button onClick={handleAdd}>新增</button>
        </div>
      );
    }
  3. App.js 中引用组件

    // client/src/App.js
    
    import React from 'react';
    import UserList from './components/UserList';
    
    function App() {
      return (
        <div style={{ padding: '20px' }}>
          <h1>React + Node.js 用户管理示例</h1>
          <UserList />
        </div>
      );
    }
    
    export default App;

此时启动前端(npm start),在浏览器访问 http://localhost:3000,即可看到与后端协作的完整用户列表增删改查功能。

3.3 跨域与代理配置

在开发阶段,前端运行在 localhost:3000,后端运行在 localhost:4000,会遇到跨域(CORS)问题。解决方法:

  1. 后端启用 CORS

    // server/index.js 中已包含:
    app.use(cors({ origin: 'http://localhost:3000' }));
  2. 前端使用 proxy
    client/package.json 中添加:

    "proxy": "http://localhost:4000"
    • 这样对 /api/... 的请求会被 CRA 自动代理到后端,无需再写完整 URL(如 http://localhost:4000/api/users)。
    • 若在生产环境,需要自行在 Nginx 或后端做代理配置。

3.4 数据流向图解

React 浏览器 (http://localhost:3000)
┌─────────────────────────────────────────┐
│ 点击 "加载用户"                         │
│ axios.get('/api/users')                │
└───────────────┬─────────────────────────┘
                │(1)发起 HTTP GET 请求
                ▼
    ┌────────────────────────────────┐
    │  Node.js/Express (http://localhost:4000) │
    │  app.use('/api/users', userRouter)       │
    └───────────────┬─────────────────────────┘
                    │(2)转到 routes/user.js
                    └──────────────────────────────────┐
                                                       │
                                  ┌────────────────────▼────────────────────┐
                                  │ userController.getAllUsers (读取 users)    │
                                  └───────────────┬───────────────────────────┘
                                                  │(3)返回 JSON 数据
                                                  ▼
                                        ┌─────────────────────────────┐
                                        │   Express 返回 JSON 到前端   │
                                        └───────────────┬─────────────┘
                                                        │(4)axios 收到数据
                                                        ▼
                                        ┌─────────────────────────────┐
                                        │    React 更新状态并渲染      │
                                        │    setUsers([...])          │
                                        └─────────────────────────────┘
  • (1) React 发起请求;
  • (2) 请求到达 Express,交给对应路由;
  • (3) 控制器读取并返回数据;
  • (4) React 收到数据并渲染列表。

4. 实时通信:Socket.io 实战

有些场景(如聊天室、协同编辑、实时通知)需要做双向、低延迟的数据传输。此时可以使用 Socket.io,底层基于 WebSocket,兼容性更好,API 更友好。

4.1 后端:集成 Socket.io

我们在之前的 server/index.js 中已简要演示过,这里再做补充说明。

// server/index.js (摘录)
import http from 'http';
import { Server as SocketIOServer } from 'socket.io';

const app = express();
const server = http.createServer(app);

// 创建 Socket.io 服务器
const io = new SocketIOServer(server, {
  cors: { origin: 'http://localhost:3000', methods: ['GET','POST'] }
});

// 监听连接
io.on('connection', (socket) => {
  console.log('客户端已连接:', socket.id);

  // 收到客户端发送的 "chat message"
  socket.on('chat message', (msg) => {
    console.log('收到消息:', msg);
    // 广播给所有客户端
    io.emit('chat message', msg);
  });

  socket.on('disconnect', () => {
    console.log('客户端断开:', socket.id);
  });
});

// 启动 server.listen(...)
  • 在创建 SocketIOServer 时,传入了跨域配置,以允许前端(localhost:3000)连接。
  • 当客户端通过 io.connect 建立连接后,后端就可以在 connection 回调里监听并发送事件。

4.2 前端:在 React 中使用 Socket.io Client

  1. 封装 Socket 服务:client/src/services/socket.js

    // client/src/services/socket.js
    
    import { io } from 'socket.io-client';
    
    // 直接连接到后端 Socket.io 地址
    const socket = io('http://localhost:4000');
    
    export default socket;
  2. 创建聊天室组件:client/src/components/ChatRoom.jsx

    // client/src/components/ChatRoom.jsx
    
    import React, { useEffect, useState } from 'react';
    import socket from '../services/socket';
    
    export default function ChatRoom() {
      const [messages, setMessages] = useState([]);
      const [input, setInput] = useState('');
    
      useEffect(() => {
        // 监听后端广播的消息
        socket.on('chat message', (msg) => {
          setMessages(prev => [...prev, msg]);
        });
    
        // 组件卸载时移除监听
        return () => {
          socket.off('chat message');
        };
      }, []);
    
      const handleSend = () => {
        if (input.trim() === '') return;
        // 发送事件到后端
        socket.emit('chat message', input);
        setInput('');
      };
    
      return (
        <div style={{ padding: '20px', border: '1px solid #ccc' }}>
          <h2>聊天室</h2>
          <div
            style={{
              width: '100%',
              height: '300px',
              border: '1px solid #ddd',
              overflowY: 'auto',
              padding: '10px'
            }}
          >
            {messages.map((msg, idx) => (
              <div key={idx}>{msg}</div>
            ))}
          </div>
          <input
            style={{ width: '80%', marginRight: '10px' }}
            value={input}
            onChange={(e) => setInput(e.target.value)}
            placeholder="输入聊天内容..."
          />
          <button onClick={handleSend}>发送</button>
        </div>
      );
    }
  3. App.js 中引用聊天室组件

    // client/src/App.js
    
    import React from 'react';
    import ChatRoom from './components/ChatRoom';
    
    function App() {
      return (
        <div style={{ padding: '20px' }}>
          <h1>React + Node.js 实时聊天示例</h1>
          <ChatRoom />
        </div>
      );
    }
    
    export default App;

4.3 实时通信流程图解

React 浏览器 (http://localhost:3000)
┌─────────────────────────────┐
│    socket = io("...")       │
│    socket.on("chat message")│
└─────────────┬───────────────┘
              │(1)建立 WebSocket 连接
              ▼
    ┌─────────────────────────────────┐
    │  Node.js/Express + Socket.io    │
    │  io.on("connection", ...)       │
    └─────────────┬───────────────────┘
                  │(2)连接成功
                  ▼
┌────────────────────────────────────────┐
│ 前端 socket.emit("chat message", msg)  │
└─────────────┬──────────────────────────┘
              │(3)发送消息给后端
              ▼
   ┌─────────────────────────────────┐
   │ 后端 io.on("chat message", ...) │
   │ console.log(msg)                │
   │ io.emit("chat message", msg)    │
   └─────────────┬───────────────────┘
                 │(4)广播消息给所有客户端
                 ▼
┌────────────────────────────────────────┐
│ 前端 socket.on("chat message", newMsg)│
│ 将 newMsg append 到消息列表           │
└────────────────────────────────────────┘
  • (1) 前端初始化 Socket.io 客户端并向后端发起 WebSocket 连接;
  • (2) 后端 io.on("connection") 捕获新连接;
  • (3) 前端调用 socket.emit("chat message", msg) 发送消息;
  • (4) 后端接收到消息后,使用 io.emit(...) 广播给所有已连接客户端;
  • (5) 前端 socket.on("chat message") 收到并更新 UI。

5. GraphQL 方式:Apollo Server 与 Apollo Client

GraphQL 作为替代 REST 的数据查询方式,也可与 React+Node.js 高效配合。

5.1 后端:搭建 Apollo Server

  1. GraphQL 模式定义:server/schema/typeDefs.js

    // server/schema/typeDefs.js
    
    import { gql } from 'apollo-server-express';
    
    const typeDefs = gql`
      type User {
        id: ID!
        name: String!
        email: String!
      }
    
      type Query {
        users: [User!]!
        user(id: ID!): User
      }
    
      type Mutation {
        createUser(name: String!, email: String!): User!
        updateUser(id: ID!, name: String!, email: String!): User!
        deleteUser(id: ID!): User!
      }
    `;
    
    export default typeDefs;
  2. Resolvers 实现:server/schema/resolvers.js

    // server/schema/resolvers.js
    
    let users = [
      { id: '1', name: 'Alice', email: 'alice@example.com' },
      { id: '2', name: 'Bob', email: 'bob@example.com' }
    ];
    
    export default {
      Query: {
        users: () => users,
        user: (_, { id }) => users.find(u => u.id === id)
      },
      Mutation: {
        createUser: (_, { name, email }) => {
          const newUser = { id: String(users.length + 1), name, email };
          users.push(newUser);
          return newUser;
        },
        updateUser: (_, { id, name, email }) => {
          const index = users.findIndex(u => u.id === id);
          if (index === -1) throw new Error('用户未找到');
          users[index] = { id, name, email };
          return users[index];
        },
        deleteUser: (_, { id }) => {
          const index = users.findIndex(u => u.id === id);
          if (index === -1) throw new Error('用户未找到');
          const [deleted] = users.splice(index, 1);
          return deleted;
        }
      }
    };
  3. index.js 中集成 ApolloServer(已示例)

    // server/index.js 中摘要
    import typeDefs from './schema/typeDefs.js';
    import resolvers from './schema/resolvers.js';
    // ...
    const apolloServer = new ApolloServer({ typeDefs, resolvers, context: ({ req }) => ({ req }) });
    await apolloServer.start();
    apolloServer.applyMiddleware({ app, path: '/graphql', cors: false });

此时 GraphQL 服务在 http://localhost:4000/graphql 提供交互式 IDE(GraphQL Playground),可以直接执行查询与变更。

5.2 前端:使用 Apollo Client 查询

  1. 配置 Apollo Client:client/src/services/graphql.js

    // client/src/services/graphql.js
    
    import { ApolloClient, InMemoryCache, gql } from '@apollo/client';
    
    const client = new ApolloClient({
      uri: 'http://localhost:4000/graphql',
      cache: new InMemoryCache()
    });
    
    export default client;
    
    // 定义常用查询/变更
    export const GET_USERS = gql`
      query GetUsers {
        users {
          id
          name
          email
        }
      }
    `;
    
    export const CREATE_USER = gql`
      mutation CreateUser($name: String!, $email: String!) {
        createUser(name: $name, email: $email) {
          id
          name
          email
        }
      }
    `;
    // 其余 UPDATE、DELETE 可类似定义
  2. index.js 中包裹 ApolloProvider

    // client/src/index.js
    
    import React from 'react';
    import ReactDOM from 'react-dom';
    import App from './App';
    import { ApolloProvider } from '@apollo/client';
    import client from './services/graphql';
    
    ReactDOM.render(
      <React.StrictMode>
        <ApolloProvider client={client}>
          <App />
        </ApolloProvider>
      </React.StrictMode>,
      document.getElementById('root')
    );
  3. GraphQL 示例组件:client/src/components/GraphQLDemo.jsx

    // client/src/components/GraphQLDemo.jsx
    
    import React, { useState } from 'react';
    import { useQuery, useMutation } from '@apollo/client';
    import { GET_USERS, CREATE_USER } from '../services/graphql';
    
    export default function GraphQLDemo() {
      const { loading, error, data, refetch } = useQuery(GET_USERS);
      const [createUser] = useMutation(CREATE_USER);
    
      const [name, setName] = useState('');
      const [email, setEmail] = useState('');
    
      const handleCreate = async () => {
        if (!name || !email) return;
        await createUser({ variables: { name, email } });
        setName('');
        setEmail('');
        refetch();
      };
    
      if (loading) return <p>加载中...</p>;
      if (error) return <p>出错:{error.message}</p>;
    
      return (
        <div style={{ padding: '20px', border: '1px solid #ccc' }}>
          <h2>GraphQL 用户列表</h2>
          <ul>
            {data.users.map(user => (
              <li key={user.id}>
                {user.name} ({user.email})
              </li>
            ))}
          </ul>
          <h3>新增用户</h3>
          <input
            placeholder="姓名"
            value={name}
            onChange={(e) => setName(e.target.value)}
          />
          <input
            placeholder="邮箱"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
          />
          <button onClick={handleCreate}>创建</button>
        </div>
      );
    }
  4. App.js 中引用

    // client/src/App.js
    
    import React from 'react';
    import GraphQLDemo from './components/GraphQLDemo';
    
    function App() {
      return (
        <div style={{ padding: '20px' }}>
          <h1>React + Node.js GraphQL 示例</h1>
          <GraphQLDemo />
        </div>
      );
    }
    
    export default App;

5.3 GraphQL 查询流程示意

React 浏览器 (http://localhost:3000)
┌──────────────────────────────────────┐
│ useQuery(GET_USERS) -> 触发网络请求 │
│ POST http://localhost:4000/graphql  │
│ { query: "...", variables: { } }    │
└─────────────┬────────────────────────┘
              │(1)发送 GraphQL 查询
              ▼
    ┌───────────────────────────────────┐
    │  Node.js/Express + ApolloServer    │
    │  接收 POST /graphql 字节流          │
    └─────────────┬──────────────────────┘
                  │(2)解析 GraphQL 查询
                  ▼
    ┌───────────────────────────────────┐
    │  执行对应 resolver,读取 User 数组  │
    │  将数据组装成符合 GraphQL 规范的 JSON │
    └─────────────┬──────────────────────┘
                  │(3)返回 { data: { users: [...] } }
                  ▼
    ┌───────────────────────────────────┐
    │  React Apollo Client 收到数据     │
    │  更新组件状态并渲染               │
    └───────────────────────────────────┘
  • GraphQL 将请求与响应都以 JSON 格式表达,更加灵活,但需要定义 Schema 和 Resolver,适合接口复杂、联合查询需求多的场景。

6. 身份验证与授权

对于大多数应用,都需要用户登录、身份验证和授权。这里以 JWT(JSON Web Token)为例,演示前后端如何协作完成登录到获取受保护数据的流程。

6.1 后端:基于 JWT 的登录与中间件

  1. 安装依赖

    npm install bcrypt jsonwebtoken
    • bcrypt:用于对密码进行哈希
    • jsonwebtoken:用于签发与验证 JWT
  2. 用户登录路由:server/routes/auth.js

    // server/routes/auth.js
    
    import express from 'express';
    import { loginUser } from '../controllers/authController.js';
    const router = express.Router();
    
    router.post('/login', loginUser); // POST /api/auth/login
    
    export default router;
  3. 登录控制器:server/controllers/authController.js

    // server/controllers/authController.js
    
    import bcrypt from 'bcrypt';
    import jwt from 'jsonwebtoken';
    
    // 模拟存储的用户(正式项目应存入数据库)
    const mockUser = {
      id: 1,
      username: 'admin',
      // bcrypt.hashSync('password123', 10)
      passwordHash: '$2b$10$abcdefghijk1234567890abcdefghijklmnopqrstuv'
    };
    
    const JWT_SECRET = 'your_jwt_secret_key';
    
    export const loginUser = async (req, res) => {
      const { username, password } = req.body;
      if (username !== mockUser.username) {
        return res.status(401).json({ message: '用户名或密码错误' });
      }
      const match = await bcrypt.compare(password, mockUser.passwordHash);
      if (!match) {
        return res.status(401).json({ message: '用户名或密码错误' });
      }
      // 签发 JWT,有效期 1 小时
      const token = jwt.sign({ id: mockUser.id, username }, JWT_SECRET, {
        expiresIn: '1h'
      });
      res.json({ token });
    };
    
    // 中间件:验证 JWT
    export const authMiddleware = (req, res, next) => {
      const authHeader = req.headers.authorization;
      if (!authHeader || !authHeader.startsWith('Bearer ')) {
        return res.status(401).json({ message: '缺少令牌' });
      }
      const token = authHeader.split(' ')[1];
      try {
        const payload = jwt.verify(token, JWT_SECRET);
        req.user = payload; // 将 payload(id、username)挂载到 req.user
        next();
      } catch (err) {
        return res.status(401).json({ message: '无效或过期令牌' });
      }
    };
  4. 受保护路由示例:server/routes/protected.js

    // server/routes/protected.js
    
    import express from 'express';
    import { authMiddleware } from '../controllers/authController.js';
    
    const router = express.Router();

// GET /api/protected/profile
router.get('/profile', authMiddleware, (req, res) => {
// req.user 已包含 id、username
res.json({ id: req.user.id, username: req.user.username });
});

export default router;


5. **在 `index.js` 中挂载**  
```js
import authRouter from './routes/auth.js';
import protectedRouter from './routes/protected.js';

app.use('/api/auth', authRouter);
app.use('/api/protected', protectedRouter);

此时后端已具备以下接口:

  • POST /api/auth/login:接收 { username, password },返回 { token }
  • GET /api/protected/profile:需在头部 Authorization: Bearer <token>,返回用户信息

6.2 前端:React 中存储与刷新令牌

  1. 封装登录逻辑:client/src/services/auth.js

    // client/src/services/auth.js
    
    import axios from 'axios';
    
    const API_AUTH = '/api/auth';
    
    // 登录并保存 token 到 localStorage
    export const login = async (username, password) => {
      const res = await axios.post(`${API_AUTH}/login`, { username, password });
      const { token } = res.data;
      localStorage.setItem('token', token);
      return token;
    };
    
    // 获取用户 profile
    export const fetchProfile = async () => {
      const token = localStorage.getItem('token');
      const res = await axios.get('/api/protected/profile', {
        headers: { Authorization: `Bearer ${token}` }
      });
      return res.data;
    };
    
    // 退出登录
    export const logout = () => {
      localStorage.removeItem('token');
    };
  2. 登录组件:client/src/components/Login.jsx

    // client/src/components/Login.jsx
    
    import React, { useState } from 'react';
    import { login, fetchProfile, logout } from '../services/auth';
    
    export default function Login() {
      const [username, setUsername] = useState('');
      const [password, setPassword] = useState('');
      const [profile, setProfile] = useState(null);
      const [error, setError] = useState('');
    
      const handleLogin = async () => {
        try {
          await login(username, password);
          const data = await fetchProfile();
          setProfile(data);
          setError('');
        } catch (err) {
          setError(err.response?.data?.message || '登录失败');
        }
      };
    
      const handleLogout = () => {
        logout();
        setProfile(null);
      };
    
      if (profile) {
        return (
          <div>
            <h2>欢迎,{profile.username}!</h2>
            <button onClick={handleLogout}>退出登录</button>
          </div>
        );
      }
    
      return (
        <div style={{ padding: '20px', border: '1px solid #ccc' }}>
          <h2>登录</h2>
          {error && <p style={{ color: 'red' }}>{error}</p>}
          <input
            placeholder="用户名"
            value={username}
            onChange={(e) => setUsername(e.target.value)}
          />
          <input
            placeholder="密码"
            type="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
          />
          <button onClick={handleLogin}>登录</button>
        </div>
      );
    }
  3. 流程图解:登录到获取受保护数据

    React 浏览器
    ┌─────────────────────────────────────────┐
    │ 点击 登录 按钮 -> login(username,password) │
    └───────────────┬─────────────────────────┘
                    │(1)POST /api/auth/login
                    ▼
        ┌───────────────────────────────────┐
        │  Node.js Express -> authController.loginUser │
        │  验证用户 -> 签发 JWT -> 返回 {token} │
        └───────────────┬───────────────────┘
                        │(2)前端收到 {token} 并保存在 localStorage
                        ▼
        ┌──────────────────────────────────────────┐
        │ React 调用 fetchProfile() -> GET /api/protected/profile │
        │ headers: { Authorization: "Bearer <token>" }           │
        └───────────────┬──────────────────────────┘
                        │(3)Node.js authMiddleware 验证 JWT
                        ▼
        ┌──────────────────────────────────────────┐
        │ profile 路由返回用户信息 -> React 接收并渲染 │
        └──────────────────────────────────────────┘
  • (1) 前端调用 login,后端验证并返回 JWT;
  • (2) 前端将 JWT 存储在 localStorage
  • (3) 前端调用受保护接口,附带 Authorization 头部;
  • (4) 后端 authMiddleware 验证令牌通过后,返回用户数据。

6.3 安全提示

  • 使用 HTTPS:生产环境务必使用 HTTPS,防止令牌在网络中被窃取。
  • 短令牌有效期:结合 Refresh Token 机制,减少令牌被滥用的风险。
  • HTTP Only Cookie:也可将 JWT 存储在 Cookie 中,并配置 httpOnlysecure 等属性,提高安全性。

7. 服务器端渲染(SSR)与同构应用

除了纯前后端分离,有时希望将 React 组件在服务器端渲染(SSR),提升首屏渲染速度、SEO 友好度,或者需要同构(Isomorphic)渲染。这里介绍两种方式:Next.js 以及自定义 Express + React SSR

7.1 Next.js 简介与示例

Next.js 是基于 React 的 SSR 框架,开箱即用,无需手动配置 Webpack、Babel。它也可以集成自定义 API 路由,甚至将之前的 Express 代码嵌入其中。

  1. 初始化 Next.js 项目

    cd react-node-guide/client
    npx create-next-app@latest ssr-demo
    cd ssr-demo
  2. 编写页面与 API

    • pages/index.js:React 页面,可使用 getServerSideProps 获取数据。
    • pages/api/users.js:Next.js 内置 API 路由,可直接处理 /api/users 请求。
  3. 启动 Next.js

    npm run dev

    此时 Next.js 自带的 SSR 功能在 http://localhost:3000 实现,API 路由在 http://localhost:3000/api/users 可用。

  4. 示例:pages/index.js

    // ssr-demo/pages/index.js
    
    import React from 'react';
    import axios from 'axios';
    
    export async function getServerSideProps() {
      const res = await axios.get('http://localhost:3000/api/users');
      return { props: { users: res.data } };
    }
    
    export default function Home({ users }) {
      return (
        <div style={{ padding: '20px' }}>
          <h1>Next.js SSR 用户列表</h1>
          <ul>
            {users.map(user => (
              <li key={user.id}>{user.name} ({user.email})</li>
            ))}
          </ul>
        </div>
      );
    }

7.2 自定义 Express + React SSR

如果不想引入完整 Next.js,也可以手动配置 Express + React SSR,过程如下:

  1. 安装依赖

    cd react-node-guide/server
    npm install react react-dom @babel/core @babel/preset-env @babel/preset-react babel-register ignore-styles
    • babel-register:在运行时动态转译 React 组件
    • ignore-styles:让服务器端在 require CSS/LESS 等文件时忽略
  2. 配置 Babel:server/babel.config.js

    module.exports = {
      presets: [
        ['@babel/preset-env', { targets: { node: 'current' } }],
        '@babel/preset-react'
      ]
    };
  3. 创建 SSR 入口:server/ssr.js

    // server/ssr.js
    
    // 在 Node.js 环境下注册 Babel,让后续 `require` 支持 JSX
    require('ignore-styles');
    require('@babel/register')({
      ignore: [/(node_modules)/],
      presets: ['@babel/preset-env', '@babel/preset-react']
    });
    
    import path from 'path';
    import fs from 'fs';
    import express from 'express';
    import React from 'react';
    import ReactDOMServer from 'react-dom/server';
    import App from '../client/src/App'; // 假设 CRA 的 App.js
    
    const app = express();
    
    // 提供静态资源(由 CRA build 生成)
    app.use(express.static(path.resolve(__dirname, '../client/build')));
    
    app.get('/*', (req, res) => {
      const appString = ReactDOMServer.renderToString(<App />);
      const indexFile = path.resolve(__dirname, '../client/build/index.html');
      fs.readFile(indexFile, 'utf8', (err, data) => {
        if (err) {
          console.error('读取 HTML 模板失败:', err);
          return res.status(500).send('内部错误');
        }
        // 将渲染好的 appString 塞回到 <div id="root"></div> 中
        const html = data.replace('<div id="root"></div>', `<div id="root">${appString}</div>`);
        return res.send(html);
      });
    });
    
    const PORT = process.env.PORT || 4000;
    app.listen(PORT, () => {
      console.log(`Express SSR 已启动,端口 ${PORT}`);
    });
  4. 构建前端静态资源

    cd client
    npm run build

    这会在 client/build 目录下生成静态文件,然后 SSR 服务器即可将其作为模板注入渲染后的内容并返回给浏览器。

7.3 SSR 渲染流程图解

浏览器请求 http://localhost:4000/
┌────────────────────────────┐
│ Express SSR (server/ssr.js) │
└───────────────┬────────────┘
                │(1)捕获所有 GET 请求
                ▼
     ┌─────────────────────────────────┐
     │ ReactDOMServer.renderToString   │
     │ 生成 HTML 字符串(<App />)     │
     └───────────────┬─────────────────┘
                     │
                     ▼
         ┌─────────────────────────────────┐
         │ 读取 client/build/index.html      │
         │ 并将 <div id="root"> 替换为渲染结果 │
         └───────────────┬─────────────────┘
                         │
                         ▼
         ┌─────────────────────────────────┐
         │ 返回包含首屏渲染内容的 HTML     │
         └─────────────────────────────────┘
  • (1) 浏览器首次请求时,Express SSR 将 React 组件渲染为 HTML,嵌入到模板中后返回。
  • (2) 浏览器接收到首屏 HTML,并继续下载后续静态资源(JS、CSS),在客户端完成 React 的 hydrating(挂载)过程。

8. 性能优化与发布部署

8.1 前端性能:Code Splitting 与懒加载

  1. Route-based Splitting(按路由分割)

    • 使用 React.lazy 和 Suspense 来按需加载组件:

      import React, { Suspense, lazy } from 'react';
      import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
      
      const Home = lazy(() => import('./pages/Home'));
      const Users = lazy(() => import('./pages/Users'));
      
      function App() {
        return (
          <Router>
            <Suspense fallback={<div>加载中...</div>}>
              <Switch>
                <Route path="/users" component={Users} />
                <Route path="/" component={Home} />
              </Switch>
            </Suspense>
          </Router>
        );
      }
    • CRA 默认支持 Code Splitting,打包时会自动生成多个 JS chunk。
  2. Bundle 分析与优化

    • 安装 source-map-explorer

      npm install --save-dev source-map-explorer
    • 构建后运行:

      npm run build
      npx source-map-explorer 'build/static/js/*.js'
    • 分析各依赖包体积,尽量剔除不必要的重型库(如 lodash 全量、moment.js),可用 lodash-esdayjs 等轻量替代。

8.2 后端性能:缓存与压缩

  1. Gzip 压缩

    npm install compression

    在 Express 中启用:

    import compression from 'compression';
    app.use(compression());
    • 可自动对响应进行 Gzip 压缩,减少网络传输大小;若使用 HTTPS,还可启用 Brotli(shrink-ray-current 等库)。
  2. 接口缓存

    • 使用内存缓存或 Redis 缓存常用数据,提高响应速度。
    • 示例:在获取用户列表时,若数据变化不频繁,可先检查缓存:

      import Redis from 'ioredis';
      const redis = new Redis();
      
      export const getAllUsers = async (req, res) => {
        const cacheKey = 'users:list';
        const cached = await redis.get(cacheKey);
        if (cached) {
          return res.json(JSON.parse(cached));
        }
        // 模拟 DB 查询
        const data = users;
        await redis.set(cacheKey, JSON.stringify(data), 'EX', 60); // 缓存 60s
        res.json(data);
      };
  3. 使用 PM2 或 Docker 进程管理

    • 使用 PM2 启动 Node.js,并开启日志、进程守护:

      npm install -g pm2
      pm2 start index.js --name react-node-server -i max
    • 或将后端打包为 Docker 镜像,在 Kubernetes / Docker Swarm 中编排多副本实例,保证高可用。

8.3 生产环境部署示例

  1. 部署前端静态文件

    • 在前端项目执行 npm run build,将 build/ 目录下的内容上传到 CDN 或静态服务器(Nginx)。
    • Nginx 配置示例:

      server {
        listen 80;
        server_name your-domain.com;
      
        location / {
          root /var/www/react-app/build;
          index index.html;
          try_files $uri /index.html;
        }
      
        # 代理 API 请求到后端
        location /api/ {
          proxy_pass http://backend:4000/api/;
        }
      
        location /graphql {
          proxy_pass http://backend:4000/graphql;
        }
      
        # WebSocket 转发
        location /socket.io/ {
          proxy_pass http://backend:4000/socket.io/;
          proxy_http_version 1.1;
          proxy_set_header Upgrade $http_upgrade;
          proxy_set_header Connection "Upgrade";
        }
      }
  2. 部署后端

    • 构建为 Docker 镜像:

      # server/Dockerfile
      
      FROM node:16-alpine
      WORKDIR /app
      COPY package*.json ./
      RUN npm ci --only=production
      COPY . .
      EXPOSE 4000
      CMD ["node", "index.js"]
    • 构建并运行:

      cd server
      docker build -t react-node-server .
      docker run -d --name react-node-server -p 4000:4000 react-node-server
    • 若使用 Kubernetes,可通过 Deployment/Service/Ingress 等方式发布。

9. 总结与最佳实践

  1. 前后端分离与统一语言栈

    • 使用 React 构建 SPA,Node.js 提供 API,实现职责分离;
    • 前后端皆用 JavaScript/TypeScript,提高团队协作效率。
  2. 合理选择通信方式

    • RESTful:简单易上手,适合大多数 CRUD 场景;
    • Socket.io:实时双向通信,适合聊天室、协同编辑;
    • GraphQL:精确按需查询,避免过多/过少数据,适合数据结构复杂或多客户端需求场景;
    • SSR:提升首屏速度与 SEO,适合新闻、博客、电商等需搜索引擎友好的应用。
  3. 跨域与代理

    • 开发阶段可通过 CRA 的 proxy 或 Express 中启用 CORS;
    • 生产环境应使用 Nginx/Apache 做统一代理,并配置 HTTPS,提升安全性。
  4. 身份验证与授权

    • 推荐基于 JWT,将令牌存储在 HTTP-only Cookie 或 localStorage(注意 XSS 风险);
    • 对受保护路由使用中间件校验令牌;
    • 前端在切换路由时检查登录状态并动态渲染界面。
  5. 性能优化

    • 前端利用 Code Splitting、Lazy Loading、Tree Shaking 减少打包体积;
    • 后端启用 Gzip/Brotli、缓存、连接池等;
    • 生产环境使用 PM2 或 Docker/K8s 做负载均衡与容错。
  6. 安全性注意事项

    • 对用户输入严防 SQL 注入、XSS、CSRF 等;
    • 后端不将敏感信息(如密码、密钥)返回给前端;
    • 使用 HTTPS,禁用不安全的 Cipher。
  7. 开发流程

    • 使用 ESLint/Prettier 统一代码风格;
    • 使用 Git Hooks(如 Husky)保证提交质量;
    • 编写单元测试(Jest、Mocha)、集成测试(Supertest、Cypress)提高稳定性。

通过本文的详细示例与图解,你已经掌握了 React 与 Node.js 在多种场景下的高效互连方式:从最简单的 RESTful 数据交互,到实时通信、GraphQL 查询,乃至 SSR。希望这份指南能帮助你在实际项目中快速上手并应用最佳实践,构建出高性能、可扩展、安全可靠的全栈应用。

本文从原理、环境配置、核心 API、代码示例、图解等多个维度进行详尽说明,帮助你快速上手 React Native + 蓝牙串口通信开发。


目录

  1. 概述
  2. 环境准备
  3. 安装与配置
  4. 原理与整体架构图解
  5. 权限设置

  6. 核心 API 详解

  7. 完整示例:一个简单的串口控制页面
  8. 注意事项与常见问题
  9. 总结

概述

在许多 IoT 场景中,蓝牙串口(Bluetooth Serial Port Profile, SPP)是最常见的无线数据传输方式之一。尤其是当你需要在手机端与 Arduino、Raspberry Pi、各种自制蓝牙模块(如 HC-05/HC-06)等设备通信时,蓝牙串口通信显得尤为重要。

React Native 社区中,有一个叫做 react-native-bluetooth-serial-next(常简称为 “React Native Bluetooth Serial”)的第三方库,可以帮助我们在 React Native 应用中快速实现蓝牙 SPP 的扫描、连接、收发数据、断开等功能。它对 Android/iOS 都提供了比较完整的封装,并且支持事件回调,非常适合初学者和中小型项目使用。

本文将从最基础的安装开始,一步步带你了解整个流程,并且附上大量代码示例与图解,帮助你快速上手。


环境准备

  • React Native 环境

    • Node.js (建议 v14 及以上)
    • React Native CLI (若已安装,可跳过)
    • Android Studio / Xcode(根据你做 Android 或 iOS)
  • 目标设备

    • 一台具备蓝牙功能的手机(Android 或 iOS)
    • 一块支持 SPP 的蓝牙模块(如 HC-05 / HC-06)
    • Arduino、树莓派或其他控制板(本文以 HC-05 + Arduino UNO 为示例)
说明: 文中示例代码基于 React Native 0.70+,在 Android 10+ 与 iOS 13+ 上测试通过。

安装与配置

首先,进入你的项目根目录,执行以下命令安装 react-native-bluetooth-serial-next

# 使用 npm
npm install react-native-bluetooth-serial-next --save

# 或使用 yarn
yarn add react-native-bluetooth-serial-next

安装完成后,进入 iOS 目录执行 CocoaPods 安装:

cd ios
pod install
cd ..
Tip: 如果你使用了 React Native 0.60 以上(自动链接),上述安装完成后,通常不需要手动链接(react-native link ...)。如果你遇到链接问题,请参考官方文档自行调整。

原理与整体架构图解

在使用蓝牙串口通信时,整体流程如下:

+---------------------+      +-----------------------+      +----------------------+
|   React Native App  | <--> | 手机内置蓝牙适配器(BLE/Classic) | <--> | HC-05蓝牙模块(SPP) |
+---------------------+      +-----------------------+      +----------------------+
                                                                 |
                                                                 |
                                                                 v
                                                   +----------------------+
                                                   |    Arduino 控制器     |
                                                   +----------------------+
  1. React Native App:我们通过 react-native-bluetooth-serial-next 调用原生蓝牙 API(Android/ iOS),进行扫描、配对、连接,以及收发数据。
  2. 手机蓝牙适配器:基于 Classic Bluetooth SPP 协议,它将手机的串口数据转换成射频进行传输。(注意:React Native Bluetooth Serial 默认使用 Classic SPP,非 BLE)
  3. HC-05 蓝牙模块:通过串口(UART)与 Arduino 等控制器连接,充当蓝牙接收端,接受手机发送的指令或返回传感器数据。
  4. Arduino / 控制器:通过串口读取或发送数据,进行逻辑处理(如控制电机、读取温度、灯光控制等)。

下面用更直观的流程图来展示主要步骤(扫描→配对→连接→收发→断开):

┌───────────────────────────────────────────────────────────────────┐
│                                                                   │
│   [1] 应用启动 -> 初始化库 -> 订阅事件                               │
│                              │                                    │
│                              ▼                                    │
│   [2] 开始扫描附近蓝牙设备(Classic)                               │
│                              │                                    │
│                              ▼                                    │
│   [3] 扫描结果列表:显示 HC-05 / HC-06 等设备                       │
│                              │                                    │
│                              ▼                                    │
│   [4] 用户点击 “连接”:触发 connectToDevice(UUID)                  │
│                              │                                    │
│                              ▼                                    │
│   [5] 与 HC-05 建立 RFCOMM 连接(SPP)                              │
│                              │                                    │
│                              ▼                                    │
│   [6] 发送/接收串口数据                                             │
│      - write(data) -> 手机蓝牙 -> HC-05 -> Arduino                 │
│      - 监听 dataReceived 事件 -> 获取 HC-05 返回数据                 │
│                              │                                    │
│                              ▼                                    │
│   [7] 用户点击 “断开”:disconnect()                                │
│                                                                   │
└───────────────────────────────────────────────────────────────────┘

权限设置

Android 权限

  1. AndroidManifest.xml:在项目 android/app/src/main/AndroidManifest.xml 中添加以下权限(<manifest> 节点下):

    <!-- 蓝牙基础权限 -->
    <uses-permission android:name="android.permission.BLUETOOTH" />
    <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
    
    <!-- Android 12+ 需要额外动态权限 -->
    <uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation"/>
    <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
    <uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
    
    <!-- 定位权限:部分 Android 版本扫描蓝牙需要定位授权 -->
    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
  2. 动态申请:在运行时,需要向用户申请定位权限(Android 6.0+)以及 Android 12+ 的蓝牙权限。可以使用 React Native 自带的 PermissionsAndroid

    import { PermissionsAndroid, Platform } from 'react-native';
    
    async function requestAndroidPermissions() {
      if (Platform.OS !== 'android') return true;
    
      const permissions = [];
      // Android 12+ 分别申请
      if (Platform.constants.Release >= '12') {
        permissions.push(PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN);
        permissions.push(PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT);
        permissions.push(PermissionsAndroid.PERMISSIONS.BLUETOOTH_ADVERTISE);
      }
      // 定位权限(扫描蓝牙)
      permissions.push(PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION);
    
      try {
        const granted = await PermissionsAndroid.requestMultiple(permissions);
        // 检查是否都被授予
        const allGranted = Object.values(granted).every(status => status === PermissionsAndroid.RESULTS.GRANTED);
        return allGranted;
      } catch (err) {
        console.warn('权限申请失败', err);
        return false;
      }
    }

iOS 权限

ios/YourProject/Info.plist 中,添加如下键值:

<key>NSBluetoothAlwaysUsageDescription</key>
<string>应用需要使用蓝牙来连接设备并进行串口通信。</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>应用需要访问蓝牙外设来发送和接收数据。</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>扫描附近蓝牙设备需要定位权限。</string>
注意: iOS 13+ 要求 NSBluetoothAlwaysUsageDescription,并且如果要做蓝牙扫描,还需要 NSLocationWhenInUseUsageDescriptionNSLocationAlwaysUsageDescription

核心 API 详解

以下示例基于 react-native-bluetooth-serial-next,在代码里我们一般这样引用:

import RNBluetoothSerial from 'react-native-bluetooth-serial-next';

1. 初始化与事件订阅

  • 初始化库
    在 App 启动时,调用 RNBluetoothSerial.initialize() 进行初始化。通常可以放在最顶层组件的 useEffect 中。
  • 订阅事件
    react-native-bluetooth-serial-next 提供了多个事件回调,例如:

    • bluetoothEnabled:蓝牙已打开
    • bluetoothDisabled:蓝牙已关闭
    • deviceConnected:成功连接到设备
    • deviceDisconnected:设备断开
    • dataReceived:收到串口数据
    • error:错误回调
    import React, { useEffect, useState } from 'react';
    import { NativeEventEmitter, Platform } from 'react-native';
    import RNBluetoothSerial from 'react-native-bluetooth-serial-next';
    
    export default function useBluetoothInit() {
      const [enabled, setEnabled] = useState(false);
      const bluetoothEmitter = new NativeEventEmitter(RNBluetoothSerial);
    
      useEffect(() => {
        // 初始化
        RNBluetoothSerial.initialize()
          .then(result => {
            // result = { isEnabled: boolean }
            setEnabled(result.isEnabled);
          })
          .catch(err => console.error('初始化失败', err));
    
        // 订阅蓝牙开关事件
        const subEnabled = bluetoothEmitter.addListener('bluetoothEnabled', () => {
          console.log('蓝牙已打开');
          setEnabled(true);
        });
        const subDisabled = bluetoothEmitter.addListener('bluetoothDisabled', () => {
          console.log('蓝牙已关闭');
          setEnabled(false);
        });
    
        // 订阅数据接收
        const subData = bluetoothEmitter.addListener('dataReceived', ({ data, device }) => {
          console.log('收到数据:', data, '来自:', device.id);
        });
    
        return () => {
          subEnabled.remove();
          subDisabled.remove();
          subData.remove();
        };
      }, []);
    
      return enabled;
    }

2. 扫描设备

调用 RNBluetoothSerial.startScanning() 开始扫描,扫描时会收到 deviceFound 事件。也可以直接使用返回值:

async function scanDevices() {
  try {
    // 开始扫描,持续时间默认 15 秒
    const devices = await RNBluetoothSerial.startScanning({});
    // devices: Array<{ id: string, name: string }>
    console.log('扫描到设备列表:', devices);
    return devices;
  } catch (err) {
    console.error('扫描失败', err);
    return [];
  }
}
  • 参数说明:

    • 可以传递 { seconds: number } 指定扫描时长,单位为秒,默认为 15 秒。
  • 事件回调:

    bluetoothEmitter.addListener('deviceFound', device => {
      // device 示例:{ id: '00:11:22:33:44:55', name: 'HC-05' }
      console.log('发现新设备:', device);
    });

3. 连接设备

扫描到设备后,用户选择要连接的设备(通常根据 id 或者 name),调用 connect 方法进行连接:

async function connectToDevice(deviceId) {
  try {
    const connected = await RNBluetoothSerial.connect(deviceId);
    if (connected) {
      console.log('已连接到设备:', deviceId);
      return true;
    } else {
      console.warn('连接失败:', deviceId);
      return false;
    }
  } catch (err) {
    console.error('连接异常:', err);
    return false;
  }
}
  • 自动重连
    如果需要连接后自动重连,可以在 deviceDisconnected 事件中逻辑判断后再次调用 connectToDevice
  • 查看已配对设备
    如果想跳过扫描,直接获取手机之前已经配对过的设备,也可以调用:

    const paired = await RNBluetoothSerial.list();
    console.log('已配对设备:', paired);

    paired 的数据结构同扫描到的设备:[{ id: string, name: string }]

4. 发送数据

连接成功后,就可以调用 write 或者 writeToDevice 将数据写入对端串口。

async function sendData(text) {
  try {
    // 默认发送字符串,底层会转成 bytes 并通过 RFCOMM 发送
    await RNBluetoothSerial.write(text);
    console.log('发送成功:', text);
  } catch (err) {
    console.error('发送失败:', err);
  }
}
  • 写入示例

    // 发送 “LED_ON\n” 给 HC-05 模块
    sendData('LED_ON\n');
  • 写入 Buffer
    如果你想发送二进制数据,也可以传入 base64 字符串或字节数组,这里我们一般直接发 ASCII 即可。

5. 接收数据

5.1 通过事件监听

在前面初始化时,我们已经订阅了 dataReceived 事件,当设备端通过串口发送数据时,该回调会触发:

bluetoothEmitter.addListener('dataReceived', ({ data, device }) => {
  // data 为字符串,通常包含 \r\n 等换行符
  console.log(`从 ${device.id} 收到:`, data);
  // 你可以根据业务需求进行解析,例如:
  // const parsed = data.trim().split(',');
  // console.log('解析后的数组:', parsed);
});
5.2 主动读取缓存

如果你不想使用事件,也可以主动调用 readreadFromDevice,读取设备端发来的缓存数据(不过推荐使用事件):

async function readData() {
  try {
    const buffer = await RNBluetoothSerial.read();
    console.log('主动读取到数据:', buffer);
    return buffer;
  } catch (err) {
    console.warn('读取失败:', err);
    return '';
  }
}

6. 断开与清理

当不再需要通信时,务必调用 disconnect 来释放资源,并取消相关事件监听,防止内存泄漏。

async function disconnectDevice() {
  try {
    await RNBluetoothSerial.disconnect();
    console.log('已断开连接');
  } catch (err) {
    console.error('断开失败', err);
  }
}
  • 事件取消(在组件卸载时):

    useEffect(() => {
      // 假设在初始化时添加了三个 listener:subEnabled、subDisabled、subData
      return () => {
        subEnabled.remove();
        subDisabled.remove();
        subData.remove();
      };
    }, []);

完整示例:一个简单的串口控制页面

下面给出一个完整的 React Native 页面示例,包含扫描、展示设备列表、连接、发送指令、接收数据,并用图解的方式标注关键步骤。

1. 项目结构

MyBluetoothApp/
├─ android/
├─ ios/
├─ src/
│  ├─ components/
│  │   └─ DeviceItem.js
│  ├─ screens/
│  │   └─ BluetoothScreen.js
│  └─ App.js
├─ package.json
└─ ...
  • App.js:入口文件,导航到 BluetoothScreen
  • BluetoothScreen.js:实现 Bluetooth 扫描、连接、收发逻辑。
  • DeviceItem.js:展示单个设备的列表项。

2. 组件代码

2.1 DeviceItem.js(设备列表项)

// src/components/DeviceItem.js
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';

export default function DeviceItem({ device, onPress }) {
  return (
    <TouchableOpacity style={styles.itemContainer} onPress={() => onPress(device)}>
      <Text style={styles.deviceName}>{device.name || '未知设备'}</Text>
      <Text style={styles.deviceId}>{device.id}</Text>
    </TouchableOpacity>
  );
}

const styles = StyleSheet.create({
  itemContainer: {
    padding: 12,
    borderBottomWidth: 0.5,
    borderColor: '#ccc',
  },
  deviceName: {
    fontSize: 16,
    fontWeight: '500',
  },
  deviceId: {
    fontSize: 12,
    color: '#666',
    marginTop: 4,
  },
});

2.2 BluetoothScreen.js(核心逻辑)

// src/screens/BluetoothScreen.js
import React, { useEffect, useState, useRef } from 'react';
import { View, Text, FlatList, Button, TextInput, TouchableOpacity, StyleSheet, Alert, Platform } from 'react-native';
import RNBluetoothSerial from 'react-native-bluetooth-serial-next';
import { PermissionsAndroid } from 'react-native';
import DeviceItem from '../components/DeviceItem';

export default function BluetoothScreen() {
  const [isEnabled, setIsEnabled] = useState(false);            // 蓝牙是否打开
  const [devices, setDevices] = useState([]);                   // 扫描到的设备列表
  const [connectingId, setConnectingId] = useState(null);       // 正在连接的设备 id
  const [connectedId, setConnectedId] = useState(null);         // 已连接设备 id
  const [logData, setLogData] = useState('');                   // 接收数据的日志
  const [inputText, setInputText] = useState('');               // 要发送的数据

  const bluetoothEmitter = useRef(new RNBluetoothSerial.BluetoothEventEmitter()).current;

  // 1. 初始化及订阅
  useEffect(() => {
    async function init() {
      // 申请 Android 权限
      if (Platform.OS === 'android') {
        const granted = await requestAndroidPermissions();
        if (!granted) {
          Alert.alert('权限不足', '缺少蓝牙或定位权限,无法进行扫描。');
          return;
        }
      }

      // 初始化蓝牙
      try {
        const result = await RNBluetoothSerial.initialize();
        setIsEnabled(result.isEnabled);
      } catch (err) {
        console.error('初始化异常:', err);
      }

      // 订阅蓝牙开关事件
      bluetoothEmitter.addListener('bluetoothEnabled', () => {
        setIsEnabled(true);
      });
      bluetoothEmitter.addListener('bluetoothDisabled', () => {
        setIsEnabled(false);
      });

      // 订阅连接事件
      bluetoothEmitter.addListener('deviceConnected', ({ device }) => {
        setConnectedId(device.id);
        setConnectingId(null);
        Alert.alert('连接成功', `已连接:${device.name}`);
      });
      bluetoothEmitter.addListener('deviceDisconnected', ({ device }) => {
        if (device.id === connectedId) {
          setConnectedId(null);
          Alert.alert('断开连接', `设备 ${device.name} 已断开`);
        }
      });

      // 订阅接收数据
      bluetoothEmitter.addListener('dataReceived', ({ data, device }) => {
        setLogData(prev => prev + `\n[${device.name || device.id}] ${data}`);
      });
    }

    init();

    return () => {
      // 注销事件监听
      bluetoothEmitter.removeAllListeners('bluetoothEnabled');
      bluetoothEmitter.removeAllListeners('bluetoothDisabled');
      bluetoothEmitter.removeAllListeners('deviceConnected');
      bluetoothEmitter.removeAllListeners('deviceDisconnected');
      bluetoothEmitter.removeAllListeners('dataReceived');
    };
  }, []);

  // 2. 扫描设备
  const scanDevices = async () => {
    try {
      setDevices([]); // 清空旧列表
      const list = await RNBluetoothSerial.startScanning({ seconds: 8 });
      setDevices(list);
    } catch (err) {
      console.error('扫描失败:', err);
    }
  };

  // 3. 连接设备
  const connectDevice = async (device) => {
    setConnectingId(device.id);
    try {
      const ok = await RNBluetoothSerial.connect(device.id);
      if (!ok) {
        setConnectingId(null);
        Alert.alert('连接失败', `无法连接到 ${device.name}`);
      }
    } catch (err) {
      setConnectingId(null);
      console.error('连接异常:', err);
    }
  };

  // 4. 发送数据
  const sendData = async () => {
    if (!connectedId) {
      Alert.alert('未连接', '请先连接设备');
      return;
    }
    try {
      await RNBluetoothSerial.write(inputText + '\r\n');
      setLogData(prev => prev + `\n[我] ${inputText}`);
      setInputText('');
    } catch (err) {
      console.error('发送异常:', err);
    }
  };

  // 5. 断开连接
  const disconnectDevice = async () => {
    try {
      await RNBluetoothSerial.disconnect();
      setConnectedId(null);
      setLogData(prev => prev + '\n[系统] 已断开连接');
    } catch (err) {
      console.error('断开失败:', err);
    }
  };

  return (
    <View style={styles.container}>
      <Text style={styles.title}>React Native 蓝牙串口通信 Demo</Text>
      <View style={styles.section}>
        <Text>蓝牙状态:{isEnabled ? '已开启' : '未开启'}</Text>
        <Button title="扫描设备" onPress={scanDevices} disabled={!isEnabled} />
      </View>

      <FlatList
        style={styles.list}
        data={devices}
        keyExtractor={item => item.id}
        renderItem={({ item }) => (
          <DeviceItem
            device={item}
            onPress={connectDevice}
            style={{
              backgroundColor: item.id === connectingId ? '#e0f7fa' : '#fff',
            }}
          />
        )}
        ListEmptyComponent={() => <Text style={styles.emptyText}>暂无扫描到设备</Text>}
      />

      {connectedId && (
        <View style={styles.section}>
          <Text>已连接:{connectedId}</Text>
          <TextInput
            style={styles.input}
            placeholder="请输入要发送的串口数据"
            value={inputText}
            onChangeText={setInputText}
          />
          <Button title="发送数据" onPress={sendData} />
          <View style={{ height: 10 }} />
          <Button title="断开连接" color="#e53935" onPress={disconnectDevice} />
        </View>
      )}

      <View style={styles.logContainer}>
        <Text style={styles.logTitle}>通信日志:</Text>
        <Text style={styles.logText}>{logData}</Text>
      </View>
    </View>
  );
}

// Android 动态申请权限
async function requestAndroidPermissions() {
  const permissions = [];
  if (Platform.Version >= 31) {
    permissions.push(PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN);
    permissions.push(PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT);
    permissions.push(PermissionsAndroid.PERMISSIONS.BLUETOOTH_ADVERTISE);
  }
  permissions.push(PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION);

  try {
    const granted = await PermissionsAndroid.requestMultiple(permissions);
    return Object.values(granted).every(status => status === PermissionsAndroid.RESULTS.GRANTED);
  } catch (err) {
    console.warn('权限申请异常:', err);
    return false;
  }
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 12, backgroundColor: '#fafafa' },
  title: { fontSize: 20, fontWeight: '600', marginBottom: 12 },
  section: { marginVertical: 8 },
  list: { flex: 1, marginVertical: 8, borderWidth: 0.5, borderColor: '#ddd', borderRadius: 6 },
  emptyText: { textAlign: 'center', color: '#999', padding: 20 },
  input: {
    borderWidth: 0.8,
    borderColor: '#ccc',
    borderRadius: 4,
    paddingHorizontal: 8,
    paddingVertical: 4,
    marginVertical: 8,
  },
  logContainer: {
    flex: 1,
    marginTop: 12,
    padding: 8,
    borderWidth: 0.5,
    borderColor: '#ccc',
    borderRadius: 4,
    backgroundColor: '#fff',
  },
  logTitle: { fontWeight: '500', marginBottom: 4 },
  logText: { fontSize: 12, color: '#333' },
});

2.3 App.js(简单导航或直接渲染)

// src/App.js
import React from 'react';
import BluetoothScreen from './screens/BluetoothScreen';

export default function App() {
  return <BluetoothScreen />;
}

图解说明

  1. 初始化与事件订阅流程

    ┌──────────────────────────┐
    │ App 启动                 │
    │  → requestAndroidPermissions() │
    │  → RNBluetoothSerial.initialize() │
    └─────────────┬────────────┘
                  │
                  ▼
        注册事件监听:bluetoothEnabled / bluetoothDisabled / deviceConnected / dataReceived…
  2. 扫描 & 列表展示

    用户点击 “扫描设备” 
        ↓
    RNBluetoothSerial.startScanning({seconds:8}) 
        ↓
    onSuccess 返回设备数组,更新 state → FlatList 渲染列表
        ↓
    用户可点击某项设备,触发 connectDevice(item)
  3. 连接 & 数据交换

    用户点击设备 → connectToDevice
        ↓
    RNBluetoothSerial.connect(deviceId) → 建立 RFCOMM 连接
        ↓
    订阅 deviceConnected 事件 → 更新 connectedId
        ↓
    // 发送数据
    用户输入文本 → 点击 “发送数据” → write(input + '\r\n')
        ↓
    HC-05 通过串口(UART)收到数据 → Arduino 处理 → 可能通过 UART 回复
        ↓
    HC-05 将回复通过蓝牙 SPP 发送回手机 → 触发 dataReceived 事件 → 更新 logData
  4. 断开连接

    用户点击 “断开连接” 
        ↓
    RNBluetoothSerial.disconnect() 
        ↓
    触发 deviceDisconnected 事件 → 清空 connectedId

以上图解帮助你对蓝牙串口通信的时序与流程有更直观的认识。


注意事项与常见问题

  1. Classic 蓝牙 vs BLE

    • 本库使用 Classic Bluetooth SPP(串口协议),并非 BLE(Bluetooth Low Energy)。BLE 需要另用 react-native-ble-plx 等库。
    • SPP 可以直接当作串口,方便 Arduino、STM32 等微控制器通信。
  2. 蓝牙名称可能为 null / 空字符串

    • 某些设备出于隐私或低功耗考虑,名称可能为空,只能通过 MAC 地址判断。建议在界面上同时展示 id(MAC)与 name,并加以提示。
  3. Android 12+ 权限问题

    • Android 12(API 31)之后,扫描 需要 BLUETOOTH_SCAN连接 需要 BLUETOOTH_CONNECT。同时,扫描通常还需要定位权限。
    • 如需在后台扫描,可能还需要 ACCESS_BACKGROUND_LOCATION
  4. iOS 系统弹窗

    • 如果缺少 Info.plist 中对应的键,iOS 会直接导致崩溃或拒绝蓝牙请求。务必检查是否填写了 NSBluetoothAlwaysUsageDescriptionNSLocationWhenInUseUsageDescription 等项。
  5. 连接超时、重连逻辑

    • 某些设备在配对后并非立刻能够连接成功,如果连接失败,建议在 catch 中加上重试。
    • 也可在 deviceDisconnected 回调中,根据具体需求自动重连(谨慎使用,避免死循环重连)。
  6. 数据格式与换行

    • 大多数串口设备以 \r\n 作为一条指令或数据结束符,发送时建议在末尾加上换行符。也可以在 initialize() 时传递 分隔符 让库自动做数据分片。
    • 如果接收乱码,请确认手机蓝牙与设备蓝牙波特率、数据位、停止位、校验位等是否匹配(通常 HC-05 默认 9600、8N1)。
  7. 调试方式

    • 串口调试助手:在 Windows 或 Mac 上使用串口助手(如 SecureCRT、PuTTY、CoolTerm 等)先调试 HC-05 与 Arduino,确保指令逻辑正常。
    • 日志打印:React Native 层面可开启 adb logcat(Android)或 Xcode 控制台,定位蓝牙模块的连接/断开/异常。

总结

本文从安装、配置、原理到完整代码示例,详细讲解了如何使用 react-native-bluetooth-serial-next(也称 React Native Bluetooth Serial)实现蓝牙串口通信。核心流程包括:

  1. 初始化:申请权限 → 初始化库 → 订阅蓝牙事件
  2. 扫描:调用 startScanning,获取设备列表
  3. 连接:调用 connect,建立 RFCOMM 连接
  4. 收发:通过 writedataReceived 完成数据交换
  5. 断开:调用 disconnect,释放资源

通过以上步骤,你可以在 React Native 应用中快速搭建一个蓝牙串口调试或控制界面。例如,控制 Arduino 上的 LED 开关、读取传感器数据、远程控制舵机等。

扩展思考:

  • 如果项目后续对低功耗、广播查询等需求增多,可考虑使用 BLE 并结合 react-native-ble-plx
  • 如果需要在后台持续扫描或连接,需要结合原生模块做更多权限及生命周期管理。

希望这篇详细的图文+代码示例文章,能帮助你更快速地上手 React Native 蓝牙串口通信,打造自己的“蓝牙神器”!

‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌React 事件系统深度剖析

在现代 Web 开发中,React 提供了一套完整、统一的事件系统,使得开发者可以用相同的方式处理浏览器的原生事件,无需担心不同浏览器间的兼容性问题。本文将从 合成事件(Synthetic Event)事件委托(Event Delegation)事件池(Event Pooling)事件传播(Propagation)Hook 中的事件使用 等多个方面,进行系统性的深度剖析,并辅以代码示例与 ASCII 图解帮助你快速理解 React 事件系统的核心原理和实战要点。


一、概述:为什么需要 React 的事件系统

  1. 跨浏览器兼容
    不同浏览器的原生事件存在细节差异,例如事件对象属性、事件名称(click vs onclick)等。React 将各种浏览器的原生事件封装为统一接口,开发者只需记住一套 API 就能适配所有主流浏览器。
  2. 性能优化:事件委托
    在传统 DOM 操作中,为每个元素单独绑定事件,会随着元素数量增多而带来更多内存和性能开销。React 通过事件委托机制,在顶层统一监听事件,然后再根据触发元素分发,让事件绑定更加高效。
  3. 一致性和可控性
    React 的合成事件模拟了 W3C 标准的事件行为,规范了事件对象的属性和方法(如 event.preventDefault()event.stopPropagation() 等),简化了处理逻辑。同时,React 在批量更新时对事件进行了“批量合并”与“延迟触发”,保证了状态更新的一致性。

下面将从事件对象本身、委托机制、生命周期到实战用例,为你逐步揭开 React 事件系统的面纱。


二、合成事件(Synthetic Event)

2.1 合成事件的定义与作用

React 并不直接将浏览器原生的 MouseEventKeyboardEventTouchEvent 等暴露给组件,而是对它们进行“包装”,形成一个统一的 SyntheticEvent 对象。它具有以下特点:

  • 跨浏览器一致性
    SyntheticEvent 将各浏览器的不同实现内聚到一个共用接口,开发者无需关心 event.targetevent.keyCode 等在不同浏览器中的表现差异。
  • 性能优化:事件池
    默认情况下,React 会回收 SyntheticEvent 对象以减少内存分配。当事件回调执行完毕后,事件对象的内部属性会被重置,事件对象本身会被放入事件池重新利用。
  • 附加便利方法
    React 会在 SyntheticEvent 上附加一些便捷的方法或属性,例如:

    • event.nativeEvent:对应浏览器原生事件对象。
    • event.persist():取消事件池回收,保留完整事件对象,常用于异步处理。

在 React 组件中,我们通常通过 onClick={handleClick}onChange={handleChange}onKeyDown={handleKeyDown} 等方式接收 SyntheticEvent

2.2 代码示例:基本合成事件用法

import React from 'react';

function App() {
  const handleClick = (event) => {
    // event 是一个 SyntheticEvent 实例
    console.log('事件类型:', event.type); // 通常返回 'click'
    console.log('触发元素:', event.target); // 真实的 DOM 元素
    console.log('原生事件:', event.nativeEvent); // 浏览器原生事件对象

    // 示例:阻止默认行为(若是<a>标签等)
    event.preventDefault();

    // 示例:停止事件传播
    event.stopPropagation();
  };

  return (
    <div>
      <button onClick={handleClick}>点击我</button>
    </div>
  );
}

export default App;

2.2.1 event.preventDefault()event.stopPropagation()

  • preventDefault()
    阻止事件的默认行为,例如 <a> 标签的跳转、表单的提交等。
  • stopPropagation()
    阻止事件向上冒泡或向下捕获,只有当前节点的事件处理器会被执行。

2.3 事件池(Event Pooling)

出于性能考虑,React 在事件回调执行完毕后,会将合成事件对象放回“事件池”中,用于后续的重新分配。事件池机制意味着在事件处理函数执行完毕后,事件对象的属性就可能会被重置:

import React from 'react';

function App() {
  const handleClick = (event) => {
    console.log(event.type); // 正常输出 'click'
    
    setTimeout(() => {
      console.log(event.type); 
      // 可能输出 null 或者抛出 “Cannot read property 'type' of null”
      // 因为事件对象已被回收
    }, 100);

    // 如果想在异步中使用事件,必须先调用 event.persist()
    event.persist(); 
    setTimeout(() => {
      console.log(event.type); // 依然是 'click'
    }, 100);
  };

  return <button onClick={handleClick}>点击我</button>;
}
  • event.persist()
    调用之后,React 不再回收该事件对象,允许我们在异步函数中继续读取其属性。缺点是会导致对象无法复用,增加内存开销,所以应谨慎使用,仅在确实需要异步访问事件对象时才调用。

2.4 合成事件的常见类型

React 支持大多数浏览器常见的事件类型,常见分组如下:

  • 鼠标事件(Mouse Events)
    onClick, onDoubleClick, onMouseDown, onMouseUp, onMouseEnter, onMouseLeave, onMouseMove, …
  • 键盘事件(Keyboard Events)
    onKeyDown, onKeyUp, onKeyPress
  • 表单事件(Form Events)
    onChange, onInput, onSubmit, onFocus, onBlur
  • 触摸事件(Mobile Touch Events)
    onTouchStart, onTouchMove, onTouchEnd, onTouchCancel
  • 指针事件(Pointer Events)
    onPointerDown, onPointerMove, onPointerUp, onPointerCancel
  • 拖放事件(Drag Events)
    onDrag, onDragStart, onDragEnd, onDrop
  • 焦点事件(Focus Events)
    onFocus, onBlur
  • 其他事件
    onScroll, onWheel, onContextMenu, onError, onLoad, …

通常我们只需关注其中常用的几种,根据业务场景灵活选择。


三、事件委托与事件分发机制

3.1 传统 DOM 中的事件绑定 vs React 中的事件委托

传统 DOM操作中,开发者往往在需要的 DOM 元素上直接绑定事件处理器,例如:

const btns = document.querySelectorAll('button');
btns.forEach((btn) => {
  btn.addEventListener('click', () => {
    console.log('按钮被点击');
  });
});

当页面中有成百上千个可点击元素时,需要逐个绑定回调,既影响性能,又难以维护。‍

React 为了统一和优化,采用了一种“事件委托”的方式:

  1. React 会在最顶层的根 DOM 节点(通常是 document 或者 root 容器)上,仅添加一套事件监听器。
  2. 当子节点发生事件(如 click)时,浏览器会先触发捕获阶段、自身阶段,再到冒泡阶段。在冒泡到根节点时,React 拦截并获取原生事件。
  3. React 通过 event.target(或 event.currentTarget)来确定具体触发事件的子元素,然后在对应的组件上触发相应的回调。

这样,只需要一套顶层监听,大大减少了事件绑定数量,实现了“统一分发”。

3.1.1 事件委托示意 ASCII 图

┌─────────────────────────────────────────────────────────────┐
│                     React 根容器 (root)                     │
│  ┌-------------------------------------------------------┐  │
│  │         document.addEventListener('click', ...)      │  │
│  └-------------------------------------------------------┘  │
│           ▲                  ▲                 ▲          │
│           │                  │                 │          │
│      冒泡阶段             冒泡阶段           冒泡阶段       │
│           │                  │                 │          │
│  ┌────────┴───────┐  ┌───────┴────────┐  ┌─────┴────────┐   │
│  │        组件A     │  │     组件B        │  │    组件C       │   │
│  │   <button>     │  │   <input>       │  │  <div>        │   │
│  └────────┬───────┘  └───────┬────────┘  └─────┬────────┘   │
│           │                  │                 │          │
│        用户点击            用户点击           用户点击      │
│           ▼                  ▼                 ▼          │
│   浏览器触发原生 click     浏览器触发原生 click    浏览器触发原生 click │
└─────────────────────────────────────────────────────────────┘
  1. 用户在某个子组件上触发原生 click
  2. 事件一路向上冒泡到根容器,由根容器上唯一的 click 监听器捕获并转交给 React。
  3. React 根据冒泡链,找到触发事件的子组件实例,将合成事件分发给对应的 props.onClick 回调。

3.2 React 内部的事件分发流程

  1. 注册阶段
    当 React 元素渲染时,如果 JSX 中存在 onClick={...} 等事件属性,React 在内部记录下该组件对应的事件类型和回调函数。并确保在最顶层绑定了相应的原生事件监听器(如 document.addEventListener('click', dispatchEvent))。
  2. 捕获与分发阶段

    • 浏览器原生事件触发后,冒泡到根节点,React 的顶层监听器收到原生事件,创建一个对应的 SyntheticEvent
    • React 会根据事件触发时的 event.target(真实 DOM 节点)在其虚拟 DOM 树(fiberNode)中找到对应的组件实例。
    • 按照 React 自身的“事件冒泡/捕获”顺序(通常只支持冒泡),依次执行从目标组件到根组件路径上注册的回调。
  3. 清理阶段

    • 当事件处理结束后,React 可能会将 SyntheticEvent 放入事件池,或者在批量更新阶段进行状态更新并重新渲染组件树。

四、深入事件传播:冒泡、捕获与阻止传播

4.1 捕获阶段(Capture Phase)与冒泡阶段(Bubble Phase)

尽管 React 只支持“冒泡”阶段的事件绑定,但在底层实现中也对事件捕获阶段做了简单处理。标准的事件传播分为三个阶段:

  1. 捕获阶段(Capture)
    事件从根节点沿层级向下传播到目标节点,但 React 默认不监听捕获阶段。
  2. 目标阶段(Target)
    事件抵达触发元素本身,React 在此阶段会执行目标元素上注册的回调。
  3. 冒泡阶段(Bubble)
    事件从目标元素沿层级向上传播到根节点,React 会在此阶段执行沿途父组件上注册的回调。

在 React 中,若要监听“捕获”阶段的事件,可以使用 onClickCaptureonMouseDownCapture 等以 Capture 结尾的属性。例如:

<div onClickCapture={() => console.log('捕获阶段')} onClick={() => console.log('冒泡阶段')}>
  <button>点击我</button>
</div>

当点击 <button> 时,控制台输出:

捕获阶段
冒泡阶段

4.2 event.stopPropagation()event.nativeEvent.stopImmediatePropagation()

  • event.stopPropagation()
    在 React 合成事件中调用,阻止当前事件继续向上传播到父组件的合成事件处理器。
  • event.nativeEvent.stopImmediatePropagation()
    直接作用于原生事件,阻止原生事件的后续监听器执行(包括在 React 之外的监听器)。应谨慎使用,避免与 React 事件系统产生冲突。

4.3 代码示例:事件传播控制

import React from 'react';

function App() {
  const handleParentClick = () => {
    console.log('父组件 click 回调');
  };

  const handleChildClick = (event) => {
    console.log('子组件 click 回调');
    // 阻止冒泡到父组件
    event.stopPropagation();
  };

  return (
    <div onClick={handleParentClick} style={styles.parent}>
      <div onClick={handleChildClick} style={styles.child}>
        点击我(子组件)
      </div>
    </div>
  );
}

const styles = {
  parent: {
    width: '200px',
    height: '200px',
    backgroundColor: '#f0f0f0',
    display: 'flex',
    justifyContent: 'center',
    alignItems: 'center',
  },
  child: {
    width: '100px',
    height: '100px',
    backgroundColor: '#87ceeb',
    display: 'flex',
    justifyContent: 'center',
    alignItems: 'center',
  },
};

export default App;
  • 点击子组件,控制台只会输出 子组件 click 回调,因为 event.stopPropagation() 阻止了冒泡。
  • 如果去掉 event.stopPropagation(),点击子组件时会先输出 子组件 click 回调,然后输出 父组件 click 回调

五、合成事件与原生事件的区别

5.1 合成事件池的重用机制

React 通过事件池(会复用 SyntheticEvent 对象)来减少对象分配,节省内存。在事件回调执行完毕后,React 会将事件对象内部的所有属性重置为 null,并放回池中。下一次相同类型的事件发生时,React 会重用该对象。

// 伪代码示意:React 内部如何进行事件回收
function handleNativeEvent(nativeEvent) {
  let syntheticEvent = eventPool.length
    ? eventPool.pop()
    : new SyntheticEvent();

  syntheticEvent.initialize(nativeEvent);
  dispatchEventToReactComponents(syntheticEvent);
  // 回收,清空属性
  syntheticEvent.destructor();
  eventPool.push(syntheticEvent);
}

5.2 何时取消池化:event.persist()

如果在异步逻辑中想保留事件对象(例如在 setTimeoutPromise.then 中)访问事件属性,必须先调用 event.persist() 取消池化。否则访问 event.typeevent.target 等属性时,可能会报 null 或者 “已被回收” 的错误。

import React from 'react';

function App() {
  const handleClick = (event) => {
    event.persist(); // 取消事件池化
    setTimeout(() => {
      console.log('事件类型:', event.type); // 依然可以访问
      console.log('触发元素:', event.target);
    }, 100);
  };

  return <button onClick={handleClick}>延迟访问事件</button>;
}

export default App;

六、React Hook 中使用事件注意事项

函数组件Hook 时代,我们常常把事件处理函数写在组件内部,而不是类组件的 this.handleClick。需要注意以下几点:

  1. 事件回调中的闭包问题
    当把事件处理函数定义在组件内部且依赖某些状态时,若没有加上适当的依赖,可能会导致“闭包捕获旧值”问题。例如:

    import React, { useState, useEffect } from 'react';
    
    function Counter() {
      const [count, setCount] = useState(0);
    
      const handleClick = () => {
        // 这个回调捕获了初始的 count 值(0)
        setTimeout(() => {
          alert('当前 count 值为:' + count);
        }, 1000);
      };
    
      return (
        <div>
          <p>count: {count}</p>
          <button onClick={() => setCount(count + 1)}>++</button>
          <button onClick={handleClick}>延迟弹出 count</button>
        </div>
      );
    }

    如果先点击“++”,count 变为 1 后,再点击“延迟弹出 count”,弹窗依然会显示 “0” 而不是最新值 “1”。原因是 handleClick 函数中的 count 被闭包捕获了初始值。

    解决方法

    • handleClick 写成带有最新 count 的回调:

      const handleClick = () => {
        setTimeout(() => {
          // React 每次渲染都会创建新的 handleClick,因此访问到的 count 永远是最新的
          alert('当前 count 值为:' + count);
        }, 1000);
      };
    • 或者使用 useRef 保存最新值:

      const countRef = useRef(count);
      useEffect(() => {
        countRef.current = count;
      }, [count]);
      
      const handleClick = () => {
        setTimeout(() => {
          alert('当前 count 值为:' + countRef.current);
        }, 1000);
      };
  2. useEffect 或回调函数中访问事件对象
    如果在 useEffectPromise.thensetTimeout 等异步逻辑中访问事件对象,必须先 event.persist()。否则事件对象会在回调执行前被回收。

    function App() {
      const handleClick = (event) => {
        event.persist();
        Promise.resolve().then(() => {
          console.log(event.type); // 依然可访问
        });
      };
    
      return <button onClick={handleClick}>点击我</button>;
    }
  3. useCallback 缓存事件回调
    若需要对事件回调进行依赖收集,以减少不必要的重新创建,可以使用 useCallback。例如:

    const handleClick = useCallback((event) => {
      console.log('点击了', event.target);
    }, []); // 空依赖,仅创建一次

    这样在组件多次渲染时,handleClick 引用不会改变,有助于优化子组件 shouldComponentUpdateReact.memo 的判断。


七、事件高级应用场景

7.1 高阶组件中的事件传递与增强

有时我们需要对现有事件进行增强或统一处理,可通过 HOC(高阶组件)包装原组件,实现“事件劫持”或“事件埋点”。示例如下:

import React from 'react';

// HOC:为组件注入点击埋点逻辑
function withClickTracker(WrappedComponent) {
  return function ClickTracker(props) {
    const handleClick = (event) => {
      console.log('[埋点] 点击发生,组件:', WrappedComponent.name, '元素:', event.target);
      // 保证原有 onClick 回调仍然能运行
      props.onClick && props.onClick(event);
    };

    // 重新传递 onClick,覆盖原有
    return <WrappedComponent {...props} onClick={handleClick} />;
  };
}

// 原始按钮组件
function Button(props) {
  return <button {...props}>{props.children}</button>;
}

const TrackedButton = withClickTracker(Button);

function App() {
  return (
    <div>
      <TrackedButton onClick={() => alert('按钮被点击')}>埋点按钮</TrackedButton>
    </div>
  );
}

export default App;
  • withClickTracker 会拦截 WrappedComponentonClick,先做埋点再执行原回调。
  • 通过 HOC 能以最小侵入的方式为点击事件添加统一逻辑(如统计、日志、权限校验等)。

7.2 自定义事件名与事件委托

有时我们需要在容器上统一监听某种“自定义行为”,并动态判断触发元素。例如,实现一个点击容器内任何含 data-action 属性的元素,就触发某逻辑的需求。示例如下:

import React, { useRef, useEffect } from 'react';

function App() {
  const containerRef = useRef(null);

  useEffect(() => {
    const handleClick = (event) => {
      const action = event.target.getAttribute('data-action');
      if (action) {
        console.log('触发自定义行为:', action);
      }
    };

    const container = containerRef.current;
    container.addEventListener('click', handleClick);

    // 清理
    return () => {
      container.removeEventListener('click', handleClick);
    };
  }, []);

  return (
    <div ref={containerRef} style={styles.container}>
      <button data-action="save">保存</button>
      <button data-action="delete">删除</button>
      <button>普通按钮</button>
    </div>
  );
}

const styles = {
  container: {
    border: '1px solid #ccc',
    padding: '20px',
  },
};

export default App;
  • 虽然这里没有使用 React 的合成事件,而是直接在 DOM 上注册原生事件。但思路与 React 的事件委托相似:只在容器上注册一次监听,然后根据 event.target 判断是否触发自定义行为。
  • 在 React 中也可写成 <div onClick={handleClick}>…</div>,由于 React 统一在根节点做了事件委托,性能同样优越。

7.3 处理事件冒泡与嵌套组件

当某些父组件和子组件都需要响应同一个事件时,需要注意以下几点:

// Scenario: 父组件与子组件都监听 onClick
function Parent() {
  const handleParentClick = () => {
    console.log('父组件点击');
  };

  return (
    <div onClick={handleParentClick} style={styles.parent}>
      <Child />
    </div>
  );
}

function Child() {
  const handleChildClick = (event) => {
    console.log('子组件点击');
    // 如果希望同时执行父组件的回调,不要调用 stopPropagation
  };

  return <button onClick={handleChildClick}>点击我</button>;
}
  • 默认情况下,点击子组件时会先输出 子组件点击,然后输出 父组件点击
  • 如果在子组件中调用了 event.stopPropagation(),则会阻止事件继续冒泡到父组件。

八、ASCII 图解:React 事件分发与委托

下面用 ASCII 图示描述 React 在浏览器中的事件分发流程,从 React 组件中的事件绑定到最终的浏览器原生事件捕获,再回传到组件实例并执行回调。

┌────────────────────────────────────────────────────────────────────────┐
│                              浏览器 DOM 树                              │
│  ┌──────────────────────────────────────────────────────────────────┐ │
│  │                            document                               │ │
│  │  ┌────────────────────────────────────────────────────────────┐  │ │
│  │  │                   React 根节点 (root DOM)                     │  │ │
│  │  │  ┌──────────────────────────────────────────────────────┐    │  │ │
│  │  │  │   <div onClick={handleParentClick}>                 │    │  │ │
│  │  │  │  ┌────────────────────────────────────────────────┐  │    │  │ │
│  │  │  │  │ <button onClick={handleChildClick}>            │  │    │  │ │
│  │  │  │  │  “点击我” 文本                                 │  │    │  │ │
│  │  │  │  └────────────────────────────────────────────────┘  │    │  │ │
│  │  │  │                                                      │    │  │ │
│  │  │  └──────────────────────────────────────────────────────┘    │  │ │
│  │  └────────────────────────────────────────────────────────────┘  │ │
│  └──────────────────────────────────────────────────────────────────┘ │
│                                                                        │
│      用户点击 “点击我” 按钮 → 浏览器原生 click 事件触发 → 冒泡到 root 节点   │
│                                                                        │
│      React 在根节点的全局监听器捕获原生事件,创建 SyntheticEvent 对象       │
│                                                                        │
│      React 寻找对应的组件路径:button → div → root → ...                │
│                                                                        │
│      React 在 SyntheticEvent 上先执行 target 阶段(child 对应的回调)       │
│        └─ 执行 handleChildClick                                        │
│                                                                        │
│      若未阻止冒泡(stopPropagation),React 继续在冒泡阶段调用父组件回调      │
│        └─ 执行 handleParentClick                                       │
│                                                                        │
│      完成后,SyntheticEvent 放入事件池,等待下次复用                         │
└────────────────────────────────────────────────────────────────────────┘
  • 从上图可见,React 的 全局顶层监听 + 路径查找 + 合成事件生成,实现了“统一绑定、按需分发”的高效机制。
  • 每次事件都会生成一个新的 SyntheticEvent(从池里取或新创建),执行完毕后被回收。

九、常见问题与最佳实践

  1. 何时使用 event.persist()

    • 当你需要在异步函数中访问事件对象时(如 setTimeoutPromise.thensetImmediate),必须先调用 event.persist()。否则事件对象会在回调执行前被重置。
    • 但是请避免无意义地调用 persist(),因为它会导致事件对象无法回收,增加内存压力。
  2. 如何优化大量列表元素的事件监听?

    • 利用 React 自身的事件委托,只需在根节点或片段最外层绑定一次事件,避免在每个列表项上重复绑定。
    • 如果某个列表项需要单独事件回调,可在渲染时将同一个处理函数作为 onClick 传入,不要使用匿名箭头函数,避免不断创建新函数。
  3. 钩子函数捕获旧值的问题

    • useEffectsetTimeoutPromise 等异步场景中,事件回调中的变量会被闭包“冻结”为当时的值。
    • 可通过将回调包裹在 useCallback 中并添加相应依赖,或者使用 useRef 保存最新值来解决。
  4. 阻止默认行为与原生 API 的区别

    • event.preventDefault() 是阻止 React 合成事件中对应的默认行为(最终会作用于浏览器原生事件)。
    • 如果想阻止浏览器原生事件的后续监听器执行,需要调用 event.nativeEvent.stopImmediatePropagation()。但这是一个“后门”方法,应谨慎使用。
  5. 在 React 中使用原生事件监听时机

    • 使用 useEffect 在组件挂载时手动注册原生事件监听(如 window.addEventListener('scroll', handleScroll)),并在卸载时移除。
    • 注意与 React 合成事件的顺序,避免重复触发或冲突。例如,若同一元素既有 onClick 又用 addEventListener('click', …),触发顺序会有所不同,需在调试时明确优先级。

十、总结

本文从以下几个方面对 React 事件系统 进行了深度剖析:

  1. 合成事件(Synthetic Event)

    • 统一跨浏览器 API,提供与原生事件一致的接口。
    • 事件池优化,减少频繁创建对象、节约内存。
    • event.persist() 取消池化,保留完整事件对象用于异步访问。
  2. 事件委托(Event Delegation)

    • React 在根节点统一监听并分发事件,大幅减少事件绑定数量。
    • 通过 event.target 与组件 fiber 树关联,定位真正的触发源并调用相应回调。
  3. 事件传播(Propagation)

    • 支持捕获(onClickCapture)与冒泡(onClick)阶段的事件绑定。
    • event.stopPropagation()event.nativeEvent.stopImmediatePropagation() 的区别与使用场景。
  4. Hook 环境下的事件使用注意

    • 及时处理闭包捕获的旧状态值问题。
    • 异步操作中访问事件需先调用 event.persist()
    • 可配合 useCallbackuseRef 保证事件回调与最新状态同步。
  5. 高级实战示例

    • HOC 中统一劫持、埋点事件。
    • 原生事件与合成事件联动。
    • 响应式、可扩展的自定义事件分发方案。

通过这篇文章,你应该对 React 事件系统的内部机制有了全面且深入的理解。在实际项目中,可以利用这些原理和技巧,编写更简洁、高效且易维护的事件处理逻辑,让你的交互体验更加流畅、代码更具可读性。

# ‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌React Native Flexbox 布局:轻松构建用户界面

在 React Native 中,Flexbox 是最常用的布局方案,它基于 CSS Flexbox 规范,却针对移动端做了轻量化调整。通过学习 Flexbox,开发者可以在不同屏幕尺寸、不同设备方向下,快速构建灵活、响应式的界面布局。本文将深入解析 React Native Flexbox 布局的核心概念与常用属性,并通过代码示例与 ASCII 图解帮助你更直观地理解如何“轻松”使用 Flexbox 构建用户界面。

---

## 目录

1. [Flexbox 核心概念](#一-flexbox-核心概念)  
   1. [Flexbox 基础术语](#11-flexbox-基础术语)  
   2. [React Native 中的默认设置](#12-react-native-中的默认设置)  
2. [主要布局属性详解](#二-主要布局属性详解)  
   1. [`flexDirection`](#21-flexdirection)  
   2. [`justifyContent`](#22-justifycontent)  
   3. [`alignItems`](#23-alignitems)  
   4. [`flex`](#24-flex)  
   5. [`alignSelf`](#25-alignself)  
   6. [`flexWrap`](#26-flexwrap)  
   7. 边距与尺寸:`width`、`height`、`margin`、`padding`  
3. [实战示例:构建常见布局](#三-实战示例构建常见布局)  
   1. [示例一:水平导航栏](#31-示例一水平导航栏)  
   2. [示例二:两列布局](#32-示例二两列布局)  
   3. [示例三:等分布局](#33-示例三等分布局)  
   4. [示例四:响应式网格布局](#34-示例四响应式网格布局)  
4. [ASCII 图解:Flexbox 布局流程](#四-ascii-图解-flexbox-布局流程)  
5. [常见疑问与最佳实践](#五-常见疑问与最佳实践)  
6. [总结](#六-总结)  

---

## 一、Flexbox 核心概念

### 1.1 Flexbox 基础术语

- **容器(Container)**  
  带有 `display: 'flex'` 或者在 React Native 中默认即为 Flex 容器(无需显式设置)。容器是子项布局的上下文。  
- **项目(Item)**  
  容器内部的直接子元素,负责根据容器的 Flex 属性进行排列与伸缩。  
- **主轴(Main Axis)**  
  决定项目排列方向的一条轴。在 React Native 中,`flexDirection: 'row'` 时主轴为水平方向;`flexDirection: 'column'` 时主轴为垂直方向。  
- **交叉轴(Cross Axis)**  
  与主轴垂直的一条轴。当主轴为水平时,交叉轴为垂直;主轴为垂直时,交叉轴为水平。  
- **主轴起点 / 终点(Main Start / Main End)**  
  在主轴方向的起始与末尾,比如 `row` 模式下,起点为左侧,终点为右侧;`column` 模式下,起点为顶部,终点为底部。  
- **交叉轴起点 / 终点(Cross Start / Cross End)**  
  在交叉轴方向的起始与末尾,比如主轴为水平方向时,起点为顶部,终点为底部。

### 1.2 React Native 中的默认设置

在 React Native 中,所有 `View` 默认就是一个 Flex 容器,默认情况下:

```js
<View style={{ flexDirection: 'column' }}>
  {/* 子项会垂直排列 */}
</View>
  • 默认 flexDirection'column',即项目沿垂直方向从上到下排列。
  • 默认 justifyContent: 'flex-start',项目会从容器的起点(顶部)开始依次排列。
  • 默认 alignItems: 'stretch',项目会在交叉轴方向拉伸以填满容器。

你可以在任何容器 style 中覆盖这些默认值,以实现个性化布局。


二、主要布局属性详解

2.1 flexDirection

flexDirection 用于设置项目在容器内沿主轴的排列方向。可选值:

  • 'column'(默认):主轴垂直向下,项目从上到下排列。
  • 'column-reverse':主轴垂直向上,项目从下到上排列。
  • 'row':主轴水平向右,项目从左到右排列。
  • 'row-reverse':主轴水平向左,项目从右到左排列。
// 示例:四个项目沿水平方向排列
<View style={{ flexDirection: 'row' }}>
  <View style={styles.box} />
  <View style={styles.box} />
  <View style={styles.box} />
  <View style={styles.box} />
</View>
// 示例:四个项目沿垂直反向排列
<View style={{ flexDirection: 'column-reverse' }}>
  <View style={styles.box} />
  <View style={styles.box} />
  <View style={styles.box} />
  <View style={styles.box} />
</View>
const styles = StyleSheet.create({
  box: {
    width: 50,
    height: 50,
    margin: 4,
    backgroundColor: 'skyblue',
  },
});

举例:

  1. 如果要创建一个底部导航栏,可使用 flexDirection: 'row' 将图标按钮从左至右排列。
  2. 如果要实现一个聊天列表,默认 column 就能使消息从顶部依次向下显示。

2.2 justifyContent

justifyContent 决定项目沿主轴方向的对齐方式。可选值:

  • 'flex-start'(默认):项目从主轴起点开始依次紧挨排列。
  • 'flex-end':项目从主轴终点开始依次紧挨排列。
  • 'center':项目在主轴上居中对齐。
  • 'space-between':项目之间平分剩余空间,首尾项目靠近容器两端。
  • 'space-around':项目两侧(两边)平分剩余空间,包含首尾。
  • 'space-evenly':项目之间平等分配剩余空间,包括首尾与项目之间。
// 示例:justifyContent 不同取值的效果
<View style={{ flexDirection: 'row', justifyContent: 'space-between', padding: 10 }}>
  <View style={styles.box} />
  <View style={styles.box} />
  <View style={styles.box} />
</View>
  • justifyContent: 'space-between' 时,三个项目第一个会靠左,最后一个会靠右,中间项目自动分散到等间距位置。

2.3 alignItems

alignItems 控制项目沿交叉轴方向的对齐方式。可选值:

  • 'flex-start':项目在交叉轴起点对齐(如主轴为水平时,交叉轴起点为顶部)。
  • 'flex-end':项目在交叉轴终点对齐(如主轴为水平时,交叉轴终点为底部)。
  • 'center':项目在交叉轴上居中对齐。
  • 'stretch'(默认):项目拉伸以填满交叉轴,若项目有固定尺寸则不会拉伸。
  • 'baseline':项目沿文字基线对齐,仅对文本或行内元素生效。
// 示例:alignItems 不同取值
<View style={{ flexDirection: 'row', alignItems: 'center', height: 100 }}>
  <View style={[styles.box, { height: 30 }]} />
  <View style={[styles.box, { height: 50 }]} />
  <View style={[styles.box, { height: 70 }]} />
</View>
  • alignItems: 'center' 时,即使项目高度不同,都会在容器高度的中心位置对齐。

2.4 flex

flex 是项目可以占据剩余空间的比例。它是 flexGrowflexShrinkflexBasis 三个属性的组合简写。常用值:

  • flex: 1:项目会占据所有剩余空间(在同一行/列中的所有 flex:1 项目平均分配空间)。
  • flex: 2:若同一行/列中有另一个项目 flex:1,则 flex:2 项目占据空间为后者的两倍。
// 示例:两个子项目以 2:1 的比例分配剩余宽度
<View style={{ flexDirection: 'row', height: 80 }}>
  <View style={{ flex: 2, backgroundColor: 'tomato' }} />
  <View style={{ flex: 1, backgroundColor: 'skyblue' }} />
</View>

在上述示例中,如果父容器宽度为 300px,则第一个项目宽度为 200px,第二个为 100px。

2.5 alignSelf

alignSelf 用于覆盖单个项目在交叉轴方向的对齐方式,优先级高于容器的 alignItems。可选值与 alignItems 一致:'auto''flex-start''flex-end''center''stretch''baseline'

// 示例:某个项目覆盖 alignItems 设置
<View style={{ flexDirection: 'row', alignItems: 'flex-start', height: 100 }}>
  <View style={[styles.box, { height: 30 }]} />
  <View style={[styles.box, { height: 50, alignSelf: 'flex-end' }]} />
  <View style={[styles.box, { height: 70 }]} />
</View>
  • 在上述示例中,虽然容器 alignItems: 'flex-start',第二个项目通过 alignSelf: 'flex-end' 将自身对齐到底部。

2.6 flexWrap

flexWrap 控制当主轴方向空间不足时,项目是否换行。可选值:

  • 'nowrap'(默认):不换行,项目会挤在一行/列中,可能会被压缩或溢出。
  • 'wrap':允许换行,会根据剩余空间换到下一行/列。
  • 'wrap-reverse':允许换行,但换行顺序与正向相反。
// 示例:flexWrap 设置
<View style={{ flexDirection: 'row', flexWrap: 'wrap', width: 150 }}>
  {Array.from({ length: 6 }).map((_, i) => (
    <View key={i} style={[styles.box, { width: 60, height: 60 }]} />
  ))}
</View>
  • 在上述示例中,父容器宽度为 150px,每个小方块宽度为 60px,三个方块后剩余空间不足,第 4 个自动换行。

2.7 边距与尺寸:widthheightmarginpadding

  • width / height:用于给容器或项目指定固定宽度/高度;如果不指定,会根据 flexalignItems: 'stretch' 等自动拉伸。
  • margin / marginLeft / marginRight / marginTop / marginBottom:用于项目或容器的外边距,影响与其他元素之间的间距。
  • padding / paddingHorizontal / paddingVertical / paddingLeft / paddingRight / paddingTop / paddingBottom:用于项目或容器的内边距,影响子元素与容器边框之间的空白。
// 示例:margin 与 padding
<View style={{ flexDirection: 'row', padding: 10 }}>
  <View style={[styles.box, { marginRight: 8 }]} />
  <View style={styles.box} />
</View>

三、实战示例:构建常见布局

下面通过几个常见场景示例,将上面讲解的属性组合运用起来,帮助你更快构建实际项目中的布局。

3.1 示例一:水平导航栏

需求:在顶部创建一个水平导航栏,包含三个按钮或图标,等间距分布,并垂直居中对齐。

// src/components/TopNavBar.js

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

export default function TopNavBar() {
  return (
    <View style={styles.navContainer}>
      <TouchableOpacity style={styles.navItem}>
        <Text style={styles.navText}>首页</Text>
      </TouchableOpacity>
      <TouchableOpacity style={styles.navItem}>
        <Text style={styles.navText}>分类</Text>
      </TouchableOpacity>
      <TouchableOpacity style={styles.navItem}>
        <Text style={styles.navText}>我的</Text>
      </TouchableOpacity>
    </View>
  );
}

const styles = StyleSheet.create({
  navContainer: {
    height: 50,
    flexDirection: 'row',
    justifyContent: 'space-around', // 等间距分布
    alignItems: 'center',            // 垂直居中
    backgroundColor: '#f8f8f8',
    borderBottomWidth: 1,
    borderBottomColor: '#ddd',
  },
  navItem: {
    paddingHorizontal: 10,
    paddingVertical: 5,
  },
  navText: {
    fontSize: 16,
    color: '#333',
  },
});
  • flexDirection: 'row':将导航项目水平排列。
  • justifyContent: 'space-around':导航按钮会平均分布,间距相等。
  • alignItems: 'center':按钮文字在导航栏高度中间对齐。

3.2 示例二:两列布局

需求:将屏幕分为左右两列,左侧占 30%,右侧占 70%。左侧可用于侧边菜单或图片展示,右侧用于主要内容。

// src/screens/TwoColumnLayout.js

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

export default function TwoColumnLayout() {
  return (
    <View style={styles.container}>
      <View style={styles.leftColumn}>
        <Text style={styles.columnText}>左侧区域 (30%)</Text>
      </View>
      <View style={styles.rightColumn}>
        <Text style={styles.columnText}>右侧区域 (70%)</Text>
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    flexDirection: 'row', // 主轴为水平
  },
  leftColumn: {
    flex: 3, // 左侧占 3 份
    backgroundColor: '#add8e6',
    justifyContent: 'center',
    alignItems: 'center',
  },
  rightColumn: {
    flex: 7, // 右侧占 7 份
    backgroundColor: '#90ee90',
    justifyContent: 'center',
    alignItems: 'center',
  },
  columnText: {
    fontSize: 18,
    color: '#333',
  },
});
  • 父容器使用 flexDirection: 'row'
  • 左侧与右侧分别为 flex: 3flex: 7,即左右宽度比为 3:7,共 10 份。
  • justifyContent: 'center'alignItems: 'center' 使文本在各列中居中显示。

3.3 示例三:等分布局

需求:在一行中创建四个等宽的方块,无论屏幕多宽,每个方块宽度都相等。

// src/screens/FourEqualBoxes.js

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

export default function FourEqualBoxes() {
  return (
    <View style={styles.container}>
      <View style={styles.box} />
      <View style={styles.box} />
      <View style={styles.box} />
      <View style={styles.box} />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flexDirection: 'row',
    height: 100,
  },
  box: {
    flex: 1,             // 四个项目都为 flex:1,平均分配水平空间
    margin: 4,           // 每个之间留 4px 间距
    backgroundColor: '#ff8c00',
  },
});
  • 只要在容器中放置四个 flex: 1 的项目,它们就会均分父容器的宽度(考虑 margin 留白)。
  • 这样可以轻松实现响应式的等分布局,无需手动计算宽度。

3.4 示例四:响应式网格布局

需求:以网格方式展示一组图片或商品列表,每行显示两个项目,支持换行。

// src/screens/ResponsiveGrid.js

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

const { width } = Dimensions.get('window');
const ITEM_MARGIN = 8;
const ITEM_WIDTH = (width - ITEM_MARGIN * 3) / 2; 
// 两列布局:左侧间距 8 + 中间间距 8 + 右侧间距 8 = 24px

export default function ResponsiveGrid() {
  const items = Array.from({ length: 6 }).map((_, i) => `Item ${i + 1}`);

  return (
    <View style={styles.container}>
      {items.map((label, idx) => (
        <View key={idx} style={styles.gridItem}>
          <Text style={styles.itemText}>{label}</Text>
        </View>
      ))}
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flexDirection: 'row',
    flexWrap: 'wrap',     // 支持换行
    padding: ITEM_MARGIN,
  },
  gridItem: {
    width: ITEM_WIDTH,
    height: ITEM_WIDTH,    // 保持正方形
    margin: ITEM_MARGIN / 2,
    backgroundColor: '#87ceeb',
    justifyContent: 'center',
    alignItems: 'center',
  },
  itemText: {
    fontSize: 16,
    color: '#fff',
  },
});
  • 计算:ITEM_WIDTH = (屏幕宽度 - 三段 ITEM_MARGIN) / 2,保证两列之间的间距一致。
  • flexWrap: 'wrap' 允许项目在不能放入当前行时自动移到下一行。
  • 每个项目都设置相同宽高比例,可实现“响应式正方形网格”。

四、ASCII 图解:Flexbox 布局流程

为了更直观地理解 Flexbox 在 React Native 中的布局流程,下面用 ASCII 图示说明主轴与交叉轴上的空间分配逻辑。

示例:justifyContent: 'space-between', alignItems: 'center', flexDirection: 'row'

┌──────────────────────────────────────────────────────────────────┐
│                     父容器 (宽度 = 320)                           │
│   flexDirection: row                                              │
│   justifyContent: space-between                                   │
│   alignItems: center                                               │
│                                                                    │
│   可用宽度 = 320                                                   │
│   子项目宽度 = 60, 60, 60                                           │
│   剩余空间 = 320 - (3 x 60) = 140                                   │
│                                                                    │
│   两个间隙均分:140 / 2 = 70                                        │
│                                                                    │
│   ┌──────────────────────────┬──────────────────────┬──────────────────────────┐ │
│   │ 子项目1 (宽=60 高=40)     │ 子项目2 (宽=60 高=40) │ 子项目3 (宽=60 高=40)     │ │
│   │   (左侧间隙 = 0)          │   (左侧间隙 = 70)     │   (左侧间隙 = 70)         │ │
│   │   (右侧间隙 = 70)         │   (右侧间隙 = 70)     │   (右侧间隙 = 0)          │ │
│   └──────────────────────────┴──────────────────────┴──────────────────────────┘ │
│        ▲                         ▲                      ▲                        │
│        │                         │                      │                        │
│   主轴方向                     主轴方向                主轴方向                   │
│        ↓                         ↓                      ↓                        │
│   纵向对齐: alignItems: center                                   │
│   三个子项的垂直中心都对应父容器中心                            │
└──────────────────────────────────────────────────────────────────┘
  • 在这个示例中,父容器宽度为 320px,三个子项各自为 60px。
  • space-between 会将剩余空间(140px)均匀分为两段放在项目间隙;
  • alignItems: 'center' 会使子项在父容器的垂直方向中间对齐。

五、常见疑问与最佳实践

  1. 为什么 React Native 默认使用 flexDirection: 'column' 而非 row

    • 移动端屏幕更狭长,垂直滚动更为常见;默认垂直布局更符合移动场景。
  2. 如何垂直水平同时居中一个子组件?

    <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
      <View style={{ width: 100, height: 100, backgroundColor: 'salmon' }} />
    </View>
    • justifyContent: 'center' 在主轴居中(默认主轴垂直),alignItems: 'center' 在交叉轴居中(水平)。
  3. 当子元素设置了固定宽度,高度如何自动调整?

    • 如果子元素设置了宽度但未设置高度,则其高度由内容撑开,或如果与容器交叉轴的 alignItems: 'stretch',则会拉伸为与容器交叉轴相同高度。
  4. 如何让子元素在容器内末尾对齐?

    • 设置 justifyContent: 'flex-end',让项目沿主轴末尾排列;或 alignItems: 'flex-end' 使其沿交叉轴末尾排列。
  5. Flexbox 性能优化

    • 避免在大量动态渲染列表项(如 FlatList)中大量使用嵌套的 Flexbox 布局,可通过合理合并和减少嵌套层级提高性能。
    • 在列表中尽量给项目设置固定宽高,减少动画或布局计算的开销。
  6. 调试布局问题

    • 在调试阶段,可临时给容器或子项设置不同背景色,快速观察 Flexbox 生效情况。
    • React Native Debugger 或 Flipper 插件中可以查看元素布局边界,辅助定位问题。

六、总结

本文系统讲解了 React Native 中 Flexbox 布局的核心概念与各个常用属性,包括:

  • flexDirection:主轴方向决定项目排列是水平还是垂直,以及是否反向。
  • justifyContent:项目在主轴方向上的对齐方式,如居中、等间距分布等。
  • alignItems:项目在交叉轴方向上的对齐方式,如居中、拉伸等。
  • flex:项目对剩余空间的占比,用于响应式布局。
  • alignSelf:单个项目在交叉轴上覆盖父容器对齐方式。
  • flexWrap:当项目超出主轴长度时是否换行。
  • 尺寸与边距:如何通过 widthheightmarginpadding 完善布局。

通过四个实战示例(水平导航栏、两列布局、等分布局、响应式网格布局),你应该能够灵活运用 Flexbox 属性,快速构建各种常见且响应式的界面。同时,借助 ASCII 图解,能更直观地理解 Flexbox 在不同属性组合下如何分配空间。

当你在项目中充分掌握了 Flexbox 基础后,可以结合 FlatListScrollViewPosition: 'absolute' 等其他布局方案,打造更加丰富且高效的移动端界面。希望本文能帮助你“轻松”入门并精通 React Native Flexbox 布局,快速提升 UI 布局能力。

# ‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌ESLint+Prettier:双剑合璧,优化 React Native 开发体验

在 React Native 项目中,保持代码风格一致、及时发现潜在错误对于提高开发效率和代码质量至关重要。**ESLint** 和 **Prettier** 是目前最常用的两款工具,前者负责静态代码分析并规范代码质量,后者负责统一代码格式并减少“样式讨论”带来的时间浪费。本文将从原理、安装配置、实战示例和图解流程四个方面,全面讲解如何将 ESLint 与 Prettier 在 React Native 项目中“双剑合璧”,以优化开发体验。

---

## 目录

1. [工具概览与核心原理](#一-工具概览与核心原理)  
   1. [ESLint:静态代码分析与代码规范](#11-eslint静态代码分析与代码规范)  
   2. [Prettier:自动代码格式化](#12-prettier自动代码格式化)  
   3. [为何要同时使用 ESLint 与 Prettier](#13-为何要同时使用-eslint-与-prettier)  
2. [React Native 项目中安装与配置](#二-react-native-项目中安装与配置)  
   1. [初始化 React Native 项目](#21-初始化-react-native-项目)  
   2. [安装 ESLint 与相关插件](#22-安装-eslint-与相关插件)  
   3. [安装 Prettier 与相关插件](#23-安装-prettier-与相关插件)  
   4. [集成 ESLint + Prettier 配置示例](#24-集成-eslint--prettier-配置示例)  
3. [实战示例:代码格式化与代码检查](#三-实战示例代码格式化与代码检查)  
   1. [示例文件:有格式和风格问题的组件](#31-示例文件有格式和风格问题的组件)  
   2. [使用 Prettier 一键格式化](#32-使用-prettier-一键格式化)  
   3. [使用 ESLint 检查并修复](#33-使用-eslint-检查并修复)  
   4. [VSCode 编辑器中实时集成](#34-vscode-编辑器中实时集成)  
4. [ASCII 图解:ESLint + Prettier 工作流程](#四-ascii-图解-eslint--prettier-工作流程)  
5. [进阶:CI 集成与 Hook 预提交检查](#五-进阶-ci-集成与-hook-预提交检查)  
   1. [CI 环境中自动执行 ESLint 与 Prettier](#51-ci-环境中自动执行-eslint-与-prettier)  
   2. [Husky + lint-staged 预提交检查示例](#52-husky--lint-staged-预提交检查示例)  
6. [常见问题与最佳实践](#六-常见问题与最佳实践)  
7. [总结](#七-总结)  

---

## 一、工具概览与核心原理

### 1.1 ESLint:静态代码分析与代码规范

**ESLint**(“E S Lint”)是 JavaScript 领域最流行的静态代码分析工具,通过定义“规则”来检查代码质量、避免潜在错误,并可对某些简单问题进行自动修复。ESLint 的核心原理如下:

- **基于 AST(抽象语法树)**:ESLint 先将源代码解析为 AST,然后针对节点树进行规则匹配。  
- **可插拔规则**:社区提供大量规则包(如 `eslint-plugin-react`、`eslint-plugin-react-native`),可以根据项目需要进行定制。  
- **自动修复**:某些规则支持 `--fix` 模式,ESLint 会在符合约定的地方自动修正代码。  
- **配置层级**:可在项目根目录下 `.eslintrc.js`、`.eslintrc.json` 等文件中写入自定义配置,或继承社区预设(如 `eslint-config-airbnb`)。  

在 React Native 场景中,常见 ESLint 插件与规则包括:  
- `eslint-plugin-react`:针对 React 代码风格和最佳实践。  
- `eslint-plugin-react-native`:React Native 特有 API 的使用限制(例如 `StyleSheet.create` 强制样式定义方式)。  
- `eslint-plugin-import`:管理 `import` 路径合法性,检测未使用或错误导入。  
- `eslint-plugin-jsx-a11y`:检测无障碍相关问题(可选)。  

### 1.2 Prettier:自动代码格式化

**Prettier** 是一款“**Opinionated**”的代码格式化工具,意味着它在格式化规则上并不提供过多的可定制项,而是以“最常见的”或“业界约定俗成”的格式为默认标准。其核心特点:  
- **一键格式化**:只需要运行 `prettier --write`,就能自动将代码调整为统一风格,比如缩进、引号、分号、换行位置等。  
- **配置简单**:通过 `.prettierrc` 文件可配置 `tabWidth`、`singleQuote`、`trailingComma` 等少量选项。  
- **多语言支持**:不仅支持 JavaScript、JSX,还支持 TypeScript、JSON、CSS、Markdown 等多种文件类型。  
- **与 ESLint 不冲突**:Prettier 专注于格式化,ESLint 专注于代码质量检查,可以通过插件让二者协同工作。  

### 1.3 为何要同时使用 ESLint 与 Prettier

- **职责分工不同**:ESLint 着重“**代码质量**”和“**潜在错误**”(如未使用变量、无法识别的 API、潜在逻辑错误或规范问题),而 Prettier 关注“**代码格式**”层面(对齐、缩进、换行、逗号位置等)。  
- **减少摩擦**:如果仅使用 ESLint 的格式化规则(如 `eslint --fix`),需要大量自定义规则才能与团队风格保持一致,成本高且易出争议。Prettier 以“零配置”著称,适合绝大多数团队快速统一格式。  
- **互相补充**:Prettier 解决“风格争议”,ESLint 解决“代码错误与规范”。两者结合后,开发者可以专注功能开发,无需纠结格式;CI 环境可以更简单地做“质量门槛”把控。  

---

## 二、React Native 项目中安装与配置

### 2.1 初始化 React Native 项目

假设你已经在本地安装了 Node.js、Yarn/ npm 以及 React Native CLI。可以使用以下命令快速初始化一个基础项目:

```bash
# 使用 React Native CLI 创建项目
npx react-native init RNESLintPrettierDemo
cd RNESLintPrettierDemo

项目目录结构示例:

RNESLintPrettierDemo/
├── android/
├── ios/
├── node_modules/
├── src/
│   └── App.js
├── .gitignore
├── App.js
├── package.json
└── ...

我们将以此项目为基础,添加 ESLint 和 Prettier。

2.2 安装 ESLint 与相关插件

在项目根目录下,执行下面命令安装 ESLint 及常用插件(以使用 Yarn 为例):

# 安装 ESLint 核心
yarn add -D eslint

# 安装 React、React Native 专用插件与扩展
yarn add -D eslint-plugin-react eslint-plugin-react-native eslint-plugin-import eslint-plugin-jsx-a11y

# 选择一个社区规则集(可选,例如 Airbnb)
yarn add -D eslint-config-airbnb eslint-plugin-jsx-a11y eslint-plugin-import eslint-plugin-react eslint-plugin-react-hooks

# 如果使用 TypeScript,还需安装
# yarn add -D @typescript-eslint/parser @typescript-eslint/eslint-plugin

安装完成后,在根目录创建 .eslintrc.js(或 .eslintrc.json),示例内容如下:

// .eslintrc.js
module.exports = {
  // 指定解析器选项,支持最新 ECMAScript 语法
  parserOptions: {
    ecmaVersion: 2021,
    sourceType: 'module',
    ecmaFeatures: {
      jsx: true,
    },
  },

  // 环境配置:React Native 默认会使用 ES6、Node、JSX 全局变量
  env: {
    browser: true,
    es6: true,
    'react-native/react-native': true,
  },

  // 继承社区规则:这里以 Airbnb 为示例,也可自行定制
  extends: [
    'airbnb',                  // Airbnb JavaScript 风格
    'plugin:react/recommended', // React 推荐规则
    'plugin:react-native/all',  // React Native 推荐规则
  ],

  // 使用 React 插件与 React Native 插件
  plugins: ['react', 'react-native', 'import', 'jsx-a11y'],

  // 全局变量(根据项目需要自行定义)
  globals: {
    __DEV__: 'readonly', // React Native 全局 __DEV__
  },

  // 自定义规则:可根据团队风格进行微调
  rules: {
    // 允许在 JSX 中使用 .js 文件扩展名
    'react/jsx-filename-extension': [1, { extensions: ['.js', '.jsx'] }],
    // 关闭 React 17 以后自动导入 React 的错误提示
    'react/react-in-jsx-scope': 'off',
    // 允许使用 console.log,仅在开发环境
    'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
    // 关闭 prop-types 检测(若不使用 propTypes)
    'react/prop-types': 'off',
    // React Native 中允许使用 inline styles
    'react-native/no-inline-styles': 'off',
    // 禁止使用未使用的变量
    'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
    // 导入排序规则(可自行选择是否启用)
    'import/order': [
      'warn',
      {
        groups: [['builtin', 'external'], ['internal'], ['parent', 'sibling', 'index']],
        'newlines-between': 'always',
        alphabetize: { order: 'asc', caseInsensitive: true },
      },
    ],
  },

  settings: {
    react: {
      version: 'detect', // 自动检测 React 版本
    },
    'import/resolver': {
      node: {
        extensions: ['.js', '.jsx'],
      },
    },
  },
};

关键说明

  1. parserOptions.ecmaFeatures.jsx:开启对 JSX 语法的支持。
  2. extends:使用社区知名的规则集,如 airbnbplugin:react/recommendedplugin:react-native/all,保证规范度。
  3. plugins:声明要使用的插件列表,这里包括 react-nativereactimportjsx-a11y
  4. rules:对部分规则进行覆盖或关闭,根据项目需要自定义。
  5. settingsimport/resolver 配置,用于解决导入路径识别。

2.3 安装 Prettier 与相关插件

同样在项目根目录下执行以下命令,安装 Prettier 及 ESLint 与 Prettier 协同的插件:

# 安装 Prettier
yarn add -D prettier

# 安装 ESLint 与 Prettier 集成插件
yarn add -D eslint-plugin-prettier eslint-config-prettier

# 若使用 VSCode,可安装 Prettier 插件:esbenp.prettier-vscode
  • eslint-plugin-prettier:将 Prettier 作为 ESLint 规则,当代码不符合 Prettier 格式时,ESLint 将报错或警告。
  • eslint-config-prettier:关闭所有与 Prettier 冲突的 ESLint 规则,保证二者不重复发号施令。

根目录创建 .prettierrc.js.prettierrc.json,示例内容:

// .prettierrc.js
module.exports = {
  printWidth: 100,           // 单行最大长度
  tabWidth: 2,               // 缩进宽度
  useTabs: false,            // 使用空格缩进
  semi: true,                // 末尾使用分号
  singleQuote: true,         // 使用单引号
  trailingComma: 'all',      // 尾随逗号(包括对象、数组、函数参数等)
  bracketSpacing: true,      // 对象字面量属性是否留空格:{ foo: bar }
  arrowParens: 'always',     // 箭头函数参数总是带括号
  jsxSingleQuote: false,     // JSX 中使用双引号
  proseWrap: 'never',        // Markdown 不自动折行
  endOfLine: 'auto',         // 根据系统使用 LF 或 CRLF
};

同时,为了让 ESLint 与 Prettier 协同工作,需要更新前面创建的 .eslintrc.js 中的 extendsplugins 项:

// .eslintrc.js
module.exports = {
  ...
- extends: [
-   'airbnb',
-   'plugin:react/recommended',
-   'plugin:react-native/all',
- ],
+ extends: [
+   'airbnb',
+   'plugin:react/recommended',
+   'plugin:react-native/all',
+   'plugin:prettier/recommended', // 将 Prettier 作为最后一个扩展
+ ],

  plugins: ['react', 'react-native', 'import', 'jsx-a11y', 'prettier'],

  rules: {
    ...
+   // 当代码与 Prettier 规则冲突时,报错
+   'prettier/prettier': 'error',
  },
  ...
};

关键说明

  1. plugin:prettier/recommended

    • 作用等同于同时引入 eslint-plugin-prettiereslint-config-prettier,并将 prettier/prettier 规则设为 error
    • 必须放在 extends 数组最后,确保 Prettier 覆盖其他规则冲突。
  2. 'prettier/prettier': 'error'

    • 当代码不符合 Prettier 规则时,ESLint 会报错。配合 VSCode 或其他编辑器,可以实现“保存自动修复”或“保存自动格式化”。

2.4 集成 ESLint + Prettier 配置示例

经过上面步骤,我们在项目根目录中应有以下配置文件:

RNESLintPrettierDemo/
├── .eslintrc.js
├── .prettierrc.js
├── package.json
└── ...

package.json 中可添加如下脚本方便日常使用:

{
  "scripts": {
    "lint": "eslint \"src/**/*.{js,jsx}\"",
    "lint:fix": "eslint \"src/**/*.{js,jsx}\" --fix",
    "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,css,md}\""
  }
}
  • yarn lint:检查 src 下的所有 JS/JSX 文件是否有 ESLint 错误或警告。
  • yarn lint:fix:自动修复可修复的 ESLint 问题。
  • yarn format:使用 Prettier 格式化项目中所有常见文件类型。

三、实战示例:代码格式化与代码检查

3.1 示例文件:有格式和风格问题的组件

假设在 src/components/Counter.js 中有如下示例文件,存在混乱缩进、不规范引号、缺少分号、多个空行等问题:

// src/components/Counter.js

import React, {useState} from 'react'
import {View,Text,Button,StyleSheet} from 'react-native'


  const Counter = () => {

    const [count,setCount] = useState(0)

    const  increment =  () => {
        setCount(count + 1)
    }

    const decrement=()=>{
      setCount(count - 1)
    }

    return (
      <View style={styles.container}>
        <Text style={styles.text}>Count: {count}</Text>
        <View style={styles.buttonRow}>
          <Button title="-" onPress={decrement}/>
          <Button title="+" onPress={increment}/>
        </View>


      </View>
    )
  }


 const styles = StyleSheet.create({
    container:{
   flex:1, justifyContent:'center',alignItems:'center'
   },
  text:{
       fontSize: 32
      },
  buttonRow:{
    flexDirection:'row',
     justifyContent:'space-between',
      width:100
  }
})

 export default Counter

可以看到,上面的代码存在:

  • {useState} 导入时没有空格;
  • 行尾缺少分号;
  • 缩进不一致;
  • 多余空行;
  • 样式对象没有统一逗号位置与缩进;
  • export default Counter 与顶端没有留空行。

3.2 使用 Prettier 一键格式化

在项目根目录运行:

yarn format

Prettier 会依据 .prettierrc.js 中的规则,将上述文件自动格式化为一致风格,示例如下:

// src/components/Counter.js

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

const Counter = () => {
  const [count, setCount] = useState(0);

  const increment = () => {
    setCount(count + 1);
  };

  const decrement = () => {
    setCount(count - 1);
  };

  return (
    <View style={styles.container}>
      <Text style={styles.text}>Count: {count}</Text>
      <View style={styles.buttonRow}>
        <Button title="-" onPress={decrement} />
        <Button title="+" onPress={increment} />
      </View>
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
  text: {
    fontSize: 32,
  },
  buttonRow: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    width: 100,
  },
});

export default Counter;

格式化细节

  • 空格与逗号:在 import 中、函数参数与对象属性处统一加上空格,行末自动添加分号。
  • 缩进:统一使用 2 个空格缩进。
  • 多余空行:Prettier 自动删除多余的空行,并保证逻辑模块之间留一行空行分隔。
  • 箭头函数:在箭头前后自动保留空格。

这一步极大减少了“谁的缩进格式对谁的审美”的无谓争论,让团队成员能够专注于业务逻辑。

3.3 使用 ESLint 检查并修复

在格式化之后,再运行 ESLint 检查代码规范及潜在错误:

yarn lint

如果代码中存在留用的 console.log()、变量未使用、React Hook 依赖数组缺失等问题,ESLint 会展示具体报错信息。例如:

/src/components/Counter.js
  12:3  warning  Unexpected console statement  no-console
  15:10 warning  'count' is already declared in the upper scope  no-shadow
  ... 

若想自动修复简单问题(如缺少分号、单引号替换、行尾多余空格等),可执行:

yarn lint:fix

ESLint 会尝试对可修复的规则进行自动修正,修复后仅留下需要人为判断的警告或错误。

3.4 VSCode 编辑器中实时集成

为了提升开发体验,可以在 VSCode 中安装相应插件,并在配置中启用“保存自动修复”与“保存自动格式化”功能。

  1. 安装 VSCode 插件

    • ESLint 插件dbaeumer.vscode-eslint
    • Prettier 插件esbenp.prettier-vscode
  2. 在工作区或用户设置中添加如下配置(.vscode/settings.json):

    {
      // 保存时自动格式化 (Prettier)
      "editor.formatOnSave": true,
    
      // 保存时自动修复 ESLint 错误
      "editor.codeActionsOnSave": {
        "source.fixAll.eslint": true
      },
    
      // 指定默认格式化工具为 Prettier
      "[javascript]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode"
      },
      "[javascriptreact]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode"
      }
    }
    • editor.formatOnSave:启用后,保存文件即自动执行 Prettier 格式化。
    • source.fixAll.eslint:启用后,保存文件时若 ESLint 可以自动修复规则,则自动应用修复。
    • editor.defaultFormatter:在 JS/JSX 文件中使用 Prettier 作为默认格式化工具。
  3. 保存文件时的执行顺序:

    1. Prettier 自动格式化 → 2. ESLint 自动修复 → 3. 最终将格式化和修复后的结果保存到磁盘。

这样就实现了在开发过程中,“保存即得到一份既符合格式规范又符合代码质量要求”的代码,大大提高开发效率与协作体验。


四、ASCII 图解:ESLint + Prettier 工作流程

下面用简易的 ASCII 图解展示开发者编写代码到最终提交的整个流程,卷入 ESLint 与 Prettier 的协作机制。

┌───────────────────────────────────────────────┐
│             开发者在编辑器中编写代码          │
└───────────────────────────────────────────────┘
                     │
                     ▼
┌───────────────────────────────────────────────┐
│      (1) 保存触发 Prettier 格式化 → 自动调整  │
│           - 缩进统一、空格调整                 │
│           - 引号、分号、逗号位置规范           │
│         预期结果:代码层面风格一致             │
└───────────────────────────────────────────────┘
                     │
                     ▼
┌───────────────────────────────────────────────┐
│    (2) 保存触发 ESLint 自动修复 → 修复简单错误  │
│           - 修复缺少分号、多余空格等            │
│           - 修复可自动修复的规则               │
│    预期结果:无常见语法错误、风格冲突            │
└───────────────────────────────────────────────┘
                     │
                     ▼
┌───────────────────────────────────────────────┐
│       (3) 手动或 CI 执行 `yarn lint` 检查       │
│           - 报告剩余的警告与错误                │
│           - 需开发者手动修改不可自动修复的规则    │
└───────────────────────────────────────────────┘
                     │
                     ▼
┌───────────────────────────────────────────────┐
│        (4) 手动或 CI 执行 `yarn format`         │
│           - 对所有文件进行 Prettier 格式化      │
│           - 确保没有遗漏的文件                  │
└───────────────────────────────────────────────┘
                     │
                     ▼
┌───────────────────────────────────────────────┐
│          (5) 提交到 Git 仓库或触发 CI           │
│           - CI 自动再次执行 ESLint 与 Prettier   │
│           - 若有错误,可阻断合并(Quality Gate)  │
└───────────────────────────────────────────────┘
                     │
                     ▼
┌───────────────────────────────────────────────┐
│               代码质量与风格达标               │
└───────────────────────────────────────────────┘
  • 如上流程示意:开发者保存时即触发 Prettier → ESLint 自动修复,再执行人工或 CI 检查。通过“前端门禁”+“CI 护栏”两层把控,确保代码在各个阶段始终符合团队规范。

五、进阶:CI 集成与 Hook 预提交检查

在实际团队开发中,仅仅依靠开发者本地配置还不够,还应在 CI 与 Git Hook 层面做“双保险”,防止遗漏和人为疏忽。

5.1 CI 环境中自动执行 ESLint 与 Prettier

示例以 GitHub Actions 为例,在项目根目录创建 .github/workflows/lint.yml

# .github/workflows/lint.yml

name: "Lint and Format"

on:
  pull_request:
    branches: [ main ]
  push:
    branches: [ main ]

jobs:
  lint:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: 16

      - name: Install dependencies
        run: yarn install --frozen-lockfile

      - name: Run Prettier (check only)
        run: yarn prettier --check "src/**/*.{js,jsx,ts,tsx,json,css,md}"

      - name: Run ESLint
        run: yarn lint

关键说明

  1. yarn prettier --check

    • 在 CI 中使用 --check 模式,仅检查文件是否符合格式,而不进行自动写入。若格式不符则返回非零退出码,失败 CI。
  2. yarn lint

    • 仅检查 ESLint 报告中是否有 error 级别的问题。如存在,则 CI 失败,让开发者修复后再合并。
  3. 结合分支保护策略

    • 在 GitHub 的“分支保护”设置中,开启“必需通过 CI 检查”,保证任何 Pull Request 在合并前都通过上述检查。

5.2 Husky + lint-staged 预提交检查示例

为防止开发者本地提交未格式化或有质量问题的代码,可以使用 Huskylint-staged 在 Git 提交时拦截。

  1. 安装依赖:

    yarn add -D husky lint-staged
  2. package.json 中添加以下配置:

    {
      "husky": {
        "hooks": {
          "pre-commit": "lint-staged"
        }
      },
      "lint-staged": {
        "src/**/*.{js,jsx,ts,tsx}": [
          "prettier --write",
          "eslint --fix",
          "git add"
        ],
        "src/**/*.{json,css,md}": [
          "prettier --write",
          "git add"
        ]
      }
    }
  3. 初始化 Husky 钩子:

    npx husky install
    npx husky add .husky/pre-commit "npx lint-staged"

流程说明

  • lint-staged:只对本次提交的临时更改文件执行配置的命令,避免全量扫描。
  • 流程:当执行 git commit 时,Husky 拦截并调用 lint-staged

    1. 对所有被修改的 .js/.jsx/.ts/.tsx 文件依次执行 prettier --writeeslint --fixgit add,保证提交的文件已格式化并修复可自动处理的 ESLint 问题。
    2. 同时对 .json/.css/.md 文件仅执行 prettier --writegit add
    3. 如果 Prettier 或 ESLint 修复后文件仍存在问题(如 ESLint 报错不可自动修复),提交会被阻止,让开发者先手动修复。

这样就保证了“提交门槛”上有一层“双保险”:预提交钩子自动修复并防止不合规范代码进入代码库;CI 再次把关,确保质量一致。


六、常见问题与最佳实践

  1. ESLint 与 Prettier 规则冲突

    • 使用 eslint-config-prettier 可以自动关闭与 Prettier 冲突的 ESLint 规则,避免“谁说了算”的困扰。一定要将 'prettier''plugin:prettier/recommended' 放在 extends 数组最后。
  2. 配置冗余与性能问题

    • 初次集成时,如果同时启用了过多插件(如 eslint-plugin-jsx-a11yeslint-plugin-security 等),会导致 lint 扫描速度变慢。可根据项目实际需求有选择地只保留必要的插件。
  3. 编辑器自动格式化后 ESLint 报错

    • 如果 VSCode 中仅启用了 Prettier 格式化,但未自动修复 ESLint 问题,则保存后可能出现 ESLint 报错。建议同时在保存时启用 source.fixAll.eslint,二者按顺序执行:

      1. Prettier 格式化 → 2. ESLint 自动修复 → 3. 保存。
  4. 团队协作时的配置统一

    • .eslintrc.js.prettierrc.js.vscode/settings.json 等配置文件加入版本控制。
    • 在团队代码库 README 中写明“开发规范”与“约定”,帮助新人快速上手并了解 lint/format 流程。
  5. CI 环境与本地配置不一致

    • 确保 CI 与本地使用一致的 Node 版本、依赖版本(锁定 yarn.lockpackage-lock.json)。
    • 若 CI 中的 yarn lint 报错但本地不报错,检查是否本地跳过了某些文件或未安装新版依赖。
  6. 使用 TypeScript 时的注意

    • 安装 @typescript-eslint/parser@typescript-eslint/eslint-plugin,在 .eslintrc.js 中设置 parser: '@typescript-eslint/parser',并在 plugins 中添加 '@typescript-eslint'
    • Prettier 对 .ts/.tsx 文件会自动识别,需在 lint-staged 中也包含相应扩展名。

七、总结

使用 ESLint + Prettier 协同工作,可以让 React Native 项目在以下方面大幅优化:

  • 代码风格一致性:所有开发者无需再为缩进、单引号/双引号、逗号位置等小细节产生分歧。
  • 提前捕获潜在错误:ESLint 在编译前就能发现未使用变量、潜在逻辑错误或不符合团队规范的写法。
  • 开发体验提升:VSCode 中“保存即格式化+修复”让代码编辑流畅度更高,减少低级别错误的来回修改。
  • 团队协作质量保障:通过 Husky + lint-staged + CI 的多层检查,实现“代码质量门槛”自动化,减少人为疏漏。

只需几步简单配置,就能让项目从此摆脱“谁的缩进对谁的审美”的横生枝节,让团队专注于业务逻辑与产品功能的实现。希望本文提供的配置示例、图解流程与实践建议,能够帮助你快速在 React Native 项目中集成 ESLint + Prettier,打造高效、规范、优雅的开发体验。

# ‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌React Native 与 Flutter:跨平台开发对比与实战精髓

随着移动应用开发需求日益多样化,跨平台框架如 React Native 和 Flutter 成为开发者的重要选择。本文从架构原理、开发体验、性能表现、生态配套等多维度进行对比,并通过实战示例演示两者在相同业务场景下的开发方式。文章包含代码示例、ASCII 图解和详细说明,帮助你快速上手并理解两种技术的核心精髓。

---

## 目录

1. [前言](#一-前言)  
2. [架构与渲染原理对比](#二-架构与渲染原理对比)  
   1. [React Native 架构](#21-react-native-架构)  
   2. [Flutter 架构](#22-flutter-架构)  
   3. [ASCII 图解:架构对比](#23-ascii-图解架构对比)  
3. [开发体验与生态对比](#三-开发体验与生态对比)  
   1. [语言与工具链](#31-语言与工具链)  
   2. [热重载与调试](#32-热重载与调试)  
   3. [第三方生态与 UI 库](#33-第三方生态与-ui-库)  
4. [性能与表现对比](#四-性能与表现对比)  
   1. [JavaScript 桥接 vs 原生编译](#41-javascript-桥接-vs-原生编译)  
   2. [渲染帧率与动画流畅度](#42-渲染帧率与动画流畅度)  
   3. [启动速度与包体大小](#43-启动速度与包体大小)  
5. [实战示例:计数器应用](#五-实战示例计数器应用)  
   1. [需求描述](#51-需求描述)  
   2. [React Native 实现](#52-react-native-实现)  
   3. [Flutter 实现](#53-flutter-实现)  
   4. [关键代码解析](#54-关键代码解析)  
6. [UI 组件与布局对比](#六-ui-组件与布局对比)  
   1. [布局系统对比](#61-布局系统对比)  
   2. [常见组件示例](#62-常见组件示例)  
7. [平台插件与原生交互](#七-平台插件与原生交互)  
   1. [React Native Native Module](#71-react-native-native-module)  
   2. [Flutter Platform Channel](#72-flutter-platform-channel)  
   3. [示例:获取电池电量](#73-示例获取电池电量)  
8. [总结与选型建议](#八-总结与选型建议)  

---

## 一、前言

在移动开发领域,“一次编写,多端运行”是理想却也充满挑战。React Native 和 Flutter 都致力于减少多栈维护成本,但它们在底层原理、开发语言和生态系统上有显著差异。选择哪一种技术,需要综合考虑团队技能、项目需求、性能预期等多方面因素。本文通过详尽的对比与实战示例,帮助你更快理解和评估这两套方案。

---

## 二、架构与渲染原理对比

跨平台框架的核心在于如何尽可能接近原生性能,同时保证开发便捷性。本节以架构示意和渲染流程为核心,对比 React Native 与 Flutter 的实现原理。

### 2.1 React Native 架构

React Native(RN)基于 React 的组件化理念,将业务逻辑写在 JavaScript 中,通过**Bridge**与原生层沟通,最终驱动 iOS/Android 的原生 UI 组件。核心流程如下:

1. **JavaScript 线程**:运行 React 业务逻辑、Component 渲染函数,生成 React 元素树。  
2. **Bridge(桥接)**:将 JS 计算结果(创建、更新 UI 指令)序列化为 JSON,通过异步消息队列发送给原生端。  
3. **Native Shadow Tree & Yoga 布局**:原生端接收指令后,在 C++ 或 Java/Objective-C 层使用 Yoga 引擎计算布局。  
4. **UIManager**:根据布局结果,在 iOS 使用 UIKit(UIView),在 Android 使用 ViewGroup 创建、更新、删除原生视图。  
5. **事件回传**:用户输入事件(点击、触摸)由原生层捕获后使用桥返回 JS,触发 React 事件处理。

#### 2.1.1 主要组件

- **JSI & Bridge**:旧版 Bridge 使用 JSON 序列化,RN 0.60+ 可选用 JSI(JavaScript Interface)减少开销。  
- **Yoga**:Facebook 开源跨平台布局引擎,使用 Flexbox 规则。  
- **Reconciliation**:React Fiber 算法进行增量渲染和调度,决定哪些原生组件需要更新。  

### 2.2 Flutter 架构

Flutter 是 Google 开源的跨平台 UI 框架,采用自己的渲染引擎和 Skia 图形库,业务逻辑使用 Dart 语言。其架构流程如下:

1. **Dart VM/ACM**:运行 Flutter 应用的 Dart 代码,包括 Widget 树生成与状态管理。  
2. **Flutter Framework**:包括 Widget、Element、RenderObject 等层次,处理布局、绘制、手势等逻辑。  
3. **Engine(C++)**:由 C++ 编写,负责调度渲染流程、调用 Skia 做实际绘制、管理平台线程、文字渲染、JPEG/PNG 解码等。  
4. **Skia 渲染**:将所有 UI 都绘制到一个单一画布上,然后提交给底层的 EGL/OpenGL 或 Metal 进行 GPU 加速显示。  
5. **Platform Channels**:Dart 与 Native 通过 MethodChannel 互相调用,完成原生功能访问。

#### 2.2.1 主要组件

- **Widget→Element→RenderObject**:Flutter 的三层视图模型,Widget 描述 UI,Element 打包生命周期管理,RenderObject 执行实际布局与绘制。  
- **Skia**:跨平台 2D 图形引擎,让 Flutter 拥有一致且高性能的 UI 绘制能力。  
- **Dart AOT 编译**:生产环境使用 Ahead-Of-Time 编译为本机机器码,极大提高启动速度与运行时性能。

### 2.3 ASCII 图解:架构对比

下面用简单的 ASCII 图,直观展示两者的渲染流程对比。

React Native 架构流程:

┌───────────────────────────────────────────────────────────────────┐
│ JavaScript 线程 (React) │
│ ┌─────────────┐ ┌──────────┐ ┌─────────────┐ │
│ │Component │ │Reconciler│ │Bridge (JSI) │ │
│ │render() │──▶ │Diff & │──▶ │serialize │ │
│ │ │ │Schedule │ │commands │ │
│ └─────────────┘ └──────────┘ └─────┬──────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────┐ │
│ │ Native Shadow Tree (C++/Java)│ │
│ │ Yoga 布局计算 │ │
│ └──────────────┬─────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ UIManager (iOS: UIView / Android: ViewGroup) │ │
│ │ 根据 Shadow Tree 创建/更新/删除原生视图 │ │
│ └──────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────┐ │
│ │ GPU / 系统渲染管线 (OpenGL/Metal) │ │
│ └───────────────────────────┘ │
└───────────────────────────────────────────────────────────────────┘

Flutter 架构流程:

┌──────────────────────────────────────────────────────────────┐
│ Dart 线程 (Flutter Framework) │
│ ┌─────────────┐ ┌──────────────┐ ┌───────────────┐ │
│ │ Widget │ │Element │ │RenderObject │ │
│ │ build() │──▶ │生命周期管理 │──▶ │布局与绘制逻辑 │ │
│ └─────────────┘ └──────────────┘ └───────┬───────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────┐ │
│ │ Flutter Engine (C++ + Skia) │ │
│ │ - Layout & Paint 调度 │ │
│ │ - Skia 绘制到画布 │ │
│ │ - GPU / 系统渲染 (OpenGL/Metal) │ │
│ └──────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘


- React Native 依赖 JavaScript → Bridge → 原生组件;Flutter 将 UI 自上而下绘制到 Skia 画布中,不使用原生控件。  
- Flutter 的渲染完全在 C++ 层面,对于动画与高帧率场景更具优势;React Native 则需要桥接往返,复杂动画性能稍逊。

---

## 三、开发体验与生态对比

选择跨平台框架,除了性能,还要考量开发效率和生态支持。本节对比两者在语言、热重载、第三方库等方面的差异。

### 3.1 语言与工具链

| 特性          | React Native                            | Flutter                                |
| ------------- | --------------------------------------- | --------------------------------------- |
| 主要语言      | JavaScript / TypeScript                 | Dart                                    |
| 开发者门槛    | Web 前端开发者容易上手                   | 需要学习 Dart 语法与 Flutter 架构           |
| 包管理器      | npm / Yarn                              | pub (Dart 官方包管理)                    |
| IDE 支持      | VS Code、WebStorm、Xcode、Android Studio | Android Studio、VS Code、IntelliJ IDEA   |
| 构建模式      | JSBundle + 原生打包                      | AOT 编译(Release)、JIT(Debug 热重载)   |

- **JavaScript / TypeScript**  
  - React Native 使用 JavaScript,若团队已有 Web 前端经验,无缝衔接。也可选择 TypeScript 增强类型安全。  
- **Dart**  
  - Flutter 采用 Google 推出的 Dart 语言,语法类似 Java/C#,专为 UI 构建设计。需要额外学习成本,但 Dart 的强类型和面向对象特性对大型应用维护友好。

### 3.2 热重载与调试

| 特性              | React Native                                                | Flutter                                                       |
| ----------------- | ----------------------------------------------------------- | ------------------------------------------------------------- |
| 热重载 (Hot Reload) | **Fast Refresh**:仅刷新更改组件代码,无需重启应用;<br>状态保持有限制,有时会丢失状态。 | **Hot Reload**:几乎实时刷新 UI,状态保持良好;<br>也支持 Hot Restart 重启整个应用。 |
| 调试工具          | Chrome DevTools、React DevTools、Flipper、Redux DevTools     | Dart DevTools:集成 Profiler、Widget Inspector、Timeline 等 |
| 日志打印          | `console.log`、`react-native-logs` 等                         | `print()`、Dart DevTools 日志面板                              |

- React Native 的 Fast Refresh 自 RN 0.61 起稳定,可在保存文件后快速更新界面。  
- Flutter 的 Hot Reload 在 Dart VM 上运行,不会重建 VM,实现更快和更完整的状态保留。

### 3.3 第三方生态与 UI 库

| 类型          | React Native                             | Flutter                                       |
| ------------- | ----------------------------------------- | --------------------------------------------- |
| UI 组件库     | React Native Elements, NativeBase, Ant Design Mobile RN, React Native Paper 等 | Material 、Cupertino (内置),GetWidget、Flutter UI Kits 等 |
| 导航库        | React Navigation, React Native Navigation | Flutter Navigator 2.0、AutoRoute、GetX       |
| 状态管理      | Redux, MobX, Recoil, Zustand, Context API | Provider, Bloc, Riverpod, GetX, MobX          |
| 网络请求      | fetch, axios, react-native-axios         | http, dio                                      |
| 原生功能插件  | 大量开源插件:react-native-camera、react-native-firebase、react-native-push-notification | 丰富插件:camera, firebase_core, flutter_local_notifications, geolocator 等 |
| 社区活跃度    | 成熟且活跃,插件数量庞大                   | 快速增长,官方及社区插件同样丰富               |

- React Native 借助 JavaScript 社区的活跃度,第三方库种类繁多。  
- Flutter 社区近年增长迅速,官方维护的 FlutterFire、Google Maps、Camera、Firebase 等插件经过持续优化,并紧跟 Flutter 版本迭代。

---

## 四、性能与表现对比

跨平台方案的性能表现往往是选型时的重要考虑因素。本节从运行时架构、动画流畅度、启动速度和包体大小等方面对比两者表现。

### 4.1 JavaScript 桥接 vs 原生编译

- **React Native**  
  - JS 层运行在 JavaScriptCore(iOS)或 Hermes/V8(Android)中,通过 Bridge 与原生通信。双线程模型(UI 线程 + JS 线程),当信息需来回传递时,会有一定延迟。  
  - 复杂动画或大量 UI 更新时,若 Bridge 队列积压,可能造成掉帧或卡顿。  

- **Flutter**  
  - Dart 代码经 AOT 编译为本机机器码,运行在 Dart VM(Release 模式)中,无需桥接进行 UI 索引,所有 UI 都由 Flutter Engine 一次性绘制到纹理上。  
  - 单线程(UI 与逻辑共用一条线程),框架本身对渲染管线做了充分优化,动画流畅度更高,理论上可稳定维持 60FPS。

### 4.2 渲染帧率与动画流畅度

- **React Native**  
  - 动画需借助 `Animated`、`Reanimated` 等库;简单动画可使用 `useNativeDriver: true` 将动画驱动交给原生。  
  - 底层原生组件渲染机制依赖原生系统,每个平台表现略有差异。  

- **Flutter**  
  - 所有视图都由 Skia 绘制在同一个画布上,原生性能更接近原生原生应用。  
  - `Ticker` + `AnimationController` 提供细粒度动画控制,结合 `addPostFrameCallback` 能更准确地把握渲染时机。  

#### 4.2.1 实测案例:列表滚动对比

| 条件            | React Native(FlatList + 复杂Item) | Flutter(ListView.builder + 复杂Item) |
| --------------- | ------------------------------------ | -------------------------------------- |
| 列表项数量:500 | 约 55 FPS(中等规格真机)            | 稳定 60 FPS                           |
| 列表项复杂度↑  | 可能出现明显卡顿                     | 依然流畅                              |

> 注:具体表现与业务逻辑、真机型号和优化手段有关,上表仅为典型参考。

### 4.3 启动速度与包体大小

- **React Native**  
  - 启动时需加载 JavaScript bundle,解析并执行 JS。若使用 Hermes,在 Android 可预编译为 bytecode,加速解析。  
  - 包体大小通常在 6MB ~ 8MB(Release APK),再加上各类原生依赖可能更大。  

- **Flutter**  
  - 因为包含 Flutter Engine,最小 Release APK 大约在 10MB ~ 12MB。  
  - 启动速度较快,因 Dart AOT 编译已经生成本机机器码,只需加载并执行即可。  

---

## 五、实战示例:计数器应用

下面以一个简单的“计数器”应用为例,分别用 React Native 和 Flutter 实现相同功能,直观对比两者的区别与开发流程。

### 5.1 需求描述

- 显示一个数字计数器,初始值 0。  
- 点击 “增加” 按钮时,计数器加 1;点击 “减少” 按钮时,计数器减 1。  
- 计数器值同步显示在屏幕中央,并且根据值的正负、零使用不同颜色:  
  - 正数:绿色  
  - 负数:红色  
  - 零:灰色  

> 本示例仅聚焦基础 UI 与状态管理,后续可扩展更多业务逻辑。

### 5.2 React Native 实现

```jsx
// src/CounterRN.js

import React, { useState } from 'react';
import { View, Text, Button, StyleSheet, SafeAreaView } from 'react-native';

export default function CounterRN() {
  const [count, setCount] = useState(0);

  // 根据计数值返回不同颜色
  const getColor = () => {
    if (count > 0) return 'green';
    if (count < 0) return 'red';
    return 'gray';
  };

  return (
    <SafeAreaView style={styles.container}>
      <Text style={[styles.counterText, { color: getColor() }]}>{count}</Text>
      <View style={styles.buttonRow}>
        <View style={styles.buttonWrapper}>
          <Button title="减少" onPress={() => setCount((prev) => prev - 1)} />
        </View>
        <View style={styles.buttonWrapper}>
          <Button title="增加" onPress={() => setCount((prev) => prev + 1)} />
        </View>
      </View>
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center', 
    alignItems: 'center',
    backgroundColor: '#F5FCFF',
  },
  counterText: {
    fontSize: 64,
    fontWeight: 'bold',
    marginBottom: 40,
  },
  buttonRow: {
    flexDirection: 'row',
  },
  buttonWrapper: {
    marginHorizontal: 20,
    width: 100,
  },
});

5.2.1 关键说明

  1. 状态管理

    • 使用 useState 钩子保存 count 状态。
    • setCount(prev => prev ± 1) 保证基于前一个状态更新。
  2. UI 布局

    • 使用 <SafeAreaView> 兼容 iOS 刘海屏。
    • 居中显示 <Text>,并使用 styles.counterText 控制字体大小与粗细。
    • <View style={styles.buttonRow}> 使按钮横向排列,buttonWrapper 控制宽度与左右间距。
  3. 动态样式

    • style={[styles.counterText, { color: getColor() }]} 根据 count 返回不同色值。

5.3 Flutter 实现

// lib/counter_flutter.dart

import 'package:flutter/material.dart';

class CounterFlutter extends StatefulWidget {
  @override
  _CounterFlutterState createState() => _CounterFlutterState();
}

class _CounterFlutterState extends State<CounterFlutter> {
  int _count = 0;

  Color _getColor() {
    if (_count > 0) return Colors.green;
    if (_count < 0) return Colors.red;
    return Colors.grey;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('计数器示例 (Flutter)'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text('$_count',
                style: TextStyle(
                  fontSize: 64,
                  fontWeight: FontWeight.bold,
                  color: _getColor(),
                )),
            SizedBox(height: 40),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: <Widget>[
                SizedBox(
                  width: 100,
                  child: ElevatedButton(
                    onPressed: () => setState(() => _count--),
                    child: Text('减少'),
                  ),
                ),
                SizedBox(width: 20),
                SizedBox(
                  width: 100,
                  child: ElevatedButton(
                    onPressed: () => setState(() => _count++),
                    child: Text('增加'),
                  ),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

5.3.1 关键说明

  1. 状态管理

    • StatefulWidgetState 组合,实现局部可变状态 _count
    • 在事件回调中使用 setState(() => _count ±= 1) 手动触发 UI 更新。
  2. UI 布局

    • 顶层使用 Scaffold 提供页面框架,包括 AppBar
    • Center 将子组件在可用空间中居中,Column 竖直排列文本与按钮。
    • Row 让按钮横向排列,SizedBox 控制按钮宽度与间隔。
  3. 动态样式

    • TextStyle(color: _getColor()) 根据 _count 返回不同色值。

5.4 关键代码解析

功能React NativeFlutter
根容器<SafeAreaView style={styles.container}>Scaffold(body: Center(...))
文本显示<Text style={[styles.counterText, { color: getColor() }]}>{count}</Text>Text('$_count', style: TextStyle(color: _getColor()))
按钮<Button title="增加" onPress={...} />ElevatedButton(onPressed: ..., child: Text('增加'))
布局Flexbox (flexDirection: 'row')Flex 布局 (Row, Column)
状态const [count, setCount] = useState(0)_count 字段 + setState(() {})
  • 灵活性对比:React Native 直接使用标准 HTML-like 组件和 Flexbox 样式;Flutter 提供一套声明式 Widget,虽然更冗长但可以更精细控制布局与绘制。
  • 更新机制:RN 借助 React reconciliation,只更新变更节点;Flutter 每次 setState 会重新调用 build(),但 Flutter 会对比 Widget 树与 Element 树,最终保持高效更新。

六、UI 组件与布局对比

跨平台框架最直观的体验在于 UI 开发方式与组件库。下面从布局系统和常见组件示例两方面比较。

6.1 布局系统对比

特性React Native (Flexbox)Flutter (Flex + Constraint)
主轴方向flexDirection: 'row' / 'column'Row / Column
对齐 & 分布justifyContent, alignItems, alignSelfMainAxisAlignment, CrossAxisAlignment
尺寸控制width, height, flexExpanded, Flexible, SizedBox, Container
内外边距margin, paddingPadding, SizedBox, Container
绝对定位position: 'absolute', top/left/right/bottomStack + Positioned

6.1.1 示例:水平等间距分布三个按钮

  • React Native

    <View style={{ flexDirection: 'row', justifyContent: 'space-between', padding: 20 }}>
      <Button title="按钮1" onPress={() => {}} />
      <Button title="按钮2" onPress={() => {}} />
      <Button title="按钮3" onPress={() => {}} />
    </View>
  • Flutter

    Padding(
      padding: const EdgeInsets.all(20.0),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: [
          ElevatedButton(onPressed: () {}, child: Text('按钮1')),
          ElevatedButton(onPressed: () {}, child: Text('按钮2')),
          ElevatedButton(onPressed: () {}, child: Text('按钮3')),
        ],
      ),
    );
  • 两者都以类似语义表述主轴对齐,仅在语言和命名上存在差异。

6.2 常见组件示例

组件类型React NativeFlutter
文本输入<TextInput placeholder="请输入" />TextField(decoration: InputDecoration(hintText: '请输入'))
滑动列表<FlatList data={data} renderItem={...} />ListView.builder(itemCount: data.length, itemBuilder: ...)
下拉菜单Picker / react-native-picker-selectDropdownButton<String>(items: ..., onChanged: ...)
弹出对话框Alert.alert('标题', '内容')showDialog(context: context, builder: ...)
网络图片<Image source={{ uri: url }} />Image.network(url)
触摸反馈<TouchableOpacity onPress={...}><View>...</View></TouchableOpacity>InkWell(onTap: ..., child: ...)
  • React Native 常用第三方库扩展组件(如 react-native-elementsreact-native-paper);Flutter 几乎所有组件都内置于框架,且与 Material/Cupertino 设计风格集成紧密。

七、平台插件与原生交互

跨平台框架难免需要调用原生 API,例如获取设备信息、调用摄像头、调用传感器等。React Native 和 Flutter 都提供了原生桥或插件机制:

7.1 React Native Native Module

  • 定义方式:在 Android (Java/Kotlin) 或 iOS (Objective-C/Swift) 中创建一个继承自 ReactContextBaseJavaModule 的类,通过 @ReactMethod 注解导出方法;再在 ReactPackage 中注册。
  • 调用方式:JS 端通过 import { NativeModules } from 'react-native'; const { MyNativeModule } = NativeModules; 调用相应方法。
  • 示例:获取电池电量。

    // android/app/src/main/java/com/myapp/BatteryModule.java
    package com.myapp;
    
    import android.content.Intent;
    import android.content.IntentFilter;
    import android.os.BatteryManager;
    import android.os.Build;
    import com.facebook.react.bridge.Promise;
    import com.facebook.react.bridge.ReactApplicationContext;
    import com.facebook.react.bridge.ReactContextBaseJavaModule;
    import com.facebook.react.bridge.ReactMethod;
    
    public class BatteryModule extends ReactContextBaseJavaModule {
        private ReactApplicationContext context;
    
        public BatteryModule(ReactApplicationContext reactContext) {
            super(reactContext);
            this.context = reactContext;
        }
    
        @Override
        public String getName() {
            return "BatteryModule";
        }
    
        @ReactMethod
        public void getBatteryLevel(Promise promise) {
            try {
                IntentFilter ifilter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED);
                Intent batteryStatus = context.registerReceiver(null, ifilter);
                int level = batteryStatus.getIntExtra(BatteryManager.EXTRA_LEVEL, -1);
                int scale = batteryStatus.getIntExtra(BatteryManager.EXTRA_SCALE, -1);
                float batteryPct = level / (float) scale;
                promise.resolve((int)(batteryPct * 100));
            } catch (Exception e) {
                promise.reject("BATTERY_ERROR", e);
            }
        }
    }
    // src/AppRN.js
    import React, { useEffect, useState } from 'react';
    import { View, Text, Button, NativeModules, StyleSheet } from 'react-native';
    const { BatteryModule } = NativeModules;
    
    export default function AppRN() {
      const [level, setLevel] = useState(null);
    
      const fetchBattery = async () => {
        try {
          const result = await BatteryModule.getBatteryLevel();
          setLevel(result);
        } catch (e) {
          console.error(e);
        }
      };
    
      return (
        <View style={styles.container}>
          <Text>当前电池电量:{level != null ? `${level}%` : '未知'}</Text>
          <Button title="获取电池电量" onPress={fetchBattery} />
        </View>
      );
    }
    
    const styles = StyleSheet.create({
      container: { flex: 1, justifyContent: 'center', alignItems: 'center' },
    });

7.2 Flutter Platform Channel

  • 定义方式:在 Dart 端通过 MethodChannel('channel_name') 创建通道,并调用 invokeMethod;在 Android (Kotlin/Java) 或 iOS (Swift/Obj-C) 中在对应通道名称下接收消息并返回结果。
  • 调用方式:Dart 端使用 await platform.invokeMethod('methodName', params);Native 端在方法回调中处理并返回。
  • 示例:获取电池电量。

    // lib/battery_channel.dart
    import 'package:flutter/services.dart';
    
    class BatteryChannel {
      static const MethodChannel _channel = MethodChannel('battery_channel');
    
      static Future<int> getBatteryLevel() async {
        try {
          final int level = await _channel.invokeMethod('getBatteryLevel');
          return level;
        } on PlatformException catch (e) {
          print("Failed to get battery level: '${e.message}'.");
          return -1;
        }
      }
    }
    // android/app/src/main/kotlin/com/myapp/MainActivity.kt
    package com.myapp
    
    import android.content.Intent
    import android.content.IntentFilter
    import android.os.BatteryManager
    import android.os.Build
    import io.flutter.embedding.android.FlutterActivity
    import io.flutter.embedding.engine.FlutterEngine
    import io.flutter.plugin.common.MethodChannel
    
    class MainActivity: FlutterActivity() {
        private val CHANNEL = "battery_channel"
    
        override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
            super.configureFlutterEngine(flutterEngine)
            MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler {
                call, result ->
                if (call.method == "getBatteryLevel") {
                    val batteryLevel = getBatteryLevel()
                    if (batteryLevel != -1) {
                        result.success(batteryLevel)
                    } else {
                        result.error("UNAVAILABLE", "Battery level not available.", null)
                    }
                } else {
                    result.notImplemented()
                }
            }
        }
    
        private fun getBatteryLevel(): Int {
            val ifilter = IntentFilter(Intent.ACTION_BATTERY_CHANGED)
            val batteryStatus = applicationContext.registerReceiver(null, ifilter)
            val level = batteryStatus?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1
            val scale = batteryStatus?.getIntExtra(BatteryManager.EXTRA_SCALE, -1) ?: -1
            return if (level == -1 || scale == -1) {
                -1
            } else {
                (level * 100) / scale
            }
        }
    }
    // lib/main.dart
    import 'package:flutter/material.dart';
    import 'battery_channel.dart';
    
    void main() => runApp(MyApp());
    
    class MyApp extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          home: BatteryHome(),
        );
      }
    }
    
    class BatteryHome extends StatefulWidget {
      @override
      _BatteryHomeState createState() => _BatteryHomeState();
    }
    
    class _BatteryHomeState extends State<BatteryHome> {
      int _batteryLevel = -1;
    
      Future<void> _getBattery() async {
        final level = await BatteryChannel.getBatteryLevel();
        setState(() {
          _batteryLevel = level;
        });
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(title: Text('电池电量 (Flutter)')),
          body: Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Text('当前电量:${_batteryLevel == -1 ? "未知" : "$_batteryLevel%"}'),
                SizedBox(height: 20),
                ElevatedButton(
                  onPressed: _getBattery,
                  child: Text('获取电池电量'),
                ),
              ],
            ),
          ),
        );
      }
    }
  • 两者的核心思想相似:通过命名通道在跨语言层之间传递消息。React Native 借助桥机制自动完成序列化与对象映射;Flutter 需要在 Dart 与 Native 两边写相应的通道处理。

八、总结与选型建议

通过上述对比与实战示例,我们可以总结两者的优势与适用场景:

  1. React Native 优势

    • 使用 JavaScript/TypeScript,Web 前端团队能快速上手;
    • 丰富的第三方生态与成熟的社区支持;
    • 与现有原生代码集成相对简单,适合逐步迁移或混合开发;
    • 热重载速度较快,对于简单 UI 改动效率较高。
  2. Flutter 优势

    • 所见即所得的渲染架构,UI 一致性更高;
    • 高性能渲染(Skia 引擎)和更流畅的动画体验;
    • 强类型 Dart 语言,代码可读性与可维护性更强;
    • 内置大量 Material 和 Cupertino 风格组件,UI 开发更快捷。
  3. 性能与包体

    • Flutter 在复杂动画、高帧率场景下表现优异;React Native 如果使用 useNativeDriverReanimated 等可大幅提升动画性能;
    • React Native 包体相对小,但需要加载 JS Bundle;Flutter 包体稍大但启动速度更快、渲染一体化。
  4. 生态与插件

    • React Native 插件多,但质量参差;Flutter 插件生态新兴,但官方插件与社区插件日渐成熟;
    • 若项目需使用特定原生功能,可对比两者所需插件是否完备,再做抉择。

8.1 选型建议

  • 已有 Web 团队:若团队主要精通 JS/TS,想在移动端复用部分业务逻辑,可优先考虑 React Native;
  • 追求顶级 UI 性能与一致性:若需要高帧率动画、复杂自定义 Widget,且愿意投入学习 Dart,可选择 Flutter;
  • 逐步迁移或混合架构:如果现有原生应用需要渐进改造,React Native 的 Native Module 与 Bridge 机制更灵活;
  • 快速原型与 MVP:React Native 起步更快,JavaScript 社区包多;Flutter 的热重载更流畅,适合快速搭建高保真原型。

结语

本文从架构原理、开发体验、性能表现、实战示例到原生交互全面对比了 React Native 与 Flutter。两者各有优劣,没有绝对的“最佳”,只有最适合的技术栈。希望通过本文的讲解与示例,能帮助你更清晰地理解两种框架的差异,并在实际项目中做出明智的选择。

# ‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌React Native 错误采集原理及 Android 平台实现详解

在移动应用开发中,**错误采集**(Error Reporting)能帮助我们在第一时间发现并定位线上问题,极大提升产品质量与用户体验。本文将从错​误采集的整体原理出发,结合 React Native 框架的特点,详细讲解如何在**Android 平台**实现完整的错误采集方案。文章包含架构原理、关键代码示例、ASCII 图解与详细说明,帮助你快速上手并构建自己的错误采集系统。

---

## 目录

1. [前言](#一-前言)  
2. [错误采集原理概览](#二-错误采集原理概览)  
   1. [JS 层错误捕获](#21-js-层错误捕获)  
   2. [Native 层错误捕获(Android)](#22-native-层错误捕获android)  
   3. [React Native 桥与异常传递](#23-react-native-桥与异常传递)  
3. [Android 平台实现详解](#三-android-平台实现详解)  
   1. [JavaScript 层面采集](#31-javascript-层面采集)  
      - [全局异常捕获:ErrorUtils](#311-全局异常捕获errorutils)  
      - [示例代码:捕获 JS 错误并上报](#312-示例代码捕获-js-错误并上报)  
   2. [Native(Java)层面采集](#32-nativejava-层面采集)  
      - [UncaughtExceptionHandler 介绍](#321-uncaughtexceptionhandler-介绍)  
      - [示例代码:在 Application 中设置全局捕获](#322-示例代码在-application-中设置全局捕获)  
   3. [JS 错误向 Native 传递](#33-js-错误向-native-传递)  
      - [使用 NativeModules 传递错误信息](#331-使用-nativemodules-传递错误信息)  
      - [示例代码:JS 调用 Native 上报接口](#332-示例代码js-调用-native-上报接口)  
   4. [错误存储与网络上报](#34-错误存储与网络上报)  
      - [本地存储方案:文件、SQLite 或 SharedPreferences](#341-本地存储方案文件sqlite-或-sharedpreferences)  
      - [网络上报方案:RESTful 接口调用](#342-网络上报方案restful-接口调用)  
      - [示例代码:保存本地并异步上报](#343-示例代码保存本地并异步上报)  
4. [错误上报流程图解](#四-错误上报流程图解)  
5. [集成示例:自定义错误采集库](#五-集成示例自定义错误采集库)  
   1. [代码结构](#51-代码结构)  
   2. [主要功能模块说明](#52-主要功能模块说明)  
   3. [完整 Demo](#53-完整-demo)  
6. [常见问题与最佳实践](#六-常见问题与最佳实践)  
7. [总结](#七-总结)  

---

## 一、前言

React Native 混合了 JavaScript 与原生代码,既有 JS 引擎执行的逻辑错误,也可能因原生模块或第三方库引发的崩溃(Crash)。线上应用若无法及时捕获并上报这些错误,就很难定位问题根源、快速迭代。  

- **JS 层错误**:诸如 `undefined is not an object`、Promise 未捕获的异常、UI 组件渲染出错等,均会在 JS 引擎中抛出异常。  
- **Native 层错误**(Android):Java/Kotlin 抛出的 `NullPointerException`、`IndexOutOfBoundsException`、甚至由于 NDK 引发的 native crash,都需要在原生层进行捕获。  

React Native 提供了 JS 与 Native 互通的“桥”(Bridge)机制,我们可以将 JS 层捕获到的异常传递到 Native,再由 Native 统一进行存储与上报。接下来,本文先从原理层面概述捕获流程,然后深入 Android 平台实现细节。

---

## 二、错误采集原理概览

在 React Native 中,错误采集通常分为两个阶段:  
1. **捕获阶段**:捕获 JS 及 Native 层抛出的异常;  
2. **上报阶段**:将异常信息持久化并发送到服务器,用于后续分析。

主要原理如下:

┌────────────────────────────────────────────────────────────────┐
│ React Native 应用 │
│ ┌───────────┐ ┌────────────┐ ┌───────────────┐ │
│ │ JS 层 │──捕获──▶│ 错误处理 │──Native─▶│ 错误上传组件 │ │
│ │ (ErrorUtils)│ │ (Native Module)│ │ (Retrofit) │ │
│ └───────────┘ └────────────┘ └───────────────┘ │
│ ▲ ▲ │
│ │ │ │
│ 错误抛出 (TypeError, etc.) 错误抛出 (NPE, etc.) │
└────────────────────────────────────────────────────────────────┘


### 2.1 JS 层错误捕获

- 利用 React Native 内置的 [`ErrorUtils`](https://reactnative.dev/docs/javascript-environment#errorutils) 全局对象,拦截未捕获的 JS 异常。  
- 也可在组件中使用 `try/catch` 捕获同步 / 异步异常,或重写 `console.error`、`window.onerror` 来捕获。  
- 捕获后,将关键信息(错误消息、堆栈、设备信息、应用版本号等)封装后,调用 Native 模块进行上报或持久化。  

### 2.2 Native 层错误捕获(Android)

- **Java 异常**:在 `Application` 或某个 `Activity` 中通过 `Thread.setDefaultUncaughtExceptionHandler(...)` 设​置全局的 `UncaughtExceptionHandler`,捕获所有未处理的 Java 异常。  
- **NDK 异常**(Native Crash):若涉及 native 代码,可借助如 [NDK Crash Handler](https://source.android.com/devices/tech/debug) 或第三方库(如 Breakpad、Bugly NDK)进行捕获。  
- 捕获到异常后,同样将信息(`Throwable` 堆栈、设备信息)传入错误采集模块,统一处理。  

### 2.3 React Native 桥与异常传递

- React Native 的桥(Bridge)允许 JS 与 Native 互相调用。JS 捕获到异常后,通过 `NativeModules.ErrorReportModule.sendJSException(...)` 将错误信息传递到 Android Native 端;  
- 对于 Native 层捕获的异常,可直接在 `UncaughtExceptionHandler` 中调用网络请求或存储逻辑;也可以通过 RN 的 `DevSupportManager` 触发 RN 的红屏(仅开发模式)。  
- 最终,所有异常信息都会汇总到同一个“错误采集中心”进行存储(本地缓存)和网络上报。  

---

## 三、Android 平台实现详解

下面我们重点围绕 Android 平台,分层次详细讲解如何捕获并上报 React Native 中的各种异常。

### 3.1 JavaScript 层面采集

#### 3.1.1 全局异常捕获:ErrorUtils

React Native 在 JS 环境中提供了一个全局对象 `ErrorUtils`,可以用来替换默认的错误处理器,从而捕获所有未被 `try/catch` 包裹的异常。典型用法如下:

```js
// src/jsExceptionHandler.js

import { NativeModules } from 'react-native';
const { ErrorReportModule } = NativeModules;

/**
 * 自定义 JS 全局异常处理器
 * @param {Error} error 捕获到的 Error 对象
 * @param {boolean} isFatal 表示是否为致命异常(RN 默认认为部分异常会触发红屏)
 */
function globalErrorHandler(error, isFatal) {
  // 1. 格式化错误信息
  const errorMessage = error.message;
  const stackTrace = error.stack; // 多行堆栈信息

  // 2. 构建上报参数
  const errorInfo = {
    message: errorMessage,
    stack: stackTrace,
    isFatal,
    ts: Date.now(),
    // 可加入更多业务字段,如 React 版本、App 版本、用户 ID 等
  };

  // 3. 调用 Native 模块上报到 Android 端
  ErrorReportModule.sendJSException(JSON.stringify(errorInfo));

  // 4. 若是开发模式,可调用默认处理以显示红屏提示;生产环境可静默处理
  if (__DEV__) {
    // 如果想保留 RN 红屏,可调用默认处理器
    // ErrorUtils.getGlobalHandler()(error, isFatal);
    console.warn('开发环境下,调用默认红屏处理');
  } else {
    // 生产环境:静默或展示自定义错误页面
    console.log('生产环境下,已将错误上报,建议重启应用或跳转到安全页面');
  }
}

// 注册全局异常处理器
ErrorUtils.setGlobalHandler(globalErrorHandler);

关键说明:

  1. ErrorUtils.setGlobalHandler(handler)

    • 此方法用于替换 React Native 默认的全局错误处理器,将所有未被 try/catch 捕获的异常交给 handler 处理。
    • handler 接收两个参数:error(Error 对象)和 isFatal(布尔值)。其中 isFatal = true 时,React Native 默认会显示红屏并终止 JS 执行;可以根据业务决定是否调用默认处理器。
  2. error.stack

    • 包含了多行堆栈信息,包括文件名、行号和函数名,有助于精确定位问题。
  3. 上报到 Native

    • 通过 NativeModules.ErrorReportModule.sendJSException(...) 将错误信息传到 Android 端,后续由 Native 统一存储与上报。
  4. 生产/开发环境差异

    • 在开发模式(__DEV__ === true)下,通常保留默认红屏提示以便调试;在生产模式下可选择静默或展示自定义错误页面。

3.1.2 示例代码:捕获 JS 错误并上报

在应用入口(如 index.jsApp.js)中,需在最早阶段安装全局异常处理器:

// index.js

import { AppRegistry } from 'react-native';
import App from './App';
import { name as appName } from './app.json';
import './jsExceptionHandler'; // 引入全局异常处理模块

AppRegistry.registerComponent(appName, () => App);

此时,任何 JS 运行期间抛出的未捕获异常都会被 globalErrorHandler 捕获,并立即调用 Native 方法进行上报。


3.2 Native(Java)层面采集

在 Android 平台,除了 JS 层可能发生的错误,还需要在 Native 层捕获 Java 层或 NDK 层抛出的异常。

3.2.1 UncaughtExceptionHandler 介绍

Java 提供了 Thread.setDefaultUncaughtExceptionHandler(...) 接口,用于设置全局未捕获异常处理器。典型流程如下:

  1. 在自定义的 Application 子类中实现 Thread.UncaughtExceptionHandler 接口。
  2. onCreate() 方法中,通过 Thread.setDefaultUncaughtExceptionHandler(...) 注册该处理器。
  3. 当任何未捕获的 Java 异常(如 NullPointerException)抛出时,系统会调用我们的 uncaughtException(Thread t, Throwable e) 方法。
  4. uncaughtException 中,可进行日志收集、设备信息采集,并通过网络上报或写入本地文件;也可选择重启应用或直接杀进程。

3.2.2 示例代码:在 Application 中设置全局捕获

// android/app/src/main/java/com/myapp/MyApplication.java

package com.myapp;

import android.app.Application;
import android.content.Context;
import android.os.Looper;
import android.os.Handler;
import android.util.Log;

import androidx.annotation.NonNull;

import org.json.JSONObject;

import java.io.File;
import java.io.FileWriter;
import java.io.PrintWriter;

import okhttp3.*; // 使用 OkHttp 进行网络上报

public class MyApplication extends Application implements Thread.UncaughtExceptionHandler {

    private static final String TAG = "CrashHandler";
    private Thread.UncaughtExceptionHandler defaultHandler;

    @Override
    public void onCreate() {
        super.onCreate();

        // 1. 记录系统默认的异常处理器
        defaultHandler = Thread.getDefaultUncaughtExceptionHandler();

        // 2. 设置当前 CrashHandler 为默认处理器
        Thread.setDefaultUncaughtExceptionHandler(this);
    }

    /**
     * 全局未捕获异常处理
     *
     * @param thread 抛出异常的线程
     * @param ex     Throwable 对象
     */
    @Override
    public void uncaughtException(@NonNull Thread thread, @NonNull Throwable ex) {
        // 1. 将异常信息写入本地文件
        writeExceptionToFile(ex);

        // 2. 异步上报到服务器
        postExceptionToServer(ex);

        // 3. 延迟一段时间后杀进程或调用默认处理器
        new Handler(Looper.getMainLooper()).postDelayed(() -> {
            // 若希望保留默认系统弹窗,可调用:
            // defaultHandler.uncaughtException(thread, ex);

            // 否则直接杀死进程
            android.os.Process.killProcess(android.os.Process.myPid());
            System.exit(1);
        }, 2000);
    }

    /**
     * 将 Throwable 信息写入本地文件
     */
    private void writeExceptionToFile(Throwable ex) {
        try {
            File dir = new File(getFilesDir(), "crash_logs");
            if (!dir.exists()) dir.mkdirs();
            String fileName = "crash_" + System.currentTimeMillis() + ".log";
            File logFile = new File(dir, fileName);

            FileWriter fw = new FileWriter(logFile);
            PrintWriter pw = new PrintWriter(fw);
            ex.printStackTrace(pw);
            pw.close();
            fw.close();

            Log.d(TAG, "Exception written to file: " + logFile.getAbsolutePath());
        } catch (Exception e) {
            Log.e(TAG, "Failed to write exception file", e);
        }
    }

    /**
     * 异步上报到服务器
     */
    private void postExceptionToServer(Throwable ex) {
        new Thread(() -> {
            try {
                // 1. 构建 JSON payload
                JSONObject json = new JSONObject();
                json.put("timestamp", System.currentTimeMillis());
                json.put("exception", ex.toString());
                json.put("stack", getStackString(ex));
                json.put("appVersion", "1.0.0");
                json.put("deviceModel", android.os.Build.MODEL);
                // 可根据业务需求添加更多字段

                // 2. 使用 OkHttp 发送 POST 请求
                OkHttpClient client = new OkHttpClient();
                RequestBody body = RequestBody.create(
                    json.toString(),
                    MediaType.parse("application/json; charset=utf-8")
                );
                Request request = new Request.Builder()
                    .url("https://api.example.com/reportCrash")
                    .post(body)
                    .build();

                Response response = client.newCall(request).execute();
                if (response.isSuccessful()) {
                    Log.d(TAG, "Crash report sent successfully");
                } else {
                    Log.e(TAG, "Crash report failed: " + response.code());
                }
            } catch (Exception e) {
                Log.e(TAG, "Error posting exception to server", e);
            }
        }).start();
    }

    /**
     * 获取 Throwable 的堆栈字符串
     */
    private String getStackString(Throwable ex) {
        StringBuilder sb = new StringBuilder();
        for (StackTraceElement element : ex.getStackTrace()) {
            sb.append(element.toString()).append("\n");
        }
        return sb.toString();
    }
}

关键说明:

  1. 记录并调用默认处理器

    • onCreate() 中,使用 Thread.getDefaultUncaughtExceptionHandler() 获取系统默认的异常处理器,并在自定义捕获完成后,可选择调用默认处理器以展示系统弹窗。
  2. 本地写日志

    • 将异常堆栈写入应用私有目录下的 crash_logs 文件夹中,文件名包含时间戳便于后续查找。
  3. 异步上报

    • 利用 OkHttp 在新线程中以 JSON 形式 POST 到后端 REST 接口。
    • 上报内容通常包含:时间戳、异常类名与消息、堆栈信息、App 版本、设备信息、系统版本、网络状态等。
  4. 延迟退出

    • 在上报完成或等待一定时间后,可选择杀死进程(避免应用处于不稳定状态)。如果想保留“原生 Crash 弹窗”,可调用 defaultHandler.uncaughtException(thread, ex)

3.3 JS 错误向 Native 传递

很多时候我们更关心的是 JS 端的业务逻辑错误,因此需要将 JS 捕获到的异常传递到 Native 层进行统一处理或持久化。

3.3.1 使用 NativeModules 传递错误信息

在 Android 端,需要先创建一个原生模块 ErrorReportModule,暴露给 JS 调用:

// android/app/src/main/java/com/myapp/ErrorReportModule.java

package com.myapp;

import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;

import android.util.Log;

import org.json.JSONObject;

/**
 * ErrorReportModule 用于接收 JS 端传过来的异常信息,并进行本地保存或上报
 */
public class ErrorReportModule extends ReactContextBaseJavaModule {
    private static final String TAG = "ErrorReportModule";

    public ErrorReportModule(ReactApplicationContext reactContext) {
        super(reactContext);
    }

    @Override
    public String getName() {
        return "ErrorReportModule";
    }

    /**
     * JS 端调用该方法上报异常
     *
     * @param jsonStr 包含异常信息的 JSON 字符串
     * @param promise 回调 Promise,用于通知 JS 端是否成功
     */
    @ReactMethod
    public void sendJSException(String jsonStr, Promise promise) {
        try {
            // 1. 解析 JSON
            JSONObject json = new JSONObject(jsonStr);
            String message = json.optString("message");
            String stack = json.optString("stack");
            boolean isFatal = json.optBoolean("isFatal", false);
            long ts = json.optLong("ts");

            // 2. 将异常信息写到本地文件或数据库
            writeJSErrorToFile(message, stack, isFatal, ts);

            // 3. 异步上报到服务器(可与 Java Crash 上报合并接口)
            postJSErrorToServer(json);

            // 成功后返回
            promise.resolve(true);
        } catch (Exception e) {
            Log.e(TAG, "Failed to send JS exception", e);
            promise.reject("ErrorReportFail", e);
        }
    }

    private void writeJSErrorToFile(String message, String stack, boolean isFatal, long ts) {
        // 参考 Java Crash 写文件逻辑,将 JS 错误写入独立目录
        // e.g., getReactApplicationContext().getFilesDir() + "/js_error_logs/"
    }

    private void postJSErrorToServer(JSONObject json) {
        // 直接复用上文 Java Crash 的 postExceptionToServer 方法
        // 或者在这里再构建一个 HTTP 请求上报 JS 错误
    }
}

MainApplication.java 中注册该模块:

// android/app/src/main/java/com/myapp/MainApplication.java

import com.myapp.ErrorReportModule; // 引入

@Override
protected List<ReactPackage> getPackages() {
    return Arrays.<ReactPackage>asList(
        new MainReactPackage(),
        new ErrorReportPackage() // 自定义 package,返回 ErrorReportModule
    );
}

然后创建 ErrorReportPackage

// android/app/src/main/java/com/myapp/ErrorReportPackage.java

package com.myapp;

import com.facebook.react.ReactPackage;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.uimanager.ViewManager;

import java.util.Arrays;
import java.util.Collections;
import java.util.List;

public class ErrorReportPackage implements ReactPackage {
    @Override
    public List<NativeModule> createNativeModules(ReactApplicationContext reactContext) {
        return Arrays.<NativeModule>asList(new ErrorReportModule(reactContext));
    }

    @Override
    public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
        return Collections.emptyList();
    }
}

3.3.2 示例代码:JS 调用 Native 上报接口

在前文注册了 ErrorUtils.setGlobalHandlerglobalErrorHandler 中,我们只需调用:

import { NativeModules } from 'react-native';
const { ErrorReportModule } = NativeModules;

// 假设已在 globalErrorHandler 中调用
ErrorReportModule.sendJSException(JSON.stringify(errorInfo))
  .then(() => console.log('JS exception reported successfully'))
  .catch((err) => console.error('Failed to report JS exception', err));

当 JS 端捕获到错误时,会将 errorInfo 以 JSON 字符串形式传给 Native,再由 Native 统一写文件或上报。


3.4 错误存储与网络上报

3.4.1 本地存储方案:文件、SQLite 或 SharedPreferences

  • 文件方案

    • 最简单也是最常用的方式:将异常日志写入应用私有目录(getFilesDir())下的 crash_logs/js_error_logs/ 文件夹,每次写一个新文件,文件名可包含时间戳,示例如:

      /data/data/com.myapp/files/crash_logs/crash_1625078400000.log
      /data/data/com.myapp/files/js_error_logs/js_error_1625078400000.log
    • 优点:实现简单、易区分;缺点:文件数量多时需定期清理,可自行在写入时检查旧日志并删除超过一定条数或时间的文件。
  • SQLite 方案

    • 若需要复杂查询或聚合分析,可借助 SQLite 在本地维护一个 errors 表,字段包括:idtimestamptype(js/native)messagestackdeviceInfosentStatus(是否已上报) 等。
    • 优点:可灵活根据条件查询;缺点:实现较文件方案复杂、性能稍低(写入大量日志需注意批量插入优化)。
  • SharedPreferences 方案

    • 一般只适用于保存少量最后一次错误信息,可用于应用重启后显示上次崩溃原因,但不适合长期存储大量日志。

3.4.2 网络上报方案:RESTful 接口调用

  • 统一上报接口

    • 后端可以提供一个 POST /api/v1/reportError 接口,接收 JSON 格式错误信息,包括:

      {
        "type": "js" | "native",
        "timestamp": 1625078400000,
        "message": "TypeError: undefined is not an object",
        "stack": "...",
        "deviceModel": "Pixel 5",
        "osVersion": "Android 11",
        "appVersion": "1.0.0",
        "network": "WIFI",
        "userId": "12345"
      }
    • Android 使用 OkHttp 或 Retrofit 进行异步 POST;iOS 可用 Alamofire;JS 可用 fetch()
  • 批量上报

    • 当网络恢复时(监听网络变化),可一次性将本地缓存中的多条日志批量上报,以减少网络请求次数并保证严格的“至少一次”上报语义。
  • 失败重试与幂等

    • 若上报失败(如网络中断),可保存到本地并在下一次网络可达时重试;后端可根据设备 ID + 时间戳做幂等去重。

3.4.3 示例代码:保存本地并异步上报

ErrorReportModule.sendJSException 中,我们可以先将 JSON 字符串写入本地文件,再调用一个统一的 uploadPendingLogs() 方法,将所有未上报的日志文件逐个发送至服务器并删除:

// android/app/src/main/java/com/myapp/ErrorReportModule.java

private void writeJSErrorToFile(String message, String stack, boolean isFatal, long ts) {
    try {
        File dir = new File(getReactApplicationContext().getFilesDir(), "js_error_logs");
        if (!dir.exists()) dir.mkdirs();
        String fileName = "js_" + ts + ".log";
        File logFile = new File(dir, fileName);

        FileWriter fw = new FileWriter(logFile);
        PrintWriter pw = new PrintWriter(fw);
        pw.println("timestamp:" + ts);
        pw.println("isFatal:" + isFatal);
        pw.println("message:" + message);
        pw.println("stack:");
        pw.println(stack);
        pw.close();
        fw.close();
        Log.d(TAG, "JS Exception written to file: " + logFile.getAbsolutePath());

        // 保存完文件后,尝试上报所有待上传日志
        uploadPendingLogs("js_error_logs");
    } catch (Exception e) {
        Log.e(TAG, "Failed to write JS exception file", e);
    }
}

private void uploadPendingLogs(String subDir) {
    new Thread(() -> {
        try {
            File dir = new File(getReactApplicationContext().getFilesDir(), subDir);
            if (!dir.exists() || !dir.isDirectory()) return;
            File[] files = dir.listFiles();
            if (files == null || files.length == 0) return;

            OkHttpClient client = new OkHttpClient();
            for (File logFile : files) {
                // 1. 读取文件内容
                StringBuilder sb = new StringBuilder();
                java.io.BufferedReader br = new java.io.BufferedReader(new java.io.FileReader(logFile));
                String line;
                while ((line = br.readLine()) != null) {
                    sb.append(line).append("\n");
                }
                br.close();

                // 2. 构建 JSON 对象
                JSONObject payload = new JSONObject();
                payload.put("type", subDir.startsWith("js") ? "js" : "native");
                payload.put("log", sb.toString());
                // 可加入额外字段:App 版本、设备信息等

                // 3. 发送 POST 请求
                RequestBody body = RequestBody.create(
                    payload.toString(),
                    MediaType.parse("application/json; charset=utf-8")
                );
                Request request = new Request.Builder()
                    .url("https://api.example.com/reportError")
                    .post(body)
                    .build();

                Response response = client.newCall(request).execute();
                if (response.isSuccessful()) {
                    // 删除已成功上报的文件
                    logFile.delete();
                    Log.d(TAG, "Uploaded and deleted log file: " + logFile.getName());
                } else {
                    Log.e(TAG, "Upload failed for file: " + logFile.getName() + ", code: " + response.code());
                }
            }
        } catch (Exception e) {
            Log.e(TAG, "Error uploading pending logs", e);
        }
    }).start();
}

四、错误上报流程图解

下面用 ASCII 图示展示从 JS 抛出异常到 Android 层捕获并上报的完整流程:

┌───────────────────────────────────────────────────────────────────┐
│                           React Native JS                        │
│  ┌───────────────────────────────────────────────────────────────┐│
│  │        1. 代码执行抛出未捕获异常 (e.g., TypeError)             ││
│  │  ┌─────────────────────────────────────────────────────────┐  ││
│  │  │ ErrorUtils.setGlobalHandler 捕获 (globalErrorHandler)   │  ││
│  │  │ - 格式化错误信息 (message, stack, ts, isFatal)          │  ││
│  │  │ - 调用: ErrorReportModule.sendJSException(jsonString)   │─┐│
│  │  └─────────────────────────────────────────────────────────┘  ││
│  │              ▲                                                ││
│  │              │  (Bridge: JS → Native)                          ││
│  └──────────────┴─────────────────────────────────────────────────┘│
│              │                                                       │
│              ▼                                                       │
│  ┌────────────────────────────────────────────────────────────────┐  │
│  │                    Android Native (ErrorReportModule)         │  │
│  │  ┌──────────────────────────────────────────────────────────┐  │  │
│  │  │ sendJSException(jsonString)                             │  │  │
│  │  │ - 解析 JSON                                              │  │  │
│  │  │ - writeJSErrorToFile 写入 /files/js_error_logs/          │  │  │
│  │  │ - uploadPendingLogs 上传到 https://.../reportError     │  │  │
│  │  └──────────────────────────────────────────────────────────┘  │  │
│  │            ▲                                                  │  │
│  │            │ (异步上报,如成功则删文件; 失败则留待下次重试)     │  │
│  └────────────┴──────────────────────────────────────────────────┘  │
│              │                                                       │
│              ▼                                                       │
│  ┌────────────────────────────────────────────────────────────────┐  │
│  │  2. Native 层 (Java CrashHandler) 捕获 Java 未捕获异常           │  │
│  │  Thread.setDefaultUncaughtExceptionHandler 监听 NPE, etc.       │  │
│  │  ┌──────────────────────────────────────────────────────────┐  │  │
│  │  │ uncaughtException(Thread t, Throwable ex)                │  │  │
│  │  │ - writeExceptionToFile 写 /files/crash_logs/              │  │  │
│  │  │ - postExceptionToServer 上传到 https://.../reportError   │  │  │
│  │  └──────────────────────────────────────────────────────────┘  │  │
│  │                  ▲                                            │  │
│  │                  │ (同样采用异步网络上报并删除已上报文件)   │  │
│  └──────────────────┴────────────────────────────────────────────┘  │
└───────────────────────────────────────────────────────────────────┘
  • JS 层:通过 ErrorUtils.setGlobalHandler 捕获未处理的 JS 异常,并调用 Native 模块上报。
  • Bridge:React Native 桥负责将 sendJSException 调用转给 Android 原生 ErrorReportModule
  • Native 层 JS 上报ErrorReportModule 将信息写入 /files/js_error_logs/,并尝试上传到服务器。
  • Native 层 Java Crash:通过 Thread.setDefaultUncaughtExceptionHandler 捕获所有 Java 未捕获异常,同样写入 /files/crash_logs/ 并异步上传。

五、集成示例:自定义错误采集库

下面我们以一个完整的自定义错误采集库为示例,演示如何将上述各模块结合起来,快速集成到 React Native 项目中。

5.1 代码结构

myapp/
├── android/
│   ├── app/
│   │   ├── src/
│   │   │   ├── main/
│   │   │   │   ├── java/com/myapp/
│   │   │   │   │   ├── ErrorReportModule.java
│   │   │   │   │   ├── ErrorReportPackage.java
│   │   │   │   │   ├── MyApplication.java
│   │   │   │   │   └── CrashHandler.java
│   │   │   └── ...
│   │   └── AndroidManifest.xml
│   └── build.gradle
├── src/
│   ├── jsExceptionHandler.js   // JS 全局异常捕获
│   └── App.js
├── index.js                    // 应用入口
└── package.json
  • ErrorReportModule.java:负责接收 JS 异常并存储/上报。
  • CrashHandler.java:实现 Thread.UncaughtExceptionHandler,负责捕获 Java 异常。
  • MyApplication.java:在 Application 中注册 CrashHandler,并导入 ErrorReportModule
  • jsExceptionHandler.js:安装 ErrorUtils 全局异常处理。
  • App.js / index.js:应用入口,加载全局异常处理器并启动主界面。

5.2 主要功能模块说明

  1. JS 全局错误捕获 (jsExceptionHandler.js)

    • 使用 ErrorUtils.setGlobalHandler 捕获所有 JS 未捕获异常,调用 NativeModules.ErrorReportModule.sendJSException(...)
  2. 原生模块 ErrorReportModule (ErrorReportModule.java)

    • 暴露 sendJSException(String jsonStr, Promise promise) 方法给 JS。
    • 将接收到的 jsonStr 写入本地文件夹 js_error_logs/,并调用统一上报接口。
  3. Java Crash 捕获 (CrashHandler.java)

    • 实现 Thread.UncaughtExceptionHandler,在 uncaughtException(Thread t, Throwable ex) 中将异常写入 crash_logs/,并上报。
  4. 应用生命周期与注册 (MyApplication.java)

    • onCreate() 中注册 Thread.setDefaultUncaughtExceptionHandler(new CrashHandler(...)),并向 React Native 注册 ErrorReportModule
  5. 异步上报逻辑

    • 使用 OkHttp 在新线程里将所有待上报的日志文件逐条发送至后端 REST 接口,并在成功后删除对应文件。

5.3 完整 Demo

下文给出各模块的完整代码示例,帮助你快速复制到自己的项目中使用。

5.3.1 CrashHandler.java

// android/app/src/main/java/com/myapp/CrashHandler.java

package com.myapp;

import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;

import org.json.JSONObject;

import java.io.File;
import java.io.FileWriter;
import java.io.PrintWriter;

import okhttp3.*;

public class CrashHandler implements Thread.UncaughtExceptionHandler {
    private static final String TAG = "CrashHandler";
    private Context mContext;
    private Thread.UncaughtExceptionHandler defaultHandler;

    public CrashHandler(Context context) {
        mContext = context;
        defaultHandler = Thread.getDefaultUncaughtExceptionHandler();
    }

    @Override
    public void uncaughtException(Thread t, Throwable ex) {
        // 写入本地
        writeExceptionToFile(ex);

        // 上报到服务器
        postExceptionToServer(ex);

        // 延迟退出
        new Handler(Looper.getMainLooper()).postDelayed(() -> {
            // 可调用默认处理器(系统弹窗),或直接杀进程
            // defaultHandler.uncaughtException(t, ex);
            android.os.Process.killProcess(android.os.Process.myPid());
            System.exit(1);
        }, 2000);
    }

    private void writeExceptionToFile(Throwable ex) {
        try {
            File dir = new File(mContext.getFilesDir(), "crash_logs");
            if (!dir.exists()) dir.mkdirs();
            String fileName = "native_" + System.currentTimeMillis() + ".log";
            File logFile = new File(dir, fileName);

            FileWriter fw = new FileWriter(logFile);
            PrintWriter pw = new PrintWriter(fw);
            ex.printStackTrace(pw);
            pw.close();
            fw.close();
            Log.d(TAG, "Native exception written to: " + logFile.getAbsolutePath());
        } catch (Exception e) {
            Log.e(TAG, "Failed to write native exception", e);
        }
    }

    private void postExceptionToServer(Throwable ex) {
        new Thread(() -> {
            try {
                JSONObject json = new JSONObject();
                json.put("type", "native");
                json.put("timestamp", System.currentTimeMillis());
                json.put("message", ex.toString());
                json.put("stack", getStackString(ex));
                json.put("appVersion", "1.0.0");
                json.put("deviceModel", android.os.Build.MODEL);

                OkHttpClient client = new OkHttpClient();
                RequestBody body = RequestBody.create(
                    json.toString(),
                    MediaType.parse("application/json; charset=utf-8")
                );
                Request request = new Request.Builder()
                    .url("https://api.example.com/reportError")
                    .post(body)
                    .build();

                Response response = client.newCall(request).execute();
                if (response.isSuccessful()) {
                    Log.d(TAG, "Native crash report sent");
                    // 可根据业务删除本地文件
                } else {
                    Log.e(TAG, "Native crash report failed: " + response.code());
                }
            } catch (Exception e) {
                Log.e(TAG, "Error sending native crash to server", e);
            }
        }).start();
    }

    private String getStackString(Throwable ex) {
        StringBuilder sb = new StringBuilder();
        for (StackTraceElement element : ex.getStackTrace()) {
            sb.append(element.toString()).append("\n");
        }
        return sb.toString();
    }
}

5.3.2 ErrorReportModule.java

// android/app/src/main/java/com/myapp/ErrorReportModule.java

package com.myapp;

import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;

import android.util.Log;

import org.json.JSONObject;

import java.io.File;
import java.io.FileWriter;
import java.io.PrintWriter;

import okhttp3.*;

public class ErrorReportModule extends ReactContextBaseJavaModule {
    private static final String TAG = "ErrorReportModule";

    public ErrorReportModule(ReactApplicationContext reactContext) {
        super(reactContext);
    }

    @Override
    public String getName() {
        return "ErrorReportModule";
    }

    @ReactMethod
    public void sendJSException(String jsonStr, Promise promise) {
        try {
            JSONObject json = new JSONObject(jsonStr);
            String message = json.optString("message");
            String stack = json.optString("stack");
            boolean isFatal = json.optBoolean("isFatal", false);
            long ts = json.optLong("ts");

            writeJSErrorToFile(message, stack, isFatal, ts);
            uploadPendingLogs("js_error_logs");

            promise.resolve(true);
        } catch (Exception e) {
            Log.e(TAG, "Failed to send JS exception", e);
            promise.reject("ErrorReportFail", e);
        }
    }

    private void writeJSErrorToFile(String message, String stack, boolean isFatal, long ts) {
        try {
            File dir = new File(getReactApplicationContext().getFilesDir(), "js_error_logs");
            if (!dir.exists()) dir.mkdirs();
            String fileName = "js_" + ts + ".log";
            File logFile = new File(dir, fileName);

            FileWriter fw = new FileWriter(logFile);
            PrintWriter pw = new PrintWriter(fw);
            pw.println("timestamp:" + ts);
            pw.println("isFatal:" + isFatal);
            pw.println("message:" + message);
            pw.println("stack:");
            pw.println(stack);
            pw.close();
            fw.close();
            Log.d(TAG, "JS exception written to: " + logFile.getAbsolutePath());
        } catch (Exception e) {
            Log.e(TAG, "Failed to write JS exception file", e);
        }
    }

    private void uploadPendingLogs(String subDir) {
        new Thread(() -> {
            try {
                File dir = new File(getReactApplicationContext().getFilesDir(), subDir);
                if (!dir.exists() || !dir.isDirectory()) return;
                File[] files = dir.listFiles();
                if (files == null || files.length == 0) return;

                OkHttpClient client = new OkHttpClient();
                for (File logFile : files) {
                    StringBuilder sb = new StringBuilder();
                    java.io.BufferedReader br = new java.io.BufferedReader(new java.io.FileReader(logFile));
                    String line;
                    while ((line = br.readLine()) != null) {
                        sb.append(line).append("\n");
                    }
                    br.close();

                    JSONObject payload = new JSONObject();
                    payload.put("type", "js");
                    payload.put("log", sb.toString());
                    payload.put("appVersion", "1.0.0");
                    payload.put("deviceModel", android.os.Build.MODEL);

                    RequestBody body = RequestBody.create(
                        payload.toString(),
                        MediaType.parse("application/json; charset=utf-8")
                    );
                    Request request = new Request.Builder()
                        .url("https://api.example.com/reportError")
                        .post(body)
                        .build();

                    Response response = client.newCall(request).execute();
                    if (response.isSuccessful()) {
                        logFile.delete();
                        Log.d(TAG, "Uploaded and deleted JS log: " + logFile.getName());
                    } else {
                        Log.e(TAG, "Upload failed for JS log: " + logFile.getName());
                    }
                }
            } catch (Exception e) {
                Log.e(TAG, "Error uploading pending JS logs", e);
            }
        }).start();
    }
}

5.3.3 MyApplication.java

// android/app/src/main/java/com/myapp/MyApplication.java

package com.myapp;

import android.app.Application;
import android.util.Log;

import com.facebook.react.ReactApplication;
import com.facebook.react.ReactNativeHost;
import com.facebook.react.ReactPackage;
import com.facebook.react.shell.MainReactPackage;

import java.util.Arrays;
import java.util.List;

public class MyApplication extends Application implements ReactApplication {
    private static final String TAG = "MyApplication";

    private final ReactNativeHost mReactNativeHost = new ReactNativeHost(this) {
        @Override
        public boolean getUseDeveloperSupport() {
            return BuildConfig.DEBUG;
        }

        @Override
        protected List<ReactPackage> getPackages() {
            return Arrays.<ReactPackage>asList(
                new MainReactPackage(),
                new ErrorReportPackage()
            );
        }

        @Override
        protected String getJSMainModuleName() {
            return "index";
        }
    };

    @Override
    public void onCreate() {
        super.onCreate();

        // 注册 Java 全局异常捕获
        Thread.setDefaultUncaughtExceptionHandler(new CrashHandler(this));
        Log.d(TAG, "CrashHandler registered");
    }

    @Override
    public ReactNativeHost getReactNativeHost() {
        return mReactNativeHost;
    }
}

5.3.4 jsExceptionHandler.js

// src/jsExceptionHandler.js

import { NativeModules } from 'react-native';
const { ErrorReportModule } = NativeModules;

/**
 * 全局 JS 错误处理器
 */
function globalErrorHandler(error, isFatal) {
  const errorMessage = error.message;
  const stackTrace = error.stack;
  const errorInfo = {
    message: errorMessage,
    stack: stackTrace,
    isFatal,
    ts: Date.now(),
  };

  ErrorReportModule.sendJSException(JSON.stringify(errorInfo))
    .then(() => console.log('JS exception reported'))
    .catch((err) => console.error('Failed to report JS exception', err));

  if (__DEV__) {
    // 保留红屏提示
    console.warn('开发模式:调用默认红屏处理');
    ErrorUtils.getGlobalHandler()(error, isFatal);
  } else {
    // 生产模式:静默处理或显示自定义页面
    console.log('生产模式:JS 错误已上报,建议重启应用');
  }
}

// 安装全局错误处理器
ErrorUtils.setGlobalHandler(globalErrorHandler);

5.3.5 App.jsindex.js

// App.js

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

export default function App() {
  // 故意抛出一个未捕获异常用于测试
  const throwError = () => {
    // 下面这一行将触发 JS 错误
    const a = undefined;
    console.log(a.b.c);
  };

  return (
    <View style={styles.container}>
      <Text style={styles.title}>React Native 错误采集 Demo</Text>
      <Button title="触发 JS 错误" onPress={throwError} />
      <Button
        title="触发本地 Crash"
        onPress={() => {
          throw new Error('模拟本地 Crash'); // 可触发 Native Java Crash
        }}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 16 },
  title: { fontSize: 24, fontWeight: 'bold', marginBottom: 24 },
});
// index.js

import { AppRegistry } from 'react-native';
import App from './App';
import { name as appName } from './app.json';
import './src/jsExceptionHandler'; // 引入全局异常捕获模块

AppRegistry.registerComponent(appName, () => App);

至此,一个完整的React Native 错误采集库已集成完毕:

  • JS 层:未捕获异常会触发 globalErrorHandler,调用 ErrorReportModule.sendJSException
  • Native(Java)层:未捕获的 Java Exception 会触发 CrashHandler.uncaughtException,写文件并上报。
  • 所有日志先被写入本地文件,再通过异步线程逐条上传后删除,保证“至少一次”上报。

六、常见问题与最佳实践

  1. JS 异常捕获不到

    • 确认是否已在入口文件(index.js)最早阶段就引入了 jsExceptionHandler.js
    • 避免使用第三方框架(如 Redux-Saga)导致的异步错误没有抛出到全局。可在每个 saga 中添加 .catch()
  2. Native 层异常 handler 被覆盖

    • 某些第三方库(如 Crashlytics)会在 Application.onCreate() 中设置自己的 UncaughtExceptionHandler,导致我们的 Handler 无效。
    • 解决办法:在 MyApplication.onCreate() 中先获取系统默认 Handler,再将 Crashlytics 的 Handler 包裹或链式调用。
  3. 上报接口频繁失败

    • 当网络不可用时,上报会失败。建议在失败时保留本地日志,监听网络恢复后再重试。
    • 使用 OkHttp 的拦截器或 WorkManager 进行持久化重试。
  4. 日志文件过多导致存储空间不足

    • 定期(如应用启动时)扫描并删除超过一定时长(比如 7 天)的旧日志文件。
    • 或在每次写入时检查存储总量是否超限(如 10MB),若超则删除最早的若干文件。
  5. NDK 层 Crash 如何捕获

    • NDK Crash 需要使用 native Crash 处理库,如 Google BreakpadTencent Bugly NDK
    • 这些库会在本地生成 .apk_crash.so_crash 日志,再配合 Java 上传逻辑上报。

七、总结

本文系统性地介绍了 React Native 中的错误采集原理与 Android 平台的实现细节,主要包括:

  1. JS 层错误捕获:通过 ErrorUtils.setGlobalHandler 拦截所有未捕获的 JS 异常,并借助 NativeModules 将信息传递到 Android 端。
  2. Native 层错误捕获:使用 Thread.setDefaultUncaughtExceptionHandler 捕获 Java 未捕获异常,并写入本地文件和网络上报。
  3. 异步上报与本地存储:示例代码展示了如何将日志写入私有目录,并使用 OkHttp 在后台线程中将所有待上报日志逐条发送到服务器。
  4. 完整 Demo:整合各模块,提供一个可直接复制粘贴到项目中的错误采集库示例。
  5. 流程图解与最佳实践:帮助大家快速理解从 JS 到 Native 再到服务器的错误上报链路,以及实际落地时的注意事项。

通过本文,你应当能够在自己的 React Native 项目中快速集成错误采集功能,实时监控线上异常,并通过日志聚合与分析提升产品可靠性。

# ‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌React Native 直播新纪元:Pili Streaming Cloud SDK 全功能探索

随着移动直播需求的爆炸式增长,选择一款可靠、高性能的直播 SDK 至关重要。本文围绕 **Pili Streaming Cloud SDK** 在 React Native 中的完整集成与使用展开,内容包含环境配置、原生模块封装、示例代码、ASCII 图解与详细说明,帮助你更快上手并掌握直播推流与拉流的全流程。

---

## 一、前言与概览

Pili(七牛云)直播云服务提供强大的云端推流、CDN 分发与点播回看功能,官方发布了 Android、iOS 原生 SDK,并对 React Native 也提供了社区维护的封装包。本文示例基于:

- **React Native ≥ 0.65**  
- **Pili Streaming Cloud SDK v2**(2025 年最新版)  
- **社区封装库**:`react-native-pili`(适配 Pili SDK v2.x)  

我们将分步讲解:

1. **环境准备与依赖安装**  
2. **Android / iOS 原生配置**  
3. **React Native 模块封装与引入**  
4. **推流(Publisher)示例**  
5. **拉流(Player)示例**  
6. **进阶功能:连麦、切换摄像头、水印、音视频参数**  
7. **ASCII 图解:直播数据流动示意**  
8. **常见问题与最佳实践**

---

## 二、环境准备与依赖安装

### 2.1 创建基础 React Native 项目

```bash
# 全局安装 React Native CLI(若未安装)
npm install -g react-native-cli

# 创建项目
react-native init PiliLiveDemo
cd PiliLiveDemo

确保本地环境已经满足 React Native 官方文档的要求(Android Studio、Xcode、Node.js 等)。

2.2 安装 Pili React Native 封装包

社区封装的库名为 react-native-pili,同时需要安装对应的原生 SDK:

# 安装 React-Native Pili 封装
yarn add react-native-pili

# 或者
# npm install react-native-pili --save

该包会自动在 podspec 中拉取 iOS 原生依赖,并在 Android build.gradle 中引入 Pili SDK。如果遇到版本冲突,请参考文档手动对齐。


三、Android / iOS 原生配置

不同平台需做少量原生配置,保证 SDK 能正常工作。

3.1 iOS 配置

  1. Podfile 添加引入
    react-native-pili 会在 PiliReactNative.podspec 中声明依赖,但我们要确保最低 iOS 版本匹配:

    platform :ios, '11.0'
    
    target 'PiliLiveDemo' do
      use_frameworks!
      pod 'PiliReactNative', :path => '../node_modules/react-native-pili'
      # ... 其他 pods
    end
  2. Info.plist 权限
    为了使用摄像头与麦克风,需在 Info.plist 中添加:

    <key>NSCameraUsageDescription</key>
    <string>App 需要访问相机用于直播推流</string>
    <key>NSMicrophoneUsageDescription</key>
    <string>App 需要访问麦克风用于直播收音</string>
    <key>NSAppTransportSecurity</key>
    <dict>
      <key>NSAllowsArbitraryLoads</key><true/>
    </dict>
  3. Pod 安装与编译

    cd ios
    pod install
    cd ..
    react-native run-ios
    注意:如果启动时遇到 Undefined symbols for architecture arm64,请检查 PiliReactNative 与 Xcode Build Settings 中的 Excluded Architectures 配置。

3.2 Android 配置

  1. android/app/build.gradle 中添加依赖
    react-native-pili 已加入自动链接,但如需手动,则:

    dependencies {
      implementation project(':react-native-pili')
      // 或者直接引入 Pili SDK
      implementation 'cn.pili:pili-rtmp-sdk:2.5.0'
      implementation 'cn.pili:pili-rtc-sdk:2.0.1'
    }
  2. 添加摄像头/录音权限
    android/app/src/main/AndroidManifest.xml 中:

    <uses-permission android:name="android.permission.CAMERA" />
    <uses-permission android:name="android.permission.RECORD_AUDIO" />
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
  3. 导入 React-Pili 包
    settings.gradle 中:

    include ':react-native-pili'
    project(':react-native-pili').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-pili/android')

    MainApplication.java 中:

    import com.pili.rn.PiliReactPackage;  // 根据封装包实际命名
    
    @Override
    protected List<ReactPackage> getPackages() {
      return Arrays.<ReactPackage>asList(
        new MainReactPackage(),
        new PiliReactPackage()
        // ...其他包
      );
    }
  4. Gradle 同步与编译

    cd android
    ./gradlew clean
    ./gradlew assembleDebug
    cd ..
    react-native run-android

至此,基础环境与原生配置已完成,下面开始在 JS 侧进行直播推流与拉流示例。


四、推流(Publisher)示例

本文推流示例分为以下几步:

  1. 界面布局与权限申请
  2. 创建推流链接与 Stream Key
  3. 初始化推流组件并开启预览
  4. 开始/停止推流
  5. 切换摄像头、麦克风静音等功能

4.1 界面布局与权限申请

在 React Native 中,为了调用摄像头与麦克风,需要先动态申请权限(Android 与 iOS 差异不大,示例以 Android 为主)。

// src/PublisherScreen.js
import React, { useEffect, useRef, useState } from 'react';
import {
  View,
  Text,
  Button,
  StyleSheet,
  PermissionsAndroid,
  Platform,
  Alert,
} from 'react-native';
import { PiliPlayerView, PiliPublisher } from 'react-native-pili'; // 示例命名

export default function PublisherScreen() {
  const publisherRef = useRef(null);
  const [hasPermission, setHasPermission] = useState(false);
  const [isStreaming, setIsStreaming] = useState(false);

  // 申请权限
  const requestPermissions = async () => {
    if (Platform.OS === 'android') {
      try {
        const granted = await PermissionsAndroid.requestMultiple([
          PermissionsAndroid.PERMISSIONS.CAMERA,
          PermissionsAndroid.PERMISSIONS.RECORD_AUDIO,
        ]);
        const cameraOK = granted[PermissionsAndroid.PERMISSIONS.CAMERA] === PermissionsAndroid.RESULTS.GRANTED;
        const audioOK = granted[PermissionsAndroid.PERMISSIONS.RECORD_AUDIO] === PermissionsAndroid.RESULTS.GRANTED;
        if (cameraOK && audioOK) {
          setHasPermission(true);
        } else {
          Alert.alert('权限不足', '请在设置中开启相机和麦克风权限');
        }
      } catch (err) {
        console.warn(err);
      }
    } else {
      setHasPermission(true);
    }
  };

  useEffect(() => {
    requestPermissions();
  }, []);

  // 流地址与推流 key
  const publishUrl = 'rtmp://pili-publish.qiniuapi.com/myapp/'; 
  const streamKey = 'user_stream_key_123456';

  // 开始推流
  const startStreaming = () => {
    if (!publisherRef.current) return;
    publisherRef.current.start((error) => {
      if (error) {
        console.error('推流失败:', error);
        Alert.alert('推流失败', error.message);
      } else {
        setIsStreaming(true);
      }
    });
  };

  // 停止推流
  const stopStreaming = () => {
    if (!publisherRef.current) return;
    publisherRef.current.stop();
    setIsStreaming(false);
  };

  if (!hasPermission) {
    return (
      <View style={styles.center}>
        <Text>正在申请摄像头与麦克风权限...</Text>
      </View>
    );
  }

  return (
    <View style={styles.container}>
      {/* 1. 推流预览视图 */}
      <PiliPlayerView
        style={styles.preview}
        ref={publisherRef}
        publishUrl={publishUrl + streamKey}
        cameraId="front" // 默认前置
        audioEnable={true}
        videoEnable={true}
        bitrate={800 * 1024} // 800kbps
        resolution={{ width: 720, height: 1280 }}
        fps={15}
      />

      {/* 2. 控制按钮 */}
      <View style={styles.controls}>
        {!isStreaming ? (
          <Button title="开始推流" onPress={startStreaming} />
        ) : (
          <Button title="停止推流" onPress={stopStreaming} />
        )}
        <View style={styles.spacer} />
        <Button
          title="切换摄像头"
          onPress={() => {
            if (publisherRef.current) {
              publisherRef.current.switchCamera();
            }
          }}
        />
        <View style={styles.spacer} />
        <Button
          title={isStreaming ? '关闭麦克风' : '开启麦克风'}
          onPress={() => {
            if (publisherRef.current) {
              publisherRef.current.toggleMic(!isStreaming);
            }
          }}
        />
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#000' },
  center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
  preview: { flex: 1 },
  controls: {
    flexDirection: 'row',
    justifyContent: 'space-around',
    paddingVertical: 12,
    backgroundColor: '#111',
  },
  spacer: { width: 16 },
});

4.1.1 关键说明

  • 组件 PiliPlayerView

    • publishUrl:RTMP 推流地址,形式如 rtmp://{domain}/{app}/{streamKey},需先在 Pili 控制台创建 App 并生成 Stream Key。
    • cameraId:可选 frontback,默认使用前置摄像头。
    • bitrateresolutionfps:设置视频编码参数。
  • Ref 操作

    • 通过 ref 拿到原生模块实例,调用 start() / stop() / switchCamera() 等方法。
    • start(callback):启动推流,若失败通过回调返回错误信息。
  • 权限申请

    • Android 端需动态请求 CAMERARECORD_AUDIO,否则推流会报错。
    • iOS 在 Info.plist 中已声明,执行时系统会弹窗请求。

4.2 推流流程图解

┌───────────────────────────────────────────────────────────────┐
│                        React Native JS                        │
│  ┌────────────────────────┐    ┌──────────────────────────┐     │
│  │ PiliPlayerView (RN)    │    │ publisherRef.start()     │     │
│  │ - publishUrl: rtmp://…  │────▶ 调用 Native Module          │     │
│  │ - cameraId, bitrate…    │    │ (PiliPublisher.start)      │     │
│  └────────────────────────┘    └──────────────────────────┘     │
│                │                                         │     │
│                ▼                                         │     │
│  ┌───────────────────────────────────────────────────────┐ │     │
│  │ Android / iOS 原生层 (Pili SDK)                        │ │     │
│  │ ┌───────────────────────────────────────────────────┐ │ │     │
│  │ │ 摄像头采集 (Camera) / 麦克风采样 (AudioRecord)        │ │ │     │
│  │ └───────────────────────────────────────────────────┘ │     │
│  │                │                                        │     │
│  │                ▼                                        │     │
│  │ ┌───────────────────────────────────────────────────┐ │     │
│  │ │ 视频编码 (H.264) / 音频编码 (AAC)                  │ │     │
│  │ └───────────────────────────────────────────────────┘ │     │
│  │                │                                        │     │
│  │                ▼                                        │     │
│  │ ┌───────────────────────────────────────────────────┐ │     │
│  │ │ RTMP 推流:Pili Cloud (CDN)                       │ │     │
│  │ └───────────────────────────────────────────────────┘ │     │
│  │                │                                        │     │
│  │                ▼                                        │     │
│  │ ┌───────────────────────────────────────────────────┐ │     │
│  │ │ 观众端可通过拉流 URL (http://…/playlist.m3u8) 拉流   │ │     │
│  │ └───────────────────────────────────────────────────┘ │     │
│  └───────────────────────────────────────────────────────┘ │     │
└───────────────────────────────────────────────────────────────┘

五、拉流(Player)示例

Pili 也提供了高效的播放组件,可直接播放 RTMP、HLS 或 FLV 格式的直播流。下面示例演示在 React Native 中使用 PiliPlayer 进行直播拉流。

// src/PlayerScreen.js
import React, { useRef, useState } from 'react';
import { View, Text, Button, StyleSheet, ActivityIndicator } from 'react-native';
import { PiliPlayer } from 'react-native-pili';

export default function PlayerScreen() {
  const playerRef = useRef(null);
  const [status, setStatus] = useState('IDLE');

  // 播放地址,可为 RTMP / HLS / FLV
  const playUrl = 'http://pili-live-example.qiniuapi.com/live/streamkey/playlist.m3u8';

  // 开始拉流
  const startPlay = () => {
    if (!playerRef.current) return;
    setStatus('LOADING');
    playerRef.current.start((err) => {
      if (err) {
        console.error('拉流失败:', err);
        setStatus('ERROR');
      } else {
        setStatus('PLAYING');
      }
    });
  };

  // 停止拉流
  const stopPlay = () => {
    if (!playerRef.current) return;
    playerRef.current.stop();
    setStatus('STOPPED');
  };

  return (
    <View style={styles.container}>
      {/* 1. 拉流预览视图 */}
      <PiliPlayer
        style={styles.player}
        ref={playerRef}
        url={playUrl}
        autoPlay={false}
        muted={false}
      />

      {/* 2. 状态指示与加载指示 */}
      <View style={styles.statusContainer}>
        {status === 'LOADING' && <ActivityIndicator size="large" color="#fff" />}
        <Text style={styles.statusText}>状态:{status}</Text>
      </View>

      {/* 3. 控制按钮 */}
      <View style={styles.controls}>
        <Button title="开始播放" onPress={startPlay} />
        <View style={styles.spacer} />
        <Button title="停止播放" onPress={stopPlay} />
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#000' },
  player: { flex: 1 },
  statusContainer: {
    position: 'absolute',
    top: 20,
    left: 20,
    flexDirection: 'row',
    alignItems: 'center',
  },
  statusText: { color: '#fff', marginLeft: 8 },
  controls: {
    flexDirection: 'row',
    justifyContent: 'space-around',
    paddingVertical: 12,
    backgroundColor: '#111',
  },
  spacer: { width: 16 },
});

5.1 关键说明

  • 组件 PiliPlayer

    • url:拉流地址,可为 HLS(.m3u8)、RTMP(需要硬解码)或 FLV。
    • autoPlay:是否自动开始拉流,设为 false 提供手动控制。
    • muted:静音开关,适用于声音调试。
  • Ref 操作

    • playerRef.current.start(callback):开始拉流并回调状态。
    • playerRef.current.stop():停止拉流并释放资源。
  • 状态管理

    • status 用于在界面上显示当前播放器状态:IDLELOADINGPLAYINGERRORSTOPPED

六、进阶功能:连麦、切换摄像头、水印、音视频参数

Pili SDK 支持更丰富的功能,下面简要介绍常见进阶用法。

6.1 连麦 (Mix Stream)

连麦场景需要将多个推流合并到同一路流,通常分两步:

  1. 后台配置:在 Pili 控制台或通过 API 创建 “合流模版” (mix-template),指定连麦布局(画面分区)。
  2. 前端推流:推流端在初始化时带上 mixStreamID,Pili C++ SDK 会自动将推流信号与合流模板匹配。
// 连麦示例:连麦推流时带上 mixStreamID
<PiliPlayerView
  ref={publisherRef}
  publishUrl={publishUrl + streamKey}
  mixStreamID="mix_123"     // Pili 后台配置的合流 ID
  // ... 其他参数不变
/>

后台合流模板可设置画中画、九宫格、拼接等多种布局。推流端只需保证 mixStreamID 与后台一致,Pili 服务会自动合并。

6.2 水印

推流端可以在画面中添加图片或文字水印,示例:

<PiliPlayerView
  ref={publisherRef}
  publishUrl={publishUrl + streamKey}
  waterMark={{
    image: 'https://your.cdn.com/watermark.png',
    pos: 'top-left', // top-left / top-right / bottom-left / bottom-right
    x: 20,
    y: 20,
    width: 80,
    height: 80,
    alpha: 0.8,
  }}
  // ... 其他推流参数
/>

参数说明:

  • image:水印图片 URL 或本地资源引用(require('./wm.png'))。
  • pos:四角位置,可微调 x, y
  • width/height:水印尺寸。
  • alpha:透明度(0.0\~1.0)。

6.3 切换摄像头与闪光灯

推流过程中可以随时切换前后摄像头或打开闪光灯:

// 切换摄像头
publisherRef.current.switchCamera();

// 切换闪光灯
publisherRef.current.toggleTorch(true);  // 打开
publisherRef.current.toggleTorch(false); // 关闭

6.4 视频参数动态调节

在推流过程中可动态修改编码参数(分辨率、比特率、帧率、GOP):

// 修改分辨率
publisherRef.current.updateVideoResolution({ width: 480, height: 640 });

// 修改比特率
publisherRef.current.updateVideoBitrate(500 * 1024); // 500kbps

// 修改帧率
publisherRef.current.updateVideoFPS(20);

// 修改关键帧间隔(GOP)
publisherRef.current.updateVideoGOP(2); // 2 秒一个关键帧
注意:动态修改参数时,需确保推流网络能稳定支撑,否则可能出现推流抖动或断流。

七、ASCII 图解:直播数据流动示意

┌─────────────────────────────────────────────────────────┐
│                   React Native App                     │
│ ┌─────────────────────────────────────────────────────┐ │
│ │  PublisherScreen.js (推流)                            │ │
│ │  ┌─────────────────────────────────────────────────┐ │ │
│ │  │ PiliPlayerView (RN Component)                    │ │ │
│ │  │ - publishUrl: rtmp://…/app/streamKey              │ │ │
│ │  │ - switchCamera / toggleTorch / updateVideoXXX     │ │ │
│ │  └─────────────────────────────────────────────────┘ │ │
│ │                 │                                      │ │
│ │ start() 调用    ▼                                      │ │
│ │  (JS → Native) ┌─────────────────────────────────────┐ │ │
│ │                │ PiliPublisher (原生封装)             │ │ │
│ │                │ - 摄像头采集(Camera2 / AVCapture)      │ │ │
│ │                │ - 麦克风采样(AVAudioSession / AudioRec) │ │ │
│ │                │ - 视频编码(H.264) / 音频编码(AAC)      │ │ │
│ │                │ - RTMP 推流(Pili RTMP 推流库)          │ │ │
│ │                └─────────────────────────────────────┘ │ │
│ │                           │                              │ │
│ │                           ▼                              │ │
│ │        ┌──────────────────────────────────────────┐     │ │
│ │        │       Pili 云端 CDN & 流媒体服务器         │     │ │
│ │        │ - 接收 RTMP 流,触发 Service 拉取 & 分发     │     │ │
│ │        │ - HLS 录制与 CDN 分发                      │     │ │
│ │        └──────────────────────────────────────────┘     │ │
│ │                           │                              │ │
│ │                           ▼                              │ │
│ │        ┌──────────────────────────────────────────┐     │ │
│ │        │        观众端拉流 (PlayerScreen.js)       │     │ │
│ │        │  ┌────────────────────────────────────┐  │     │ │
│ │        │  │ PiliPlayer (RN Component)           │  │     │ │
│ │        │  │ - url: http://…/streamKey/playlist.m3u8 │ │     │ │
│ │        │  │ - start() / stop() / mute()          │  │     │ │
│ │        │  └────────────────────────────────────┘  │     │ │
│ │        │                  │                         │ │
│ │        │                  ▼                         │ │
│ │        │     ┌────────────────────────────────┐      │ │
│ │        │     │  PiliPlayer (原生封装 iOS / Android) │      │ │
│ │        │     │  - HLS 解码(AVPlayer / ExoPlayer)    │      │ │
│ │        │     │  - 视频渲染 (SurfaceView / UIView)   │      │ │
│ │        │     └────────────────────────────────┘      │ │
│ │        └──────────────────────────────────────────┘     │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
  • 推流链路:RN JS 层调用 PiliPublisher → 原生 SDK 采集/编码 → RTMP 推送到 Pili 云。
  • 拉流链路:RN JS 层调用 PiliPlayer → 原生播放器组件使用 HLS/RTMP 播放库解码渲染 → 画面呈现到界面。

八、常见问题与最佳实践

8.1 推流前常见调试要点

  1. 检查 RTMP 推流地址与 Stream Key

    • 在 Pili 控制台创建 App、创建流并记住 Stream Key。示例如:rtmp://pili-publish.qiniuapi.com/live/user123
  2. 网络、时延与带宽

    • 在 Wi-Fi 环境下测试时,注意带宽是否充足;在移动网络下,建议将码率控制在 500–800kbps 左右。
  3. 权限与系统兼容性

    • Android 6.0+ 需动态申请摄像头与麦克风权限;iOS 端需在 Info.plist 中正确声明。
  4. 测试日志输出

    • Pili SDK 原生层会打印推流/拉流状态日志,需在 Android Studio 或 Xcode Console 中查看,确认是否连上服务器。

8.2 拉流时延与自动重连

  • 拉流时可能会因为网络不稳产生卡顿或断流,建议:

    1. 监听网络状态:使用 NetInfo 监听网络变化,当网络恢复时自动重连。
    2. 播放器重连策略:在 onError 回调中尝试调用 playerRef.current.start() 重连,或在短时间(如 3 秒)后重新调用。

8.3 后台推流与前台服务

  • 在 Android 后台推流时,一旦应用进入后台,进程可能被系统杀死。若要保证后台长时间稳定推流,需要:

    1. 使用前台服务:配合 react-native-background-actions 启动 Foreground Service,并在通知栏显示 “正在直播中” 提示。
    2. 优化推流参数:背景中可适当降低分辨率与帧率,减少网络与 CPU 占用。
    3. 监测系统杀死事件:在 AppState 监听到 background 状态时,提前将推流参数切换为“静态图像或纯音频”,避免因为长时间无画面被系统回收。

8.4 iOS 多任务(Multitasking)与后台模式

  • iOS 进入后台后,非音频/VoIP/导航等模式的推流会被挂起。若希望在后台也能保持某些功能,可考虑:

    1. 开启 Audio Background Mode:Fake 播放一个无声音轨,让系统认为正在播放音频。
    2. 开启 Location Background Mode:Fake 启动持续定位服务,保持应用在后台存活。
    3. 静默推送:当需要从后台唤醒执行推流/断流逻辑时,可发送静默推送触发。
      但这些方案并不完全符合 Apple 审核要求,请谨慎评估使用场景。

九、总结

本文从 React Native 视角出发,全面讲解了如何在Pili Streaming Cloud SDK的加持下,实现高质量的直播推流与拉流功能。主要涵盖了:

  1. 环境与原生配置:包括 Android / iOS 原生依赖、动态权限申请。
  2. 推流模块(Publisher):示例代码展示推流预览、开始/停止推流、切换摄像头、水印、参数动态调整等功能点。
  3. 拉流模块(Player):示例代码展示播放 HLS/RTMP 流、显示加载状态、停止播放等。
  4. 进阶功能:连麦、合流、水印、动态视频参数、前台服务。
  5. ASCII 图解:直播数据流动全流程示意,从 RN JS → 原生采集编码 → Pili 云端 → 观众端解码渲染。
  6. 常见问题与最佳实践:推流前调试要点、后台运行限制、网络抖动优化、iOS 后台模式注意事项。

通过以上示例与说明,你已经具备了在 React Native 中集成 Pili SDK、快速搭建直播功能的能力。接下来可根据业务场景继续拓展:

  • 多路拉流与画中画:在同一个页面展示多个直播流或观众互动画面。
  • 连麦与 PK:使用 Pili 提供的混流功能,实现主播与主播、主播与观众连麦。
  • 自定义美颜与滤镜:引入 OpenGL / Metal 自定义渲染管线,叠加美颜滤镜。
  • 与 RTC 合作:结合 Pili RTC SDK,实现更低时延互动直播。
  • 运营策略:封装流量监控、统计 SDK 埋点、弹幕与聊天功能。
# ‌‌‌‌‌‌‌‌‌‌‌‌‌React Native 后台任务高效执行:Background Job 实战攻略

在移动应用中,后台任务(Background Job)非常常见,例如定时拉取数据、上传日志、地理位置跟踪、离线消息同步、播放音乐等。然而由于 iOS 与 Android 系统对后台执行的限制,各平台实现方式各异,且有诸多陷阱与注意事项。本文将从原理出发,结合实战代码示例与 ASCII 图解,帮助你在 React Native 中高效实现后台任务,覆盖常用场景与优化建议,让你快速上手并避免踩坑。

---

## 目录

1. [背景与挑战](#一-背景与挑战)  
2. [常见实现方案与对比](#二-常见实现方案与对比)  
   1. [Headless JS(仅 Android)](#21-headless-js仅-android)  
   2. [react-native-background-fetch](#22-react-native-background-fetch)  
   3. [react-native-background-task](#23-react-native-background-task)  
   4. [react-native-background-actions](#24-react-native-background-actions)  
3. [实战示例:周期性任务与一次性任务](#三-实战示例周期性任务与一次性任务)  
   1. [使用 react-native-background-fetch 周期性执行](#31-使用-react-native-background-fetch-周期性执行)  
   2. [使用 Headless JS 在 Android 端执行一次性任务](#32-使用-headless-js-在-android-端执行一次性任务)  
4. [高级技巧与注意事项](#四-高级技巧与注意事项)  
   1. [iOS Background Modes 配置](#41-ios-background-modes-配置)  
   2. [Android 前台服务与电池优化](#42-android-前台服务与电池优化)  
   3. [任务调度时机与系统限制](#43-任务调度时机与系统限制)  
5. [图解:后台任务整体执行流程](#五-图解后台任务整体执行流程)  
6. [总结与最佳实践](#六-总结与最佳实践)  

---

## 一、背景与挑战

在 React Native 应用中,我们时常需要让应用在**切换到后台**或**被系统杀死**后,仍能继续执行某些关键逻辑,如:

- **定时同步**:每隔一段时间从服务器拉取最新数据(天气、股票、消息推送等)。  
- **上传日志**:收集设备日志、用户行为,定期上报。  
- **音乐播放/计步**:即使应用在后台,也能持续播放音乐或统计步数。  
- **地理位置跟踪**:实时获取用户位置并上传,用于导航或物流场景。  

但在 iOS 与 Android 上实现后台任务面临诸多挑战:

1. **iOS 限制更严格**  
   - iOS 从 7.0 开始引入 **Background Modes**,只允许少数几类后台执行(例如 VoIP、音频播放、定位、蓝牙、后台 fetch)。  
   - 普通 JavaScript 代码在 iOS 端一旦进入后台很快就会被挂起,无法长时间存活。  
2. **Android 电池优化**  
   - Android 6.0+ 引入 Doze 模式,对后台进程有严格限制,系统会暂停网络和调度 Alarm,延后任务执行。  
   - 需要使用 前台服务(Foreground Service)或 JobScheduler / WorkManager 等来保障任务不被杀死。  
3. **React Native layer 的局限**  
   - JavaScript 线程只有在 App 未被系统回收时才会存活;若 App 被杀,JS 线程也将终止。  
   - 因此常借助 **Headless JS** 或原生模块来“唤醒” JS 执行任务。  

综上,需要结合多种技术手段:原生配置(Info.plist、AndroidManifest.xml)、第三方库、Headless JS、前台服务等,才能在 RN 中实现可靠的后台任务。

---

## 二、常见实现方案与对比

下面我们先简单对比几种常见方案及其优缺点,帮助你选择最适合自己场景的方法。

### 2.1 Headless JS(仅 Android)

- **原理**:在 Android 端,React Native 暴露了 Headless JS 接口,可以在应用被杀或在后台时,通过原生模块启动 JS 任务并执行一段逻辑。  
- **优点**:  
  - 无需第三方库,系统原生方案。  
  - 在 App 不在前台时,可以触发一次性任务。  
- **缺点**:  
  - 仅限 Android,iOS 不支持。  
  - 适合一次性执行任务(如接收到推送后执行上传),不适合严格定时任务。  

### 2.2 react-native-background-fetch

- **原理**:基于 iOS 的 `Background Fetch` API 和 Android 的 `JobScheduler` / `AlarmManager`,自动帮你调度周期性任务。  
- **优点**:  
  - 跨平台支持,iOS/Android 均可使用。  
  - 默认仅在系统空闲且网络可用时调度,节省电量。  
  - API 简单:只需注册回调即可。  
- **缺点**:  
  - 调度频率由系统决定,无法精确到秒级,iOS 端约 15 分钟一次,Android 受 Doze 影响也不保证精准。  
  - 不支持长时间持续任务,只适合“定时拉取”场景。  

### 2.3 react-native-background-task

- **原理**:通过原生模块封装 iOS/Android 的定时调度接口(iOS 的后台 fetch + Android 的 JobScheduler)实现周期性任务。  
- **优点**:  
  - 配置较简单,API 接近使用习惯。  
  - 支持在 App 被杀死后依旧能执行。  
- **缺点**:  
  - 库作者维护不够及时,社区问题较多。  
  - 同样无法保证精准定时。  

### 2.4 react-native-background-actions

- **原理**:使用 Android 的前台服务和 iOS 的长时后台模式(Audio/Location)来保持 JS 持续运行。  
- **优点**:  
  - 可以在后台长时间运行一个 JS 函数,适用于需要持续执行的场景(如计步、下载)。  
  - 支持进度通知栏提示(Android),保持服务存活。  
- **缺点**:  
  - iOS 端需要打开某个后台模式(如 Audio 或 Location),否则长任务会被暂停。  
  - 用户体验上需要告知用户正在后台运行,以防电量消耗引起投诉。  

---

## 三、实战示例:周期性任务与一次性任务

下面我们针对常见场景提供两个实战示例,一是**周期性任务**(定时拉取数据),二是**一次性后台任务**(在接收到系统事件后执行)。

### 3.1 使用 react-native-background-fetch 周期性执行

本节示例展示如何使用 `react-native-background-fetch` 实现每隔大约 15 分钟拉取一次服务器数据,既支持 iOS 也支持 Android。

#### 3.1.1 安装与原生配置

```bash
# 安装依赖
yarn add react-native-background-fetch
# 或 npm install react-native-background-fetch --save

iOS 配置

  1. ios/Podfile 中添加:

    pod 'react-native-background-fetch', :path => '../node_modules/react-native-background-fetch'
  2. 执行 cd ios && pod install && cd ..
  3. 在 Xcode 中打开 Info.plist,添加:

    <key>UIBackgroundModes</key>
    <array>
      <string>fetch</string>
    </array>
  4. AppDelegate.m#import "RNBackgroundFetch.h",并在 didFinishLaunchingWithOptions 中添加:

    [[RNBackgroundFetch sharedInstance] didFinishLaunching];
  5. application:performFetchWithCompletionHandler: 中添加:

    - (void)application:(UIApplication *)application performFetchWithCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler {
      [[RNBackgroundFetch sharedInstance] performFetchWithCompletionHandler:completionHandler];
    }

Android 配置

  1. android/app/src/main/AndroidManifest.xml 中,添加权限和服务:

    <uses-permission android:name="android.permission.WAKE_LOCK" />
    <application ...>
      ...
      <!-- 添加 BackgroundFetch 服务 -->
      <service android:name="com.transistorsoft.rnbackgroundfetch.HeadlessJobService" android:exported="false"/>
      <receiver android:enabled="true" android:exported="true" android:permission="android.permission.RECEIVE_BOOT_COMPLETED">
        <intent-filter>
          <action android:name="android.intent.action.BOOT_COMPLETED" />
          <action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
        </intent-filter>
      </receiver>
    </application>
  2. MainApplication.java 中:

    import com.transistorsoft.rnbackgroundfetch.RNBackgroundFetchPackage;
    // ...
    @Override
    protected List<ReactPackage> getPackages() {
      return Arrays.<ReactPackage>asList(
        new MainReactPackage(),
        new RNBackgroundFetchPackage()
      );
    }

3.1.2 JS 端代码示例

// src/backgroundFetchTask.js
import BackgroundFetch from 'react-native-background-fetch';
import { Alert } from 'react-native';

// 后台任务执行的逻辑
let backgroundFetchTask = async () => {
  console.log('[BackgroundFetch] Event received');
  try {
    // 1. 执行你的后台业务逻辑,例如拉取服务器最新数据
    let response = await fetch('https://api.example.com/data');
    let data = await response.json();
    console.log('[BackgroundFetch] Fetched data:', data);

    // 2. 根据业务需要,存储到本地数据库或发本地通知
    // ...
  } catch (error) {
    console.error('[BackgroundFetch] Fetch error:', error);
  }
  // 3. 告诉系统任务已完成
  BackgroundFetch.finish(BackgroundFetch.FETCH_RESULT_NEW_DATA);
};

// 注册 Headless JS 任务(Android 被系统杀死后仍能执行)
BackgroundFetch.registerHeadlessTask(backgroundFetchTask);

export default backgroundFetchTask;
// src/App.js
import React, { useEffect } from 'react';
import { View, Text, StyleSheet, Button, Alert } from 'react-native';
import BackgroundFetch from 'react-native-background-fetch';
import backgroundFetchTask from './backgroundFetchTask';

export default function App() {
  useEffect(() => {
    initBackgroundFetch();
  }, []);

  const initBackgroundFetch = async () => {
    // 请求权限并配置任务参数
    const status = await BackgroundFetch.configure(
      {
        minimumFetchInterval: 15, // 以分钟为单位,iOS 最小为 15 分钟
        stopOnTerminate: false,   // 应用被杀时是否停止任务
        enableHeadless: true,     // Android: 允许被杀死后 Headless 执行
        startOnBoot: true,        // Android: 重启后自动启动
        requiredNetworkType: BackgroundFetch.NETWORK_TYPE_ANY, // 任意网络
      },
      // 成功回调
      async (taskId) => {
        console.log('[BackgroundFetch] taskId:', taskId);
        await backgroundFetchTask(); // 执行实际逻辑
        BackgroundFetch.finish(taskId);
      },
      // 失败回调
      (error) => {
        console.error('[BackgroundFetch] configure error:', error);
      }
    );

    console.log('[BackgroundFetch] configure status:', status);
    if (status !== BackgroundFetch.STATUS_AVAILABLE) {
      Alert.alert('BackgroundFetch 权限不可用:' + status);
    }
  };

  // 手动触发测试
  const onTestPress = async () => {
    console.log('[BackgroundFetch] Performing manual fetch');
    await backgroundFetchTask();
  };

  return (
    <View style={styles.container}>
      <Text style={styles.title}>React Native 后台任务演示</Text>
      <Button title="手动触发后台任务" onPress={onTestPress} />
      <Text style={styles.note}>
        后台任务会在应用切换至后台或系统调度时自动触发,无法保证绝对定时。
      </Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 16 },
  title: { fontSize: 20, fontWeight: 'bold', marginBottom: 16 },
  note: { marginTop: 12, color: '#666', textAlign: 'center' },
});

3.1.3 运行与验证

  1. 启动应用

    yarn android   # 或 yarn ios
  2. 切换至后台,等待 \~15 分钟后查看日志输出(Android Studio Logcat / Xcode Console)。
  3. 手动触发:点击 “手动触发后台任务” 按钮,立即执行 backgroundFetchTask,查看日志。
提示:iOS 模拟器不完全支持后台 Fetch,请在真机上测试;Android 真机或部分模拟器可用。

3.2 使用 Headless JS 在 Android 端执行一次性任务

Headless JS 适用于:应用在后台或被系统杀死后,接收推送通知(如 FCM)、系统事件(如 Boot Completed)后执行一次性 JS 逻辑。下面演示一个在 Android 端监听 BootCompleted 后执行清理缓存的示例。

3.2.1 Android 原生配置

  1. 创建 Headless 服务
    android/app/src/main/java/com/myapp/HeadlessTask.java 中:

    package com.myapp;
    
    import android.content.Intent;
    import android.util.Log;
    import com.facebook.react.HeadlessJsTaskService;
    
    public class HeadlessTask extends HeadlessJsTaskService {
      @Override
      protected void onStartCommand(Intent intent, int flags, int startId) {
        Log.d("HeadlessTask", "Boot Completed, starting headless task");
        // 参数:任务名称,对应 JS registerTask
        startTask("BootCleanupTask", null);
        return super.onStartCommand(intent, flags, startId);
      }
    }
  2. 注册广播接收器
    AndroidManifest.xml 中添加:

    <receiver android:name=".BootReceiver" android:exported="true">
      <intent-filter>
        <action android:name="android.intent.action.BOOT_COMPLETED" />
      </intent-filter>
    </receiver>
    <service android:name=".HeadlessTask" android:exported="false" />

    然后新建 BootReceiver.java

    package com.myapp;
    
    import android.content.BroadcastReceiver;
    import android.content.Context;
    import android.content.Intent;
    import android.util.Log;
    
    public class BootReceiver extends BroadcastReceiver {
      @Override
      public void onReceive(Context context, Intent intent) {
        if (Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())) {
          Log.d("BootReceiver", "Device Boot Completed");
          Intent serviceIntent = new Intent(context, HeadlessTask.class);
          context.startService(serviceIntent);
        }
      }
    }
  3. MainApplication.java 注册 Headless JS 任务

    import com.facebook.react.HeadlessJsTaskService;
    // ...
    public class MainApplication extends Application implements ReactApplication {
      @Override
      public void onCreate() {
        super.onCreate();
        SoLoader.init(this, /* native exopackage */ false);
        // 无需额外操作,HeadlessJsTaskService 会读取 JS 端注册
      }
    }

3.2.2 JS 端注册 Headless 任务

// src/bootCleanupTask.js
import { AppRegistry } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';

const BootCleanupTask = async () => {
  console.log('[BootCleanupTask] Started');

  try {
    // 执行缓存清理逻辑
    await AsyncStorage.clear();
    console.log('[BootCleanupTask] Cache cleared successfully');
  } catch (error) {
    console.error('[BootCleanupTask] Error clearing cache:', error);
  }
  // 任务完成后不需显式调用 finish,HeadlessJsTaskService 会自动结束
};

// 在 JS 入口(index.js)中注册任务
AppRegistry.registerHeadlessTask('BootCleanupTask', () => BootCleanupTask);

说明

  • 当 Android 系统启动完成后,BootReceiver 会接收到 BOOT_COMPLETED 事件,启动 HeadlessTask 服务;
  • HeadlessTask 在原生端调用 startTask("BootCleanupTask") 唤醒 JS 引擎执行 BootCleanupTask
  • JS 端清理完缓存后,服务自动结束。

3.2.3 测试步骤

  1. 安装应用并授予接收开机启动权限:

    adb shell pm grant com.myapp android.permission.RECEIVE_BOOT_COMPLETED
  2. 重启设备(或模拟器)
  3. 在 Android Studio Logcat 中搜索 BootCleanupTask 日志,验证是否成功触发。

四、高级技巧与注意事项

实际项目中,后台任务需要结合系统限制与用户体验进行优化。下面列出若干高级技巧与常见注意事项。

4.1 iOS Background Modes 配置

iOS 对后台执行做了严格限制,仅允许以下几类模式:

  1. Background Fetch

    • 允许系统定期唤醒应用进行数据拉取。无需长时间驻留,系统根据使用频率与用户习惯动态调整频率。
  2. Push Notifications

    • 收到静默推送后可短暂唤醒应用,执行少量任务(如同步)。静默推送需在 aps 里加 "content-available": 1
  3. VoIP / Audio / Location / Bluetooth

    • 如果应用有持续播放音频、实时语音通话、持续定位、蓝牙外设通信等需求,可在 Xcode 项目的 Background Modes 中勾选相应权限,使 App 在后台保持运行。

示例:配置静默推送
Info.plist 中:

<key>UIBackgroundModes</key>
<array>
  <string>location</string>
  <string>remote-notification</string>
</array>

推送 payload:

{
  "aps": {
    "content-available": 1
  },
  "customData": { ... }
}

iOS 收到该推送后会调用 AppDelegate 的 didReceiveRemoteNotification:fetchCompletionHandler:,可在 JS 端通过 react-native-push-notification 或自定义桥接来执行静默任务。

4.2 Android 前台服务与电池优化

对于需要持续运行的后台任务(如 GPS 跟踪、音乐播放),仅靠 Headless JS 或 BackgroundFetch 不够,需要前台服务(Foreground Service)

  • 前台服务:在 Android 上运行一个常驻服务,必须携带通知栏通知,告知用户应用在后台持续执行任务。示例库:react-native-background-actionsreact-native-foreground-service

    import BackgroundService from 'react-native-background-actions';
    
    const options = {
      taskName: 'LocationTracking',
      taskTitle: '正在后台跟踪位置',
      taskDesc: '应用正在后台记录你的位置信息',
      taskIcon: {
        name: 'ic_launcher',
        type: 'mipmap',
      },
      parameters: {
        interval: 10000, // ms
      },
    };
    
    const veryIntensiveTask = async (taskData) => {
      const { interval } = taskData;
      for (let i = 0; BackgroundService.isRunning(); i++) {
        // 获取位置并发送到服务端
        const location = await Geolocation.getCurrentPosition();
        await fetch('https://api.example.com/loc', {
          method: 'POST',
          body: JSON.stringify(location),
        });
        await new Promise((resolve) => setTimeout(resolve, interval));
      }
    };
    
    // 启动前台服务
    const startService = async () => {
      await BackgroundService.start(veryIntensiveTask, options);
    };
    
    // 关闭服务
    const stopService = async () => {
      await BackgroundService.stop();
    };
  • 电池优化 (Doze / App Standby):Android 6.0+ 默认启用 Doze,会暂停普通后台定时任务,若不加入白名单,任务可能被延迟。可在用户引导页使用:

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
      Intent intent = new Intent();
      String packageName = context.getPackageName();
      PowerManager pm = (PowerManager) context.getSystemService(Context.POWER_SERVICE);
      if (!pm.isIgnoringBatteryOptimizations(packageName)) {
        intent.setAction(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS);
        intent.setData(Uri.parse("package:" + packageName));
        context.startActivity(intent);
      }
    }

    让用户手动给予“忽略电池优化”权限。但需要注意,这会影响应用在后台运行的稳定性且会被部分用户拒绝。

4.3 任务调度时机与系统限制

  • iOS Fetch 时间不准:iOS 会根据用户使用情况、系统电量等智能调整后台 Fetch 的触发间隔,一般在 15 分钟到几小时不等,开发者无法硬性控制。
  • Android JobScheduler / WorkManager 延迟:受 Doze 和 App Standby 限制,任务可能被延迟,为了提高执行几率,尽量使用前台服务或请求设备保持唤醒。
  • 避免长时间占用 JS 线程:即使后台 JS 唤醒,也要保证任务执行尽快结束,并在完成后调用 BackgroundFetch.finish() 或停止 Headless 任务,否则系统会认为任务超时并可能对应用后台行为做限制。

五、图解:后台任务整体执行流程

下面用 ASCII 图解展示一个典型的“后台 Fetch + Headless JS + 前台 Service”混合场景,帮助理清逻辑流程。

┌───────────────────────────────┐
│         用户打开 APP           │
│     (React Native JS 线程启动)  │
└───────────────────────────────┘
                │
                ▼
┌────────────────────────────────────┐
│  初始化 BackgroundFetch & Headless  │
│  - 配置周期性 Fetch (iOS/Android)   │
│  - 注册 BootCompleted 等 Headless   │
└────────────────────────────────────┘
                │
                ▼
      用户切换 APP 至后台或退出
                │
          (进入后台/被杀)      
                │
          iOS / Android 系统      
                ▼
┌───────────────────────────────┐     ┌─────────────────────────────┐
│  iOS 系统调度 Background Fetch  │     │ Android 系统调度 JobScheduler │
│   (约 15 分钟间隔,Fetch 回调)   │     │  或 AlarmManager / WorkManager │
└───────────────────────────────┘     └─────────────────────────────┘
                │                           │
       回调触发 JS Fetch 任务             HeadlessJS 或 Alarm
                │                           │
                ▼                           ▼
┌───────────────────────────┐      ┌────────────────────────────────┐
│  JS 线程唤醒执行 fetchTask │<─────│ Headless JS Service (Android) │
│  - 拉取数据,处理逻辑        │      │ - 接收到推送 / BootCompleted   │
│  - 调用 BackgroundFetch.finish() │  │ - 调用 JS Task 注册回调         │
└───────────────────────────┘      └────────────────────────────────┘
                │                           │
                ▼                           ▼
┌───────────────────────────────┐  (如需长期运行) 
│ 上传数据 / 本地存储 / 发本地通知   │◀────────────────────────────────┐
└───────────────────────────────┘     在 Android 使用
                │                      ForegroundService 等
                ▼
┌───────────────────────────────┐
│     后台任务完成,JS 线程挂起     │
└───────────────────────────────┘
  1. 初始化阶段:应用一启动就配置好 BackgroundFetch,并在 Android 上注册 HeadlessJS 任务(如 BootCleanupTask)。
  2. 系统调度阶段:iOS 每隔约 15 分钟唤醒应用执行后台 Fetch;Android 通过 JobScheduler / AlarmManager 调度 HeadlessJS
  3. 执行阶段:系统通过回调将控制权交给 JS 线程,执行注册的 fetchTaskHeadless Task,完成数据拉取 / 清理 / 上传等操作。
  4. 前台服务场景:若需要持续执行(如位置跟踪),可在应用进入后台时启动 Android Foreground Service,时刻保持 JS 线程运行。
  5. 收尾阶段:一旦任务执行完毕,调用相关回调(BackgroundFetch.finish())或让 HeadlessJS 服务自动停止,减少电量消耗。

六、总结与最佳实践

本文介绍了在 React Native 中实现后台任务的多种方案,涵盖:

  • 周期性任务:使用 react-native-background-fetch 实现 iOS/Android 平台上的定时拉取。
  • 一次性任务:利用 Android 的 Headless JS 在系统事件(如开机)时执行逻辑。
  • 持续后台任务:通过 Android 前台服务(Foreground Service)保持 JS 线程持续运行,适用于 GPS 跟踪、音乐播放等场景。
  • iOS 特殊限制:通过 Background Modes(静默推送、后台 fetch、定位、音频等)保持应用后台执行能力。

最佳实践与注意事项:

  1. 尽量减少后台任务耗时

    • 后台执行时间有限,尤其是 iOS,无法长时间运行。尽快完成任务,并调用 finish()
  2. 合理使用前台服务

    • 对于需要持续后台运行的场景(如计步、导航),在 Android 上必须使用前台服务并显示持续通知;iOS 上需使用合规的后台模式(如 audio / location)。
  3. 尊重用户体验与电量

    • 后台任务会消耗系统资源,过度使用会导致电量快速下降,影响用户体验。
    • 在用户设置中允许“关闭后台刷新”时,要优雅降级或提醒用户手动打开。
  4. 监测并捕获异常

    • 后台任务异常崩溃可能导致后续任务无法执行,需加上全局异常捕获与日志上报机制。
  5. 依赖系统调度,不要期望秒级定时

    • 系统会根据电量、使用频率动态调整后台执行间隔。不要指望精准到秒级的定时任务,可在前台唤醒时做补偿。

通过本文的示例与图解,你应该能在 React Native 中灵活运用上述技术,实现大多数后台需求。如果项目对后台执行有更严格的实时性要求,建议在原生端结合 JobScheduler / WorkManager(Android)或 PushKit / BGAppRefreshTask(iOS 13+)来进行更精细的控制,并在必要时编写自定义原生模块进行扩展。