陌上人如玉
公子世无双

10.15 React Native / Expo 概览

10.15 React Native / Expo 概览

一句话:React Native 不是「把 React 跑在浏览器里」,而是「换掉渲染器(react-dom → 原生组件)+ 换掉宿主环境(DOM → Native)」——所以「React 的心智模型(组件/状态/Hooks/单向数据流)完全通用」,但「没有 DOM、没有 CSS、没有 URL」意味着 Web 的很多习惯必须换掉;而 Expo 是「把 RN 的原生工程复杂度封装掉」的工具链。

一、本质:同一个协调器,不同的「宿主配置」

⭐⭐⭐ RN 与 react-dom 的关系(理解这一点,后面全都顺):

   React 的架构分两层:
     ① **reconciler**(协调器)——「diff、Fiber、优先级、Hooks、Suspense」
        ⭐ 这一层是**共享的**(`react-reconciler` 包)
     ② **host config**(宿主配置)——「怎么把变更落到具体平台」
        · react-dom → 操作 DOM 节点
        · react-native → 操作原生视图(UIView / android.view.View)
        · react-three-fiber → 操作 WebGL 场景
        · react-pdf / react-test-renderer → 各自的宿主

   ⭐ 类比:一个「虚拟 DOM 的操作系统内核」+ 「多个驱动」。
     RN 就是「把驱动换成原生视图的那个 React」。

   ⭐ 所以:
     ✅ 通用的:组件/Props/State/Hooks/Context/Suspense/错误边界/单向数据流/不可变更新
     ❌ 不通用的:DOM API、CSS、`<div>`、`localStorage`、URL/History、`window`
⭐⭐⭐ RN 架构演进(面试高频,也是「为什么现在更快」的答案):

【旧架构(Bridge)】
   JS 线程 ──(异步 JSON 消息)── Bridge ──→ Native 线程
   · ⭐ **异步**:JS 无法「同步读」原生值(如「读取当前滚动位置」要回调)
   · ⭐ **序列化开销**:每次通信都要 JSON 序列化/反序列化
   · ⭐ **队列拥堵**:Bridge 是单队列,密集通信会「排队」(掉帧的常见原因)
   · 三个线程:JS / Shadow(算布局)/ Native(UI)

【新架构(0.76 起默认启用)】
   · **JSI(JavaScript Interface)**⭐ 核心
     JS 引擎可以直接持有 **C++ 对象的引用**,**同步调用**、**无需序列化**
     → 让「同步读原生值」「直接用 C++ 写的高性能库」成为可能
   · **Fabric(新渲染器)**
     C++ 实现;支持「同步布局」、「中断式渲染」、优先级(⭐ 与 React 并发特性对齐)
   · **TurboModules**
     新的原生模块系统:**懒加载**(用到才加载)+ 走 JSI(更快)
   · **Codegen**
     根据 TypeScript 类型「生成原生接口代码」→ ⭐ 「类型安全的原生模块」(不再手写桥接样板)
   · **Hermes**(默认 JS 引擎)
     为 RN 优化:启动快、内存小、**支持「字节码预编译」**(发布时不带 JS 源码)
   · **Bridgeless**
     完全移除 Bridge(新架构的最终形态)

   ⭐ 一句话总结收益:「**同步调用 + 更少序列化 + 可中断渲染 + 更快启动**」。

二、与 Web 的核心差异:一张表 + 五段代码

维度 Web(react-dom) React Native
基础元素 div/span/img/a View/Text/Image/Pressable
样式 CSS(选择器 + 级联) ⭐ JS 对象 + StyleSheet.create(无级联、无选择器)
布局 CSS 布局(含 grid/float) Yoga(Flexbox 子集),⚠️ flexDirection 默认 column
单位 px/rem/em/% ⭐ 无单位数字(dp);% 支持但父容器必须有确定尺寸
滚动 页面天然滚动 ⭐ ScrollView/FlatList(没有「页面滚动」)
列表 自己实现 / 虚拟滚动库 FlatList/SectionList/FlashList(内建虚拟化)
导航 URL + History React Navigation(Stack/Tab/Drawer)
存储 localStorage/IndexedDB AsyncStorage/MMKV/expo-sqlite
网络 fetch(自动带 cookie) fetch(⚠️ 无 cookie,token 要自己管)
动画 CSS/WAAPI Animated / Reanimated(可在 UI 线程)
无障碍 aria-* accessibilityLabel/accessibilityRole(⭐ 新版也支持 aria-*)
文本 任意元素可含文本 ⚠️ 文本必须包在 <Text> 里(否则报错)
阴影 box-shadow ⚠️ iOS 用 shadow*,Android 用 elevation(新版支持 boxShadow)
调试 DevTools Flipper / React DevTools / Metro 日志
热更新 HMR(模块级) Fast Refresh(组件级)
发布 部署静态文件 ⭐ 打包成 app(App Store/Play)+ OTA 更新 JS
// ✅ 差异 1:基础元素与样式写法
// Web
<div style={{ display: 'flex', flexDirection: 'row', gap: 8, padding: 16 }}>
  <span style={{ fontWeight: 'bold', color: '#1677ff' }}>标题</span>
