Skip to content
Web 与托管

Web 与托管

Expo 的第三个平台是浏览器。这一章讲清楚三件事:web 端在 Expo 工程里的真实定位(不是 web-first 框架,是三端共享代码的补充端)、两种部署形态(纯静态导出 vs 按请求渲染——路由行为见 04-Expo-Router.md,这里只看产物和托管)、以及发布链路(export、serve、静态托管要点、EAS Hosting、PWA)。基线 SDK 58:web 默认输出翻成了 server,这是本版对 web 影响最大的一组变更。


1. 定位:一套代码三端,web 是补位不是主场

Expo 官方把 web 列为 first-class 支持(docs.expo.dev/workflow/web),但要理解它的实际用法:同一个工程,Android/iOS 走原生渲染,web 走 React DOM——<View> <Text> 这类 RN 组件由 react-native-web 映射为 <div> <p> <img>(顺带一提,X 的整个网页端就建立在 react-native-web 上)。常见落点有三类:营销页/SEO 落地页(配合静态导出)、内部后台/桌面场景(SPA 或 server 输出)、桌面壳(web 产物再包一层)。官方也明说取舍:能原生就原生,离线体验原生更好,PWA 是"给桌面用户"的备选。

成本线放在最前面:web 端共享的是逻辑与大部分 JSX,样式(CSS 子集)、输入方式(键盘/鼠标)、窗口模型(可随意 resize)这三件事和原生差异最大。三端一致性不是免费的,第 9 节把最常见的坑集中过一遍。

2. 编译模型:Metro 统一打包三端

SDK 50 起 webpack 路线已废弃(npx expo export:web 在 metro 工程里直接禁用;web.bundler 的默认值是"装了 @expo/webpack-config 才走 webpack,否则 metro",新项目什么都不用配),web 与原生共用 Metro:开发时

npx expo install react-dom react-native-web @expo/metro-runtime   # 模板默认已装好
npx expo start --web                                              # 浏览器里 Fast Refresh

expo start 不加 --web 也会打印 web 的 Launch 链接。生产用 npx expo export -p web 出静态产物。CLI 在打包时按平台做了platform shaking——Platform.OS === 'web' 分支和 *.web.tsx 文件在原生包里被剥掉,反之亦然(docs /guides/tree-shaking),所以"共享代码"不会让原生包变胖。Fast Refresh、环境变量(EXPO_PUBLIC_*)、自定义 Metro 配置全部通用。顺带一提,58 的实验特性 Noxcturnal(experiments.noxcturnalTransformWorker,Rust transformer)冷打包约快 2 倍,web 项目同样受益,但要求没有自定义 Babel 插件/presets。

3. 模块的 web 支持矩阵:会查,别背

expo-* 包不是个个有 web 实现,官方也不维护一份"背下来就赢"的清单——每个模块的文档页头部就标注了支持平台。查法有两种,都验证过可用:

  • 浏览器里看 docs.expo.dev/versions/latest/sdk/<模块> 页面顶部的平台标识;
  • 终端/agent 直接 grep markdown 版的 frontmatter:
curl -s https://docs.expo.dev/versions/latest/sdk/clipboard.md | head -5
# platforms: ['android', 'ios', 'web', 'expo-go']   ← 有没有 'web' 看这行

模块在 web 不支持时的标准兜底是平台文件分流:src/Thing.ts 给原生、src/Thing.web.ts 用浏览器 API 实现同名 API(见 04-Expo-Router.md 的 platform-specific modules 一节)——这也是大部分 expo-* 包自己的做法。SDK 58 在运行时 polyfill 侧有两处值得知道的更新:expo 包提供的 URL/URLSearchParams 按 IDNA/TR-46 重写,TextDecoder 为速度重写(changelog 原文)——对 web 是行为对齐,对原生是补 Hermes 的缺。

4. 样式生态入口

Tailwind 官方指南(docs /guides/tailwind,2026-07 改版)目前的前提是 web.bundler: "metro",且文档开头就声明:标准 Tailwind 只支持 web 平台;要三端同写 utility class 得用 NativeWind 或 Uniwind 这类转译库——那是 RN 样式的事,见 react-native/03-样式与布局.md,本章不展开。纯 web 端用法与任意 Node 项目一致:

npx expo install tailwindcss @tailwindcss/postcss postcss --dev

配好 PostCSS、写一个全局 CSS 入口导入即可(v3 路线另有 tailwind.config.js 初始化步骤,新项目直接 v4)。web-only 的 CSS 特性(grid、media query、:hover)在 RN 侧没有对应物,混用前先确认这页代码是不是只给 web 跑。

