zemlan.in

everywhere is undefined

Используя скриптовую часть джаваскрипта

(адаптировано из выступления на JavaScript fwdays’21. слайды)

Я, как и многие разработчики, склеиваю коллажи из старых систем, нового кода, хотелок пользователей и требований бизнеса. Сами по себе, эти коллажи плохо держатся, поэтому их нужно чем-то скреплять. Так сложилось, что я для этого использую джаваскрипт

We aimed to provide a “glue language” for the Web designers and part time programmers who were building Web content from components such as images, plugins, and Java applets. We saw Java as the “component language” used by higher-priced programmers, where the glue programmers — the Web page designers — would assemble components and automate their interactions using JS

The A-Z of Programming Languages: JavaScript - Computerworld

Это «скрепляющее» свойство было в языке с самого начала, когда с его помощью добавляли щепотку интерактива на страницы. Когда JS был быстро-быстро написан в конце 1995 года, уже существовал способ создания интерактивных элементов на страницу — java applet’ы были представлены несколькими месяцами ранее

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

1995: Browser

(все исходники доступны на гитхабе)

<style>
  body { max-width: 12em; margin: 1em auto; }
  #lastRoll { font-size: 4em; margin: 0.5em; }
</style>

<!-- https://xkcd.com/221/ -->
<h1 id="lastRoll"></h1>

Представь, что ты написала лучший вебсайт. Да, «вебсайт», потому что до «веб-приложений» социум тогда ещё не созрел. Та и приложения тогда «программами» называли… Короче, лучший вебсайт 1995 года — симулятор бросков кубика. Когда ты его писала, ты фокусировалась на главном — как предоставить посетителям самый случайный кубик — а за «доставку» симулятора и за его графический интерфейс пусть отдувается браузер

<style>
  body { max-width: 12em; margin: 1em auto; }
  #lastRoll { font-size: 4em; margin: 0.5em; }
</style>

<!-- https://xkcd.com/221/ -->
<h1 id="lastRoll"></h1>
<input type="button" id="roll" value="🔄"></input>

<script>
  roll.onclick = function () {
    lastRoll.innerText = ["⚀","⚁","⚂","⚃","⚄","⚅"][parseInt(Math.random() * 6)]
  }
</script>

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

20XX

За последующие годы, ты понемножку рефакторишь теперь-уже-веб-приложение — добавляешь историю бросков, немного динамизма, начинаешь использовать новые фишки джаваскрипта, вроде классов и модулей — но его главный принцип, «статическая страница с щепоткой интерактива», продолжает отлично работать

const R = (n) => parseInt(Math.random() * n);

class Core {
  static symbols = ["⚀", "⚁", "⚂", "⚃", "⚄", "⚅"];

  constructor(options) {
    options = options || {};

    this.symbols = options.symbols || Core.symbols;
    this.lastRoll =
      typeof options.lastRoll === "number"
        ? // 0 < options.lastRoll < this.symbols.length
          Math.max(0, Math.min(options.lastRoll, this.symbols.length - 1))
        : R(this.symbols.length);
    this.history = options.history || [];
  }

  roll() {
    this.lastRoll = R(this.symbols.length);
    this.history = [this.lastRoll, ...this.history].slice(0, 10);
  }

  pretty() {
    return {
      lastRoll: this.symbols[this.lastRoll],
      history: this.history.map((e) => this.symbols[e]),
    };
  }

  clear() {
    this.history = [];
  }
}

export default Core;

mo-dice
mo-dice


То, что браузеры следуют более-менее общему стандарту ECMAScript’а, обусловлено, кроме всего прочего, тем, что им надо показывать одни и те же сайты и, следовательно, запускать одни и те же скрипты. Если какой-то популярный сайт не работает, то тухлые помидоры полетят не только в его сторону, но и в сторону браузера. Если сайт достаточно популярный, то это разработчики браузера кому придётся адаптировать движок под сайт, а не наоборот. Как это делают, например, разработчики Вебкита, "исправляя" движок под кучу сайтов

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


Одним примером такого софта является nginx. nginx — это веб-сервер, который часто служит как reverse proxy, прослойка между диким интернетом и компьютерами, на которых крутятся веб-приложения. Это прослойка может раздавать статические файлы, направлять запросы на наименее нагружённые сервера, управлять правами доступа вроде «пускай на этот секретный адрес только посетителей с этих айпишников», и заниматься прочими «сисадминскими» штуками

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

Lua is a good tool in this area, but it’s not as widely known as some other languages.

Launching nginScript and Looking Ahead - NGINX

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


2015: nginx

Будучи ранним адоптером веба, ты, конечно же, завела блог, он же «набор статических файлов, спрятанных за nginx’ом». Из-за твоего прошлого успеха с симулятором кубика, ссылка на блог попадает в круг "успешных" людей, которые начинают спамить комментариями о том, как купить крипту и стать такой же успешной. Чтобы спрятаться от всего этого успеха, ты решаешь пускать к блогу только самых невезучих. Тех, кому кубик всегда показывает единичку

Ты берёшь ядро симулятора кубика и делаешь на его основе революционную схему авторизации. Так как джаваскрипт у nginx’а отличается от браузерного, тебе понадобится немного напильника…


Так, например, nginx вполне неплохо справляется без классов. А действительно, зачем классы, когда для обработки «куда отправить запрос?» не нужны сотни объектов, каждый со своим стейтом и десятками методов. А если уже приспичит, то и олдового прототипного var Class = function() {} + Class.prototype хватит. В то же время, разделение кода с помощью ES модулей более оправдана даже для короткоживущих скриптов

(конечно, большую часть напильника можно заменить автоматической транспиляцией, но это выступление не о настройке вебпака)

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

Для загрузки состояния из кук нужно парсить эти самые куки. К сожалению (или к счастью) njs не заморачивался с поддержкой npm и node_modules, поэтому придётся копипастить всякое. Лиииибо, можно воспользоваться тем, что предоставляет окружение, и достать уже распаршенное значение из nginx’овых переменных


2017: Google Docs

Продав свой Dice-as-a-service многотриллионой корпорации за миллиарды денег, но оставив за собой права на использование бесценного ядра, ты начинаешь осваивать бухгалтерию. Так как именно веб оплатил твой бутерброд с маслом, бухгалтерию ты ведёшь в Google Таблицах

Ты активно используешь кучу стандартный функций Таблиц, для банальной арифметики, обработки текста, статистики, удалённых запросов к гугловым сервисам (GOOGLEFINANCE) или сервисам в остальном интернете (IMPORTXML)

Однако, точность формул в таблицах тебе со временем надоедает. Ты хочешь вернуться во времена шального рандома…

Поэтому ты открываешь Tools > Script editor и создаёшь там два файлика — один с любимым ядром, другой — с кастомной функцией ROLL


То, что MS Office делает Visual Basic’ом, Google Docs делает джаваскриптом. С его помощью можно генерировать слайды презентации, делать автозамены в текстовых документах, или создавать кастомные функции для вычислений в таблицах

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

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

/**
 * Rolls a die with custom faces.
 *
 * @param {string|Array<Array<string>>} faces The value or range of cells
 *     to use as a die faces.
 * @return A die roll.
 * @customfunction
 */
function ROLL(faces) {
  let symbols;

  if (Array.isArray(faces)) {
    symbols = faces.reduce((acc, row) => [...acc, ...row], []).filter(Boolean);
  } else if (faces) {
    symbols = faces.toString().split("");
  }

  const core = new Core({
    symbols,
  });
  core.roll();
  return core.pretty().lastRoll;
}

Функция ROLL будет принимать в качестве аргумента либо строку со всеми гранями кубика, либо двухмерный массив граней. Так как кастомные скрипты запускаются в V8 на серверах Гугла, то можно использовать довольно современный синтаксис и фичи джаваскрипта, вне зависимости от твоего браузера. За исключением модулей, которые здесь заменены глобальным неймспейсом для всех файлов «скрипта»

Теперь, если указать в ячейке значение =ROLL("fwdays"), то функция наградит тебя случайным допамином на основе "fwdays"’а. Или javascript’а

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

Кроме стандартной библиотеки, у скриптов есть доступ к дополнительным сервисам, вроде HTTP-запросов, парсинга XML’я, или чисто-гугловых переводов и навигации

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

