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

—

от автора

Некоторое время назад я опубликовал похожую статью про 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.

ссылка на оригинал статьи https://habr.com/ru/company/timeweb/blog/547188/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *