Когда фронтенд перестаёт быть игрушкой: пишем собственный реактивный движок на JavaScript
- суббота, 14 марта 2026 г. в 00:00:04
Почти каждый фронтенд-разработчик однажды задаётся вопросом: что на самом деле происходит внутри современных фреймворков. Почему изменение переменной автоматически обновляет интерфейс? Как библиотека понимает, что именно нужно перерисовать?

Можно бесконечно читать документацию, но лучший способ понять — написать минимальную реактивную систему самостоятельно. Не игрушечный пример из десяти строк, а маленький, но рабочий движок.
В процессе мы реализуем:
систему реактивных данных
отслеживание зависимостей
механизм эффектов
примитивный виртуальный DOM
автоматическое обновление интерфейса
Это не будет полноценной заменой Vue или React. Но после такой практики внутренности этих библиотек становятся гораздо понятнее.
Наша задача: когда значение меняется — автоматически запускать функции, которые от него зависят.
Создадим реактивный объект.
function reactive(target) { return new Proxy(target, { get(obj, key) { track(obj, key) return obj[key] }, set(obj, key, value) { obj[key] = value trigger(obj, key) return true } }) }
Теперь нужны две функции: одна будет запоминать зависимости, вторая — запускать эффекты.
Создадим структуру, которая хранит зависимости.
const targetMap = new WeakMap() function track(target, key) { if (!activeEffect) return let depsMap = targetMap.get(target) if (!depsMap) { depsMap = new Map() targetMap.set(target, depsMap) } let dep = depsMap.get(key) if (!dep) { dep = new Set() depsMap.set(key, dep) } dep.add(activeEffect) }
Объяснение:
WeakMap хранит зависимости для объектов
Map хранит зависимости для конкретных свойств
Set хранит функции, которые нужно запускать
Структура получается такой:
WeakMap -> объект -> Map -> ключ -> Set эффектов
Это почти та же схема, которую используют современные фреймворки.
Теперь нужно вызывать функции, когда данные меняются.
function trigger(target, key) { const depsMap = targetMap.get(target) if (!depsMap) return const dep = depsMap.get(key) if (!dep) return dep.forEach(effect => effect()) }
Каждый эффект — обычная функция.
Добавим механизм регистрации эффекта.
let activeEffect = null function effect(fn) { activeEffect = fn fn() activeEffect = null }
Теперь проверим.
const state = reactive({ count: 0 }) effect(() => { console.log("count changed:", state.count) }) state.count++ state.count++
Вывод:
count changed: 0 count changed: 1 count changed: 2
Мы получили простейшую реактивность.
Если оставить систему в таком виде, она быстро начнёт вести себя странно.
Например:
effect(() => { console.log(state.count) console.log(state.count) })
Функция добавится в зависимости дважды.
Исправим это, добавив очистку зависимостей.
Добавим хранение зависимостей внутри самого эффекта.
function effect(fn) { const effectFn = () => { cleanup(effectFn) activeEffect = effectFn fn() activeEffect = null } effectFn.deps = [] effectFn() }
Функция очистки:
function cleanup(effectFn) { for (const dep of effectFn.deps) { dep.delete(effectFn) } effectFn.deps.length = 0 }
Теперь изменим track.
function track(target, key) { if (!activeEffect) return let depsMap = targetMap.get(target) if (!depsMap) { depsMap = new Map() targetMap.set(target, depsMap) } let dep = depsMap.get(key) if (!dep) { dep = new Set() depsMap.set(key, dep) } dep.add(activeEffect) activeEffect.deps.push(dep) }
Теперь зависимости всегда актуальны.
Современные фреймворки позволяют создавать вычисляемые значения.
Сделаем простой вариант.
function computed(getter) { let value let dirty = true const runner = effect(() => { value = getter() }) return { get value() { if (dirty) { runner() dirty = false } return value } } }
Пример использования:
const price = reactive({ value: 100, tax: 0.2 }) const total = computed(() => { return price.value * (1 + price.tax) }) console.log(total.value)
Теперь попробуем связать реактивность с DOM.
function render() { document.getElementById("app").innerHTML = ` <h1>${state.count}</h1> ` } effect(render)
Кнопка увеличения:
document.getElementById("btn").onclick = () => { state.count++ }
Теперь при изменении count интерфейс автоматически обновляется.
Без фреймворка.
Мы написали всего около 100 строк кода, но уже получили:
реактивные данные
отслеживание зависимостей
автоматический запуск эффектов
вычисляемые значения
реактивный интерфейс
Однако реальные фреймворки добавляют ещё много уровней оптимизации:
батчинг обновлений
планировщик задач
виртуальный DOM
диффинг деревьев
серверный рендеринг
асинхронные эффекты
Например, в Vue реактивная система — это тысячи строк кода, потому что нужно учитывать десятки крайних случаев.
Но фундамент остаётся тем же: зависимости, эффекты и триггеры.
После такой практики становится понятно:
почему некоторые паттерны в React выглядят странно
зачем во Vue существуют watch и computed
как устроен механизм ререндеринга
И самое интересное — многие разработчики обнаруживают, что для небольших проектов им вообще не нужен тяжёлый фреймворк. Иногда достаточно нескольких десятков строк реактивности.
Если интересно, можно пойти ещё дальше и реализовать:
собственный виртуальный DOM
систему компонентов
асинхронный планировщик обновлений
И внезапно окажется, что маленькая учебная библиотека постепенно превращается в полноценный фронтенд-фреймворк.
Именно так когда-то начинались многие из тех инструментов, которыми мы пользуемся сегодня.
В завершении хочу задать несколько вопросов тем, кто активно пишет фронтенд.
После того как я разобрался, как работает реактивность внутри фреймворков, у меня появился странный вопрос.
Почему для огромного количества проектов мы продолжаем тянуть библиотеки на десятки тысяч строк кода, если фундаментальная механика умещается в сотню?
Понятно, что есть оптимизации, SSR, экосистема и куча edge-кейсов. Но если убрать всё это — основа остаётся довольно простой.
Иногда создаётся ощущение, что современный фронтенд стал сложным не потому, что задачи сложные, а потому что инструменты эволюционировали быстрее, чем необходимость в них.
Поэтому интересно услышать мнение сообщества.
В каких проектах вы реально чувствовали, что без полноценного фреймворка невозможно обойтись?
И второй вопрос, который меня давно мучает.
Если бы сегодня не существовало ни React, ни Vue, ни Svelte — какой минимальный набор инструментов вы бы использовали для современного фронтенда?
Очень любопытно почитать опыт разных программеров. Обычно именно в таких обсуждениях всплывают самые интересные инженерные решения.