Библиотека сайта rus-linux.net
Электронные таблицы для веба
Предварительная публикация главы из сборника "500 строк или меньше", новой книги серии «The Architecture of Open Source Applications»
Оригинал: "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
, которое по умолчанию будет именно тем, которое было сохранено когда мы первый раз запустили приложение:
Несколько особенностей функции 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"]
всякий раз, когда переменная появляется в выражении:
Синтаксическая конструкция { 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 исходный вид строки:
Если первым символом переменной 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]
: