javascript

Когда фронтенд перестаёт быть игрушкой: пишем собственный реактивный движок на JavaScript

  • суббота, 14 марта 2026 г. в 00:00:04
https://habr.com/ru/articles/1009790/

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

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

В процессе мы реализуем:

  • систему реактивных данных

  • отслеживание зависимостей

  • механизм эффектов

  • примитивный виртуальный DOM

  • автоматическое обновление интерфейса

Это не будет полноценной заменой Vue или React. Но после такой практики внутренности этих библиотек становятся гораздо понятнее.


Шаг 1. Простая реактивность через Proxy

Наша задача: когда значение меняется — автоматически запускать функции, которые от него зависят.

Создадим реактивный объект.

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
    }
  })
}

Теперь нужны две функции: одна будет запоминать зависимости, вторая — запускать эффекты.


Шаг 2. Система зависимостей

Создадим структуру, которая хранит зависимости.

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 эффектов

Это почти та же схема, которую используют современные фреймворки.


Шаг 3. Запуск эффектов

Теперь нужно вызывать функции, когда данные меняются.

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

Мы получили простейшую реактивность.


Шаг 4. Почему наивная реализация ломается

Если оставить систему в таком виде, она быстро начнёт вести себя странно.

Например:

effect(() => {
  console.log(state.count)
  console.log(state.count)
})

Функция добавится в зависимости дважды.

Исправим это, добавив очистку зависимостей.


Шаг 5. Управление зависимостями эффекта

Добавим хранение зависимостей внутри самого эффекта.

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)
}

Теперь зависимости всегда актуальны.


Шаг 6. Добавим вычисляемые значения

Современные фреймворки позволяют создавать вычисляемые значения.

Сделаем простой вариант.

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)

Шаг 7. Минимальный рендеринг интерфейса

Теперь попробуем связать реактивность с DOM.

function render() {
  document.getElementById("app").innerHTML = `
    <h1>${state.count}</h1>
  `
}

effect(render)

Кнопка увеличения:

document.getElementById("btn").onclick = () => {
  state.count++
}

Теперь при изменении count интерфейс автоматически обновляется.

Без фреймворка.


Шаг 8. Почему современные фреймворки намного сложнее

Мы написали всего около 100 строк кода, но уже получили:

  • реактивные данные

  • отслеживание зависимостей

  • автоматический запуск эффектов

  • вычисляемые значения

  • реактивный интерфейс

Однако реальные фреймворки добавляют ещё много уровней оптимизации:

  • батчинг обновлений

  • планировщик задач

  • виртуальный DOM

  • диффинг деревьев

  • серверный рендеринг

  • асинхронные эффекты

Например, в Vue реактивная система — это тысячи строк кода, потому что нужно учитывать десятки крайних случаев.

Но фундамент остаётся тем же: зависимости, эффекты и триггеры.


Почему это полезно

После такой практики становится понятно:

  • почему некоторые паттерны в React выглядят странно

  • зачем во Vue существуют watch и computed

  • как устроен механизм ререндеринга

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


Если интересно, можно пойти ещё дальше и реализовать:

  • собственный виртуальный DOM

  • систему компонентов

  • асинхронный планировщик обновлений

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

Именно так когда-то начинались многие из тех инструментов, которыми мы пользуемся сегодня.


В завершении хочу задать несколько вопросов тем, кто активно пишет фронтенд.

После того как я разобрался, как работает реактивность внутри фреймворков, у меня появился странный вопрос.

Почему для огромного количества проектов мы продолжаем тянуть библиотеки на десятки тысяч строк кода, если фундаментальная механика умещается в сотню?

Понятно, что есть оптимизации, SSR, экосистема и куча edge-кейсов. Но если убрать всё это — основа остаётся довольно простой.

Иногда создаётся ощущение, что современный фронтенд стал сложным не потому, что задачи сложные, а потому что инструменты эволюционировали быстрее, чем необходимость в них.

Поэтому интересно услышать мнение сообщества.

В каких проектах вы реально чувствовали, что без полноценного фреймворка невозможно обойтись?

И второй вопрос, который меня давно мучает.

Если бы сегодня не существовало ни React, ни Vue, ни Svelte — какой минимальный набор инструментов вы бы использовали для современного фронтенда?

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