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

Frontend Developer Interview - 11 | Fresher | JavaScript & React Interview Questions

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

Сегодня мы разберём собеседование на позицию фронтенд-разработчика, в ходе которого кандидат решает алгоритмические задачи на JavaScript, отвечает на вопросы по работе с промисами, асинхронностью, виртуальным DOM в React и оптимизации производительности, а также пишет компонент аккордеона в реальном времени. Кандидат демонстрирует базовое понимание ключевых концепций, но испытывает затруднения с нюансами — например, при работе с типами данных в методах массива или при объяснении различий между микро- и макрозадачами. Интервью проходит в дружелюбной атмосфере с активной поддержкой со стороны интервьюера, который направляет кандидата и помогает ему прийти к правильному решению.

Вопрос 1. Как удалить все числовые символы из строки 'A1B2C3', чтобы результат был 'abc'?

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

Ответ собеседника: правильный. Кандидат предложил разбить строку на массив, затем пройтись циклом, проверяя каждый символ с помощью массива цифр и метода includes. После подсказки интервьюера использовал функцию isNaN() для проверки, является ли символ числом. В итоге корректно реализовал решение с помощью for...of и isNaN, собрав новую строку из нечисловых символов.

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

В контексте Golang задача удаления числовых символов из строки решается с использованием пакета unicode и типа strings.Builder для эффективной конкатенации.

Решение с помощью unicode.IsDigit

package main

import (
"fmt"
"strings"
"unicode"
)

func removeDigits(input string) string {
var builder strings.Builder
for _, r := range input {
if !unicode.IsDigit(r) {
builder.WriteRune(r)
}
}
return builder.String()
}

func main() {
result := removeDigits("A1B2C3")
fmt.Println(result) // ABC
}

Ключевые моменты:

  • unicode.IsDigit(r) — проверяет, является ли руна цифрой (0-9 и другие Unicode-цифры)
  • strings.Builder — предпочтительный способ построения строк в цикле, так как избегает создания промежуточных строк
  • WriteRune вместо WriteString корректно обрабатывает Unicode-символы

Альтернативное решение через регулярные выражения:

import "regexp"

func removeDigitsRegex(input string) string {
re := regexp.MustCompile(`[0-9]`)
return re.ReplaceAllString(input, "")
}

Сравнение подходов:

  • Ручной цикл с unicode.IsDigit быстрее, так как не требует компиляции регулярного выражения
  • Регулярные выражения удобнее при сложных паттернах фильтрации
  • Для однократного использования цикл предпочтительнее; при многократном вызове стоит скомпилировать regexp один раз и переиспользовать

Обработка регистра: если требуется дополнительно привести к нижнему регистру ('abc'), используйте strings.ToLower(builder.String()) или применяйте unicode.ToLower(r) при записи в builder.

Вопрос 2. Что выведет код при вызове obj.show(), если внутри метода show используется setTimeout с обычной функцией callback, и внутри callback обращение к this.name? Как сделать так, чтобы вывелось имя объекта?

Таймкод: 00:05:28

Ответ собеседника: неполный. Кандидат изначально указал, что this.name внутри setTimeout будет undefined, но не смог чётко объяснить, почему this теряет контекст объекта. После подсказки интервьюера добавить console.log(this.name) внутри show, кандидат понял, что в обычной функции this теряет привязку, и предложил использовать стрелочную функцию в callback setTimeout, так как она не имеет своего this и берёт его из лексического окружения. Однако не дал полного объяснения механизма потери контекста this в обычных функциях внутри setTimeout.

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

Проблема: потеря контекста this

Рассмотрим пример:

const obj = {
name: "Object",
show: function() {
setTimeout(function() {
console.log(this.name); // undefined (или имя из globalThis)
}, 1000);
}
};

obj.show(); // undefined

Почему this теряется:

  • Обычная функция, переданная в setTimeout, вызывается как самостоятельная функция, а не как метод объекта
  • В strict mode this будет undefined, в non-strict mode — глобальный объект (window в браузере, global в Node.js)
  • setTimeout вызывает callback через callback(), а не через obj.callback(), поэтому привязка к объекту теряется

Способы решения:

1. Стрелочная функция (рекомендуемый способ)

const obj = {
name: "Object",
show: function() {
setTimeout(() => {
console.log(this.name); // "Object"
}, 1000);
}
};

Стрелочная функция не имеет собственного this, она захватывает this из лексического окружения, где была определена (в данном случае — из метода show).

2. Сохранение this в переменную (замыкание)

const obj = {
name: "Object",
show: function() {
const self = this; // или that, _this
setTimeout(function() {
console.log(self.name); // "Object"
}, 1000);
}
};

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

3. Метод bind

const obj = {
name: "Object",
show: function() {
setTimeout(function() {
console.log(this.name); // "Object"
}.bind(this), 1000);
}
};

bind создаёт новую функцию с жёстко привязанным контекстом this.

4. Использование call или apply внутри wrapper

const obj = {
name: "Object",
show: function() {
const callback = function() {
console.log(this.name);
};
setTimeout(() => callback.call(this), 1000);
}
};

Как работает this в разных контекстах:

  • Метод объекта: obj.method()this указывает на obj
  • Обычный вызов функции: func()this указывает на undefined (strict) или глобальный объект
  • Стрелочная функция: наследует this из окружения определения
  • События DOM: this указывает на элемент, вызвавший событие (для обычных функций)

Примечание для Golang-разработчика:

В Golang подобной проблемы нет, так как нет концепции this с динамической привязкой. Методы в Go всегда привязаны к конкретному экземпляру через явный receiver:

type MyStruct struct {
Name string
}

func (m *MyStruct) Show() {
go func() {
// Здесь m захватывается замыканием, аналог self = this
fmt.Println(m.Name)
}()
}

Вопрос 3. Как преобразовать вложенный массив [1,[2,[3,[4,[5,[6,[7,8,9]]]]]]] в плоский массив [1,2,3,4,5,6,7,8,9]?

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

Ответ собеседника: правильный. Кандидат предложил использовать метод flat(), но сначала не указал глубину. После подсказки передал flat(2), что сработало для данного уровня вложенности. Когда спросили про произвольную глубину, кандидат узнал, что можно передать flat(Infinity). Также реализовал альтернативный способ с использованием рекурсии и Array.isArray() для проверки вложенности.

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

1. Метод Array.prototype.flat()

Самый простой и современный способ:

const nested = [1, [2, [3, [4, [5, [6, [7, 8, 9]]]]]]];

// Для конкретной глубины
console.log(nested.flat(6)); // [1, 2, 3, 4, 5, 6, 7, 8, 9]

// Для произвольной глубины
console.log(nested.flat(Infinity)); // [1, 2, 3, 4, 5, 6, 7, 8, 9]

flat(depth) разворачивает массив на указанную глубину. По умолчанию depth = 1. Infinity позволяет обработать любой уровень вложенности.

2. Рекурсивное решение

function flatten(arr) {
const result = [];
for (const item of arr) {
if (Array.isArray(item)) {
result.push(...flatten(item));
} else {
result.push(item);
}
}
return result;
}

console.log(flatten(nested)); // [1, 2, 3, 4, 5, 6, 7, 8, 9]

3. Решение с reduce

function flattenReduce(arr) {
return arr.reduce((acc, item) => {
return acc.concat(Array.isArray(item) ? flattenReduce(item) : item);
}, []);
}

4. Решение с toString (только для чисел)

// Работает только если все элементы — числа
console.log(nested.toString().split(',').map(Number)); // [1, 2, 3, 4, 5, 6, 7, 8, 9]

Аналог в Golang:

В Go нет встроенного метода flat, но можно реализовать рекурсивно:

package main

import (
"fmt"
)

func flatten(input []interface{}) []interface{} {
var result []interface{}
for _, item := range input {
switch v := item.(type) {
case []interface{}:
result = append(result, flatten(v)...)
default:
result = append(result, v)
}
}
return result
}

func main() {
nested := []interface{}{
1,
[]interface{}{
2,
[]interface{}{
3,
[]interface{}{4, 5},
},
},
}

flat := flatten(nested)
fmt.Println(flat) // [1 2 3 4 5]
}

Сравнение подходов:

  • flat(Infinity) — самый читаемый и производительный способ в современном JavaScript
  • Рекурсия — универсальное решение, работает во всех средах
  • reduce — функциональный стиль, но менее читаемый
  • toString — хак, который работает только для примитивных типов

Вопрос 4. В каком порядке будут выведены A, B и C в коде с Promise.resolve().then(), setTimeout и обычным console.log?

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

Ответ собеседния: правильный. Кандидат объяснил, что сначала выполняется синхронный код (console.log(A)), затем микрозадачи (Promise.then выводит C), и в конце макрозадачи (setTimeout выводит B). Правильно указал порядок A, C, B и объяснил разницу между микрозадачами (Promise, async/await, fetch) и макрозадачами (setTimeout, setInterval), а также то, что микрозадачи имеют более высокий приоритет.

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

Порядок выполнения: A → C → B

Рассмотрим код:

console.log('A'); // 1. Синхронный код

setTimeout(() => {
console.log('B'); // 3. Макрозадача
}, 0);

Promise.resolve().then(() => {
console.log('C'); // 2. Микрозадача
});

Вывод:

A
C
B

Механизм Event Loop:

JavaScript использует однопоточную модель с Event Loop, который обрабатывает задачи в следующем порядке:

1. Синхронный код (Call Stack)

  • Выполняется первым, блокирует поток до завершения
  • Пример: console.log(), вызовы функций, арифметические операции

2. Микрозадачи (Microtask Queue)

  • Выполняются после синхронного кода, но до макрозадач
  • Очищается полностью перед каждым рендером
  • Источники:
    • Promise.then(), Promise.catch(), Promise.finally()
    • queueMicrotask()
    • MutationObserver
    • process.nextTick() (Node.js, высший приоритет)

3. Макрозадачи (Macrotask/Task Queue)

  • Выполняются после полной очистки микрозадач
  • Каждая итерация Event Loop обрабатывает одну макрозадачу
  • Источники:
    • setTimeout(), setInterval()
    • setImmediate() (Node.js)
    • I/O операции
    • UI рендеринг (браузер)
    • requestAnimationFrame() (браузер)

Визуализация цикла:

┌─────────────────────────┐
│ Синхронный код │ ← Выполняется первым
└───────────┬─────────────┘

┌─────────────────────────┐
│ Микрозадачи │ ← Очищается полностью
│ (Promise, queueMicro) │
└───────────┬─────────────┘

