При разработке одностраничных приложений (SPA) маршрутизация часто выполняется на стороне клиента. Библиотеки вроде React Router, Vue Router или Angular Router перехватывают изменение URL внутри браузера без полноценного перехода между HTML-страницами.
Пример маршрутов SPA:
/
/catalog
/catalog/phones
/profile/settings
При переходе между такими адресами сервер зачастую всегда должен возвращать один и тот же файл:
index.html
Именно этот файл загружает JavaScript-приложение, после чего клиентский роутер определяет, какой компонент необходимо отрисовать.
Проблема возникает при прямом открытии вложенного маршрута:
http://localhost:8080/profile/settings
Без специальной настройки dev-сервер Webpack пытается найти физический файл:
/profile/settings
Если такого файла не существует, сервер возвращает ошибку:
404 Not Found
Параметр historyApiFallback решает эту проблему.
В классическом приложении каждому URL соответствует отдельный HTML-файл:
/about -> about.html
/contact -> contact.html
Сервер знает, какой файл вернуть.
В SPA существует единая точка входа:
index.html
Все маршруты обрабатываются JavaScript-кодом:
const routes = {
'/': HomePage,
'/catalog': CatalogPage,
'/profile': ProfilePage,
};
Навигация выполняется через History API браузера:
history.pushState({}, '', '/profile');
Страница физически не перезагружается.
При обновлении страницы браузер отправляет HTTP-запрос на сервер:
GET /profile/settings
Webpack Dev Server не знает, что это SPA-маршрут.
Он пытается:
Если ничего не найдено:
404 Not Found
historyApiFallbackmodule.exports = {
devServer: {
historyApiFallback: true,
},
};
Теперь любой неизвестный маршрут будет перенаправлен на:
index.html
После включения параметра:
historyApiFallback: true
Webpack Dev Server изменяет поведение.
GET /profile
-> поиск файла /profile
-> 404
GET /profile
-> файла нет
-> возврат index.html
Затем SPA-приложение:
npm install react-router-dom
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Home from './pages/Home';
import Profile from './pages/Profile';
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/profile" element={<Profile />} />
</Routes>
</BrowserRouter>
);
}
export default App;
module.exports = {
devServer: {
historyApiFallback: true,
},
};
localhost:8080/profile
После обновления страницы:
Cannot GET /profile
Сервер возвращает:
index.html
React Router успешно отображает:
Profile component
trueПараметр поддерживает расширенную конфигурацию.
module.exports = {
devServer: {
historyApiFallback: {},
},
};
Объект позволяет:
indexПо умолчанию используется:
index.html
Можно указать другой файл.
module.exports = {
devServer: {
historyApiFallback: {
index: '/main.html',
},
},
};
Теперь fallback будет возвращать:
main.html
indexПодобная конфигурация применяется:
rewritesrewrites позволяет задавать разные fallback-страницы для
различных маршрутов.
module.exports = {
devServer: {
historyApiFallback: {
rewrites: [
{
from: /^\/admin/,
to: '/admin.html',
},
],
},
},
};
module.exports = {
devServer: {
historyApiFallback: {
rewrites: [
{
from: /^\/admin/,
to: '/admin.html',
},
{
from: /^\/shop/,
to: '/shop.html',
},
{
from: /./,
to: '/index.html',
},
],
},
},
};
/admin/users
-> admin.html
/shop/cart
-> shop.html
/profile
-> index.html
rewritesПравило:
from: /^\/admin/
означает:
URL начинается с /admin
rewrites: [
{
from: /^\/docs\/.*$/,
to: '/docs.html',
},
];
Совпадения:
/docs
/docs/api
/docs/webpack/config
toВместо строки можно использовать функцию.
module.exports = {
devServer: {
historyApiFallback: {
rewrites: [
{
from: /^\/app/,
to(context) {
return '/app.html';
},
},
],
},
},
};
contextФункция получает информацию о запросе.
to(context) {
console.log(context.parsedUrl);
console.log(context.match);
console.log(context.request);
}
rewrites: [
{
from: /^\/mobile/,
to(context) {
const userAgent = context.request.headers['user-agent'];
if (userAgent.includes('Mobile')) {
return '/mobile.html';
}
return '/desktop.html';
},
},
];
disableDotRuleПо умолчанию URLs с точкой считаются запросами к файлам.
Пример:
/profile/edit.png
Webpack предполагает, что это статический ресурс.
Некоторые роутеры используют точки в маршрутах:
/users/v1.0
/docs/api.v2
Без специальной настройки fallback не сработает.
disableDotRulemodule.exports = {
devServer: {
historyApiFallback: {
disableDotRule: true,
},
},
};
Теперь URL:
/docs/api.v2
будет корректно перенаправляться на:
index.html
historyApiFallback взаимодействует со статикойПредположим:
devServer: {
static: './public',
historyApiFallback: true,
}
Структура:
public/
logo.png
fonts/
GET /logo.png
Webpack:
GET /profile
Webpack:
Алгоритм dev-server:
Название historyApiFallback связано с:
window.history
Основные методы:
history.pushState()
history.replaceState()
history.back()
<BrowserRouter>
Использует:
/history/api
Требуется fallback.
<HashRouter>
URL:
/#/profile
Сервер видит только:
/
Fallback не нужен.
| Особенность | BrowserRouter | HashRouter |
|---|---|---|
| Красивые URL | Да | Нет |
| Требуется серверная настройка | Да | Нет |
| Использует History API | Да | Нет |
| SEO | Лучше | Хуже |
| Подходит для production | Да | Ограниченно |
const path = require('path');
module.exports = {
mode: 'development',
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js',
clean: true,
},
devServer: {
port: 3000,
static: {
directory: path.join(__dirname, 'public'),
},
hot: true,
open: true,
historyApiFallback: {
index: '/index.html',
disableDotRule: true,
rewrites: [
{
from: /^\/admin/,
to: '/admin.html',
},
{
from: /./,
to: '/index.html',
},
],
},
},
};
historyApiFallback относится только к:
webpack-dev-server
В production необходимо отдельно настраивать сервер.
location / {
try_files $uri /index.html;
}
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
</IfModule>
app.use(express.static('dist'));
app.get('*', (req, res) => {
res.sendFile(path.resolve(__dirname, 'dist', 'index.html'));
});
Cannot GET /routeПричина:
historyApiFallback: false
или отсутствие настройки.
Локально всё работает:
webpack-dev-server
Но production-сервер не настроен на SPA fallback.
Некорректная rewrite-конфигурация может ломать ресурсы.
Плохой пример:
rewrites: [
{
from: /./,
to: '/index.html',
},
];
при неправильной структуре может перехватывать:
/app.js
/styles.css
/logo.png
Сначала проверяются реальные файлы.
Только затем применяется fallback.
Открытие:
http://localhost:3000/profile
Ожидаемый результат:
SPA загружается корректно
Обновление страницы:
F5
Если конфигурация правильная:
404 не возникает
Прямой переход по URL
http://localhost:3000/catalog/phones
Страница должна открыться без ошибок.
В Webpack 5 настройка выглядит так:
devServer: {
historyApiFallback: true,
}
Поддерживается полностью.
output.publicPathПри использовании SPA важно корректно задавать:
output: {
publicPath: '/',
}
Без корректного publicPath могут ломаться:
module.exports = {
output: {
publicPath: '/',
},
devServer: {
historyApiFallback: true,
},
};
const ProfilePage = lazy(() => import('./ProfilePage'));
Webpack загружает chunk:
/profilePage.chunk.js
При неправильной конфигурации:
Ошибка:
Unexpected token <
Обычно означает:
вместо JavaScript-файла сервер вернул HTML
Чаще всего:
index.html
Во вкладке DevTools:
Network -> JS file
Если Content-Type:
text/html
значит fallback сработал для JS-файла ошибочно.
devServer: {
proxy: {
'/api': {
target: 'http://localhost:5000',
},
},
historyApiFallback: true,
}
/api/users
Уходит в backend.
/profile
Попадает в fallback.
Типичное SPA:
Frontend: Webpack Dev Server
Backend: Express / Laravel / NestJS
Frontend-маршруты:
/profile
/settings
/dashboard
API:
/api/users
/api/posts
historyApiFallback должен работать только для
frontend-маршрутов.
/about.html
/contact.html
Например:
Маршруты обрабатываются сервером.
/#/profile
История браузера не используется полноценно.
historyApiFallbackПараметр фактически превращает dev-server в SPA-aware сервер.
Он: