Наши партнеры

UnixForum





Библиотека сайта rus-linux.net

Электронные таблицы для веба

Оригинал: "Early Access Release of Audrey Tang's "Web Spreadsheet" Chapter"
Автор: Audrey Tang
Дата публикации: 8 сентября 2015
Перевод: Н.Ромоданов
Дата перевода: декабрь 2015 г.

Creative Commons

Перевод был сделан в соответствие с лицензией Creative Commons. С русским вариантом лицензии можно ознакомиться здесь.

Это предварительная публикация главы из сборника «500 строк или меньше», четвертой книги из серии книг Архитектура приложений с открытым исходным кодом. Пожалуйста, сообщайте о любых проблемах, которые вы обнаружите при чтении этой главы, в нашем треккере GitHub. Следите за объявлениями о предварительных публикациях новых глав и окончательной публикацией в блоге AOSA или в Твиттере.

Программист и переводчик, получившая образование самостоятельно, Одри работает с фирмой Apple в качестве независимого исполнителя, решающего вопросы локализации облачного сервиса и технологий естественного языка. Ранее Одри разработала и возглавила реализацию первой рабочей версии Perl 6, а также принимала участие в работе комитетов разработки компьютерных языков для языков Haskell, Perl 5 и Perl 6. В настоящее время Одри является штатным участником проекта g0v и руководит первыми на Тайване проектом e-Rulemaking (связанным с созданием электронного правительства).

В настоящей главе рассказывается об электронных таблицах для веба — проекте размером в 99 строк, написанных на трех языках, изначально поддерживаемых в браузерах: HTML, JavaScript и CSS.

Версия этого проекта для ES5 доступна как проект jsFiddle.

Настоящая глава также переведена на китайский язык (Traditional Chinese).

Введение

Когда Тим Бернерс-Ли в 1990 году изобрел веб, веб-страницы писались на языке HTML путем разметки текста при помощи тегов, состоящих из угловых скобок и определяющих логическую структуру содержания страницы. Тексты, размещаемые внутри тегов <a>…</a>, стали называться гиперссылками, с помощью которых пользователи перенаправлялись на другие страницы в Интернете.

В 1990-е годы словарь языка HTML в браузерах был расширен различными презентационными тегами, в том числе некоторыми заведомо нестандартными тегами, такими как <blink>…</blink> в Netscape Navigator и <marquee>…</marquee> в Internet Explorer, в результате чего широко распространились проблемы, связанные с удобством использования браузеров и их совместимости.

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

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

Сегодня, кросс-платформенные веб-приложения (например, электронные таблицы для веба) являются столь же повсеместно используемыми и столь же популярными, как и ориентированные на конкретные платформы приложения (такие как VisiCalc, Lotus 1-2-3 и Excel) прошлого века.

Сколько возможностей может предложить веб-приложение, состоящее из 99 строк и написанное на AngularJS? Давайте посмотрим это в действии!

Обзор

В каталоге spreadsheet находится вариант нашего кода версии конца 2014 года, в котором были использованы следующие три веб языка: HTML5 - для описания структуры, CSS3 - для описания представления и JS стандарта "ES6 Harmony" — для описания взаимодействий с пользователем. Также используется веб хранилище для постоянного хранения данных (data persistence), а также скрипты web workers, позволяющие запускать код на языке JS код в фоновом режиме. На момент написания статьи все эти веб-стандарты поддерживаются в Firefox, Chrome и Internet Explorer 11+, а также в мобильных браузерах в iOS 5+ и Android 4+.

Теперь давайте откроем в browser () нашу электронную таблицу:

Рис.5.1 - С выключенным CSS

Как видно на рис.5.1 в случае, если в браузере включен JS и отключен CSS, мы получим следующий результат:

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

Рассмотрим код

На рис.5.2 показаны связи между компонентами HTML и JS

Рис.5.2 - Архитектура

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

  • index.html: 19 строк
  • main.js: 38 строк (за исключением комментариев и пустых строк)
  • worker.js: 30 строк (за исключением комментариев и пустых строк)
  • styles.css:
  • 12 строк

HTML

В первой строке файла index.html объявляется, что он написан на языке HTML5 (<!DOCTYPE html>) с использованием кодировки UTF-8:

<!DOCTYPE html><html><head><meta charset="UTF-8">

