Статьи

Раскрытие магии JavaScript

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

Создание элементов DOM из строки

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

var text = $('<div>Simple text</div>');

$('body').append(text);

Результатом является новый элемент <div> Эта простая операция выполняется только с одной строкой jQuery. Без jQuery код немного сложнее, но не намного:

 var stringToDom = function(str) {
  var temp = document.createElement('div');

  temp.innerHTML = str;
  return temp.childNodes[0];
}
var text = stringToDom('<div>Simple text</div>');

document.querySelector('body').appendChild(text);

Мы определили наш собственный служебный метод stringToDom<div> Мы изменили его свойство innerHTML Это работало так же. Тем не менее, мы увидим разные результаты со следующим кодом:

 var tableRow = $('<tr><td>Simple text</td></tr>');
$('body').append(tableRow);

var tableRow = stringToDom('<tr><td>Simple text</td></tr>');
document.querySelector('body').appendChild(tableRow);

Визуально, на странице нет никаких отличий. Однако, если мы проверим сгенерированную разметку с помощью инструментов разработчика Chrome, мы получим интересный результат:

Создание элемента DOM из строки

Похоже, наша функция stringToDom<tr> Но в то же время jQuery как-то удалось это сделать. Проблема в том, что строка, содержащая элемент HTML, запускается через парсер в браузере. Этот синтаксический анализатор игнорирует теги, которые не помещены в правильный контекст, и мы получаем только текстовый узел. Строка таблицы без таблицы недопустима для браузера.

jQuery успешно решает проблему, создавая правильный контекст и извлекая только необходимую часть. Если мы немного покопаемся в коде библиотеки, то увидим карту, подобную этой:

 var wrapMap = {
  option: [1, '<select multiple="multiple">', '</select>'],
  legend: [1, '<fieldset>', '</fieldset>'],
  area: [1, '<map>', '</map>'],
  param: [1, '<object>', '</object>'],
  thead: [1, '<table>', '</table>'],
  tr: [2, '<table><tbody>', '</tbody></table>'],
  col: [2, '<table><tbody></tbody><colgroup>', '</colgroup></table>'],
  td: [3, '<table><tbody><tr>', '</tr></tbody></table>'],
  _default: [1, '<div>', '</div>']
};
wrapMap.optgroup = wrapMap.option;
wrapMap.tbody = wrapMap.tfoot = wrapMap.colgroup = wrapMap.caption = wrapMap.thead;
wrapMap.th = wrapMap.td;

Каждому элементу, который требует специальной обработки, назначен массив. Идея состоит в том, чтобы создать правильный элемент DOM и зависеть от уровня вложенности, чтобы получить то, что нам нужно. Например, для элемента <tr><tbody> Итак, у нас есть два уровня вложенности.

Имея карту, мы должны выяснить, какой тег мы хотим в конце. Следующий код извлекает tr<tr><td>Simple text</td></tr>

 var match = /&lt;\s*\w.*?&gt;/g.exec(str);
var tag = match[0].replace(/&lt;/g, '').replace(/&gt;/g, '');

Остальное — найти правильный контекст и вернуть элемент DOM. Вот последний вариант функции stringToDom

 var stringToDom = function(str) {
  var wrapMap = {
    option: [1, '<select multiple="multiple">', '</select>'],
    legend: [1, '<fieldset>', '</fieldset>'],
    area: [1, '<map>', '</map>'],
    param: [1, '<object>', '</object>'],
    thead: [1, '<table>', '</table>'],
    tr: [2, '<table><tbody>', '</tbody></table>'],
    col: [2, '<table><tbody></tbody><colgroup>', '</colgroup></table>'],
    td: [3, '<table><tbody><tr>', '</tr></tbody></table>'],
    _default: [1, '<div>', '</div>']
  };
  wrapMap.optgroup = wrapMap.option;
  wrapMap.tbody = wrapMap.tfoot = wrapMap.colgroup = wrapMap.caption = wrapMap.thead;
  wrapMap.th = wrapMap.td;
  var element = document.createElement('div');
  var match = /<\s*\w.*?>/g.exec(str);

  if(match != null) {
    var tag = match[0].replace(/</g, '').replace(/>/g, '');
    var map = wrapMap[tag] || wrapMap._default, element;
    str = map[1] + str + map[2];
    element.innerHTML = str;
    // Descend through wrappers to the right content
    var j = map[0]+1;
    while(j--) {
      element = element.lastChild;
    }
  } else {
    // if only text is passed
    element.innerHTML = str;
    element = element.lastChild;
  }
  return element;
}

Обратите внимание, что мы проверяем, есть ли в строке тег — match != null Если нет, мы просто возвращаем текстовый узел. Все еще используется временный <div> В конце, используя цикл while, мы продвигаемся все глубже и глубже, пока не достигнем нужного тега.

Вот CodePen, показывающий нашу реализацию:

Давайте продолжим, исследуя замечательную инъекцию зависимостей AngularJS.

Выявление зависимости AngularJS

Когда мы начинаем использовать AngularJS, он впечатляет своей двусторонней привязкой данных. Второе, что мы замечаем, это инъекция магических зависимостей. Вот простой пример:

 while

Это типичный контроллер AngularJS. Он выполняет HTTP-запрос, получает данные из файла JSON и передает их в текущую область. Мы не выполняем функцию function TodoCtrl($scope, $http) {
$http.get('users/users.json').success(function(data) {
$scope.users = data;
});
}
Фреймворк делает. Итак, откуда взялись эти переменные TodoCtrl$scope Это супер крутая функция, которая очень напоминает черную магию. Посмотрим, как это делается.

У нас есть функция JavaScript, которая отображает пользователей в нашей системе. Для этой же функции необходим доступ к элементу DOM, чтобы поместить сгенерированный HTML, и оболочку Ajax для получения данных. Чтобы упростить пример, мы смоделируем данные и запрос HTTP.

 $http

Мы будем использовать var dataMockup = ['John', 'Steve', 'David'];
var body = document.querySelector('body');
var ajaxWrapper = {
get: function(path, cb) {
console.log(path + ' requested');
cb(dataMockup);
}
}
<body>ajaxWrapper Вот функция, которую мы будем использовать:

 dataMockup

И конечно, если мы запустим var displayUsers = function(domEl, ajax) {
ajax.get('/api/users', function(users) {
var html = '';
for(var i=0; i < users.length; i++) {
html += '<p>' + users[i] + '</p>';
}
domEl.innerHTML = html;
});
}
displayUsers(body, ajaxWrapper)
Можно сказать, что наш метод имеет две зависимости — /api/users requestedbody Итак, теперь идея состоит в том, чтобы заставить функцию работать без передачи аргументов, т.е. мы должны получить тот же результат, вызывая только ajaxWrapper Если мы сделаем это с помощью кода, результатом будет:

 displayUsers()

И это нормально, потому что параметр Uncaught TypeError: Cannot read property 'get' of undefined

Большинство структур, которые предоставляют механизмы для внедрения зависимостей, имеют модуль, обычно называемый инжектором . Чтобы использовать зависимость, нам нужно зарегистрировать ее там. Позже, в какой-то момент, наш ресурс предоставляется логике приложения тем же модулем.

Давайте создадим наш инжектор:

 ajax

Нам нужно только два метода. Первый, var injector = {
storage: {},
register: function(name, resource) {
this.storage[name] = resource;
},
resolve: function(target) {

}
};
Второй принимает цель нашего внедрения — функцию, которая имеет зависимости и должна получать их в качестве параметров. Ключевым моментом здесь является то, что инжектор не должен вызывать нашу функцию. Это наша работа, и мы должны это контролировать. То, что мы можем сделать в методе registerresolve Например:

 target

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

Конечно, передача функции resolve: function(target) {
return function() {
target();
};
}
displayUsers

 resolve

Мы все еще получаем ту же ошибку. Следующим шагом является выяснение того, что нужно для пройденной цели. Каковы его зависимости? А вот и хитрая часть, которую мы можем перенять у AngularJS. Я снова покопался в коде фреймворка и нашел это:

 displayUsers = injector.resolve(displayUsers);
displayUsers();

Мы специально пропустили некоторые части, потому что они больше похожи на детали реализации. Это код, который нам интересен. Функция var FN_ARGS = /^function\s*[^\(]*\(\s*([^\)]*)\)/m;
var STRIP_COMMENTS = /((\/\/.*$)|(\/\*[\s\S]*?\*\/))/mg;
...
function annotate(fn) {
...
fnText = fn.toString().replace(STRIP_COMMENTS, '');
argDecl = fnText.match(FN_ARGS);
...
}
annotate
Он преобразует переданную целевую функцию в строку, удаляет комментарии (если есть) и извлекает аргументы. Давайте использовать это и посмотрим результаты:

 resolve

Вот вывод в консоли:

Revealing the AngularJS dependency injection

