Перейти к основному содержимому

РЕАЛЬНОЕ СОБЕСЕДОВАНИЕ НА FRONTEND РАЗРАБОТЧИКА С ЗП 310К И НЕТРИВИАЛЬНЫМИ ЗАДАЧАМИ (MIDDLE/SENIOR)!

· 76 мин. чтения

Сегодня мы разберем собеседование на позицию middle plus фронтенд-разработчика в крупную финтех-компанию, где кандидат продемонстрировал уверенные знания в области оптимизации веб-приложений, кроссбраузерной совместимости и безопасности, а также успешно справился с нестандартными задачами на TypeScript, включая работу с дженериками и рекурсивными типами.

Вопрос 1. Как происходил процесс оптимизации в предыдущем проекте: взаимодействие в команде, согласование решений и поиск точек улучшения?

Таймкод: 00:00:58

Ответ собеседника: Правильный. Оптимизация проводилась в рамках работы с техдолгом: запускал Lighthouse, оптимизировал изображения, договаривался с бэкендом о сжатии, добавлял lazy loading на уровне роутинга, проводил мемоизацию через React Profiler, внедрял виртуализацию таблиц и использовал кэширование в RTK Query с инвалидацией по тегам.

Правильный ответ:

Ответ собеседника корректен и демонстрирует практический опыт оптимизации фронтенд-приложений. Однако для Golang-разработчика важно адаптировать этот процесс к бэкенд-контексту, где оптимизация затрагивает другие уровни системы.

Процесс оптимизации в Golang-проектах

В бэкенд-разработке на Go процесс оптимизации обычно начинается с профилирования и измерения метрик. Используются инструменты вроде pprof для анализа CPU и памяти, trace для трассировки горутин и блокировок, а также внешние системы мониторинга (Prometheus, Grafana) для отслеживания латентности и throughput.

Взаимодействие в команде

Оптимизация в команде обычно проходит через регулярные ревью метрик на стендах мониторинга. Когда выявляются узкие места — например, рост latency или увеличение потребления памяти — создаётся задача в баг-трекере. Команда обсуждает возможные решения на технических митингах, оценивает риски и приоритеты. Важно документировать baseline-метрики до изменений, чтобы объективно оценить эффект от оптимизации.

Согласование решений

Решения по оптимизации согласовываются через pull request с обязательным описанием проблемы, предложенного решения и результатов бенчмарков. Для критичных изменений проводятся A/B тесты или канареечные релизы. Архитектурные изменения, такие как переход на другой протокол или внедрение кэширования, обсуждаются на архитектурных ревью.

Поиск точек улучшения

Точки улучшения находятся через:

  • Профилирование под нагрузкой с помощью go test -bench и pprof
  • Анализ логов и метрик в продакшене
  • Ревью кода с фокусом на алгоритмическую сложность
  • Нагрузочное тестирование с помощью инструментов вроде vegeta или k6

Примеры оптимизаций в Go

Оптимизация работы с памятью через пулы объектов:

var bufferPool = sync.Pool{
New: func() interface{} {
return new(bytes.Buffer)
},
}

func processRequest(data []byte) {
buf := bufferPool.Get().(*bytes.Buffer)
defer bufferPool.Put(buf)
buf.Reset()

// Используем буфер вместо аллокации нового
buf.Write(data)
// ... обработка
}

Оптимизация горутин через ограничение параллелизма:

func processItems(items []Item) {
sem := make(chan struct{}, runtime.NumCPU())
var wg sync.WaitGroup

for _, item := range items {
wg.Add(1)
go func(i Item) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
processItem(i)
}(item)
}
wg.Wait()
}

Оптимизация запросов к базе данных через батчинг:

func batchInsert(db *sql.DB, items []Item) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()

stmt, err := tx.Prepare("INSERT INTO items (name, value) VALUES ($1, $2)")
if err != nil {
return err
}
defer stmt.Close()

for _, item := range items {
if _, err := stmt.Exec(item.Name, item.Value); err != nil {
return err
}
}

return tx.Commit()
}

Ключевые принципы

  1. Измеряй до и после — без метрик оптимизация превращается в гадание
  2. Оптимизируй узкие места — 80% времени тратится на 20% кода
  3. Документируй решения — чтобы команда понимала контекст изменений
  4. Тестируй под нагрузкой — локальные бенчмарки не всегда отражают продакшен-поведение

Вопрос 2. Какие действия были предприняты для повышения оценки в Lighthouse и какие метрики улучшались?

Таймкод: 00:03:18

Ответ собеседника: Правильный. Виртуализация улучшила First Contentful Paint на несколько секунд, оптимизация изображений повысила Largest Contentful Paint, а общая метрика производительности в Lighthouse также выросла.

Правильный ответ:

Ответ собеседника демонстрирует понимание ключевых метрик Lighthouse и их связи с конкретными оптимизациями. Для полноты картины стоит расширить описание метрик и методов их улучшения.

Основные метрики Lighthouse и способы их улучшения

First Contentful Paint (FCP)

FCP измеряет время от начала загрузки страницы до момента, когда первый элемент контента отображается на экране. Улучшается через:

  • Виртуализацию списков и таблиц (как указано в ответе)
  • Минификацию и сжатие CSS/JS
  • Использование preload для критических ресурсов
  • Серверный рендеринг (SSR) или статическую генерацию (SSG)

Largest Contentful Paint (LCP)

LCP измеряет время загрузки самого большого видимого элемента (обычно изображение или блок текста). Улучшается через:

  • Оптимизацию изображений (сжатие, современные форматы WebP/AVIF)
  • Использование loading="lazy" для некритичных изображений
  • Предзагрузку критических изображений через <link rel="preload">
  • Оптимизацию шрифтов (font-display: swap)

Time to Interactive (TTI)

TTI показывает, когда страница становится полностью интерактивной. Улучшается через:

  • Разделение кода (code splitting)
  • Удаление неиспользуемого кода (tree shaking)
  • Оптимизацию выполнения JavaScript

Cumulative Layout Shift (CLS)

CLS измеряет визуальную стабильность страницы. Улучшается через:

  • Указание размеров для изображений и видео
  • Резервирование места для динамического контента
  • Избегание вставки контента над существующим

Total Blocking Time (TBT)

TBT измеряет суммарное время, когда главный поток был заблокирован. Улучшается через:

  • Разбиение длинных задач на более мелкие
  • Использование Web Workers для тяжёлых вычислений
  • Мемоизацию компонентов (как упомянуто в ответе)

Пример оптимизации изображений в Go

Если бэкенд на Go отвечает за обработку изображений, можно реализовать автоматическую оптимизацию:

package main

import (
"bytes"
"image"
"image/jpeg"
"net/http"

"github.com/disintegration/imaging"
)

func optimizeImageHandler(w http.ResponseWriter, r *http.Request) {
// Загрузка оригинального изображения
img, err := loadImageFromStorage(r.URL.Query().Get("src"))
if err != nil {
http.Error(w, "Image not found", http.StatusNotFound)
return
}

// Ресайз до нужного размера
resized := imaging.Resize(img, 800, 0, imaging.Lanczos)

// Сжатие с оптимальным качеством
var buf bytes.Buffer
err = jpeg.Encode(&buf, resized, &jpeg.Options{Quality: 75})
if err != nil {
http.Error(w, "Encoding failed", http.StatusInternalServerError)
return
}

w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Cache-Control", "public, max-age=31536000")
w.Write(buf.Bytes())
}

Инструменты для мониторинга

Для постоянного мониторинга метрик производительности используются:

  • Lighthouse CI для автоматической проверки в CI/CD
  • Web Vitals для сбора метрик от реальных пользователей
  • Chrome User Experience Report (CrUX) для агрегированных данных

Важно помнить

Оптимизация метрик Lighthouse — это не разовое мероприятие, а непрерывный процесс. Необходимо настроить мониторинг, чтобы отслеживать регрессии и своевременно реагировать на изменения.

Вопрос 3. Какие способы оптимизации изображений использовались и какие существуют методы минимизации веса картинок в проекте?

Таймкод: 00:04:44

Ответ собеседника: Правильный. Использовались lazy loading через HTML-атрибут, сжатие на бэкенде или через сервисы конвертации в WebP/AVIF, прогрессивный рендер, тег picture для адаптивных изображений под разные устройства.

Правильный ответ:

Ответ собеседника охватывает основные методы оптимизации изображений. Для полноты картины стоит расширить список техник и рассмотреть их реализацию с точки зрения бэкенд-разработки на Go.

Способы оптимизации изображений

1. Современные форматы изображений

Переход на современные форматы даёт значительное сжатие без потери качества:

  • WebP — на 25-35% меньше JPEG при том же качестве
  • AVIF — на 50% меньше JPEG, поддержка HDR и широкого цветового охвата
  • WebP Lossless — альтернатива PNG с лучшим сжатием

2. Адаптивные изображения

Использование тега <picture> и атрибутов srcset/sizes позволяет загружать оптимальный размер под устройство пользователя:

<picture>
<source srcset="image.avif" type="image/avif">
<source srcset="image.webp" type="image/webp">
<img src="image.jpg" alt="Description">
</picture>

3. Lazy Loading

Отложенная загрузка изображений, которые не видны на экране:

<img src="image.jpg" loading="lazy" alt="Description">

4. Прогрессивная загрузка

Отображение изображения с постепенным улучшением качества (progressive JPEG, interlaced PNG).

5. Оптимизация на бэкенд-стороне

Серверная обработка изображений позволяет автоматизировать оптимизацию:

package imageoptimizer

import (
"bytes"
"fmt"
"image"
"image/jpeg"
"net/http"
"strings"

"github.com/disintegration/imaging"
)

type Optimizer struct {
defaultQuality int
maxWidth int
maxHeight int
}

func NewOptimizer() *Optimizer {
return &Optimizer{
defaultQuality: 75,
maxWidth: 1920,
maxHeight: 1080,
}
}

func (o *Optimizer) ServeImage(w http.ResponseWriter, r *http.Request, imagePath string) {
img, err := imaging.Open(imagePath)
if err != nil {
http.Error(w, "Image not found", http.StatusNotFound)
return
}

// Проверяем поддержку форматов клиентом
acceptHeader := r.Header.Get("Accept")
format := o.selectFormat(acceptHeader)

// Ресайз если изображение слишком большое
bounds := img.Bounds()
if bounds.Dx() > o.maxWidth || bounds.Dy() > o.maxHeight {
img = imaging.Fit(img, o.maxWidth, o.maxHeight, imaging.Lanczos)
}

var buf bytes.Buffer
var contentType string

switch format {
case "avif":
// Для AVIF потребуется дополнительная библиотека
// Здесь упрощенный пример
err = jpeg.Encode(&buf, img, &jpeg.Options{Quality: o.defaultQuality})
contentType = "image/jpeg"
case "webp":
err = jpeg.Encode(&buf, img, &jpeg.Options{Quality: o.defaultQuality})
contentType = "image/jpeg"
default:
err = jpeg.Encode(&buf, img, &jpeg.Options{Quality: o.defaultQuality})
contentType = "image/jpeg"
}

if err != nil {
http.Error(w, "Encoding failed", http.StatusInternalServerError)
return
}

w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", "public, max-age=31536000")
w.Write(buf.Bytes())
}

func (o *Optimizer) selectFormat(acceptHeader string) string {
if strings.Contains(acceptHeader, "image/avif") {
return "avif"
}
if strings.Contains(acceptHeader, "image/webp") {
return "webp"
}
return "jpeg"
}

6. CDN и кэширование

Использование CDN с автоматической оптимизацией изображений (Cloudflare, Imgix, Cloudinary) позволяет:

  • Автоматически конвертировать в оптимальный формат
  • Ресайзить на лету под нужный размер
  • Кэшировать обработанные изображения
  • Отдавать с ближайшего к пользователю сервера

7. Спрайты и иконки

Объединение мелких иконок в спрайты или использование SVG-спрайтов уменьшает количество HTTP-запросов.

8. Оптиметация через инструменты

Автоматическая оптимизация в процессе сборки:

  • imagemin — плагины для различных форматов
  • sharp — быстрая обработка на Node.js
  • squoosh — веб-инструмент от Google

Пример интеграции с хранилищем

func (o *Optimizer) ProcessAndUpload(originalPath string) (string, error) {
img, err := imaging.Open(originalPath)
if err != nil {
return "", fmt.Errorf("failed to open image: %w", err)
}

// Создаём несколько размеров
sizes := map[string]int{
"thumbnail": 150,
"medium": 600,
"large": 1200,
}

urls := make(map[string]string)

for name, width := range sizes {
resized := imaging.Resize(img, width, 0, imaging.Lanczos)

var buf bytes.Buffer
if err := jpeg.Encode(&buf, resized, &jpeg.Options{Quality: 80}); err != nil {
return "", fmt.Errorf("failed to encode %s: %w", name, err)
}

// Загружаем в облачное хранилище
url, err := uploadToStorage(buf.Bytes(), fmt.Sprintf("%s_%s.jpg", originalPath, name))
if err != nil {
return "", fmt.Errorf("failed to upload %s: %w", name, err)
}

urls[name] = url
}

return urls["medium"], nil
}

Метрики для оценки эффективности

Для измерения результатов оптимизации отслеживаются:

  • Общий вес изображений на странице
  • Время загрузки LCP-элемента
  • Количество загружаемых байт изображений
  • Процент использования современных форматов

Важно помнить

Оптимизация изображений — это баланс между качеством и размером. Необходимо тестировать различные уровни сжатия и форматы для нахождения оптимального соотношения для конкретного проекта.

Вопрос 4. Какие форматы изображений использовались в проекте?

Таймкод: 00:06:38

Ответ собеседника: Неполный. Использовались SVG для иконок, WebP и PNG. Кандидат не упомянул AVIF самостоятельно, хотя интервьюер подсказал, что AVIF — более современный и легковесный формат.

Правильный ответ:

Ответ собеседника частично корректен, но требует дополнения более полным обзором форматов изображений и их характеристик.

Основные форматы изображений и их применение

SVG (Scalable Vector Graphics)

Векторный формат для иконок, логотипов и простых графических элементов. Преимущества:

  • Масштабируется без потери качества
  • Обычно имеет малый размер файла
  • Можно стилизовать через CSS
  • Поддерживает анимацию

Идеален для иконок, логотипов, простых иллюстраций.

PNG (Portable Network Graphics)

Растровый формат с потерless-сжатием. Преимущества:

  • Поддержка прозрачности (альфа-канал)
  • Сжатие без потери качества
  • Хорошо подходит для изображений с чёткими краями и текстом

Недостатки:

  • Большой размер файла по сравнению с JPEG для фотографий
  • Не подходит для изображений с большим количеством цветов

JPEG (Joint Photographic Experts Group)

Растровый формат с потеряющим сжатием. Преимущества:

  • Хорошее сжатие для фотографий
  • Широкая поддержка браузерами

Недостатки:

  • Потеря качества при сжатии
  • Не поддерживает прозрачность
  • Артефакты на градиентах и чётких краях

WebP

Современный формат от Google, поддерживающий как потеряющее, так и безпотерное сжатие. Преимущества:

  • На 25-35% меньше JPEG при том же качестве
  • Поддержка прозрачности
  • Поддержка анимации

Недостатки:

  • Не поддерживается в старых браузерах (Safari до 14 версии)
  • Требует fallback для совместимости

AVIF (AV1 Image File Format)

Наиболее современный формат на основе видеокодека AV1. Преимущества:

  • На 50% меньше JPEG при сопоставимом качестве
  • Лучше сохраняет градиенты и мелкие детали
  • Поддержка HDR и широкого цветового охвата
  • Отличное сжатие для изображений с текстом и графикой

Недостатки:

  • Ограниченная поддержка браузерами (Chrome, Firefox, Edge; Safari с 2023 года)
  • Более длительное время кодирования
  • Требует fallback для совместимости

Сравнение форматов

ФорматСжатиеПрозрачностьАнимацияПоддержка браузерами
JPEGПотеряющееНетНетУниверсальная
PNGБезпотерноеДаНетУниверсальная
WebPОба типаДаДаСовременные браузеры
AVIFОба типаДаДаНовейшие браузеры
SVGВекторныйДаДа (CSS/SMIL)Универсальная

Рекомендации по выбору формата

Для иконок и логотипов — SVG Для фотографий — WebP с fallback на JPEG Для изображений с прозрачностью — WebP или PNG Для максимального сжатия — AVIF с fallback на WebP/JPEG

Пример реализации на Go

package imageformat

import (
"fmt"
"strings"
)

type ImageFormat string

const (
FormatJPEG ImageFormat = "jpeg"
FormatPNG ImageFormat = "png"
FormatWebP ImageFormat = "webp"
FormatAVIF ImageFormat = "avif"
FormatSVG ImageFormat = "svg"
)

