Вступление

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

В данной статье перечислены начальные шаги, и проблемы, с которыми Я столкнулся, когда решил написать для себя очередной, универсальный инструмент, для создания интерфейса веб-приложения.

Дисклеймер

В статье не используются современные решения и стандарты. Хотя весь код был написан под браузер Chrome последней версии, автор ориентируется на API, стандартизированный спецификацией ECMAScript 5. Кроме того, в данной части статьи код использует API не совместимый с браузером IE (Internet Explorer). В следующих частях будет описано, как устранить несовместимость с IE.

С чего начнём

Во-первых, для начала решим, из чего будет состоять фреймворк. Можно тупо писать всё в один файл. В итоге, можем закончить с файлом, состоящий из 100 тысяч строк кода. Мы можем разделить всё на отдельные файлы, каждый файл представить в виде отдельного модуля, и определить один главный файл, который всё подтянет. Окей, давайте же сделаем это. Напишем index.html с нашим главным скриптом: "widgets-all.js". В тэге <body> зададим <div> с идентификатором root. Данный <div> будет контейнером приложения. А через тэг <style> зададим данному элементу абсолютное позиционирование, и координаты верхнего, левого угла.

<!DOCTYPE html>
<html>
  <head>
    <title>Main GUI</title>
    <meta charset="UTF-8">
    <style>
      #root {
        position: "absolute";
        left: "0px";
        top: "0px";
      }
    </style>
    <script type="text/javascript" src="./widgets-all.js">
    </script>
  </head>
  <body>
    <div id="root">
    </div>
  </body>
</html>

А теперь сделаем так, чтобы наш "widgets-all.js" загружал модули последовательно и синхронно. И вот тут возникает ПЕРВАЯ ПРОБЛЕМА. Если мы динамически создаём тэги <script> и внедряем их в документ, то некоторые браузеры загружают их асинхронно, несмотря на то, что мы явно устанавливаем атрибут "async" в значение false. А это значит, что скрипт может загрузиться позже, чем этого хотелось. В результате из-за зависимостей у нас будет классическая ошибка "varialbeName is not a function". Самый простой способ динамически загрузить скрипты синхронно и последовательно, это использовать метод документа - document.write(htmlStr).

Напишем данный код

//Файл widgets-all.js

//Заставляем HTMLParser прерваться и синхронно записать содержимое
//Данные элементы будут правыми братьями текущего тэга.
//Ссылку на сам тэг можно получить либо заранее по id (если его задать самому)
//либо через свойство document.currentScript.

document.write('<script type="text/javascript" src="./Module1.js"></script>');
document.write('<script type="text/javascript" src="./Widgets.js"></script>');

Отлично. Теперь после нашего "widgets-all.js" будут добавлены два тэга <script>. Если же после самого "widgets-all.js" были другие тэги <script>, то они не затрутся, поскольку документ ещё не загружен. Когда документ загружен, то вызывать document.write нельзя, иначе можно стереть весь контент страницы. Следует отметить, что данное решение - костыль, так как согласно спецификации, некоторые браузеры будут игнорировать вставку и исполнение кода переданных тэгов <script>. Но оно работает в большинстве современных браузеров до сих пор.

Определение модуля

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

//define module1 at global scope (as window property)
var module1 = (function(){
  var module1 = null; //local scope.
  module1 = {};
  return module1; //assign to global name local object.
}());

И тут, разумеется, ВТОРАЯ ПРОБЛЕМА. НАИМЕНОВАНИЯ МОГУТ БЫТЬ НЕ УНИКАЛЬНЫМИ. Самый простой случай, это когда мы пытаемся подключить различные версии своей же библиотеки, в которых имена модулей не менялись. Добавим проверку учёта имени, а также определим функцию разрешения конфликтов.

var module1 = (function(){
  var m = null; //local scope. Rename var to distinguish global name.

  //defined above at someone else.
  if(typeof module1 != "undefined")
     var _____module1 = module1; //save original value.
  
  m = {
    resolveConflict: function(){
      window.module1 = _____module1; //reset to original value in global scope.
      return this; //return new value.
    }
  };
  return m; //assign to global name local object.
}());

Мы переименовали локальную переменную, поскольку теперь нам нужно проверять глобальную. Если глобальная переменная уже определена, то сохраняем её старое значение в локальную переменную "_____module1". А с помощью открытого метода resolveConflict возвращаем её значение в глобальный контекст. А чтобы не потерять её предыдущее значение, возвращаем его этой же функцией.

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

Состояние загрузки документа определяется через свойство document.readyState.

  • "loading" - документ загружается. Не все тэги были разобраны.

  • "interactive" - синтаксический анализ документа завершён. К этому моменту, все тэги были распознаны и вставлены в DOM. Однако, содержимое элементов <iframe> и <img>, <script> по src ещё не загружено.

  • "complete" - документ полностью загружен.

Нам нужно, чтобы скрипт приложения, который будет использовать нашу библиотеку, запускался в состоянии "interactive" или "complete".