Если мы получим второй элемент массива resolve: function(target) {
var FN_ARGS = /^function\s*[^\(]*\(\s*([^\)]*)\)/m;
var STRIP_COMMENTS = /((\/\/.*$)|(\/\*[\s\S]*?\*\/))/mg;
fnText = target.toString().replace(STRIP_COMMENTS, '');
argDecl = fnText.match(FN_ARGS);
console.log(argDecl);
return function() {
target();
}
}
Это именно то, что нам нужно, поскольку, имея имена, мы сможем доставлять ресурсы из argDecl Вот версия, которая работает и успешно охватывает наши цели:

 storage

Обратите внимание, что мы используем resolve: function(target) {
var FN_ARGS = /^function\s*[^\(]*\(\s*([^\)]*)\)/m;
var STRIP_COMMENTS = /((\/\/.*$)|(\/\*[\s\S]*?\*\/))/mg;
fnText = target.toString().replace(STRIP_COMMENTS, '');
argDecl = fnText.match(FN_ARGS)[1].split(/, ?/g);
var args = [];
for(var i=0; i&lt;argDecl.length; i++) {
if(this.storage[argDecl[i]]) {
args.push(this.storage[argDecl[i]]);
}
}
return function() {
target.apply({}, args);
}
}
.split(/, ?/g)
После этого мы проверяем, зарегистрированы ли зависимости, и если да, то передаем их domEl, ajax Код вне инжектора выглядит так:

 target

Преимущество такой реализации состоит в том, что мы можем внедрить элемент DOM и оболочку Ajax во многие функции. Мы могли бы даже распределить конфигурацию нашего приложения таким образом. Нет необходимости передавать объекты из класса в класс. Это просто методы injector.register('domEl', body);
injector.register('ajax', ajaxWrapper);

displayUsers = injector.resolve(displayUsers);
displayUsers();
register

Конечно, наш инжектор не идеален. Есть еще возможности для улучшений, например, поддержка определения области видимости. resolve Мы должны также поддерживать отправку пользовательских аргументов вместе с зависимостями.

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

 target

Вместо только displayUsers = injector.resolve(['domEl', 'ajax', displayUsers]);displayUsers

Наш пример в действии:

Принятие вычисленных свойств Ember

Ember — одна из самых популярных платформ в наше время. Он имеет множество полезных функций. Есть один, который особенно интересен — вычисляемые свойства . Таким образом, вычисляемые свойства являются функциями, которые действуют как свойства. Давайте посмотрим на простой пример, взятый из документации Ember:

 App.Person = Ember.Object.extend({
  firstName: null,
  lastName: null,
  fullName: function() {
    return this.get('firstName') + ' ' + this.get('lastName');
  }.property('firstName', 'lastName')
});
var ironMan = App.Person.create({
  firstName: "Tony",
  lastName:  "Stark"
});
ironMan.get('fullName') // "Tony Stark"

Есть класс, который имеет свойства firstNamelastName Вычисленное свойство fullName Странно то, что мы используем метод .propertyfullName Я лично не видел этого где-либо еще. И, опять же, быстрый просмотр кода фреймворка раскрывает магию:

 Function.prototype.property = function() {
  var ret = Ember.computed(this);
  // ComputedProperty.prototype.property expands properties; no need for us to
  // do so here.
  return ret.property.apply(ret, arguments);
};

Библиотека настраивает прототип глобального объекта Function Это хороший подход для запуска логики во время определения класса.

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

 var User = {
  firstName: 'Tony',
  lastName: 'Stark',
  name: function() {
    // getter + setter
  }
};

console.log(User.name); // Tony Stark
User.name = 'John Doe';
console.log(User.firstName); // John
console.log(User.lastName); // Doe

namefirstNamelastName

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

 var User = {
  firstName: 'Tony',
  lastName: 'Stark'
};
Object.defineProperty(User, "name", {
  get: function() { 
    return this.firstName + ' ' + this.lastName;
  },
  set: function(value) { 
    var parts = value.toString().split(/ /);
    this.firstName = parts[0];
    this.lastName = parts[1] ? parts[1] : this.lastName;
  }
});

Метод Object.defineProperty Все, что нам нужно сделать, это написать тело двух методов. Вот и все. Мы сможем запустить приведенный выше код и получим ожидаемые результаты:

 console.log(User.name); // Tony Stark
User.name = 'John Doe';
console.log(User.firstName); // John
console.log(User.lastName); // Doe

Object.defineProperty Возможно, нам потребуется предоставить полифилл, запустить дополнительную логику или что-то в этом роде. В идеальном случае мы хотим предоставить интерфейс, похожий на интерфейс Ember. Только одна функция является частью определения класса. В этом разделе мы напишем вспомогательную функцию Computizename

 var Computize = function(obj) {
  return obj;
}
var User = Computize({
  firstName: 'Tony',
  lastName: 'Stark',
  name: function() {
    ...
  }
});

