Knockoutjs. «Растим» дерево

в 7:25, , рубрики: javascript, knockout, knockoutjs, mvvm, tree, treeview, Веб-разработка, метки: , , , , ,

Knockoutjs. «Растим» дерево
Судя по частоте появления статей, KnockoutJS набирает популярность на Хабре. Внесу и я свою лепту. Хочу осветить тему нестандартных для HTML элементов управления и «дерева» в частности. Под деревом здесь понимается аналог элемента управления TreeView. Статья подразумевает, что читатель уже знаком с KnockoutJS на базовом уровне. Публикация может рассматриваться, как пособие для изучения KnockoutJS. С другой стороны, надеюсь, и опытные пользователи KnockoutJS смогут почерпнуть для себя что-то новое.

Для отображения TreeView написано немало библиотек. A для использования сторонних библиотек совместно с KnockoutJS традиционно создаются соответствующие привязки(binding) для KnockoutJS. К сожалению, часто TreeView библиотеки громадны, содержат избыток функциональности, часто необходимо приспосабливать свою модель данных под библиотеку. Если нужно использовать TreeView совместно с KnockoutJS, программист ведет поиск идеальной библиотеки и привязки. Не всегда под найденную библиотеку есть готовая привязка, так что приходится создавать свою привязку, и тут начинается самое интересное — изучение внутренностей библиотеки, что не всегда приятно. А хочется просто чтоб работало… Тут предлагается альтернативный подход — сделать TreeView прямо на KnockoutJS.

Для начала будем строить абстрактное дерево, без привязки к данным. Привязку к реальным данным вы сможете осуществить сами, когда будете «растить» свои деревья. Либо эту проблему решит мой способ повторного использования кода KnockoutJS, который я покажу в конце.

… и не дерево, а список.

Традиционно деревья на HTML строят при помощи набора вложенных неупорядоченных списков (тег UL) и CSS стилей. Т.е. для построения дерева необходимо генерировать примерно следующую HTML разметку:

<ul>
  <li>Узел 1
     <ul>
           <li>Узел 3</li>         
     </ul>
  </li>
  <li>Узел 2
 </li>
<ul>

Внешний вид узлов настраивается при помощи css классов.
Модель отображения(ViewModel), очевидно, можно построить из двух объектов — TreeViewNode и TreeView для начала так:

function TreeViewNode(caption,children){
  this.caption = caption;
  this.children = children;
}

function TreeView(children){
  this.children = children;
}}

Тут возникает соблазн обойтись лишь одним TreeViewNode, поскольку TreeView отличается лишь отсутствием поля caption. Однако с этим не стоит торопиться, поскольку позже у этих объектов будет намного больше отличий.
Разметка с привязкой к модели будет использовать рекурсивный шаблон:

<div data-bind='with: tree'>
  <ul data-bind='template: {name:"treeNode", foreach: children}'>
  </ul>    
</div>  

<script id='treeNode' type='text/html'>
  <li> 
    <span data-bind='text:caption'></span>
    <ul data-bind='template: {name:"treeNode", foreach: children}'>
    </ul> 
  </li>  
 </script>

Конечно нам необходимо заполнить нашу модель данными и инициализировать привязку:

var vm = {
  tree: new TreeView([
     new TreeViewNode('Node 1',[
        new TreeViewNode('Node 3')
     ]),
     new TreeViewNode('Node 2')
  ])
};

ko.applyBindings(vm);

Вот что у нас получилось:
image
JSFiddle для экспериментов.

Пока что это не похоже на TreeView. Необходимо назначить CSS классы и добавить соответствующие стили.

Отражение положения.

Классы должны отражать положение и состояние узла дерева. Состояние узла должно отражаться и в модели, поэтому перейдем к модели.
Узел может быть развернут или свернут. Одно состояние из другого можно получить простой операцией, но для удобства определим оба:

function TreeViewNode(caption,children){
...
this.isOpen = ko.observable();
this.isClosed = ko.computed(function(){
       return !this.isOpen();
  },this);
...
}

Я сделал свойства .isOpen, .isClosed наблюдаемыми(observable), поскольку они зависят друг от друга, и их изменение будет автоматически приводить к изменениям в DOM. Изменяя эти свойства, мы будем раскрывать/сворачивать узлы дерева.
Я сделал .isClosed свойством только для чтения, чтобы не плодить лишнего кода и не вводить лишнюю здесь циклическую зависимость, хотя и ее можно «разрулить». Таким образом, напрямую изменять можно только свойство .isOpen.

Для правильного отображения нам также важно знать, есть ли у узла дети (можно ли его раскрыть) и является ли узел последним на своем уровне, чтобы не рисовать от него линию к следующему узлу.

function TreeViewNode(caption,children){
...
this.children = children||[];

this.isLeaf = !this.children.length;

this.isLast = false;
...
}

Поскольку узел может не иметь детей, а это значит, что свойство children или пустое(null или undefined), или представляет собой массив нулевой длины, я добавил этому вопросу однозначности — инициализировал свойство пустым массивом, если детей нам не передали.

Что касается свойства isLast, есть два варианта подхода к его реализации. Его можно сделать ko.computed c передачей в узел ссылки на его родителя, либо сделать так, чтобы родитель вычислял свойства isLast своих детей. Я выбрал второй подход, потому что с ним пока меньше кода. Первый подход тоже может пригодиться, поскольку ссылка на родителя узла полезна во многих сценариях. Впрочем, перейти от одного к другому потом будет легко.
Итак, добавляем обработку свойства isLast:

function setIsLast(children){
  for(var i=0,l=children.length;i<l;i++){
     children[i].isLast = (i==(l-1));   
  }
}
function TreeViewNode(caption,children){
...
setIsLast(this.children);
...
}
function TreeView(children){
...
setIsLast(this.children);
...
}

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

...
<li data-bind='css:{closed:isClosed,open:isOpen, leaf: isLeaf, last: isLast}'> 
...
</li>
...

Осталось добавить возможность сворачивать и раскрывать узлы:

function TreeViewNode(caption,children){
 var self = this;
...
this.toggleOpen = function(){
    self.isOpen(!self.isOpen());
};
...
}

И добавить привязку:

...
<li data-bind='css:{closed:isClosed,open:isOpen, leaf: isLeaf, last: isLast}, click: toggleOpen, clickBubble: false'> 
...
</li>
...

clickBubble: false нужно для того, чтоб событие не всплывало к родителям и не влияло на них.

Оп-па новый стиль.

Можно переходить к CSS. Я не стал придумывать свой CSS, а просто упростил стили из другой библиотеки.

Привожу их ниже:

Стили

.tree li, 
.tree ins{ background-image:url("http://habrastorage.org/storage2/0eb/507/98d/0eb50798dca00f5cc8e153e6da9a87f9.png"); background-repeat:no-repeat; background-color:transparent; }
.tree li { background-position:-90px 0; background-repeat:repeat-y; }

.tree li { display:block; min-height:18px; line-height:18px; white-space:nowrap; margin-left:18px; min-width:18px; }

.tree ul, .tree li { display:block; margin:0 0 0 0; padding:0 0 0 0; list-style-type:none; }
.tree li { display:block; min-height:18px; line-height:18px; white-space:nowrap; margin-left:18px; min-width:18px; } 
.tree > ul > li { margin-left:0px; }

.tree li.last { background:transparent; }
.tree .open > ins { background-position:-72px 0;}
.tree .closed > ins { background-position:-54px 0;}
.tree .leaf > ins { background-position:-36px 0;}


.tree ins { display:inline-block; text-decoration:none; width:18px; height:18px; margin:0 0 0 0; padding:0; }
li.open > ul { display:block; }
li.closed > ul { display:none; } 