type FormatSupport struct {
AVIF bool
WebP bool
}

func DetectBrowserSupport(acceptHeader string, userAgent string) FormatSupport {
support := FormatSupport{}

// Проверяем заголовок Accept
if strings.Contains(acceptHeader, "image/avif") {
support.AVIF = true
}
if strings.Contains(acceptHeader, "image/webp") {
support.WebP = true
}

return support
}

func SelectOptimalFormat(support FormatSupport, hasTransparency bool) ImageFormat {
// Приоритет: AVIF > WebP > PNG/JPEG
if support.AVIF {
return FormatAVIF
}

if support.WebP {
return FormatWebP
}

if hasTransparency {
return FormatPNG
}

return FormatJPEG
}

func GetContentType(format ImageFormat) string {
switch format {
case FormatJPEG:
return "image/jpeg"
case FormatPNG:
return "image/png"
case FormatWebP:
return "image/webp"
case FormatAVIF:
return "image/avif"
case FormatSVG:
return "image/svg+xml"
default:
return "image/jpeg"
}
}

func GetFileExtension(format ImageFormat) string {
switch format {
case FormatJPEG:
return ".jpg"
case FormatPNG:
return ".png"
case FormatWebP:
return ".webp"
case FormatAVIF:
return ".avif"
case FormatSVG:
return ".svg"
default:
return ".jpg"
}
}

Важно помнить

При внедрении современных форматов всегда предусматривайте fallback для старых браузеров. Используйте тег <picture> для предоставления нескольких вариантов изображения и позволяйте браузеру выбирать оптимальный формат.

Вопрос 5. Работал ли с PWA? Что знаешь о PWA — что это, для чего нужно, какие особенности?

Таймкод: 00:08:12

Ответ собеседника: Неполный. Работал с PWA на pet-проектах, настраивал manifest и service workers, пытался делать push-уведомления и офлайн-режим. Не упомянул ключевые особенности: стратегии кэширования, offline-first, installability и специфику Safari.

Правильный ответ:

Ответ собеседника демонстрирует базовое знакомство с PWA, но требует существенного дополнения для полного понимания темы.

Что такое PWA

Progressive Web Application — это веб-приложение, которое использует современные веб-технологии для предоставления пользователю опыта, сопоставимого с нативным приложением. PWA работает в браузере, но может быть установлен на устройство и работать офлайн.

Ключевые характеристики PWA

Installability (Устанавливаемость)

PWA может быть установлено на домашний экран устройства без магазина приложений. Для этого необходимы:

  • Web App Manifest
  • HTTPS
  • Service Worker с обработкой fetch-событий

Пример manifest.json:

{
"name": "My Application",
"short_name": "MyApp",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#000000",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

Offline-first и кэширование

Service Worker позволяет кэшировать ресурсы и работать без интернета. Основные стратегии кэширования:

  • Cache First — сначала проверяем кэш, потом сеть
  • Network First — сначала сеть, при ошибке — кэш
  • Stale While Revalidate — отдаём из кэша и обновляем в фоне
  • Network Only — только сеть
  • Cache Only — только кэш

Пример Service Worker с использованием Workbox:

import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { CacheFirst, StaleWhileRevalidate, NetworkFirst } from 'workbox-strategies';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
import { ExpirationPlugin } from 'workbox-expiration';

// Прекэширование ресурсов
precacheAndRoute(self.__WB_MANIFEST);

// Статика — Cache First
registerRoute(
({ request }) => request.destination === 'style' ||
request.destination === 'script',
new CacheFirst({
cacheName: 'static-resources',
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 30 * 24 * 60 * 60 })
]
})
);

// Изображения — Cache First с лимитом
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: 'images',
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 60 * 24 * 60 * 60 })
]
})
);

// API запросы — Network First
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({
cacheName: 'api-cache',
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 5 * 60 })
]
})
);

Push-уведомления

PWA поддерживает push-уведомления через Push API и Notifications API:

// Подписка на push
async function subscribeToPush() {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
});

// Отправляем подписку на сервер
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription)
});
}

Специфика Safari

Safari исторически имел ограниченную поддержку PWA:

  • Нет поддержки Web App Banner для установки
  • Ограниченная поддержка Push API (добавлена в Safari 16.1+)
  • Ограниченный размер кэша (50 МБ)
  • Нет поддержки Background Sync
  • Ограничения на работу Service Worker в фоне

Для чего нужен PWA

  • Публикация без магазинов приложений (актуально при ограничениях)
  • Работа в условиях нестабильного интернета
  • Снижение затрат на разработку (один код для всех платформ)
  • Меньший барьер входа для пользователя (не нужно устанавливать)
  • Возможность отправки push-уведомлений

Бэкенд для PWA на Go

Серверная часть для PWA должна поддерживать:

package main

import (
"encoding/json"
"net/http"

"github.com/SherClockHolmes/webpush-go"
)

type PushSubscription struct {
Endpoint string `json:"endpoint"`
Keys struct {
P256dh string `json:"p256dh"`
Auth string `json:"auth"`
} `json:"keys"`
}

type PushHandler struct {
vapidPrivateKey string
vapidPublicKey string
}

func (h *PushHandler) Subscribe(w http.ResponseWriter, r *http.Request) {
var sub PushSubscription
if err := json.NewDecoder(r.Body).Decode(&sub); err != nil {
http.Error(w, "Invalid subscription", http.StatusBadRequest)
return
}

// Сохраняем подписку в базу данных
if err := saveSubscription(sub); err != nil {
http.Error(w, "Failed to save subscription", http.StatusInternalServerError)
return
}

w.WriteHeader(http.StatusOK)
}

func (h *PushHandler) SendNotification(w http.ResponseWriter, r *http.Request) {
subscriptions, err := getAllSubscriptions()
if err != nil {
http.Error(w, "Failed to get subscriptions", http.StatusInternalServerError)
return
}

for _, sub := range subscriptions {
subscription := webpush.Subscription{
Endpoint: sub.Endpoint,
Keys: webpush.Keys{
P256dh: sub.Keys.P256dh,
Auth: sub.Keys.Auth,
},
}

resp, err := webpush.SendNotification(
[]byte(`{"title":"New Update","body":"Check out the new features!"}`),
&subscription,
&webpush.Options{
VAPIDPublicKey: h.vapidPublicKey,
VAPIDPrivateKey: h.vapidPrivateKey,
TTL: 30,
},
)

if err != nil {
log.Printf("Failed to send notification: %v", err)
continue
}
resp.Body.Close()
}

w.WriteHeader(http.StatusOK)
}

Важно помнить

PWA — это не замена нативным приложениям, а дополнение. Для сложных приложений с интенсивным использованием аппаратных возможностей устройства нативная разработка может быть предпочтительнее. PWA идеально подходит для контентных приложений, интернет-магазинов и корпоративных порталов.

Вопрос 6. Как отправлялись push-уведомления в PWA — использовались ли сторонние сервисы?

Таймкод: 00:09:44

Ответ собеседника: Неполный. Использовал Workbox и window.navigator для push-уведомлений. Не упомянул Firebase и другие сервисы для push-уведомлений, особенно для Safari на iOS.

Правильный ответ:

Ответ собеседника демонстрирует поверхностное знакомство с механизмом push-уведомлений. Для полного понимания темы необходимо рассмотреть архитектуру push-уведомлений и все используемые компоненты.

Архитектура push-уведомлений в PWA

Push-уведомления в PWA работают через три компонента:

  • Push API — подписка на push-уведомления в браузере
  • Notifications API — отображение уведомлений пользователю
  • Push Service — сервис браузера, который доставляет уведомления

Процесс отправки push-уведомлений

  1. Пользователь подписывается на push через Push API
  2. Браузер получает уникальный endpoint от Push Service
  3. Endpoint и ключи шифрования отправляются на ваш сервер
  4. Сервер отправляет уведомление через Push Service
  5. Push Service доставляет уведомление на устройство пользователя

Сторонние сервисы для push-уведомлений

Firebase Cloud Messaging (FCM)

Наиболее популярный сервис для push-уведомлений, особенно для Safari на iOS:

  • Бесплатный тариф
  • Поддержка всех платформ
  • Интеграция с аналитикой
  • Поддержка topic-based рассылок
// Инициализация Firebase
import { initializeApp } from 'firebase/app';
import { getMessaging, getToken, onMessage } from 'firebase/messaging';

const firebaseApp = initializeApp({
apiKey: "your-api-key",
authDomain: "your-project.firebaseapp.com",
projectId: "your-project",
messagingSenderId: "123456789",
appId: "your-app-id"
});

const messaging = getMessaging(firebaseApp);

// Получение токена
getToken(messaging, { vapidKey: 'your-vapid-key' })
.then((currentToken) => {
if (currentToken) {
// Отправляем токен на сервер
sendTokenToServer(currentToken);
}
})
.catch((err) => {
console.log('Error getting token:', err);
});

// Обработка входящих сообщений
onMessage(messaging, (payload) => {
console.log('Message received:', payload);
// Показываем уведомление
const notificationTitle = payload.notification.title;
const notificationOptions = {
body: payload.notification.body,
icon: payload.notification.icon
};

new Notification(notificationTitle, notificationOptions);
});

OneSignal

Альтернативный сервис с расширенными возможностями:

  • Бесплатный тариф до 10,000 подписчиков
  • A/B тестирование уведомлений
  • Сегментация аудитории
  • Аналитика доставки

Pusher Beams

Сервис от Pusher:

  • Бесплатный тариф до 1,000 устройств
  • Поддержка всех платформ
  • REST API для отправки

Собственная реализация

Для полного контроля можно реализовать push-уведомления самостоятельно:

package push

import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/json"
"fmt"
"net/http"

"github.com/SherClockHolmes/webpush-go"
)

type PushService struct {
privateKey *ecdsa.PrivateKey
publicKey string
vapidPublicKey string
vapidPrivateKey string
}

func NewPushService() (*PushService, error) {
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, fmt.Errorf("failed to generate key: %w", err)
}

publicKey := privateKey.PublicKey

// Генерируем VAPID ключи
vapidPrivateKey, vapidPublicKey, err := webpush.GenerateVAPIDKeys()
if err != nil {
return nil, fmt.Errorf("failed to generate VAPID keys: %w", err)
}

return &PushService{
privateKey: privateKey,
publicKey: marshalPublicKey(&publicKey),
vapidPublicKey: vapidPublicKey,
vapidPrivateKey: vapidPrivateKey,
}, nil
}

type NotificationPayload struct {
Title string `json:"title"`
Body string `json:"body"`
Icon string `json:"icon,omitempty"`
Badge string `json:"badge,omitempty"`
Data map[string]string `json:"data,omitempty"`
Actions []Action `json:"actions,omitempty"`
}

type Action struct {
Action string `json:"action"`
Title string `json:"title"`
Icon string `json:"icon,omitempty"`
}

type Subscription struct {
ID int64 `json:"id" db:"id"`
UserID int64 `json:"user_id" db:"user_id"`
Endpoint string `json:"endpoint" db:"endpoint"`
P256dh string `json:"p256dh" db:"p256dh"`
Auth string `json:"auth" db:"auth"`
CreatedAt string `json:"created_at" db:"created_at"`
}

func (s *PushService) SendNotification(subscription *Subscription, payload *NotificationPayload) error {
sub := webpush.Subscription{
Endpoint: subscription.Endpoint,
Keys: webpush.Keys{
P256dh: subscription.P256dh,
Auth: subscription.Auth,
},
}

data, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal payload: %w", err)
}

resp, err := webpush.SendNotification(data, &sub, &webpush.Options{
VAPIDPublicKey: s.vapidPublicKey,
VAPIDPrivateKey: s.vapidPrivateKey,
TTL: 30,
Topic: "new-updates",
Urgency: webpush.UrgencyHigh,
})

if err != nil {
return fmt.Errorf("failed to send notification: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode == 410 || resp.StatusCode == 404 {
// Подписка больше не действительна, удаляем из базы
return s.removeSubscription(subscription.ID)
}

if resp.StatusCode != 201 {
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}

return nil
}

func (s *PushService) SendBulkNotifications(subscriptions []*Subscription, payload *NotificationPayload) {
for _, sub := range subscriptions {
if err := s.SendNotification(sub, payload); err != nil {
log.Printf("Failed to send notification to subscription %d: %v", sub.ID, err)
}
}
}

func (s *PushService) removeSubscription(id int64) error {
// Удаляем подписку из базы данных
_, err := db.Exec("DELETE FROM push_subscriptions WHERE id = $1", id)
return err
}

Особенности Safari на iOS

Safari имеет особенности в работе с push-уведомлениями:

  • Требуется установка PWA на домашний экран
  • Push API добавлен в Safari 16.1+ (2022)
  • Ограниченная поддержка Background Sync
  • Ограничения на работу Service Worker в фоне

Для iOS рекомендуется использовать Firebase Cloud Messaging, так как он лучше интегрирован с Safari.

Сравнение сервисов

СервисБесплатный тарифПоддержка iOSАналитикаСложность интеграции
FCMДаОтличнаяБазоваяНизкая
OneSignalДо 10KОтличнаяРасширеннаяНизкая
Pusher BeamsДо 1KХорошаяБазоваяНизкая
СобственнаяБесплатноЗависит от реализацииНетВысокая

Важно помнить

При выборе сервиса для push-уведомлений учитывайте:

  • Количество подписчиков
  • Требуемую аналитику
  • Бюджет
  • Технические ограничения целевых платформ
  • Необходимость сегментации и A/B тестирования

Для большинства проектов Firebase Cloud Messaging является оптимальным выбором благодаря бесплатному тарифу и широкой поддержке платформ.

Вопрос 7. Что такое Service Worker, что он делает и зачем нужен?

Таймкод: 00:10:56

Ответ собеседника: Правильный. Service Worker — это механизм для выполнения действий в отдельном потоке: кэширование, обработка запросов, сохранение в IndexedDB, push-уведомления.

Правильный ответ:

Ответ собеседника корректен и охватывает основные функции Service Worker. Для полноты картины стоит расширить описание архитектуры и возможностей.

Что такое Service Worker

Service Worker — это JavaScript-скрипт, который работает в отдельном потоке (worker context) и выступает в роли прокси между веб-приложением и сетью. Он не имеет доступа к DOM, но может перехватывать и модифицировать сетевые запросы.

Ключевые характеристики

  • Работает в отдельном потоке, не блокирует основной
  • Не имеет доступа к DOM и window
  • Использует события для взаимодействия
  • Работает только через HTTPS (кроме localhost)
  • Жизненный цикл отделён от страницы

Жизненный цикл Service Worker

1. Регистрация (Registration)

if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('SW registered:', registration.scope);
})
.catch(error => {
console.log('SW registration failed:', error);
});
}

2. Установка (Install)

self.addEventListener('install', event => {
event.waitUntil(
caches.open('v1').then(cache => {
return cache.addAll([
'/',
'/styles/main.css',
'/scripts/app.js',
'/images/logo.png'
]);
})
);
});

3. Активация (Activate)

self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.filter(name => name !== 'v1')
.map(name => caches.delete(name))
);
})
);
});

4. Перехват запросов (Fetch)

self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => response || fetch(event.request))
);
});

Основные возможности Service Worker

Кэширование ресурсов

Service Worker позволяет кэшировать статические и динамические ресурсы для работы офлайн:

const CACHE_NAME = 'app-v1';
const urlsToCache = [
'/',
'/styles/main.css',
'/scripts/app.js',
'/images/logo.png'
];

self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(urlsToCache))
);
});

Стратегии кэширования

Cache First — для статики:

self.addEventListener('fetch', event => {
if (event.request.destination === 'style' ||
event.request.destination === 'script') {
event.respondWith(
caches.match(event.request)
.then(cached => cached || fetch(event.request))
);
}
});

Network First — для API:

self.addEventListener('fetch', event => {
if (event.request.url.includes('/api/')) {
event.respondWith(
fetch(event.request)
.then(response => {
const clone = response.clone();
caches.open('api-cache')
.then(cache => cache.put(event.request, clone));
return response;
})
.catch(() => caches.match(event.request))
);
}
});

Push-уведомления

self.addEventListener('push', event => {
const data = event.data.json();
const options = {
body: data.body,
icon: '/images/icon.png',
badge: '/images/badge.png',
data: { url: data.url }
};

event.waitUntil(
self.registration.showNotification(data.title, options)
);
});

self.addEventListener('notificationclick', event => {
event.notification.close();
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
});

Background Sync

Отложенная синхронизация при восстановлении соединения:

// В основном скрипте
async function syncData() {
const registration = await navigator.serviceWorker.ready;
await registration.sync.register('sync-data');
}

// В Service Worker
self.addEventListener('sync', event => {
if (event.tag === 'sync-data') {
event.waitUntil(syncWithServer());
}
});

