RSS Мои друзья Контакты

Best practisies в Knockout: упрощаем view

Последний год я имел удовольствие работать над созданием так называемых single page application-ов, используя новейшие технологии, такие как: MV* based frameworks, HTML5, CSS3.

Одним из моих подручных инструментов стал Knockout. Это удивительный фреймворк: когда думаешь, что уже все о нем знаешь, пишешь код достаточно долго, изменяешь стандартное поведение, создаешь вложенные View Model-и - найдется, что-то новое, загадочное и до трепета программистских чувств волнительное полезное.

К сожалению, существует не так уж много хороших примеров по применению Knockout (есть один хороший блог). Вот и я узнав пару тройку трюков, хочу ими поделиться.

Создавайте читабельное View

Knockout, так сказать, рекомендует декларативный стиль для создания шаблонов, более того он дает для этого все инструменты. Это значит, что data-bind должен быть максимально простым и максимально читабельным. Чтобы шаблоны можно было "читать" добавляйте dependent observable-ы. Допустим у есть view model:

var ViewModel = function () {
  this.items = ko.observableArray();
};

Плохо:

<div data-bind="visible: !items().length">There are no items</div>

Если читать код, упуская синтаксические конструкции, получается: "There are no items" visible not items length или "There are no items" visible items length equals 0. Хотя и понятно, но не читабельно. Всегда читайте свой код, если код прозрачный, эффективный и может быть переиспользован - его можно прочесть, как обычное предложение на английском (понятно, что без сахара не обойтись, но лучше добавить сахар чем потом писать кучу документации). Придерживайтесь правила: никаких комментариев, минимум документации.

Чтобы улучшить предыдущий пример, нужно добавить новое computed свойство - hasItems. Логично, что модель которая имеет массив айтемов имеет метод hasItems. Тем более, любую логику относящуюся к внутренней реализации нужно прятать от вьюхи. В будущем внутренняя реализация модели может изменяться и при таких условиях хотелось бы оставить шаблон без изменений.

var ViewModel = function () {
  this.items = ko.observableArray();
  this.hasItems = ko.computed(function () {
    return this.items().length > 0
  }, this);
};

Хорошо:

<div data-bind="ifnot: hasItems">There are no items</div>

Попробуем прочитать: "There are no items" if not has items. Уже на много лучше!

Уничтожайте зло

Никогда не используйте анонимные ф-ции внутри байндингов, если так хочется передать параметры в метод используйте метод bind, а лучше - data-* атрибуты, они для этого и были придуманы.

Плохо:

<a data-bind="click: function(vm, event) { $data.doSmth(event, 'param_1', 'param_2') }">Click Me</a>

Чуть лучше, но ставит в зависимость от последовательности передаваемых аргументов:

<a data-bind="click: doSmth.bind($data, 'param_1', 'param_2')">Click Me</a>

Хорошо:

<a data-bind="clickWithData: doSmth" data-param1="param_1" data-param2="param_2">Click Me</a>

clickWithData байндинг - нестандартный (его реализация занимает пару минут): первым аргументом в метод doSmth передается хэш data атрибутов. Такой подход намного более гибкий: нет зависимости от последовательности передаваемых аргументов и делает шаблон более прозрачным.

Уменьшайте количество байндингов

Допустим есть таблица. Нужно сделать, чтобы по нажатию на названия колонок происходила сортировка и соответственно колонка по которой отсортирована таблица должна иметь стрелочку вверх/вниз. Конечно же, первое что приходит в голову:

Плохо:

<table class="table">
  <thead>
    <tr>
     <th data-bind="click: sortBy.bind($data, 'name'), css: classForColumn('name')">Name</th> 
     <th data-bind="click: sortBy.bind($data, 'status'), css: classForColumn('status')">Status</th>
     <th data-bind="click: sortBy.bind($data, 'created_at'), css: classForColumn('created_at')">Created At</th>
    </tr>
  </thead>
  <tbody>...</tbody>
</table>

С увеличением колонок, код мягко говоря станет ужасным, а что если надо будет добавить еще какой-то байндинг... Здесь существует 3 проблемы:

  • во-первых, слишком много байндингов на сантиметр квадратный
  • во-вторых, метод bind ухудшает чтение, используйте его в самом крайнем случае (к тому же создает замыкание на каждую колонку)
  • в третьих, дублирование названий колонок

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