Полный JavaScript

function setIsLast(children){
  for(var i=0,l=children.length;i<l;i++){
     children[i].isLast = (i==(l-1));   
  }
}

function TreeViewNode(caption,children){
  var self = this;
  this.caption = caption;
  this.children = children||[];
  
  this.isOpen = ko.observable();
  this.isClosed = ko.computed(function(){
       return !this.isOpen();
  },this);
  
  this.isLeaf = !this.children.length;
  this.isLast = false;
  setIsLast(this.children);
  
  this.toggleOpen = function(){
    self.isOpen(!self.isOpen());
  };
}

function TreeView(children){
  this.children = children;
  setIsLast(this.children);
}

var vm = {
  tree: new TreeView([
     new TreeViewNode('Node 1',[
        new TreeViewNode('Node 3')
     ]),
     new TreeViewNode('Node 2')
  ])
};

ko.applyBindings(vm);

Полный HTML

<div class='tree' data-bind='with: tree'>
  <ul data-bind='template: {name:"treeNode", foreach: children}'>
  </ul>    
</div>  

<script id='treeNode' type='text/html'>
  <li data-bind='css:{closed:isClosed,open:isOpen, leaf: isLeaf, last: isLast}, click: toggleOpen, clickBubble: false'> 
    <ins></ins>
    <span data-bind='text:caption'></span>
    <ul data-bind='template: {name:"treeNode", foreach: children}'>
    </ul> 
  </li>  
 </script>

Получилось примерно так:
Knockoutjs. «Растим» дерево
JSFiddle для экспериментов.

Не очень красиво, но уже вполне функционально. Теперь не сложно добавить каждому узлу иконку или checkbox. Можно сделать узлы выделяемыми. Но прежде всего хотелось бы сделать код более универсальным, чтобы TreeView можно было легко использовать с произвольной иерархической структурой данных. Более того, необходимо, чтобы изменения в данных автоматически отражались в модели дерева, а далее и в DOM.

В ФП это называют «map».

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

function dataToNodes(dataArray){
    var res = [];
    for(var i=0,l=dataArray.length;i<l;i++){
        res.push(new TreeViewNode(dataArray[i]));
    }
    return res;
}

TreeViewNode теперь берет на вход не надпись узла и «детей», а некие абстрактные данные. Очевидно, что надпись и «детей» он должен сам извлекать из данных. И для начала предположим, что данные не совсем абстрактные, а это объект, в котором подпись хранится в свойстве caption, а «дети» в свойстве children, которое является массивом.

function TreeViewNode(data){
 ...
  this.data = data;  
  this.caption = data.caption;
  if(data.children) this.children = dataToNodes(data.children);
  else this.children = [];
...
}

Это не тот map, который map в ФП.

Впрочем, нам несложно отказаться от жесткой привязки к именам свойств. Пусть нам передается некий объект map, в котором указывается соответствие свойств:

function TreeViewNode(data){
   ...
   this.data = data;  
   var captionProp = (map && map.caption)||'caption',
       childrenProp = (map && map.children)||'children';
   this.caption = data[captionProp];
   if(data[childrenProp]) this.children = dataToNodes(data[childrenProp]);
   else this.children = []; 
...
}

А еще лучше, чтобы была возможность определять соответствие свойств динамически, исходя из типа объекта data:

function TreeViewNode(data){
  ...
   this.data = data;  
   var 
       map = (typeof propMap == 'function') ? propMap(data):propMap,
       captionProp = (map && map.caption)||'caption',
       childrenProp = (map && map.children)||'children';
...
}

Теперь можно спрятать объявление TreeViewNode и вспомогательных функций внутрь объявления модели TreeView, поскольку экземпляры TreeViewNode более не должны создаваться пользователем самостоятельно.

Полный JavaScript