</div>

// React Native
import { View, Text, StyleSheet } from 'react-native';
<View style={styles.row}>
  <Text style={styles.title}>标题</Text>
</View>

const styles = StyleSheet.create({
  row: {
    // ⚠️ RN 里 flex 是「默认开启」的,且 flexDirection 默认是 'column'
    flexDirection: 'row',
    gap: 8,                 // ⭐ RN 0.71+ 支持 gap
    padding: 16,            // ⚠️ 无单位(不是 '16px')
  },
  title: {
    fontWeight: 'bold',
    color: '#1677ff',
    // ⚠️ 没有「继承」:字号/颜色必须写在使用它的 Text 上
  },
});
// ✅ 差异 2:布局的「两个致命差异」
// ① flexDirection 默认 column(Web 默认 row)
// ② 「百分比高度」需要「父容器有确定尺寸」

// ❌ 这段在 RN 里「什么都不显示」
<View style={{ flex: 1 }}>
  <View style={{ height: '50%' }} />       {/* ⚠️ 父容器没有明确高度的话,% 无效 */}
</View>

// ✅ 正确:用 flex 或者给父容器明确尺寸
<View style={{ flex: 1 }}>
  <View style={{ flex: 1 }} />             {/* 占满剩余空间 */}
  <View style={{ flex: 1 }} />
</View>

// ✅ 或者用 Dimensions / useWindowDimensions 拿到具体数值
import { useWindowDimensions } from 'react-native';
function HalfScreen() {
  const { height } = useWindowDimensions();
  return <View style={{ height: height / 2 }} />;
}
// ✅ 差异 3:文本必须包在 <Text> 里(且 Text 不能有 View 子节点)
// ❌ 报错:Text strings must be rendered within a <Text> component
<View>Hello</View>

// ✅
<View><Text>Hello</Text></View>

// ⚠️ 反过来也不行:Text 里不能直接放 View
<Text><View /></Text>                       // ❌
// ✅ Text 里可以嵌套 Text(用于「部分加粗/变色」)
<Text>共 <Text style={{ fontWeight: 'bold' }}>3</Text> 项</Text>
// ✅ 差异 4:列表(RN 内建虚拟化)
import { FlatList } from 'react-native';

<FlatList
  data={items}
  keyExtractor={(item) => item.id}
  renderItem={({ item }) => <Row item={item} />}
  // ⭐ 性能相关(RN 里比 Web 更需要注意,因为「渲染」成本更高)
  initialNumToRender={10}
  maxToRenderPerBatch={10}
  windowSize={7}
  removeClippedSubviews          // ⚠️ Android 上有效,但可能有「空白」问题
  getItemLayout={(_, index) => ({ length: 56, offset: 56 * index, index })}   // ⭐ 定高时大幅提速
  onEndReached={loadMore}
  onEndReachedThreshold={0.5}
  ListEmptyComponent={<Empty />}
  ListFooterComponent={loading ? <Spinner /> : null}
/>
// ✅ 差异 5:动画(Reanimated 可以让动画「完全跑在 UI 线程」)
import Animated, { useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';

function Draggable() {
  // ⭐ shared value:存在于「UI 线程」的值(JS 线程读不到实时值)
  const offset = useSharedValue({ x: 0, y: 0 });

  const style = useAnimatedStyle(() => ({
    // ⭐ 这段「worklet」在 UI 线程执行 → 即使 JS 线程卡顿,动画依然流畅
    transform: [{ translateX: offset.value.x }, { translateY: offset.value.y }],
  }));

  return (
    <Animated.View style={style} />
  );
}

// ⭐ 这与 Web 的「合成线程动画」是同一个思想(见 5.17):
//   把「每帧的更新」从「主(JS)线程」搬到「渲染线程」

三、Expo:把原生复杂度封装掉

⭐⭐⭐ Expo 的四层能力(理解这个就能判断「什么时候需要 eject」):

① 【Expo SDK】
   一大套「开箱可用的原生能力」的 JS API:
   expo-camera / expo-location / expo-notifications / expo-image / expo-router …
   ⭐ 好处:不用「找库 + 手动链接原生代码 + 配 Xcode/Gradle」

② 【EAS Build / EAS Update】
   · **EAS Build**——⭐ **云端构建**(不用本地装 Xcode/Android Studio)
     · 支持「凭据管理」「多环境(dev/preview/prod)」「CI 集成」
   · **EAS Update**——⭐ **OTA 更新 JS bundle**
     ⚠️ **只能更新 JS/资源,不能更新「原生代码」**(原生变了必须重新发版)
     ⭐ 能力:按「渠道/版本/用户」灰度发布 JS(见 8.9)

③ 【三种工作流】(决定「你能改多少原生代码」)
   · **Expo Go**——用官方 App 跑你的 JS
     ✅ 零配置、秒级预览;⚠️ 只能用 SDK 里已有的原生模块(不能加自定义原生代码)
   · **Development Build**(⭐ 现在的推荐)——构建「你自己的 App」但带 Expo 工具链
     ✅ 可以用任意原生库、有调试菜单、支持 Expo 的所有能力
   · **Bare / Prebuild**——生成 `ios/` 与 `android/` 目录,完全自己管
     ✅ 最大控制权;⚠️ 升级 RN/Expo 版本要自己处理原生差异
   ⭐ 关键概念:**Config Plugins**——「用 JS 声明式地修改原生配置」
     (如「加一个权限」「改 Info.plist」),这样「不 eject 也能定制原生」

④ 【Expo Router】
   文件路由(与 Next.js 类似):
     app/index.tsx          → /
     app/about.tsx          → /about
     app/user/[id].tsx      → /user/:id
     app/(tabs)/_layout.tsx → 标签导航
   ⭐ 好处:「路由」变成「文件结构」,且支持「深链接(Deep Link)自动映射」
⭐⭐⭐ 「什么时候需要自定义原生代码」(Expo 上最常见的决策):

   需要 → 用 **Development Build**(或 Prebuild):
     · 用了「Expo SDK 之外的原生库」(如第三方 SDK:支付/地图/推送/统计)
     · 需要「自定义原生权限/配置」(用 **Config Plugin** 声明式地加)
     · 需要「写原生模块」(用 **TurboModule + Codegen**)
     · 需要「改启动图/图标/签名」
   ⭐ 反过来:如果你「只用 Expo SDK 里的能力」→ **Expo Go 就能开发**
     (但要发布上线,仍然建议 Development Build:更接近真实环境)

   ⚠️ 一个常见误解:「用了 Expo 就不能自定义原生」
     ❌ 不对——**Config Plugins + Development Build** 让「不 eject 也能定制」。
     真正「不能」的是「Expo Go 里跑自定义原生代码」。

四、完整实现:一套「Web 与 RN 共享代码」的策略(230 行)

⭐⭐⭐ 共享策略的三层(从「完全共享」到「完全分叉」):

   ① 【100% 共享】纯逻辑(无平台 API)
      数据转换、校验、格式化、状态机、业务规则、类型定义
      ⭐ 这些「与渲染无关」,两端完全一样

   ② 【接口共享、实现分叉】平台适配层
      · 用「平台特定文件」:`Button.native.tsx` / `Button.web.tsx`
      · 用「适配接口」:`getStorage()` / `showToast()` / `navigate()`
      · 用 `Platform.select({ ios, android, web, default })`

   ③ 【完全分叉】真正平台专属的东西
      · 原生权限、推送、蓝牙、生物识别、文件系统路径
      · 平台特有的交互(长按菜单、Swipe 手势、Tab bar 样式)
      ⭐ 结论:「能共享逻辑就共享逻辑,该分叉就大方分叉」——
        不要为了「代码统一」把两端的体验都做差
// ✅ 层 1:纯逻辑共享(packages/shared/)
// ⭐ 关键:不 import 任何平台 API(不引 react-dom、不引 react-native)
export interface CartItem {
  id: string;
  name: string;
  price: number;
  qty: number;
}

/** ⭐ 纯函数:两端完全一样(可单测) */
export function calcCartTotal(items: CartItem[], coupon: string | null): number {
  const sum = items.reduce((acc, i) => acc + i.price * i.qty, 0);
  if (coupon === 'HALF') return sum / 2;
  if (coupon === 'MINUS10') return Math.max(0, sum - 10);
  return sum;
}

/** ⭐ 业务校验也共享(表单两端同一套规则) */
export function validateEmail(email: string): string | null {
  if (!email) return '邮箱必填';
  if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return '邮箱格式不正确';
  return null;
}

/** ⭐ 状态机也共享(用 useReducer 的 reducer 是纯函数) */
export function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case 'add': {
      const existing = state.items.find((i) => i.id === action.item.id);
      return existing
        ? { ...state, items: state.items.map((i) => (i.id === action.item.id ? { ...i, qty: i.qty + 1 } : i)) }
        : { ...state, items: [...state.items, { ...action.item, qty: 1 }] };
    }
    case 'remove':
      return { ...state, items: state.items.filter((i) => i.id !== action.id) };
    default:
      return state;
  }
}
// ✅ 层 2:平台适配层(定义接口,两端各自实现)
// packages/shared/adapters.ts —— ⭐ 只定义「接口」,不实现
export interface PlatformAdapters {
  storage: {
    get: (key: string) => Promise<string | null>;
    set: (key: string, value: string) => Promise<void>;
    remove: (key: string) => Promise<void>;
  };
  toast: {
    show: (message: string, type?: 'info' | 'error') => void;
  };
  haptic: {
    light: () => void;
  };
  /** 打开外部链接(Web 用 window.open / location;RN 用 Linking) */
  openUrl: (url: string) => Promise<void>;
  /** 读取「安全区」(RN 有刘海/底部横条;Web 为 0) */
  getSafeAreaInsets: () => { top: number; bottom: number; left: number; right: number };
}

// ⭐ 用一个 Context 注入(而不是「直接 import 平台代码」)
import { createContext, useContext } from 'react';
export const AdaptersContext = createContext<PlatformAdapters | null>(null);

