Встроенный просмотрщик PDF — типичная задача для кабинетов документооборота, страниц договоров, порталов нормативных документов и систем отчётности. Простое <iframe src="file.pdf"> — плохая идея: поведение зависит от браузера, мобильный Chrome скачивает файл вместо показа, UI не контролируется. За годы работы мы переделали десятки таких встраиваний и знаем, как сделать удобно и безопасно. Каждый раз сталкивались с проблемами производительности и безопасности, поэтому выработали проверенный подход на базе react-pdf и PDF.js. Сократите расходы на самостоятельную разработку — наше решение окупается за счёт сокращения времени и ошибок.
Мы используем проверенный стек: React 18+, TypeScript, react-pdf и PDF.js. Настраиваем навигацию, зум, выделение текста, поиск — всё как в Figma или Google Docs. На каждом этапе замеряем Core Web Vitals: LCP не превышает 2.5 с, TTFB — 0.8 с. Экономия бюджета на внедрение может достигать 40% по сравнению с самостоятельной разработкой.
Почему react-pdf — лучший выбор для встраивания PDF?
Mozilla PDF.js — де-факто стандарт для браузерного рендеринга PDF. Использует Canvas для отрисовки каждой страницы, поддерживает текст-слой и аннотации. Именно его использует Firefox для встроенного просмотрщика. react-pdf — зрелая обёртка, которую используют GitHub, CodeSandbox и Canva для предпросмотра. Она решает проблему кросс-браузерности и даёт полный контроль над интерфейсом.
npm install pdfjs-dist
npm install react-pdf # React-обёртка над PDF.js
Как реализовать постраничный режим?
import { Document, Page, pdfjs } from 'react-pdf'
import 'react-pdf/dist/esm/Page/AnnotationLayer.css'
import 'react-pdf/dist/esm/Page/TextLayer.css'
// Указываем worker — обязательно
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url,
).toString()
interface PDFViewerProps {
url: string
}
export function PDFViewer({ url }: PDFViewerProps) {
const [numPages, setNumPages] = useState<number>(0)
const [pageNumber, setPageNumber] = useState<number>(1)
const [scale, setScale] = useState<number>(1.0)
const [isLoading, setIsLoading] = useState(true)
function onDocumentLoadSuccess({ numPages }: { numPages: number }) {
setNumPages(numPages)
setIsLoading(false)
}
return (
<div className="flex flex-col items-center">
{/* Тулбар */}
<div className="flex items-center gap-4 p-3 bg-gray-800 text-white w-full">
<button
onClick={() => setPageNumber((p) => Math.max(1, p - 1))}
disabled={pageNumber <= 1}
className="px-3 py-1 bg-gray-600 rounded disabled:opacity-40"
>
←
</button>
<span className="text-sm">
{pageNumber} / {numPages}
</span>
<button
onClick={() => setPageNumber((p) => Math.min(numPages, p + 1))}
disabled={pageNumber >= numPages}
className="px-3 py-1 bg-gray-600 rounded disabled:opacity-40"
>
→
</button>
<div className="ml-auto flex items-center gap-2">
<button onClick={() => setScale((s) => Math.max(0.5, s - 0.25))}>−</button>
<span className="text-sm w-12 text-center">{Math.round(scale * 100)}%</span>
<button onClick={() => setScale((s) => Math.min(3, s + 0.25))}>+</button>
</div>
</div>
{/* Документ */}
<div className="overflow-auto bg-gray-200 w-full" style={{ maxHeight: '80vh' }}>
<Document
file={url}
onLoadSuccess={onDocumentLoadSuccess}
loading={<div className="p-8 text-center">Загрузка...</div>}
error={<div className="p-8 text-center text-red-500">Ошибка загрузки PDF</div>}
>
<Page
pageNumber={pageNumber}
scale={scale}
renderTextLayer={true} // Выделяемый текст
renderAnnotationLayer={true} // Кликабельные ссылки
className="shadow-lg mx-auto my-4"
/>
</Document>
</div>
</div>
)
}
Как внедрить скролл всех страниц?
Для длинных документов удобнее прокрутка, а не пагинация:
function PDFScrollViewer({ url }: { url: string }) {
const [numPages, setNumPages] = useState(0)
const [containerWidth, setContainerWidth] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!containerRef.current) return
const observer = new ResizeObserver(([entry]) => {
setContainerWidth(entry.contentRect.width)
})
observer.observe(containerRef.current)
return () => observer.disconnect()
}, [])
return (
<div ref={containerRef} className="overflow-auto" style={{ height: '80vh' }}>
<Document file={url} onLoadSuccess={({ numPages }) => setNumPages(numPages)}>
{Array.from({ length: numPages }, (_, i) => (
<Page
key={i + 1}
pageNumber={i + 1}
width={containerWidth - 32} // Адаптивная ширина
className="mb-4 shadow mx-4"
renderTextLayer={true}
/>
))}
</Document>
</div>
)
}
Почему нужна виртуализация для больших PDF?
100-страничный PDF рендерить целиком — убить память браузера: потребление может превысить 500 МБ. Виртуализация рендерит только видимые страницы + небольшой оффсет (по умолчанию 2 страницы сверху и снизу). Это снижает нагрузку на 90% и ускоряет первый рендер до 0.5 с. Используем @tanstack/react-virtual:
import { useVirtualizer } from '@tanstack/react-virtual'
function VirtualPDFViewer({ url }: { url: string }) {
const [numPages, setNumPages] = useState(0)
const [pageHeight] = useState(842) // A4 при scale=1
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: numPages,
getScrollElement: () => parentRef.current,
estimateSize: () => pageHeight + 16, // высота страницы + отступ
overscan: 2,
})
return (
<div ref={parentRef} style={{ height: '80vh', overflow: 'auto' }}>
<Document file={url} onLoadSuccess={({ numPages }) => setNumPages(numPages)}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
transform: `translateY(${virtualItem.start}px)`,
width: '100%',
padding: '8px 16px',
}}
>
<Page
pageNumber={virtualItem.index + 1}
width={600}
renderTextLayer={false} // Отключаем для скорости
/>
</div>
))}
</div>
</Document>
</div>
)
}
Как выбрать режим просмотра?
Для небольших документов (до 30 страниц) удобен постраничный режим с кнопками навигации — он прост и точен. Если страниц до 50, можно включить скролл всех страниц сразу, но это потребует больше памяти. Для документов от 50 страниц настоятельно рекомендую виртуальный скроллинг: рендерятся только видимые страницы, что экономит до 90% памяти и ускоряет первый рендер до 0.5 с. На практике это означает, что 100-страничный PDF в виртуальном режиме потребляет около 35 МБ вместо 280 МБ.
| Количество страниц | Режим | Время первого рендера | Потребление памяти |
|---|---|---|---|
| 10 | Постраничный | 0.6 с | 30 МБ |
| 100 | Скролл | 2.1 с | 280 МБ |
| 100 | Виртуальный скролл | 0.5 с | 35 МБ |
| 500 | Виртуальный скролл | 1.2 с | 70 МБ |
Как защитить PDF от скачивания?
Чтобы пользователь не мог скачать файл напрямую по URL, используем Blob URL. Загружаем PDF через API с авторизацией, преобразуем в Blob и создаём временный URL, который живёт только в рамках сессии. Прямая ссылка на файл никогда не передаётся во фронтенд.
async function loadProtectedPDF(documentId: string): Promise<string> {
const response = await fetch(`/api/documents/${documentId}/content`, {
headers: { Authorization: `Bearer ${getToken()}` },
})
if (!response.ok) throw new Error('Access denied')
const blob = await response.blob()
return URL.createObjectURL(blob)
// Blob URL действителен только в рамках сессии браузера
// Прямой ссылки нет — только через API с авторизацией
}
Дополнительно можем наложить водяной знак на каждую страницу через canvas — это уже кастомизация под ваш проект. Например, водяной знак с email пользователя снижает риск утечки на 80% по нашим наблюдениям.
Если API для PDF на другом домене, настройте CORS: Access-Control-Allow-Origin: https://вашсайт.ru и передавайте токен в заголовке Authorization.
Типичные проблемы и их решения
| Проблема | Решение |
|---|---|
| PDF не рендерится из-за CORS | Настроить сервер на ответ с заголовком Access-Control-Allow-Origin |
| Медленная загрузка больших PDF | Включить виртуализацию страниц |
| Не выделяется текст | Убедиться, что renderTextLayer=true и TextLayer.css подключён |
| Не работают ссылки в PDF | Проверить renderAnnotationLayer=true и AnnotationLayer.css |
Что входит в работу
- Аудит текущей реализации (если есть)
- Интеграция react-pdf с выбранным режимом
- Кастомный тулбар (навигация, зум, поиск)
- Настройка CORS и загрузки через API
- Документация и примеры использования
- Гарантия на код 6 месяцев
Обсудите ваш проект — мы подберём оптимальный режим под ваш бюджет. Свяжитесь с нами, чтобы получить предварительную оценку сроков под ключ: от 1 до 4 дней в зависимости от сложности. Закажите интеграцию с гарантией 6 месяцев — экономьте до 40% бюджета по сравнению с самостоятельной разработкой.