async function syncWithServer() {
const db = await openDB();
const pendingData = await db.getAll('pending');

for (const item of pendingData) {
try {
await fetch('/api/sync', {
method: 'POST',
body: JSON.stringify(item)
});
await db.delete('pending', item.id);
} catch (error) {
console.error('Sync failed:', error);
}
}
}

Periodic Background Sync

Периодическая синхронизация в фоне:

// Регистрация
const registration = await navigator.serviceWorker.ready;
await registration.periodicSync.register('update-content', {
minInterval: 24 * 60 * 60 * 1000 // 24 часа
});

// В Service Worker
self.addEventListener('periodicsync', event => {
if (event.tag === 'update-content') {
event.waitUntil(updateContent());
}
});

Ограничения Service Worker

  • Нет доступа к DOM
  • Нет доступа к localStorage (используйте IndexedDB)
  • Ограничения на размер кэша (зависит от браузера)
  • Safari имеет ограниченную поддержку
  • Требует HTTPS

Пример полной реализации Service Worker

const CACHE_NAME = 'app-v1';
const DYNAMIC_CACHE = 'dynamic-v1';

self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => {
return cache.addAll([
'/',
'/styles/main.css',
'/scripts/app.js'
]);
})
);
});

self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(keys => {
return Promise.all(
keys.filter(key => key !== CACHE_NAME && key !== DYNAMIC_CACHE)
.map(key => caches.delete(key))
);
})
);
});

self.addEventListener('fetch', event => {
// API запросы — Network First
if (event.request.url.includes('/api/')) {
event.respondWith(networkFirst(event.request));
return;
}

// Статика — Cache First
if (event.request.destination === 'style' ||
event.request.destination === 'script' ||
event.request.destination === 'image') {
event.respondWith(cacheFirst(event.request));
return;
}

// Остальное — Stale While Revalidate
event.respondWith(staleWhileRevalidate(event.request));
});

async function cacheFirst(request) {
const cached = await caches.match(request);
return cached || fetch(request);
}

async function networkFirst(request) {
try {
const response = await fetch(request);
const cache = await caches.open(DYNAMIC_CACHE);
cache.put(request, response.clone());
return response;
} catch (error) {
return caches.match(request);
}
}

async function staleWhileRevalidate(request) {
const cached = await caches.match(request);
const fetchPromise = fetch(request).then(response => {
caches.open(DYNAMIC_CACHE).then(cache => {
cache.put(request, response.clone());
});
return response;
});

return cached || fetchPromise;
}

Важно помнить

Service Worker — мощный инструмент для создания офлайн-способных приложений, но требует тщательного проектирования стратегий кэширования и тестирования на всех целевых платформах.

Вопрос 8. В чём главное отличие Service Worker от Web Worker?

Таймкод: 00:11:47

Ответ собеседния: Неполный. Web Worker разгружает основной поток, Service Worker — для кэширования и push-уведомлений. Не упомянул, что Service Worker продолжает работать после закрытия браузера.

Правильный ответ:

Ответ собеседника частично корректен, но требует более детального сравнения этих двух технологий.

Web Worker

Web Worker — это механизм для выполнения JavaScript-кода в отдельном потоке, параллельно основному потоку страницы. Основное назначение — выполнение вычислительно тяжёлых задач без блокировки UI.

Характеристики Web Worker:

  • Работает только пока открыта страница
  • Имеет ограниченный API (нет доступа к DOM, window, document)
  • Общается с основным потоком через postMessage
  • Идеален для тяжёлых вычислений, обработки данных, парсинга

Пример использования Web Worker:

// main.js
const worker = new Worker('worker.js');

worker.postMessage({ data: largeArray });

worker.onmessage = function(event) {
console.log('Result:', event.data);
};

// worker.js
self.onmessage = function(event) {
const result = heavyComputation(event.data);
self.postMessage(result);
};

function heavyComputation(data) {
// Тяжёлые вычисления
return data.map(item => item * 2).filter(item => item > 100);
}

Service Worker

Service Worker — это прокси-сервер, работающий в браузере, который перехватывает сетевые запросы и управляет кэшированием. Основное назначение — офлайн-работа, кэширование, push-уведомления.

Характеристики Service Worker:

  • Продолжает работать после закрытия страницы
  • Может обрабатывать push-уведомления в фоне
  • Перехватывает и модифицирует сетевые запросы
  • Имеет доступ к Cache API и Push API
  • Работает как прокси между приложением и сетью

Ключевые отличия

Время жизни

Web Worker существует только пока открыта страница, которая его создала. Service Worker продолжает работать после закрытия всех страниц и может быть активирован браузером для обработки событий.

Назначение

Web Worker предназначен для вычислений — выполнения кода параллельно основному потоку. Service Worker предназначен для управления сетевыми запросами и кэшированием.

Сетевой доступ

Web Worker не имеет специальных возможностей для работы с сетью. Service Worker может перехватывать fetch-запросы, кэшировать ответы и работать офлайн.

Фоновая работа

Web Worker не может работать в фоне. Service Worker может получать события push и sync даже когда приложение закрыто.

Доступ к API

Web Worker имеет доступ к ограниченному набору API (нет DOM, window). Service Worker имеет доступ к Cache API, Push API, Background Sync API, но также не имеет доступа к DOM.

Сравнительная таблица

ХарактеристикаWeb WorkerService Worker
Время жизниПока открыта страницаПосле закрытия страниц
НазначениеВычисленияКэширование, сеть
Доступ к DOMНетНет
Перехват запросовНетДа
Push-уведомленияНетДа
Фоновая работаНетДа
Cache APIНетДа
Область видимостиСтраницаВесь origin

Примеры использования Web Worker

Обработка изображений:

// main.js
const worker = new Worker('image-worker.js');

worker.postMessage({
imageData: canvas.getContext('2d').getImageData(0, 0, width, height)
});

worker.onmessage = function(event) {
const processedImageData = event.data;
canvas.getContext('2d').putImageData(processedImageData, 0, 0);
};

// image-worker.js
self.onmessage = function(event) {
const imageData = event.data.imageData;
const data = imageData.data;

// Применяем фильтр (например, чёрно-белый)
for (let i = 0; i < data.length; i += 4) {
const avg = (data[i] + data[i + 1] + data[i + 2]) / 3;
data[i] = avg;
data[i + 1] = avg;
data[i + 2] = avg;
}

self.postMessage(imageData);
};

Парсинг больших JSON:

// main.js
const worker = new Worker('parser-worker.js');

fetch('/api/large-data')
.then(response => response.text())
.then(text => {
worker.postMessage({ jsonString: text });
});

worker.onmessage = function(event) {
const parsedData = event.data;
renderUI(parsedData);
};

// parser-worker.js
self.onmessage = function(event) {
const parsed = JSON.parse(event.data.jsonString);
const processed = processData(parsed);
self.postMessage(processed);
};

Примеры использования Service Worker

Кэширование для офлайн-работы:

self.addEventListener('install', event => {
event.waitUntil(
caches.open('offline-v1').then(cache => {
return cache.addAll([
'/',
'/styles/main.css',
'/scripts/app.js',
'/offline.html'
]);
})
);
});

self.addEventListener('fetch', event => {
event.respondWith(
fetch(event.request).catch(() => {
return caches.match(event.request)
.then(response => response || caches.match('/offline.html'));
})
);
});

Push-уведомления:

self.addEventListener('push', event => {
const data = event.data.json();

event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/images/icon.png',
data: { url: data.url }
})
);
});

self.addEventListener('notificationclick', event => {
event.notification.close();

event.waitUntil(
clients.matchAll({ type: 'window' }).then(windowClients => {
for (const client of windowClients) {
if (client.url === event.notification.data.url) {
return client.focus();
}
}
return clients.openWindow(event.notification.data.url);
})
);
});

Когда использовать Web Worker

  • Тяжёлые вычисления (математика, криптография)
  • Обработка больших объёмов данных
  • Парсинг сложных форматов
  • Любые операции, которые могут заблокировать UI

Когда использовать Service Worker

  • Офлайн-работа приложения
  • Кэширование ресурсов
  • Push-уведомления
  • Background sync
  • Оптимизация загрузки

Важно помнить

Web Worker и Service Worker решают разные задачи и не являются взаимозаменяемыми. Web Worker — для вычислений, Service Worker — для управления сетью и кэшированием. В сложных приложениях они могут использоваться вместе: Service Worker управляет кэшированием, а Web Worker выполняет тяжёлые вычисления в основном потоке.

Вопрос 9. Что такое полифиллы и приходилось ли с ними работать?

Таймкод: 00:12:56

Ответ собеседника: Правильный. Полифиллы реализуют новые функции JavaScript в устаревших браузерах. Кандидат обеспечивал кроссбраузерность через проверки наличия методов и свойств, проверяя совместимость на Can I Use и MDN.

Правильный ответ:

Ответ собеседника корректен и демонстрирует понимание концепции полифиллов. Для полноты картины стоит расширить описание и рассмотреть практические примеры.

Что такое полифиллы

Полифилл (polyfill) — это фрагмент кода, который реализует функциональность, отсутствующую в текущей среде выполнения. Термин происходит от штукатурки Polyfilla, которая заполняет трещины — аналогично полифиллы «заполняют» пробелы в поддержке функций браузерами.

Отличие от других понятий

  • Полифилл — реализует отсутствующую функциональность в среде выполнения
  • Шим (shim) — перехватывает существующий API и модифицирует его поведение
  • Транспайлер — преобразует синтаксис нового стандарта в синтаксис старого (например, Babel)

Типы полифиллов

Полифиллы для методов объектов

Реализация отсутствующих методов встроенных объектов:

// Полифилл для Array.prototype.includes
if (!Array.prototype.includes) {
Array.prototype.includes = function(searchElement, fromIndex) {
if (this == null) {
throw new TypeError('"this" is null or not defined');
}

var o = Object(this);
var len = o.length >>> 0;

if (len === 0) {
return false;
}

var n = fromIndex | 0;
var k = Math.max(n >= 0 ? n : len - Math.abs(n), 0);

while (k < len) {
if (o[k] === searchElement) {
return true;
}
k++;
}

return false;
};
}

// Полифилл для Object.assign
if (typeof Object.assign !== 'function') {
Object.assign = function(target) {
if (target == null) {
throw new TypeError('Cannot convert undefined or null to object');
}

var to = Object(target);

for (var index = 1; index < arguments.length; index++) {
var nextSource = arguments[index];

if (nextSource != null) {
for (var nextKey in nextSource) {
if (Object.prototype.hasOwnProperty.call(nextSource, nextKey)) {
to[nextKey] = nextSource[nextKey];
}
}
}
}

return to;
};
}

Полифиллы для новых объектов

Реализация полностью новых объектов и классов:

// Полифилл для Promise
(function(global) {
function Promise(executor) {
var self = this;
self.status = 'pending';
self.value = undefined;
self.callbacks = [];

function resolve(value) {
if (self.status !== 'pending') return;
self.status = 'resolved';
self.value = value;
self.callbacks.forEach(function(callback) {
callback.onResolved(value);
});
}

function reject(reason) {
if (self.status !== 'pending') return;
self.status = 'rejected';
self.reason = reason;
self.callbacks.forEach(function(callback) {
callback.onRejected(reason);
});
}

try {
executor(resolve, reject);
} catch (error) {
reject(error);
}
}

Promise.prototype.then = function(onResolved, onRejected) {
var self = this;
onResolved = typeof onResolved === 'function' ? onResolved : function(v) { return v; };
onRejected = typeof onRejected === 'function' ? onRejected : function(e) { throw e; };

return new Promise(function(resolve, reject) {
function handle(callback) {
try {
var result = callback(self.value);
if (result instanceof Promise) {
result.then(resolve, reject);
} else {
resolve(result);
}
} catch (error) {
reject(error);
}
}

if (self.status === 'resolved') {
setTimeout(function() { handle(onResolved); }, 0);
} else if (self.status === 'rejected') {
setTimeout(function() { handle(onRejected); }, 0);
} else {
self.callbacks.push({
onResolved: function() { handle(onResolved); },
onRejected: function() { handle(onRejected); }
});
}
});
};

global.Promise = Promise;
})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : global);

Полифиллы для Web API

Реализация отсутствующих браузерных API:

// Полифилл для fetch
if (!window.fetch) {
window.fetch = function(url, options) {
return new Promise(function(resolve, reject) {
var xhr = new XMLHttpRequest();
xhr.open(options.method || 'GET', url);

if (options.headers) {
Object.keys(options.headers).forEach(function(key) {
xhr.setRequestHeader(key, options.headers[key]);
});
}

xhr.onload = function() {
resolve({
ok: xhr.status >= 200 && xhr.status < 300,
status: xhr.status,
statusText: xhr.statusText,
json: function() { return Promise.resolve(JSON.parse(xhr.responseText)); },
text: function() { return Promise.resolve(xhr.responseText); }
});
};

xhr.onerror = function() {
reject(new TypeError('Network request failed'));
};

xhr.send(options.body || null);
});
};
}

Популярные библиотеки полифиллов

core-js

Наиболее полная коллекция полифиллов для ECMAScript:

// Импорт всех полифиллов для ES2015+
import 'core-js/stable';

// Импорт конкретных полифиллов
import 'core-js/stable/array/includes';
import 'core-js/stable/promise';
import 'core-js/stable/object/assign';

polyfill.io

Сервис, который автоматически отдаёт полифиллы в зависимости от браузера пользователя:

<script src="https://polyfill.io/v3/polyfill.min.js?features=es2015,es2016,es2017,fetch,Promise"></script>

@babel/preset-env

Автоматически включает необходимые полифиллы на основе целевых браузеров:

{
"presets": [
["@babel/preset-env", {
"useBuiltIns": "usage",
"corejs": 3,
"targets": "> 0.25%, not dead"
}]
]
}

Feature Detection vs Polyfills

Как упомянул собеседник, иногда достаточно проверки наличия функциональности:

// Feature detection
if ('IntersectionObserver' in window) {
// Используем нативный API
const observer = new IntersectionObserver(callback);
} else {
// Fallback для старых браузеров
window.addEventListener('scroll', throttle(checkVisibility, 100));
}

// Или через проверку метода
if (Array.prototype.includes) {
arr.includes(item);
} else {
arr.indexOf(item) !== -1;
}

Когда использовать полифиллы

  • Необходимость поддержки старых браузеров
  • Использование новых возможностей языка
  • Критическая функциональность, которая должна работать везде

Когда не использовать полифиллы

  • Целевая аудитория использует только современные браузеры
  • Функциональность не критична и может быть отключена
  • Полифилл слишком большой и замедляет загрузку

Инструменты для проверки совместимости

  • Can I Use (caniuse.com) — таблица поддержки функций браузерами
  • MDN Web Docs — документация с информацией о совместимости
  • Browserslist — конфигурация целевых браузеров
  • eslint-plugin-compat — проверка совместимости в коде

Пример конфигурации Browserslist

# .browserslistrc
> 0.5%
last 2 versions
Firefox ESR
not dead
not IE 11

Важно помнить

Полифиллы увеличивают размер бандла и могут влиять на производительность. Используйте их осмотрительно, основываясь на реальных данных о браузерах ваших пользователей. Современный подход — использовать условную загрузку полифиллов только для тех браузеров, которые их действительно нуждаются.

Вопрос 10. Были ли на проекте требования к поддержке определённых браузеров или версий? С какими проблемами кроссбраузерности сталкивался?

Таймкод: 00:14:26

Ответ собеседника: Правильный. Конкретного списка версий не было, тестировщики проверяли в разных браузерах. Сталкивался с проблемой, когда on property не работал в одной версии браузера, пришлось дорабатывать. Использовал проверки на наличие свойств и альтернативные подходы.

Правильный ответ:

Ответ собеседника демонстрирует практический опыт решения проблем кроссбраузерности. Для полноты картины стоит рассмотреть типичные проблемы и системный подход к их решению.

Типичные проблемы кроссбраузерности

1. Различия в CSS

Flexbox и Grid имели разную поддержку в старых браузерах:

/* Проблема: старые версии Safari требуют префиксы */
.container {
display: -webkit-flex; /* Safari 6.1+ */
display: flex;

-webkit-flex-direction: column;
flex-direction: column;
}

/* Решение с помощью Autoprefixer */
/* В конфигурации postcss */
module.exports = {
plugins: [
require('autoprefixer')({
overrideBrowserslist: ['> 1%', 'last 2 versions']
})
]
}

2. Различия в JavaScript API