export function useAdapters(): PlatformAdapters {
  const ctx = useContext(AdaptersContext);
  if (!ctx) throw new Error('AdaptersContext 未提供:请在应用根部注入平台适配器');
  return ctx;
}
// ✅ 层 2 的实现(Web 侧)
// apps/web/src/adapters.ts
import type { PlatformAdapters } from '@acme/shared/adapters';

export const webAdapters: PlatformAdapters = {
  storage: {
    get: async (key) => localStorage.getItem(key),
    set: async (key, value) => localStorage.setItem(key, value),
    remove: async (key) => localStorage.removeItem(key),
  },
  toast: {
    show: (message, type) => {
      // 用现有的 Toast 组件(见 11.15)
      window.dispatchEvent(new CustomEvent('toast', { detail: { message, type } }));
    },
  },
  haptic: {
    // ⭐ Web 用 Vibration API(支持有限)
    light: () => navigator.vibrate?.(10),
  },
  openUrl: async (url) => { window.open(url, '_blank', 'noopener,noreferrer'); },
  getSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
};
// ✅ 层 2 的实现(RN 侧)
// apps/mobile/src/adapters.ts
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as Haptics from 'expo-haptics';
import * as Linking from 'expo-linking';
import { Platform, ToastAndroid, Alert } from 'react-native';
import { initialWindowMetrics } from 'react-native-safe-area-context';
import type { PlatformAdapters } from '@acme/shared/adapters';

export const nativeAdapters: PlatformAdapters = {
  storage: {
    get: (key) => AsyncStorage.getItem(key),
    set: (key, value) => AsyncStorage.setItem(key, value),
    remove: (key) => AsyncStorage.removeItem(key),
  },
  toast: {
    show: (message, type) => {
      // ⭐ 平台差异:Android 有 ToastAndroid,iOS 只能自己做一个 toast 组件
      if (Platform.OS === 'android') {
        ToastAndroid.show(message, ToastAndroid.SHORT);
      } else {
        // ⭐ iOS 上用「自研 toast」或 Alert(注意:Alert 会打断用户)
        if (type === 'error') Alert.alert('提示', message);
        else useIosToastStore.getState().show(message);   // 自研
      }
    },
  },
  haptic: {
    light: () => { void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); },
  },
  openUrl: (url) => Linking.openURL(url),
  getSafeAreaInsets: () => ({
    top: initialWindowMetrics?.insets.top ?? 0,
    bottom: initialWindowMetrics?.insets.bottom ?? 0,
    left: initialWindowMetrics?.insets.left ?? 0,
    right: initialWindowMetrics?.insets.right ?? 0,
  }),
};

// ⚠️ 上面的 useIosToastStore 是示意——真实项目会放一个 toast 组件
const useIosToastStore = { getState: () => ({ show: (_m: string) => {} }) };
// ✅ 层 2:用「包装组件」共享 UI 逻辑,让「平台特定文件」只负责渲染
// packages/shared/ui/Button.tsx —— ⭐ 共享的「逻辑与 props 契约」
import { useAdapters } from '../adapters';

export interface ButtonProps {
  variant?: 'primary' | 'default' | 'danger';
  size?: 'sm' | 'md' | 'lg';
  loading?: boolean;
  disabled?: boolean;
  onPress: () => void;
  children: string;
}

/** ⭐ 共享的「业务逻辑」:禁用条件、加载时阻止重复点击、触感反馈 */
export function useButtonBehavior({ loading, disabled, onPress }: Pick<ButtonProps, 'loading' | 'disabled' | 'onPress'>) {
  const { haptic } = useAdapters();
  const isDisabled = disabled || loading;

  const handlePress = () => {
    if (isDisabled) return;
    // ⭐ 移动端「触感反馈」是体验的一部分(Web 上通常是 no-op)
    haptic.light();
    onPress();
  };

  return { isDisabled, handlePress };
}
// Button.native.tsx —— ⭐ Metro 会「优先选 .native.tsx」
import { Pressable, Text, ActivityIndicator, StyleSheet } from 'react-native';
import { useButtonBehavior, type ButtonProps } from '@acme/shared/ui/Button';

export function Button({ variant = 'default', size = 'md', loading, disabled, onPress, children }: ButtonProps) {
  const { isDisabled, handlePress } = useButtonBehavior({ loading, disabled, onPress });

  return (
    <Pressable
      onPress={handlePress}
      disabled={isDisabled}
      // ⭐ 无障碍:RN 用 accessibility* 属性(新版也支持 aria-*)
      accessibilityRole="button"
      accessibilityState={{ disabled: isDisabled, busy: loading }}
      accessibilityLabel={children}
      style={({ pressed }) => [
        styles.base,
        styles[size],
        styles[variant],
        pressed && styles.pressed,       // ⭐ RN 的「按压态」由 Pressable 提供
        isDisabled && styles.disabled,
      ]}
    >
      {loading ? <ActivityIndicator size="small" /> : <Text style={styles.label}>{children}</Text>}
    </Pressable>
  );
}