Без объявления charset браузер может отображать символ юникода ↻, используемый в кнопке перезагрузки reset как ↻, пример mojibake: искажение текста, вызванного проблемами декодирования.

Следующие три строки, как и обычно, являются объявлениями JS, которые размещены в секции head:

<script src="lib/angular.js"></script>
  <script src="main.js"></script>
  <script>try{ angular.module('500lines') }catch(e){ location="es5/index.html" }</script>

С помощью тегов <script src="…" tags> загружаются ресурсы JS — используется тот же самый путь, что и для страницы HTML. Например, если текущим адресом URL является http://audreyt.github.io/500lines/spreadsheet/index.html, то lib/angular.js ссылается на http://audreyt.github.io/500lines/spreadsheet/lib/angular.js.

В операторе try{ angular.module('500lines') } проверяется, расположен ли файл main.js там, где надо; если нет, то браузеру вместо этого указывается перейти по ссылке es5/index.html. Этот изящный метод перенаправления гарантирует, что для тех браузеров, которые вышли до 2015 года и не поддерживают использование ES6, мы можем в качестве запасного варианта использовать версии программ JS, транслированные на ES5.

В следующих двух строках загружается ресурс CSS, закрывается секция head и начинается секция body, в которой находится видимое для пользователя содержимое страницы:

  <link href="styles.css" rel="stylesheet">
</head><body ng-app="500lines" ng-controller="Spreadsheet" ng-cloak>

Атрибуты ng-app и ng-controller,приведенные выше, указывают AngularJS вызвать функцию Spreadsheet модуля 500lines, которая вернет модель (model): объект, который обеспечивает привязку (binding) к представлению (view) документа. Атрибут ng-cloak скрывает документ от пользователя до тех пор, пока не будет выполнена привязка.

Например, когда пользователь щелкает по тегу <button>, который определен в следующей строке, происходит переключение атрибута ng-click и происходит вызов reset() и calc(), двух функций с указанными именами, которые есть в модели JS:

<table><tr>
    <th><button type="button" ng-click="reset(); calc()">↻</button></th>

В следующей строке используется атрибут ng-repeat, отображающий списка меток столбцов, которые находятся в верхней строке:

<th ng-repeat="col in Cols">{{ col }}</th>

Например, если в модели JS метки Cols определены как ["A","B","C"], то три верхние ячейки (th) будут помечены соответствующим образом. Обозначение {{ col }} требует от AngularJS вычислить (interpolate) это выражение, заполнив содержимое в каждой ячейке th текущим значением col.

Аналогичным образом в следующих двух строках происходит переход к значениям в Rows - [1,2,3] и т. д.: для каждого из них создается строка и самая левая ячейка th помечается номером строки:

  </tr><tr ng-repeat="row in Rows">
    <th>{{ row }}</th>

Поскольку тег <tr ng-repeat> не закрыт тегом </tr>, переменную row можно использовать в выражениях. Ниже показано, как в текущей строке таблицы создается ячейка данных (td) и в атрибуте ng-class используются обе переменные col и row:

<td ng-repeat="col in Cols" ng-class="{ formula: ('=' === sheet[col+row][0]) }">

Здесь происходит несколько вещей. На языке HTML атрибут class описывает имена классов (set of class names), который позволяет на языке CSS определять для этих классов различные стили. С помощью ng-class здесь осуществляется вычисление выражения ('=' === sheet[col+row][0]); если оно истинно (значение true), то formula используется в теге <td> в качестве дополнительного класса, который устанавливает цвет фона светло синим в соответствии с тем, как это определено в строке 8 файла styles.css для селектора классов (class selector) .formula.

В выражении, приведенном выше, проверяется, является ли текущая ячейка формулой, т. е. проверяется, является ли первый символ строки sheet[col+row] символом =, где sheet представляет собой объект модели JS с координатами (например, "E1"), являющимися свойствами этого объекта, а содержимое ячейки (например, "=A1+C1") используется в качестве значения. Обратите внимание, что поскольку col является строкой, а не числом, то + в col+row означает конкатенацию строк, а не сложение.

В теге <td> мы предоставляем пользователю поле ввода для того, чтобы можно было редактировать содержимое ячейки, которое хранится в sheet[col+row]:

<input id="{{ col+row }}" ng-model="sheet[col+row]" ng-change="calc()"
        ng-model-options="{ debounce: 200 }" ng-keydown="keydown( $event, col, row )">

