We're building a small REPL that evaluates (with eval
) javascript expressions as they are being entered by the user. Since the whole thing is event-driven, evaluation must take place in a separate function, but the context (that is, all declared variables and functions) must be preserved between the calls. I came up with the following solution:
function* _EVAL(s) {
while (1) {
try {
s = yield eval(s)
} catch(err) {
s = yield err
}
}
}
let _eval = _EVAL()
_eval.next()
function evaluate(expr) {
let result = _eval.next(expr).value
if (result instanceof Error)
console.log(expr, 'ERROR:', result.message)
else
console.log(expr, '===>', result)
}
evaluate('var ten = 10')
evaluate('function cube(x) { return x ** 3 }')
evaluate('ten + cube(3)')
evaluate('console.log("SIDE EFFECT")')
evaluate('let twenty = 20')
evaluate('twenty + 40') // PROBLEM
We're building a small REPL that evaluates (with eval
) javascript expressions as they are being entered by the user. Since the whole thing is event-driven, evaluation must take place in a separate function, but the context (that is, all declared variables and functions) must be preserved between the calls. I came up with the following solution:
function* _EVAL(s) {
while (1) {
try {
s = yield eval(s)
} catch(err) {
s = yield err
}
}
}
let _eval = _EVAL()
_eval.next()
function evaluate(expr) {
let result = _eval.next(expr).value
if (result instanceof Error)
console.log(expr, 'ERROR:', result.message)
else
console.log(expr, '===>', result)
}
evaluate('var ten = 10')
evaluate('function cube(x) { return x ** 3 }')
evaluate('ten + cube(3)')
evaluate('console.log("SIDE EFFECT")')
evaluate('let twenty = 20')
evaluate('twenty + 40') // PROBLEM
As you can see it works fine with function-scoped variables (var
and function
), but fails on block scoped ones (let
).
How can I write a context-preserving eval
wrapper that would also preserve block-scoped variables?
The code runs in a browser, DOM and Workers are fully available.
It should be mentioned that the desired function must handle side effects properly, that is, each line of code, or, at least, each side effect, should be performed exactly once.
Links:
JavaScript: do all evaluations in one vm | https://vane.life/2016/04/03/eval-locally-with-persistent-context/
Share Improve this question edited May 4, 2021 at 7:29 georg asked Apr 29, 2021 at 18:11 georggeorg 215k56 gold badges322 silver badges398 bronze badges 10-
If you're in a front-end environment, an alternative method is to append a
<script>
tag each time. But then you'll need some way to get the result of the final statement in a given input string back to the user somehow, which may require a full-fledged parser like Acorn. Vaguely plausible, but there's gotta be an easier way – CertainPerformance Commented Apr 29, 2021 at 18:47 - You'll want to have a look at how the Chrome console is implemented. They employ several tricks, including TDZ avoidance. – Bergi Commented Apr 29, 2021 at 20:21
- Would you be fine with the statements running in the global scope? Or: a global scope, like a web worker? – Bergi Commented Apr 29, 2021 at 20:22
- @CertainPerformance: yes, this is in a browser – georg Commented Apr 30, 2021 at 6:55
-
1
@georg Ah, that might not work for
let
, it's quite possible lexical variables are always constrained to the scope of the eval'd expression :-/ – Bergi Commented Apr 30, 2021 at 10:36
4 Answers
Reset to default 8 +200The article you linked contains a crazy approach that actally works: during each eval()
call, we create a new closure inside that eval
scope and export it so that to we can use it evaluate the next statement.
var __EVAL = s => eval(`void (__EVAL = ${__EVAL.toString()}); ${s}`);
function evaluate(expr) {
try {
const result = __EVAL(expr);
console.log(expr, '===>', result)
} catch(err) {
console.log(expr, 'ERROR:', err.message)
}
}
evaluate('var ten = 10')
evaluate('function cube(x) { return x ** 3 }')
evaluate('ten + cube(3)')
evaluate('console.log("SIDE EFFECT")')
evaluate('let twenty = 20')
evaluate('twenty + 40') // NO PROBLEM :D
TL;DR
Here is the remended, best solution I came up with below, supporting all expressions including promise-based expressions like fetch()
, making use of async
/await
and nesting evaluate()
in the final then()
of my fetch()
.
Note (also mentioned in full post below) |
---|
The result of the nested evaluate() expression is logged first. This is correct and to be expected as that nested expression runs within the fetch() that runs it. Once the entire fetch runs, it will return undefined just as a variable assignment would. For every other [non-remended] solution in my answer below, the title variable will be evaluated if and after the fetch() statement has been fully evaluated successfully. This is because we are either forcefully deferring the evaluation of the title variable by means of setTimeout() or a pre-processed then() , or by means of forced sequential loading in the "BONUS" solution at the bottom of this solution. |
var __EVAL = s => eval(`void (__EVAL = ${__EVAL.toString()}); ${s}`);
async function evaluate(expr) {
try {
const result = await __EVAL(expr);
console.log(expr, '===>', result)
} catch (err) {
console.log(expr, 'ERROR:', err.message)
}
}
evaluate('var ten = 10')
evaluate('function cube(x) { return x ** 3 }')
evaluate('ten + cube(3)')
evaluate('let twenty = 20')
evaluate('twenty + 40')
evaluate('let title = ""')
evaluate('fetch("https://jsonplaceholder.typicode./todos/1").then(res => res.json()).then(obj => title = obj.title).then(() => evaluate("title"))')
The madness explained
A few other solutions came very close here, so I must give credit to both Bergi and Brandon McConnell— Bergi for his/her clever use of closures with eval()
and Brandon for his ingenuity in using a "stepped" result.
The correct solution does exist, and it does work with promises. For ease of use, I did use Bergi's solution as a foundation for my own, and if you do select my answer, I will gladly split the reputation bonus evenly with them.
Simply by making the evaluate()
function async/await, you allow it to work with promises. From here, you have to decide how you would like for it to run— either organically, where fetch()
statements run asynchronously as they normally would, or synchronously and wait for any Promise to be settled before proceeding to the next evaluate()
call.
In my solution here, I chose to go the organic route as this is how JavaScript does actually work natively. To force all promises to run before proceeding would be to circumvent the nature of JavaScript. For example, if you were using this code to build a JavaScript engine or piler, you would want the code to run the same with your engine as it would on the web for other users, so organic would be the wait to go.
BONUS ✨