Защо Expo Router промени начина, по който навигираме в React Native
Ако сте работили с React Native навигация, знаете колко болезнен може да бъде целият процес. Конфигуриране на стекове, вложени навигатори, ръчно дефиниране на маршрути, управление на типове — честно казано, всичко това бързо излиза извън контрол. Expo Router решава тези проблеми по доста елегантен начин, заимствайки идеята за файлово-базирано маршрутизиране от уеб фреймуърки като Next.js и Remix.
С Expo Router вашата файлова структура е вашата навигация. Създавате файл в app/ — и автоматично получавате маршрут. Толкова просто е. Няма ръчна конфигурация, няма забравени маршрути, няма разминаване между кода и навигационната карта.
В тази статия ще разгледаме подробно как работи Expo Router — от основната настройка, през layouts и tab навигация, до аутентикация, типизирани маршрути и deep linking. Всички примери са тествани с Expo SDK 53 и Expo Router v4.
Инсталация и начална настройка
Нов проект с Expo Router
Най-лесният начин да стартирате е с шаблона на Expo:
npx create-expo-app@latest my-app --template tabs
cd my-app
npx expo start
Този шаблон ви дава проект с вече конфигуриран Expo Router, tab навигация и примерни екрани — готово за тестване за секунди. Ако предпочитате минимална настройка обаче:
npx create-expo-app@latest my-app --template blank
cd my-app
npx expo install expo-router expo-linking expo-constants expo-status-bar
Конфигурация на app.json
Expo Router изисква правилна конфигурация на входната точка и deep linking схемата. Нищо сложно, просто добавете следното:
{
"expo": {
"scheme": "myapp",
"web": {
"bundler": "metro",
"output": "server"
},
"plugins": ["expo-router"]
}
}
Входна точка
В package.json задайте входната точка на expo-router/entry:
{
"main": "expo-router/entry"
}
Това казва на Metro bundler да използва Expo Router за зареждане на приложението. Рутерът автоматично сканира директорията app/ и генерира навигационната структура вместо вас.
Файловата система като навигация — основни конвенции
Базова структура
Тук е магията. В Expo Router всеки файл в директорията app/ се превръща в маршрут. Ето как файловата структура се превежда директно в URL пътища:
app/
├── _layout.tsx → Основен layout
├── index.tsx → /
├── about.tsx → /about
├── settings.tsx → /settings
├── profile/
│ ├── _layout.tsx → Layout за /profile/*
│ ├── index.tsx → /profile
│ └── edit.tsx → /profile/edit
└── blog/
├── _layout.tsx → Layout за /blog/*
├── index.tsx → /blog
└── [id].tsx → /blog/123, /blog/abc
Правилата са прости и предвидими:
index.tsx— маршрутът по подразбиране за директорията_layout.tsx— layout компонент, обвива дъщерните маршрути[param].tsx— динамичен сегмент (параметър в URL)[...rest].tsx— catch-all маршрут (хваща всички оставащи сегменти)(group)— група за организация, без промяна на URL+not-found.tsx— персонализирана 404 страница
Файлове, които НЕ стават маршрути
Не всеки файл в app/ се превръща в маршрут, разбира се. Expo Router игнорира:
- Файлове с имена, започващи с долна черта (
_layout.tsx,_utils.ts) - Файлове в директории, започващи с долна черта (
app/_components/) - Тестови файлове (
*.test.tsx,*.spec.tsx)
Това означава, че спокойно можете да организирате помощни файлове и компоненти вътре в app/, без да замърсявате навигацията. Доста удобно.
Layouts — скелетът на навигацията
Root Layout
Файлът app/_layout.tsx е коренният layout на приложението. Тук зареждате шрифтове, конфигурирате теми и обвивате всичко с нужните providers:
import { Stack } from 'expo-router';
import { ThemeProvider, DarkTheme, DefaultTheme } from '@react-navigation/native';
import { useColorScheme } from 'react-native';
import { useFonts } from 'expo-font';
import * as SplashScreen from 'expo-splash-screen';
import { useEffect } from 'react';
SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
const colorScheme = useColorScheme();
const [fontsLoaded] = useFonts({
'Inter-Regular': require('../assets/fonts/Inter-Regular.ttf'),
'Inter-Bold': require('../assets/fonts/Inter-Bold.ttf'),
});
useEffect(() => {
if (fontsLoaded) {
SplashScreen.hideAsync();
}
}, [fontsLoaded]);
if (!fontsLoaded) return null;
return (
);
}
Tab Navigation Layout
За приложения с долна навигационна лента (а това са повечето мобилни приложения, нека бъдем честни) създайте група (tabs) с layout:
// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
export default function TabLayout() {
return (
(
),
}}
/>
(
),
}}
/>
(
),
}}
/>
);
}
Вложени Layouts
Можете да влагате layouts колкото е необходимо. Например профилният раздел може да си има собствен Stack навигатор:
// app/(tabs)/profile/_layout.tsx
import { Stack } from 'expo-router';
export default function ProfileLayout() {
return (
);
}
Навигация между екрани — Link, router и useRouter
Компонентът Link
Най-простият начин за навигация е чрез Link компонента. Работи подобно на <a> тага в уеб, така че ако идвате от уеб разработка, ще ви е познато:
import { Link } from 'expo-router';
import { View, Text, StyleSheet } from 'react-native';
export default function HomeScreen() {
return (
Добре дошли
{/* Прост линк */}
За нас
{/* Линк с динамичен параметър */}
Статия #42
{/* Линк, който замества текущия екран в стека */}
Настройки
{/* Линк като бутон */}
Моят профил
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 16, justifyContent: 'center' },
title: { fontSize: 24, fontWeight: 'bold', marginBottom: 20 },
link: { color: '#007AFF', fontSize: 16, marginVertical: 8 },
button: { backgroundColor: '#007AFF', padding: 12, borderRadius: 8 },
buttonText: { color: 'white', textAlign: 'center', fontWeight: '600' },
});
Програмна навигация с useRouter
За навигация от код (например след успешно изпращане на форма) ще използвате хука useRouter:
import { useRouter } from 'expo-router';
import { View, Button, Alert } from 'react-native';
export default function LoginScreen() {
const router = useRouter();
const handleLogin = async () => {
try {
await authenticateUser();
// Навигиране и замяна на текущия екран
router.replace('/(tabs)');
} catch (error) {
Alert.alert('Грешка', 'Неуспешно влизане');
}
};
const handleGoBack = () => {
if (router.canGoBack()) {
router.back();
} else {
router.replace('/');
}
};
return (
);
}
Разлики между push, replace и navigate
Expo Router предлага три основни метода за навигация и разликата между тях е важна. Не ги бъркайте:
- router.push(href) — Добавя нов екран в стека. Потребителят може да се върне назад.
- router.replace(href) — Замества текущия екран. Няма връщане назад към заменения.
- router.navigate(href) — Интелигентна навигация. Ако маршрутът вече е в стека, връща се до него вместо да създава дубликат.
// Пример за разликите
const router = useRouter();
// Добавя /details в стека (Home → Details)
router.push('/details');
// Замества текущия екран (Home се замества с Details)
router.replace('/details');
// Ако Details вече е в стека, връща се до него
// Ако не е — добавя го като push
router.navigate('/details');
Динамични маршрути и параметри
Единичен динамичен сегмент
Файлове с квадратни скоби в името дефинират динамични маршрути. Ако идвате от Next.js, веднага ще се ориентирате:
// app/blog/[id].tsx
import { useLocalSearchParams } from 'expo-router';
import { View, Text, StyleSheet, ActivityIndicator } from 'react-native';
import { useState, useEffect } from 'react';
interface BlogPost {
id: string;
title: string;
content: string;
author: string;
publishedAt: string;
}
export default function BlogPostScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const [post, setPost] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchPost(id).then(data => {
setPost(data);
setLoading(false);
});
}, [id]);
if (loading) return ;
if (!post) return Статията не е намерена ;
return (
{post.title}
от {post.author} • {post.publishedAt}
{post.content}
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 16 },
title: { fontSize: 24, fontWeight: 'bold', marginBottom: 8 },
meta: { color: '#666', marginBottom: 16 },
content: { fontSize: 16, lineHeight: 24 },
});
Множество динамични параметри
Можете да комбинирате статични и динамични сегменти без проблем:
app/
└── shop/
└── [category]/
└── [productId].tsx → /shop/electronics/42
// app/shop/[category]/[productId].tsx
import { useLocalSearchParams } from 'expo-router';
export default function ProductScreen() {
const { category, productId } = useLocalSearchParams<{
category: string;
productId: string;
}>();
return (
Категория: {category}
Продукт: {productId}
);
}
Catch-all маршрути
За маршрути, които трябва да хващат произволен брой сегменти, използвайте тройни точки:
// app/docs/[...path].tsx — съвпада с /docs/a, /docs/a/b, /docs/a/b/c
import { useLocalSearchParams } from 'expo-router';
export default function DocsScreen() {
const { path } = useLocalSearchParams<{ path: string[] }>();
// /docs/react/hooks/useState → path = ['react', 'hooks', 'useState']
const fullPath = Array.isArray(path) ? path.join('/') : path;
return Документация: {fullPath} ;
}
Групи за организация — скоби без промяна на URL
Скобите () в имената на директории създават логически групи без да променят URL пътищата. Лично аз смятам, че това е една от най-полезните функции — особено за разделяне на автентикирани и неавтентикирани потоци:
app/
├── _layout.tsx
├── (auth)/
│ ├── _layout.tsx
│ ├── login.tsx → /login
│ └── register.tsx → /register
├── (app)/
│ ├── _layout.tsx
│ ├── (tabs)/
│ │ ├── _layout.tsx
│ │ ├── index.tsx → /
│ │ ├── explore.tsx → /explore
│ │ └── profile.tsx → /profile
│ └── settings.tsx → /settings
└── +not-found.tsx
Забележете — (auth) и (app) не се появяват в URL адресите. URL-ът за login е просто /login, не /auth/login. Чисто и подредено.
Аутентикация и защитени маршрути
Имплементация на auth контекст
Expo Router работи чудесно с React Context за управление на аутентикацията. Ето една пълна имплементация, която можете да адаптирате за вашия проект:
// contexts/AuthContext.tsx
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import * as SecureStore from 'expo-secure-store';
interface User {
id: string;
name: string;
email: string;
}
interface AuthContextType {
user: User | null;
isLoading: boolean;
signIn: (email: string, password: string) => Promise<void>;
signOut: () => Promise<void>;
signUp: (name: string, email: string, password: string) => Promise<void>;
}
const AuthContext = createContext(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
loadStoredAuth();
}, []);
const loadStoredAuth = async () => {
try {
const token = await SecureStore.getItemAsync('auth_token');
if (token) {
const userData = await fetchUserProfile(token);
setUser(userData);
}
} catch (error) {
console.error('Грешка при зареждане на сесията:', error);
} finally {
setIsLoading(false);
}
};
const signIn = async (email: string, password: string) => {
const response = await api.post('/auth/login', { email, password });
await SecureStore.setItemAsync('auth_token', response.token);
setUser(response.user);
};
const signOut = async () => {
await SecureStore.deleteItemAsync('auth_token');
setUser(null);
};
const signUp = async (name: string, email: string, password: string) => {
const response = await api.post('/auth/register', { name, email, password });
await SecureStore.setItemAsync('auth_token', response.token);
setUser(response.user);
};
return (
{children}
);
}
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) throw new Error('useAuth трябва да се използва в AuthProvider');
return context;
};
Пренасочване въз основа на аутентикация
В root layout-а използвайте Redirect компонента за автоматично пренасочване. Тази част е наистина готина — потребителят просто бива „прехвърлен" към правилния екран:
// app/_layout.tsx
import { Stack, Redirect } from 'expo-router';
import { AuthProvider, useAuth } from '../contexts/AuthContext';
import { ActivityIndicator, View } from 'react-native';
function RootLayoutNav() {
const { user, isLoading } = useAuth();
if (isLoading) {
return (
);
}
return (
{user ? (
) : (
)}
);
}
export default function RootLayout() {
return (
);
}
С тази имплементация потребителят автоматично се пренасочва към екрана за вход, ако не е аутентикиран. След успешно влизане навигацията се превключва към основното приложение — без ръчна навигация, без допълнителен код.
Типизирани маршрути с TypeScript
Автоматично генериране на типове
Expo Router може автоматично да генерира TypeScript типове за всички маршрути, което е страхотно за предотвратяване на грешки. Активирайте го в app.json:
{
"expo": {
"experiments": {
"typedRoutes": true
}
}
}
След стартиране на npx expo start, рутерът генерира файл .expo/types/router.d.ts с типове за всички маршрути. Резултатът? Autocomplete и компилационна проверка:
import { Link, useRouter } from 'expo-router';
// TypeScript ще покаже грешка, ако маршрутът не съществува
Профил // ✓ Валиден
Нищо // ✗ Грешка!
const router = useRouter();
router.push('/blog/42'); // ✓ Валиден
router.push('/invalid-route'); // ✗ Грешка!
Типове за динамични параметри
С useLocalSearchParams можете да типизирате параметрите лесно:
// Генерираните типове автоматично знаят за [id] параметъра
import { useLocalSearchParams } from 'expo-router';
export default function PostScreen() {
// TypeScript знае, че id е string
const { id } = useLocalSearchParams<{ id: string }>();
// Допълнителни query параметри
const { id, sort, page } = useLocalSearchParams<{
id: string;
sort?: string;
page?: string;
}>();
}
Deep Linking — безпроблемни връзки към приложението
Автоматична настройка
Едно от най-големите предимства на Expo Router е, че deep linking работи практически автоматично. Тъй като маршрутите се базират на файловата система, URL адресите директно съвпадат с екраните. Няма нужда от допълнителни mapping-и.
Конфигурирайте URL схемата в app.json:
{
"expo": {
"scheme": "myapp",
"android": {
"intentFilters": [
{
"action": "VIEW",
"autoVerify": true,
"data": [
{
"scheme": "https",
"host": "myapp.com",
"pathPrefix": "/"
}
],
"category": ["BROWSABLE", "DEFAULT"]
}
]
},
"ios": {
"associatedDomains": ["applinks:myapp.com"]
}
}
}
Universal Links (iOS) и App Links (Android)
С правилната конфигурация URL адресите от вашия уебсайт автоматично отварят съответните екрани:
https://myapp.com/blog/42→ отваряapp/blog/[id].tsxсid=42https://myapp.com/profile→ отваряapp/profile/index.tsxmyapp://settings→ отваряapp/settings.tsx
Не е необходима допълнителна конфигурация на маршрутите за deep linking. Expo Router разбира структурата автоматично и просто работи.
Модални прозорци и споделени елементи
Модали
Модалните прозорци се конфигурират чрез опцията presentation в layout-а. Ето два варианта — стандартен модал и прозрачен:
// app/_layout.tsx
import { Stack } from 'expo-router';
export default function RootLayout() {
return (
);
}
// app/modal.tsx
import { useRouter } from 'expo-router';
import { View, Text, Button, StyleSheet } from 'react-native';
export default function ModalScreen() {
const router = useRouter();
return (
Създаване на нов пост
{/* Съдържание на формата */}
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 16 },
title: { fontSize: 20, fontWeight: 'bold', marginBottom: 16 },
});
Персонализирана 404 страница и обработка на грешки
Not Found екран
Expo Router предлага готова конвенция за обработка на несъществуващи маршрути. Просто създайте файл +not-found.tsx:
// app/+not-found.tsx
import { Link, Stack } from 'expo-router';
import { View, Text, StyleSheet } from 'react-native';
export default function NotFoundScreen() {
return (
<>
🔍
Упс! Тази страница не съществува
Възможно е връзката да е грешна или страницата да е преместена
Обратно към началната страница
>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 20 },
emoji: { fontSize: 64, marginBottom: 16 },
title: { fontSize: 22, fontWeight: 'bold', marginBottom: 8, textAlign: 'center' },
subtitle: { fontSize: 16, color: '#666', textAlign: 'center', marginBottom: 20 },
link: { color: '#007AFF', fontSize: 16, fontWeight: '600' },
});
Error Boundary
Всеки маршрут може да експортира ErrorBoundary компонент, което е много полезно за грациозна обработка на грешки без да се срине цялото приложение:
// app/blog/[id].tsx
import { useLocalSearchParams } from 'expo-router';
import { View, Text, Button } from 'react-native';
export function ErrorBoundary({ error, retry }: { error: Error; retry: () => void }) {
return (
Нещо се обърка
{error.message}
);
}
export default function BlogPostScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
// ... компонентът
}
Drawer навигация
За приложения със странична навигация (drawer / хамбургер меню), Expo Router поддържа и Drawer layout. Първо инсталирайте зависимостите:
npx expo install @react-navigation/drawer react-native-gesture-handler react-native-reanimated
// app/(drawer)/_layout.tsx
import { Drawer } from 'expo-router/drawer';
import { Ionicons } from '@expo/vector-icons';
export default function DrawerLayout() {
return (
(
),
}}
/>
(
),
}}
/>
(
),
}}
/>
);
}
Съвети за производителност и добри практики
Lazy Loading на екрани
По подразбиране Expo Router лениво зарежда екраните — те се импортират едва когато потребителят навигира до тях. Но можете да оптимизирате допълнително:
// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
export default function TabLayout() {
return (
{/* ... */}
);
}
Оптимизиране на списъци с маршрути
При екрани със списъци горещо препоръчвам FlashList от Shopify вместо стандартния FlatList. Разликата в производителността е забележима:
import { Link } from 'expo-router';
import { FlashList } from '@shopify/flash-list';
import { Pressable, Text, View } from 'react-native';
export default function BlogListScreen() {
const posts = usePosts();
return (
(
{item.title}
{item.excerpt}
)}
/>
);
}
Предзареждане на данни
Можете да предзареждате данни за екран, преди потребителят да навигира до него. Това е малка оптимизация, но прави навигацията значително по-гладка:
import { Link, usePrefetchRoute } from 'expo-router';
import { Pressable, Text } from 'react-native';
export default function HomeScreen() {
// Предзарежда маршрута при рендериране на компонента
usePrefetchRoute('/profile');
return (
Виж профила
);
}
Миграция от React Navigation към Expo Router
Ако вече имате проект с React Navigation, миграцията е по-лесна, отколкото си мислите. Сериозно. Ето основните стъпки:
- Създайте директорията
app/— преместете екраните от навигационната конфигурация в съответните файлове. - Конвертирайте навигаторите в layouts —
createStackNavigator()ставаStackв_layout.tsx,createBottomTabNavigator()ставаTabs. - Заменете
navigation.navigate()сrouter.push()илиLink. - Заменете
navigation.getParam()сuseLocalSearchParams(). - Премахнете ръчната deep linking конфигурация — Expo Router я управлява автоматично.
// ПРЕДИ: React Navigation
const Stack = createStackNavigator();
function App() {
return (
);
}
// СЛЕД: Expo Router
// app/_layout.tsx
import { Stack } from 'expo-router';
export default function Layout() {
return ;
}
// app/index.tsx — HomeScreen
// app/details.tsx — DetailsScreen
Често задавани въпроси
Мога ли да използвам Expo Router без Expo?
Expo Router е проектиран да работи в рамките на Expo екосистемата и зависи от Metro bundler-а с Expo конфигурация. Ако използвате чист React Native проект (bare workflow), можете да добавите Expo чрез npx install-expo-modules и след това да инсталирате рутера. Технически обаче той изисква Expo инфраструктура, така че напълно без Expo — не, няма да стане.
Как да скрия определен маршрут от Tab навигацията?
Използвайте href: null в опциите на екрана. Маршрутът остава достъпен чрез програмна навигация, но просто не се показва в tab bar-а:
Expo Router поддържа ли Server-Side Rendering (SSR)?
Да, поддържа. Expo Router v4 има поддръжка за SSR и статично генериране за уеб платформата. Конфигурирате уеб изхода като "server" или "static" в app.json. За мобилните платформи SSR не е приложимо, но уеб версията на приложението ви получава SEO предимства.
Как работи Expo Router с EAS Update?
Expo Router е напълно съвместим с EAS Update (over-the-air ъпдейти). Добавянето на нови маршрути или промяната на съществуващи се поддържа чрез OTA ъпдейти без проблем. Единственото ограничение — промени в app.json (като deep linking схемата) изискват ново изграждане на приложението.
Каква е разликата между useLocalSearchParams и useGlobalSearchParams?
useLocalSearchParams връща параметрите, специфични за текущия маршрут, и не се актуализира при навигация до друг екран. useGlobalSearchParams връща глобалните URL параметри и се актуализира при всяка промяна. В повечето случаи useLocalSearchParams е правилният избор — предотвратява ненужни повторни рендирания и е по-предвидим.