// Проблема: IntersectionObserver не поддерживается в Safari до 12.1
if ('IntersectionObserver' in window) {
const observer = new IntersectionObserver(callback, options);
observer.observe(element);
} else {
// Fallback для старых браузеров
const checkVisibility = () => {
const rect = element.getBoundingClientRect();
const isVisible = rect.top < window.innerHeight && rect.bottom > 0;
if (isVisible) {
callback();
}
};

window.addEventListener('scroll', throttle(checkVisibility, 100));
checkVisibility();
}

3. Различия в обработке событий

// Проблема: событие input ведёт себя по-разному в IE
element.addEventListener('input', handler);

// Для IE9-11 может потребоваться fallback на propertychange
if ('oninput' in element) {
element.addEventListener('input', handler);
} else {
element.attachEvent('onpropertychange', function(e) {
if (e.propertyName === 'value') {
handler();
}
});
}

4. Различия в работе с датами

// Проблема: формат дат парсится по-разному
const date = new Date('2023-01-15'); // Работает в современных браузерах

// Более надёжный вариант
const parseDate = (dateString) => {
const parts = dateString.split('-');
return new Date(parts[0], parts[1] - 1, parts[2]);
};

Системный подход к кроссбраузерности

Определение целевых браузеров

Создание .browserslistrc файла:

# Поддержка браузеров с долей > 0.5%
> 0.5%

# Последние 2 версии каждого браузера
last 2 versions

# Firefox ESR
Firefox ESR

# Не поддерживаем мёртвые браузеры
not dead

# Не поддерживаем IE
not IE 11

Автоматическое тестирование

Использование BrowserStack или Sauce Labs для автоматического тестирования:

// Пример конфигурации для Playwright
const { test, expect } = require('@playwright/test');

test.describe('Cross-browser tests', () => {
test('should work in all browsers', async ({ page }) => {
await page.goto('/');
await expect(page.locator('h1')).toBeVisible();
});
});

Feature Detection вместо Browser Detection

// Плохо: определение по user agent
if (navigator.userAgent.includes('MSIE')) {
// IE-специфичный код
}

// Хорошо: проверка наличия функциональности
if ('geolocation' in navigator) {
navigator.geolocation.getCurrentPosition(success, error);
} else {
// Fallback
}

Использование инструментов

Babel — транспиляция современного JavaScript:

{
"presets": [
["@babel/preset-env", {
"useBuiltIns": "usage",
"corejs": 3
}]
]
}

PostCSS с Autoprefixer — автоматическое добавление префиксов:

// postcss.config.js
module.exports = {
plugins: [
require('autoprefixer')
]
}

eslint-plugin-compat — проверка совместимости в коде:

{
"plugins": ["compat"],
"rules": {
"compat/compat": "error"
},
"settings": {
"browserslist": ["> 0.5%", "last 2 versions"]
}
}

Типичные проблемы и решения

Проблема с CSS Grid в IE11

/* Современный код */
.container {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}

/* Fallback для IE11 */
.container {
display: -ms-grid;
-ms-grid-columns: 1fr 20px 1fr 20px 1fr;
}

.container > *:nth-child(1) {
-ms-grid-column: 1;
}

.container > *:nth-child(2) {
-ms-grid-column: 3;
}

.container > *:nth-child(3) {
-ms-grid-column: 5;
}

Проблема с CSS Custom Properties

/* Современный код */
:root {
--primary-color: #007bff;
}

.button {
background-color: var(--primary-color);
}

/* Fallback для старых браузеров */
.button {
background-color: #007bff; /* Fallback */
background-color: var(--primary-color);
}

Проблема с async/await в старых браузерах

// Современный код
async function fetchData() {
const response = await fetch('/api/data');
return response.json();
}

// Транспилированный код (Babel)
function fetchData() {
return regeneratorRuntime.async(function fetchData$(_context) {
while (1) {
switch (_context.prev = _context.next) {
case 0:
_context.next = 2;
return regeneratorRuntime.awrap(fetch('/api/data'));
case 2:
response = _context.sent;
_context.next = 5;
return regeneratorRuntime.awrap(response.json());
case 5:
return _context.abrupt('return', _context.sent);
case 6:
case 'end':
return _context.stop();
}
}
});
}

Мониторинг реальных пользователей

// Сбор информации о браузерах пользователей
const browserInfo = {
userAgent: navigator.userAgent,
language: navigator.language,
platform: navigator.platform,
cookiesEnabled: navigator.cookieEnabled,
screenWidth: screen.width,
screenHeight: screen.height,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight
};

// Отправка на сервер для аналитики
fetch('/api/analytics/browser', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(browserInfo)
});

Важно помнить

Кроссбраузерность — это не только о поддержке старых браузеров, но и о корректной работе в современных браузерах с разными движками (Blink, Gecko, WebKit). Используйте feature detection, автоматические тесты и мониторинг реальных пользователей для обеспечения совместимости.

Вопрос 11. Были ли проблемы с тем, что библиотеки не позволяли поддерживать браузеры ниже определённой версии? Как решались такие проблемы?

Таймкод: 00:16:13

Ответ собеседника: Неполный. Не смог вспомнить конкретных случаев с библиотеками, помнит только ошибки с Яндекс-картами. Не упомянул стратегию с двумя бандлами для legacy и современных браузеров.

Правильный ответ:

Проблемы совместимости библиотек с браузерами — распространённая ситуация в разработке. Рассмотрим типичные проблемы и способы их решения.

Типичные проблемы с библиотеками

1. Использование современных API

Многие современные библиотеки используют API, недоступные в старых браузерах:

// Библиотека использует IntersectionObserver
class LazyImage extends HTMLElement {
connectedCallback() {
this.observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.loadImage();
}
});
});
this.observer.observe(this);
}
}

// Проблема: IntersectionObserver не поддерживается в Safari до 12.1

2. Использование современного синтаксиса

Библиотеки могут распространяться в ES2015+ формате:

// Исходный код библиотеки
export class MyComponent {
#privateField = 42;

async render() {
const data = await fetch('/api/data');
return data.json();
}
}

// Проблема: приватные поля и async/await не работают в старых браузерах

3. Зависимость от полифиллов

Некоторые библиотеки требуют наличия определённых полифиллов:

// Библиотека ожидает, что Promise существует
import { MyLibrary } from 'my-library';

// Проблема: в IE11 нет нативного Promise

Стратегии решения

1. Два бандла (Legacy и Modern)

Наиболее эффективный подход — создание двух версий бандла:

// webpack.config.js
module.exports = [
// Современный бандл
{
name: 'modern',
entry: './src/index.js',
output: {
filename: 'bundle.[contenthash].js',
path: path.resolve(__dirname, 'dist')
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: [
['@babel/preset-env', {
targets: {
esmodules: true
},
useBuiltIns: 'usage',
corejs: 3
}]
]
}
}
}
]
}
},
// Legacy бандл
{
name: 'legacy',
entry: './src/index.js',
output: {
filename: 'bundle.legacy.[contenthash].js',
path: path.resolve(__dirname, 'dist')
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: [
['@babel/preset-env', {
targets: {
ie: 11
},
useBuiltIns: 'usage',
corejs: 3
}]
]
}
}
}
]
}
}
];

2. Условная загрузка бандлов

<!-- Современные браузеры загружают современный бандл -->
<script type="module" src="/dist/bundle.modern.js"></script>

<!-- Старые браузеры загружают legacy бандл -->
<script nomodule src="/dist/bundle.legacy.js"></script>

3. Использование @babel/preset-env с настройками

{
"presets": [
["@babel/preset-env", {
"targets": {
"browsers": ["> 0.5%", "last 2 versions", "not dead"]
},
"useBuiltIns": "usage",
"corejs": 3,
"modules": false
}]
]
}

4. Выбор совместимых библиотек

При выборе библиотек проверяйте их совместимость:

// package.json
{
"dependencies": {
"core-js": "^3.30.0",
"regenerator-runtime": "^0.13.11"
},
"browserslist": [
"> 0.5%",
"last 2 versions",
"Firefox ESR",
"not dead",
"not IE 11"
]
}

5. Ручная полифилляция библиотек

Если библиотека не совместима, можно добавить полифиллы вручную:

// polyfills.js — загружается первым
import 'core-js/stable';
import 'regenerator-runtime/runtime';

// Теперь можно использовать любые библиотеки
import { MyLibrary } from 'my-library';

6. Использование CDN с условной загрузкой

<script>
// Проверяем поддержку ES модулей
var supportsESModules = 'noModule' in HTMLScriptElement.prototype;

if (supportsESModules) {
document.write('<script type="module" src="/dist/modern.js"><\/script>');
} else {
document.write('<script src="/dist/legacy.js"><\/script>');
}
</script>

Пример конфигурации для двух бандлов

// webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

const modernConfig = {
name: 'modern',
entry: './src/index.js',
output: {
filename: '[name].[contenthash].modern.js',
path: path.resolve(__dirname, 'dist')
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: [
['@babel/preset-env', {
targets: { esmodules: true },
useBuiltIns: 'usage',
corejs: 3
}]
]
}
}
}
]
},
plugins: [
new HtmlWebpackPlugin({
template: './src/index.html',
filename: 'index.html',
excludeChunks: ['legacy']
})
]
};

const legacyConfig = {
name: 'legacy',
entry: './src/index.js',
output: {
filename: '[name].[contenthash].legacy.js',
path: path.resolve(__dirname, 'dist')
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: [
['@babel/preset-env', {
targets: { ie: 11 },
useBuiltIns: 'usage',
corejs: 3
}]
]
}
}
}
]
},
plugins: [
new HtmlWebpackPlugin({
template: './src/index.html',
filename: 'index.legacy.html',
excludeChunks: ['modern']
})
]
};

module.exports = [modernConfig, legacyConfig];

Мониторинг и аналитика

// Сбор информации о браузерах пользователей
function collectBrowserInfo() {
return {
userAgent: navigator.userAgent,
language: navigator.language,
platform: navigator.platform,
cookiesEnabled: navigator.cookieEnabled,
screenWidth: screen.width,
screenHeight: screen.height,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
connection: navigator.connection ? {
effectiveType: navigator.connection.effectiveType,
downlink: navigator.connection.downlink,
rtt: navigator.connection.rtt
} : null
};
}

// Отправка на сервер
fetch('/api/analytics/browser', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(collectBrowserInfo())
});

Важно помнить

Стратегия с двумя бандлами — наиболее эффективный подход для поддержки legacy браузеров без ущерба для современных. Современные браузеры получают компактный бандл без полифиллов, а старые — расширенный бандл с необходимыми полифиллами. Это позволяет оптимизировать размер бандла для большинства пользователей, сохраняя совместимость.

Вопрос 12. Что знаешь о веб-безопасности? Какие меры безопасности применялись на проектах?

Таймкод: 00:18:04

Ответ собеседника: Правильный. Упомянул защиту от XSS через избегание innerHTML/eval, санитизацию через DOMPurify, использование флагов Secure и HttpOnly для куки, CORS, Content Security Policy и CSRF-токены.

Правильный ответ:

Ответ собеседника демонстрирует хорошее понимание основ веб-безопасности. Для полноты картины стоит расширить описание и рассмотреть дополнительные аспекты.

Основные угрозы веб-безопасности

XSS (Cross-Site Scripting)

Инъекция вредоносного кода в страницу. Типы:

  • Stored XSS — вредоносный код сохраняется в базе данных
  • Reflected XSS — вредоносный код отражается от сервера
  • DOM-based XSS — вредоносный код выполняется при манипуляции DOM

Защита:

// Плохо: уязвимо к XSS
element.innerHTML = userInput;

// Хорошо: безопасно
element.textContent = userInput;

// Если нужен HTML — санитизация
import DOMPurify from 'dompurify';
element.innerHTML = DOMPurify.sanitize(userInput);

// React автоматически экранирует
function Component({ userInput }) {
return <div>{userInput}</div>; // Безопасно
}

CSRF (Cross-Site Request Forgery)

Подделка запроса от имени пользователя:

// Генерация CSRF-токена на сервере
func generateCSRFToken() string {
token := make([]byte, 32)
rand.Read(token)
return base64.StdEncoding.EncodeToString(token)
}

// Проверка CSRF-токена
func validateCSRFToken(r *http.Request) bool {
cookieToken := getCookie(r, "csrf_token")
formToken := r.FormValue("csrf_token")
headerToken := r.Header.Get("X-CSRF-Token")

return subtle.ConstantTimeCompare([]byte(cookieToken), []byte(formToken)) == 1 ||
subtle.ConstantTimeCompare([]byte(cookieToken), []byte(headerToken)) == 1
}

SQL Injection

Инъекция SQL-кода через пользовательский ввод:

// Плохо: уязвимо к SQL-инъекциям
query := fmt.Sprintf("SELECT * FROM users WHERE id = %s", userID)

// Хорошо: параметризованные запросы
query := "SELECT * FROM users WHERE id = $1"
row := db.QueryRow(query, userID)

Content Security Policy (CSP)

Определение разрешённых источников контента:

// Установка заголовка CSP
func setCSPHeaders(w http.ResponseWriter) {
csp := "default-src 'self'; " +
"script-src 'self' 'nonce-{random}' https://trusted-cdn.com; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: https:; " +
"font-src 'self' https://fonts.googleapis.com; " +
"connect-src 'self' https://api.example.com; " +
"frame-ancestors 'none'; " +
"base-uri 'self'; " +
"form-action 'self'"

w.Header().Set("Content-Security-Policy", csp)
}

CORS (Cross-Origin Resource Sharing)

Контроль доступа к ресурсам с других доменов:

// Настройка CORS
func setupCORS() *cors.Cors {
return cors.New(cors.Options{
AllowedOrigins: []string{"https://example.com", "https://app.example.com"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Authorization", "Content-Type", "X-CSRF-Token"},
ExposedHeaders: []string{"X-Request-ID"},
AllowCredentials: true,
MaxAge: 86400,
})
}

// Или вручную
func setCORSHeaders(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")

if isAllowedOrigin(origin) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-CSRF-Token")
w.Header().Set("Access-Control-Max-Age", "86400")
}
}

Безопасные куки

// Установка безопасных куки
func setSecureCookie(w http.ResponseWriter, name, value string) {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: value,
Path: "/",
Domain: ".example.com",
MaxAge: 86400,
Secure: true, // Только по HTTPS
HttpOnly: true, // Нет доступа из JavaScript
SameSite: http.SameSiteStrictStrict, // Защита от CSRF
})
}

HTTPS и HSTS

// Принудительное использование HTTPS
func forceHTTPS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Forwarded-Proto") == "http" {
target := "https://" + r.Host + r.URL.Path
if len(r.URL.RawQuery) > 0 {
target += "?" + r.URL.RawQuery
}
http.Redirect(w, r, target, http.StatusMovedPermanently)
return
}

// HSTS заголовок
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")

next.ServeHTTP(w, r)
})
}

Rate Limiting

Защита от DDoS и брутфорс-атак:

package ratelimit

import (
"net/http"
"sync"
"time"

"golang.org/x/time/rate"
)

type RateLimiter struct {
visitors map[string]*rate.Limiter
mu sync.RWMutex
rate rate.Limit
burst int
}

func NewRateLimiter(r rate.Limit, b int) *RateLimiter {
return &RateLimiter{
visitors: make(map[string]*rate.Limiter),
rate: r,
burst: b,
}
}

func (rl *RateLimiter) GetLimiter(ip string) *rate.Limiter {
rl.mu.Lock()
defer rl.mu.Unlock()

limiter, exists := rl.visitors[ip]
if !exists {
limiter = rate.NewLimiter(rl.rate, rl.burst)
rl.visitors[ip] = limiter
}

return limiter
}

func (rl *RateLimiter) Limit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := r.RemoteAddr
limiter := rl.GetLimiter(ip)

if !limiter.Allow() {
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
return
}

next.ServeHTTP(w, r)
})
}

// Использование
limiter := NewRateLimiter(10, 20) // 10 запросов в секунду, burst 20
http.Handle("/api/", limiterLimit(apiHandler))

Валидация и санитизация входных данных

package validator

import (
"html"
"regexp"
"strings"
)

type Validator struct {
errors []string
}

func (v *Validator) SanitizeString(input string) string {
// Удаляем HTML-теги
re := regexp.MustCompile(`<[^>]*>`)
cleaned := re.ReplaceAllString(input, "")

// Экранируем специальные символы
cleaned = html.EscapeString(cleaned)

// Удаляем лишние пробелы
cleaned = strings.TrimSpace(cleaned)

return cleaned
}

func (v *Validator) ValidateEmail(email string) bool {
pattern := `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`
matched, _ := regexp.MatchString(pattern, email)
return matched
}

func (v *Validator) ValidatePassword(password string) bool {
// Минимум 8 символов, одна заглавная, одна строчная, одна цифра
if len(password) < 8 {
return false
}

hasUpper := regexp.MustCompile(`[A-Z]`).MatchString(password)
hasLower := regexp.MustCompile(`[a-z]`).MatchString(password)
hasDigit := regexp.MustCompile(`[0-9]`).MatchString(password)

return hasUpper && hasLower && hasDigit
}