Здесь ключевым атрибутом является модель ng-model, с помощью которой обеспечивается двунаправленное связывание (two-way binding) между моделью JS и редактируемым контентом, находящимся в полях ввода. На практике это означает, что всякий раз, когда пользователь вносит изменения в поле ввода, модель JS будет обновлять содержимое ячейки sheet[col+row] лист [колонка + строки] и будет запускать функцию calc(), которая пересчитает значения во всех ячейках с формулами.

Чтобы избежать повторных вызовов функции calc() в случае, когда пользователь нажимает клавишу и удерживает ее нажатой, в параметре ng-model-options задается ограничение, которое разрешает обновлять значения только один раз за каждые 200 миллисекунд.

Атрибут id здесь пересчитывается в соответствие с координатами col+row. Атрибут id элемента HTML должен отличаться от id всех других элементов в одном и том же документе. Это гарантирует, что селектор ID #A1 будет ссылаться на единственный элемент, а не на набор элементов так, как это делает селектор класса .formula. Когда пользователь нажимает клавишу ВВЕРХ/ВНИЗ/ВВЕСТИ, то логическая часть алгоритма в keydown() будет использовать селекторы ID для того, чтобы определить на какое поле ввода переместить фокус.

После поля ввода, мы размещаем тег <div>, в котором отображается вычисленное значение текущей ячейки, представленное в модели JS объектами errs и vals:

<div ng-class="{ error: errs[col+row], text: vals[col+row][0] }">
        {{ errs[col+row] || vals[col+row] }}</div>

Если при вычислении формулы происходит ошибка, то выдается сообщение об ошибке, содержащееся в errs[col+row], а ng-class применяет к элементу класс error, что позволяет поменять стиль CSS (красные буквы, выравнивание по центру и т.д.)

Когда нет ошибок, то вместо этого справа от || отображается значение vals[col+row]. Если это не пустая строка, то исходное значение ([0]) будет интерпретироваться как истина, в результате чего к элементу будет применен класс text и текст будет выравниваться по левой стороне.

Поскольку пустые строки и числовые значения не отображают исходных значений, то ng-class не будет для них устанавливать каких-либо классов, поэтому стиль CSS для них будет использоваться выравнивание по правой стороне, определяемое по умолчанию.

Наконец, мы закрываем цикл ng-repeat по столбцам тегом </td>, цикл по строкам — тегом с </tr> и закрываем документ HTML:

</td>
  </tr></table>
</body></html>

JS: Главный контроллер

В файле main.js определяется модуль 500lines и его функция-контроллер Spreadsheet, к которой обращается элемент <body> в файле index.html.

Когда происходит взаимодействие HTML с процессом worker, работающем в фоновом режиме, то реализуются следующие четыре задачи:

  • определяются размер и метки столбцов и строк;
  • устанавливаются обработчики событий для обеспечения навигации с помощью клавиатуры и работы кнопки сброса;
  • когда пользователь изменяет содержимое таблицы, то новое содержимое пересылается в процесс worker;
  • когда из процесса worker поступают вычисленные результаты, внешний вид таблицы обновляется и сохраняется как ее текущее состояние

Более подробно взаимодействие с контроллером worker показано в схеме на рис.5.3:

Рис.5.3 - Блок-схема работы контроллера Worker

Теперь давайте пройдемся по коду. В первой строке мы запрашиваем у AngularJS объект $scope для модели JS:

angular.module('500lines', []).controller('Spreadsheet', function ($scope, $timeout) {

Символ $ в $scope является частью имени. Здесь мы также запрашиваем у AngularJS функцию сервиса $timeout; позже, мы будем использовать ее для предотвращения бесконечных циклов при расчете формул.

Чтобы в модель поместить строки Cols и столбцы Rows, их надо просто определить как свойства объекта $scope:

// Начинаем со свойств $scope; старт с меток столбца/строки
  $scope.Cols = [], $scope.Rows = [];
  for (col of range( 'A', 'H' )) { $scope.Cols.push(col); }
  for (row of range( 1, 20 )) { $scope.Rows.push(row); }

В ES6 синтаксис вида for...of позволяет с помощью вспомогательной функции range, представляющей собой генератор, достаточно просто организовать цикл по диапазону значений от первого значения и до последнего:

  function* range(cur, end) { while (cur <= end) { yield cur;

Обозначение function*, приведенное выше, указывает, что функция range возвращает итератор для цикла while, который каждый раз получает с помощью операции yield только одно значение. Всякий раз, когда цикл получит следующее значение, он возобновит свое исполнение той части тела цикла, которое расположено справа за оператором yield:

// Если это число, то увеличиваем на единицу; в противном случае переходим к следующей букве
    cur = (isNaN( cur ) ? String.fromCodePoint( cur.codePointAt()+1 ) : cur+1);
  } }

Для создания следующего значения мы используем isNaN для того, чтобы узнать, является ли cur буквой (NaN означает "not a number" - "не число"). Если это так, то увеличивает указатель позиции на единицу и все повторяем с тем, чтобы получить свою следующую букву. В противном случае, мы просто увеличиваем число на единицу.

Затем, мы определяем функцию keydown(), которая обрабатывает навигацию с помощью клавиатуры по строкам:

// UP(38) и DOWN(40)/ENTER(13) перемещают фокус на строку выше (-1) или ниже (+1).
  $scope.keydown = ({which}, col, row)=>{ switch (which) {

Функция, обрабатывающая нажатие клавиш со стрелками, получает аргументы ($event, col, row) из <input ng-keydown>, затем из $event.which получает параметр which и проверяет, является ли он одним из трех основных навигационных кодов:

case 38: case 40: case 13: $timeout( ()=>{

Если это так, то мы используем переменную $timeout для того, чтобы спланировать изменение фокуса ячейки после того, как отработают еng-keydown и ng-change. Поскольку для $timeout в качестве аргумента требуется функция, для создания логики, осуществляющей изменение фокуса, используется синтаксическая конструкция ()=>{…}, которое начинается с проверки направления перемещения:

 const direction = (which === 38) ? -1 : +1;

Декларация const обозначает, что direction не будет изменяться во время исполнения функции. Направление движения может происходить либо вверх (-1, от A2 к A1) в случае, если код клавиши 38(UP), либо вниз (+1, от A2 к A3) в противном случае.

Затем, мы получаем необходимый элемент с помощью селектора ID селектора (например, "#A3"), построенного с использованием строки-шаблона, заключенного внутри обратных кавычек, конкатенации с предваряющим символом #, текущего значения col и целевого значения row + direction:

      const cell = document.querySelector( `#${ col }${ row + direction }` );
      if (cell) { cell.focus(); }
    } );
  } };

Мы добавляем дополнительную проверку результата работы querySelector, поскольку движение вверх от A1 приведет к построению селектора #A0, для которого нет соответствующего элемента, и поэтому изменение фокуса не должно происходить. То же самое для случая нажатия клавиши DOWN в нижней строке.

Далее, мы определяем функцию reset() таким образом, чтобы с помощью нажатия кнопки можно было восстанавливать исходное состояние листа sheet:

// Default sheet content, with some data cells and one formula cell.
  $scope.reset = ()=>{ $scope.sheet = { A1: 1874, B1: '+', C1: 2046, D1: '⇒', E1: '=A1+C1' }; }

Функция init() выполняет восстановление содержимого листа sheet и использует для этого его предыдущее состояние, сохраненное в локальном хранилище localStorage, которое по умолчанию будет именно тем, которое было сохранено когда мы первый раз запустили приложение:

  // Оределяем функцию инициализации и немедленно ее вызываем
  ($scope.init = ()=>{
    // Восстанавливаем предыдущее значение .sheet; переустанавливаем к значению, заваемому по умолчанию в случае, если это первый запуск
    $scope.sheet = angular.fromJson( localStorage.getItem( '' ) );
    if (!$scope.sheet) { $scope.reset(); }
    $scope.worker = new Worker( 'worker.js' );
  }).call();

Несколько особенностей функции init(), показанной выше:

  • Для того, чтобы определить функцию и немедленно ее вызвать, мы используем синтексическую конструкцию ($scope.init = ()=>{…}).call().
  • Поскольку в локальном хранилище localStorage запоминаются только строк, мы с помощью angular.fromJson() выполняем синтаксический разбор структуры sheet по его представлению в JSON.
  • На последнем этапе работы init() мы создаем новый поток web worker и назначаем его свойству области видимости worker. Хотя при формировании внешнего вида таблицы процесс этого потока непосредственно не используется, он обычно требуется для доступа к объектам из функций модели; в данном случае — для обмена данными между функцией init() и функцией calc(), о которой будет рассказано ниже.

В sheet хранится содержимое ячеек, которое редактируется пользователем, а в errs и vals находятся результаты вычислений - ошибки и значения, которые доступны пользователю только для чтения:

// В ячейках с формулами ошибки указываются в .errs; в обычных ячейках содержимое находится в .vals
  [$scope.errs, $scope.vals] = [ {}, {} ];

Мы можем ими воспользоваться и можем определить функцию calc(), которая будет выполнять переключения всякий раз, когда пользователь вносит изменения в листе sheet:

  // Определяем обработчик вычислений; но еще его не вызываем
  $scope.calc = ()=>{
    const json = angular.toJson( $scope.sheet );

Здесь мы сначала запомним состояние листа sheet в виде константы json, которая является строкой формата JSON.

Затем, мы получим константу promise из процесса $timeout и отменим вычисление в случае, если оно будет выполняться более 99 миллисекунд:

const promise = $timeout( ()=>{
      // Если процесс worker не возвратил результат через 99 миллисекунд, то завершаем этот процесс
      $scope.worker.terminate();
      // Возврат к предыдущему состоянию и создание новового процесса worker
      $scope.init();
      // Отмена результатов вычислений и возврат к предыдущему известному состоянию
      $scope.calc();
    }, 99 );

Поскольку мы уверены, что функция calc() вызывается из <input ng-model-options> в HTML по крайней мере один раз каждые 200 миллисекунд, то благодаря такому условию остается 101 миллисекунда для того, чтобы с помощью функции init() восстановить последнее известное состояние листа sheet и создать новый процесс worker.

Задача процесса worker состоит в вычислении значений errs и vals по содержимому листа sheet. Поскольку main.js и worker.js общаются между собой при помощи сообщений, нам нужен обработчик onmessage, который будет принимать результаты сразу, как только они будут готовы:

    // Когда worker возвращает результат, этот результат используется в области видимости
    $scope.worker.onmessage = ({data})=>{
      $timeout.cancel( promise );
      localStorage.setItem( '', json );
      $timeout( ()=>{ [$scope.errs, $scope.vals] = data; } );
    };

Мы знаем, что когда вызывается обработчик onmessage, в json сохранено стабильное состояние листа sheet (т.е. не содержащее формул, имеющих бесконечные циклы), поэтому мы отменяем тайм-аут в 99 миллисекунд, записываем этот снимок в локальное хранилище localStorage и планируем обновление пользовательского интерфейса с помощью функции $timeout, которая обновляет видимое для пользователя представление значений errs и vals.

При наличии обработчика мы можем передать состояние листа sheet в worker, который в фоновом режиме запустит вычисления:

  // Отправка содержимого текущего листа в процесс worker для обработки
    $scope.worker.postMessage( $scope.sheet );
  };

  // Запуск вычислений, когда процесс worker готов к работе
  $scope.worker.onmessage = $scope.calc;
  $scope.worker.postMessage( null );
});