┌─────────────────────────┐
│ Макрозадача │ ← Одна за итерацию
│ (setTimeout, I/O) │
└───────────┬─────────────┘

┌─────────────────────────┐
│ Рендеринг (браузер) │
└─────────────────────────┘

└──────────→ (повтор)

Важные нюансы:

// Пример с вложенными микрозадачами
Promise.resolve()
.then(() => {
console.log('1');
Promise.resolve().then(() => console.log('2'));
})
.then(() => console.log('3'));

setTimeout(() => console.log('4'), 0);

// Вывод: 1, 2, 3, 4
// Новая микрозадача из then добавляется в очередь,
// но выполняется раньше макрозадач

Аналогия в Golang:

В Go нет Event Loop, но есть похожая концепция с горутинами и планировщиком:

package main

import (
"fmt"
"time"
)

func main() {
fmt.Println("A") // Синхронный код

// Аналог setTimeout — запускается в отдельной горутине
go func() {
time.Sleep(0) // Минимальная задержка
fmt.Println("B")
}()

// Аналог Promise — канал с немедленным выполнением
done := make(chan struct{})
go func() {
fmt.Println("C")
close(done)
}()

<-done // Ожидание завершения "микрозадачи"
time.Sleep(time.Millisecond) // Даём время макрозадаче
}

В Go порядок выполнения горутин не гарантирован без явной синхронизации (каналы, WaitGroup, Mutex).

Вопрос 5. В чём разница между промисами и async/await?

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

Ответ собеседника: правильный. Кандидат объяснил, что промисы появились для решения проблемы инверсии контроля callback-функций, позволяют использовать цепочки then/catch для обработки результатов и ошибок. Async/await — это синтаксический сахар над промисами, который делает асинхронный код похожим на синхронный. Await приостанавливает выполнение async-функции до разрешения промиса. Кандидат также отметил, что промисы лучше подходят для параллельного выполнения запросов, а async/await — для последовательных зависимых операций.

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

Promise — это объект, представляющий результат асинхронной операции. Может находиться в одном из трёх состояний: pending, fulfilled, rejected.

async/await — синтаксический сахар над промисами, введённый в ES2017, который позволяет писать асинхронный код в синхронном стиле.

Сравнение подходов:

1. Базовый синтаксис

// Promise
function fetchUser(id) {
return fetch(`/api/users/${id}`)
.then(response => response.json())
.then(user => {
console.log(user);
return user;
})
.catch(error => {
console.error('Error:', error);
throw error;
});
}

// async/await
async function fetchUser(id) {
try {
const response = await fetch(`/api/users/${id}`);
const user = await response.json();
console.log(user);
return user;
} catch (error) {
console.error('Error:', error);
throw error;
}
}

2. Параллельное выполнение

// Promise.all — параллельный запрос
async function fetchMultipleUsers(ids) {
// Параллельно через Promise
const promises = ids.map(id => fetch(`/api/users/${id}`).then(r => r.json()));
const users = await Promise.all(promises);

// Или через async/await с Promise.all
const users2 = await Promise.all(
ids.map(async (id) => {
const response = await fetch(`/api/users/${id}`);
return response.json();
})
);

return users;
}

3. Последовательное выполнение

// Через Promise — цепочка then
function fetchWithDependencies() {
return fetchUser(1)
.then(user => fetchUserPosts(user.id))
.then(posts => fetchPostComments(posts[0].id))
.then(comments => console.log(comments));
}

// Через async/await — читаемый последовательный код
async function fetchWithDependencies() {
const user = await fetchUser(1);
const posts = await fetchUserPosts(user.id);
const comments = await fetchPostComments(posts[0].id);
console.log(comments);
}

4. Обработка ошибок

// Promise — цепочка catch
fetchData()
.then(processData)
.then(saveData)
.catch(handleError) // Ловит ошибку из любого then
.finally(cleanup); // Выполняется всегда

// async/await — try/catch/finally
async function handleData() {
try {
const data = await fetchData();
const processed = await processData(data);
await saveData(processed);
} catch (error) {
handleError(error); // Ловит ошибку из любого await
} finally {
cleanup();
}
}

5. Условная логика

// Promise — сложная вложенность
function conditionalFetch(useCache) {
return Promise.resolve()
.then(() => {
if (useCache) {
return getFromCache();
}
return null;
})
.then(cached => {
if (cached) return cached;
return fetchFromServer();
});
}

// async/await — линейный код
async function conditionalFetch(useCache) {
if (useCache) {
const cached = await getFromCache();
if (cached) return cached;
}
return fetchFromServer();
}

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

Promise лучше подходит для:

  • Параллельного выполнения независимых операций (Promise.all, Promise.race)
  • Создания переиспользуемых асинхронных утилит
  • Сценариев с динамическими цепочками вызовов

async/await лучше подходит для:

  • Последовательных зависимых операций
  • Сложной условной логики
  • Циклов с асинхронными операциями
  • Код, который должен читаться как синхронный

Аналогия в Golang:

В Go нет промисов в классическом понимании, но каналы и горутины решают те же задачи:

// Аналог Promise — канал для результата
func fetchUser(id int) <-chan User {
ch := make(chan User, 1)
go func() {
user := // ... HTTP запрос
ch <- user
}()
return ch
}

