Обновить
1135.37
Рейтинг
Timeweb Cloud
Облачная платформа для разработчиков и бизнеса

Сделаем худший Vue.js в мире

Блог компании Timeweb Cloud JavaScript *Программирование *VueJS *
Перевод
Автор оригинала: Serge Zaitsev
Некоторое время назад я опубликовал похожую статью про React, где с помощью пары строк кода мы создали крошечный клон React.js с нуля. Но React — далеко не единственный инструмент в современном фронтенд-мире, Vue.js стремительно набирает популярность. Давайте разберемся, как работает этот фреймворк, и создадим примитивный клон, похожий на Vue.js, в образовательных целях.

Реактивность


Как и React.js, Vue является реактивным, то есть все изменения в состоянии приложения автоматически отражаются в DOM. Но в отличие от React, Vue отслеживает зависимости во время рендеринга и обновляет только связанные части без каких-либо «сравнений».

Ключ к реактивности Vue.js — это метод Object.defineProperty. Он позволяет указывать настраиваемый метод getter / setter для поля объекта и перехватывать каждый доступ к нему:

const obj = {a: 1};
Object.defineProperty(obj, 'a', {
  get() { return 42; },
  set(val) { console.log('you want to set "a" to', val); }
});
console.log(obj.a); // prints '42'
obj.a = 100;        // prints 'you want to set "a" to 100'

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

Выражения


Vue.js позволяет привязать выражение JavaScript к атрибуту узла DOM с помощью директивы. Например, <div v-text="s.toUpperCase()"></div> установит текст внутри div’а в значение переменной s в верхнем регистре.

Самый простой подход к оценке строк, таких как s.toUpperCase(), использовать eval(). Хотя eval никогда не считался безопасным решением, мы можем попробовать сделать его немного лучше, обернув в функцию и передав специальный глобальный контекст:

const call = (expr, ctx) =>
  new Function(`with(this){${`return ${expr}`}}`).bind(ctx)();

call('2+3', null);                    // returns 5
call('a+1', {a:42});                  // returns 43
call('s.toUpperCase()', {s:'hello'}); // returns "HELLO"

Это немного безопаснее, чем нативный eval, и его достаточно для простого фреймворка, который мы создаем.

Прокси


Теперь мы можем использовать Object.defineProperty, чтобы обернуть каждое свойство объекта данных; можно использовать call() для оценки произвольных выражений и для того, чтобы сказать, к каким свойствам прямо или косвенно обращалось выражение. Мы также должны суметь определить, когда выражение должно быть повторно вычислено, потому что одна из его переменных изменилась:

const data = {a: 1, b: 2, c: 3, d: 'foo'}; // Data model
const vars = {}; // List of variables used by expression
// Wrap data fields into a proxy that monitors all access
for (const name in data) {
  let prop = data[name];
  Object.defineProperty(data, name, {
    get() {
      vars[name] = true; // variable has been accessed
      return prop;
    },
    set(val) {
      prop = val;
      if (vars[name]) {
        console.log('Re-evaluate:', name, 'changed');
      }
    }
  });
}
// Call our expression
call('(a+c)*2', data);
console.log(vars); // {"a": true, "c": true} -- these two variables have been accessed
data.a = 5;  // Prints "Re-evaluate: a changed"
data.b = 7;  // Prints nothing, this variable does not affect the expression
data.c = 11; // Prints "Re-evaluate: c changed"
data.d = 13; // Prints nothing.

Директивы


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

Как и в Vue.js, мы будем использовать специальные атрибуты, такие как q-on:click для связывания обработчиков событий, q-text для привязки textContent, q-bind:style для привязки стиля CSS и так далее. Я использую здесь префикс «q-», потому что «q» похоже на «vue».

Вот неполный список возможных поддерживаемых директив:

const directives = {
  // Bind innerText to an expression value
  text: (el, _, val, ctx) => (el.innerText = call(val, ctx)),
  // Bind event listener
  on: (el, name, val, ctx) => (el[`on${name}`] = () => call(val, ctx)),
  // Bind node attribute to an expression value
  bind: (el, name, value, ctx) => el.setAttribute(name, call(value, ctx)),
};

Каждая директива — это функция, которая принимает узел DOM, имя необязательного параметра для таких случаев, как q-on:click (имя будет «click»). Также требуется строка выражения (value) и объект данных для использования в качестве контекста выражения.

Теперь у нас есть все строительные блоки, пора всё склеить вместе!

Конечный результат


const call = ....       // Our "safe" expression evaluator
const directives = .... // Our supported directives

// Currently evaluated directive, proxy uses it as a dependency
// of the individual variables accessed during directive evaluation
let $dep;

// A function to iterate over DOM node and its child nodes, scanning all
// attributes and binding them as directives if needed
const walk = (node, q) => {
  // Iterate node attributes
  for (const {name, value} of node.attributes) {
    if (name.startsWith('q-')) {
      const [directive, event] = name.substring(2).split(':');
      const d = directives[directive];
      // Set $dep to re-evaluate this directive
      $dep = () => d(node, event, value, q);
      // Evaluate directive for the first time
      $dep();
      // And clear $dep after we are done
      $dep = undefined;
    }
  }
  // Walk through child nodes
  for (const child of node.children) {
    walk(child, q);
  }
};

// Proxy uses Object.defineProperty to intercept access to
// all `q` data object properties.
const proxy = q => {
  const deps = {}; // Dependent directives of the given data object
  for (const name in q) {
    deps[name] = []; // Dependent directives of the given property
    let prop = q[name];
    Object.defineProperty(q, name, {
      get() {
        if ($dep) {
          // Property has been accessed.
          // Add current directive to the dependency list.
          deps[name].push($dep);
        }
        return prop;
      },
      set(value) { prop = value; },
    });
  }
  return q;
};

// Main entry point: apply data object "q" to the DOM tree at root "el".
const Q = (el, q) => walk(el, proxy(q));

Реактивный, подобный Vue.js фреймворк во всей красе. Насколько он полезен? Вот пример:

<div id="counter">
  <button q-on:click="clicks++">Click me</button>
  <button q-on:click="clicks=0">Reset</button>
  <p q-text="`Clicked ${clicks} times`"></p>
</div>

Q(counter, {clicks: 0});

Нажатие на одну кнопку увеличивает счетчик и автоматически обновляет содержимое <p>. Щелчок по другому устанавливает счетчик на ноль, а также обновляет текст.

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

Дальнейшие шаги


Если вам интересно узнать о Vue.js больше — попробуйте реализовать «q-if» для переключения видимости элементов на основе выражения или «q-each» для привязки списков повторяющихся дочерних элементов (это будет хорошим упражнением).

Полные исходники нанофреймворка Q находятся на Github. Не стесняйтесь контрибьютить, если вы заметили проблему или хотите предложить улучшение!

В заключение я должен упомянуть, что Object.defineProperty использовался в Vue 2, а создатели Vue 3 переключились на другой механизм, предоставляемый ES6, а именно Proxy и Reflect. Прокси позволяет передать обработчик для перехвата доступа к свойствам объекта, как и в нашем примере, в то время как Reflect позволяет получить доступ к свойствам объекта изнутри прокси и сохранить this объект нетронутым (в отличие от нашего примера с defineProperty).

Я оставляю оба Proxy / Reflect в качестве упражнения для читателя, так что, кто бы ни сделал пулл-реквест для правильного использования их в Q — я буду рад совместить это. Удачи!

Надеюсь, вам понравилась статья. Вы можете следить за новостями и делиться предложениями в Github, Twitter или подписываться через rss.
Теги:
Хабы:
Всего голосов 32: ↑31 и ↓1 +30
Просмотры 14K
Комментарии Комментарии 4

Информация

Дата основания
Местоположение
Россия
Сайт
cloud.timeweb.com
Численность
201–500 человек
Дата регистрации
Представитель