是否可以直接评估 Mapbox 表达式?
Is it possible to evaluate a Mapbox expression directly?
我正在寻找 JavaScript 表达式语法来指定 JSON 中的操作。 Mapbox's Expressions 正是我要找的,但我找不到任何关于这些是否可以在 Mapbox 之外使用的文档。那可能吗?如果是,你会怎么做?
它们“只是”一种 JSON 形式的抽象语法树,因此您可以编写自己的执行程序。根据他们自己的文档,特别是他们似乎遵循以下约定:
- 数组是表达式,而所有其他 JSON 类型都是文字(奇怪的是,这意味着没有直接的数组文字!我稍后会详细说明修复)
- 数组的第一项是要执行的函数,其余项是该函数的参数。
- 根 object 不一定与表达式语法相关,只是在它们发生的地方使用它。
- 唯一“有状态”的是
let
/var
函数,它允许您创建变量,范围限定为封闭的 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
,操作可以创建新的上下文!
我建议使用以下签名,但您可以根据自己的需要更改它
特别偏好和 use-cases:
第一个参数:
当前上下文
第二个参数:
所有操作参数的数组。这使得
如果操作是可变的,那么简单的迭代,更简单的东西
仍然可以只使用解构来获得“固定”签名。
我们将传递参数“原始”,而不是评估,以便
操作可以为所欲为。
Return 值:
无论操作要评估什么!
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
,它既是可变参数又会改变上下文!
我会在这里拔掉牙套,因为它会比之前漂亮的 one-line 手术大一点!
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];
这里我选择了同一块中的变量可以依赖
对于之前的变量,如果您不喜欢,请使用 parent
上下文而不是我们目前正在修改的上下文。
innerContext[name] = evaluate(value, innerContext);
}
变量完成,现在让我们评估body!
return evaluate(body, innerContext);
};
大功告成!这就是评估语法树的基础!
您现在可能想继续并添加自己的 domain-specific 操作。
我制作这个片段是为了演示它最终是如何工作的,如果你喜欢的话,我会使用代码注释而不是 literate-coding。 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>
我认为 Kroltan 的回答有点低估了实现语义的剩余工作,特别是 ["interpolate"]
。
一个更有成效的方法可能是访问存在于 Mapbox-GL 中的表达式评估引擎并公开它,就像 this gist 中所做的那样。
已跟踪此问题 here。
如果您真的只是在寻找一种通用的表达式求值机制,大致类似于 Mapbox GL,您可能会发现 cheap-eval
很有用。
更新
我已经发布了一个 NPM 包,mapbox-expression
以支持直接评估 Mapbox GL 表达式。
我正在寻找 JavaScript 表达式语法来指定 JSON 中的操作。 Mapbox's Expressions 正是我要找的,但我找不到任何关于这些是否可以在 Mapbox 之外使用的文档。那可能吗?如果是,你会怎么做?
它们“只是”一种 JSON 形式的抽象语法树,因此您可以编写自己的执行程序。根据他们自己的文档,特别是他们似乎遵循以下约定:
- 数组是表达式,而所有其他 JSON 类型都是文字(奇怪的是,这意味着没有直接的数组文字!我稍后会详细说明修复)
- 数组的第一项是要执行的函数,其余项是该函数的参数。
- 根 object 不一定与表达式语法相关,只是在它们发生的地方使用它。
- 唯一“有状态”的是
let
/var
函数,它允许您创建变量,范围限定为封闭的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
,操作可以创建新的上下文!
我建议使用以下签名,但您可以根据自己的需要更改它 特别偏好和 use-cases:
第一个参数: 当前上下文
第二个参数: 所有操作参数的数组。这使得 如果操作是可变的,那么简单的迭代,更简单的东西 仍然可以只使用解构来获得“固定”签名。 我们将传递参数“原始”,而不是评估,以便 操作可以为所欲为。
Return 值: 无论操作要评估什么!
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
,它既是可变参数又会改变上下文!
我会在这里拔掉牙套,因为它会比之前漂亮的 one-line 手术大一点!
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];
这里我选择了同一块中的变量可以依赖 对于之前的变量,如果您不喜欢,请使用 parent 上下文而不是我们目前正在修改的上下文。
innerContext[name] = evaluate(value, innerContext);
}
变量完成,现在让我们评估body!
return evaluate(body, innerContext);
};
大功告成!这就是评估语法树的基础!
您现在可能想继续并添加自己的 domain-specific 操作。
我制作这个片段是为了演示它最终是如何工作的,如果你喜欢的话,我会使用代码注释而不是 literate-coding。 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>
我认为 Kroltan 的回答有点低估了实现语义的剩余工作,特别是 ["interpolate"]
。
一个更有成效的方法可能是访问存在于 Mapbox-GL 中的表达式评估引擎并公开它,就像 this gist 中所做的那样。
已跟踪此问题 here。
如果您真的只是在寻找一种通用的表达式求值机制,大致类似于 Mapbox GL,您可能会发现 cheap-eval
很有用。
更新
我已经发布了一个 NPM 包,mapbox-expression
以支持直接评估 Mapbox GL 表达式。