// Аналог async/await — чтение из канала
func main() {
user := <-fetchUser(1) // Блокирует до получения результата
posts := <-fetchUserPosts(user.ID)
fmt.Println(posts)
}

// Аналог Promise.all — sync.WaitGroup
func fetchMultiple(ids []int) []User {
var wg sync.WaitGroup
users := make([]User, len(ids))

for i, id := range ids {
wg.Add(1)
go func(idx, userID int) {
defer wg.Done()
users[idx] = <-fetchUser(userID)
}(i, id)
}

wg.Wait()
return users
}

Вопрос 6. Что такое Virtual DOM в React и чем он отличается от реального DOM?

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

Ответ собеседника: правильный. Кандидат объяснил, что Virtual DOM — это легковесная копия реального DOM в памяти. При изменении состояния React создаёт новую копию Virtual DOM, сравнивает её с предыдущей с помощью алгоритма diffing (reconciliation), и применяет только необходимые минимальные изменения к реальному DOM. Это делает обновления быстрее, так как реальный DOM обновляется целиком при каждом изменении, что дорого. Также кандидат упомянул Fiber-архитектуру React для reconciliation.

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

Virtual DOM — это JavaScript-объект, представляющий структуру DOM-дерева в памяти. Это легковесная копия реального DOM, с которой React работает для оптимизации обновлений.

Реальный DOM vs Virtual DOM:

Реальный DOM (Document Object Model):

  • Дерево объектов, представляющее HTML-документ в браузере
  • Каждое изменение вызывает reflow (пересчёт позиций) и repaint (перерисовку)
  • Операции дорогие: создание, удаление, изменение узлов
  • Синхронные операции блокируют рендеринг

Virtual DOM:

  • Обычный JavaScript-объект в памяти
  • Быстрое создание и сравнение объектов
  • Изменения не влияют на отображение до применения
  • Пакетное обновление реального DOM

Как работает процесс:

1. Рендеринг компонента

function App() {
const [count, setCount] = useState(0);

return (
<div className="app">
<h1>Count: {count}</h1>
<button onClick={() => setCount(count + 1)}>+</button>
</div>
);
}

React создаёт Virtual DOM:

{
type: 'div',
props: { className: 'app' },
children: [
{
type: 'h1',
props: {},
children: ['Count: 0']
},
{
type: 'button',
props: { onClick: handleClick },
children: ['+']
}
]
}

2. Процесс Reconciliation (согласование)

State Change


┌─────────────────────┐
│ New Virtual DOM │ ← Создаётся при изменении state
└─────────┬───────────┘


┌─────────────────────┐
│ Diff Algorithm │ ← Сравнение с предыдущим Virtual DOM
│ (Reconciliation) │
└─────────┬───────────┘


┌─────────────────────┐
│ Minimal Updates │ ← Вычисляется минимальный набор изменений
└─────────┬───────────┘


┌─────────────────────┐
│ Real DOM Update │ ← Применяются только необходимые изменения
└─────────────────────┘

3. Алгоритм Diffing:

React использует эвристический алгоритм O(n) с двумя ключевыми допущениями:

  • Элементы разных типов создают разные деревья
  • Ключ (key) помогает идентифицировать стабильные элементы при перестановке
// Плохо: без ключей React не может отследить перестановку
{items.map(item => <li>{item.name}</li>)}

// Хорошо: с ключами React эффективно обновляет порядок
{items.map(item => <li key={item.id}>{item.name}</li>)}

Fiber Architecture (React 16+):

Fiber — переписанная внутренняя реализация reconciliation, которая позволяет:

  • Приоритизацию обновлений — пользовательский ввод важнее фоновых задач
  • Паузу и возобновление работы — рендеринг можно прервать для более важных задач
  • Деление на чанки — работа разбивается на маленькие части, не блокируя main thread
  • Параллельный рендеринг — React 18+ может готовить несколько версий UI одновременно

Пример приоритизации:

function SearchResults() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);

// Высокий приоритет: обновление поля ввода
const handleChange = (e) => {
setQuery(e.target.value); // Немедленное обновление UI
};

// Низкий приоритет: обновление результатов поиска
useEffect(() => {
fetchResults(query).then(data => {
setResults(data); // Может быть отложено
});
}, [query]);

return (
<>
<input value={query} onChange={handleChange} />
<ResultsList results={results} />
</>
);
}

Сравнение производительности:

// Прямое манипулирование DOM — медленно
for (let i = 0; i < 1000; i++) {
const div = document.createElement('div');
div.textContent = `Item ${i}`;
document.body.appendChild(div); // 1000 reflow!
}

// React Virtual DOM — быстро
function List() {
return (
<>
{Array.from({ length: 1000 }, (_, i) => (
<div key={i}>Item {i}</div>
))}
</>
);
// React может батчить обновления и оптимизировать
}

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

Virtual DOM — это не магическая оптимизация, а паттерн, который:

  • Упрощает разработку (декларативный UI)
  • Позволяет эффективно вычислять минимальные изменения
  • Абстрагирует работу с DOM от разработчика

Стоимость Virtual DOM — дополнительное потребление памяти и время на diffing, но это компенсируется снижением дорогих операций с реальным DOM.

Вопрос 7. Когда следует использовать useMemo в React?

Таймкод: 00:17:02

Ответ собеседника: правильный. Кандидат объяснил, что useMemo используется для мемоизации значений, чтобы избежать повторных вычислений дорогостоящих операций при каждом рендере. Если входные данные не изменились, возвращается закэшированное значение. Это полезно для сложных математических вычислений и оптимизации производительности.

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

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

Сигнатура:

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

Основные сценарии использования:

1. Дорогостоящие вычисления

function ProductList({ products, filter }) {
// Без useMemo: фильтрация при каждом рендере
// const filtered = products.filter(p => p.category === filter);

// С useMemo: фильтрация только при изменении products или filter
const filtered = useMemo(() => {
console.log('Filtering products...');
return products.filter(p => p.category === filter);
}, [products, filter]);

return (
<ul>
{filtered.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}

2. Стабильные ссылки на объекты для дочерних компонентов

function Parent() {
const [count, setCount] = useState(0);

// Без useMemo: новый объект при каждом рендере → Child перерендеривается
// const config = { theme: 'dark', locale: 'ru' };

// С useMemo: стабильная ссылка, если зависимости не изменились
const config = useMemo(() => ({
theme: 'dark',
locale: 'ru'
}), []); // Пустой массив зависимостей — объект создаётся один раз

return <Child config={config} />;
}

// Child обёрнут в React.memo для предотвращения лишних рендеров
const Child = React.memo(({ config }) => {
return <div className={config.theme}>Content</div>;
});

3. Зависости для других хуков

function DataFetcher({ userId }) {
const queryParams = useMemo(() => ({
userId,
include: ['profile', 'posts'],
limit: 100
}), [userId]);

// useEffect зависит от queryParams
// Без useMemo: новый объект при каждом рендере → бесконечный цикл запросов
useEffect(() => {
fetchData(queryParams).then(setData);
}, [queryParams]);

return <div>{/* render data */}</div>;
}

4. Сложные преобразования данных

function Dashboard({ rawData }) {
const chartData = useMemo(() => {
return rawData
.filter(item => item.isActive)
.map(item => ({
x: new Date(item.timestamp),
y: item.value,
label: item.name
}))
.sort((a, b) => a.x - b.x);
}, [rawData]);

return <Chart data={chartData} />;
}

Когда НЕ использовать useMemo:

function Component({ a, b }) {
// Избыточно: простая арифметика — дешёвая операция
const sum = useMemo(() => a + b, [a, b]);

// Лучше без мемоизации:
const sum = a + b;

// Избыточно: примитивные значения сравниваются по значению
const fullName = useMemo(() => `${firstName} ${lastName}`, [firstName, lastName]);

// Лучше без мемоизации:
const fullName = `${firstName} ${lastName}`;
}

useMemo vs useCallback:

// useMemo мемоизирует РЕЗУЛЬТАТ функции
const memoizedValue = useMemo(() => computeValue(a, b), [a, b]);

// useCallback мемоизирует САМУ ФУНКЦИЮ (эквивалент useMemo для функций)
const memoizedCallback = useCallback(() => {
doSomething(a, b);
}, [a, b]);

// useCallback — это синтаксический сахар:
const memoizedCallback = useMemo(() => {
return () => doSomething(a, b);
}, [a, b]);

Правила использования:

  • Используйте useMemo для дорогих вычислений (сортировка больших массивов, сложные математические операции)
  • Используйте для стабилизации ссылок на объекты/массивы, передаваемые в React.memo компоненты
  • Используйте для создания зависимостей для useEffect, useCallback, других useMemo
  • НЕ используйте для простых вычислений — overhead мемоизации дороже
  • НЕ используйте как гарантию — React может сбросить кэш при необходимости

Аналогия в Golang:

В Go нет встроенной мемоизации, но можно реализовать вручную:

type Memoize struct {
cache map[string]interface{}
mu sync.RWMutex
}

func (m *Memoize) Get(key string, compute func() interface{}) interface{} {
m.mu.RLock()
if val, ok := m.cache[key]; ok {
m.mu.RUnlock()
return val
}
m.mu.RUnlock()

m.mu.Lock()
defer m.mu.Unlock()

// Double-check после получения write lock
if val, ok := m.cache[key]; ok {
return val
}

val := compute()
m.cache[key] = val
return val
}

Вопрос 8. Выполнялись ли синхронные операции в Redux и как обрабатываются асинхронные операции?

Таймкод: 00:17:31

Ответ собеседника: неполный. Кандидат сообщил, что сам не выполнял синхронные операции в Redux, но знает, что для асинхронных операций используется middleware под названием thunk. Ответ неполный, так как кандидат не объяснил, что Redux по умолчанию синхронный, а thunk позволяет возвращать функции вместо объектов действий для выполнения асинхронных операций.

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

Redux по умолчанию полностью синхронный. Store принимает action, вызывает reducer, обновляет state — всё происходит мгновенно и синхронно.

Стандартный поток Redux:

dispatch(action) → middleware → reducer → new state → notify subscribers
// Синхронный action — обычный объект
const increment = () => ({ type: 'INCREMENT' });

// Синхронный reducer — чистая функция
const counterReducer = (state = 0, action) => {
switch (action.type) {
case 'INCREMENT':
return state + 1; // Синхронное вычисление
default:
return state;
}
};

store.dispatch(increment()); // Сразу обновляет state
console.log(store.getState()); // 1

Проблема с асинхронностью:

Redux требует, чтобы action был простым объектом, а reducer — чистой функцией. Асинхронные операции (API запросы, таймеры) нарушают эти правила.

Решение: Middleware

Middleware перехватывает actions до их попадания в reducer, позволяя выполнять побочные эффекты.

1. Redux Thunk (самый популярный)

Thunk позволяет диспатчить функции вместо объектов:

// Action creator возвращает функцию вместо объекта
const fetchUser = (userId) => {
return async (dispatch, getState) => {
// Начало загрузки
dispatch({ type: 'FETCH_USER_START' });

try {
const response = await fetch(`/api/users/${userId}`);
const user = await response.json();

// Успешный результат
dispatch({ type: 'FETCH_USER_SUCCESS', payload: user });
} catch (error) {
// Ошибка
dispatch({ type: 'FETCH_USER_ERROR', payload: error.message });
}
};
};

// Использование
store.dispatch(fetchUser(123));

Цепочка thunk actions:

const fetchUserWithPosts = (userId) => {
return async (dispatch) => {
// Последовательные запросы
dispatch({ type: 'FETCH_START' });

const user = await dispatch(fetchUser(userId));
const posts = await dispatch(fetchPosts(user.id));
const comments = await dispatch(fetchComments(posts[0].id));

dispatch({ type: 'FETCH_COMPLETE' });
};
};

2. Redux Saga (на основе генераторов)

import { call, put, takeEvery } from 'redux-saga/effects';

// Worker saga
function* fetchUserSaga(action) {
try {
const user = yield call(api.fetchUser, action.payload.userId);
yield put({ type: 'FETCH_USER_SUCCESS', payload: user });
} catch (error) {
yield put({ type: 'FETCH_USER_ERROR', payload: error.message });
}
}

// Watcher saga
function* watchFetchUser() {
yield takeEvery('FETCH_USER_REQUEST', fetchUserSaga);
}

3. Redux Observable (на RxJS)

const fetchUserEpic = (action$) =>
action$.pipe(
ofType('FETCH_USER_REQUEST'),
mergeMap(action =>
ajax.getJSON(`/api/users/${action.payload.userId}`).pipe(
map(response => ({
type: 'FETCH_USER_SUCCESS',
payload: response
})),
catchError(error => of({
type: 'FETCH_USER_ERROR',
payload: error.message
}))
)
)
);

Сравнение middleware:

КритерийThunkSagaObservable
Кривая обученияНизкаяВысокаяВысокая
Сложность тестированияСредняяВысокаяВысокая
Отмена запросовРучнаяВстроеннаяВстроенная
Сложные потокиСложноЛегкоЛегко
Размер бандлаМаленькийСреднийБольшой

Redux Toolkit (современный подход):

Redux Toolkit включает thunk по умолчанию и предоставляет createAsyncThunk:

import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';

// Автоматически создаёт actions: pending/fulfilled/rejected
export const fetchUser = createAsyncThunk(
'users/fetchUser',
async (userId, { rejectWithValue }) => {
try {
const response = await fetch(`/api/users/${userId}`);
return await response.json();
} catch (error) {
return rejectWithValue(error.message);
}
}
);

const usersSlice = createSlice({
name: 'users',
initialState: { entities: {}, loading: 'idle', error: null },
extraReducers: (builder) => {
builder
.addCase(fetchUser.pending, (state) => {
state.loading = 'loading';
})
.addCase(fetchUser.fulfilled, (state, action) => {
state.loading = 'idle';
state.entities[action.payload.id] = action.payload;
})
.addCase(fetchUser.rejected, (state, action) => {
state.loading = 'idle';
state.error = action.payload;
});
}
});

Аналогия в Golang:

В Go нет Redux, но паттерн похож на обработку сообщений через каналы:

type Action struct {
Type string
Payload interface{}
}

type Store struct {
state map[string]interface{}
ch chan Action
done chan struct{}
}

func NewStore(reducer func(map[string]interface{}, Action) map[string]interface{}) *Store {
s := &Store{
state: make(map[string]interface{}),
ch: make(chan Action, 100),
done: make(chan struct{}),
}
go s.loop(reducer)
return s
}

func (s *Store) loop(reducer func(map[string]interface{}, Action) map[string]interface{}) {
for {
select {
case action := <-s.ch:
s.state = reducer(s.state, action)
case <-s.done:
return
}
}
}

// Dispatch — синхронная отправка в канал
func (s *Store) Dispatch(action Action) {
s.ch <- action
}

// Асинхронный thunk-аналог
func (s *Store) DispatchAsync(fn func() Action) {
go func() {
action := fn()
s.ch <- action
}()
}

Вопрос 9. Какие существуют способы улучшения производительности React-приложений?

Таймкод: 00:17:51

Ответ собеседника: правильный. Кандидат назвал несколько способов: использование CDN и кэширования для статических изображений, сжатие изображений, code splitting с lazy loading и Suspense для разделения кода, виртуализация для больших списков (рендеринг только видимых элементов), предотвращение ненужных изменений состояния, использование React.memo для предотвращения лишних рендеров дочерних компонентов, а также избегание создания inline-функций при передаче в props.

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

1. Code Splitting и Lazy Loading

Разделение бандла на части для загрузки по требованию:

import { lazy, Suspense } from 'react';

// Компонент загружается только при первом обращении
const HeavyComponent = lazy(() => import('./HeavyComponent'));

function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<HeavyComponent />
</Suspense>
);
}

// React Router с lazy loading
const routes = [
{
path: '/dashboard',
component: lazy(() => import('./pages/Dashboard'))
},
{
path: '/settings',
component: lazy(() => import('./pages/Settings'))
}
];

2. Мемоизация компонентов

// React.memo — предотвращает рендер при неизменных props
const ExpensiveComponent = React.memo(({ data, onAction }) => {
return <div>{/* сложный рендер */}</div>;
}, (prevProps, nextProps) => {
// Кастомная функция сравнения (опционально)
return prevProps.data.id === nextProps.data.id;
});

// Использование с мемоизированными callbacks
function Parent() {
const [count, setCount] = useState(0);

// useCallback сохраняет ссылку на функцию
const handleAction = useCallback(() => {
// логика действия
}, []); // Зависимости не меняются → функция стабильна

return <ExpensiveComponent data={data} onAction={handleAction} />;
}

3. Виртуализация списков

Рендеринг только видимых элементов:

import { FixedSizeList } from 'react-window';

// Без виртуализации: рендер всех 10000 элементов
function SlowList({ items }) {
return (
<div style={{ height: 600, overflow: 'auto' }}>
{items.map((item, index) => (
<div key={index} style={{ height: 50 }}>
{item.name}
</div>
))}
</div>
);
}

// С виртуализацией: рендер ~20 видимых элементов
function FastList({ items }) {
const Row = ({ index, style }) => (
<div style={style}>{items[index].name}</div>
);

return (
<FixedSizeList
height={600}
itemCount={items.length}
itemSize={50}
width="100%"
>
{Row}
</FixedSizeList>
);
}

4. Оптимизация состояния

// Плохо: лишние рендеры при обновлении неиспользуемого поля
const [state, setState] = useState({
user: null,
posts: [],
comments: [],
settings: {}
});

// Лучше: разделение состояния
const [user, setUser] = useState(null);
const [posts, setPosts] = useState([]);
const [comments, setComments] = useState([]);
const [settings, setSettings] = useState({});

// Плобо: вложенные объекты — нужно копировать полностью
setState(prev => ({
...prev,
user: { ...prev.user, name: 'New Name' }
}));

// Лучше: использовать useReducer для сложного состояния
const [state, dispatch] = useReducer(reducer, initialState);

5. Web Workers для тяжёлых вычислений

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

// Component.jsx
function Component() {
const [result, setResult] = useState(null);

useEffect(() => {
const worker = new Worker(new URL('./worker.js', import.meta.url));
worker.postMessage(largeData);
worker.onmessage = (e) => setResult(e.data);

return () => worker.terminate();
}, []);
}

6. Оптимизация рендеринга

// Плохо: inline-функции создают новые ссылки при каждом рендере
<Child onClick={() => handleClick(id)} />
<Child style={{ color: 'red' }} />

// Лучше: стабильные ссылки
const handleChildClick = useCallback(() => handleClick(id), [id]);
const childStyle = useMemo(() => ({ color: 'red' }), []);

<Child onClick={handleChildClick} style={childStyle} />

// Плохо: условный рендер с потерей состояния
{isVisible && <ExpensiveComponent />}

// Лучше: скрытие через CSS сохраняет состояние
<div style={{ display: isVisible ? 'block' : 'none' }}>
<ExpensiveComponent />
</div>

7. Профилирование и отладка

import { Profiler } from 'react';

function onRenderCallback(
id, // id компонента
phase, // "mount" | "update" | "nested-update"
actualDuration, // время рендера
baseDuration, // время рендера без мемоизации
startTime,
commitTime
) {
console.log({ id, phase, actualDuration, baseDuration });
}

<Profiler id="App" onRender={onRenderCallback}>
<App />
</Profiler>

8. Оптимизация ресурсов

// Ленивая загрузка изображений
<img src="image.jpg" loading="lazy" alt="description" />

// Оптимизация изображений
<picture>
<source srcSet="image.webp" type="image/webp" />
<source srcSet="image.jpg" type="image/jpeg" />
<img src="image.jpg" alt="description" />
</picture>

// Preload критических ресурсов
<link rel="preload" href="/fonts/main.woff2" as="font" crossorigin />

Чеклист оптимизации:

  • Code splitting на уровне маршрутов и компонентов
  • Виртуализация длинных списков
  • Мемоизация дорогих вычислений и компонентов
  • Стабильные ссылки для callbacks и объектов
  • Разделение состояния на независимые части
  • Профилирование через React DevTools
  • Оптимизация изображений и шрифтов
  • Web Workers для тяжёлых вычислений

Вопрос 10. Реализовать компонент Accordion в React, где при клике на элемент открывается его содержимое, а предыдущий открытый элемент закрывается

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

Ответ собеседника: неполный. Кандидат начал реализацию: создал структуру с использованием data.map для итерации по элементам, добавил заголовки в тегах, использовал key с index для списка, создал состояние (useState) для управления открытым элементом с начальным значением null. Также добавил обработчик onClick. Однако реализация не была завершена — кандидат не закончил условный рендеринг содержимого и логику переключения состояния.

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

Базовая реализация Accordion:

import { useState } from 'react';

