#javascript #expression #interpreter #mapbox-gl-js
#javascript #выражение #переводчик #картографический ящик-gl-js
Вопрос:
Я ищу синтаксис выражения JavaScript для указания действий в JSON. Выражения Mapbox — это именно то, что я ищу, но я не могу найти никакой документации о том, можно ли их использовать за пределами Mapbox. Возможно ли это? Если да, то как бы вы это сделали?
Ответ №1:
Это «просто» JSON-форма абстрактного синтаксического дерева, поэтому вы можете написать свой собственный исполнитель. В частности, кажется, что они следуют следующему соглашению, согласно их собственным документам:
- Массивы являются выражениями, в то время как все остальные типы JSON являются литералами (любопытно, что это подразумевает отсутствие литералов массива напрямую! Я разработаю исправление позже)
- Первый элемент массива — это выполняемая функция, в то время как остальные элементы являются параметрами этой функции.
- Корневой объект не обязательно имеет отношение к синтаксису выражения, просто там, где они его используют.
- Единственная «отслеживающая состояние» вещь — это
let
/var
functions , которая позволяет вам создавать переменные, ограниченные заключающимlet
выражением, что предполагает, что у них есть какой-то способ передать контекст функциям.
Итак, давайте создадим его! Я попытаюсь просмотреть код построчно ниже, но вы также можете просто посмотреть источник фрагмента в конце вопроса, если вы предпочитаете форматирование там.
Позже мы определим все функции, доступные для языка выражений
const OPERATIONS = {};
Теперь давайте настроим функцию оценщика. Очевидно, что он должен получить выражение, которое он будет вычислять, но также и контекст, который может быть изменен с помощью операций.
const evaluate = (expression, context = {}) => {
Во-первых, мы имеем дело с литералами, оценивая их как самих себя
if (!(expression instanceof Array)) {
return expression;
}
Хорошо, а теперь перейдем к настоящему делу:
давайте выясним, какую операцию запускать и ее параметры.
const [operationKey, ...rawParameters] = expression;
const operation = OPERATIONS[operationKey];
Мы справляемся с неизвестными операциями, паникуя! АААА!
if (operation == null) {
throw new Error(`Unknown operation ${operationKey}!`);
}
О, здорово, мы знаем эту операцию! Теперь, как мы должны это назвать?
Очевидно, что ему необходимо получить его параметры, а также контекст,
на случай, если это одна из тех надоедливых операций с отслеживанием состояния. Кроме того, как мы
видели с Mapbox let
, операции могут создавать новые контексты!
Я предлагаю следующую подпись, хотя вы можете изменить ее в соответствии с вашими конкретными предпочтениями и вариантами использования:
Первый параметр: Текущий контекст
Второй параметр: массив всех параметров операции. Это упрощает итерацию, если операция является переменной, и более простые вещи все равно могут просто использовать деконструкцию, чтобы иметь «фиксированную» подпись. Мы передадим параметры «сырыми», а не оцененными, чтобы операция могла делать с ними любые злые вещи, которые она захочет сделать.
Возвращаемое значение: все, что хочет оценить операция!
return operation(context, rawParameters);
};
Верно, верно, мы настроили оценщик, но как мы на самом деле его используем?
Нам нужны некоторые операции, давайте начнем с самых простых, чтобы намочить ноги: помните, как я сказал выше, что массив параметров поставляется в необработанном виде? Нам нужно будет оценить их вручную внутри наших операционных функций.
OPERATIONS["-"] = (context, [a, b]) => evaluate(a, context) - evaluate(b, context);
OPERATIONS[" "] = (context, [a, b]) => evaluate(a, context) evaluate(b, context);
Хорошо, это было легко, но что, если мы хотим принять произвольное количество аргументов?
OPERATIONS["*"] = (context, parameters) => parameters
.map(p => evaluate(p, context))
.reduce((accumulator, x) => accumulator * x);
Хорошо, теперь давайте реализуем те массивы, о которых мы говорили. Решение простое, есть операция, которая создает массив из его параметров!
OPERATIONS["array"] = (context, parameters) => parameters
.map(p => evaluate(p, context));
Круто, круто, но как насчет злобных порождений самого сатаны? let
и var
?
Давайте начнем с меньшего из них: легко, мы просто читаем все, что было сохранено в контексте для этого имени переменной!
OPERATIONS["var"] = (context, [variable]) => context[variable];
Теперь «хитрый» вариант, let
, который одновременно является переменным и меняет контекст!
Я вытащу свои брекеты здесь, потому что это будет немного больше, чем предыдущие красивые однолинейные операции!
OPERATIONS["let"] = (context, [...definitions]) => {
Да, у нас есть контекст, но мы не хотим загрязнять его за пределами let
квартала! Итак, давайте скопируем его в новый временный файл:
const innerContext = { ...context };
Теперь нам нужно перебрать определения, помните, что каждое из них состоит из 2 элементов:
Имя переменной и выражение ее значения! Но сначала нам нужно выбрать последний аргумент, который является выражением, которое будет выполнено в результирующем контексте:
const body = definitions.pop()
Давайте уберем с пути очевидные вещи: если у нас нечетное количество вещей в наших определениях, пользователь ошибается! Давайте бросим это на их уродливое лицо! Давайте используем загадочное сообщение об ошибке просто для того, чтобы быть злыми…
if (definitions.length % 2 === 1) {
throw new Error("Unmatched definitions!");
}
Круто, теперь мы должны сделать классную вещь, которая заключается в создании этих переменных:
for (let i = 0; i < definitions.length - 1; i = 2) {
const name = definitions[i];
const value = definitions[i 1];
Здесь я сделал выбор, что переменные в одном и том же блоке могут зависеть
от предыдущих переменных, если это вам не нравится, используйте родительский
контекст вместо того, который мы изменяем в данный момент.
innerContext[name] = evaluate(value, innerContext);
}
С переменными покончено, теперь давайте оценим тело!
return evaluate(body, innerContext);
};
И мы закончили! Это основа оценки синтаксического дерева!
Возможно, вы захотите продолжить и добавить свои собственные доменные операции прямо сейчас.
Я сделал этот фрагмент для демонстрации того, как это в конечном итоге работает, и использования комментариев к коду вместо грамотного кодирования, если это ваш стиль. HTML и CSS не имеют значения, просто немного помады, чтобы сделать его более презентабельным.
// Here we will later define all functions available for the expression language
const OPERATIONS = {};
// Now, let's set up the evaluator function.
// It obviously must receive the expression it will evaluate,
// but also a context that can be modified by operations.
const evaluate = (expression, context = {}) => {
// First, we deal with literals by evaluating them as themselves
if (!(expression instanceof Array)) {
return expression;
}
// Right, now to the real deal:
// let's find out what operation to run and its parameters.
const [operationKey, ...rawParameters] = expression;
const operation = OPERATIONS[operationKey];
// We handle unknown operations by panicking! AAAH!
if (operation == null) {
throw new Error(`Unknown operation ${operationKey}!`);
}
// Oh nice, we know this operation! Now, how should we call it?
// It obviously needs to receive its parameters, as well as the context,
// in case it is one of those pesky stateful operations. Plus, as we
// have seen with Mapbox's `let`, operations can create new contexts!
//
// I propose the following signature, though you can change it for your
// particular preference and use-cases:
//
// First parameter:
// Current context
// Second parameter:
// Array of all of the operation's parameters. This makes for
// easy iteration if the operation is variadic, and simpler stuff
// can still just use deconstruction to have a "fixed" signature.
// We will pass the parameters "raw", not evaluated, so that the
// operation can do whatever evil things it wants to do to them.
// Return value:
// Whatever the operation wants to evaluate to!
return operation(context, rawParameters);
};
// Right, right, we have set up the evaluator, but how do we actually use it?
// We need some operations, let's start with the easy ones to wet our feet:
// Remember how I said above that the parameters array comes in raw?
// We'll need to evaluate them manually inside our operation functions.
OPERATIONS["-"] = (context, [a, b]) => evaluate(a, context) - evaluate(b, context);
OPERATIONS[" "] = (context, [a, b]) => evaluate(a, context) evaluate(b, context);
// Okay, that was easy, but what if we want
// to accept an arbitrary amount of arguments?
OPERATIONS["*"] = (context, parameters) => parameters
.map(p => evaluate(p, context))
.reduce((accumulator, x) => accumulator * x);
// Right, now let's implement those arrays we spoke of.
// The solution is simple, have an operation that
// creates the array from its parameters!
OPERATIONS["array"] = (context, parameters) => parameters
.map(p => evaluate(p, context));
// Cool, cool, but what about the evil spawns of Satan himself? Let and Var?
// Let's start with the lesser of them:
// Easy, we just read whatever was stored in the context for that variable name!
OPERATIONS["var"] = (context, [variable]) => context[variable];
// Now, the "tricky" one, Let, which is both variadic AND changes the context!
// I'll pull out my braces here because it's gonna be a bit bigger than the
// previous beautiful one-line operations!
OPERATIONS["let"] = (context, [...definitions]) => {
// Right, we have A context, but we don't want to pollute it outside
// the Let block! So let's copy it to a new temporary one:
const innerContext = { ...context
};
// Now we need to loop the definitions, remember, they are 2 elements each:
// A variable name, and its value expression! But first, we need to pick
// out the last argument which is the expression to be executed in the
// resulting context:
const body = definitions.pop()
// Let's get the obvious stuff out of the way, if we have an odd number of
// things in our definitions, the user is wrong! Let's throw it on their
// ugly face! Let's use a cryptic error message just to be evil...
if (definitions.length % 2 === 1) {
throw new Error("Unmatched definitions!");
}
// Cool, now we get to do the cool stuff which is create those variables:
for (let i = 0; i < definitions.length - 1; i = 2) {
const name = definitions[i];
const value = definitions[i 1];
// Here I made the choice that variables in the same block can depend
// on previous variables, if that's not to your liking, use the parent
// context instead of the one we're modifying at the moment.
innerContext[name] = evaluate(value, innerContext);
}
// Variables are DONE, now let's evaluate the body!
return evaluate(body, innerContext);
};
// Bonus points for reading the snippet code:
// Remember that we are not limited to numeric values,
// anything that JSON accepts we accept too!
// So here's some simple string manipulation.
OPERATIONS["join"] = (context, [separator, things]) => evaluate(things, context)
.flat()
.join(separator);
// And we're done! That is the basic of evaluating a syntax tree!
// Not really relevant to the question itself, just a quick and dirty REPL
(() => {
const input = document.getElementById("input");
const output = document.getElementById("output");
const runSnippet = () => {
let expression;
try {
expression = JSON.parse(input.value);
} catch (e) {
// Let the user type at peace by not spamming errors on partial JSON
return;
}
const result = evaluate(expression);
output.innerText = JSON.stringify(result, null, 2);
}
input.addEventListener("input", runSnippet);
runSnippet();
})();
html {
display: flex;
align-items: stretch;
justify-content: stretch;
height: 100vh;
background: beige;
}
body {
flex: 1;
display: grid;
grid-template-rows: 1fr auto;
grid-gap: 1em;
}
textarea {
padding: 0.5em;
border: none;
background: rgba(0, 0, 0, 0.8);
color: white;
resize: none;
}
<textarea id="input">
[
"let",
"pi", 3.14159,
"radius", 5,
[
"join",
" ",
[
"array",
"a circle with radius",
["var", "radius"],
"has a perimeter of",
[
"*",
2,
["var", "pi"],
["var", "radius"]
]
]
]
]
</textarea>
<pre id="output">
</pre>
Комментарии:
1. Спасибо вам за этот фантастический, подробный ответ!
Ответ №2:
Я думаю, что ответ Кролтана несколько недооценивает оставшуюся работу по реализации семантики, особенно для ["interpolate"]
.
Более плодотворный подход, вероятно, заключается в том, чтобы получить доступ к механизму вычисления выражений, который существует внутри Mapbox-GL, и предоставить его, как это делается в этом gist .
Эта проблема отслеживается здесь.
Если вы действительно просто ищете общий механизм оценки выражений, смутно напоминающий Mapbox GL, вы можете оказаться cheap-eval
полезными.
Обновить
Я выпустил пакет NPM mapbox-expression
для поддержки непосредственного вычисления выражений Mapbox GL.
Комментарии:
1. Правда, функции Mapbox обладают некоторой утонченностью, но я интерпретировал вопрос как «Я хочу запускать выражения, а подход Mapbox аккуратный», а не «Я хочу запускать выражения Mapbox».
2. С учетом сказанного, для поддержки выражений камеры, в которых есть операторы, которые действительны только внутри них, можно было бы расширить
context
значение реализации, чтобы иметь как способ поиска переменных, так и операций (в терминах TS,type Context = { operations: {[key:string]: Function}, variables: {[key:string]: any}}
). И, наконец, спасибо вам за то, что нашли время написать этот пакет! Кому-то это наверняка пригодится!3. Конечно, это запутанный вопрос.