const styles = StyleSheet.create({
  base: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', borderRadius: 8, gap: 6 },
  sm: { height: 28, paddingHorizontal: 10 },
  md: { height: 36, paddingHorizontal: 14 },
  lg: { height: 44, paddingHorizontal: 18 },
  default: { backgroundColor: '#fff', borderWidth: 1, borderColor: '#d9d9d9' },
  primary: { backgroundColor: '#1677ff' },
  danger: { backgroundColor: '#f5222d' },
  pressed: { opacity: 0.86 },                    // ⭐ 没有 :hover/:active,用 pressed
  disabled: { opacity: 0.5 },
  label: { fontSize: 14, color: '#1f1f1f' },
});
// Button.web.tsx —— 同一份 props 契约,Web 实现
import { useButtonBehavior, type ButtonProps } from '@acme/shared/ui/Button';
import './Button.css';

export function Button({ variant = 'default', size = 'md', loading, disabled, onPress, children }: ButtonProps) {
  const { isDisabled, handlePress } = useButtonBehavior({ loading, disabled, onPress });

  return (
    <button
      type="button"
      className={`btn btn--${variant} btn--${size} ${loading ? 'btn--loading' : ''}`}
      disabled={isDisabled}
      aria-busy={loading || undefined}
      onClick={handlePress}
    >
      {loading && <span className="btn__spinner" aria-hidden />}
      <span className="btn__content">{children}</span>
    </button>
  );
}
// ✅ 层 2:共享的 design token(Web 用 CSS 变量 / RN 用 JS 对象)
// ⭐ 从「同一份源」生成两种产物(见 10.13 的 token 生成器)
// packages/tokens/src/tokens.ts —— 原始值(两端都可读)
export const rawTokens = {
  colorPrimary: '#1677ff',
  spaceSm: 4,
  spaceMd: 8,
  spaceLg: 16,
  radiusMd: 8,
  fontSizeSm: 14,
} as const;

// packages/tokens/src/native.ts —— RN 用「JS 对象」
export const theme = {
  color: { primary: rawTokens.colorPrimary, text: '#1f1f1f' },
  space: { sm: rawTokens.spaceSm, md: rawTokens.spaceMd, lg: rawTokens.spaceLg },
  radius: { md: rawTokens.radiusMd },
  fontSize: { sm: rawTokens.fontSizeSm },
  // ⭐ RN 没有 CSS 变量 → 主题切换要靠「Context 提供一个新的 theme 对象」
} as const;

// packages/tokens/src/tokens.css —— Web 用 CSS 变量(由生成器产出)
// :root { --color-primary: #1677ff; --space-md: 8px; }
/* ✅ 层 2:共享的「响应式断点」也要「各自表述」 */
/* Web:媒体查询 */
/* @media (min-width: 768px) { .card { flex-direction: row; } } */

/* RN:用 useWindowDimensions 判断(没有媒体查询 CSS) */
// ✅ RN 侧的「响应式」写法
import { useWindowDimensions } from 'react-native';

function useBreakpoint() {
  const { width } = useWindowDimensions();
  // ⭐ 与 Web 的断点保持一致(从 token 里读)
  if (width < 768) return 'sm';
  if (width < 1024) return 'md';
  return 'lg';
}

function Card() {
  const bp = useBreakpoint();
  return (
    <View style={{ flexDirection: bp === 'sm' ? 'column' : 'row', gap: theme.space.md }}>
      {/* ... */}
    </View>
  );
}
⭐⭐⭐ 这套策略的五个「关键决定」:

① 【纯逻辑 100% 共享】⭐ 收益最大
   校验/计算/状态机/类型 → 两端零重复(也不会「Web 改了规则 App 没改」)
   判据:「**这段代码 import 了平台 API 吗**」——没有就共享。

② 【用「接口 + Context 注入」而不是「条件 import 平台代码」】
   ❌ `import { Platform } from 'react-native'` 出现在共享代码里
   ✅ 共享代码只依赖「接口」,由各端注入实现
   ⭐ 好处:共享包**不依赖任何平台**(可以单独单测,也不会「Web 打包时把 RN 打进来」)

③ 【平台特定文件(`Button.native.tsx` / `Button.web.tsx`)】
   Metro(RN)与 Vite/webpack 都支持「平台后缀优先」解析
   ⭐ 好处:**同一份 props 契约、两份渲染实现**——
     共享的是「接口与行为逻辑」,分叉的是「渲染原语」。

④ 【「行为逻辑」提炼成 Hook(`useButtonBehavior`)】⭐
   这样「禁用条件、防重复点击、触感反馈」只写一次,
   两端只是「用不同的原语渲染」。
   ⭐ 这是「跨平台共享」最有效的模式:**逻辑共享 + 渲染分叉**。

⑤ 【token 从「同一份源」生成两种产物】
   Web → CSS 变量(可主题切换);RN → JS 对象(+ Context 做主题)
   ⭐ 避免「两边的品牌色慢慢不一致」

五、本篇特有的坑

// ① 文本没有包在 <Text> 里
<View>Hello</View>                                    // ❌ 直接报错
// ✅ <View><Text>Hello</Text></View>

// ② 在共享代码里 import 了 react-native(Web 打包会炸)
// packages/shared/hooks/useFoo.ts
import { Platform } from 'react-native';               // ❌ 共享包被污染
// ✅ 抽成「接口」,由各端注入

// ③ 以为 `flexDirection` 默认是 row(Web 习惯)
<View style={{ flex: 1 }}>                             // ⚠️ RN 默认 column
// ✅ 显式写 flexDirection

// ④ 用「百分比高度」但没有「确定高度的父容器」
<View><View style={{ height: '50%' }} /></View>         // ⚠️ 无效
// ✅ 用 flex 或 useWindowDimensions

// ⑤ 把「px 字符串」写进 RN 样式
{ padding: '16px' }                                    // ❌ RN 只接受数字
// ✅ { padding: 16 }

// ⑥ 期望「样式继承」(如 body 的 fontFamily 传下去)
// ⚠️ RN 里「文本样式不继承」(除了「Text 套 Text」)
// ✅ 自己封装一个 <AppText> 或在 theme 里统一

// ⑦ 用 `box-shadow` 但两端表现不同
// ⚠️ iOS 用 shadowColor/shadowOffset/shadowOpacity/shadowRadius;
//    Android 只支持 elevation(没有颜色/偏移的精细控制)
// ✅ 用「库封装」(如 react-native-shadow-2)或「接受差异」

// ⑧ 用「`position: fixed` 做浮层」(RN 里没有 fixed)
// ✅ 用「绝对定位 + 父容器占满屏幕」,或库(Modal / Portal 实现)

// ⑨ 用「CSS 媒体查询」做响应式
// ✅ useWindowDimensions + 断点常量(与 Web 共享断点值)

// ⑩ 用「DOM 事件」的思维做手势(onMouseEnter 等)
// ✅ RN 用 Pressable 的 pressed/hovered 状态,或 Gesture Handler

// ⑪ 忽略「安全区」(刘海屏/全面屏底部横条)
// ✅ react-native-safe-area-context 的 SafeAreaView/useSafeAreaInsets

// ⑫ 用「`window`/`document`/`localStorage`」等 Web API
// ✅ 通过「适配层」(见第四节)

// ⑬ 「网络请求」以为会自动带 cookie(RN 里没有 cookie 罐)
// ✅ 用 token + 显式 header;或 cookies 库

// ⑭ 直接在 RN 里用「大数据量的 SectionList/FlatList」但不做性能优化
// ⚠️ RN 的「渲染成本」比 Web 高(要跨 JSI/原生)
// ✅ getItemLayout(定高)+ 合理的 windowSize + 「避免内联函数/对象」
//    (⭐ 与 Web 的 React 优化是同一套思路,见 5.4/5.8)

// ⑮ 「热更新」当作「发了新版本」(EAS Update 只能更新 JS)
// ⚠️ 「原生代码变了」必须重新发版(App Store/Play 审核)
// ✅ 把「原生依赖的变更」与「JS 变更」分开发布

// ⑯ 用「Expo Go」开发但用了「自定义原生库」(跑不起来)
// ✅ 改用 Development Build

// ⑰ 升级 Expo SDK 时「直接改版本号」(原生依赖不匹配)
// ✅ 用 `npx expo install` 装包(它会「选与当前 SDK 兼容的版本」)⭐
// ⚠️ 不要用 `npm install <pkg>`(会装最新版,可能与 SDK 不兼容)

// ⑱ 用「Web 的图片方案」(图片没有显式尺寸)
<Image source={{ uri }} />                              // ⚠️ RN 里「必须给尺寸」否则不显示
// ✅ <Image source={{ uri }} style={{ width: 300, height: 200 }} />
//    ⭐ 或用 expo-image 的 contentFit/placeholder(更现代)

// ⑲ 「动画」用 JS 线程的 Animated(掉帧)
// ⚠️ 如果动画涉及「JS 线程的计算」,主线程卡就掉帧
// ✅ 用 Reanimated(worklet 在 UI 线程)

// ⑳ 「共享代码」里用了 Web 独有的库(如 react-dom、CSS-in-JS 的 Web 实现)
// ⚠️ 打包会报错或体积暴增
// ✅ 共享包里「零平台依赖」(可用 ESLint `no-restricted-imports` 强制)
// ⑳ 的完整处理(用 ESLint 强制「共享包不许引平台代码」)
// packages/shared/eslint.config.js
export default [
  {
    files: ['src/**/*.{ts,tsx}'],
    rules: {
      'no-restricted-imports': ['error', {
        paths: [
          { name: 'react-native', message: '共享包不能依赖 react-native(请通过适配层接口)' },
          { name: 'react-dom', message: '共享包不能依赖 react-dom' },
        ],
        patterns: [
          { group: ['react-native-*', 'expo-*'], message: '共享包不能依赖 RN/Expo 的库' },
        ],
      }],
      // ⭐ 也不允许用「平台全局变量」
      'no-restricted-globals': ['error',
        { name: 'window', message: '共享代码不能直接用 window(用适配层)' },
        { name: 'document', message: '共享代码不能直接用 document(用适配层)' },
        { name: 'localStorage', message: '共享代码不能直接用 localStorage(用适配层)' },
      ],
    },
  },
];
// ⑪ 的完整处理(安全区)
// app/_layout.tsx(Expo Router)
import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';