const data = [
{
id: 1,
title: 'Section 1',
content: 'Content for section 1'
},
{
id: 2,
title: 'Section 2',
content: 'Content for section 2'
},
{
id: 3,
title: 'Section 3',
content: 'Content for section 3'
}
];

function Accordion() {
// Храним ID открытого элемента (null = ничего не открыто)
const [openId, setOpenId] = useState(null);

const handleToggle = (id) => {
// Если кликнули на открытый — закрываем, иначе открываем новый
setOpenId(openId === id ? null : id);
};

return (
<div className="accordion">
{data.map((item) => (
<div key={item.id} className="accordion-item">
<button
className="accordion-header"
onClick={() => handleToggle(item.id)}
aria-expanded={openId === item.id}
>
{item.title}
<span>{openId === item.id ? '−' : '+'}</span>
</button>

{openId === item.id && (
<div className="accordion-content">
{item.content}
</div>
)}
</div>
))}
</div>
);
}

Улучшенная версия с анимацией:

import { useState, useRef, useEffect } from 'react';

function AccordionWithAnimation() {
const [openId, setOpenId] = useState(null);
const contentRefs = useRef({});

const handleToggle = (id) => {
setOpenId(openId === id ? null : id);
};

return (
<div className="accordion">
{data.map((item) => {
const isOpen = openId === item.id;

return (
<div key={item.id} className="accordion-item">
<button
className={`accordion-header ${isOpen ? 'active' : ''}`}
onClick={() => handleToggle(item.id)}
aria-expanded={isOpen}
aria-controls={`content-${item.id}`}
>
{item.title}
<span className={`icon ${isOpen ? 'open' : ''}`}>

</span>
</button>

<div
id={`content-${item.id}`}
className={`accordion-content ${isOpen ? 'open' : ''}`}
style={{
maxHeight: isOpen
? contentRefs.current[item.id]?.scrollHeight + 'px'
: '0',
overflow: 'hidden',
transition: 'max-height 0.3s ease-out'
}}
ref={el => contentRefs.current[item.id] = el}
>
<div className="accordion-body">
{item.content}
</div>
</div>
</div>
);
})}
</div>
);
}

Версия с множественным открытием (опционально):

function AccordionMultiOpen() {
const [openIds, setOpenIds] = useState(new Set());

const handleToggle = (id) => {
setOpenIds(prev => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id); // Закрываем
} else {
newSet.add(id); // Открываем
}
return newSet;
});
};

return (
<div className="accordion">
{data.map((item) => {
const isOpen = openIds.has(item.id);

return (
<div key={item.id} className="accordion-item">
<button
className="accordion-header"
onClick={() => handleToggle(item.id)}
>
{item.title}
</button>
{isOpen && (
<div className="accordion-content">
{item.content}
</div>
)}
</div>
);
})}
</div>
);
}

Выделенный AccordionItem компонент:

function AccordionItem({ item, isOpen, onToggle }) {
return (
<div className={`accordion-item ${isOpen ? 'active' : ''}`}>
<button
className="accordion-header"
onClick={onToggle}
aria-expanded={isOpen}
>
{item.title}
<ChevronIcon isOpen={isOpen} />
</button>

<div className={`accordion-content ${isOpen ? 'open' : ''}`}>
{item.content}
</div>
</div>
);
}

function ChevronIcon({ isOpen }) {
return (
<svg
className={`chevron ${isOpen ? 'open' : ''}`}
viewBox="0 0 24 24"
width="24"
height="24"
>
<path d="M7 10l5 5 5-5z" />
</svg>
);
}

function Accordion({ items, allowMultiple = false }) {
const [openIds, setOpenIds] = useState(
allowMultiple ? new Set() : null
);

const handleToggle = (id) => {
if (allowMultiple) {
setOpenIds(prev => {
const newSet = new Set(prev);
newSet.has(id) ? newSet.delete(id) : newSet.add(id);
return newSet;
});
} else {
setOpenIds(openIds === id ? null : id);
}
};

const isOpen = (id) => allowMultiple
? openIds.has(id)
: openIds === id;

return (
<div className="accordion">
{items.map(item => (
<AccordionItem
key={item.id}
item={item}
isOpen={isOpen(item.id)}
onToggle={() => handleToggle(item.id)}
/>
))}
</div>
);
}

CSS для базовой стилизации:

.accordion {
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
}

.accordion-item {
border-bottom: 1px solid #ddd;
}

.accordion-item:last-child {
border-bottom: none;
}

.accordion-header {
width: 100%;
padding: 16px;
display: flex;
justify-content: space-between;
align-items: center;
background: #f8f9fa;
border: none;
cursor: pointer;
font-size: 16px;
transition: background 0.2s;
}

.accordion-header:hover {
background: #e9ecef;
}

.accordion-header.active {
background: #dee2e6;
}

.accordion-content {
padding: 16px;
background: white;
}

.icon {
transition: transform 0.3s;
}

.icon.open {
transform: rotate(180deg);
}

Ключевые моменты реализации:

  • Хранение ID открытого элемента в состоянии (null означает "ничего не открыто")
  • Логика toggle: setOpenId(openId === id ? null : id) — закрывает при повторном клике
  • Условный рендеринг содержимого: {isOpen && <Content />}
  • Использование aria-expanded для доступности
  • Анимация через CSS transition на maxHeight
  • Разделение на компоненты для переиспользования