Разумеется, можно пойти простым путём и поместить скрипт в качестве последнего дочернего элемента тэга <body>, а содержимое с виджетами класть в элемент root.

Но если вы хотите поместить свой скрипт в тэге <head> перед <body>, то чтобы он корректно работал с документом, его необходимо запускать в состоянии "interactive" или "complete".

Ниже дан код, как это сделать.

//Файл Widgets.js
//Этот модуль будет содержать в себе все зависимости.
//В нём аналогично определяется resolveConflict 
//(которая вызывает одноимённую функцию у каждого модуля)

var Widgets = (function(){

  //Стандартная проверка имён.
  //Восстанавливается по resolveConflict()
  if(typeof Widgets != "undefined")
    var _____Widgets  = Widgets;
  
  var m = {
    utils: {
      module1: module1 //global name defined previously at Module1.js script.
    }
  };

  var isBoundReady = false; //Зарегистрирован ли обработчик
  var isReady = false; //Загружен ли DOM.
  var readyList = []; //Список функций f, которые должны быть вызваны, когда DOM будет загружен.

  var userAgent = navigator.userAgent.toLowerCase(); //name of current browser

  //browser names
  var browser = {
		version: (userAgent.match(/.+(?:rv|it|ra|ie)[\/: ]([\d.]+)/) || [])[1],
		safari: /webkit/.test(userAgent),
		chrome: /chrome/.test(userAgent),
		opera: /opera/.test(userAgent),
		msie: /msie/.test(userAgent) && !/opera/.test(userAgent),
		mozilla: /mozilla/.test(userAgent) && !/(compatible|webkit)/.test(userAgent)
  };
  
  //private handler
  function ready(){
    if(isReady) //DOM loaded only once.
      return;
    
      isReady = true; //DOM is loaded.

      //exec each f on readyList
      for(var i = 0; i < readyList.length; i++){
        readyList[i]();
      }

      readyList = null; //clear list.

      //and remove event handler.
      if(browser.mozilla || browser.opera || browser.chrome)
        document.removeEventListener("DOMContentLoaded", ready, false);

      //for IE browser
      var s = document.getElementById('__ie_init');
      if(s)
        s.remove(); //with onreadystatechange hndlr.
  }; //end ready()

  //registrate ready handler.
  function bindReady(){
    if(isBoundReady) //execute only once.
      return;
    
    isBoundReady = true; //flag that handler registrated.

    if (browser.mozilla || browser.opera || browser.chrome ){
      document.addEventListener("DOMContentLoaded", ready, false );
    }
	
    // If IE is used, use the excellent hack by Matthias Miller
    // http://www.outofhanwell.com/blog/index.php?title=the_window_onload_problem_revisited
    else if (browser.msie ) {
			
      // Again use document.write to synchronous add <script>
      document.write("<scr" + "ipt id=__ie_init defer=true " + 
        "src=//:><\/script>");
	
      // Use the defer script hack
      var script = document.getElementById("__ie_init");
			
      if ( script ) { 
        script.onreadystatechange = function() {
          if ( this.readyState != "complete" ) return;
          ready();
        };
      }
      
    } else if (browser.safari ){
		
        // Continually check to see if the document.readyState is valid
        timers.safariTimer = setInterval(function(){
          if ( document.readyState == "loaded" 
            || document.readyState == "complete"
            || document.readyState == "interactive") {
            clearInterval( timers.safariTimer );
            timers.safariTimer = null;
            ready();
          }
        }, 10);
    }

  }; //end bindReady()

  //public function
  //Executes f only when document has been loaded or parsed
  m.onReady = function(f){
    if(typeof f !== 'function')
      throw new TypeError('onReady(): Argument f is not a function');

    bindReady(); //registrate handler.
    if(isReady){
      f.apply(this, f.arguments); //call f with its arguments.
    }
    else {
      //append f. 
      //anonymous function is wrapper to preserve context and arguments.
      readyList.push(function(){
        return f.apply(this, f.arguments);
      });
    }
  }; //end onReady()

  m.resolveConflict = function(){
    module1.resolveConflict();//restore original value of global name module1
    window.Widgets = _____Widgets; //original value of Widgets
    return this; //new value of Widgets
  }
  
  return m;
}());

Теперь достаточно вызвать открытую функцию модуля Widgets.onReady и передать ей функцию, которая будет вызвана, когда документ будет обработан. Добавим простой тест в файл index.html. Для этого определим два тэга <script>, один перед фреймворком, другой после него. Первый определит глобальные имена, совпадающие с именами модулей. Второй будет содержать код приложения, который будет использовать фреймворк.

<head>
  ...
  <!--Заранее определим старые значения для имён модулей-->
  <script type="text/javascript">
    var Widgets = 1111, module1 = 2222;
  </script>
  <script type="text/javascript" src="./widgets-all.js"> <!--framework/libs-->
  </script>
  <script type="text/javascript"> <!--application-->
    Widgets.onReady(function(){
      console.log(document.readyState); // => interactive or complete.
      var lib = Widgets.resolveConflict();
      console.log(Widgets);
      console.log(module1);
      window.lib = lib;
      console.log("%o", lib);
    });
  </script>