Мы хотим использовать метод name Это похоже на вычисляемые свойства Эмбер.

Теперь давайте добавим нашу собственную логику в прототип объекта Function

 Function.prototype.computed = function() {
  return { computed: true, func: this };
};

Как только мы добавим вышеупомянутые строки, мы сможем добавить .computed()

 name: function() {
  ...
}.computed()

В результате свойство namecomputedtruefunc Настоящее волшебство происходит в реализации помощника Computize Он просматривает все свойства объекта и использует Object.defineProperty

 var Computize = function(obj) {
  for(var prop in obj) {
    if(typeof obj[prop] == 'object' && obj[prop].computed === true) {
      var func = obj[prop].func;
      delete obj[prop];
      Object.defineProperty(obj, prop, {
        get: func,
        set: func
      });
    }
  }
  return obj;
}

Обратите внимание, что мы удаляем исходное name В некоторых браузерах Object.defineProperty

Вот окончательная версия объекта User.computed()

 var User = Computize({
  firstName: 'Tony',
  lastName: 'Stark',
  name: function() {
    if(arguments.length > 0) {
      var parts = arguments[0].toString().split(/ /);
      this.firstName = parts[0];
      this.lastName = parts[1] ? parts[1] : this.lastName;
    }
    return this.firstName + ' ' + this.lastName;
  }.computed()
});

Функция, которая возвращает полное имя, используется для изменения firstNamelastName Это идея проверки переданных аргументов и обработки первого. Если он существует, мы разделяем его и применяем значения к обычным свойствам.

Мы уже упоминали желаемое использование, но давайте посмотрим на это еще раз:

 console.log(User.name); // Tony Stark
User.name = 'John Doe';
console.log(User.firstName); // John
console.log(User.lastName); // Doe
console.log(User.name); // John Doe

Следующий CodePen показывает нашу работу на практике:

Сумасшедшие шаблоны реакции

Вы, наверное, слышали о фреймворке Facebook React . Он построен на идее, что все является компонентом. Что интересно, так это определение компонента. Давайте посмотрим на следующий пример:

 <script type="text/jsx">;
  /** @jsx React.DOM */
  var HelloMessage = React.createClass({
    render: function() {
      return <div>Hello {this.props.name}</div>;
    }
  });
</script>;

Первое, о чем мы начинаем думать, это то, что это JavaScript, но он недопустим. Существует функция render Однако хитрость заключается в том, что этот код помещается в <script>type Браузер не обрабатывает его, что означает, что мы защищены от ошибок. React имеет свой собственный анализатор, который переводит написанный нами код в действительный JavaScript. Разработчики из Facebook назвали XML подобным языком JSX . Их JSX-преобразователь 390K и содержит примерно 12000 строк кода. Итак, это немного сложно. В этом разделе мы создадим что-то простое, но все же достаточно мощное Класс JavaScript, который анализирует HTML-шаблоны в стиле React.

Подход, использованный Facebook, состоит в том, чтобы смешать код JavaScript с разметкой HTML. Итак, допустим, что у нас есть следующий шаблон:

 <script type="text/template" id="my-content">;
  <div class="content">;
    <h1>;<% title %>;</h1>;
  </div>;
</script>;

И компонент, который использует его:

 var Component = {
  title: 'Awesome template',
  render: '#my-content'
}

Идея заключается в том, что мы указываем id Последняя часть нашей реализации — это механизм, который объединяет два элемента. Давайте назовем его Engine

 var Engine = function(comp) {
  var parse = function(tplHTML) {
    // ... magic
  }
  var tpl = document.querySelector(comp.render);
  if(tpl) {
    var html = parse(tpl.innerHTML);
    return stringToDom(html);
  }
}
var el = Engine(Component);

Мы получаем содержимое <script type="text/template" id="my-content"> Разобрать его и сгенерировать строку HTML. Сразу после этого преобразуйте этот HTML-код в действительный элемент DOM и верните его в результате всего процесса. Обратите внимание, что мы используем функцию stringToDom Тот, который мы написали в первом разделе этой статьи.

Теперь давайте напишем нашу функцию parse Наша первая задача — отличить HTML от выражений. Под выражениями мы подразумеваем строки, помещенные между <%%> Мы будем использовать RegEx, чтобы найти их, и простой цикл while, чтобы пройти все совпадения:

 while

Результат приведенного выше кода выглядит следующим образом:

 var parse = function(tplHTML) {
  var re = /<%([^%>]+)?%>/g;
  while(match = re.exec(tplHTML)) {
    console.log(match);
  }
}

Существует только одно выражение, и его содержание — [
"<% title %>",
"title",
index: 55,
input: "<div class="content"><h1><% title %></h1></div>"
]
Первый интуитивный подход, который мы можем использовать, — это использовать функцию titlereplace<% title %> Однако это будет работать только с простыми свойствами. Что делать, если у нас есть вложенные объекты или даже если мы хотим использовать функцию. Как например:

 comp

Вместо создания сложного парсера и почти изобретения нового языка мы можем использовать чистый JavaScript. Единственное, что нам нужно сделать, — это использовать var Component = {
data: {
title: 'Awesome template',
subtitle: function() {
return 'Second title';
}
},
render: '#my-content'
}
new Function

 var fn = new Function('arg', 'console.log(arg + 1);');
fn(2); // outputs 3

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

 var parse = function(tplHTML) {
  var re = /<%([^%>]+)?%>/g;
  var code = [], cursor = 0;
  while(match = re.exec(tplHTML)) {
    code.push(tplHTML.slice(cursor, match.index));
    code.push({code: match[1]}); // <-- expression
    cursor = match.index + match[0].length;
  }
  code.push(tplHTML.substr(cursor, tplHTML.length - cursor));
  console.log(code);
}

Вывод в консоли показывает, что мы на правильном пути:

 [
  "<div class="content"><h1>", 
  { code: "title" },
  "</h1></div>"
]

Массив code Например:

 return "<div class=\"content\"><h1>" + title + "</h1></div>";

Это довольно легко достичь этого результата. Мы можем написать цикл, который проходит через все элементы массива code Однако это опять-таки охватывает только часть случаев. Что делать, если у нас есть следующий шаблон:

 // component
var Component = {
  title: 'Awesome template',
  colors: ['read', 'green', 'blue'],
  render: '#my-content'
}

// template
<script type="text/template" id="my-content">
    <div class="content">
        <h1><% title %></h1>
        <% while(c = colors.shift()) { %>
            <p><% c %></p>
        <% } %>
    </div>
</script>

Мы не можем просто объединить выражения и ожидать, что цвета будут перечислены. Таким образом, вместо добавления строки в строку мы будем собирать их в массив. Вот обновленная версия функции parse

 var parse = function(tplHTML) {
  var re = /<%([^%>]+)?%>/g;
  var code = [], cursor = 0;
  while(match = re.exec(tplHTML)) {
    code.push(tplHTML.slice(cursor, match.index));
    code.push({code: match[1]}); // <-- expression
    cursor = match.index + match[0].length;
  }
  code.push(tplHTML.substr(cursor, tplHTML.length - cursor));
  var body = 'var r=[];\n';
  while(line = code.shift()) {
    if(typeof line === 'string') {
      // escaping quotes
      line = line.replace(/"/g, '\\"');
      // removing new lines
      line = line.replace(/[\r\t\n]/g, '');
      body += 'r.push("' + line+ '");\n'
    } else {
      if(line.code.match(/(^( )?(if|for|else|switch|case|break|while|{|}))(.*)?/g)) {
        body += line.code + '\n';
      } else {
        body += 'r.push(' + line.code + ');\n';
      }
    }
  }
  body += 'return r.join("");';
  console.log(body);
}

Как только массив codebody Каждая строка шаблона будет храниться в массиве r Если строка является строкой, мы ее немного очищаем, экранируя кавычки и удаляя новые строки и вкладки. Он добавляется в массив с помощью метода push Если у нас есть фрагмент кода, мы проверяем, не является ли он допустимым оператором JavaScript. Если да, то мы не добавляем его в массив, а просто отбрасываем как новую строку. console.log

 var r=[];
r.push("<div class=\"content\"><h1>");
r.push(title);
r.push("</h1>");

while(c = colors.shift()) { 
  r.push("<p>");
  r.push(c);
  r.push("</p>");
}

r.push("</div>");
return r.join("");

Хорошо, не правда ли? Правильно отформатированный рабочий JavaScript, который выполняется в контексте нашего Component

Последнее, что осталось, это фактическое выполнение нашей виртуально созданной функции:

 body = 'with(component) {' + body + '}';
return new Function('component', body).apply(comp, [comp]);

Мы завернули наш код в оператор with Без этого нам нужно использовать this.titlethis.colorstitlecolors

Вот CodePen, демонстрирующий конечный результат:

Резюме

За большими фреймворками и библиотеками стоят умные разработчики. Они нашли и используют хитрые решения, которые не являются тривиальными и даже своего рода волшебными. В этой статье мы раскрыли часть этой магии. Приятно, что в мире JavaScript мы можем учиться у лучших и использовать их код.

Код из этой статьи доступен для скачивания с GitHub