JavaScript Playground






4.93/5 (10投票s)
JavaScript Playground,JavaScript 计算器,开发和演示工具
引言人内在的孩童是其独特性和创造力的源泉,而游乐场是其才能和天赋展现的最佳环境。
目录
情绪
JavaScript Playground 作为计算器
核心
用户脚本上下文保护
主机上下文保护
控制台
文件 I/O
异常处理
处理词法错误
防止数据丢失
Playground API
用法
实现
限制
实时演示
版本
情绪
JavaScript Playground 是我早期的一个工具“JavaScript Calculator”的衍生版本。在我拥有 Node.js 和现代浏览器开发工具之前,我曾用它进行开发,但更多的是作为一个计算器。我的主要驱动力是摆脱任何模仿历史设备的软件计算器,以及任何自制的脚本解析器。它必须基于一种易于使用的、定义明确且标准的语言。
当我开始发布包含 JavaScript 代码示例和组件的文章时,JavaScript Calculator 就演变成了 JavaScript Playground。我决定可以将其变成一个紧凑的、自包含的工具,用于演示 JavaScript 代码。
后来我添加了一些有用的功能,例如文件 I/O 和一个功能齐全的控制台,用于浏览具有复杂结构的对象的。
JavaScript Playground 作为计算器
核心
这是解释核心功能的主脚本的骨架
const consoleInstance = //...
const consoleApi = // ...
const evaluate = () => {
const isStrictMode = strictModeSwitch.checked;
consoleInstance.reset();
try {
const api = {
write: (...objects) => consoleInstance.write(objects),
writeLine: (...objects) => consoleInstance.writeLine(objects),
consoleApi: consoleApi,
}; //api
evaluateResult.value = evaluateWith(
editor.value,
api,
isStrictMode);
} catch (exception) {
consoleInstance.showException(exception);
} finally {
if (!isStrictMode)
hostApplicationContextCleanup();
} //exception
};
// ...
const evaluateWith = (text, api, isStrict) => {
return new Function("api", safeInput(text, isStrict))(api);
};
const safeInput = (text, isStrict) => {
const safeGlobals =
"const write = api.write, writeLine = api.writeLine," +
"console = api.consoleApi, document = null," +
"window = null, navigator = null, globalThis = " +
"setReadonly({console: console, write: write, writeLine: writeLine})";
return isStrict ?
`"use strict"; ${safeGlobals}; ${text}`
:
`${safeGlobals}; with (Math) \{${text}\n\}`;
};
在脚本的最顶层堆栈帧中,通过 editor.value
获取用户 JavaScript 代码字符串,并将其与在代码上下文中使用的 api
对象以及定义是否为 严格模式 的标志一起传递给 evaluateWith
。
请注意,用户脚本的文本不会直接传递给 Function
实例并执行。会向用户脚本文本添加一些“隐藏”代码。这部分代码是必需的,以便将一些 API 传递给脚本。此外,还会传递 严格模式 选项以及快捷方式 with (Math) {}
(仅限非严格模式)。
代码的上下文在 saveInput
函数中定义,传递给 Function
构造函数 的第一个参数与 safeInput
添加的代码文本中的“api”一词匹配。
函数调用的结果将被返回,其值用于填充编辑器控件下方的 evaluateResult
控件;
write
和 writeLine
函数通过 consoleInstance
实现,该实例也用于实现 consoleApi
。它们被添加以保持与前身项目 JavaScript Calculator 的向后兼容性。它们支持的参数数量是任意的。这是使用 展开语法 实现的。
console
对象重新实现了标准的 JavaScript console
对象。evaluate
作为 console
传递的实际参数是包含 consoleApi
的控制台方法的对象。
除了将 API 传递给用户代码外,safeInput
添加到用户脚本中的代码文本还可以保护浏览器环境免受不安全调用,并保护用户脚本免受 API 对象修改。
用户脚本上下文保护
为什么 safeInput
是安全的?
首先,safeInput
添加的文本将全局对象 document
、window
、navigator
重定义为等于 null
的常量。这样,对敏感环境属性的访问就被阻止了。此外,globalWith
需要一些保护,这需要更多的工作。
保护提供给用户代码的 API 免受修改非常重要。让我们考虑用户代码中一些可能引起麻烦的部分。
console.log(1);
console.log = null;
console.log(2);
console = null;
console.log(3);
或
globalThis.console.log(1);
globalThis.console.log = null;
globalThis.console.log(2);
globalThis.console = null;
globalThis.console.log(3);
哪些赋值为 null
的行会中断 console.log
的执行?哪些行会不仅给当前用户脚本的执行带来麻烦,还会给整个 JavaScript Playground 会话带来麻烦?如果未采取适当的预防措施,这两种麻烦都是可能的。对于 write
和 writeLine
也是如此。任何尝试将值赋给 API 对象都应导致以下两种情况之一:赋值被忽略或抛出异常。
第一层保护是不要直接使用 api
对象的功能,而是通过单独的常量对象 write
、writeLine
和 console
。这样,用户尝试将值赋给这些对象中的任何一个都会抛出异常。请注意,JavaScript 参数始终是可变的,但由于这种技术,诸如 api = null
这样的赋值不会破坏任何东西,因为它只能发生在 API 对象初始化之后。
然而,这本身并不能防止通过 globalThis.write
、globalThis.writeLine
或 globalThis.console
访问这些对象。它也不能防止 console
对象修改其函数成员,例如 console.log
,或者通过例如 globalThis.console.log
访问相同的函数。为了防止这种情况,只需使 console
和 globalThis
的所有成员函数不可变。
这是使对象属性只读的最简单方法,基于 Proxy。
const setReadonly = target => {
const readonlyHandler = { set(obj, prop, value) { return false; } };
return new Proxy(target, readonlyHandler);
};
在这种情况下,它不是递归的,只影响 target
对象 的属性。在 JavaScript Playground 中,它使用两个对象进行调用:consoleApi
和 globalThis
,它们以用户脚本文本的形式定义,请参阅 evaluate
和 safeInput
。
主机上下文保护
在解决了 用户脚本上下文问题 后,我们仍然面临非严格模式带来的问题。这种模式非常棘手:当我们创建一个顶层对象时,它在全局作用域中作为 globalThis 的属性创建。
它会污染主机应用程序的全局作用域。是的,即使用户脚本是由 Function
构造函数 执行的,也会发生这种情况。为了认识到其严重性,让我们考虑以下简单示例。
// strict mode:
x = 10
由于严格模式,它应该会抛出“ReferenceError: x is not defined”异常。会发生这种情况吗?不一定。答案取决于整个 JavaScript Playground 会话的预历史记录。让我们做以下练习:切换到非严格模式并执行
// non-strict mode:
x = 11
然后切换到严格模式并执行
// strict mode again:
x = 12 // fine!
y = 13 // ReferenceError: y is not defined
赋值给 x
将会生效,因为在严格模式下,代码将修改由用户脚本在非严格模式下先运行在主机应用程序全局作用域中定义的 globalThis.x
。
在版本 v. 4.2.0 之前,该问题通过每次修改“Strict Mode”控件的值时通过 window.location.reload
重新加载主机应用程序页面来解决。这增加了复杂性,并对 防止数据丢失 功能造成了很大问题。
自 v. 4.2.0 起,通过在非严格模式下执行用户脚本之前和之后注册 globalThis
的属性来保护主机应用程序上下文。执行后,会删除用户脚本创建的属性。
const hostApplicationContextCleanup = (() => {
const globalSet = new Set();
for (let property in globalThis)
globalSet.add(property);
return () => {
const currectSet = [];
for (let property in globalThis)
currectSet.push(property);
for (let property of currectSet)
if (!globalSet.has(property))
delete globalThis[property];
currectSet.splice(0);
};
})();
请参阅 evaluate
中 hostApplicationContextCleanup
的使用。
控制台
传递给 safeInput
函数 的 console
对象的实际实现是 consoleApi
。
当第一次调用适当的 console
方法时,将在编辑器控件的右侧创建一个控制台元素,占用窗口一部分水平空间。传递给此 console
方法的对象将在控制台中可视化。如果对象是结构化的,则会创建相应的树形结构。树的节点是动态创建的。这解决了对象中循环引用的问题。
实现的 console
方法包括 console.assert、console.clear、console.count、console.debug、console.dir、console.error、console.info、console.log、console.time、console.timeEnd、console.timeLog、console.warn。
console.debug
、console.dir、console.error、console.info、console.log 和 console.warn 之间的唯一区别是控制台消息的 CSS 样式。对于从 Object
派生的对象,这些方法会在控制台中显示交互式内容,以树形结构表示对象结构的层次结构。当树节点打开时,会显示层次结构的嵌套级别,当关闭时,会移除。这样,对象中的循环引用就可以安全地允许和表示。这些函数的第二种形式,其中第一个参数是格式字符串,其余参数表示替换,未实现,因为它意义不大。相反,建议使用 ECMAScript 2015 引入的 模板字面量。
console.assert(assertion, ...objects)
方法也像 console.log 和其他类似方法一样工作,但有以下例外:第一个参数是布尔值,保留用于断言条件。如果第一个参数 assertion
的计算结果为 true
,则什么也不发生,否则,将显示断言失败消息,后跟其余参数 ...objects
。
<u注意
在某些浏览器中,计时方法 console.time、console.timeEnd、console.timeLog 可能会出现准确性问题。时间读取可能会被特定浏览器舍入或轻微随机化。在撰写本文时,在基于 Blink+V8 的浏览器(如 Chromium、Chrome、Opera 和 Vivaldi)中观察到了正确的计时。在使用 SpiderMonkey 的浏览器中观察到了舍入。有关更多信息,请参阅 此文档页面。
有关控制台实现,请参阅源代码中的函数对象 consoleInstance.showException(exception)
。
文件 I/O
文件 I/O 是最新的功能。这对于网页来说并不太常见,但也没有什么太难的。文件 I/O 在三个地方使用:1) JavaScript 文本可以加载到编辑器控件中,2) 编辑器控件的内容可以保存到文件中,3) 控制台的内容可以转换为文本(可能丢失一些信息)并保存到文本文件中。这是实现。
const fileIO = {
storeFile: (fileName, content) > {
const link = document.createElement('a');
link.href = `data:text/plain;charset=utf-8,${content}`; //sic!
link.download = fileName;
link.click();
},
// loadTextFile arguments:
// fileHandler(fileName, text),
// acceptFileTypes: comma-separated, in the form: ".js,.json"
loadTextFile: (fileHandler, acceptFileTypes) > {
const input = document.createElement("input");
input.type = "file";
input.accept = acceptFileTypes;
input.value = null;
if (fileHandler)
input.onchange = event > {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.readAsText(file);
reader.onload = readEvent >
fileHandler(file.name, readEvent.target.result);
}; //input.onchange
input.click();
},
};
请注意,这里没有使用现代 File System Access API,尽管它在原型项目中被使用过。基于临时创建元素和 click
调用的实现更简单,足以满足目的。
异常处理
在 evaluate
的代码中,所有异常都被 consoleInstance.showException(exception)
捕获和处理。如果捕获到异常,则显示对应于 consoleInstance
的元素,背景为黄色,并显示异常信息。此时可能存在两种情况:一些浏览器会定位输入脚本中检测到错误的行和列,并报告它。在其他一些浏览器中,此信息被认为不安全,并且不可用。
如果此信息可用,文本插入符号也会被放置在 edit
控件的报告位置。这是设置插入符号并显示异常信息的 consoleInstance
成员函数的骨架。
const consoleInstance = {
// ...
setCaret: (line, column) => {
const lines = editor.value.split(definitionSet.textFeatures.newLine);
let position = 0;
for (let index = 0; index < line && index < lines.length; ++index)
position += lines[index].length + 1;
editor.setSelectionRange(position + column - 1, position + column);
},
showException: function (exceptionInstance) {
// ...
this.writeLine(/* exceptionInstance.name, message, formatting... */);
const knownPosition = isKnown(exceptionInstance.lineNumber)
&& isKnown(exceptionInstance.columnNumber);
if (knownPosition) {
this.writeLine(
[`Line: ${exceptionInstance.lineNumber - 2},` +
` column: ${exceptionInstance.columnNumber + 1}`]);
this.setCaret(
exceptionInstance.lineNumber - 3,
exceptionInstance.columnNumber + 1);
}
},
// ...
};
行和列号的偏移量是因为并非所有脚本代码都显示在 edit
控件中。一些代码是不可见的,由 safeInput
添加。
处理词法错误
脚本在浏览器中根本不执行,即使使用 try
捕获所有异常,它也是静默执行的。当某些错误是词法级别时,会发生这种情况。
当您编写常规 JavaScript 代码而不使用 new Function
时,您无法在词法级别捕获某些错误。
您可以捕获
try {
const x = 3;
x = x/y;
} catch (e) {
alert(e); // Reference error: y is not defined
}
但这对于
try {
const a = [1, 2, 3;] // ';' is the lexical-level bug
} catch (e) { // won't be caught!
alert(e);
}
然而,如果您在 editor
元素(文本区域)中输入第二个代码片段,将其值传递给 evaluate
,并将其放入 try-catch 块中,则词法级别问题将作为异常进行处理!
上述 代码示例 将显示警报 "SyntaxError: missing ] after element list"
。
您可以将其视为一种“将所有错误转换为异常”的方法。所有错误都将被共同的 异常处理 所覆盖。
防止数据丢失
此功能很简单,但我想对其进行描述,因为有很多用于不同浏览器的有效或无效的配方。据我所知,我收集了能够涵盖大多数浏览器特殊性的技术。
用户可以很容易地无意中重新加载页面或通过关闭其选项卡或窗口来移除它。如果用户在代码编辑器控件中有一些有价值的代码,则需要用户确认。实现方法如下。
window.addEventListener('beforeunload', event => { // sic!
const requiresConfirmation = isCodeModified
&& editor.value.trim().length > 0;
if (requiresConfirmation) {
event.preventDefault(); // guarantees showing confirmation dialog
event.returnValue = true; // show confirmation dialog
} else // to guarantee unconditional unload
delete(event.returnValue);
isCodeModified
状态由代码编辑器控件的 input
事件更新。
editor.oninput = () => isCodeModified = true;
当代码从文件加载或通过 Playground API 提供时,此状态将被清除。
此代码并非所有行都绝对必需:删除 event.returnValue
可以用赋值为 undefined
替换,而 event.preventDefault()
可能是多余的。然而,在某些情况下,对于某些浏览器,这些行可能是必不可少的。有关更多详细信息,请参阅 此文档页面。
Playground API
playgroundAPI
的理念是向主 JavaScript Playground 应用程序“index.html”传递一些脚本,用脚本文本填充 editor
控件的值,并可选地在主应用程序中执行它,可选地以严格模式执行。在这种用法下,让我们将主 JavaScript Playground 应用程序视为由用户编写的“客户端”部分使用的“主机”。
这样,用户就可以在上下文服务应用程序中运行客户端脚本,并执行该应用程序可以执行的所有操作(作为计算器)。这可以用于演示 JavaScript 代码和技术,生活演示说明出版物,等等。要查看其外观,请尝试 Playground API 演示。
playgroundAPI
的用户提供客户端脚本,并指示主机用脚本文本填充 edit
控件,并定义如何处理它。自然,这应该以一种非常简单的方式完成。让我们看一个简单的代码示例。
用法
这是使用 playgroundAPI
的最简单的客户端代码示例。
<!DOCTYPE html>
<script src="../JavaScript.Playground/playgroundAPI.js"></script>
<script>
return "Write some code and press Ctrl+Enter"
</script>
<script>showSample("Just Calculator");</script>
主机用第二个 scrip
元素中显示的脚本文本填充 editor
控件,并将网页标题设置为“Just Calculator”。默认情况下,主机应执行代码并使用非严格模式。要更改此行为,脚本应调用 showSample
并带有一个或两个额外的布尔参数。此调用将重定向到 Web 页面,即主机应用程序,后者将完成其余工作。
实现
核心实现对象 JavaScriptPlaygroundAPI
实现了客户端和主机部分的功能。它还解释了理念。
const JavaScriptPlaygroundAPI = {
APIDataKey: "S. A. Kryukov JavaScript Playground API",
storage: sessionStorage,
userCall: function (path, code, title, doNotEvaluate, strict) {
this.storage.setItem(this.APIDataKey,
JSON.stringify({
code: code,
doNotEvaluate: doNotEvaluate,
strict: strict,
title: title }));
document.location = path;
},
// host's internal:
onLoad: function (handler) {
const item = this.storage.getItem(this.APIDataKey);
this.storage.removeItem(this.APIDataKey);
if (!item) return;
const script = JSON.parse(item);
if (!script) return;
if (handler)
handler(script.title,
script.code,
script.doNotEvaluate,
script.strict);
},
};
userCall
方法提供了客户端的基本功能。它初始化操作。showSample
函数应计算主机 Web 页面的 URL,包含用户脚本文本的第二个 script
元素的上下文,定义选项,并将这些数据传递给 userCall
。这些数据以 JSON
的形式存储在 sessionStorage
中。
要了解 showSample
如何从用户提供的 HTML 收集数据,请参阅其在 playgroundAPI.js
中的实现。
将用户 HTML 加载到主机 Web 页面后,它会调用 JavaScriptPlaygroundAPI.onLoad
。如果 sessionStorage
中的内容与键 APIDataKey
匹配并评估为 true
,并且脚本文本不为空,则 JavaScriptPlaygroundAPI.onLoad
会调用主机 JavaScript Playground 应用程序提供的 handler
。根据通过 JSON 内容传递的选项,主机将更改页面标题,填充 editor
元素,可选地切换到严格或非严格模式,并可选地执行用户脚本代码。
有关主机 Web 页面行为实现的详细信息,请参阅“index.js”中 JavaScriptPlaygroundAPI.
的代码片段。
限制
并非所有浏览器在重定向后都能使用 sessionStorage
。它适用于基于 Blink+V8 引擎的现代浏览器,但对于其他一些浏览器来说可能存在问题。特别是,Mozilla 只能在主机应用程序和用户 HTML 位于同一目录下时才能做到。
为了处理这种情况,我决定测试一些 JavaScript 功能,以检测可能不支持 playgroundAPI
使用的浏览器,就像在 演示代码 中那样。目前,可以通过支持 私有类字段 来检测。
const goodJavaScriptEngine = (() => {
try {
const advanced = !!Function("class a{ #b; }");
return advanced;
} catch {
return false;
}
})();
此解决方案并非完美。如果像 SpiderMonkey 这样的 JavaScript 引擎升级支持私有类字段会怎样?嗯,如果与 sessionStorage
相关的网页位置的特殊性保持不变,那么像 Firefox 这样的浏览器将能够使用 playgroundAPI
并加载主机 JavaScript Playground 网页,但无法用脚本文本填充 editor
元素。
好吧,至少我警告过并建议在我的错误消息中可以使用什么。在捉迷藏游戏中,“它”的人在隐藏时间结束时,在睁开眼睛之前应该喊:“准备好了吗?我要来了!”。它的俄语形式是:“如果还有人没藏好,那不是我的错”。这里也是一样。:-)
实时演示
版本
4.0.0:从 JavaScript Calculator 派生后的初始发布
4.1.0:引入了全面的 用户脚本上下文保护 和 防止数据丢失。
4.2.0:引入了 主机上下文保护,消除了在修改“Strict Mode”控件值时重新加载主机应用程序。
4.3.0:许多修复和改进。