Open jrainlau opened 3 years ago
一直觉得 Codepen 的在线代码预览系统很神奇,能够所见即所得地实时展示代码的运行效果,无论是代码演示,还是测试功能,都是非常方便快捷的存在。刚好最近手头有业务需要用到类似 Codepen 的能力,经过一番调研之后开发了一个具有基本的在线运行代码能力的 demo 出来。
在线体验地址:https://jrainlau.github.io/online-code-runner/ 由于业务只需要执行 JS 代码,因此 demo 也只具备 JS 代码的运行能力。
在线体验地址:https://jrainlau.github.io/online-code-runner/
由于业务只需要执行 JS 代码,因此 demo 也只具备 JS 代码的运行能力。
我们知道,浏览器是通过自带的引擎来处理 html,css 和 js 资源的,处理过程在页面载入的时候就已经开始。如果我们想要动态地运行这些资源,对于 html 和 css 我们可以用 DOM 操作的方式,对于 js 我们可以用 eval 或者 new Function()。但是这些操作都偏复杂且不安全(eval 和 new Function()很容易出事),那么有没有什么办法可以既优雅方便,又能安全地动态运行呢?我们看看大名鼎鼎的 Codepen 是怎么做的。
eval
new Function()
我在 Codepen 里面简单地写了一个按钮,绑定了样式和点击事件,可以看到白色区域已经展示出我们想要的结果。打开控制台自吸观察后可以发现,整个白色区域是一个 iframe,当中的 html 内容就是我们刚刚所编辑的代码。
iframe
不难想象,它的运行原理有点类似于 document.write,把内容直接写入到某一个 html 文件中,然后把它以 iframe 的方式内嵌到其他网页当中,实现代码预览的逻辑。那么使用 iframe 有什么好处呢?iframe 可以独立成为一个和宿主隔离的沙箱环境,在当中运行的代码在大部分情况下不会影响宿主,能有效地保证安全。配合 HTML5 新增的 sandbox 属性,可以给 iframe 定义更为精细的权限,比如是否允许它运行脚本,是否允许它弹窗等等。
document.write
sandbox
要实现类似 Codepen 的效果,最重要的一步就是如何把代码注入到 iframe 当中。由于我们需要使用到操控 iframe 的相关 API,浏览器出于安全的考虑,我们只能使用同域的 iframe 链接,否则将会报跨域的错误。
首先准备好一个 index.html 和 iframe.html,使用一个静态资源服务器把它们跑起来,假设均跑在 localhost:8080。然后我们在 index.html 里面插入一个 iframe,其链接就是 localhost:8080/iframe.html,代码如下:
index.html
iframe.html
localhost:8080
localhost:8080/iframe.html
<iframe src="localhost:8080/iframe.html"></iframe>
接下来我们可以使用 iframe.contentDocument 来获取 iframe 的内容,然后操作它:
iframe.contentDocument
<script> const ifrme = document.querySelector('iframe'); const iframeDoc = iframe.contentDocument; iframeDoc.open(); // 需要先调用 `open()`,打开“写”的开关 iframeDoc.write(` <body> <style>button { color: red }</style> <button>Click</button> <script> document.querySelector('button').addEventListener(() => { console.log('on-click!') }) <\/script> </body> `); iframeDoc.close(); // 最后调用 `close()`,关闭“写”的开关 </script>
运行完毕后,我们可以在 localhost:8080/index.html 里面看到和前文 Codepen 所展示的一样的效果:
localhost:8080/index.html
后续我们只需要找个输入框,把所写的代码保存成变量,然后调用 iframeDoc.write() 就可以动态地把代码写入到 iframe 并实时运行了。
iframeDoc.write()
观察 Codepen 的页面,可以看到有一个 Console 的面板,它可以把 iframe 当中的 console 信息直接输出。这是怎么实现的呢?答案很简单,我们可以在 iframe 页面中劫持 console 等 API,在保留原有的控制台输出的功能的前提下,把相关的信息通过 postMessage 的方式把它们输出给父页面,父页面监听到 message 以后把信息整理后输出到页面上,实现 Console 面板。
console
postMessage
在 iframe.html 中,我们在<body></body> 以外写入一段 js 代码(因为父页面调用 iframeDoc.write() 会覆盖 <body></body> 内的全部内容):
<body></body>
function rewriteConsole(type) { const origin = console[type]; console[type] = (...args) => { window.parent.postMessage({ from: 'codeRunner', type, data: args }, '*'); origin.apply(console, args); }; } rewriteConsole('log'); rewriteConsole('info'); rewriteConsole('debug'); rewriteConsole('warn'); rewriteConsole('error'); rewriteConsole('table');
此外我们会给 iframe 设置 sandbox 属性来限制其部分权限,但是这里有一个套娃的隐患,就是如果在 iframe 里面执行 window.parent.document 相关 API 的话,可以让 iframe 去改写父页面的内容,甚至改写 sandbox 属性,这肯定是不安全的,因此我们需要在 iframe 中把这相关 API 给屏蔽掉:
window.parent.document
Object.defineProperty(window, 'disableParent', { get() { throw new Error('无法调用 window.parent 属性!'); }, set() {}, });
在调用父页面的 iframeDoc.write(code) 之前,我们需要先把用户输入的自定义代码 code 进行一次 replace,把当中的所有 parent.document 改成 window.disableParent。当用户调用 parent.document 相关 API 时,实际在 iframe 运行的是 window.disableParent,届时将会直接报错无法调用 window.parent 属性!,有效避免了套娃的安全隐患。
iframeDoc.write(code)
code
replace
parent.document
window.disableParent
无法调用 window.parent 属性!
我所搭建的这个 online-code-runner 是基于 monaco-editor 来实现编辑模块和 Console 面板模块的,接下来会简单讲述它们分别都是怎么实现的。
对于编辑模块来说,就是一个简单的 monaco-editor,只需要简单地设置它的样式就可以了:
monaco.editor.create(document.querySelector('#editor'), { { language: 'javascript', tabSize: 2, autoIndent: true, theme: 'github', automaticLayout: true, wordWrap: 'wordWrapColumn', wordWrapColumn: 120, lineHeight: 28, fontSize: 16, minimap: { size: 'fill', }, }, });
点击”执行代码“的按钮后,可以通过 editor.getValue() 把编辑模块中的内容读取出来,然后交给 iframe 去运行。
editor.getValue()
对于 Console 面板来说,它是另一个只读的 monaco-editor,主要有2个问题会有一点点费劲。其一是如何让新添加的内容挨个插入进去;其二是如何根据不同的 console 类型产生不用的背景色。
问题一的解法很简单,只需要定义一个字符串类型变量 infos,每当监听到来自 iframe 的 postMessage() 时,就往 infos 添加当中的信息,最后调用 editor.setValue() 即可。
infos
postMessage()
editor.setValue()
问题二的解法,我们已经在 iframe 中劫持 console 的逻辑,在 postMessage 的时候同时告诉父页面 consle[type] 到底是 log 还是 warn 还是其他,因此父页面可以根据这里的 console[type] 来知道具体的类型。
consle[type]
log
warn
console[type]
接下来我们可以调用 editor.deltaDecorations 方法来设置某行某列的背景色:
editor.deltaDecorations
const deltaDecorations = [] // 每当有新的 consle 消息推送过来时,都往 deltaDecorations 里插入一条信息,后面会用到 // 这里的 startLine 和 endLine 代表着这条新的消息的起始行号和结束行号,需要自行记录 // `${info.type}Decoration` 为不同 `console[type]` 的背景色对应的 className,对应着具体的 CSS deltaDecorations.push({ range: new monaco.Range(startLine, 1, endLine, 1), options: { isWholeLine: true, className: `${info.type}Decoration` }, });
然后我们可以定义不同 consle[type] 对应的背景色 CSS:
.warnDecoration { background: #ffd900; width: 100% !important; } .errorDecoration { background: #ff3300; width: 100% !important; }
具体代码可以看这里:https://github.com/jrainlau/online-code-runner/blob/main/src/components/Console.vue#L62-L71
本文通过分析 Codepen 的实现方式,使用 iframe 的方式配合 monaco-editor 自行开发了一套专用于执行 JavaScript 代码的在线代码预览系统。除了可以作为代码预览、展示的作用外,对于一些管理系统而言,往往需要人为编写一些后置脚本来处理系统中的数据,正好可以利用本文的方式去搭建一套代码预览系统,实时又安全地预览后置脚本,用处非常大。
一直觉得 Codepen 的在线代码预览系统很神奇,能够所见即所得地实时展示代码的运行效果,无论是代码演示,还是测试功能,都是非常方便快捷的存在。刚好最近手头有业务需要用到类似 Codepen 的能力,经过一番调研之后开发了一个具有基本的在线运行代码能力的 demo 出来。
一、原理
我们知道,浏览器是通过自带的引擎来处理 html,css 和 js 资源的,处理过程在页面载入的时候就已经开始。如果我们想要动态地运行这些资源,对于 html 和 css 我们可以用 DOM 操作的方式,对于 js 我们可以用
eval
或者new Function()
。但是这些操作都偏复杂且不安全(eval
和new Function()
很容易出事),那么有没有什么办法可以既优雅方便,又能安全地动态运行呢?我们看看大名鼎鼎的 Codepen 是怎么做的。我在 Codepen 里面简单地写了一个按钮,绑定了样式和点击事件,可以看到白色区域已经展示出我们想要的结果。打开控制台自吸观察后可以发现,整个白色区域是一个
iframe
,当中的 html 内容就是我们刚刚所编辑的代码。不难想象,它的运行原理有点类似于
document.write
,把内容直接写入到某一个 html 文件中,然后把它以 iframe 的方式内嵌到其他网页当中,实现代码预览的逻辑。那么使用 iframe 有什么好处呢?iframe 可以独立成为一个和宿主隔离的沙箱环境,在当中运行的代码在大部分情况下不会影响宿主,能有效地保证安全。配合 HTML5 新增的sandbox
属性,可以给 iframe 定义更为精细的权限,比如是否允许它运行脚本,是否允许它弹窗等等。二、实现方式
要实现类似 Codepen 的效果,最重要的一步就是如何把代码注入到 iframe 当中。由于我们需要使用到操控 iframe 的相关 API,浏览器出于安全的考虑,我们只能使用同域的 iframe 链接,否则将会报跨域的错误。
首先准备好一个
index.html
和iframe.html
,使用一个静态资源服务器把它们跑起来,假设均跑在localhost:8080
。然后我们在index.html
里面插入一个 iframe,其链接就是localhost:8080/iframe.html
,代码如下:接下来我们可以使用
iframe.contentDocument
来获取 iframe 的内容,然后操作它:运行完毕后,我们可以在
localhost:8080/index.html
里面看到和前文 Codepen 所展示的一样的效果:后续我们只需要找个输入框,把所写的代码保存成变量,然后调用
iframeDoc.write()
就可以动态地把代码写入到 iframe 并实时运行了。三、控制台输出及安全
观察 Codepen 的页面,可以看到有一个 Console 的面板,它可以把 iframe 当中的
console
信息直接输出。这是怎么实现的呢?答案很简单,我们可以在 iframe 页面中劫持console
等 API,在保留原有的控制台输出的功能的前提下,把相关的信息通过postMessage
的方式把它们输出给父页面,父页面监听到 message 以后把信息整理后输出到页面上,实现 Console 面板。在
iframe.html
中,我们在<body></body>
以外写入一段 js 代码(因为父页面调用iframeDoc.write()
会覆盖<body></body>
内的全部内容):此外我们会给 iframe 设置
sandbox
属性来限制其部分权限,但是这里有一个套娃的隐患,就是如果在 iframe 里面执行window.parent.document
相关 API 的话,可以让 iframe 去改写父页面的内容,甚至改写sandbox
属性,这肯定是不安全的,因此我们需要在 iframe 中把这相关 API 给屏蔽掉:在调用父页面的
iframeDoc.write(code)
之前,我们需要先把用户输入的自定义代码code
进行一次replace
,把当中的所有parent.document
改成window.disableParent
。当用户调用parent.document
相关 API 时,实际在 iframe 运行的是window.disableParent
,届时将会直接报错无法调用 window.parent 属性!
,有效避免了套娃的安全隐患。四、使用 monaco-editor 实现编辑模块和 Console 面板模块
我所搭建的这个 online-code-runner 是基于 monaco-editor 来实现编辑模块和 Console 面板模块的,接下来会简单讲述它们分别都是怎么实现的。
对于编辑模块来说,就是一个简单的 monaco-editor,只需要简单地设置它的样式就可以了:
点击”执行代码“的按钮后,可以通过
editor.getValue()
把编辑模块中的内容读取出来,然后交给 iframe 去运行。对于 Console 面板来说,它是另一个只读的 monaco-editor,主要有2个问题会有一点点费劲。其一是如何让新添加的内容挨个插入进去;其二是如何根据不同的
console
类型产生不用的背景色。问题一的解法很简单,只需要定义一个字符串类型变量
infos
,每当监听到来自 iframe 的postMessage()
时,就往infos
添加当中的信息,最后调用editor.setValue()
即可。问题二的解法,我们已经在 iframe 中劫持
console
的逻辑,在postMessage
的时候同时告诉父页面consle[type]
到底是log
还是warn
还是其他,因此父页面可以根据这里的console[type]
来知道具体的类型。接下来我们可以调用
editor.deltaDecorations
方法来设置某行某列的背景色:然后我们可以定义不同
consle[type]
对应的背景色 CSS:具体代码可以看这里:https://github.com/jrainlau/online-code-runner/blob/main/src/components/Console.vue#L62-L71
五、小结
本文通过分析 Codepen 的实现方式,使用 iframe 的方式配合 monaco-editor 自行开发了一套专用于执行 JavaScript 代码的在线代码预览系统。除了可以作为代码预览、展示的作用外,对于一些管理系统而言,往往需要人为编写一些后置脚本来处理系统中的数据,正好可以利用本文的方式去搭建一套代码预览系统,实时又安全地预览后置脚本,用处非常大。