JS: Процесс worker, работающий в фоновом режиме

Выполнение расчета формул в процессе worker, а не в основном потоке JS обусловлено следующими тремя причинами:

  • Пока процесс worker работает в фоновом режиме, пользователь может свободно продолжать взаимодействовать с электронной таблицей - блокировок вычислений в основном потоке не будет.
  • Поскольку мы разрешаем в качестве формул использовать любое выражение JS, процесс worker образует специальную песочницу, которая предотвратит взаимное влияния формул и основной страницы, в которой они находятся, например, в случае,когда происходит вызов диалогового окна alert().
  • В формуле в качестве переменных можно использовать любые координаты. В другой ячейке с этими координатами может находиться другая формула, что может приводить к циклическим ссылкам. Для решения этой проблемы мы в процессе worker используем глобальный объект self и, чтобы защитить его от логики зацикливания, определяем в нем функции get.

Давайте с учетом всего вышесказанного рассмотрим код worker.

Единственное назначение worker заключается в использовании его обработчика onmessage. Обработчик берет страницу sheet, вычисляет значения errs и vals, а затем отправляет их в основной поток JS нити. Когда мы получаем сообщение, то начинаем с переинициализации трех переменных:

let sheet, errs, vals;
self.onmessage = ({data})=>{
  [sheet, errs, vals] = [ data, {}, {} ];

Для того, чтобы превратить координаты в глобальные переменные, мы сначала проходим по всем свойствам листа sheet, используя для этого цикл for…in:

for (const coord in sheet) {

В ES6 с помощью const и let, определяется область видимости констант и переменных; конструкция const coord, приведенная выше означает, что функции, определяемых внутри цикла, будут при каждой итерации получать конкретное значение переменной coord.

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

Обычно переменные, используемые в формулах, нечувствительны к выбору регистра и дополнительно могут иметь префикс $. Поскольку переменные в JS чувствительны к выбору регистра, мы используем два обращения к map для того, чтобы свести все четыре варианта обращения к переменных к использованию одних и тех же координат:

    // Четыре имени переменных, указывающих на одну и ту же координату: A1, a1, $A1, $a1
    [ '', '$' ].map( p => [ coord, coord.toLowerCase() ].map(c => {
      const name = p+c;

Обратите внимание выше на синтаксис записи функции: p => ... обозначает то же самое, что и (p) => { ... }.

Для каждого имени переменной, например, a1 и $a1, A1 и $A1, мы определяем в self свойство-акксессор, которое вычисляет vals["A1"] всякий раз, когда переменная появляется в выражении:

// Процесс worker используется для вычислений неоднократно, поэтому каждая переменная определяется только один раз
      if ((Object.getOwnPropertyDescriptor( self, name ) || {}).get) { return; }

      // Определяется self['A1'], что тоже самое, что и глобальная переменная A1
      Object.defineProperty( self, name, { get() {

Синтаксическая конструкция { get() { … } }, приведенная выше, является сокращением конструкции { get: ()=>{ … } }. Потому что мы определяем только get, а не set, переменные доступны только для чтения и их нельзя изменить в формулах, которые записывают пользователи.

Аксессор get начинает свое действие с проверки переменной vals[coord] и просто возвращает ее значение в случае, если оно уже вычислено:

       if (coord in vals) { return vals[coord]; }

Если оно еще не вычислено, то мы должны вычислить vals[coord] в sheet[coord].

Во-первых, мы установить значение равным NaN с тем, чтобы ссылки на туже самую ячейку, например, указание в A1 функции =A1 завершалось обработкой значения NaN, а не приводило к бесконечному циклу:

        vals[coord] = NaN;

Затем мы проверяем, является ли sheet[coord] числом, преобразовывая его для этого в цифровое представление с префиксом +, назначая полученный результат переменной x и сравнивая строковое представление значения этой переменно с оригинальной строкой. Если они отличаются, то мы присваиваем переменной x исходный вид строки:

        // Строки с цифрами преобразуются в числа, так что =A1+C1 работает также и для чисел
        let x = +sheet[coord];
        if (sheet[coord] !== x.toString()) { x = sheet[coord]; }

Если первым символом переменной x является символ =, то это ячейка с формулой. Мы вычисляем часть, находящуюся за символом =, с помощью функции eval.call() и используем в качестве первого аргумент значение null с тем, чтобы указать функции eval работать в глобальной области видимости, и не выполнять вычисления переменных лексической области таких как x и sheet:

        // Расчитываются ячейки с формулами, содержимое которых начинается с =
        try { vals[coord] = (('=' === x[0]) ? eval.call( null, x.slice( 1 ) ) : x);

Если вычисления завершатся успешно, то результат будет запомнен в vals[coord]. Для тех ячеек, в которых формулы нет, значением переменной vals[coord] будет просто элемент x, который может быть числом или строкой.

Если в результате работы eval возникнет ошибка, то блок catch проверит, возникла ли она от того, что формула ссылается пустую ячейку, для которой в self еще не определено значение:

        } catch (e) {
          const match = /\$?[A-Za-z]+[1-9][0-9]*\b/.exec( e );
          if (match && !( match[0] in self )) {

В этом случае, мы устанавливаем по умолчанию отсутствующее значение равным "0", очищаем vals[coord] и повторно запускаем вычисления с использованием значения self[coord]:

            // В формуле ссылка на неициализированную ячейку; устанавливается значение 0 и делается еще попытка вычисления
            self[match[0]] = 0;
            delete vals[coord];
            return self[coord];
          }

Если пользователь позднее запишет в недостающее значение в ячейку sheet[coord], то отработает Object.defineProperty, которое переопределит временное значение.

Другие виды ошибок будут просто сохранены в errs[coord]:

          // В противном случае преобразуем в строку и, если возникло исключение, передаем в объект errs
          errs[coord] = e.toString();
        }

В случае ошибок значением vals[coord] останется значение NaN, поскольку назначение значения не будет выполнено.

Наконец, аксессор get возвратит вычисленное значение, которое было сохранено в vals[coord] и которое может быть числом, логическим значением или строкой:

        // Преобразование vals[coord] в строку строку в случае, если это не число и не логическое значение
        switch (typeof vals[coord]) { case 'function': case 'object': vals[coord]+=''; }
        return vals[coord];
      } } );
    }));
  }

В случае наличия аксессоров для всех координат, worker снова пройдет по всем координатам, вызывая акксесоры для каждой ячейки self[coord], а затем передаст сообщения errs и vals в основной поток JS:

  // Для каждой координаты в листе sheet вызывается метод get, определенный выше
  for (const coord in sheet) { self[coord]; }
  return [ errs, vals ];
}