</head>

В результате, у нас должен получиться следующий вывод:

interactve
1111
2222
{
  resolveConflict: function(){...},
  onReady: function(f){...},
  utils: { module1: {...} },
  ...other properties inherited from Object.prototype
}

Определение типов

Разобравшись с загрузкой и разрешением имён модулей, теперь можно подумать о самих элементах модуля. Что есть компонент? Как его создавать? Как с ним работать? Что он содержит?

Определим компонент как экземпляр класса, а класс - определение компонента, его схему (чертёж). Классы можно реализовать с помощью функций конструкторов, если не брать в расчёт современные стандарты (ES6). Будем реализовывать класс вручную. Следовательно, экземпляры класса будут создаваться вызовом функции конструктора через оператор "new".

Хорошо, но что будет относится к самому классу, а что к конкретному экземпляру? Самый простой пример, имя класса - это свойство класса, а не экземпляра. Например, имя класса Человек - "Человек" общее для всех экземпляров людей, у которых может быть собственное имя (Джон, Смит, Билли). И где и как хранить свойства класса?

Свойства класса - объект. Этот объект должен быть доступен, для извлечения метаданных класса. Хранить его мы будем не как отдельное свойство функции конструктора, а как свойство самого объекта-модуля. Определим, какие свойства будет содержать данный объект:

  • className - имя класса

  • callConstructor - функция-конструктор класса

  • callParent - функция-конструктор родительского класса

  • beforeCreate - функция-валидатор, вызывается перед созданием экземпляра класса, проверяет аргументы, переданные в конструктор, читая constructorParameters.

  • noDefaultConstructor - флаг, указывающий запрещено ли использовать конструктор по умолчанию. Т.е. конструктор без параметров.

  • constructorParameters - объект, описывающий параметры конструктора. Свойства объекта - имена параметров. Каждый параметр представлен отдельным объектом со следующими свойствами:

    • required - обязателен ли параметр.

    • type - тип параметра в виде строки.

    • oridnal - позиция в массиве аргументов функции-конструктора.

    • getter - функция преобразователь. Преобразует параметр произвольного типа в соответствующий тип, указанный в type, если это возможно.

  • count - число экземпляров данного класса.

  • getCount - метод, возвращающий значение count.

Описав свойства класса, мы можем написать функцию, которая создаст объект, представляющий класс, от которого можно создавать экземпляры. Но перед этим, мы должны решить три вопроса: будут ли переопределяться одинаковые имена в дочерних классах? Надо ли вызывать конструктор родительского класса самому напрямую, или же пусть этим займётся фреймворк (автоматически вызывая конструктор родителя при вызове конструктора дочернего класса)? Если переопределять метод, должны ли мы включать логику старого метода родителя, или мы его полностью стираем?

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

Окей, пойдем реализовывать. Начнём с имён классов и переопределения методов.

//Файл Widgets.js
//В теле функции модуля.
//1. Определяем вспомогательные функции.

var protoprops = ['toString', 'toJSON', 'valueOf', 'constructor',
                  'hasOwnProperty', 'isPrototypeOf', 'propertyIsEnumerable',
                  'toLocaleString'];

//Добавим метод extend, который копирует свойства из объектов.
//Defined at ECMAScript 5.
//Not worked at IE.
Object.defineProperty(Object.prototype, "extend", {
  value: function(t){
    for(var i = 0; i < arguments.length; i++){
      var source = arguments[i];
      for(var p in source)
        if(!(p in this))
          this[p] = source[p];
      for(var j = 0; j < protoprops.length; j++){ //IE
        p = protoprops[j];
        if(source.hasOwnProperty(p)) this[p] = source[p]; //Object.prototype props at some IE treat as ownProperties.
      }
    }
    return this;
  }, writable: false, configurable: false, enumerable: false
});

//Определим функции извлечения имени типа (класса).
function classof(o){
  return Object.prototype.toString.call(o).slice(8, -1);
}

