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 Refreshexpo 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.88Text的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)与输入模型共享不了,平台文件就是用来光明正大地分家的,别硬凑一套代码伺候三端。