function TreeView(data, propMap){
  this.data = data;  
  this.children = dataToNodes(data);
  setIsLast(this.children);

  function dataToNodes(dataArray){
    var res = [];
    for(var i=0,l=dataArray.length;i<l;i++){
        res.push(new TreeViewNode(dataArray[i]));
    }
    return res;
  }
    
  function setIsLast(children){
   for(var i=0,l=children.length;i<l;i++){
     children[i].isLast = (i==(l-1));   
   }
  }

  function TreeViewNode(data){
   var self = this;
   this.data = data;  
   var 
       map = (typeof propMap == 'function') ? propMap(data):propMap;
       captionProp = (map && map.caption)||'caption',
       childrenProp = (map && map.children)||'children';
   this.caption = data[captionProp];
   if(data[childrenProp]) this.children = dataToNodes(data[childrenProp]);
   else this.children = []; 
   
   this.isOpen = ko.observable();
   this.isClosed = ko.computed(function(){
        return !this.isOpen();
   },this);
  
   this.isLeaf = !this.children.length;
   this.isLast = false;
   setIsLast(this.children);
   
   this.toggleOpen = function(){
     self.isOpen(!self.isOpen());
   };
  }

}

var vm = {
    data: [
        {
            name:'Node 1',
            list: [{
                name: 'Node 3'
            }]
        },
        {
            name:'Node2',
            list: [{
                name: 'Node 6',
                list: [{
                name: 'Node 5'
                }]
            }]
        }
    ]    
};
vm.tree = new TreeView(vm.data,{caption:'name',children:'list'});

ko.applyBindings(vm);

JSFiddle для экспериментов.

Магия начинается тут.