Но он довольно бесполезен для кубиков, потому что, насколько я знаю, кастомные функции запускаются только на изменения входных параметров (будь то константы или значения ячеек), а не каких-то внешних (или внутренний) триггеров вроде «эта функция была вызвана в другой ячейке»


2018: iOS

Остановившись на «незапоминающихся» бросках кубиков и насладившись случайными таблицами, ты подвела бюджеты и поняла, что можешь позволить себе айфон. Ты слышала про чрезвычайно умную Siri и, сразу после распаковки и настройки, попросила её бросить кубик. И снова. И снова. Весь этот допамин!

Но после того, как телефон дважды подряд выбросил тройку, ты поняла, что тебе не хватает поздравлений с удачей, а Siri оказалась слишком забывчива. Старенькое ядро умеет хранить историю бросков. Ах, если бы была возможность подружить его с Siri…


Нативный софт на большинстве эппловых платформ имеет доступ к JavascriptCore рантайму, которым активно пользуются все приложения на React Native. К счастью, этот рантайм подходит не только для кривого повторения нативного UI, но и для взаимодействия с нативными API вроде виджетов, Share Sheet’а, Shortcuts.app, или Siri. Приложение Scriptable занимается именно этим, запуская произвольный джаваскрипт в окружении с байндингами к нативным функциям iOS

JSC, а значит и Scriptable, поддерживает современный синтаксис, так что ядро можно скопировать из браузера как есть, за исключением export’а, который надо будет заменить на типа-CommonJS’овый module.exports =, чтобы потом импортировать ядро предоставленной Scriptable’ом функцией importModule()

const Core = importModule("core.js");

const fileManager = FileManager.local();
const cachePath = fileManager.joinPath(
  fileManager.documentsDirectory(), "modice.json"
);

function load() {
  const symbols = [
    "единицу", "двойку", "тройку", "четвёрку", "пятёрку", "шестёрку",
  ];

  if (fileManager.fileExists(cachePath)) {
    try {
      return new Core({
        ...JSON.parse(fileManager.readString(cachePath)),
        symbols,
      });
    } catch (e) {
      console.error(e);
    }
  }
  return new Core({ symbols });
}

function save(core) {
  fileManager.writeString(cachePath, JSON.stringify(core));
}

function getSpokenRoll(core) {
  let nth = 0;

  for (const entry of core.history) {
    if (core.lastRoll === entry) {
      nth = nth + 1;
    } else {
      break;
    }
  }

  const numeralWord = [
    "", "", "второй", "третий", "четвёртый", "пятый",
    "шестой", "седьмой", "восьмой", "девятый", "десятый",
  ][nth];
  return numeralWord
    ? `Кубик ${numeralWord} раз показал ${core.pretty().lastRoll}`
    : `Кубик показал ${core.pretty().lastRoll}`;
}

const core = load();

core.roll();

save(core);

const response = getSpokenRoll(core);

console.log(response);

if (config.runsWithSiri) {
  Speech.speak(response);
}

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

Далее, необходимо собрать фразу, которой Siri будет отвечать на твои запросы. Раз кубик будет существовать только на словах, то и его грани могут быть словами, которые будут произноситься. В numeralWord записываем сколько раз подряд был показан последний результат. Склеиваем всё вместе, и отдаём это в Speech.speak()

Теперь остаётся только обозначить, что этот скрипт надо запускать в ответ на волшебное слово


everywhere

Кроме уже перечисленных браузеров, nginx, Google Docs и Scriptable, джаваскрипт можно запускать приблизительно везде и делать им приблизительно всё:

Этот список уже сейчас неполный, а может быть ещё более неполным, если ты заскриптуешь свой нативный софт. Если V8/JavascriptCore сильно тяжёлые, то есть другие JS движки, вроде duktape и quickjs


И так, что же мы сегодня узнали…

Что джаваскрипт джаваскрипту рознь, но если какого-нибудь синтаксиса в этом JS окружении нет, то это необязательно баг или недостаток. Возможно, этот синтаксис бесполезен. Или окружение решает ту же проблему по-своему

Что в джаваскрипт приходят люди из разных уголков разработки, каждый со своими ожиданиями и привычками

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

Спасибо Кристине Ландвитович за помощь в написании этой записи