Аутентификация и авторизация

package auth

import (
"crypto/rand"
"encoding/base64"
"net/http"
"time"

"github.com/golang-jwt/jwt/v5"
)

type Claims struct {
UserID int64 `json:"user_id"`
Email string `json:"email"`
Role string `json:"role"`
jwt.RegisteredClaims
}

type AuthService struct {
secretKey []byte
issuer string
}

func NewAuthService(secretKey string) *AuthService {
return &AuthService{
secretKey: []byte(secretKey),
issuer: "myapp",
}
}

func (s *AuthService) GenerateToken(userID int64, email, role string) (string, error) {
claims := &Claims{
UserID: userID,
Email: email,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: s.issuer,
},
}

token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(s.secretKey)
}

func (s *AuthService) ValidateToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return s.secretKey, nil
})

if err != nil {
return nil, err
}

if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}

return nil, jwt.ErrSignatureInvalid
}

func (s *AuthService) AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tokenString := r.Header.Get("Authorization")
if tokenString == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}

// Убираем "Bearer " префикс
tokenString = strings.TrimPrefix(tokenString, "Bearer ")

claims, err := s.ValidateToken(tokenString)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}

// Добавляем claims в контекст
ctx := context.WithValue(r.Context(), "claims", claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

Безопасные заголовки

func setSecurityHeaders(w http.ResponseWriter) {
// Защита от XSS
w.Header().Set("X-XSS-Protection", "1; mode=block")

// Предотвращение MIME-sniffing
w.Header().Set("X-Content-Type-Options", "nosniff")

// Защита от clickjacking
w.Header().Set("X-Frame-Options", "DENY")

// Referrer Policy
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")

// Permissions Policy
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
}

Важно помнить

Безопасность — это непрерывный процесс, а не разовое мероприятие. Необходимо регулярно обновлять зависимости, проводить аудит безопасности, следить за новыми уязвимостями и применять принцип наименьших привилегий.

Вопрос 13. В каком контексте приходилось работать с HTML — отправлялся ли он на бэкенд или приходил от бэкенда?

Таймкод: 00:20:56

Ответ собеседника: Правильный. Работал с HTML в проекте конструктора сайтов с WYSIWYG-редактором. Интегрировал санитизацию в библиотеку, HTML-код в виде строки отправлялся на бэкенд.

Правильный ответ:

Ответ собеседника демонстрирует практический опыт работы с HTML в контексте WYSIWYG-редакторов. Рассмотрим подробнее аспекты работы с HTML на стыке фронтенда и бэкенда.

Контексты работы с HTML

1. WYSIWYG-редакторы и конструкторы

Работа с WYSIWYG-редакторами требует особого внимания к безопасности:

// Интеграция DOMPurify с редактором
import DOMPurify from 'dompurify';

class SafeEditor {
constructor(editorElement) {
this.editor = editorElement;
this.config = {
ALLOWED_TAGS: [
'p', 'br', 'strong', 'em', 'u', 's', 'h1', 'h2', 'h3',
'ul', 'ol', 'li', 'a', 'img', 'blockquote', 'code', 'pre',
'table', 'thead', 'tbody', 'tr', 'td', 'th'
],
ALLOWED_ATTR: [
'href', 'src', 'alt', 'title', 'class', 'id', 'target',
'width', 'height', 'style'
],
ALLOW_DATA_ATTR: false,
ADD_ATTR: ['target'],
FORBID_TAGS: ['script', 'style', 'iframe', 'form', 'input'],
FORBID_ATTR: ['onerror', 'onclick', 'onload', 'onmouseover']
};
}

sanitize(html) {
return DOMPurify.sanitize(html, this.config);
}

getCleanHTML() {
const rawHTML = this.editor.innerHTML;
return this.sanitize(rawHTML);
}

async saveToServer() {
const cleanHTML = this.getCleanHTML();

await fetch('/api/content', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCSRFToken()
},
body: JSON.stringify({
content: cleanHTML,
editorId: this.editor.id
})
});
}
}

2. Обработка HTML на бэкенде

Серверная валидация и санитизация:

package html

import (
"bytes"
"html/template"
"regexp"
"strings"

"github.com/microcosm-cc/bluemonday"
)

type HTMLSanitizer struct {
policy *bluemonday.Policy
}

func NewHTMLSanitizer() *HTMLSanitizer {
p := bluemonday.UGCPolicy()

// Разрешаем дополнительные теги и атрибуты
p.AllowElements("h1", "h2", "h3", "h4", "h5", "h6")
p.AllowElements("table", "thead", "tbody", "tr", "td", "th")
p.AllowElements("blockquote", "pre", "code")
p.AllowElements("figure", "figcaption")

p.AllowAttrs("class", "id").Globally()
p.AllowAttrs("href", "target", "rel").OnElements("a")
p.AllowAttrs("src", "alt", "width", "height").OnElements("img")
p.AllowAttrs("style").OnElements("p", "span", "div")

// Принудительно добавляем rel="noopener" для внешних ссылок
p.RequireNoFollowOnLinks(true)
p.RequireNoFollowOnFullyQualifiedLinks(true)
p.AddTargetBlankToFullyQualifiedLinks(true)

return &HTMLSanitizer{policy: p}
}

func (s *HTMLSanitizer) Sanitize(input string) string {
return s.policy.Sanitize(input)
}

func (s *HTMLSanitizer) SanitizeBytes(input []byte) []byte {
return s.policy.SanitizeBytes(input)
}

// Дополнительная валидация
func (s *HTMLSanitizer) Validate(input string) error {
// Проверяем на наличие запрещённых паттернов
forbiddenPatterns := []string{
`<script[^>]*>`,
`javascript:`,
`on\w+\s*=`,
`<iframe`,
`<object`,
`<embed`,
}

for _, pattern := range forbiddenPatterns {
matched, _ := regexp.MatchString(`(?i)`+pattern, input)
if matched {
return fmt.Errorf("forbidden pattern detected: %s", pattern)
}
}

return nil
}

// Использование в обработчике
func (h *ContentHandler) SaveContent(w http.ResponseWriter, r *http.Request) {
var req struct {
Content string `json:"content"`
EditorID string `json:"editorId"`
}

if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}

// Валидация
sanitizer := NewHTMLSanitizer()
if err := sanitizer.Validate(req.Content); err != nil {
http.Error(w, "Invalid HTML content", http.StatusBadRequest)
return
}

// Санитизация
cleanHTML := sanitizer.Sanitize(req.Content)

// Сохранение в базу
err := h.db.SaveContent(r.Context(), req.EditorID, cleanHTML)
if err != nil {
http.Error(w, "Failed to save", http.StatusInternalServerError)
return
}

w.WriteHeader(http.StatusOK)
}

3. Отображение HTML из безопасного источника

// Использование template.HTML для безопасного отображения
type PageData struct {
Title string
Content template.HTML // Помечаем как безопасный HTML
}

func (h *PageHandler) ServePage(w http.ResponseWriter, r *http.Request) {
pageID := r.URL.Query().Get("id")

// Получаем контент из базы
content, err := h.db.GetContent(r.Context(), pageID)
if err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}

data := PageData{
Title: content.Title,
Content: template.HTML(content.HTML), // Безопасно, так как уже санитизирован
}

tmpl := template.Must(template.ParseFiles("templates/page.html"))
tmpl.Execute(w, data)
}
<!-- templates/page.html -->
<!DOCTYPE html>
<html>
<head>
<title>{{.Title}}</title>
</head>
<body>
<main>
<!-- Безопасно отображаем HTML -->
{{.Content}}
</main>
</body>
</html>

4. Работа с HTML в API

type ContentAPI struct {
db *sql.DB
sanitizer *HTMLSanitizer
}

func (api *ContentAPI) CreateContent(w http.ResponseWriter, r *http.Request) {
var req struct {
Title string `json:"title"`
Content string `json:"content"`
}

if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request")
return
}

// Валидация длины
if len(req.Content) > 100000 {
respondError(w, http.StatusBadRequest, "Content too long")
return
}

// Санитизация
cleanContent := api.sanitizer.Sanitize(req.Content)

// Сохранение
id, err := api.db.CreateContent(r.Context(), req.Title, cleanContent)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to save")
return
}

respondJSON(w, http.StatusCreated, map[string]interface{}{
"id": id,
"title": req.Title,
"content": cleanContent,
})
}

func (api *ContentAPI) GetContent(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")

content, err := api.db.GetContent(r.Context(), id)
if err != nil {
respondError(w, http.StatusNotFound, "Not found")
return
}

respondJSON(w, http.StatusOK, map[string]interface{}{
"id": content.ID,
"title": content.Title,
"content": content.HTML, // Уже санитизирован
})
}

5. Хранение HTML в базе данных

-- Создание таблицы для хранения HTML-контента
CREATE TABLE content (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(255) NOT NULL,
html TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
created_by INTEGER REFERENCES users(id),
updated_by INTEGER REFERENCES users(id)
);

-- Индекс для быстрого поиска
CREATE INDEX idx_content_created_at ON content(created_at);
CREATE INDEX idx_content_created_by ON content(created_by);

6. Экспорт и импорт HTML

type HTMLExporter struct {
db *sql.DB
}

func (e *HTMLExporter) ExportToHTML(w http.ResponseWriter, r *http.Request) {
contentID := r.URL.Query().Get("id")

content, err := e.db.GetContent(r.Context(), contentID)
if err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}

// Формируем полную HTML-страницу
fullHTML := fmt.Sprintf(`<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>%s</title>
<style>
body { font-family: Arial, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }
</style>
</head>
<body>
<h1>%s</h1>
%s
</body>
</html>`, template.HTMLEscapeString(content.Title),
template.HTMLEscapeString(content.Title),
content.HTML)

w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.html\"", contentID))
w.Write([]byte(fullHTML))
}

Важно помнить

При работе с HTML на стыке фронтенда и бэкенда необходимо:

  • Санитизировать HTML на клиенте для немедленной обратной связи
  • Санитизировать HTML на сервере для гарантированной безопасности
  • Использовать Content Security Policy для дополнительной защиты
  • Хранить в базе уже санитизированный HTML
  • Использовать параметризованные запросы для предотвращения SQL-инъекций
  • Ограничивать размер входных данных
  • Логировать все операции с HTML-контентом

Вопрос 14. Настраивал ли Content Security Policy на проектах или просто знаешь концепцию?

Таймкод: 00:21:53

Ответ собеседника: Неполный. Знает концепцию CSP как список разрешённых источников контента, но не имеет практического опыта настройки, так как этим занимался бэкенд.

Правильный ответ:

Ответ собеседника демонстрирует базовое понимание CSP, но практический опыт настройки отсутствует. Рассмотрим подробнее, как правильно настраивать CSP.

Что такое Content Security Policy

CSP — это HTTP-заголовок, который определяет, какие ресурсы браузер может загружать для страницы. Это мощный механизм защиты от XSS-атак и других инъекций.

Директивы CSP

Основные директивы:

  • default-src — политика по умолчанию для всех типов ресурсов
  • script-src — разрешённые источники JavaScript
  • style-src — разрешённые источники CSS
  • img-src — разрешённые источники изображений
  • font-src — разрешённые источники шрифтов
  • connect-src — разрешённые источники для AJAX, WebSocket, EventSource
  • frame-src — разрешённые источники для iframe
  • object-src — разрешённые источники для object, embed, applet
  • base-uri — разрешённые значения для base
  • form-action — разрешённые цели для форм

Значения директив:

  • 'self' — только текущий домен
  • 'none' — ничего не разрешено
  • 'unsafe-inline' — разрешить inline-скрипты и стили
  • 'unsafe-eval' — разрешить eval()
  • 'nonce-{random}' — разрешить с определённым nonce
  • 'sha256-{hash}' — разрешить с определённым хешем
  • https: — любой HTTPS-источник
  • *.example.com — поддомены example.com

Настройка CSP на бэкенде в Go

package csp

import (
"crypto/rand"
"encoding/base64"
"fmt"
"net/http"
"strings"
)

type CSPConfig struct {
DefaultSrc []string
ScriptSrc []string
StyleSrc []string
ImgSrc []string
FontSrc []string
ConnectSrc []string
FrameSrc []string
ObjectSrc []string
BaseURI []string
FormAction []string
ReportURI string
ReportOnly bool
}

type CSPMiddleware struct {
config CSPConfig
}

func NewCSPMiddleware(config CSPConfig) *CSPMiddleware {
return &CSPMiddleware{config: config}
}

func (m *CSPMiddleware) GenerateNonce() string {
b := make([]byte, 16)
rand.Read(b)
return base64.StdEncoding.EncodeToString(b)
}

func (m *CSPMiddleware) BuildPolicy(nonce string) string {
var directives []string

if len(m.config.DefaultSrc) > 0 {
directives = append(directives, fmt.Sprintf("default-src %s", strings.Join(m.config.DefaultSrc, " ")))
}

if len(m.config.ScriptSrc) > 0 {
scriptSrc := append(m.config.ScriptSrc, fmt.Sprintf("'nonce-%s'", nonce))
directives = append(directives, fmt.Sprintf("script-src %s", strings.Join(scriptSrc, " ")))
}

if len(m.config.StyleSrc) > 0 {
styleSrc := append(m.config.StyleSrc, fmt.Sprintf("'nonce-%s'", nonce))
directives = append(directives, fmt.Sprintf("style-src %s", strings.Join(styleSrc, " ")))
}

if len(m.config.ImgSrc) > 0 {
directives = append(directives, fmt.Sprintf("img-src %s", strings.Join(m.config.ImgSrc, " ")))
}

if len(m.config.FontSrc) > 0 {
directives = append(directives, fmt.Sprintf("font-src %s", strings.Join(m.config.FontSrc, " ")))
}

if len(m.config.ConnectSrc) > 0 {
directives = append(directives, fmt.Sprintf("connect-src %s", strings.Join(m.config.ConnectSrc, " ")))
}

if len(m.config.FrameSrc) > 0 {
directives = append(directives, fmt.Sprintf("frame-src %s", strings.Join(m.config.FrameSrc, " ")))
}

if len(m.config.ObjectSrc) > 0 {
directives = append(directives, fmt.Sprintf("object-src %s", strings.Join(m.config.ObjectSrc, " ")))
}

if len(m.config.BaseURI) > 0 {
directives = append(directives, fmt.Sprintf("base-uri %s", strings.Join(m.config.BaseURI, " ")))
}

if len(m.config.FormAction) > 0 {
directives = append(directives, fmt.Sprintf("form-action %s", strings.Join(m.config.FormAction, " ")))
}

if m.config.ReportURI != "" {
directives = append(directives, fmt.Sprintf("report-uri %s", m.config.ReportURI))
}

return strings.Join(directives, "; ")
}

func (m *CSPMiddleware) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nonce := m.GenerateNonce()

// Добавляем nonce в контекст для использования в шаблонах
ctx := context.WithValue(r.Context(), "csp_nonce", nonce)

// Устанавливаем заголовок CSP
policy := m.BuildPolicy(nonce)

if m.config.ReportOnly {
w.Header().Set("Content-Security-Policy-Report-Only", policy)
} else {
w.Header().Set("Content-Security-Policy", policy)
}

next.ServeHTTP(w, r.WithContext(ctx))
})
}

// Использование
func main() {
cspConfig := CSPConfig{
DefaultSrc: []string{"'self'"},
ScriptSrc: []string{"'self'", "https://cdn.example.com"},
StyleSrc: []string{"'self'", "https://fonts.googleapis.com"},
ImgSrc: []string{"'self'", "data:", "https:"},
FontSrc: []string{"'self'", "https://fonts.gstatic.com"},
ConnectSrc: []string{"'self'", "https://api.example.com"},
FrameSrc: []string{"'none'"},
ObjectSrc: []string{"'none'"},
BaseURI: []string{"'self'"},
FormAction: []string{"'self'"},
ReportURI: "/csp-report",
}

cspMiddleware := NewCSPMiddleware(cspConfig)

mux := http.NewServeMux()
mux.HandleFunc("/", handler)

handler := cspMiddleware.Middleware(mux)
http.ListenAndServe(":8080", handler)
}

Использование nonce в шаблонах

// Получение nonce из контекста
func getNonce(r *http.Request) string {
if nonce, ok := r.Context().Value("csp_nonce").(string); ok {
return nonce
}
return ""
}