Стилевой файл CSS

В стилевом файле styles.css находятся всего лишь несколько селекторов и их стили, которые они предствляют. Во-первых, мы выбрали стиль таблицы так, чтобы объединить границы всех ячеек вместе и не оставлять места между соседними ячейками:

table { border-collapse: collapse; }

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

th, td { border: 1px solid #ccc; }
th { background: #ddd; }
td.formula { background: #eef; }

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

td div { text-align: right; width: 120px; min-height: 1.2em;
         overflow: hidden; text-overflow: ellipsis; }

Выравнивание и внешний вид текста зависит от типа конкретного значения — они заданы в селекторах классов text и error:

div.text { text-align: left; }
div.error { text-align: center; color: #800; font-size: 90%; border: solid 1px #800 }

Что касается полей ввода input, которые пользователи могут редактировать, то мы используем полное наложение поверх ячейки и делаем поле ввода прозрачным так, чтобы через него было видно лежащий ниже div:

input { position: absolute; border: 0; padding: 0;
        width: 120px; height: 1.3em; font-size: 100%;
        color: transparent; background: transparent; }

Когда пользователь устанавливает фокус на поле ввода, то оно раскрывается и перемещается на передний план:

input:focus { color: #111; background: #efe; }

Кроме того, лежащий ниже div сворачивается в одну строку, так что он будет полностью закрыт полем ввода:

input:focus + div { white-space: nowrap; }

Заключение

Так как название этой книги «500 строк или меньше», то веб-таблица размером в 99 строк является самым маленьким примером - пожалуйста, не стесняйтесь экспериментировать и расширять этот пример в любом желаемом направлении.

Ниже приведены несколько идей, с помощью которых вы легко можете потратить оставшиеся 400 строк:

  • Онлайн редактор с использованием ShareJS, AngularFire или GoAngular.
  • Поддержка синтаксиса Markdown для текстовых ячеек с использованием разметки angular-marked.
  • Общие формульные функции (SUM, TRIM и т.д.) стандарта OpenFormula.
  • Взаимосвязь с популярными форматами электронных таблиц, такими как CSV и SpreadsheetML при помощи SheetJS.
  • Обмен данными с онлайн-сервисами электронных таблиц, такими как Google Spreadsheet и EtherCalc.

Замечание, касающееся версий JS

Целью этой главы была демонстрация новых концепций, имеющихся в ES6, поэтому мы пользовались компилятором Traceur, который преобразовывал код в стандарт ES5 для его использования в браузерах, выпущенных до 2015 года.

Если вы предпочитаете работать непосредственно с версией JS от 2010 года , то в каталоге as-javascript-1.8.5 есть файлы main.js и worker.js, написанные в стиле ES5; исходный код строка за строкой повторяет версию ES6 с тем же количеством строк.

Для людей, предпочитающих ясный синтаксис, в каталоге as-livescript-1.3.0 есть файлы main.ls и worker.ls, при создании которых использовался скриптовый язык LiveScript, а не ES6; исходный код на 20 строк короче версии на JS.

На основе версии на LiveScript в каталоге as-react-livescript создан вариант для фреймворка ReactJS; исходный код на 10 строк больше, чем эквивалентный вариант на AngularJS, но он работает значительно быстрее.

Если вам интересно, как этот пример преобразуется на другие языки JS, то напишите - я бы хотела знать об этом!