export default function RootLayout() {
  return (
    <SafeAreaProvider>
      {/* ⭐ edges 指定「哪些边要避让」(不是所有页面都需要避让顶部) */}
      <SafeAreaView style={{ flex: 1, backgroundColor: '#fff' }} edges={['top', 'bottom']}>
        <Slot />
      </SafeAreaView>
    </SafeAreaProvider>
  );
}

// ⭐ 需要「局部避让」时用 Hook:
import { useSafeAreaInsets } from 'react-native-safe-area-context';
function BottomBar() {
  const insets = useSafeAreaInsets();
  return <View style={{ paddingBottom: insets.bottom + 8 }} />;
}
// ⑰ 的完整处理(用 expo install 保证版本兼容)
{
  "scripts": {
    "add": "npx expo install",
    "//": "⭐ 用 `npm run add expo-camera` 而不是 `npm install expo-camera`",
    "doctor": "npx expo-doctor",
    "//2": "⭐ expo-doctor 会检查「依赖版本与 SDK 是否匹配」"
  }
}
# ⭐ 升级 Expe SDK 的标准流程
npx expo install expo@latest
npx expo install --fix          # 把所有包「修到与当前 SDK 兼容的版本」
npx expo-doctor                 # 检查问题
npx expo start --clear          # 清缓存重启