// В шаблоне
func handler(w http.ResponseWriter, r *http.Request) {
nonce := getNonce(r)

tmpl := template.Must(template.New("page").Parse(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Page</title>
<link rel="stylesheet" href="/styles/main.css" nonce="{{.Nonce}}">
</head>
<body>
<h1>Hello World</h1>
<script nonce="{{.Nonce}}">
console.log("This script is allowed by CSP");
</script>
<script src="/scripts/app.js" nonce="{{.Nonce}}"></script>
</body>
</html>
`))

tmpl.Execute(w, map[string]string{
"Nonce": nonce,
})
}

CSP с хешами вместо nonce

import (
"crypto/sha256"
"encoding/base64"
)

func generateHash(content string) string {
hash := sha256.Sum256([]byte(content))
return "sha256-" + base64.StdEncoding.EncodeToString(hash[:])
}

// Для inline-скрипта
scriptContent := "console.log('Hello World');"
hash := generateHash(scriptContent)
// Добавляем hash в CSP: script-src 'sha256-...'

Отчёты о нарушениях CSP

func cspReportHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}

var report struct {
CSPReport struct {
DocumentURI string `json:"document-uri"`
Referrer string `json:"referrer"`
BlockedURI string `json:"blocked-uri"`
ViolatedDirective string `json:"violated-directive"`
OriginalPolicy string `json:"original-policy"`
} `json:"csp-report"`
}

if err := json.NewDecoder(r.Body).Decode(&report); err != nil {
http.Error(w, "Invalid report", http.StatusBadRequest)
return
}

// Логируем нарушение
log.Printf("CSP Violation: document-uri=%s, blocked-uri=%s, directive=%s",
report.CSPReport.DocumentURI,
report.CSPReport.BlockedURI,
report.CSPReport.ViolatedDirective)

w.WriteHeader(http.StatusOK)
}

Режим Report-Only

Для тестирования CSP без блокировки:

cspConfig := CSPConfig{
// ... другие настройки
ReportOnly: true, // Только отчёты, без блокировки
ReportURI: "/csp-report",
}

Примеры политик

Строгая политика:

default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'

Политика с CDN:

default-src 'self'; script-src 'self' https://cdn.example.com 'nonce-{random}'; style-src 'self' https://cdn.example.com 'nonce-{random}'; img-src 'self' https://cdn.example.com data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self' https://api.example.com

Политика для разработки:

default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:

Важно помнить

  • Начинайте с режима Report-Only для тестирования
  • Используйте nonce или хеши вместо 'unsafe-inline'
  • Регулярно проверяйте отчёты о нарушениях
  • Обновляйте CSP при добавлении новых внешних ресурсов
  • Используйте CSP в сочетании с другими мерами безопасности

Вопрос 15. Знаешь ли что такое Subresource Integrity (SRI) и приходилось ли с ним работать?

Таймкод: 00:23:35

Ответ собеседника: Неправильный. Кандидат не встречался с SRI и не знает, что это такое. Интервьюер пояснил, что SRI используется для проверки целостности ресурсов по хэшу.

Правильный ответ:

Ответ собеседника некорректен — SRI не связан с SSR и не используется для хэширования HTML-документов. Рассмотрим, что такое SRI на самом деле.

Что такое Subresource Integrity (SRI)

Subresource Integrity — это механизм безопасности, который позволяет браузеру проверять, что загружаемые ресурсы (скрипты, стили) не были изменены. Работает это через криптографический хэш: в теге ресурса указывается ожидаемый хэш, и браузер сравнивает его с фактическим хэшем загруженного файла.

Как работает SRI

  1. Вы генерируете хэш файла (обычно SHA-256, SHA-384 или SHA-512)
  2. Добавляете атрибут integrity с хэшем в тег script или link
  3. Браузер загружает файл и вычисляет его хэш
  4. Если хэши не совпадают — ресурс не загружается

Пример использования SRI

<!-- Подключение jQuery с проверкой целостности -->
<script
src="https://code.jquery.com/jquery-3.6.0.min.js"
integrity="sha384-vtXRMe3mGCbOeY7l30aIg8H9p3GdeSe4IFlP6G8JMa7o7lXvnz3GFKzPxzJdPfGK"
crossorigin="anonymous">
</script>

<!-- Подключение CSS с проверкой целостности -->
<link
rel="stylesheet"
href="https://cdn.example.com/styles/main.css"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
crossorigin="anonymous">

Генерация хэша SRI

Через командную строку:

# Генерация SHA-384 хэша
openssl dgst -sha384 -binary filename.js | openssl base64 -A

# Или с помощью shasum
shasum -a 384 filename.js | xxd -r -p | base64

Через Node.js:

const crypto = require('crypto');
const fs = require('fs');

function generateSRIHash(filePath, algorithm = 'sha384') {
const fileBuffer = fs.readFileSync(filePath);
const hash = crypto.createHash(algorithm).update(fileBuffer).digest('base64');
return `${algorithm}-${hash}`;
}

const hash = generateSRIHash('./dist/app.js');
console.log(hash);
// sha384-abc123...

Интеграция SRI в сборку

// webpack.config.js
const SriPlugin = require('webpack-subresource-integrity');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
output: {
crossOriginLoading: 'anonymous', // Необходимо для SRI
},
plugins: [
new HtmlWebpackPlugin({
template: './src/index.html'
}),
new SriPlugin({
hashFuncNames: ['sha384', 'sha256'],
enabled: process.env.NODE_ENV === 'production'
})
]
};

Генерация SRI в Go

package sri

import (
"crypto/sha256"
"crypto/sha512"
"encoding/base64"
"fmt"
"hash"
"io"
"os"
)

type Algorithm string

const (
SHA256 Algorithm = "sha256"
SHA384 Algorithm = "sha384"
SHA512 Algorithm = "sha512"
)

type SRIGenerator struct {
algorithm Algorithm
}

func NewSRIGenerator(algorithm Algorithm) *SRIGenerator {
return &SRIGenerator{algorithm: algorithm}
}

func (g *SRIGenerator) GenerateFromFile(filePath string) (string, error) {
file, err := os.Open(filePath)
if err != nil {
return "", fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()

return g.GenerateFromReader(file)
}

func (g *SRIGenerator) GenerateFromReader(reader io.Reader) (string, error) {
var h hash.Hash

switch g.algorithm {
case SHA256:
h = sha256.New()
case SHA384:
h = sha512.New384()
case SHA512:
h = sha512.New()
default:
return "", fmt.Errorf("unsupported algorithm: %s", g.algorithm)
}

if _, err := io.Copy(h, reader); err != nil {
return "", fmt.Errorf("failed to hash: %w", err)
}

hashBytes := h.Sum(nil)
encodedHash := base64.StdEncoding.EncodeToString(hashBytes)

return fmt.Sprintf("%s-%s", g.algorithm, encodedHash), nil
}

func (g *SRIGenerator) GenerateFromBytes(data []byte) (string, error) {
var h hash.Hash

switch g.algorithm {
case SHA256:
h = sha256.New()
case SHA384:
h = sha512.New384()
case SHA512:
h = sha512.New()
default:
return "", fmt.Errorf("unsupported algorithm: %s", g.algorithm)
}

h.Write(data)
hashBytes := h.Sum(nil)
encodedHash := base64.StdEncoding.EncodeToString(hashBytes)

return fmt.Sprintf("%s-%s", g.algorithm, encodedHash), nil
}

// Использование
func main() {
generator := NewSRIGenerator(SHA384)

hash, err := generator.GenerateFromFile("./dist/app.js")
if err != nil {
log.Fatal(err)
}

fmt.Printf("SRI Hash: %s\n", hash)
// Используйте этот hash в шаблоне
}

Использование SRI в шаблонах Go

type TemplateData struct {
AppJSHash string
AppCSSHash string
CDNBaseURL string
}

func renderPage(w http.ResponseWriter, r *http.Request) {
data := TemplateData{
AppJSHash: "sha384-abc123...",
AppCSSHash: "sha384-def456...",
CDNBaseURL: "https://cdn.example.com",
}

tmpl := template.Must(template.New("page").Parse(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>My App</title>
<link rel="stylesheet"
href="{{.CDNBaseURL}}/styles/app.css"
integrity="{{.AppCSSHash}}"
crossorigin="anonymous">
</head>
<body>
<div id="app"></div>
<script src="{{.CDNBaseURL}}/scripts/app.js"
integrity="{{.AppJSHash}}"
crossorigin="anonymous"></script>
</body>
</html>
`))

tmpl.Execute(w, data)
}

Автоматическая генерация SRI при сборке

package main

import (
"encoding/json"
"os"
"path/filepath"
)

type AssetManifest struct {
Files map[string]string `json:"files"`
SRI map[string]string `json:"sri"`
}

func generateManifest(buildDir string) (*AssetManifest, error) {
manifest := &AssetManifest{
Files: make(map[string]string),
SRI: make(map[string]string),
}

generator := NewSRIGenerator(SHA384)

err := filepath.Walk(buildDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}

if info.IsDir() {
return nil
}

ext := filepath.Ext(path)
if ext != ".js" && ext != ".css" {
return nil
}

relPath, _ := filepath.Rel(buildDir, path)
hash, err := generator.GenerateFromFile(path)
if err != nil {
return err
}

manifest.Files[relPath] = "/" + relPath
manifest.SRI[relPath] = hash

return nil
})

if err != nil {
return nil, err
}

// Сохраняем манифест
manifestJSON, _ := json.MarshalIndent(manifest, "", " ")
os.WriteFile(filepath.Join(buildDir, "manifest.json"), manifestJSON, 0644)

return manifest, nil
}

Обработка ошибок SRI на клиенте

// Слушаем события ошибок загрузки ресурсов
window.addEventListener('error', function(event) {
if (event.target.tagName === 'SCRIPT' || event.target.tagName === 'LINK') {
console.error('SRI check failed for:', event.target.src || event.target.href);

// Отправляем отчёт на сервер
fetch('/api/sri-error', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
resource: event.target.src || event.target.href,
type: event.target.tagName,
timestamp: new Date().toISOString()
})
});
}
}, true);

Когда использовать SRI

  • Загрузка ресурсов с CDN
  • Загрузка сторонних библиотек
  • Критически важные скрипты и стили
  • Соответствие требованиям безопасности

Ограничения SRI

  • Не работает с inline-скриптами и стилями
  • Требует атрибут crossorigin для CORS-ресурсов
  • Не все старые браузеры поддерживают SRI
  • Необходимо обновлять хэши при изменении файлов

Важно помнить

SRI — это дополнительный уровень защиты, который предотвращает выполнение изменённых ресурсов. Особенно важно использовать SRI при загрузке ресурсов с внешних CDN, так как компрометация CDN может привести к инъекции вредоносного кода на ваш сайт.

Вопрос 16. Напиши Type Guard функцию, которая проверяет, что переданный объект является Circle (имеет свойство radius)

Таймкод: 00:25:16

Ответ собеседника: Правильный. Написал Type Guard функцию с проверкой наличия свойства radius и проверкой на объект. Указал на проблему с null, которую исправил.

Правильный ответ:

Ответ собеседника корректен. Рассмотрим подробнее Type Guard функции и их применение в TypeScript.

Базовая реализация Type Guard

interface Circle {
radius: number;
}

interface Rectangle {
width: number;
height: number;
}

type Shape = Circle | Rectangle;

// Простой Type Guard
function isCircle(shape: Shape): shape is Circle {
return (shape as Circle).radius !== undefined;
}

// Более надёжный Type Guard с проверкой типов
function isCircleSafe(shape: unknown): shape is Circle {
return (
typeof shape === 'object' &&
shape !== null &&
'radius' in shape &&
typeof (shape as Circle).radius === 'number'
);
}

// Использование
function getArea(shape: Shape): number {
if (isCircle(shape)) {
// TypeScript знает, что shape — это Circle
return Math.PI * shape.radius ** 2;
} else {
// TypeScript знает, что shape — это Rectangle
return shape.width * shape.height;
}
}

Проблема с typeof null

// Проблема: typeof null возвращает 'object'
console.log(typeof null); // 'object'

// Поэтому нужна явная проверка на null
function isObject(value: unknown): value is object {
return typeof value === 'object' && value !== null;
}

// Или более строгая проверка
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

Продвинутые Type Guard функции

Type Guard для нескольких типов:

interface Circle {
kind: 'circle';
radius: number;
}

interface Rectangle {
kind: 'rectangle';
width: number;
height: number;
}

interface Triangle {
kind: 'triangle';
base: number;
height: number;
}

type Shape = Circle | Rectangle | Triangle;

// Type Guard с дискриминированным union
function isCircle(shape: Shape): shape is Circle {
return shape.kind === 'circle';
}

function isRectangle(shape: Shape): shape is Rectangle {
return shape.kind === 'rectangle';
}

function isTriangle(shape: Shape): shape is Triangle {
return shape.kind === 'triangle';
}

// Использование
function getArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'rectangle':
return shape.width * shape.height;
case 'triangle':
return 0.5 * shape.base * shape.height;
default:
// Exhaustive check
const _exhaustive: never = shape;
return _exhaustive;
}
}

Универсальный Type Guard:

// Универсальный Type Guard для проверки по ключу
function hasProperty<T extends object, K extends string>(
obj: T,
key: K
): obj is T & Record<K, unknown> {
return key in obj;
}

// Использование
function processShape(shape: Shape): string {
if (hasProperty(shape, 'radius')) {
return `Circle with radius ${shape.radius}`;
} else if (hasProperty(shape, 'width')) {
return `Rectangle ${shape.width}x${shape.height}`;
}
return 'Unknown shape';
}

Type Guard с валидацией значений:

interface Circle {
radius: number;
}

// Type Guard с валидацией, что radius — положительное число
function isValidCircle(shape: unknown): shape is Circle {
if (typeof shape !== 'object' || shape === null) {
return false;
}

const candidate = shape as Record<string, unknown>;

return (
'radius' in candidate &&
typeof candidate.radius === 'number' &&
candidate.radius > 0 &&
Number.isFinite(candidate.radius)
);
}

// Использование
function createCircle(shape: unknown): Circle | null {
if (isValidCircle(shape)) {
return shape;
}
return null;
}

Type Guard для массивов:

// Проверка, что массив содержит только определённый тип
function isArrayOfType<T>(
arr: unknown[],
guard: (item: unknown) => item is T
): arr is T[] {
return arr.every(guard);
}

// Использование
function processShapes(shapes: unknown[]): Shape[] {
if (isArrayOfType(shapes, isShape)) {
return shapes; // TypeScript знает, что это Shape[]
}
throw new Error('Invalid shapes array');
}

function isShape(item: unknown): item is Shape {
if (typeof item !== 'object' || item === null) {
return false;
}

const candidate = item as Record<string, unknown>;

if (candidate.kind === 'circle') {
return typeof candidate.radius === 'number';
}

if (candidate.kind === 'rectangle') {
return typeof candidate.width === 'number' && typeof candidate.height === 'number';
}

if (candidate.kind === 'triangle') {
return typeof candidate.base === 'number' && typeof candidate.height === 'number';
}

return false;
}

Композиция Type Guard:

// Комбинирование нескольких Type Guard
function and<T, U extends T>(
guard1: (value: unknown) => value is T,
guard2: (value: T) => value is U
): (value: unknown) => value is U {
return (value: unknown): value is U => guard1(value) && guard2(value);
}

// Использование
interface ValidatedCircle extends Circle {
radius: number & { __brand: 'positive' };
}

function isPositiveRadius(circle: Circle): circle is ValidatedCircle {
return circle.radius > 0;
}

const isValidPositiveCircle = and(isCircle, isPositiveRadius);

function processCircle(shape: unknown): number {
if (isValidPositiveCircle(shape)) {
return Math.PI * shape.radius ** 2;
}
throw new Error('Invalid circle');
}

Type Guard для API-ответов:

interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
}

function isApiResponse<T>(
response: unknown,
dataGuard: (data: unknown) => data is T
): response is ApiResponse<T> {
if (typeof response !== 'object' || response === null) {
return false;
}

const candidate = response as Record<string, unknown>;

if (typeof candidate.success !== 'boolean') {
return false;
}

if (candidate.success && candidate.data !== undefined) {
return dataGuard(candidate.data);
}

if (!candidate.success && candidate.error !== undefined) {
return typeof candidate.error === 'string';
}

return false;
}

// Использование
interface User {
id: number;
name: string;
email: string;
}

function isUser(data: unknown): data is User {
if (typeof data !== 'object' || data === null) {
return false;
}

const candidate = data as Record<string, unknown>;

return (
typeof candidate.id === 'number' &&
typeof candidate.name === 'string' &&
typeof candidate.email === 'string'
);
}

async function fetchUser(id: number): Promise<User | null> {
const response = await fetch(`/api/users/${id}`);
const json = await response.json();

if (isApiResponse(json, isUser)) {
return json.data;
}

return null;
}

Важно помнить

  • Type Guard должен возвращать value is Type, а не просто boolean
  • Всегда проверяйте на null, так как typeof null === 'object'
  • Используйте дискриминированные union типы для более надёжной типизации
  • Комбинируйте Type Guard для сложных проверок
  • Документируйте ожидаемую структуру данных в Type Guard