5. 两种 web.output:部署形态全览

web.output 决定 expo export -p web 的产物形态。config 参考页仍写着默认 single,但 SDK 58 的现实是:Expo Router 工程未设置 web.output 时按 server 处理,且 58 的 server 是"按请求渲染 HTML",不再是导出时预渲染(changelog 原文);默认/tabs 模板也已经把 "server" 直接写进 app.json。三种形态对照(按 docs /guides/publishing-websites 整理):

output 产物 API routes 部署去向
single 一个 index.html + JS,无静态可索引 HTML 不支持 任意静态托管,需 SPA fallback
static 每个路由预渲染 HTML(loaders 在导出时执行、结果嵌进 HTML) 需显式开 apiRoutes: true 纯静态托管;开了 apiRoutes 则 dist/server 需要可部署的服务端
server dist/client(水合资源)+ dist/server(render.js + routes.json),不预生成 HTML 默认启用 需要能跑 Expo Router server 输出的环境(官方推荐 EAS Hosting)
// app.json —— server 输出需要 expo-router 插件参与构建
{
  "expo": {
    "web": { "output": "server" },
    "plugins": ["expo-router"]
  }
}

还有一个前置条件藏在表里:static 与 server 都建立在 Expo Router 之上(config 参考页明说 static rendering 仅限 Router 工程,server 输出同样由 expo-router 插件驱动构建)。如果你的 app 直接用 React Navigation 而没有 Router,web 端事实上只剩 single 这一种 SPA 形态可选——这也是不少团队最后为 web 迁到 Router 的原因之一(Router 本身见 04-Expo-Router.md)。

三条 58 的迁移须知:

  • 想回到 SDK 57 的行为(当时 server 导出是预渲染的):设 web.output: "static" 并在 expo-router 插件开 apiRoutes——["expo-router", { "apiRoutes": true }]。这是 docs server-rendering 页给出的官方等价配方,也再次说明别依赖隐式默认值,把 output 写死。
  • 最大的部署陷阱:server 输出只把 dist/client 传上静态托管,等于没做 SSR——docs 原话,“上传仅 client 目录到静态托管不构成 server rendering”。client 资源和 server 代码必须一起交给支持它的环境。
  • loader/SSR/middleware 在 58 全部转正 stable,行为细节(渲染时机、Cache-Control 缓存、middleware)见 04-Expo-Router.md;本章只管产物去哪。

5.1 static 输出的两个硬边界

预渲染意味着产物是死的文件,两件事要提前知道(docs /router/web/static-rendering 原文):

  • 动态路由不会凭空出现。app/blog/[id].tsx 在 static 下只会生成你列举过的路径——导出时逐个枚举已知路由,靠 Next.js 同名概念的 generateStaticParams:
// src/app/blog/[id].tsx —— 导出时决定生成哪些 [id].html
export async function generateStaticParams(): Promise<Record<string, string>[]> {
  const posts = await getPosts();
  return posts.map(post => ({ id: post.slug }));
}

嵌套动态路由的参数字典级联下发(父 layout 的 params 会作为入参传给子路由的同名导出),且"所有变体都必须枚举到"——没列出的 /blog/未知id 刷新就是 404。内容随时新增、无法穷举的站点,别硬撑 static,转 server。

  • metadata 的时机。要在 JS 执行前就出现在 HTML 里的 <title>/meta(SEO 场景的基本盘),页面导出 generateMetadata();expo-router/head 的 <Head> 只在水合后更新标签、不进初始 HTML——57→58 迁移时官方专门有一节 “Migrate static page metadata” 处理这个错位。

async routes 与代码分割:58 的 expo-router 插件默认在 web(开发和生产都是)开启 async routes——路由文件拆成独立 JS chunk,浏览器导航到该路由才加载;初始包大的项目这一条就把体积问题解决了大半,不需要手动 lazy/import()。原生端默认关(Hermes 字节码本就内存映射、拆了收益小,生产构建还会禁用 suspense 边界),且原生生产构建的拆包只惠及 OTA/RSC/web。显式关:["expo-router", { "asyncRoutes": { "web": false } }]。

6. export、serve 与静态托管

生产导出的标准动作:

npx expo export -p web     # 产物进 dist/,public/ 目录原样拷入
npx expo serve             # 本地按生产形态预览(static+apiRoutes 与 server 都走它)