六、面试延伸

  1. 「React Native 是什么?和 React 什么关系?」
    React Native 不是「把 React 跑在浏览器里」,而是「换掉渲染器与宿主环境」。React 的架构分两层:① reconciler(协调器)——负责 diff、Fiber、优先级、Hooks、Suspense;② host config(宿主配置)——负责「把变更落到具体平台」。⭐ reconciler 是共享的(react-reconciler),RN 只是把 host config 从「操作 DOM」换成「操作原生视图」(UIView / android.view.View)。所以:组件、Props、State、Hooks、Context、Suspense、错误边界、单向数据流、不可变更新完全通用;而 DOM API、CSS、<div>、localStorage、URL/History、window 都不存在。(同一个机制也解释了 react-three-fiber / react-pdf 这类库的存在。)

  2. 「React Native 的新架构(New Architecture)带来了什么?」
    核心是 JSI 带来的「同步调用 + 无序列化」:① ⭐ JSI(JavaScript Interface)——JS 引擎可以直接持有 C++ 对象的引用,同步调用(旧 Bridge 是异步 JSON 消息),于是「同步读原生值」「直接用 C++ 写的高性能库」成为可能,也消除了序列化开销与桥接队列拥堵;② Fabric——C++ 实现的新渲染器,支持同步布局、可中断渲染、优先级(⭐ 与 React 的并发特性对齐);③ TurboModules——新原生模块系统,懒加载 + 走 JSI;④ Codegen——根据 TypeScript 类型生成原生接口代码(类型安全,不用手写桥接样板);⑤ Hermes——为 RN 优化的 JS 引擎(启动快、内存小、字节码预编译);⑥ Bridgeless——完全移除 Bridge。

  3. 「RN 和 Web 开发最需要注意哪些差异?」
    六类:① ⭐ 没有 DOM/CSS——用 View/Text/Image,样式是 JS 对象 + StyleSheet.create(无选择器、无级联、无继承);② ⭐ 布局是 Yoga(Flexbox 子集)——flexDirection 默认 column(Web 默认 row),没有 grid/float,百分比高度需要父容器有确定尺寸;③ ⭐ 文本必须包在 <Text> 里(且 Text 不能有 View 子节点);④ 没有「页面滚动」——用 ScrollView/FlatList(列表内建虚拟化);⑤ 没有 URL/History——用 React Navigation(或 Expo Router 的文件路由);⑥ 没有 window/document/localStorage——用 适配层(AsyncStorage/MMKV 等)。此外还有「单位无 px」「阴影两端不同」「动画用 Animated/Reanimated」「无障碍用 accessibility*」等。

  4. 「RN 里怎么做动画才不卡?」
    核心是「把每帧的更新从 JS 线程搬到 UI 线程」——⭐ 这与 Web 的「合成线程动画」是同一思想(见 5.17)。① Animated API 的 useNativeDriver: true 可以把 transform/opacity 动画交给原生驱动(但布局属性不支持原生驱动);② ⭐ Reanimated——用 useSharedValue + useAnimatedStyle 写「worklet」,worklet 在 UI 线程执行,所以「即使 JS 线程被卡住,动画依然流畅」(⭐ 这是 RN 里做手势/拖拽/复杂动画的主流方案);③ 避免「动画里做 JS 重计算」;④ 手势用 Gesture Handler(跑在 UI 线程)。

  5. 「Expo 是什么?什么时候需要自定义原生代码?」
    Expo 是「把 RN 的原生工程复杂度封装掉」的工具链,四层能力:① Expo SDK(开箱可用的原生能力 JS API:camera/location/notifications/image/router…);② EAS Build / EAS Update(云端构建 + OTA 更新 JS bundle,⚠️ 只能更新 JS/资源,原生代码变了必须重新发版);③ 三种工作流——Expo Go(零配置但只能用 SDK 内的原生模块)、⭐ Development Build(现在的推荐:自己的 App + Expo 工具链,可用任意原生库)、Bare/Prebuild(生成 ios//android/,最大控制权);④ Expo Router(文件路由 + 深链接自动映射)。需要自定义原生代码时:用 Development Build(或 Prebuild),并通过 ⭐ Config Plugins「声明式地修改原生配置」(如加权限、改 Info.plist)——⭐ 所以「用了 Expo 就不能自定义原生」是误解,真正不能的是「在 Expo Go 里跑自定义原生代码」。

  6. 「Web 与 RN 的代码怎么共享?」
    三层策略:① ⭐ 纯逻辑 100% 共享(校验、计算、状态机的 reducer、类型定义)——收益最大,判据是「这段代码 import 了平台 API 吗」;② ⭐ 接口共享、实现分叉——定义 PlatformAdapters 接口(storage/toast/haptic/openUrl/safeArea…),用 Context 注入各端实现(而不是「在共享代码里条件 import 平台代码」);③ ⭐ UI 用「平台特定文件」+ 「行为逻辑提炼成 Hook」——Button.native.tsx / Button.web.tsx 共享同一份 props 契约,而「禁用条件、防重复点击、触感反馈」抽成 useButtonBehavior(逻辑共享、渲染分叉)。另外:design token 从「同一份源」生成两种产物(Web → CSS 变量;RN → JS 对象)。原则:「能共享逻辑就共享,该分叉就大方分叉」——不要为了「代码统一」把两端体验都做差。

  7. 「(如果做过)RN 里最容易踩的坑是什么?」
    六个高频:① ⭐ 文本没包 <Text>(直接报错);② ⭐ flexDirection 默认 column(Web 习惯会写错布局);③ 百分比高度失效(父容器没有确定尺寸);④ 样式写 '16px'(RN 只接受数字);⑤ 共享代码里 import 了 react-native/window/localStorage(Web 打包会炸,⭐ 用 ESLint 的 no-restricted-imports/no-restricted-globals 强制);⑥ ⭐ 用 npm install 装 Expo 包(版本可能与 SDK 不兼容,必须用 npx expo install,它会选兼容版本;升级时用 expo install --fix + expo-doctor 检查)。

  8. 「RN 的性能优化和 Web 有什么异同?」
    相同的部分(因为 reconciler 一样):① 减少重渲染——memo、稳定 props 引用、状态下移、children 透传(见 5.4/5.8);② 列表虚拟化(Web 用库,RN 内建 FlatList);③ 避免「渲染中做重计算」(useMemo);④ ⭐ 「只改 transform/opacity 的动画更快」(Web 走合成线程,RN 走 UI 线程/原生驱动)。不同的部分:① ⭐ RN 的「渲染成本」更高(要跨 JSI/原生创建视图)→ 所以 FlatList 的 getItemLayout、windowSize、initialNumToRender 这些参数比 Web 更关键;② ⭐ 列表项里的「内联函数/对象」代价更大(会触发更多原生更新)→ useCallback/useMemo 在 RN 里更值得用;③ 动画要用 Reanimated / useNativeDriver(Web 用 CSS);④ 启动时间(Hermes 字节码 + 延迟初始化);⑤ ⭐ removeClippedSubviews、InteractionManager 这类 RN 特有手段。

一句话速记

RN 的本质是「共享 reconciler(diff/Fiber/Hooks/Suspense)、替换 host config(DOM → 原生视图)」——所以 React 心智模型通用,但没有 DOM/CSS/URL/window;新架构的核心是 JSI(同步调用 + 无序列化,替代异步 Bridge) + Fabric(C++ 渲染器,支持同步布局与可中断渲染) + TurboModules(懒加载) + Codegen(类型生成桥接) + Hermes(字节码预编译) + Bridgeless;六大 Web→RN 差异:View/Text/Image 替代 DOM、样式是 JS 对象(无选择器/无级联/无继承)、Yoga 布局(⚠️ flexDirection 默认 column、无 grid、百分比高度需父容器确定尺寸)、文本必须包 <Text>、列表用 FlatList(内建虚拟化)、没有 URL(用 React Navigation)、没有 window/localStorage(用适配层);动画不卡的关键是「搬到 UI 线程」(useNativeDriver / ⭐ Reanimated 的 worklet,与 Web 的合成线程同源);Expo = 「封装原生复杂度的工具链」(SDK + EAS Build/Update ⚠️ OTA 只能更 JS + Development Build(推荐)/Bare + Expo Router),⭐ 「用了 Expo 就不能自定义原生」是误解(Config Plugins 可声明式改原生配置);共享策略三层:纯逻辑 100% 共享(判据:有没有 import 平台 API)→ 接口共享 + Context 注入实现(禁止在共享包里条件 import RN/DOM,用 ESLint 强制)→ UI 用平台特定文件 + 「行为逻辑提炼成 Hook」(逻辑共享、渲染分叉);⭐ 装 Expo 包必须用 npx expo install(不是 npm install)。

赞(0) 打赏
未经允许不得转载:陌上寒 » 10.15 React Native / Expo 概览

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