Вопрос 17. Напиши тип Except<T, K>, который делает все свойства опциональными, кроме указанных в K, работая только на первом уровне вложенности

Таймкод: 00:31:04

Ответ собеседника: Неполный. Понял направление решения через Partial, Omit и Pick, но не смог соединить их через пересечение типов. В итоге понял логику с подсказками интервьюера.

Правильный ответ:

Задача на создание условного типа, который делает все свойства опциональными, кроме указанных. Рассмотрим решение шаг за шагом.

Понимание задачи

Нужно создать тип Except<T, K>, который:

  • Принимает тип T и набор ключей K
  • Делает все свойства T опциональными, кроме тех, что указаны в K
  • Работает только на первом уровне вложенности

Решение

// Базовое решение
type Except<T, K extends keyof T> = Partial<T> & Pick<T, K>;

// Пример использования
interface User {
id: number;
name: string;
email: string;
age: number;
}

// Все поля опциональные, кроме id и name
type UserUpdate = Except<User, 'id' | 'name'>;

// Результат:
// {
// id: number; // обязательное
// name: string; // обязательное
// email?: string; // опциональное
// age?: number; // опциональное
// }

const update: UserUpdate = {
id: 1,
name: 'John',
// email и age можно не указывать
};

Пошаговое объяснение

// Шаг 1: Partial<T> делает все свойства опциональными
type PartialUser = Partial<User>;
// {
// id?: number;
// name?: string;
// email?: string;
// age?: number;
// }

// Шаг 2: Pick<T, K> выбирает указанные свойства как обязательные
type RequiredFields = Pick<User, 'id' | 'name'>;
// {
// id: number;
// name: string;
// }

// Шаг 3: Пересечение (&) объединяет оба типа
type Result = Partial<User> & Pick<User, 'id' | 'name'>;
// {
// id: number; // из Pick — обязательное
// name: string; // из Pick — обязательное
// email?: string; // из Partial — опциональное
// age?: number; // из Partial — опциональное
// }

Альтернативные реализации

Вариант с Omit и Required:

// Делаем все обязательными, потом исключаем K и делаем опциональными
type Except<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;

// Это неверно! Правильный вариант:
type Except<T, K extends keyof T> = Partial<Omit<T, K>> & Pick<T, K>;

Вариант с Mapped Types:

// Ручная реализация через Mapped Types
type Except<T, K extends keyof T> = {
[P in Exclude<keyof T, K>]?: T[P]; // Все кроме K — опциональные
} & {
[P in K]: T[P]; // K — обязательные
};

// Пример
type UserUpdate = Except<User, 'id' | 'name'>;
// {
// email?: string;
// age?: number;
// } & {
// id: number;
// name: string;
// }

Вариант с использованием Pick и Exclude:

// Более явная реализация
type Except<T, K extends keyof T> =
Partial<Pick<T, Exclude<keyof T, K>>> &
Required<Pick<T, K>>;

// Пример
type UserUpdate = Except<User, 'id' | 'name'>;

Расширенные варианты

ExceptDeep — рекурсивная версия:

// Рекурсивная версия для вложенных объектов
type ExceptDeep<T, K extends keyof T> = T extends object
? {
[P in Exclude<keyof T, K>]?: T[P] extends object
? ExceptDeep<T[P], keyof T[P]>
: T[P];
} & {
[P in K]: T[P] extends object
? ExceptDeep<T[P], keyof T[P]>
: T[P];
}
: T;

// Пример
interface NestedUser {
id: number;
profile: {
name: string;
avatar: string;
};
settings: {
theme: string;
notifications: boolean;
};
}

type NestedUpdate = ExceptDeep<NestedUser, 'id'>;
// {
// id: number; // обязательное
// profile?: { // опциональное, но внутри тоже опциональные
// name?: string;
// avatar?: string;
// };
// settings?: {
// theme?: string;
// notifications?: boolean;
// };
// }

ExceptStrict — с проверкой на существование ключей:

// Строгая версия, которая не позволяет указывать несуществующие ключи
type ExceptStrict<T, K extends keyof T> =
Partial<Omit<T, K>> & Pick<T, K>;

// Это вызовет ошибку, так как 'invalid' не существует в User
// type Invalid = ExceptStrict<User, 'id' | 'invalid'>;

Практические примеры использования

DTO для обновления сущности:

interface Product {
id: string;
name: string;
description: string;
price: number;
category: string;
createdAt: Date;
updatedAt: Date;
}

// При обновлении все поля опциональные, кроме id
type UpdateProductDto = Except<Product, 'id'>;

function updateProduct(id: string, updates: UpdateProductDto): Product {
// updates.name, updates.price и т.д. — опциональные
// Но updates.id — обязательный
return { ...existingProduct, ...updates, id };
}

Формы с обязательными полями:

interface RegistrationForm {
email: string;
password: string;
confirmPassword: string;
firstName: string;
lastName: string;
phone: string;
agreeToTerms: boolean;
}

// При частичном заполнении формы все опциональные, кроме email
type PartialRegistration = Except<RegistrationForm, 'email'>;

function savePartialRegistration(data: PartialRegistration): void {
// data.email — обязательный
// Все остальные — опциональные
}

API параметры:

interface SearchParams {
query: string;
page: number;
limit: number;
sortBy: string;
sortOrder: 'asc' | 'desc';
filters: Record<string, string>;
}

// При поиске query обязателен, остальное опционально
type SearchQuery = Except<SearchParams, 'query'>;

function search(params: SearchQuery): Promise<SearchResult[]> {
// params.query — обязательный
// params.page, params.limit и т.д. — опциональные
const { query, page = 1, limit = 10, sortBy = 'relevance', sortOrder = 'desc', filters = {} } = params;
// ...
}

Тестирование типа:

// Тест 1: Базовое использование
type Test1 = Except<User, 'id'>;
const test1: Test1 = { id: 1 }; // OK — только id обязателен

// Тест 2: Несколько обязательных полей
type Test2 = Except<User, 'id' | 'email'>;
const test2: Test2 = { id: 1, email: 'test@example.com' }; // OK

// Тест 3: Все поля обязательные
type Test3 = Except<User, 'id' | 'name' | 'email' | 'age'>;
const test3: Test3 = { id: 1, name: 'John', email: 'test@example.com', age: 25 }; // OK

// Тест 4: Все поля опциональные
type Test4 = Except<User, never>;
const test4: Test4 = {}; // OK — все опциональные

Важно помнить

  • Partial<T> делает все свойства опциональными
  • Pick<T, K> выбирает указанные свойства
  • Omit<T, K> исключает указанные свойства
  • Пересечение типов & объединяет свойства
  • Порядок важен: Partial<T> & Pick<T, K> работает, а Pick<T, K> & Partial<T> — нет, потому что Partial перезапишет Pick

Вопрос 18. Напиши функцию, которая принимает массив чисел и возвращает объект с двумя свойствами: uniqueValues (массив уникальных значений) и duplicateValues (массив повторяющихся значений)

Таймкод: 00:41:30

Ответ собеседника: Правильный. Предложил решение через Map для подсчёта вхождений, затем распределение значений в uniqueValues и duplicateValues.

Правильный ответ:

Ответ собеседника корректен. Рассмотрим различные варианты решения и оптимизации.

Решение через Map (как предложил собеседник)

interface ValueClassification {
uniqueValues: number[];
duplicateValues: number[];
}

function classifyValues(numbers: number[]): ValueClassification {
const countMap = new Map<number, number>();

// Подсчитываем количество вхождений
for (const num of numbers) {
countMap.set(num, (countMap.get(num) || 0) + 1);
}

const uniqueValues: number[] = [];
const duplicateValues: number[] = [];

// Распределяем значения
for (const [value, count] of countMap) {
if (count === 1) {
uniqueValues.push(value);
} else {
duplicateValues.push(value);
}
}

return { uniqueValues, duplicateValues };
}

// Использование
const result = classifyValues([1, 2, 3, 2, 4, 5, 3, 6]);
console.log(result);
// { uniqueValues: [1, 4, 5, 6], duplicateValues: [2, 3] }

Решение через Set

function classifyValuesWithSet(numbers: number[]): ValueClassification {
const seen = new Set<number>();
const duplicates = new Set<number>();

for (const num of numbers) {
if (seen.has(num)) {
duplicates.add(num);
} else {
seen.add(num);
}
}

// Уникальные — те, что в seen, но не в duplicates
const uniqueValues = [...seen].filter(x => !duplicates.has(x));
const duplicateValues = [...duplicates];

return { uniqueValues, duplicateValues };
}

Решение через reduce

function classifyValuesReduce(numbers: number[]): ValueClassification {
const counts = numbers.reduce((acc, num) => {
acc[num] = (acc[num] || 0) + 1;
return acc;
}, {} as Record<number, number>);

return Object.entries(counts).reduce((acc, [value, count]) => {
const numValue = Number(value);
if (count === 1) {
acc.uniqueValues.push(numValue);
} else {
acc.duplicateValues.push(numValue);
}
return acc;
}, { uniqueValues: [] as number[], duplicateValues: [] as number[] });
}

Решение с сохранением порядка

function classifyValuesOrdered(numbers: number[]): ValueClassification {
const countMap = new Map<number, number>();
const orderMap = new Map<number, number>(); // Сохраняем порядок первого появления

numbers.forEach((num, index) => {
countMap.set(num, (countMap.get(num) || 0) + 1);
if (!orderMap.has(num)) {
orderMap.set(num, index);
}
});

const uniqueValues: number[] = [];
const duplicateValues: number[] = [];

for (const [value, count] of countMap) {
if (count === 1) {
uniqueValues.push(value);
} else {
duplicateValues.push(value);
}
}

// Сортируем по порядку первого появления
const sortByOrder = (a: number, b: number) => orderMap.get(a)! - orderMap.get(b)!;

return {
uniqueValues: uniqueValues.sort(sortByOrder),
duplicateValues: duplicateValues.sort(sortByOrder)
};
}

Решение с использованием filter

function classifyValuesFilter(numbers: number[]): ValueClassification {
const uniqueValues = numbers.filter((num, index, arr) => arr.indexOf(num) === arr.lastIndexOf(num));
const duplicateValues = [...new Set(numbers.filter((num, index, arr) => arr.indexOf(num) !== arr.lastIndexOf(num)))];

return { uniqueValues, duplicateValues };
}

Оптимизированное решение для больших массивов

function classifyValuesOptimized(numbers: number[]): ValueClassification {
const countMap = new Map<number, number>();
const uniqueValues: number[] = [];
const duplicateValues: number[] = [];
const addedToDuplicates = new Set<number>();

for (const num of numbers) {
const count = (countMap.get(num) || 0) + 1;
countMap.set(num, count);

if (count === 1) {
uniqueValues.push(num);
} else if (count === 2) {
// Первый дубликат — добавляем в duplicateValues
duplicateValues.push(num);
addedToDuplicates.add(num);
}
// При count > 2 уже добавлено в duplicateValues
}

// Удаляем из uniqueValues те, что стали дубликатами
const finalUnique = uniqueValues.filter(num => !addedToDuplicates.has(num));

return { uniqueValues: finalUnique, duplicateValues };
}

Решение с подробной статистикой

interface DetailedClassification {
uniqueValues: number[];
duplicateValues: number[];
statistics: {
totalCount: number;
uniqueCount: number;
duplicateCount: number;
mostFrequent: { value: number; count: number } | null;
};
}

function classifyValuesDetailed(numbers: number[]): DetailedClassification {
const countMap = new Map<number, number>();

for (const num of numbers) {
countMap.set(num, (countMap.get(num) || 0) + 1);
}

const uniqueValues: number[] = [];
const duplicateValues: number[] = [];
let mostFrequent: { value: number; count: number } | null = null;

for (const [value, count] of countMap) {
if (count === 1) {
uniqueValues.push(value);
} else {
duplicateValues.push(value);
}

if (!mostFrequent || count > mostFrequent.count) {
mostFrequent = { value, count };
}
}

return {
uniqueValues,
duplicateValues,
statistics: {
totalCount: numbers.length,
uniqueCount: uniqueValues.length,
duplicateCount: duplicateValues.length,
mostFrequent
}
};
}

Решение для обобщённых типов

interface Classification<T> {
uniqueValues: T[];
duplicateValues: T[];
}

function classifyValuesGeneric<T>(items: T[]): Classification<T> {
const countMap = new Map<T, number>();

for (const item of items) {
countMap.set(item, (countMap.get(item) || 0) + 1);
}

const uniqueValues: T[] = [];
const duplicateValues: T[] = [];

for (const [value, count] of countMap) {
if (count === 1) {
uniqueValues.push(value);
} else {
duplicateValues.push(value);
}
}

return { uniqueValues, duplicateValues };
}

// Использование с разными типами
const numbers = classifyValuesGeneric([1, 2, 3, 2, 4]);
const strings = classifyValuesGeneric(['a', 'b', 'c', 'b', 'd']);

Юнит-тесты

function testClassifyValues() {
// Тест 1: Базовый случай
const result1 = classifyValues([1, 2, 3, 2, 4, 5, 3, 6]);
console.assert(
JSON.stringify(result1.uniqueValues.sort()) === JSON.stringify([1, 4, 5, 6]),
'Test 1 failed: uniqueValues'
);
console.assert(
JSON.stringify(result1.duplicateValues.sort()) === JSON.stringify([2, 3]),
'Test 1 failed: duplicateValues'
);

// Тест 2: Все уникальные
const result2 = classifyValues([1, 2, 3, 4, 5]);
console.assert(result2.uniqueValues.length === 5, 'Test 2 failed: uniqueValues');
console.assert(result2.duplicateValues.length === 0, 'Test 2 failed: duplicateValues');

// Тест 3: Все дубликаты
const result3 = classifyValues([1, 1, 2, 2, 3, 3]);
console.assert(result3.uniqueValues.length === 0, 'Test 3 failed: uniqueValues');
console.assert(result3.duplicateValues.length === 3, 'Test 3 failed: duplicateValues');

// Тест 4: Пустой массив
const result4 = classifyValues([]);
console.assert(result4.uniqueValues.length === 0, 'Test 4 failed: uniqueValues');
console.assert(result4.duplicateValues.length === 0, 'Test 4 failed: duplicateValues');

// Тест 5: Один элемент
const result5 = classifyValues([42]);
console.assert(result5.uniqueValues.length === 1, 'Test 5 failed: uniqueValues');
console.assert(result5.duplicateValues.length === 0, 'Test 5 failed: duplicateValues');

console.log('All tests passed!');
}

testClassifyValues();

Сравнение производительности

ПодходВремяПамятьКогда использовать
MapO(n)O(n)Универсальный случай
SetO(n)O(n)Когда нужна простота
filterO(n²)O(n)Для маленьких массивов
reduceO(n)O(n)Функциональный стиль

Важно помнить

  • Решение через Map — оптимальное по времени и памяти: O(n)
  • Решение через filter имеет квадратичную сложность O(n²) из-за indexOf/lastIndexOf
  • Для больших массивов используйте Map или Set
  • Если важен порядок элементов — используйте дополнительную структуру для отслеживания порядка

Вопрос 19. Модифицируй функцию так, чтобы она принимала два массива и возвращала объект с uniqueValues и duplicateValues, где уникальность и дубликаты считаются среди обоих массивов

Таймкод: 00:46:14

Ответ собеседника: Правильный. Предложил объединить массивы через spread оператор и использовать для подсчёта. Также предложил масштабируемое решение с массивом массивов.

Правильный ответ:

Ответ собеседника корректен. Рассмотрим различные варианты решения.

Решение через spread оператор

interface ValueClassification {
uniqueValues: number[];
duplicateValues: number[];
}

function classifyTwoArrays(arr1: number[], arr2: number[]): ValueClassification {
const combined = [...arr1, ...arr2];
const countMap = new Map<number, number>();

for (const num of combined) {
countMap.set(num, (countMap.get(num) || 0) + 1);
}

const uniqueValues: number[] = [];
const duplicateValues: number[] = [];

for (const [value, count] of countMap) {
if (count === 1) {
uniqueValues.push(value);
} else {
duplicateValues.push(value);
}
}

return { uniqueValues, duplicateValues };
}

// Использование
const result = classifyTwoArrays([1, 2, 3], [2, 3, 4]);
console.log(result);
// { uniqueValues: [1, 4], duplicateValues: [2, 3] }

Решение с переменным числом аргументов