Function.prototype.getName = function(){
  if('name' in this) return this.name;
  return this.name = this.toString().match(  /function\s*([^(]*)\(/ )[1]; //pattern: function fName( where fName is first group
};

Function.prototype.setName = function(className){
  Object.defineProperty(this, "name", {value: className, configurable: false, writable: false, enumerable: false});
};

//Возвращает имя типа или класса для о.
//Для примитивных типов и функций возвращает type.
//Для объектов возвращает имя функции-конструктора.
//Для встроенных объектов возвращает значение аттрибута "class".
function type(o){
  var t, c, n;
  if(o === null) return "null";
  if(o === undefined) return "undefined";
  if(o !== o) return "NaN";

  //primitives and functions
  if((t = typeof o) !== 'object') return t;
		
  //base objects.
  if((c = classof(o)) !== 'Object') return c;
		
  if(o.constructor && typeof o.constructor === "function"
    && (n = o.constructor.getName())
  ) 
    return n;

  //extract className from classObject of instance o.
  if(o.self && o.self.className && typeof o.self.className === 'string')
    return o.self.className;
  
  return "Object"; //common type.
};

//Определить, является ли объект instance подклассом класса с именем className
//или сам instance класс с именем className.
//Если instance примитив - идёт сравнение по типу.
//Если instance потомок или объект класса className => true.
function isSubClassOf(instance, className){
  if(instance == null || instance == undefined) //null or undefined is not a class
    return false;
  if(typeof className !== 'string')
    throw new TypeError('Argument className must be String!');
  if(typeof instance !== 'object' && typeof instance === className)
    return true;
  else if(typeof instance === 'string' && className === 'cssStyle')
    return true;
  
  var cls = instance.self || instance; //if not instance => assume as classObject
  while(cls){
    if(cls.className && cls.className === className)
      return true;
    cls = cls && cls.callParent && cls.callParent.prototype && cls.callParent.prototype.self;
  }

  return false;
};

//Переопределяет методы и свойства родительского класса.
//Переопределение свойств разрешено для совместимых (дочерних) типов.
//Метод заменяется анонимной функцией, которая
//вызывает унаследованный метод родителя, а затем свой собственный.
//cls - дочерний класс.
//props - методы и свойства экземпляра данного класса (объекта-прототипа)
function overrides(cls, props){
  if(!cls || !cls.callParent || !props) //no props or superclass or noclass => nothing overrided
    return;
  var props = Object.keys(cls.callParent.prototype); //parent props

  //through enumerable and own props
  for(var i = 0; i < props.length; i++){
    var prop = props[i];
    if(prop in funs){ //defined at subclass too.
      var oldp = cls.callParent.prototype[prop]; //save old property value.
      var newp = funs[prop]; //new method/prop that must be copied.
      var t = undefined;
      if((t = typeof newp) !== typeof newp)
        continue;
      else if(t === 'function'){ //matched by type and type is fun => override.
        cls.callConstructor.prototype[prop] = function()
        {

          //calls parent then overriden version.
          //When you use recursion
          //this method also calls parent m() recursively
          oldp.apply(this, arguments);
          newp.apply(this, arguments);
        }; //end f.
      } //end else

      //check type by covariance.
      else if(isSubClassOf(newp, type(oldp))){
        cls.callConstructor.prototype[prop] = newp; //reset to new value.
      }
      //matched primitive.
      else if(t !== 'object'){
        cls.callConstructor.prototype[prop] = newp; //reset to new value.
      }
    } //endif
  }//end for
}

С помощью функции type мы пытаемся получить имя функции-конструктора объекта, или имя класса из объекта класса. Каждый экземпляр класса имеет ссылку на объект самого класса в виде свойства self. Также была определена функция isSubClassOf, которая определяет является ли объект экземпляром класса, указанным по имени. Объект является экземпляром класса A, если он либо экземпляр самого класса A, либо экземпляр одного из его дочерних классов. Если это примитив, то функция проверяет, совпадает ли его тип с указанным типом. Эти функции используются в методе overrides, который переопределяет методы и свойства дочернего класса, если указанные методы и свойства если

  1. Они уже определены в родительском классе.

  2. Они совпадают по типу и не являются объектом.

  3. Они являются экземпляром дочернего класса родительского экземпляра. Т.е. они совместимы по классовому типу.

Теперь, можно определить функцию определения классов - defineClass

//Файл Widgets.js
//Вспомогательные функции и проверки...

//Вспомогательная функция определения классов.
//Принимает функцию конструктор родительского класса (superCtor)
//функцию конструктор нового класса (ctor)
//функцию валидатора параметров конструктора (before)
//методы и свойства экземпляра класса (methods)
//методы и свойства самого класса (statics)
//и описания параметров конструктора класса (paramsDesc)
function defineClass(superCtor, ctor, before, methods, statics, paramsDesc){
  var c = {};
  if(superCtor && ctor){ //if no constructor => return null. 
    ctor.prototype = Object.create(superCtor.prototype); //inherit superclass prototype
    c.callParent = superCtor; //save superClass constructor function.
  }
  if(ctor){
    ctor.prototype.constructor = ctor;
    ctor.prototype.self = c;

    if(methods)
      ctor.prototype.extend(methods);
    if(statics)
      c.extend(statics);
    
    c.callConstructor = ctor;
    c.className = type(ctor.prototype);
			
    if(!before)
      before = (superCtor && superCtor.prototype && superCtor.prototype.self && superCtor.prototype.self.beforeCreate);

    //copy beforeCreate into classObject.
    if(before && typeof before === 'function')
      Object.defineProperty(c, "beforeCreate", {writable: false, enumerable: false, configurable: false,
        value: before
      });

    //inherit static properties from superclass.
    if(superCtor && superCtor.prototype && superCtor.prototype.self)
      c.extend(superCtor.prototype.self);

    //define .ctor params description.
    c.constructorParameters = null;
    c.constructorParameters = {}; //do not copy object from parent. define new with props from parent.

    //copy from superclass into ctor.constructorParameters
    if(superCtor && superCtor.prototype && superCtor.prototype.self && superCtor.prototype.self.constructorParameters)
      c.constructorParameters.extend(superCtor.prototype.self.constructorParameters);
    
    //and add own parameters.
    if(paramsDesc)
      c.constructorParameters.extend(paramsDesc); //just attach props to new object from parent and paramDesc.

    //override properties and methods of parent with owns.
    overrides(c, methods);
    return c;
  }
  else return null; //no ctor => no class object.
};

Эта функция создаёт объект класса, с помощью которого можно будет создавать экземпляры самого класса (через свойство callConstructor или beforeCreate). Определим же функцию создания экземпляра класса, а также вызова его родительского конструктора - callParent.

//Файл Widgets.js

//Публичная (открытая) функция создания экземпляра класса с именем className.
//Аргументы функции конструктора передаются в виде объекта-словаря args.
//Функция beforeCreate(), определённая ниже, парсит args
//и возвращает экземпляра класса.
Widgets.create = function(className, args){
  if(typeof className !== 'string') //name is not a string
    throw new TypeError('Expected className as String!');
  else if(!args || typeof args !== 'object') //only keyword arguments are available for object creation.
    throw new TypeError('Expected named-args (keyword arguments = kwargs) for constructor arguments!');

  var rootObj = this;
  className = className.split('.');

  for(var i = 0; i < className.length; i++)
    if(className[i] in rootObj)
      rootObj = rootObj[className[i]];
				
  var classObj = rootObj; //found ctor
  if(!classObj.beforeCreate){
    return new classObj.callConstructor(args);
  }
  else {
    return classObj.beforeCreate(args, classObj);
  }
};

//Вызывается для валидации и приведении аргументов к типам параметров конструктора.
//А также создания экземпляра класса.
function beforeCreate(){
  var kwargs = Object.keys(arguments[0]); //one single argument is object.
  var classObject = arguments[1]; //Class<T>

  //Проверяем конструктор без параметров (по умолчанию).
  if(kwargs.length === 0 && classObject.noDefaultConstructor)
    throw new TypeError('Cannot call constructor with no-args as default constructor was prohibited and now illegal!');

  else if(kwargs.length === 0) //no-args => constructor without args.
    return new classObject.callConstructor();

  //process each argument.
  var args = new Array(kwargs.length);

  //Для каждого аргумента.
  for(var i = 0; i < kwargs.length; i++){
    var paramName = kwargs[i];

    //Имя параметра не определено для конструктора.
    if(!classObject.constructorParameters || !classObject.constructorParameters[paramName])
      throw new TypeError('Property ' + paramName + ' is not defined for class ' + classObject.className + '!');

    //Дескриптор параметра и его значение.
    var paramDesc = classObject.constructorParameters[paramName];
    var paramValue = arguments[0][paramName];

    //Для обработки стилевых строк напишем пока отдельную ветку if
    //Стилевые строки будут иметь тип cssStyle
    //Данное наименование типа эквивалетно типу string.
    if(paramDesc.type === "cssStyle"){ //Стилевая строка.
      
      //Проверяем, есть ли getter для строки в cssStyleValidators
      if(typeof cssStyleValidators[paramName] !== 'function')
        throw new TypeError('Cannot find getter for cssStyle property: "' + paramName + '"!');
      
      //Если нашли getter, то приводим строку к виду "styleProperty": "styleValue";
      paramValue = "" + paramName + ":" + cssStyleValidators[paramName](paramValue);
    }

    //Проверка обязательных параметров.
    if(paramDesc.required && 
      (
        (paramValue =  (paramDesc.getter && typeof paramDesc.getter === 'function' && paramDesc.getter(paramValue)) || paramValue) !== paramValue  //NaN
					|| !isSubClassOf(paramValue, paramDesc.type) //then is null => false.
      )    
    )
      throw new TypeError('Expected required parameter ' + paramName + ' with type ' + paramDesc.type + ' but actual ' + type(paramValue));

    //Если это необязательный параметр и его значение не опущено (передано)
    //то проверяем его.
    else if(!paramDesc.required && paramValue && 
      (
					(paramValue =  (paramDesc.getter && typeof paramDesc.getter === 'function' && paramDesc.getter(paramValue)) || paramValue) !== paramValue  //NaN
					|| !isSubClassOf(paramValue, paramDesc.type) //then is null => false.
      )
    )
      throw new TypeError('Expected non-required parameter ' + paramName + ' with type ' + paramDesc.type + ' but actual ' + type(paramValue));


    //Для необязательных параметров с типом string, если они не заданы, то поставить им значение пустой строки.
    else if( (paramValue === null || paramValue === undefined) && !paramDesc.required && paramDesc.type === 'string')
      paramValue = '';

    //Остальные проверки на null и undefined уже выполнены.
    //Функция isSubClassOf вернёт исключение, если первый аргумент - null.
    args[paramDesc.ordinal] = paramValue;
  }

  //check omitted required parameters. ifPresent => error.
  var missingParams = [];
  kwargs = Object.keys(classObject.constructorParameters);
  for(i = 0; i < kwargs.length; i++){
    paramDesc = classObject.constructorParameters[kwargs[i]];
    if(paramDesc.required && (paramDesc.ordinal >= args.length || args[paramDesc.ordinal] === undefined))
      missingParams.push(kwargs[i]);
  }

  if(missingParams.length > 0)
    throw new TypeError('Parameters ' + missingParams.toString() + ' are required and cannot be omitted or undefined!');

	
  var instance = Object.create(classObject.callConstructor.prototype);
  classObject.callConstructor.apply(instance, args);
  return instance;
};


//Эта функция вызывает конструктор родительского класса указанного экземпляра instance.
//Через его функцию конструктор ctor (с помощью которого instance был создан)
//получаем объект класса (classObject) 
//У объекта класса получаем и делаем косвенный вызов функции конструктора родительского класса,
//передавая ему массив аргументов args.
function callParent(instance, ctor, args){
  if(ctor.prototype.self && ctor.prototype.self.callParent && typeof ctor.prototype.self.callParent === 'function'){
    ctor.prototype.self.callParent.apply(instance, args); //call parent if defined through self property. Self is a class object (Class<T>)
  }
};

Функцию beforeCreate, определённую выше, можно использовать как общий валидатор аргументов функции конструктора класса для функции defineClass. А с помощью функции callParent, можно инициировать вызов конструктора прямого родителя. Теперь определим классы и их функции конструкторы. Напишем две функции конструктора, для двух классов. Определим родительский класс Container и его дочерний класс Panel следующим образом.

//Файл Widgets.js
//Функции конструкторы классов.

//Container.
function Container(width, height, background){ 
  callParent(this, Container, arguments); //check super and call super.ctor(args)

  this.self.count += 1; //count of containers (with subclasses instances)
  this.root = document.createElement('div'); //content.
  if(!background)
    background = '#CCFFFF';
  this.background = background;

  //Пока формируем строку со стилями вручную.
  var css_style_str = "";
  if(width)
    css_style_str += 'width: ' width + 'px;';
  if(height)
    css_style_str += 'height: ' + height + 'px;';
  if(css_style_str !== ""){
    this.root.style = css_style_str;
    this.root.style.setProperty('background-color', this.background);
  }
  else
    this.root.style = "width:0;height:0;";

  this.root.style.setProperty('position', 'relative');
};

//Panel
function Panel(width, height, background, title){
  callParent(this, Panel, arguments); //check super and call super.ctor(args)
  this.title = (title) ? title : '';
  
  //create Title with backgrounds
  this.titleBar = document.createElement('div');
  this.root.appendChild(this.titleBar);

  this.titleBar.style = "position:relative; width:100%; height: 15%; top: 0; left: 0;";
  this.titleBar.style.setProperty('background-color', 'black');
  this.titleBar.style.setProperty('color', 'white');
  var span = document.createElement('span');
  span.appendChild(document.createTextNode(this.title));
  this.titleBar.appendChild(span);

  var btn_close = document.createElement('button');
  btn_close.appendChild(document.createTextNode('X'));
  btn_close.type = 'reset';

  var c_root = this.root;
  btn_close.addEventListener('click', function(e){
    c_root.remove();
  }, false);
  this.titleBar.appendChild(btn_close);
  btn_close.style = "position: absolute; top: 0; right: 0; width: 10%; height: 100%";
};

//define classes at namespace panels.
Widgets.panels = {
  Container: defineClass(null, Container, beforeCreate, {
    getWidth: function(){return this.width;},
    getHeight: function(){return this.height;},
    setWidth: function(w){this.root.style.setProperty('width', w); this.width = w;},
    setHeight: function(h){this.root.style.setProperty('height', h); this.height = h;}
  }, {
    //properties of .self object.
    count: 0,
    getCount: function(){
      return this.count; //this is classObject. => self.getCount()
    }
  }, {
    //.ctor parameters
    width: {type: 'number', required: true, getter: Number, ordinal: 0},
    height: {type: 'number', required: true, getter: Number, ordinal: 1},
    background: {type: 'string', required: false, ordinal: 2},
  }),
  
  Panel: defineClass(Container, Panel, beforeCreate, {
    getTitle: function(){return this.title;},
    setTitle: function(txt){this.titleBar.children[0].textContent = txt; this.title = txt;}
  }, /*no own statics. All inherited from parent*/ null, {
    // .ctor parameters
    title: {type: 'string', required: false, ordinal: 3}
  })
  
};

Данные классы определены в panels глобального модуля Widgets. Функция create ищет классы в объекте Widgets, извлекает из объекта класса функции beforeCreate и callConstructor. Если есть beforeCreate, то вызывается она, иначе вызывается конструктор (callConstructor). Теперь создадим экземпляры классов.

<!--index.html-->
<script type="text/javascript" src="./widgets-all.js"></script>
<script type="text/javascript">
  Widgets.onReady(function(){
    ...
    var r = document.getElementById('root');
    var p1 = Widgets.create('panels.Panel', {width: 200, height: 200, title: 'My Panel'});
    var p2 = Widgets.create('panels.Panel', {width: 400, height: 300});
    r.appendChild(p1.root);
    r.appendChild(p2.root);
  });
</script>

В итоге на странице в веб-браузере получим следующий вывод. (Браузер Chrome).

Что дальше?

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

//Файл Widgets.js

var aliases = {}; //Сохраним псевдонимы.

//define - открытая функция
//определяет класс с именем className и методами/свойствами экземпляров
//config, а также методами/свойствами самого класса - statics.
//config содержит три свойства, которые зарезерированы под
//настройки (указание родительского класса, конструктора, псевдонима)
Widgets.define = function(className, config, statics){
  var names = className.split('.');
  var rootObj = this;

  //traverse down through global object.
  //define nearest available namespace.
  for(var i = 0; i < names.length - 1; i++){
    if(!(names[i] in rootObj))
      rootObj[names[i]] = {};
    else if('callConstructor' in rootObj[names[i]]) //already defined
      throw new TypeError('Cannot define class with the same name "' + className + '"');
    
    rootObj = rootObj[names[i]];
  }

  if(rootObj[names[i]]) //at namespace className is defined.
    throw new TypeError('Cannot define class with the same name "' + className + '"');

  var methods = {}; //Методы и свойства экземпляров класса.
  var parentClassObj = Object; //Родительский класс.
  var parentCtr = Object; //и конструктор.
  var ctr = null; //функция-конструктор для нового класса.
  var options = Object.keys(config);
  var alias = null; //Псевдоним для имени класса.

  //for each properties at config.
  for(var j = 0; j < options.length; j++){
    var option = options[j]; //Проверяем, не является ли свойство спец. параметром.

    //Значение 'extend' => имя родительского класса.
    //Если такого класса нет (либо в объекте класса не задана функция callConstructor)
    //выбрасывается исключение.
    if(option === 'extend'){
      parentClassObj = propertyAt(this, config[option]);
      if(!parentClassObj || typeof parentClassObj !== 'object' || !parentClassObj.callConstructor || typeof parentClassObj.callConstructor !== 'function')
        throw TypeError('Cannot find constructor of parent class "' + config[option] + '"');

        parentCtr = parentClassObj.callConstructor;
    }

    //Значение свойства 'constructor' => функция конструктор для нового класса.
    else if(option === 'constructor'){
      ctr = config[option];
      if(!ctr || typeof ctr !== 'function')
        throw new TypeError('Constructor property is not a function!');
    }

    //Значение свойства 'alias' => строка, определяющая псевдоним (краткое имя для класса).
    else if(option === 'alias'){
      if(typeof config[option] !== 'string')
        throw TypeError('Alias property is not a string!');
      
      alias = config[option];
    }
    else
      methods[option] = config[option];
  }

  if(!ctr)
    var ctr2 = function(){
      callParent(this, ctr2, arguments); 
    };
  else
    var ctr2 = function(){
      callParent(this, ctr2, arguments);
      if(ctr2.c)
        ctr2.c.apply(this, arguments);

    };
				
  ctr2.setName(names[i]);

  //ctr2 - оборачивает конструктор ctr. ctr2 вызывает конструктор родителя.
  //Следовательно, вызывать конструктор родителя в своей функции не надо.
  rootObj[names[i]] = defineClass(parentCtr, ctr2, null, methods, statics);
  if(ctr)
    ctr2.c = ctr; //Функция конструктор обернута в функцию ctr2.


  
  if(alias)
    aliases[alias] = rootObj[names[i]];
}

Все определяемые классы, а точнее их объекты (мета-данные) храним в глобальном объекте Widgets. С помощью пар функций create/define можно создавать классы из объектов в Widgets, либо определять новые классы в объекте Widgets. Каждый отдельный класс представлен объектом. А через defineClass - напрямую определить класс, передав ему функции конструкторы родительского и дочернего классов, свойства экземпляров и самого класса, а также функцию-валидатор параметров конструктора вместе с их описанием.

Всё это имеет следующие ограничения:

  1. Необходимо придерживаться чёткого порядка параметров в функции конструкторе.

  2. В каждом следующем дочернем классе по иерархии у функции конструктора сначала перечисляются все параметры функции родителя, начиная с самого общего предка. Например следующий порядок A(p1, p2) -> B(p1, p2, p3) -> C(p1..p3, own4, own5) обозначает три класса, где А родитель В, и С - потомок В. В должен перечислить параметры А, перед тем как объявить собственные, С - тоже самое, начиная с параметров А, и заканчивая параметрами В.

  3. Строка со стилем и обыкновенная строка по типу неразличимы, однако можно указать, что параметр имеет другой тип, отличный от строки. И тем не менее, необходимо писать обработку таких строк, для корректной инициализации аnрибута style.

  4. Пока приходится писать getters/setters вручную, поскольку логика обработки значения для каждого свойства может быть разной.

  5. Нет кросс-браузерности. Совместимость с IE никакая.

В следующей части будет описано создание простого виджета - панели, о том как решить проблему с getters/setters, а также об управлении размещении компонентов внутри контейнера (панели) - layouts. А также обсудим проблему с IE и со строками стилей.

Комментарии (14)


  1. sunnybear
    02.10.2022 00:42

    Есть же асинхроннвя проверка доступности через let (или setTimeout, если по старинке). Модули все можно через неё грузить, а по загрузке вешать обработчики/хуки. Это стандартный шаблон асинхронного дизайна взаимодействия.


    1. OldNileCrocodile Автор
      02.10.2022 10:25
      -3

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


    1. OldNileCrocodile Автор
      03.10.2022 10:01

      Второе, setTimeout не гарантирует, что функция вызовется после указанной задержки, скажем в 200 ms. Он лишь кидает функцию в очередь сообщений. В эту очередь могут попасть и другие функции-обработчики. Кроме того, если после него поставить что-то, что будет вычисляться долго, то задержка превысит указанное время ожидания.


  1. Gigatrop
    02.10.2022 09:26
    +5

    Классы можно реализовать с помощью функций конструкторов, если не брать в расчёт современные стандарты (ES6). Будем реализовывать класс вручную.

    Чем это оправдано? У классов сейчас 96% поддержки.

    Вы зачем-то так сконцентрировались на классах и их реализации, хотя это банальная вещь и не имеет отношения к библиотеке.


    1. OldNileCrocodile Автор
      02.10.2022 10:27
      -3

      Чтобы динамически определять классы с помощью функций и объектов-параметров.


    1. OldNileCrocodile Автор
      03.10.2022 10:01
      -2

      В ReactJS используются классы. Но там да, классы определяются современным синтаксисом. А для динамики можно использовать class-expressions. Но тут стояла необходимость устранить дубли кода при проверке аргументов конструктора класса, а также реализовать автоматический вызов конструктора родителя, без явного вызова super().


      1. Gigatrop
        03.10.2022 10:09
        +2

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


        1. OldNileCrocodile Автор
          03.10.2022 10:28
          -2

          Хорошо. Просто тематика статьи была не про "современные решения" а ad hoc решения, написанные вручную.


  1. Zoolander
    02.10.2022 11:49
    +1

    Хорошо!
    Я вернусь к этой статье, когда забанят возможность пользоваться готовыми фреймворками


  1. i360u
    02.10.2022 13:58
    +4

    Давайте использовать устаревший синтаксис, но, при этом, не поддерживаемый в IE, чтобы потом закостылить свои модули и классы... Затем напишем кучу лапши... ЗАЧЕМ? Вы всерьез пытаетесь кого-то чему-то научить?


    1. Mingun
      02.10.2022 16:32
      +2

      Особенно странно на фоне заявления о несовместимости с IE выглядят проверки, где что-то на IE всё-таки проверяется (типа User-Agent'а).


    1. OldNileCrocodile Автор
      03.10.2022 10:14
      -1

      В дисклеймере указано, что синтаксис не современный. Поскольку тематика статьи не современные решения, а ad hoc решения. Т.е. а что делать, если у нас браузер не поддерживает современный синтаксис, а пересаживаться на новые версии лень.

      Что же касается IE, то о нём пойдет речь в следующей части статьи.


      1. i360u
        03.10.2022 12:33
        +1

        Зачем писать о IE в следующей статье? Этот браузер МЕРТВ, окончательно и бесповоротно. Его давно списали даже сами его разработчики, доля его использования в общем трафике - микроскопическая, на уровне погрешностей, и продолжает уверенно стремиться к нулю. Зачем кому-то может понадобиться инвестировать свое время в исчезнувшую платформу? Ради чего?

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

        Это какой именно браузер его не поддерживает? ES6+ поддерживают ВСЕ актуальные браузеры, уже ОЧЕНЬ давно. Вам еще нужно сильно постараться, чтобы найти некробраузер, которые умеет только в ES5, не умеет в классы и модули. А еще вы можете, если вам все-таки сильно приспичит, преобразовать свой код в ES5 в любой момент с помощью Babel.

        Пожалуйста, изучите современные технологии и стандарты, прежде чем давать людям какие-либо советы и рекомендации, иначе это выглядит как уроки по созданию каменного топора от покрытого мхом динозавра. Для реализации библиотеки виджетов, вам понадобятся Shadow DOM и Custom Elements. Еще вам понадобится стандарт ESM.


        1. OldNileCrocodile Автор
          03.10.2022 12:42

          Так-то да. Но статья была о том, что у нас этого вообще нет. И мы не хотим это скачивать и разбираться. А хотим строить своё ad hoc решение. Которое не идеальное, что указано в статье. Которое ещё нужно дорабатывать, и не раз. Это про трудный, тернистый, бесполезный путь virgin разработчика.