Создадим байндинг setChildrenCss, который пройдет по всем дочерним элементам и применит на них стандартный css binding:

(function (cssBinding) {
  ko.bindingHandlers.setChildrenCss = {
    update: function (element, valueAccessor, allBindingsAccessor, viewModel) {
      var rules = ko.utils.unwrapObservable(valueAccessor());
      var children = ko.utils.arrayFilter(element.childNodes, function (child) {
        return child.nodeType == 1;
      });

      var updater;
      if (rules.call) {
        rules = rules.bind(viewModel);
        updater = function (child) {
          var nodeName = child.getAttribute('data-name');
          cssBinding.update(child, function () { return rules(nodeName) });
        };
      } else {
        updater = function (child) { cssBinding.update(child, valueAccessor) };
      }
      ko.utils.arrayForEach(children, updater);
    }
  };
})(ko.bindingHandlers.css);

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

<table class="table">
  <thead>
    <tr data-bind="setChildrenCss: classForColumn">
     <th data-name="name" data-bind="click: sortBy.bind($data, 'name')">Name</th> 
     <th data-name="status" data-bind="click: sortBy.bind($data, 'status')">Status</th>
     <th data-name="created_at" data-bind="click: sortBy.bind($data, 'created_at')">Created At</th>
    </tr>
  </thead>
  <tbody>...</tbody>
</table>

Стало чуть лучше, но click байндинг все еще портит весь пейзаж. Для решения этой проблемы напишем новый on data-binding c использованием jquery и его метода on.

(function ($) {
  function lookupMethodIn(context, methodName) {
    var scopes = [ context.$data ].concat(context.$parents), i = 0, count = scopes.length;
      
    do {
      var scope = scopes[i];
    } while (++i < count && !(scope[methodName] && scope[methodName].call));
      
    if (!scope[methodName] || !scope[methodName].call) {
      throw new Error('Unknown method "' + methodName + '" in context');
    }
    return scope[methodName].bind(scope);
  }

  function createEventHandlerFor(config, rule) {
    var methodName = config[rule], dataKey = ko.utils.unwrapObservable(config.data);
    
    return function (event) {
      var context = ko.contextFor(this), data = $(this).data(dataKey);
      if (data.bind) {
        delete data.bind;
      }
      var method = lookupMethodIn(context, methodName);
      var result = method(data, context.$data, event);
      if (result !== true) {
        event.preventDefault();
      }
    };
  }

  ko.bindingHandlers.on = {
    init: function (element, valueAccessor, allBindings, viewModel) {
      var config = valueAccessor(), domNode = $(element);
      
      for (var rule in config) {
        if (config.hasOwnProperty(rule)) {
          var handler = createEventHandlerFor(config, rule);
          rule = rule.split(/\s+/, 2);
          if (rule[1]) {
            domNode.on(rule[0], rule[1], handler);
          } else {
            domNode.on(rule[0], handler);
          }
        }
      }
    }
  };
})(jQuery);

Байндинг принимает в качестве параметра хэш событий и обработчиков. Имя события может быть расширено css селектором ("click a", "mouseenter .item"). В конечном итоге первоначальный шаблон выглядит так:

Хорошо:

<table class="table">
  <thead>
    <tr data-bind="on: { 'click th': 'sortBy', data: 'name' }, setChildrenCss: classForColumn">
     <th data-name="name">Name</th> 
     <th data-name="status">Status</th>
     <th data-name="created_at">Created At</th>
    </tr>
  </thead>
  <tbody>...</tbody>
</table>

Можно прочесть: on click - th sort by data name and set children css class for column.

Созданный байндинг on решает как минимум 3 задачи:

  • делает шаблоны более простыми и читабельными (чистота и порядок - свойства качественного кода)
  • уменьшает к-во создаваемых обработчиков событий (уменьшение используемых ресурсов)
  • реализовывает механизм передачи параметров в хэндлеры посредством data-* атрибутов (гибкость для методов view model-и)

На счет делегирования событий можно ознакомится и с другой реализацией.

P.S.: Knockout предоставляет очень мощные инструменты, не бойтесь их использовать, усовершенствовать и добавлять свои.

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

Комментариев: 0