Теперь нам необходимо выполнить последнее требование — автоматическое отражение изменений данных в модели «дерева». Воспользуемся «магией» KnockoutJS при помощи объектов ko.observable, ko.computed, ko.observableArray. Для этого нам необходимо всего лишь сделать свойство children вычисляемым. А также изменить код для других свойств, которые от него зависят:

 function TreeViewNode(data){
...
if(data[childrenProp]) this.children = ko.computed(function(){
         return dataToNodes(ko.utils.unwrapObservable(data[childrenProp]));
    });
    else this.children = null; 
...
this.isLeaf = ko.computed(function(){
         return !(this.children && this.children().length);
   },this);
   this.isLast = ko.observable(false);
      
   if(this.children){
       setIsLast(this.children());
       this.children.subscribe(function(newVal){
          setIsLast(newVal);
       });   
   }
...

Функция ko.utils.unwrapObservable возвращает текущее значение наблюдаемого объекта, или, если это не наблюдаемый объект, то же значение, что передали ей на вход. Использование ko.utils.unwrapObservable внутри ko.computed автоматически создаст зависимость и .children будет автоматически обновляться, если мы использовали в качестве данных наблюдаемое значение. С другой стороны можно использовать и просто JS массив, тогда автоматического отслеживания изменений не будет.
Аналогично поступаем для TreeView

function TreeView(data, propMap){
  ...
  this.children = ko.computed(function(){ 
       return dataToNodes(ko.utils.unwrapObservable(data));
  });                                       
  setIsLast(this.children());
  this.children.subscribe(function(newVal){
          setIsLast(newVal);
  });   
...

Теперь изменения в данных будут автоматически отражаться в модели «дерева» и далее автоматически в DOM.
Можете поэкспериментировать с JSFiddle.
Есть только одна неприятная проблема — добавление узлов сворачивает наше дерево. Это происходит оттого, что мы каждый раз при обновлении создаем модели TreeViewNode заново. Необходим более умный подход — создавать модели только для новых данных, а для старых использовать старые. Это можно реализовать двумя способами:

  1. Сохранять ссылку на TreeViewNode в данных;
  2. При обновлении списка узлов искать TreeViewNode в старом списке.

Я покажу первый способ, потому что он короче. Однако он имеет ограничение — если вы захотите использовать один и тот же объект для разных узлов дерева, этот способ не сработает. Точнее он будет приводить к неожиданным эффектам. Но если у вас каждому объекту данных соответствует только один узел дерева, то все будет нормально.
Итак:

 function TreeViewNode(data){
...
 data._treeviewNode = this; // сохраняем в данных ссылку на наш узел
...
 }
  function dataToNodes(dataArray,old){
      var res = [];
    for(var i=0,l=dataArray.length;i<l;i++){
        res.push(dataArray[i]._treeviewNode || new TreeViewNode(dataArray[i])); // создаем новый узел только для новых данных
    }
    return res;
  }

Возможность выбора — это всегда приятно.

Наше «дерево» практически выращено. Для полного счастья нам не хватает только возможности выбирать отдельные узлы «дерева».

function TreeView(data, propMap){
  var treeView = this; // сохраняем ссылку на TreeView
  this.selectedNode = ko.observable(); // выделенный узел
...
function TreeViewNode(data){
...
   this.isSelected = ko.computed(function(){   // показывает выделен ли этот узел
         return (treeView.selectedNode()===this)    
   },this); 
   
   this.toggleSelection = function(){   // обработчик события для выделения
       if(this.isSelected()) treeView.selectedNode(null);
       else treeView.selectedNode(this);
  } 
}
}

Также необходимо дополнить шаблон:

...
<span class='caption' data-bind='text:caption, css: {selected:isSelected},click:toggleSelection, clickBubble: false'></span>
...

Теперь мы можем построить полноценный редактор «дерева».

Полный JavaScript

function TreeView(data, propMap){
  var treeView = this;
    
  this.data = data;  
  this.selectedNode = ko.observable();  
  this.children = ko.computed(function(){ 
       return dataToNodes(ko.utils.unwrapObservable(data));
  });                                       
  setIsLast(this.children());
  this.children.subscribe(function(newVal){
          setIsLast(newVal);
  });   
  
  

  function dataToNodes(dataArray,old){
      var res = [];
    for(var i=0,l=dataArray.length;i<l;i++){
        res.push(dataArray[i]._treeviewNode || new TreeViewNode(dataArray[i]));
    }
    return res;
  }
    
  function setIsLast(children){
   for(var i=0,l=children.length;i<l;i++){
     children[i].isLast(i==(l-1));   
   }
  }

  function TreeViewNode(data){
   var self = this;
   this.data = data;  
   data._treeviewNode = this;   
   var 
       map = (typeof propMap == 'function') ? propMap(data):propMap;
       captionProp = (map && map.caption)||'caption',
       childrenProp = (map && map.children)||'children';
   this.caption = data[captionProp];
   if(data[childrenProp]) this.children = ko.computed(function(){
         return dataToNodes(ko.utils.unwrapObservable(data[childrenProp]));
    });
    else this.children = null; 
   
   this.isOpen = ko.observable();
   this.isClosed = ko.computed(function(){
        return !this.isOpen();
   },this);
  
   this.isLeaf = ko.computed(function(){
         return !(this.children && this.children().length);
   },this);
   this.isLast = ko.observable(false);
      
   if(this.children){
       setIsLast(this.children());
       this.children.subscribe(function(newVal){
          setIsLast(newVal);
       });   
   }
      
   this.toggleOpen = function(){
     self.isOpen(!self.isOpen());
   };
   
   this.isSelected = ko.computed(function(){
         return (treeView.selectedNode()===this)    
   },this);   
   
   this.toggleSelection = function(){
       if(this.isSelected()) treeView.selectedNode(null);
       else treeView.selectedNode(this);
   }
      
  }

}

function SomeObject(col){
    this.name = ko.observable('New SomeObject');
    this.list = ko.observableArray();
    this.collection = col;
}

var vm = {
    data:ko.observableArray(),
    AddRootNode: function(){
      this.data.push(new SomeObject(this.data));
    },
    AddChildNode: function(){
        var data = this.tree.selectedNode().data;
        data.list.push(new SomeObject(data.list));
    },
    RemoveNode:function(){
        var data = this.tree.selectedNode().data;
        this.tree.selectedNode(null);
        data.collection.remove(data);
    }
 };
vm.tree = new TreeView(vm.data,{caption:'name',children:'list'});

ko.applyBindings(vm);

Полный HTML

<button data-bind='click:AddRootNode'>Add New Root Node</button>
<button data-bind='click:AddChildNode,visible: tree.selectedNode'>Add Child toSelected Node</button>
<button data-bind='click:RemoveNode,visible: tree.selectedNode'>Delete Selected Node</button>

<div class='tree'  data-bind='with: tree'>
  <ul data-bind='template: {name:"treeNode", foreach: children}'>
  </ul>    
</div>  

<div data-bind='with: tree.selectedNode'>
    <!-- ko with: data -->
        <input data-bind='value:name'>
    <!-- /ko   -->
</div>    


<script id='treeNode' type='text/html'>
  <li data-bind='css:{closed:isClosed,open:isOpen, leaf: isLeaf, last: isLast}, click: toggleOpen, clickBubble: false'> 
    <ins></ins>
    <span class='caption' data-bind='text:caption, css: {selected:isSelected},click:toggleSelection, clickBubble: false'></span>
    <ul data-bind='template: {name:"treeNode", foreach: children}'>
    </ul> 
  </li>  
 </script>


Полный CSS

.tree li, 
.tree ins{ background-image:url("http://habrastorage.org/storage2/0eb/507/98d/0eb50798dca00f5cc8e153e6da9a87f9.png"); background-repeat:no-repeat; background-color:transparent; }
.tree li { background-position:-90px 0; background-repeat:repeat-y; }

.tree li { display:block; min-height:18px; line-height:18px; white-space:nowrap; margin-left:18px; min-width:18px; }

.tree ul, .tree li { display:block; margin:0 0 0 0; padding:0 0 0 0; list-style-type:none; }
.tree li { display:block; min-height:18px; line-height:18px; white-space:nowrap; margin-left:18px; min-width:18px; } 
.tree > ul > li { margin-left:0px; }

.tree li.last { background:transparent; }
.tree .open > ins { background-position:-72px 0;}
.tree .closed > ins { background-position:-54px 0;}
.tree .leaf > ins { background-position:-36px 0;}


.tree ins { display:inline-block; text-decoration:none; width:18px; height:18px; margin:0 0 0 0; padding:0; }
li.open > ul { display:block; }
li.closed > ul { display:none; } 
.selected {background-color: #ccc; }
span.caption {cursor: pointer}

JSFiddle для экспериментов.

Итоги. Скрытый пиар. Раздача слонов.

Итак, мы получили вполне работоспособную реализацию TreeView приспособленную для работы с Knockout, которая занимает на JavaScript чуть более 60 строк кода, понятна, легко может быть расширена новыми функциями, легко адаптируется к модели данных. Теперь рассмотрим возможные сценарии повторного использования:

  1. Copy&Paste функции TreeView свой JS код или вынесение ее в отдельный файл. Вставка стилей в свои стили, или импорт стилей в виде отдельного файла. Вставка и адаптация шаблонов. Такой сценарий похож на сценарий использования code snippet.
  2. Сделать свою привязку (binding).
  3. Воспользоваться моей библиотекой knockout-component для конвертации набора шаблон+модель в привязку.

Я воспользовался третьим способом. Теперь вставка дерева сводится к такому HTML коду:


<div data-bind='kc.treeView: {data:data,map:{caption:"name",children:"list"}},kc.assign:tree'></div>

Как я это сделал — тема для отдельной статьи. А посмотреть на предыдущий пример, но уже с использованием TreeView в качестве компонента можно тут JSFiddle
Сам компонент доступен на GitHub.

Автор: xdenser

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js