细节与坑:

  • public/ 里放 favicon、manifest.json、robots.txt 这类要原样上线的文件;但避开 /assets 等 Metro 保留路径(docs 有完整的 reserved paths 列表)。
  • npx expo serve 只用于本地预览产物,不是生产服务器。58 还加了一道防呆:对 expo export --dev 出来的、包含 server 代码的产物直接拒绝启动,让你重新不带 --dev 导出(changelog 原文)——--dev 产物没 minify、没剥 __DEV__,本来就不该上线。
  • static 输出:dist/ 就是普通静态目录,nginx(docroot 指过去即可)、Vercel/Netlify(指向 dist)或任意对象存储 + CDN 都行,public/ 的内容已随导出拷入。single 输出则要求托管配 SPA fallback——子路由刷新 404 是它唯一的经典事故:
# single(SPA):任何未命中文件的请求回 index.html
server {
  root /var/www/dist;
  location / { try_files $uri $uri/ /index.html; }
}
// vercel.json —— 同一件事在 Vercel 的写法
{ "rewrites": [{ "source": "/(.*)", "destination": "/" }] }
  • server 输出:需要能运行导出的 server bundle 的环境,官方点名 EAS Hosting;自托管 Node/边缘运行时不是不可行,但不在 docs 的支持承诺里,出问题没有现成答案。
  • 58 起 static/server 两种模式都能通过 expo-router 插件配置全局 HTTP 响应头(docs /router/web/server-headers),静态托管自己配头之前先看这条。

7. EAS Hosting 现状

EAS Hosting 的定位是"从 create-expo-app 到带 API routes 与 server functions 的已部署网站"的最短路径(docs /eas/hosting/introduction)。命令面很小:

eas deploy            # 导出并部署,产出 preview URL(如 my-app--1234.expo.app)
eas deploy --prod     # 生产部署

首次运行会让你选 preview 子域名(my-app--1234.expo.app 里 my-app 这段);别名与提升到生产、自定义域名、环境 secrets 在 hosting 文档各有专页。运行时基于 Cloudflare Workers,API routes / server 输出原生支持;static 输出同样可托管。CI 走 EAS Workflows,官方给的最小配置就几行:

# .eas/workflows/deploy-web.yml —— push main 即部署生产
name: Deploy web
on:
  push:
    branches: ['main']
jobs:
  deploy_web:
    name: Deploy web
    type: deploy
    params:
      prod: true

另有 eas-hosting agent skill 教 coding agent 走完整部署流程(导出、prod/PR 预览、secrets、自定义域)。定价与免费额度不在本手册范围——数字会过期,直接看 expo.dev/pricing。

8. 图标、favicon 与 PWA

58 时代这些配置全部由 app config 驱动,不需要手碰原生工程(顺带纠一个常见误会:npx expo customize 是生成 babel.config.js/webpack.config.js 这类工程文件的 CLI,与图标无关):

  • App 图标:app.json 的 expo.icon 给一张 1024×1024 PNG,prebuild / EAS Build 时自动展开成 iOS AppIcon 全尺寸与 Android mipmap 各档;Android 自适应图标写 android.adaptiveIcon.foregroundImage / backgroundImage / monochromeImage(Android 13+ 主题图标),iOS 侧 ios.icon.light/dark 支持深浅两套。
  • favicon:web.favicon 指向源图,导出 web 时自动生成 favicon.ico;不想用配置就自己往 public/favicon.ico 放一个。
  • PWA:manifest 是手工活——在 public/manifest.json 声明 name/short_name/icons(192/512 两张 PNG 同放 public/)、start_url、display: "standalone"、主题色,随导出拷进 dist/。没有 manifest 生成器,也没有 Service Worker 一键开关;docs 对 PWA 的态度保持"装到桌面可以,真离线回原生"。
  • splash(启动图):浏览器没有启动画面概念,expo.splash 配置只作用于原生冷启动,与 web 输出无关(模块 API 见 08-SDK模块速查.md,配置归 03-应用配置与插件.md)。

web 端图标类配置在 docs.expo.dev/versions/latest/config/app 都有条目,改完重导出即可,不涉及原生重建;原生端图标与构建的关系见 03-应用配置与插件.md。

9. Safe areas:58 在 web 上的破坏性变更

注意:changelog 原文——Expo Router 在 web 上不再通过 react-native-safe-area-context 提供 insets,改用 CSS env(safe-area-inset-*)。也就是说 web 端把 useSafeAreaInsets() 的数字拿来 paddingTop 的写法从此没有数据来源;原生端不受影响,照旧用库。web 端写法变成纯 CSS:

/* 刘海/圆角避让:web 交给浏览器,原生由 safe-area-context 负责 */
.header { padding-top: env(safe-area-inset-top); }
.fab    { bottom: calc(env(safe-area-inset-bottom) + 16px); }

配合 SDK 58 对 iPhone Duo/折叠屏的适配方向(safe area 在原生侧反而更重要、可能要往 ScrollView 里多套 <SafeAreaView>,见 changelog “iPhone Duo support” 一节),web 与原生各自的避让手段从此分家:原生用组件,web 用 env()。平台文件分流正好用来隔开两套写法——同一个组件名,两份实现:

// components/BottomSpacer.tsx(原生取 insets 数字)
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export default function BottomSpacer() {
  const { bottom } = useSafeAreaInsets();
  return <View style={{ height: bottom }} />;
}
// components/BottomSpacer.web.tsx(web 交给 CSS,不 import safe-area 库)
export default function BottomSpacer() {
  return <div className="bottom-spacer" />;
}

10. 桌面与其他平台

  • macOS:changelog 原文——SDK 58 暂不支持 macOS,要等 react-native-macos 追到 React Native 0.86+ 才会回归。此前教程里"macOS 目标"的一切写法在 58 都不成立,盯 changelog。
  • Windows:Expo SDK 不支持 react-native-windows 目标(截至 2026-10-20 没有官方桌面支持文档章节可引)。社区路线(bare RN + react-native-windows,Expo 工程用 prebuild 后手工接入)维护成本高,不在本手册展开。
  • 模块作者侧的"更多 Apple 平台":expo-module.config.json 支持 "apple" 平台来同时声明 iOS/macOS/tvOS 链接(见 09-Expo-Modules与原生扩展.md)——那是 SDK 模块层面的事,与 app 桌面目标不是一回事。
  • 务实替代:需要"桌面应用"形态时,把 web 输出(static/server 皆可)包进 Tauri/Electron 壳是当前成本最低的路——它消费的是本章产物,不涉及任何 RN 桌面支持。官方对 PWA(第 8 节)的推荐倾向也侧面印证了这条路线的定位。

11. Web 端常见坑(随手记)

  • 窗口是活的:桌面浏览器可 resize,任何假设固定宽高的布局都会变形。Dimensions.get('window') 拿到的是快照不是合同;useWindowDimensions() 才有响应。同一课 iOS 27 顺手也教了(app 默认可 resize,见 03/04 章)。
  • 输入模型不同:键盘事件(onKeyDown 等)、hover、右键菜单在 RN 组件上没有对等物;需要就在 *.web.tsx 里用 DOM 原生 props,别指望跨端共享这段。
  • Platform.select 与包体积:web 包容易混进只在原生跑的重依赖。Metro 的 platform shaking 只剥得掉 Platform.OS !== 'web' 这类静态分支,动态条件救不了你——重库要用平台文件或 lazy 分包隔离。
  • 跨域 API:开发时 web 跑在 dev server 端口,后端在另一端口就是跨域。最省事的是后端配 CORS;同域方案是用 API routes/async routes 做一层同源代理,生产部署天然同源。
  • 子路由刷新 404:见第 6 节 SPA fallback——static/server 没有这个问题(每路由有 HTML 或由服务端处理),single 一定有。
  • 图片与字体:require() 的图片三端都被打包/哈希,web 上等价于 build-time 资源;远程 URL 则三端都走网络,注意 CDN 的 CORS。58 起 expo-font 的变量字体三端可用(配合 RN 0.88 Text 的 fontVariationSettings),web 端字体文件同样从 dist 静态分发。

实战建议

  • 何时开 web 输出:有营销/SEO 需求(static)、有内部桌面场景或想要一个"零安装"入口(server 或 single)、或者团队本就维护同一份业务逻辑的 React Web 版——这三种都划算。纯移动体验、重度手势/动画、依赖一堆无 web 实现的 expo-* 模块的项目,开 web 只会收获一份双倍 CI 时长。
  • 形态选择按消费方定:给搜索引擎看 → static;给团队内部用、要 API routes → server + EAS Hosting;给 App 配套的最小官网 → static 甚至单页 single 都够。
  • 把默认值写死:58 之后 web.output 的"默认"在 config 参考(single)与 changelog(Router 工程按 server)之间存在口径差,模板已经显式写 server——照模板抄,别让隐式默认替你决定部署形态。
  • 三端一致性的成本线画在样式和输入上:逻辑、数据层、路由能共享 90%,样式(CSS 子集 vs 全量 CSS)与输入模型共享不了,平台文件就是用来光明正大地分家的,别硬凑一套代码伺候三端。