function classifyMultipleArrays(...arrays: number[][]): ValueClassification {
const combined = arrays.flat();
const countMap = new Map<number, number>();

for (const num of combined) {
countMap.set(num, (countMap.get(num) || 0) + 1);
}

const uniqueValues: number[] = [];
const duplicateValues: number[] = [];

for (const [value, count] of countMap) {
if (count === 1) {
uniqueValues.push(value);
} else {
duplicateValues.push(value);
}
}

return { uniqueValues, duplicateValues };
}

// Использование
const result1 = classifyMultipleArrays([1, 2, 3], [2, 3, 4]);
const result2 = classifyMultipleArrays([1, 2], [2, 3], [3, 4], [5]);

Решение без создания промежуточного массива

function classifyTwoArraysOptimized(arr1: number[], arr2: number[]): ValueClassification {
const countMap = new Map<number, number>();

// Обрабатываем оба массива без создания промежуточного
for (const num of arr1) {
countMap.set(num, (countMap.get(num) || 0) + 1);
}

for (const num of arr2) {
countMap.set(num, (countMap.get(num) || 0) + 1);
}

const uniqueValues: number[] = [];
const duplicateValues: number[] = [];

for (const [value, count] of countMap) {
if (count === 1) {
uniqueValues.push(value);
} else {
duplicateValues.push(value);
}
}

return { uniqueValues, duplicateValues };
}

Решение с разделением по источникам

interface DetailedClassification {
uniqueValues: number[];
duplicateValues: number[];
details: {
onlyInFirst: number[];
onlyInSecond: number[];
inBoth: number[];
};
}

function classifyWithDetails(arr1: number[], arr2: number[]): DetailedClassification {
const set1 = new Set(arr1);
const set2 = new Set(arr2);

const onlyInFirst = [...set1].filter(x => !set2.has(x));
const onlyInSecond = [...set2].filter(x => !set1.has(x));
const inBoth = [...set1].filter(x => set2.has(x));

const combined = [...arr1, ...arr2];
const countMap = new Map<number, number>();

for (const num of combined) {
countMap.set(num, (countMap.get(num) || 0) + 1);
}

const uniqueValues: number[] = [];
const duplicateValues: number[] = [];

for (const [value, count] of countMap) {
if (count === 1) {
uniqueValues.push(value);
} else {
duplicateValues.push(value);
}
}

return {
uniqueValues,
duplicateValues,
details: { onlyInFirst, onlyInSecond, inBoth }
};
}

// Использование
const result = classifyWithDetails([1, 2, 3, 3], [2, 3, 4, 4]);
console.log(result);
// {
// uniqueValues: [1],
// duplicateValues: [2, 3, 4],
// details: {
// onlyInFirst: [1],
// onlyInSecond: [4],
// inBoth: [2, 3]
// }
// }

Решение для обобщённых типов

interface Classification<T> {
uniqueValues: T[];
duplicateValues: T[];
}

function classifyArraysGeneric<T>(...arrays: T[][]): Classification<T> {
const countMap = new Map<T, number>();

for (const arr of arrays) {
for (const item of arr) {
countMap.set(item, (countMap.get(item) || 0) + 1);
}
}

const uniqueValues: T[] = [];
const duplicateValues: T[] = [];

for (const [value, count] of countMap) {
if (count === 1) {
uniqueValues.push(value);
} else {
duplicateValues.push(value);
}
}

return { uniqueValues, duplicateValues };
}

// Использование с разными типами
const numbers = classifyArraysGeneric([1, 2, 3], [2, 3, 4]);
const strings = classifyArraysGeneric(['a', 'b', 'c'], ['b', 'c', 'd']);

Решение с использованием reduce

function classifyArraysReduce(...arrays: number[][]): ValueClassification {
const countMap = arrays
.flat()
.reduce((acc, num) => {
acc.set(num, (acc.get(num) || 0) + 1);
return acc;
}, new Map<number, number>());

return [...countMap.entries()].reduce((acc, [value, count]) => {
if (count === 1) {
acc.uniqueValues.push(value);
} else {
acc.duplicateValues.push(value);
}
return acc;
}, { uniqueValues: [] as number[], duplicateValues: [] as number[] });
}

Решение с сохранением порядка

function classifyArraysOrdered(...arrays: number[][]): ValueClassification {
const countMap = new Map<number, number>();
const orderMap = new Map<number, number>();
let orderIndex = 0;

for (const arr of arrays) {
for (const num of arr) {
countMap.set(num, (countMap.get(num) || 0) + 1);
if (!orderMap.has(num)) {
orderMap.set(num, orderIndex++);
}
}
}

const uniqueValues: number[] = [];
const duplicateValues: number[] = [];

for (const [value, count] of countMap) {
if (count === 1) {
uniqueValues.push(value);
} else {
duplicateValues.push(value);
}
}

const sortByOrder = (a: number, b: number) => orderMap.get(a)! - orderMap.get(b)!;

return {
uniqueValues: uniqueValues.sort(sortByOrder),
duplicateValues: duplicateValues.sort(sortByOrder)
};
}

Тестирование

function testClassifyTwoArrays() {
// Тест 1: Базовый случай
const result1 = classifyTwoArrays([1, 2, 3], [2, 3, 4]);
console.assert(
JSON.stringify(result1.uniqueValues.sort()) === JSON.stringify([1, 4]),
'Test 1 failed: uniqueValues'
);
console.assert(
JSON.stringify(result1.duplicateValues.sort()) === JSON.stringify([2, 3]),
'Test 1 failed: duplicateValues'
);

// Тест 2: Нет общих элементов
const result2 = classifyTwoArrays([1, 2, 3], [4, 5, 6]);
console.assert(result2.uniqueValues.length === 6, 'Test 2 failed: uniqueValues');
console.assert(result2.duplicateValues.length === 0, 'Test 2 failed: duplicateValues');

// Тест 3: Все элементы общие
const result3 = classifyTwoArrays([1, 2, 3], [1, 2, 3]);
console.assert(result3.uniqueValues.length === 0, 'Test 3 failed: uniqueValues');
console.assert(result3.duplicateValues.length === 3, 'Test 3 failed: duplicateValues');

// Тест 4: Пустые массивы
const result4 = classifyTwoArrays([], []);
console.assert(result4.uniqueValues.length === 0, 'Test 4 failed: uniqueValues');
console.assert(result4.duplicateValues.length === 0, 'Test 4 failed: duplicateValues');

// Тест 5: Один пустой массив
const result5 = classifyTwoArrays([1, 2, 3], []);
console.assert(result5.uniqueValues.length === 3, 'Test 5 failed: uniqueValues');
console.assert(result5.duplicateValues.length === 0, 'Test 5 failed: duplicateValues');

// Тест 6: Несколько массивов
const result6 = classifyMultipleArrays([1, 2], [2, 3], [3, 4]);
console.assert(result6.uniqueValues.length === 2, 'Test 6 failed: uniqueValues'); // 1, 4
console.assert(result6.duplicateValues.length === 2, 'Test 6 failed: duplicateValues'); // 2, 3

console.log('All tests passed!');
}

testClassifyTwoArrays();

Важно помнить

  • Spread оператор создаёт новый массив, что может быть неэффективно для больших массивов
  • Для оптимизации можно обрабатывать массивы отдельно без создания промежуточного
  • Решение с rest-параметрами (...arrays) более гибкое и масштабируемое
  • Array.flat() — удобный способ объединить массив массивов
  • Для сохранения порядка используйте дополнительную структуру данных

Вопрос 20. Напиши функцию, которая рекурсивно обходит объект с вложенными значениями и суммирует все числовые значения, а также строки, которые можно привести к числу

Таймкод: 00:51:32

Ответ собеседника: Правильный. Реализовал рекурсивную функцию с проверкой типов: объект — рекурсия, число — суммирование, строка — попытка преобразования в число.

Правильный ответ:

Ответ собеседника корректен. Рассмотрим подробную реализацию с различными вариантами.

Базовая реализация

function sumNestedValues(obj: unknown): number {
let sum = 0;

if (typeof obj === 'number' && Number.isFinite(obj)) {
return obj;
}

if (typeof obj === 'string') {
const num = Number(obj);
if (!Number.isNaN(num) && num !== 0) {
return num;
}
// Для строки "0" тоже должны вернуть 0
if (obj.trim() === '0' || obj.trim() === '0.0') {
return 0;
}
return 0;
}

if (Array.isArray(obj)) {
for (const item of obj) {
sum += sumNestedValues(item);
}
return sum;
}

if (typeof obj === 'object' && obj !== null) {
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
sum += sumNestedValues((obj as Record<string, unknown>)[key]);
}
}
return sum;
}

return 0;
}

// Использование
const data = {
a: 1,
b: '2',
c: {
d: 3,
e: '4',
f: {
g: 'not a number',
h: 5
}
},
i: [6, '7', { j: 8 }]
};

console.log(sumNestedValues(data)); // 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 = 36

Реализация с подробной типизацией

type NestedObject = {
[key: string]: NestedValue;
};

type NestedValue =
| number
| string
| boolean
| null
| undefined
| NestedObject
| NestedValue[];

function sumNestedValuesTyped(obj: NestedValue): number {
// Обработка чисел
if (typeof obj === 'number') {
if (Number.isFinite(obj)) {
return obj;
}
return 0;
}

// Обработка строк
if (typeof obj === 'string') {
const trimmed = obj.trim();
if (trimmed === '') return 0;

const num = Number(trimmed);
if (!Number.isNaN(num)) {
return num;
}
return 0;
}

// Обработка массивов
if (Array.isArray(obj)) {
return obj.reduce((sum, item) => sum + sumNestedValuesTyped(item), 0);
}

// Обработка объектов
if (typeof obj === 'object' && obj !== null) {
return Object.values(obj).reduce(
(sum, value) => sum + sumNestedValuesTyped(value),
0
);
}

// boolean, null, undefined
return 0;
}

Реализация с отслеживанием пути

interface SumResult {
sum: number;
path: string[];
values: { path: string; value: number }[];
}

function sumNestedValuesWithPath(
obj: unknown,
currentPath: string = ''
): SumResult {
const result: SumResult = { sum: 0, path: [], values: [] };

if (typeof obj === 'number' && Number.isFinite(obj)) {
result.sum = obj;
result.path = [currentPath];
result.values = [{ path: currentPath, value: obj }];
return result;
}

if (typeof obj === 'string') {
const num = Number(obj);
if (!Number.isNaN(num)) {
result.sum = num;
result.path = [currentPath];
result.values = [{ path: currentPath, value: num }];
}
return result;
}

if (Array.isArray(obj)) {
obj.forEach((item, index) => {
const childResult = sumNestedValuesWithPath(
item,
currentPath ? `${currentPath}[${index}]` : `[${index}]`
);
result.sum += childResult.sum;
result.path.push(...childResult.path);
result.values.push(...childResult.values);
});
return result;
}

if (typeof obj === 'object' && obj !== null) {
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
const childResult = sumNestedValuesWithPath(
(obj as Record<string, unknown>)[key],
currentPath ? `${currentPath}.${key}` : key
);
result.sum += childResult.sum;
result.path.push(...childResult.path);
result.values.push(...childResult.values);
}
}
return result;
}

return result;
}

// Использование
const data = {
a: 1,
b: '2',
c: { d: 3, e: 'not a number' }
};

const result = sumNestedValuesWithPath(data);
console.log(result.sum); // 6
console.log(result.values);
// [
// { path: 'a', value: 1 },
// { path: 'b', value: 2 },
// { path: 'c.d', value: 3 }
// ]

Реализация с обработкой специальных случаев

function sumNestedValuesSafe(obj: unknown): number {
const seen = new Set<object>(); // Для обнаружения циклических ссылок

function sum(value: unknown, path: string = ''): number {
// Обработка чисел
if (typeof value === 'number') {
if (Number.isNaN(value) || !Number.isFinite(value)) {
console.warn(`Warning: ${path} is ${value}, treating as 0`);
return 0;
}
return value;
}

// Обработка строк
if (typeof value === 'string') {
const trimmed = value.trim();
if (trimmed === '') return 0;

// Обработка научной нотации
const num = Number(trimmed);
if (Number.isNaN(num)) {
return 0;
}
return num;
}

// Обработка boolean
if (typeof value === 'boolean') {
return value ? 1 : 0;
}

// Обработка null и undefined
if (value === null || value === undefined) {
return 0;
}

// Обнаружение циклических ссылок
if (typeof value === 'object') {
if (seen.has(value)) {
console.warn(`Warning: circular reference at ${path}`);
return 0;
}
seen.add(value);
}

// Обработка массивов
if (Array.isArray(value)) {
let sum = 0;
for (let i = 0; i < value.length; i++) {
sum += sum(value[i], path ? `${path}[${i}]` : `[${i}]`);
}
return sum;
}

// Обработка объектов
if (typeof value === 'object') {
let sum = 0;
for (const key in value) {
if (Object.prototype.hasOwnProperty.call(value, key)) {
const newPath = path ? `${path}.${key}` : key;
sum += sum((value as Record<string, unknown>)[key], newPath);
}
}
return sum;
}

return 0;
}

return sum(obj, 'root');
}

// Использование с циклической ссылкой
const cyclic: Record<string, unknown> = { a: 1 };
cyclic.self = cyclic;
console.log(sumNestedValuesSafe(cyclic)); // 1

Реализация с фильтрацией по типу

interface SumOptions {
includeNumbers?: boolean;
includeStrings?: boolean;
includeBooleans?: boolean;
ignoreNaN?: boolean;
ignoreInfinity?: boolean;
}

function sumNestedValuesWithOptions(
obj: unknown,
options: SumOptions = {}
): number {
const {
includeNumbers = true,
includeStrings = true,
includeBooleans = false,
ignoreNaN = true,
ignoreInfinity = true
} = options;

function sum(value: unknown): number {
if (typeof value === 'number') {
if (!includeNumbers) return 0;
if (ignoreNaN && Number.isNaN(value)) return 0;
if (ignoreInfinity && !Number.isFinite(value)) return 0;
return value;
}

if (typeof value === 'string') {
if (!includeStrings) return 0;
const num = Number(value);
return Number.isNaN(num) ? 0 : num;
}

if (typeof value === 'boolean') {
if (!includeBooleans) return 0;
return value ? 1 : 0;
}

if (Array.isArray(value)) {
return value.reduce((acc, item) => acc + sum(item), 0);
}

if (typeof value === 'object' && value !== null) {
return Object.values(value).reduce(
(acc, val) => acc + sum(val),
0
);
}

return 0;
}

return sum(obj);
}

// Использование
const data = {
a: 1,
b: '2',
c: true,
d: null,
e: [3, '4', false]
};

console.log(sumNestedValuesWithOptions(data)); // 10
console.log(sumNestedValuesWithOptions(data, { includeBooleans: true })); // 11
console.log(sumNestedValuesWithOptions(data, { includeStrings: false })); // 4

Тестирование

function testSumNestedValues() {
// Тест 1: Простой объект
console.assert(sumNestedValues({ a: 1, b: 2, c: 3 }) === 6, 'Test 1 failed');

// Тест 2: Вложенный объект
console.assert(
sumNestedValues({ a: 1, b: { c: 2, d: { e: 3 } } }) === 6,
'Test 2 failed'
);

// Тест 3: Строки, которые можно преобразовать
console.assert(
sumNestedValues({ a: '1', b: '2', c: '3' }) === 6,
'Test 3 failed'
);

// Тест 4: Смешанные типы
console.assert(
sumNestedValues({ a: 1, b: '2', c: 'not a number', d: true, e: null }) === 3,
'Test 4 failed'
);

// Тест 5: Массивы
console.assert(
sumNestedValues({ a: [1, 2, 3], b: ['4', '5'] }) === 15,
'Test 5 failed'
);

// Тест 6: Пустой объект
console.assert(sumNestedValues({}) === 0, 'Test 6 failed');

// Тест 7: Глубокая вложенность
console.assert(
sumNestedValues({ a: { b: { c: { d: { e: 10 } } } } }) === 10,
'Test 7 failed'
);

// Тест 8: Отрицательные числа
console.assert(
sumNestedValues({ a: -1, b: '-2', c: 3 }) === 0,
'Test 8 failed'
);

// Тест 9: Дробные числа
console.assert(
sumNestedValues({ a: 1.5, b: '2.5' }) === 4,
'Test 9 failed'
);

// Тест 10: Научная нотация
console.assert(
sumNestedValues({ a: '1e2', b: 50 }) === 150,
'Test 10 failed'
);

console.log('All tests passed!');
}

testSumNestedValues();

Важно помнить

  • Всегда проверяйте на null, так как typeof null === 'object'
  • Используйте Number.isNaN() вместо глобального isNaN()
  • Обрабатывайте специальные значения: NaN, Infinity, -Infinity
  • Для больших объектов добавьте обнаружение циклических ссылок
  • Number('') возвращает 0, что может быть неожиданным
  • Строки с пробелами вроде ' 42 ' корректно преобразуются через Number()