Chapter 08

一份代码 iOS / Android / Web

Expo Router 最吸引人的是它把 Web 做成了一等公民——React Native 组件在 Web 上有 react-native-web 映射成 div/span,路由直接走浏览器 history。合理规划,同一套业务逻辑上三个平台。

启用 Web

npx expo install react-native-web react-dom @expo/metro-runtime
// app.json
{
  "expo": {
    "web": {
      "bundler": "metro",        // 默认 metro,能复用 RN 包
      "output": "static",        // static / server / single
      "favicon": "./assets/favicon.png"
    }
  }
}
npx expo start --web
# 或开着 expo start 然后按 w

三种 output 模式

output产物适用
single单页 HTML,所有路由客户端渲染内部工具、Demo
static每个路由预渲染一份 HTML营销站、文档、博客(SEO 友好)
serverNode 服务端渲染 + API 路由需要登录态定制、Server Functions
# 静态导出
npx expo export -p web
# 产物在 dist/,扔 Vercel / CF Pages / 任何 static host

Platform-specific 文件

同一目录下,Expo 根据平台自动选对应后缀的文件:

components/
├── Header.tsx            ← 三端共用
├── Header.web.tsx        ← Web 专用(如需覆盖)
├── Map.native.tsx        ← iOS + Android(不在 Web 上构建)
└── Map.web.tsx           ← Web(例如用 Mapbox GL JS)
// 调用时永远 import 裸名,Metro 自动匹配
import { Map } from './Map';

// native → Map.native.tsx
// web    → Map.web.tsx

Platform.OS 运行时判断

import { Platform } from 'react-native';

const styles = StyleSheet.create({
  header: {
    paddingTop: Platform.OS === 'ios' ? 44 : 0,
    ...Platform.select({
      web: { cursor: 'pointer' },
      default: {},
    }),
  },
});

路由差异

默认情况下 app/ 下的路由三端都能访问。但某些路由只在 Web 上存在(营销页、博客),可以用 .web.tsx:

app/
├── (app)/
│   └── home.tsx             ← 三端
├── blog.web.tsx             ← Web only
└── marketing.web.tsx        ← Web only

另一头,某些 Native only(比如蓝牙连接设置),可以 .native.tsx 或运行时 Platform.OS !== 'web' 守卫。

SEO:head 信息

import { Head } from 'expo-router/head';

export default function ProductDetail() {
  const { data } = useProduct(id);
  return (
    <>
      <Head>
        <title>{data.name} - MyApp</title>
        <meta name="description" content={data.summary} />
        <meta property="og:image" content={data.image} />
      </Head>
      ...
    </>
  );
}

在 static/server 模式下 <Head> 会被注入到导出的 HTML,爬虫和分享预览都能读到。Native 端无副作用(忽略)。

Server Routes(beta)

app/
└── api/
    └── hello+api.ts
// app/api/hello+api.ts —— 服务端接口
export async function GET(request: Request) {
  return Response.json({ msg: 'hello' });
}
// 前端调用
const data = await fetch('/api/hello').then((r) => r.json());

需要 output: 'server',部署到 Vercel/EAS Hosting。三端调用同样 URL,适合做轻量 BFF。

共享代码组织

src/
├── api/              ← fetch 函数,三端用
├── hooks/            ← 业务 hook
├── utils/            ← 纯逻辑
├── components/
│   ├── ui/           ← 跨端 UI(Button/Input/Card)
│   └── native/       ← 纯 Native 组件(相机/蓝牙)
└── config/

规则:业务逻辑永远跨端,只有强依赖 native API 的放到 .native/.ios/.android 文件

常见跨端坑

Shadow 样式
RN 的 shadowOffset/shadowOpacity Web 不支持,要加 elevation + boxShadow。用 Platform.select 分发。
SafeAreaView
Web 没有刘海概念,react-native-safe-area-context 在 Web 上 insets 返回 0,不用特殊处理。
KeyboardAvoidingView
Web 下键盘没概念(桌面也没),条件渲染或直接用 flex。
第三方包没 Web 支持
比如 react-native-reanimated(有 Web 支持)、react-native-ble-plx(无)。查包文档的"Platforms"段落。
字体加载
Web 用 CSS @font-face,Native 用 expo-font。可以写一个 useLoadedFonts hook,web 下立即 true。

跳转链接:Link 自动处理

<Link href="/products/42">详情</Link>

// Web: 渲染成 <a href="/products/42">,支持右键新开、ctrl+点击
// Native: 渲染成 Pressable,点击 push 到下一屏

这是 Expo Router 的关键桥:同一个 Link 组件三端行为各自原生,URL 一致。

Web 特有:History / Pathname

import { usePathname, useRouter } from 'expo-router';

const router = useRouter();
router.push('/page');    // Web 调 history.pushState,Native 推屏
router.back();           // Web 调 history.back,Native 弹栈
const path = usePathname();  // 三端一致

打包部署

# 1. 静态导出
npx expo export -p web

# 2. 产物 dist/ 推 Vercel
vercel --prod dist

# 3. 或 CF Pages:
wrangler pages deploy dist

# 4. 或纯 Nginx:
rsync -avz dist/ root@server:/var/www/myapp/

Web-only 项目的选择

如果纯 Web,没必要用 Expo Router——Next.js 更合适。Expo Router 的甜区是「三端共用且 Mobile 为主」,Web 是锦上添花。

本章小结